import { describe, expect, it } from "vitest"; import themeCss from "./styles/theme-vars.css?raw"; interface ParsedColor { rgb: [number, number, number]; alpha: number; } function parseThemeVars() { const matches = [ ...themeCss.matchAll(/:root([^{]*)\{([\s\S]*?)\n {2}\}/g), ]; const byTheme: Record> = {}; for (const match of matches) { const selector = match[1]; const body = match[2]; const themes = [ ...selector.matchAll(/data-theme="([^"]+)"/g), ].map((themeMatch) => themeMatch[1]); if (themes.length === 0) continue; const vars: Record = {}; for (const varMatch of body.matchAll(/--([\w-]+):\s*([^;]+);/g)) { vars[varMatch[1]] = varMatch[2].trim(); } for (const theme of themes) { byTheme[theme] = { ...(byTheme[theme] ?? {}), ...vars }; } } return byTheme; } function parseColor(value: string | undefined): ParsedColor { if (!value) { throw new Error("Missing color token."); } const hexMatch = value.match(/^#([\da-f]{3,8})$/i); if (hexMatch) { let hex = hexMatch[1]; if (hex.length === 3) { hex = hex .split("") .map((char) => char + char) .join(""); } let alpha = 1; if (hex.length === 8) { alpha = parseInt(hex.slice(6), 16) / 255; hex = hex.slice(0, 6); } return { rgb: [ parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16), ], alpha, }; } const rgbMatch = value.match(/^rgba?\(([^)]+)\)$/i); if (rgbMatch) { const parts = rgbMatch[1].split(",").map((part) => part.trim()); return { rgb: [ Number(parts[0]), Number(parts[1]), Number(parts[2]), ], alpha: parts[3] === undefined ? 1 : Number(parts[3]), }; } throw new Error(`Unsupported color format: ${value}`); } function blendColor(foreground: ParsedColor, background: ParsedColor): ParsedColor { if (foreground.alpha >= 1) return foreground; return { rgb: foreground.rgb.map((channel, index) => channel * foreground.alpha + background.rgb[index] * (1 - foreground.alpha), ) as [number, number, number], alpha: 1, }; } function relativeChannel(channel: number) { const normalized = channel / 255; return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; } function luminance(color: ParsedColor) { const [r, g, b] = color.rgb; return ( 0.2126 * relativeChannel(r) + 0.7152 * relativeChannel(g) + 0.0722 * relativeChannel(b) ); } function contrastRatio(foreground: ParsedColor, background: ParsedColor) { const a = luminance(foreground); const b = luminance(background); const [lighter, darker] = a >= b ? [a, b] : [b, a]; return (lighter + 0.05) / (darker + 0.05); } describe("theme contrast tokens", () => { const themeVars = parseThemeVars(); const themeIds = Object.keys(themeVars); it("keeps secondary text readable on the dashboard surfaces", () => { for (const themeId of themeIds) { const vars = themeVars[themeId]; const surface = parseColor(vars["app-surface"]); const elevated = parseColor(vars["app-surface-elevated"]); const muted = parseColor(vars["app-text-muted"]); const soft = parseColor(vars["app-text-soft"]); const faint = parseColor(vars["app-text-faint"]); expect( contrastRatio(muted, surface), `${themeId} muted text should stay comfortably readable on the base surface`, ).toBeGreaterThanOrEqual(7); expect( contrastRatio(soft, surface), `${themeId} soft text should stay readable on the base surface`, ).toBeGreaterThanOrEqual(5); expect( contrastRatio(faint, elevated), `${themeId} faint text should still clear caption contrast on elevated surfaces`, ).toBeGreaterThanOrEqual(4.5); } }); it("preserves a descending text hierarchy in every theme", () => { for (const themeId of themeIds) { const vars = themeVars[themeId]; const surface = parseColor(vars["app-surface"]); const muted = parseColor(vars["app-text-muted"]); const soft = parseColor(vars["app-text-soft"]); const faint = parseColor(vars["app-text-faint"]); const mutedContrast = contrastRatio(muted, surface); const softContrast = contrastRatio(soft, surface); const faintContrast = contrastRatio(faint, surface); expect( mutedContrast, `${themeId} muted text should remain stronger than soft text`, ).toBeGreaterThan(softContrast); expect( softContrast, `${themeId} soft text should remain stronger than faint text`, ).toBeGreaterThan(faintContrast); } }); it("keeps semantic chips readable when translucent fills sit on app surfaces", () => { const chipPairs = [ ["app-attention-text", "app-attention-background"], ["app-success-text", "app-success-background"], ["app-danger-text", "app-danger-background"], ["app-info-text", "app-info-background"], ["role-leader-badge-text", "role-leader-badge-bg"], ["role-worker-badge-text", "role-worker-badge-bg"], ["stage-plan-text", "stage-plan-bg"], ["stage-review-text", "stage-review-bg"], ["stage-freeze-text", "stage-freeze-bg"], ["stage-execution-text", "stage-execution-bg"], ["stage-verification-text", "stage-verification-bg"], ] as const; for (const themeId of themeIds) { const vars = themeVars[themeId]; const surface = parseColor(vars["app-surface"]); for (const [textToken, backgroundToken] of chipPairs) { const text = parseColor(vars[textToken]); const translucentBackground = parseColor(vars[backgroundToken]); const background = blendColor(translucentBackground, surface); expect( contrastRatio(text, background), `${themeId} ${textToken} on ${backgroundToken} should remain readable`, ).toBeGreaterThanOrEqual(4.5); } } }); });