66 lines
3.5 KiB
TypeScript
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);
|
|
});
|
|
});
|