/** * Contrast Ratio Utility * * Calculates WCAG contrast ratios between foreground and background colors. * Used for automated accessibility testing. * * @see https://www.w3.org/WAI/GL/wiki/Contrast_ratio */ /** * Converts RGB values (0-255) to relative luminance * @param r Red component (0-255) * @param g Green component (0-255) * @param b Blue component (0-255) * @returns Relative luminance (0-1) */ export function getRelativeLuminance(r: number, g: number, b: number): number { // Normalize RGB values to 0-1 const [rs, gs, bs] = [r, g, b].map((val) => { const normalized = val / 255; return normalized <= 0.03928 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4); }); // Calculate relative luminance using WCAG formula return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; } /** * Calculates contrast ratio between two colors * @param color1 RGB values [r, g, b] for first color * @param color2 RGB values [r, g, b] for second color * @returns Contrast ratio (1-21, where 21 is maximum) */ export function getContrastRatio( color1: [number, number, number], color2: [number, number, number], ): number { const l1 = getRelativeLuminance(...color1); const l2 = getRelativeLuminance(...color2); // Ensure lighter color is in numerator const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); // Calculate contrast ratio return (lighter + 0.05) / (darker + 0.05); } /** * Checks if contrast ratio meets WCAG AA standard * @param ratio Contrast ratio * @param isLargeText Whether text is large (≥18pt regular or ≥14pt bold) * @returns true if meets WCAG AA (4.5:1 for normal, 3:1 for large) */ export function meetsWCAGAA(ratio: number, isLargeText = false): boolean { return isLargeText ? ratio >= 3 : ratio >= 4.5; } /** * Checks if contrast ratio meets WCAG AAA standard * @param ratio Contrast ratio * @param isLargeText Whether text is large (≥18pt regular or ≥14pt bold) * @returns true if meets WCAG AAA (7:1 for normal, 4.5:1 for large) */ export function meetsWCAGAAA(ratio: number, isLargeText = false): boolean { return isLargeText ? ratio >= 4.5 : ratio >= 7; } /** * Parses RGB string from CSS variable format (e.g., "255 255 255" or "rgb(255, 255, 255)") * @param rgbString RGB string * @returns RGB tuple [r, g, b] or null if invalid */ export function parseRGB(rgbString: string): [number, number, number] | null { // Handle CSS variable format: "255 255 255" const spaceSeparated = rgbString.trim().match(/^(\d+)\s+(\d+)\s+(\d+)$/); if (spaceSeparated) { return [ parseInt(spaceSeparated[1], 10), parseInt(spaceSeparated[2], 10), parseInt(spaceSeparated[3], 10), ]; } // Handle rgb() format: "rgb(255, 255, 255)" const rgbMatch = rgbString.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); if (rgbMatch) { return [ parseInt(rgbMatch[1], 10), parseInt(rgbMatch[2], 10), parseInt(rgbMatch[3], 10), ]; } return null; }