- Collapse extractRootBlock + extractDarkBlock into a single extractBlock
helper; the two functions shared identical body logic (same regex, same
return) and differed only in the string they received — now the call
sites pass the appropriate slice inline.
- Remove the second test in base.test.ts ("covers all three form control
selectors in one rule"): the first test's regex already asserts all
three selectors appear together with font-size: 16px, so the individual
/input/, /textarea/, /select/ matches added no coverage.
128 lines
4.2 KiB
TypeScript
128 lines
4.2 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { readFileSync } from 'fs';
|
|
|
|
const css = readFileSync(new URL('../dist/tokens.css', import.meta.url), 'utf-8');
|
|
|
|
// ---- CSS parser helpers ----
|
|
|
|
function extractBlock(src: string): string {
|
|
const m = src.match(/:root\s*\{([^}]+)\}/);
|
|
return m?.[1] ?? '';
|
|
}
|
|
|
|
function parseProps(block: string): Map<string, string> {
|
|
const map = new Map<string, string>();
|
|
const re = /--([\w-]+)\s*:\s*([^;]+);/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(block)) !== null) {
|
|
map.set(`--${m[1]}`, m[2].trim());
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function resolveToken(value: string, props: Map<string, string>): string {
|
|
const varRe = /var\(--([\w-]+)\)/;
|
|
let v = value;
|
|
for (let i = 0; i < 10 && varRe.test(v); i++) {
|
|
v = v.replace(varRe, (_, name) => props.get(`--${name}`) ?? 'unresolved');
|
|
}
|
|
return v.trim();
|
|
}
|
|
|
|
// ---- WCAG contrast helpers ----
|
|
|
|
function expandHex(hex: string): string {
|
|
const h = hex.replace('#', '');
|
|
return h.length === 3
|
|
? `#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}`
|
|
: `#${h}`;
|
|
}
|
|
|
|
function luminance(hex: string): number {
|
|
const h = expandHex(hex).replace('#', '');
|
|
const r = parseInt(h.slice(0, 2), 16) / 255;
|
|
const g = parseInt(h.slice(2, 4), 16) / 255;
|
|
const b = parseInt(h.slice(4, 6), 16) / 255;
|
|
const lin = (c: number) =>
|
|
c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
}
|
|
|
|
function contrast(hex1: string, hex2: string): number {
|
|
const l1 = luminance(hex1);
|
|
const l2 = luminance(hex2);
|
|
const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
|
|
return (lighter + 0.05) / (darker + 0.05);
|
|
}
|
|
|
|
// ---- Fixtures ----
|
|
|
|
const rootProps = parseProps(extractBlock(css));
|
|
const darkProps = parseProps(extractBlock(css.split('@media (prefers-color-scheme: dark)')[1] ?? ''));
|
|
// Merged: dark overrides on top of light primitives so var() refs resolve correctly.
|
|
const mergedDark = new Map([...rootProps, ...darkProps]);
|
|
|
|
// Tokens that must be explicitly defined in both light and dark blocks.
|
|
const DARK_MODE_TOKENS = [
|
|
'--bg', '--bg-elevated', '--surface',
|
|
'--fg', '--fg-muted',
|
|
'--border-color', '--border-strong',
|
|
'--accent', '--accent-fg', '--accent-hover',
|
|
'--danger', '--danger-fg', '--danger-bg',
|
|
'--input-bg', '--input-fg', '--input-border', '--input-bg-focus',
|
|
];
|
|
|
|
// ---- Tests ----
|
|
|
|
describe('tokens.css — token presence', () => {
|
|
it('defines all dark-mode tokens in :root (light)', () => {
|
|
for (const token of DARK_MODE_TOKENS) {
|
|
expect(rootProps.has(token), `${token} missing from :root`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('defines all dark-mode tokens in @media (prefers-color-scheme: dark)', () => {
|
|
for (const token of DARK_MODE_TOKENS) {
|
|
expect(darkProps.has(token), `${token} missing from @media dark`).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('tokens.css — WCAG AA contrast (light)', () => {
|
|
const fg = resolveToken(rootProps.get('--fg')!, rootProps);
|
|
const fgMuted = resolveToken(rootProps.get('--fg-muted')!, rootProps);
|
|
const bg = resolveToken(rootProps.get('--bg')!, rootProps);
|
|
const borderStrong = resolveToken(rootProps.get('--border-strong')!, rootProps);
|
|
|
|
it('--fg on --bg >= 4.5:1', () => {
|
|
expect(contrast(fg, bg)).toBeGreaterThanOrEqual(4.5);
|
|
});
|
|
|
|
it('--fg-muted on --bg >= 4.5:1', () => {
|
|
expect(contrast(fgMuted, bg)).toBeGreaterThanOrEqual(4.5);
|
|
});
|
|
|
|
it('--border-strong on --bg >= 3.0:1 (UI contrast)', () => {
|
|
expect(contrast(borderStrong, bg)).toBeGreaterThanOrEqual(3.0);
|
|
});
|
|
});
|
|
|
|
describe('tokens.css — WCAG AA contrast (dark)', () => {
|
|
const fg = resolveToken(mergedDark.get('--fg')!, mergedDark);
|
|
const fgMuted = resolveToken(mergedDark.get('--fg-muted')!, mergedDark);
|
|
const bg = resolveToken(mergedDark.get('--bg')!, mergedDark);
|
|
const borderStrong = resolveToken(mergedDark.get('--border-strong')!, mergedDark);
|
|
|
|
it('--fg on --bg >= 4.5:1', () => {
|
|
expect(contrast(fg, bg)).toBeGreaterThanOrEqual(4.5);
|
|
});
|
|
|
|
it('--fg-muted on --bg >= 4.5:1', () => {
|
|
expect(contrast(fgMuted, bg)).toBeGreaterThanOrEqual(4.5);
|
|
});
|
|
|
|
it('--border-strong on --bg >= 3.0:1 (UI contrast)', () => {
|
|
expect(contrast(borderStrong, bg)).toBeGreaterThanOrEqual(3.0);
|
|
});
|
|
});
|