import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); /** Declarations of the block that starts at `opener`, up to its closing brace. */ function blockTokens(opener: string): Map { const start = globalStyles.indexOf(opener); assert.notEqual(start, -1, `missing block: ${opener}`); const end = globalStyles.indexOf("\n}", start); assert.notEqual(end, -1, `unterminated block: ${opener}`); const body = globalStyles.slice(start, end); return new Map([...body.matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)] .map((hit) => [hit[1], hit[2].trim().replace(/\s+/g, " ")])); } const light = blockTokens(":root {\n color-scheme: light;"); const preferred = blockTokens(':root:not([data-theme="light"]) {'); const pinned = blockTokens(':root[data-theme="dark"] {'); // A token whose value is the same in both themes on purpose. Everything else // must be redefined, or dark mode ships a light-theme value on a dark ground. const themeNeutral = new Set([ "font-display", "font-body", "font-mono", "ease-out", "composer-reserve", ]); test("the OS-preference and pinned dark blocks never drift apart", () => { // Plain CSS cannot share a declaration list, so the dark palette is written // twice. This is the guard that keeps the copies honest. assert.deepEqual([...pinned.keys()].sort(), [...preferred.keys()].sort()); for (const [token, value] of pinned) { assert.equal(preferred.get(token), value, `--${token} differs between the two dark blocks`); } }); test("every themeable token has a dark value", () => { const themeable = [...light.keys()].filter((token) => ( !themeNeutral.has(token) && (token.startsWith("color-") || token.startsWith("shadow-") || token.startsWith("report-") || token === "ring-hairline" || token === "sheen") )); const missing = themeable.filter((token) => !pinned.has(token)).sort(); assert.deepEqual(missing, [], `no dark value for: ${missing.join(", ")}`); assert.ok(themeable.length >= 30, `only ${themeable.length} themeable tokens found`); }); test("both themes declare color-scheme so form controls and scrollbars follow", () => { assert.equal(light.get("color-scheme") ?? "light", "light"); assert.match(globalStyles, /:root:not\(\[data-theme="light"\]\) \{\n\s*color-scheme: dark;/); assert.match(globalStyles, /:root\[data-theme="dark"\] \{\n\s*color-scheme: dark;/); }); test("dark ink reads lighter than dark canvas, and the action stays legible", () => { const luminance = (hex: string) => { const channel = (value: number) => { const c = value / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); }; const contrast = (a: string, b: string) => { const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); }; const canvas = pinned.get("color-canvas")!; for (const token of ["color-ink", "color-action", "color-danger", "color-success", "color-warning"]) { const value = pinned.get(token)!; assert.match(value, /^#[0-9a-f]{6}$/i, `--${token} must be a hex so contrast is checkable`); assert.ok( contrast(value, canvas) >= 4.5, `--${token} (${value}) on --color-canvas (${canvas}) is ${contrast(value, canvas).toFixed(2)}:1, below AA`, ); } // Elevation reads through lightness on dark: floor is darkest, raised steps up. const steps = ["color-canvas-soft", "color-canvas", "color-canvas-muted", "color-canvas-strong"] .map((token) => luminance(pinned.get(token)!)); for (let i = 1; i < steps.length; i += 1) { assert.ok(steps[i] > steps[i - 1], "dark surfaces must get lighter as they rise"); } });