import { existsSync, readdirSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { hexContrast, hueGap, mixHex } from "./colorMath"; import { SCHEMES } from "./theme"; // docs/DESIGN.md's colour rules, held in code instead of prose. Every scheme × mode in index.css: // - body text and accent text ≥ 4.5:1 on the page, muted text ≥ 6:1 there (the headroom glass costs), // and the accent's own text ≥ 4.5:1 on an accent fill [WCAG 1.4.3]; // - a control's edge (--edge-strong, the fg mixed 55% into the bg) ≥ 3:1 on the page [WCAG 1.4.11]; // - the accent ≥ 30° of OKLCH hue away from danger, warn and success, so a primary button never // reads as a status (decision B2: the old YouTube, Slate, Forest and Matrix accents all did). // The status colours are text too, so they are held to 4.5:1 on every scheme's page as well. // A new scheme, or a retuned colour, has to pass this before it ships. const css = readFileSync(new URL("../index.css", import.meta.url), "utf8"); type Mode = "dark" | "light"; const MODES: Mode[] = ["dark", "light"]; /** The `--name: #hex;` declarations of every block whose selector is exactly `selector`, merged in * source order (the same selector also carries non-colour blocks, e.g. `color-scheme`). */ function block(selector: string): Record { const out: Record = {}; for (let at = css.indexOf(`${selector} {`); at >= 0; at = css.indexOf(`${selector} {`, at + 1)) { const body = css.slice(at, css.indexOf("}", at)); for (const m of body.matchAll(/--([\w-]+):\s*(#[0-9a-fA-F]{6})\s*;/g)) out[m[1]!] = m[2]!; } expect(Object.keys(out), `no colour tokens for ${selector} in index.css`).not.toHaveLength(0); return out; } const status = (mode: Mode) => block(`html[data-theme="${mode}"]`); const scheme = (id: string, mode: Mode) => block(`html[data-scheme="${id}"][data-theme="${mode}"]`); // Theme-independent colours: what sits on media (a thumbnail, a poster, the player) looks the same in // dark and light, because the picture under it does. const media = () => block(":root"); // F16: a rating's gold and a broadcast's red are their own tokens, not palette literals and not a status // (a score is not a warning, "live" is not an error). describe("the media colours", () => { it("the live badge's text holds 4.5:1 on its fill", () => { expect(hexContrast(media()["live-fg"]!, media().live!)).toBeGreaterThanOrEqual(4.5); }); // A poster card's score sits on a black 85–95% scrim over any picture: the worst case is 85% black // over white, #262626. it("a score on a poster's scrim holds 4.5:1", () => { expect(hexContrast(media()["rating-media"]!, "#262626")).toBeGreaterThanOrEqual(4.5); }); it("the player chrome's white text holds 4.5:1", () => { expect(hexContrast("#ffffff", media()["media-chrome"]!)).toBeGreaterThanOrEqual(4.5); }); // The amber that marks a place on the always-dark chrome and over a video — a playhead, a focused // end's ring, the crop frame's ring — is the same in both themes: a light theme's warn is a dark // amber, which fell under 3:1 on the clip strip (white 10% over the chrome). [WCAG 1.4.11] it("a mark on the player chrome holds 3:1 on the clip strip", () => { const strip = mixHex("#ffffff", media()["media-chrome"]!, 0.1); expect(hexContrast(media()["media-mark"]!, strip)).toBeGreaterThanOrEqual(3); }); // A form on that chrome (the clip panel, `media-scope`) keeps neither the theme's danger nor its // accent for what must be seen: a light theme's are dark (#b91c1c 2.8:1, Slate's accent 2.8:1 on // the chrome). Its error edge and text take the media red, and every focus ring in it — the base // ring and a field's focus edge both read --focus — the media amber. [WCAG 1.4.11, 1.4.3] it("a form on the player chrome says an error and a focus in colours that hold there", () => { expect(hexContrast(media()["media-danger"]!, media()["media-chrome"]!)).toBeGreaterThanOrEqual( 4.5, ); const at = css.indexOf("@utility media-scope {"); const scope = css.slice(at, css.indexOf("\n}", at)); expect(scope).toMatch(/--danger:\s*var\(--media-danger\);/); expect(scope).toMatch(/--focus:\s*var\(--media-mark\);/); expect(css).toMatch(/outline: 2px solid var\(--focus, var\(--accent\)\);/); // In the `inline` theme block, so `focus-*` utilities read --focus where they are used — a plain // @theme resolves it once on :root, and a scope's own --focus never reaches them. const at2 = css.indexOf("@theme inline {"); expect(css.slice(at2, css.indexOf("\n}", at2))).toMatch( /--color-focus: var\(--focus, var\(--accent\)\);/, ); // A field's focus edge is that colour too, not the accent's. const field = readFileSync(new URL("../components/ui/fieldStyles.ts", import.meta.url), "utf8"); expect(field).toMatch(/\bfocus-within:border-focus\b/); expect(field).toMatch(/\bfocus-within:ring-focus\b/); expect(field).not.toMatch(/\bfocus-within:(border|ring)-accent\b/); }); it("every surface painted the player chrome names its marks: the focus ring and what is chosen", () => { const block = (name: string) => { const at = css.indexOf(name); return css.slice(at, css.indexOf("\n}", at)); }; expect(block("@utility media-marks {")).toMatch(/--focus:\s*var\(--media-mark\);/); expect(block("@utility media-marks {")).toMatch(/--chosen:\s*var\(--media-mark\);/); expect(block("@utility media-scope {")).toMatch(/--chosen:\s*var\(--media-mark\);/); expect(block("@theme inline {")).toMatch(/--color-chosen: var\(--chosen, var\(--accent\)\);/); // The tick of a chosen row reads the chosen colour, so a scope can name its own. const check = readFileSync(new URL("../components/ui/MenuCheck.tsx", import.meta.url), "utf8"); expect(check).toMatch(/\btext-chosen\b/); // So does an icon button that is on or has its menu open (the player's track menu button). const buttons = readFileSync( new URL("../components/ui/buttonStyles.ts", import.meta.url), "utf8", ); expect(buttons).toMatch(/aria-pressed:text-chosen/); expect(buttons).toMatch(/aria-expanded:text-chosen/); // A native checkbox's and slider's fill is the chosen colour too (the player's settings), and so // is the skip button's countdown: a fill that must be seen on the chrome. const native = readFileSync( new URL("../components/ui/NativeControls.tsx", import.meta.url), "utf8", ); expect(native).not.toMatch(/\baccent-accent\b/); expect(native.match(/\baccent-chosen\b/g)).toHaveLength(2); const plex = readFileSync(new URL("../modules/plex/PlexPlayer.tsx", import.meta.url), "utf8"); // No accent fill at all on the Plex player: its progress bar's played part is white and its head // the media amber — a marked place, like the intro / credits markers and the clip's playhead. expect(plex).not.toMatch(/\bbg-accent\b/); // (A pressed FILLED button keeps the accent fill under its own `text-accent-fg`.) expect(buttons).not.toMatch(/aria-(pressed|expanded):text-accent(?!-)/); // The player's own components are chrome throughout: nothing chosen there is the accent's text // (a light theme's accent is dark on it — Slate 2.84:1). for (const f of ["PlexPlayer.tsx", "PlexPlayerSettings.tsx", "UpNextCard.tsx"]) { const src = readFileSync(new URL(`../modules/plex/${f}`, import.meta.url), "utf8"); expect(src, f).not.toMatch(/\btext-accent\b/); } // A class string that paints the chrome carries the media marks (media-scope re-points them // too): the theme's accent ring fell under 3:1 on it in a light scheme. const root = new URL("../", import.meta.url); const offenders: string[] = []; for (const f of readdirSync(root, { recursive: true, encoding: "utf8" })) { if (!/\.tsx?$/.test(f) || /\.test\.tsx?$/.test(f)) continue; const src = readFileSync(new URL(f.replace(/\\/g, "/"), root), "utf8"); for (const m of src.matchAll(/["'`]([^"'`]*\bbg-media-chrome\b[^"'`]*)["'`]/g)) if (!/\bmedia-(scope|marks)\b/.test(m[1]!)) offenders.push(`${f}: ${m[1]}`); } expect(offenders).toEqual([]); }); }); it("styles exactly the schemes the app offers", () => { const inCss = new Set( [...css.matchAll(/html\[data-scheme="(\w+)"\]\[data-theme="dark"\]/g)].map((m) => m[1]), ); expect([...inCss].sort()).toEqual(SCHEMES.map((s) => s.id).sort()); // The edge token is defined the way this test measures it. expect(css).toMatch(/--edge-strong:\s*color-mix\(in srgb, var\(--fg\) 55%, var\(--bg\)\)/); }); // B3: the state layers answer only a control that can act. A disabled or busy button that lights up // advertises a click that does nothing, and a pressed toggle that greys under the pointer hides its // own state; the primitives (Button, IconButton) rely on the utilities to hold this for them. it("the state layers stand down on a disabled, busy or pressed control", () => { const guard = /:not\(\s*:disabled,\s*\[aria-disabled="true"\]/; for (const name of ["state-layer", "state-filled", "state-danger"]) { const at = css.indexOf(`@utility ${name} {`); const body = css.slice(at, css.indexOf("\n}", at)); const states = [...body.matchAll(/&:(hover|active)([^{]*)\{/g)]; expect(states.length, name).toBe(2); for (const [, state, sel] of states) expect(sel, `${name} :${state}`).toMatch(guard); } // A pressed toggle and a lit mode cycle keep their look under the pointer: no layer over them. const layer = css.slice( css.indexOf("@utility state-layer {"), css.indexOf("@utility state-filled {"), ); const states = [...layer.matchAll(/&:(hover|active):not\(([^)]*)\)/g)]; expect(states.map(([, state]) => state)).toEqual(["hover", "active"]); for (const [, state, exempt] of states) { expect(exempt, state).toContain('[aria-pressed="true"]'); expect(exempt, state).toContain("[data-lit]"); } }); // …and their colours outrank a variant's hover colour (`act:hover:text-fg`, the overlay's // `act:hover:bg-white/15`), the more specific selector: only an important one does. it("a pressed or lit control's colours are marked important", () => { const styles = readFileSync(new URL("../components/ui/buttonStyles.ts", import.meta.url), "utf8"); for (const name of ["PRESSED", "LIT", "ICON_PRESSED", "ICON_LIT"]) { const value = new RegExp(`const ${name} =\\s*"([^"]+)"`).exec(styles)?.[1]; expect(value, name).toBeTruthy(); for (const cls of value!.split(/\s+/)) expect(cls, name).toMatch(/!$/); } }); // The 4.5:1 above only holds for text that USES --accent-fg: a fixed white or black on a solid accent // fill passes on one scheme and fails on the next (white on the old Matrix lime was 1.5:1). it("text on a solid accent fill uses --accent-fg", () => { const root = new URL("../", import.meta.url); const offenders: string[] = []; for (const f of readdirSync(root, { recursive: true, encoding: "utf8" })) { if (!f.endsWith(".tsx")) continue; readFileSync(new URL(f.replace(/\\/g, "/"), root), "utf8") .split("\n") .forEach((line, i) => { if (/\bbg-accent(?![\w/-])/.test(line) && /\b(text|bg)-(white|black)\b/.test(line)) offenders.push(`${f}:${i + 1}`); }); } expect(offenders).toEqual([]); }); // The tints measured below are only what renders while the tonal fills USE them: a translucent // `bg-fg/8` there passes every token check and still lets the glass under a toolbar or a rail through. it("a tonal fill under text is an opaque tint", () => { const sites = [ "components/ui/Badge.tsx", "components/ui/ItemCount.tsx", "modules/files/FilesRail.tsx", ]; const offenders = sites.flatMap((f) => { const src = readFileSync(new URL(`../${f}`, import.meta.url), "utf8"); const bad = [...src.matchAll(/\bbg-(?:fg|accent|warn)\/\d+/g)].map((m) => `${f}: ${m[0]}`); return /\bbg-tint/.test(src) ? bad : [...bad, `${f}: no bg-tint`]; }); expect(offenders).toEqual([]); }); describe.each(SCHEMES.map((s) => s.id))("scheme %s", (id) => { it.each(MODES)("%s: backdrop rule and file", (mode) => { const file = `backdrops/${mode === "light" ? "light/" : ""}${id}.svg`; expect(css).toContain(`url("/${file}")`); expect(existsSync(new URL(`../../public/${file}`, import.meta.url)), file).toBe(true); }); it.each(MODES)("%s: text and control contrast", (mode) => { const t = scheme(id, mode); const s = status(mode); const bg = t.bg!; const measured = { fg: hexContrast(t.fg!, bg), muted: hexContrast(t.muted!, bg), accentText: hexContrast(t.accent!, bg), onAccent: hexContrast(t["accent-fg"]!, t.accent!), edge: hexContrast(mixHex(t.fg!, bg, 0.55), bg), danger: hexContrast(s.danger!, bg), warn: hexContrast(s.warn!, bg), success: hexContrast(s.success!, bg), // A score is text, on the page and on its own 15% chip (PlexInfo); the live glyph is not text. rating: hexContrast(s.rating!, bg), ratingOnChip: hexContrast(s.rating!, mixHex(s.rating!, bg, 0.15)), // A tonal fill under text (the `tint` tokens: a Badge, the item count, the current rail row) is // OPAQUE — its colour mixed into --surface, so the glass and what scrolls under it never show // through: the neutral one's muted text on the fg at 8%, a toned one's text on its own colour at // 6%, the current row's accent on the accent at 12%. mutedOnBadge: hexContrast(t.muted!, mixHex(t.fg!, t.surface!, 0.08)), accentOnBadge: hexContrast(t.accent!, mixHex(t.accent!, t.surface!, 0.06)), warnOnBadge: hexContrast(s.warn!, mixHex(s.warn!, t.surface!, 0.06)), accentOnCurrent: hexContrast(t.accent!, mixHex(t.accent!, t.surface!, 0.12)), live: hexContrast(media().live!, bg), }; const nonText = new Set(["edge", "live"]); // Muted text mostly sits on GLASS (a toolbar, a tab row, a panel): the scrim, the blur and the // backdrop under it cost up to ~1.5 of the ratio a bare --bg gives (pixel-measured over every // scheme × mode × backdrop), so on the bare page it keeps that headroom over 4.5. const onGlass = new Set(["muted"]); const need = (k: string) => (nonText.has(k) ? 3 : onGlass.has(k) ? 6 : 4.5); const failing = Object.entries(measured).filter(([k, v]) => v < need(k)); expect(failing.map(([k, v]) => `${k} ${v.toFixed(2)}:1`)).toEqual([]); }); it.each(MODES)("%s: the accent never looks like a status", (mode) => { const accent = scheme(id, mode).accent!; const s = status(mode); const close = (["danger", "warn", "success"] as const) .map((k) => [k, hueGap(accent, s[k]!)] as const) .filter(([, gap]) => gap < 30); expect(close.map(([k, gap]) => `${k} ${gap.toFixed(0)}°`)).toEqual([]); }); });