The ring of `[data-onboarding-highlight="true"]` is an author outline: it wins over the browser's focus ring, so a keyboard user who reached the highlighted control saw nothing change (WCAG 2.4.7, failure F78). Measured by the review in headless Chromium, WebView2's engine: the highlighted button kept `solid 3px` focused or not. `outline: revert` under `:focus-visible` gives the browser's ring back, kept as is in the built stylesheet; the contrast test reads the rule from the stylesheet. Review of #398 (PR #404). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
171 lines
6.5 KiB
TypeScript
171 lines
6.5 KiB
TypeScript
// The getting-started highlight in styles.css (#398): the ring a step card
|
|
// draws around the control to use. Read from the stylesheet itself, so a
|
|
// change to a theme color or to the rule is checked where it is made:
|
|
// - WCAG 1.4.11: the ring reaches 3:1 against the page background and the
|
|
// cards, in the light theme (`:root`) and the dark one (`.dark`);
|
|
// - WCAG 2.2.2: the pulse stops by itself after three cycles, within 5 s;
|
|
// - the pulse does not run under `prefers-reduced-motion: reduce`;
|
|
// - WCAG 2.4.7: the browser's focus ring replaces the ring on keyboard focus.
|
|
import { describe, it, expect } from "vitest";
|
|
import { readFileSync } from "fs";
|
|
import { resolve } from "path";
|
|
|
|
const CSS = readFileSync(resolve(import.meta.dirname, "styles.css"), "utf-8");
|
|
|
|
const HIGHLIGHT = '[data-onboarding-highlight="true"]';
|
|
|
|
interface Block {
|
|
selector: string;
|
|
body: string;
|
|
}
|
|
|
|
/** The top-level blocks of a stylesheet, comments removed; nested braces stay in the body. */
|
|
function blocks(css: string): Block[] {
|
|
const text = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
const found: Block[] = [];
|
|
let depth = 0;
|
|
let start = 0;
|
|
let open = -1;
|
|
for (let i = 0; i < text.length; i++) {
|
|
const c = text[i];
|
|
if (c === "{") {
|
|
if (depth === 0) open = i;
|
|
depth++;
|
|
} else if (c === "}") {
|
|
depth--;
|
|
if (depth === 0) {
|
|
found.push({ selector: text.slice(start, open).trim(), body: text.slice(open + 1, i) });
|
|
start = i + 1;
|
|
}
|
|
} else if (c === ";" && depth === 0) {
|
|
start = i + 1; // a statement such as `@import "tailwindcss";`
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
/** The declarations of a block body, `name -> value`, the last one winning. */
|
|
function declarations(body: string): Map<string, string> {
|
|
const result = new Map<string, string>();
|
|
for (const [, name, value] of body.matchAll(/([\w-]+)\s*:\s*([^;{}]+);/g)) {
|
|
result.set(name, value.trim());
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const TOP = blocks(CSS);
|
|
|
|
const merged = (selector: string, into = new Map<string, string>()) => {
|
|
for (const block of TOP.filter((b) => b.selector === selector)) {
|
|
for (const [name, value] of declarations(block.body)) into.set(name, value);
|
|
}
|
|
return into;
|
|
};
|
|
|
|
const THEME_TOKENS = merged("@theme");
|
|
const LIGHT = merged(":root");
|
|
/** `.dark` overrides `:root`, which still holds what it does not redefine. */
|
|
const DARK = merged(".dark", new Map(LIGHT));
|
|
|
|
/** A color variable resolved through `var()` down to its hex value. */
|
|
function color(name: string, theme: Map<string, string>): string {
|
|
let value = theme.get(name) ?? THEME_TOKENS.get(name);
|
|
for (let hops = 0; value !== undefined && hops < 10; hops++) {
|
|
const ref = value.match(/^var\((--[\w-]+)\)$/);
|
|
if (!ref) break;
|
|
value = theme.get(ref[1]) ?? THEME_TOKENS.get(ref[1]);
|
|
}
|
|
if (value === undefined || !/^#[0-9a-f]{6}$/i.test(value)) {
|
|
throw new Error(`${name} does not resolve to a #rrggbb color: ${String(value)}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/** WCAG relative luminance of a #rrggbb color. */
|
|
function luminance(hex: string): number {
|
|
const [r, g, b] = [1, 3, 5].map((i) => {
|
|
const c = parseInt(hex.slice(i, i + 2), 16) / 255;
|
|
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
});
|
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
}
|
|
|
|
function contrast(a: string, b: string): number {
|
|
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
return (hi + 0.05) / (lo + 0.05);
|
|
}
|
|
|
|
const highlightRule = () => {
|
|
const rule = TOP.find((b) => b.selector === HIGHLIGHT);
|
|
expect(rule, "the highlight rule").toBeDefined();
|
|
return declarations(rule!.body);
|
|
};
|
|
|
|
/** The variable the ring is drawn with: `--accent`, or `--onboarding-ring` if the accent failed. */
|
|
function ringVariable(): string {
|
|
const outline = highlightRule().get("outline") ?? "";
|
|
const variable = outline.match(/var\((--[\w-]+)\)/);
|
|
expect(variable, `outline: ${outline}`).not.toBeNull();
|
|
return variable![1];
|
|
}
|
|
|
|
describe("styles.css — the contrast helpers", () => {
|
|
it("compute the WCAG ratios of known pairs", () => {
|
|
expect(contrast("#000000", "#ffffff")).toBeCloseTo(21, 5);
|
|
expect(contrast("#ffffff", "#ffffff")).toBeCloseTo(1, 5);
|
|
// #767676 is the classic 4.54:1 grey on white.
|
|
expect(contrast("#767676", "#ffffff")).toBeCloseTo(4.54, 2);
|
|
});
|
|
|
|
it("read both themes from the stylesheet", () => {
|
|
expect(color("--background", LIGHT)).toBe("#FFF8F0");
|
|
expect(color("--card", LIGHT)).toBe("#FFFFFF");
|
|
expect(color("--background", DARK)).toBe("#111827");
|
|
expect(color("--card", DARK)).toBe("#1f2937");
|
|
});
|
|
});
|
|
|
|
describe("styles.css — the getting-started highlight (#398)", () => {
|
|
it("draws a 3px outline set off by 2px, in the accent color", () => {
|
|
const rule = highlightRule();
|
|
expect(rule.get("outline")).toBe("3px solid var(--accent)");
|
|
expect(rule.get("outline-offset")).toBe("2px");
|
|
expect(ringVariable()).toBe("--accent");
|
|
});
|
|
|
|
it.each([
|
|
["light", LIGHT],
|
|
["dark", DARK],
|
|
] as const)("reaches 3:1 against the background and the cards, %s theme", (_name, theme) => {
|
|
const ring = color(ringVariable(), theme);
|
|
for (const surface of ["--background", "--card"]) {
|
|
expect(contrast(ring, color(surface, theme)), surface).toBeGreaterThanOrEqual(3);
|
|
}
|
|
});
|
|
|
|
it("pulses three times at most, within the 5 s of WCAG 2.2.2", () => {
|
|
const rule = highlightRule();
|
|
expect(rule.get("animation-name")).toBe("onboarding-pulse");
|
|
expect(rule.get("animation-iteration-count")).toBe("3");
|
|
expect(rule.has("animation")).toBe(false);
|
|
const duration = rule.get("animation-duration") ?? "";
|
|
expect(duration).toMatch(/^\d+(\.\d+)?s$/);
|
|
expect(parseFloat(duration) * 3).toBeLessThanOrEqual(5);
|
|
const keyframes = TOP.find((b) => b.selector === "@keyframes onboarding-pulse");
|
|
expect(keyframes?.body).toContain("box-shadow");
|
|
});
|
|
|
|
it("does not pulse when the system asks for less motion", () => {
|
|
const media = TOP.find((b) => b.selector === "@media (prefers-reduced-motion: reduce)");
|
|
expect(media, "the reduced-motion block").toBeDefined();
|
|
const inner = blocks(media!.body).find((b) => b.selector === HIGHLIGHT);
|
|
expect(inner, "the highlight rule under reduced motion").toBeDefined();
|
|
expect(declarations(inner!.body).get("animation")).toBe("none");
|
|
});
|
|
|
|
it("gives the browser's focus ring back to the highlighted control (WCAG 2.4.7)", () => {
|
|
const rule = TOP.find((b) => b.selector === `${HIGHLIGHT}:focus-visible`);
|
|
expect(rule, "the focus-visible rule").toBeDefined();
|
|
expect(declarations(rule!.body).get("outline")).toBe("revert");
|
|
});
|
|
});
|