The watched toggle weighed its own click at click time against marks stamped when the server answered, so the late answer of its previous click overruled the newer one; it now holds its word while any of its writes is on the way, is stamped at the answer, and drops a refused write. The hosts return the write. In a live search the feed's own answered writes stand down over the rows, which carry the marks, so a newer mark from the related list reaches a card the feed touched earlier. A lit mode cycle got the hover layer, and a variant's hover colour outranked the pressed and lit colours: the state layer stands down on [data-lit] and those colours are important. The menus' Play from the beginning wears RotateCcw like the card's Restart, Reset wears Eraser and Redownload the retry arrow (DESIGN decision log). Tests pin the toggle's orders, the Watch later mark at its call sites, the live-search overrides, the hover exemptions and the menu glyphs.
267 lines
15 KiB
TypeScript
267 lines
15 KiB
TypeScript
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<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("../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([]);
|
||
});
|
||
});
|