import type { ContrastCheck, ContrastReport, ThemeProposal } from './types.js'; /** Parse `#rgb` / `#rrggbb` → `{ r, g, b }` 0..255. Throws on invalid. */ export function parseHex(hex: string): { r: number; g: number; b: number } { const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim()); if (!m) throw new Error(`Invalid hex colour: ${hex}`); let body = m[1]!; if (body.length === 3) { body = body.split('').map((ch) => ch + ch).join(''); } const num = parseInt(body, 16); return { r: (num >> 16) & 0xff, g: (num >> 8) & 0xff, b: num & 0xff, }; } /** Render `{ r, g, b }` → `#rrggbb`. */ export function toHex(r: number, g: number, b: number): string { const c = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0'); return `#${c(r)}${c(g)}${c(b)}`; } /** Relative luminance per WCAG 2.x. */ export function relativeLuminance(hex: string): number { const { r, g, b } = parseHex(hex); const f = (v: number) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); } /** WCAG contrast ratio for two hex colours. */ export function contrast(a: string, b: string): number { const la = relativeLuminance(a); const lb = relativeLuminance(b); const lo = Math.min(la, lb); const hi = Math.max(la, lb); return (hi + 0.05) / (lo + 0.05); } export function report(fg: string, bg: string): ContrastReport { const ratio = contrast(fg, bg); return { ratio, aa: ratio >= 4.5, aaa: ratio >= 7 }; } /** Audit the canonical text/accent pairings in a theme proposal. */ export function auditTheme(theme: ThemeProposal): ContrastCheck { return { textPrimaryOnSurface: report(theme.textPrimary, theme.surface), textSecondaryOnSurface: report(theme.textSecondary, theme.surface), textPrimaryOnSurfaceCard: report(theme.textPrimary, theme.surfaceCard), accentOnSurface: report(theme.accent, theme.surface), }; } /** * Iteratively darken or lighten `fg` until `contrast(fg, bg) >= * target`. Caps at black / white. Pure function. */ export function adjustForContrast(fg: string, bg: string, target: number): string { let current = fg; // Decide direction once: if bg is light, push fg darker; else lighter. const bgLum = relativeLuminance(bg); const darker = bgLum > 0.5; for (let i = 0; i < 30; i++) { if (contrast(current, bg) >= target) return current; current = shift(current, darker ? -0.04 : 0.04); } return current; } function shift(hex: string, delta: number): string { const { r, g, b } = parseHex(hex); const k = delta * 255; return toHex(r + k, g + k, b + k); } /** * Enforce a contrast policy across the audited pairings, returning a * new ThemeProposal where any failing fg has been nudged toward black / * white until it passes. */ export function enforceContrast( theme: ThemeProposal, policy: 'aa' | 'aaa' = 'aa', ): ThemeProposal { const target = policy === 'aaa' ? 7 : 4.5; return { ...theme, textPrimary: adjustForContrast(theme.textPrimary, theme.surface, target), textSecondary: adjustForContrast(theme.textSecondary, theme.surface, Math.max(3, target - 1.5)), accent: adjustForContrast(theme.accent, theme.surface, Math.max(3, target - 1.5)), }; }