- 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
189 lines
6.2 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|
|
});
|