Simpl-Resultat/src/styles.contrast.test.ts
le king fu 74a6572475 fix(onboarding): give the focus ring back to the highlighted control
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>
2026-10-08 21:05:30 -04:00

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");
});
});