- Created contrast utility (apps/web/src/utils/contrast.ts) - getRelativeLuminance() - calculates WCAG relative luminance - getContrastRatio() - calculates contrast ratio between colors - meetsWCAGAA() / meetsWCAGAAA() - validates WCAG standards - parseRGB() - parses RGB strings from CSS variables - Created contrast test suite (apps/web/src/__tests__/contrast.test.ts) - Tests all design system color combinations - Validates primary text (white) on all backgrounds - Validates secondary text (dim) on all backgrounds - Validates text with opacity variants - All combinations must meet WCAG AA (4.5:1) - Added contrast test step to CI workflow - Prevents contrast ratio regressions - Action 11.1.1.5 complete
98 lines
3 KiB
TypeScript
98 lines
3 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|