feat(design): the scheme set, the colour contract and the token utilities (E28 S3a)

Four schemes (Midnight; Forest cyan, Slate fuchsia, Matrix lime), YouTube
and Starship retired with no data migration: normalizeTheme maps a stored
retired scheme to Midnight. A vitest contract measures every scheme x mode
(text 4.5:1, control edge 3:1, status text, the accent's 30-degree hue gap);
it caught light-mode danger at 4.43:1, now red-700. The state layers, the
strong edge, the touch target and the fades are index.css tokens and
utilities the lab now reads. Backdrops recoloured to the new accents, with
a grey overlay grain that actually breaks the banding (longest flat run
15-26px -> 4-8px). The glass tuner is retired.
This commit is contained in:
2026-09-24 04:33:13 +02:00
parent 9c4a999e54
commit ad136c3fe8
26 changed files with 326 additions and 851 deletions
+76
View File
@@ -0,0 +1,76 @@
import { existsSync, 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}"]`);
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\)\)/);
});
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),
};
const failing = Object.entries(measured).filter(([k, v]) => v < (k === "edge" ? 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([]);
});
});