veza/apps/web/src/__tests__/contrast.test.ts
senke 7fe1484de3 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

189 lines
6.2 KiB
TypeScript

/**
* Contrast Ratio Tests
* Action 11.1.1.5: Automated contrast testing
*
* Validates that all design system color combinations meet WCAG AA standards.
*/
import { describe, it, expect } from 'vitest';
import {
getRelativeLuminance,
getContrastRatio,
meetsWCAGAA,
meetsWCAGAAA,
parseRGB,
} from '../utils/contrast';
describe('Contrast Ratio Utilities', () => {
describe('getRelativeLuminance', () => {
it('should calculate luminance for white', () => {
const luminance = getRelativeLuminance(255, 255, 255);
expect(luminance).toBeCloseTo(1.0, 2);
});
it('should calculate luminance for black', () => {
const luminance = getRelativeLuminance(0, 0, 0);
expect(luminance).toBeCloseTo(0.0, 3);
});
it('should calculate luminance for gray', () => {
const luminance = getRelativeLuminance(128, 128, 128);
expect(luminance).toBeGreaterThan(0);
expect(luminance).toBeLessThan(1);
});
});
describe('getContrastRatio', () => {
it('should calculate maximum contrast (white on black)', () => {
const ratio = getContrastRatio([255, 255, 255], [0, 0, 0]);
expect(ratio).toBeCloseTo(21.0, 1);
});
it('should calculate minimum contrast (same colors)', () => {
const ratio = getContrastRatio([255, 255, 255], [255, 255, 255]);
expect(ratio).toBeCloseTo(1.0, 2);
});
it('should be symmetric (order-independent)', () => {
const ratio1 = getContrastRatio([255, 255, 255], [128, 128, 128]);
const ratio2 = getContrastRatio([128, 128, 128], [255, 255, 255]);
expect(ratio1).toBeCloseTo(ratio2, 2);
});
});
describe('meetsWCAGAA', () => {
it('should pass for white on black (normal text)', () => {
const ratio = getContrastRatio([255, 255, 255], [0, 0, 0]);
expect(meetsWCAGAA(ratio, false)).toBe(true);
});
it('should pass for white on dark background (normal text)', () => {
const ratio = getContrastRatio([255, 255, 255], [11, 12, 16]); // kodo-void
expect(meetsWCAGAA(ratio, false)).toBe(true);
expect(ratio).toBeGreaterThan(4.5);
});
it('should fail for low contrast (normal text)', () => {
expect(meetsWCAGAA(3.0, false)).toBe(false);
});
it('should pass for lower ratio with large text', () => {
expect(meetsWCAGAA(3.5, true)).toBe(true);
});
});
describe('meetsWCAGAAA', () => {
it('should pass for white on black', () => {
const ratio = getContrastRatio([255, 255, 255], [0, 0, 0]);
expect(meetsWCAGAAA(ratio, false)).toBe(true);
});
it('should fail for 6:1 ratio (normal text)', () => {
expect(meetsWCAGAAA(6.0, false)).toBe(false);
});
it('should pass for 7:1 ratio (normal text)', () => {
expect(meetsWCAGAAA(7.0, false)).toBe(true);
});
});
describe('parseRGB', () => {
it('should parse space-separated RGB', () => {
const rgb = parseRGB('255 255 255');
expect(rgb).toEqual([255, 255, 255]);
});
it('should parse rgb() format', () => {
const rgb = parseRGB('rgb(255, 255, 255)');
expect(rgb).toEqual([255, 255, 255]);
});
it('should return null for invalid format', () => {
expect(parseRGB('invalid')).toBeNull();
expect(parseRGB('#ffffff')).toBeNull();
});
});
});
describe('Design System Color Contrast', () => {
// Kodo Design System colors from index.css
const colors = {
void: [11, 12, 16] as [number, number, number],
ink: [23, 25, 35] as [number, number, number],
graphite: [31, 40, 51] as [number, number, number],
slate: [44, 54, 67] as [number, number, number],
steel: [59, 69, 84] as [number, number, number],
textMain: [255, 255, 255] as [number, number, number], // White
contentDim: [156, 163, 175] as [number, number, number], // Gray-400
};
describe('Primary Text (White) on Backgrounds', () => {
const backgrounds = [
{ name: 'void', rgb: colors.void },
{ name: 'ink', rgb: colors.ink },
{ name: 'graphite', rgb: colors.graphite },
{ name: 'slate', rgb: colors.slate },
{ name: 'steel', rgb: colors.steel },
];
backgrounds.forEach((bg) => {
it(`should meet WCAG AA for white text on ${bg.name}`, () => {
const ratio = getContrastRatio(colors.textMain, bg.rgb);
expect(ratio).toBeGreaterThan(4.5);
expect(meetsWCAGAA(ratio, false)).toBe(true);
});
it(`should meet WCAG AAA for white text on ${bg.name}`, () => {
const ratio = getContrastRatio(colors.textMain, bg.rgb);
expect(ratio).toBeGreaterThan(7.0);
expect(meetsWCAGAAA(ratio, false)).toBe(true);
});
});
});
describe('Secondary Text (Dim) on Backgrounds', () => {
const backgrounds = [
{ name: 'void', rgb: colors.void },
{ name: 'ink', rgb: colors.ink },
{ name: 'graphite', rgb: colors.graphite },
{ name: 'slate', rgb: colors.slate },
{ name: 'steel', rgb: colors.steel },
];
backgrounds.forEach((bg) => {
it(`should meet WCAG AA for dim text on ${bg.name}`, () => {
const ratio = getContrastRatio(colors.contentDim, bg.rgb);
expect(ratio).toBeGreaterThan(4.5);
expect(meetsWCAGAA(ratio, false)).toBe(true);
});
});
});
describe('Text with Opacity', () => {
// Simulate opacity by blending with background
// opacity-80: ~80% white = ~204 RGB
// opacity-90: ~90% white = ~230 RGB
const opacity80 = [204, 204, 204] as [number, number, number];
const opacity90 = [230, 230, 230] as [number, number, number];
const backgrounds = [
{ name: 'void', rgb: colors.void },
{ name: 'ink', rgb: colors.ink },
{ name: 'graphite', rgb: colors.graphite },
];
backgrounds.forEach((bg) => {
it(`should meet WCAG AA for opacity-80 text on ${bg.name}`, () => {
const ratio = getContrastRatio(opacity80, bg.rgb);
expect(ratio).toBeGreaterThan(4.5);
expect(meetsWCAGAA(ratio, false)).toBe(true);
});
it(`should meet WCAG AA for opacity-90 text on ${bg.name}`, () => {
const ratio = getContrastRatio(opacity90, bg.rgb);
expect(ratio).toBeGreaterThan(4.5);
expect(meetsWCAGAA(ratio, false)).toBe(true);
});
});
});
});