storebound/apps/desktop/tests/theme.test.ts

66 lines
3.5 KiB
TypeScript

// 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<string, string> {
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<string, string> = {};
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<string, string> = {};
for (const m of css.slice(open + 1, close).matchAll(/--([\w-]+):\s*([^;]+);/g)) sys[m[1]!] = m[2]!.trim();
const chosen: Record<string, string> = {};
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);
});
});