/** * WCAG 2.2 contrast ratio utilities for runtime color validation */ export function hexToRgb(hex: string): [number, number, number] { const clean = hex.replace('#', ''); const full = clean.length === 3 ? clean.split('').map(c => c + c).join('') : clean; const num = parseInt(full, 16); return [(num >> 16) & 255, (num >> 8) & 255, num & 255]; } export function relativeLuminance([r, g, b]: [number, number, number]): number { const [rs, gs, bs] = [r, g, b].map(c => { const s = c / 255; return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }); return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; } export function contrastRatio(color1: string, color2: string): number { const l1 = relativeLuminance(hexToRgb(color1)); const l2 = relativeLuminance(hexToRgb(color2)); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); return (lighter + 0.05) / (darker + 0.05); } /** * Adjusts foreground color to meet minimum contrast ratio against background. * Lightens or darkens the foreground as needed. */ export function ensureContrast(fg: string, bg: string, minRatio: number): string { if (contrastRatio(fg, bg) >= minRatio) return fg; const bgLum = relativeLuminance(hexToRgb(bg)); const [r, g, b] = hexToRgb(fg); // Determine direction: lighten if bg is dark, darken if bg is light const lighten = bgLum < 0.5; for (let step = 1; step <= 50; step++) { const factor = step * 0.02; let nr: number, ng: number, nb: number; if (lighten) { nr = Math.min(255, Math.round(r + (255 - r) * factor)); ng = Math.min(255, Math.round(g + (255 - g) * factor)); nb = Math.min(255, Math.round(b + (255 - b) * factor)); } else { nr = Math.max(0, Math.round(r * (1 - factor))); ng = Math.max(0, Math.round(g * (1 - factor))); nb = Math.max(0, Math.round(b * (1 - factor))); } const adjusted = '#' + [nr, ng, nb].map(c => c.toString(16).padStart(2, '0')).join(''); if (contrastRatio(adjusted, bg) >= minRatio) return adjusted; } // Fallback: return white or black return lighten ? '#FFFFFF' : '#000000'; } /** * Determines if text at given size/weight qualifies as "large text" per WCAG. * Large text = 18pt+ (24px+) or 14pt+ bold (18.66px+) */ export function isLargeText(sizePt: number, bold: boolean): boolean { return sizePt >= 18 || (bold && sizePt >= 14); }