// src/lib/__tests__/design-tokens.test.ts import { describe, it, expect } from "vitest"; /** * Asserts all token combinations meet WCAG contrast requirements for the * surfaces they actually render on. Rationale: outdoor + sun-glare is * harder than WCAG accounts for, so we aim for AAA (7:1) on the * surfaces text appears on, and AA (4.5:1) as the documented floor. * * Surface roles: * bg — page background * surface — card / panel background * surface-2 — input fields, secondary cards * surface-3 — high-emphasis containers, skeletons, dividers * * Token roles (and which assertion groups they belong to): * text / text-muted — body text, appears on all 4 surfaces → AAA * text-faint — meta / timestamps, appears on all 4 surfaces → AA * accent / success — brand + positive status text → AAA on bg, surface * danger / warning / info — status text, on bg + surface + their -soft pill bg → AAA * accent-2 — button background, white text sits on top → AAA * *-soft — pill background, distinct from page surface (decorative) */ type RGB = [number, number, number]; function parseHex(hex: string): RGB { const h = hex.replace("#", ""); return [ parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), ]; } function srgbToLinear(c: number): number { const v = c / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); } function luminance([r, g, b]: RGB): number { return ( 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b) ); } function contrastRatio(fg: string, bg: string): number { const l1 = luminance(parseHex(fg)); const l2 = luminance(parseHex(bg)); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); return (lighter + 0.05) / (darker + 0.05); } const SURFACES: Record = { bg: "#ffffff", surface: "#faf8f5", "surface-2": "#f5f5f7", "surface-3": "#e8e8ed", }; // Page surfaces that status text actually renders on. Status text does // not render directly on --color-surface-3 (skeleton/dividers); it lives // on its own -soft pill background instead. const PAGE_SURFACES: Record = { bg: "#ffffff", surface: "#faf8f5", }; const BODY_TEXT_AAA: Record = { text: "#1d1d1f", "text-muted": "#424245", info: "#1e3a8a", }; const META_TEXT_AA: Record = { "text-faint": "#5e5e63", }; const STATUS_TEXT_AAA: Record = { accent: "#14532d", danger: "#7f1d1d", warning: "#5e2a04", success: "#14532d", }; const STATUS_ON_SOFT: Record = { "accent on accent-soft": { text: "#14532d", soft: "#dcfce7" }, "danger on danger-soft": { text: "#7f1d1d", soft: "#fee2e2" }, "warning on warning-soft": { text: "#5e2a04", soft: "#fef9c3" }, "success on success-soft": { text: "#14532d", soft: "#dcfce7" }, "info on info-soft": { text: "#1e3a8a", soft: "#dbeafe" }, }; const ON_ACCENT_BUTTON = { fg: "#ffffff", // button label bg: "#166534", // --color-accent-2 }; describe("design tokens — WCAG contrast", () => { describe("body text on every page surface (AAA)", () => { Object.entries(BODY_TEXT_AAA).forEach(([token, hex]) => { Object.entries(SURFACES).forEach(([surfaceName, surfaceHex]) => { it(`${token} on ${surfaceName} meets AAA (>= 7:1)`, () => { const ratio = contrastRatio(hex, surfaceHex); expect( ratio, `${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`, ).toBeGreaterThanOrEqual(7); }); }); }); }); describe("meta text on every page surface (AA)", () => { Object.entries(META_TEXT_AA).forEach(([token, hex]) => { Object.entries(SURFACES).forEach(([surfaceName, surfaceHex]) => { it(`${token} on ${surfaceName} meets AA (>= 4.5:1)`, () => { const ratio = contrastRatio(hex, surfaceHex); expect( ratio, `${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`, ).toBeGreaterThanOrEqual(4.5); }); }); }); }); describe("status text on page surfaces (AAA)", () => { Object.entries(STATUS_TEXT_AAA).forEach(([token, hex]) => { Object.entries(PAGE_SURFACES).forEach(([surfaceName, surfaceHex]) => { it(`${token} on ${surfaceName} meets AAA (>= 7:1)`, () => { const ratio = contrastRatio(hex, surfaceHex); expect( ratio, `${token} (${hex}) on ${surfaceName} (${surfaceHex}) = ${ratio.toFixed(2)}:1`, ).toBeGreaterThanOrEqual(7); }); }); }); }); describe("status text on its -soft pill background (AAA)", () => { Object.entries(STATUS_ON_SOFT).forEach(([name, { text, soft }]) => { it(`${name} meets AAA (>= 7:1)`, () => { const ratio = contrastRatio(text, soft); expect( ratio, `${name}: ${text} on ${soft} = ${ratio.toFixed(2)}:1`, ).toBeGreaterThanOrEqual(7); }); }); }); describe("button text on accent-2 background (AAA)", () => { it(`white on accent-2 meets AAA (>= 7:1)`, () => { const ratio = contrastRatio(ON_ACCENT_BUTTON.fg, ON_ACCENT_BUTTON.bg); expect( ratio, `white on ${ON_ACCENT_BUTTON.bg} = ${ratio.toFixed(2)}:1`, ).toBeGreaterThanOrEqual(7); }); }); describe("pill backgrounds are visually distinct from page surface", () => { // The "soft" backgrounds are decorative. They don't need to pass // contrast on their own, but they must read as a distinct fill // against the page surface. const softs: Record = { "accent-soft": "#dcfce7", "danger-soft": "#fee2e2", "warning-soft": "#fef9c3", "success-soft": "#dcfce7", "info-soft": "#dbeafe", }; Object.entries(softs).forEach(([token, hex]) => { it(`${token} reads as a distinct fill on --color-surface`, () => { const ratio = contrastRatio(hex, "#faf8f5"); expect( ratio, `${token} too close to surface (${ratio.toFixed(2)}:1)`, ).toBeLessThan(1.5); }); }); }); });