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 extractRootBlock(src: string): string { const m = src.match(/:root\s*\{([^}]+)\}/); return m?.[1] ?? ''; } function extractDarkBlock(src: string): string { const afterMedia = src.split('@media (prefers-color-scheme: dark)')[1] ?? ''; const m = afterMedia.match(/:root\s*\{([^}]+)\}/); return m?.[1] ?? ''; } function parseProps(block: string): Map { const map = new Map(); 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 { 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(extractRootBlock(css)); const darkProps = parseProps(extractDarkBlock(css)); // 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); }); });