Files
siftlode/frontend/src/lib/theme.contract.test.ts
T
peter 663a7e8012 fix(player): the Plex player's fills hold on its chrome: an amber head on a white played part
A light theme's accent as a fill on the always-dark chrome was ~2.8:1 (Slate), under the 3:1 a
control needs. A native checkbox and slider tint with `accent-chosen` (the accent elsewhere, the
media amber under `media-marks`), and so does the skip button's countdown. The progress bar plays
white with an amber head: amber there marks a place, as the intro / credits markers do, and an amber
played part made a marker ahead of the head read as already played (owner's choice of three).
2026-10-10 22:56:09 +02:00

224 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, muted text and accent text ≥ 4.5:1 on the page, 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<string, string> {
const out: Record<string, string> = {};
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("../components/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(`../components/${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\(:disabled, \[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);
}
expect(css.slice(css.indexOf("@utility state-layer {"))).toMatch(
/&:hover:not\([^)]*\[aria-pressed="true"\][^)]*\)/,
);
});
// 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([]);
});
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 Badge (a role, a status, a count, F29): the neutral one's muted text on the fg at 8%, a toned
// one's text on its own colour at 6% (ui/Badge).
mutedOnBadge: hexContrast(t.muted!, mixHex(t.fg!, bg, 0.08)),
accentOnBadge: hexContrast(t.accent!, mixHex(t.accent!, bg, 0.06)),
warnOnBadge: hexContrast(s.warn!, mixHex(s.warn!, bg, 0.06)),
live: hexContrast(media().live!, bg),
};
const nonText = new Set(["edge", "live"]);
const failing = Object.entries(measured).filter(([k, v]) => v < (nonText.has(k) ? 3 : 4.5));
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([]);
});
});