veza/apps/web/src/utils/contrast.ts
senke 4ceefadaa7 aesthetic-improvements: add automated contrast testing for WCAG compliance
- 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
2026-01-16 10:26:20 +01:00

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;
}