// 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 { const result = new Map(); 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()) => { 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 { 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"); }); });