// Theme doctor: every text token keeps 7:1 contrast (WCAG AAA) on every surface it is used on, in light and dark; // the dark theme is the same whether it comes from the system or from Settings. import { describe, expect, test } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; const css = readFileSync(join(import.meta.dir, '..', 'src', 'styles.css'), 'utf8'); function block(selector: string): Record { const at = css.indexOf(selector); if (at < 0) throw new Error(`no ${selector} block`); const open = css.indexOf('{', at), close = css.indexOf('}', open); const out: Record = {}; for (const m of css.slice(open + 1, close).matchAll(/--([\w-]+):\s*(#[0-9a-fA-F]{6})\b/g)) out[m[1]!] = m[2]!.toLowerCase(); return out; } const lum = (hex: string): number => { const c = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16) / 255).map(v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); return 0.2126 * c[0]! + 0.7152 * c[1]! + 0.0722 * c[2]!; }; export const contrast = (a: string, b: string): number => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x! + 0.05) / (y! + 0.05); }; /** [text token, background token] pairs the stylesheet uses for text. */ const PAIRS: [string, string][] = [ ...['ink', 'ink-2', 'muted'].flatMap(f => ['bg', 'bg-sunken', 'surface', 'surface-2', 'hover'].map(b => [f, b] as [string, string])), ['ink', 'accent-soft'], ['accent', 'surface'], ['accent', 'bg'], ['accent', 'accent-soft'], ['accent-ink', 'accent'], ['ok-ink', 'ok-bg'], ['warn-ink', 'warn-bg'], ['bad-ink', 'bad-bg'], ['info-ink', 'info-bg'], ['neutral-ink', 'neutral-bg'], ['code-ink', 'code-bg'], ]; describe('theme doctor', () => { const light = block(':root {'); const dark = block(':root[data-theme="dark"] {'); for (const [name, theme] of [['light', light], ['dark', dark]] as const) { test(`${name}: text tokens reach 7:1`, () => { const low: string[] = []; for (const [f, b] of PAIRS) { const fg = theme[f] ?? light[f], bg = theme[b] ?? light[b]; expect(fg, `--${f}`).toBeDefined(); expect(bg, `--${b}`).toBeDefined(); const c = contrast(fg!, bg!); if (c < 7) low.push(`--${f} on --${b}: ${c.toFixed(2)}:1`); } expect(low).toEqual([]); }); } test('system dark and chosen dark are the same tokens', () => { const at = css.indexOf(':root:not([data-theme="light"]) {'); const open = css.indexOf('{', at), close = css.indexOf('}', open); const sys: Record = {}; for (const m of css.slice(open + 1, close).matchAll(/--([\w-]+):\s*([^;]+);/g)) sys[m[1]!] = m[2]!.trim(); const chosen: Record = {}; const a2 = css.indexOf(':root[data-theme="dark"] {'), o2 = css.indexOf('{', a2), c2 = css.indexOf('}', o2); for (const m of css.slice(o2 + 1, c2).matchAll(/--([\w-]+):\s*([^;]+);/g)) chosen[m[1]!] = m[2]!.trim(); expect(Object.keys(sys).length).toBeGreaterThan(30); expect(sys).toEqual(chosen); }); test('reduced motion turns transitions and animations off', () => { expect(css).toMatch(/prefers-reduced-motion: reduce\) \{ :root \{ --speed: 0s; \} \}/); expect(css).toMatch(/:root\[data-motion="reduce"\] \{ --speed: 0s; \}/); for (const anim of ['.spin', '.sk', '.toast .bar.indet > span']) expect(css).toContain(`:root[data-motion="reduce"] ${anim}`); // no transition longer than 200 ms for (const m of css.matchAll(/transition:[^;]*?(\d*\.?\d+)s/g)) expect(Number(m[1])).toBeLessThanOrEqual(0.2); }); });