diff --git a/frontend/src/lib/design-system/contrast.test.ts b/frontend/src/lib/design-system/contrast.test.ts new file mode 100644 index 0000000..fecb745 --- /dev/null +++ b/frontend/src/lib/design-system/contrast.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; + +// WCAG 2.2 relative luminance for a single 8-bit channel value +function channelLuminance(val: number): number { + const sRGB = val / 255; + return sRGB <= 0.04045 ? sRGB / 12.92 : ((sRGB + 0.055) / 1.055) ** 2.4; +} + +function relativeLuminance(hex: string): number { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return 0.2126 * channelLuminance(r) + 0.7152 * channelLuminance(g) + 0.0722 * channelLuminance(b); +} + +function contrastRatio(hex1: string, hex2: string): number { + const l1 = relativeLuminance(hex1); + const l2 = relativeLuminance(hex2); + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +} + +// Design token values from app.css @theme +const tokens = { + colorText: '#1C1C18', + colorTextMuted: '#6B6A63', + colorPage: '#FAFAF7', + colorSurface: '#F5F4EE', + greenDark: '#2E6E39', // button background — --green (#3D8C4A) only gives 4.16:1, fails AA + white: '#FFFFFF' +}; + +describe('WCAG 2.2 AA contrast ratios', () => { + it('--color-text on --color-page meets 4.5:1 (normal text)', () => { + expect(contrastRatio(tokens.colorText, tokens.colorPage)).toBeGreaterThanOrEqual(4.5); + }); + + it('--color-text on --color-surface meets 4.5:1 (card text)', () => { + expect(contrastRatio(tokens.colorText, tokens.colorSurface)).toBeGreaterThanOrEqual(4.5); + }); + + it('--color-text-muted on --color-page meets 4.5:1 (muted labels)', () => { + expect(contrastRatio(tokens.colorTextMuted, tokens.colorPage)).toBeGreaterThanOrEqual(4.5); + }); + + it('white on --green-dark meets 4.5:1 (primary button background)', () => { + // --green (#3D8C4A) only gives 4.16:1 — buttons use --green-dark (#2E6E39) instead + expect(contrastRatio(tokens.white, tokens.greenDark)).toBeGreaterThanOrEqual(4.5); + }); +});