/** * WCAG color contrast checker. * * Evaluates text contrast ratios against backgrounds for all visible text elements. * Reports elements that fail WCAG 2.1 AA (4.5:1 for normal text, 3:1 for large text) * or AAA (7:1 for normal, 4.5:1 for large text) requirements. * * Uses computedStyle to get actual rendered colors — accounts for CSS variables * and inheritance. */ import type { Page } from 'playwright'; export interface ContrastViolation { selector: string; text: string; foreground: string; // rgb(r, g, b) background: string; // rgb(r, g, b) ratio: number; // actual contrast ratio required: number; // required ratio (4.5 or 3.0) level: 'AA' | 'AAA'; isLargeText: boolean; // font-size >= 18px or bold >= 14px severity: 'fail' | 'warn'; // fail = below AA, warn = below AAA } export interface ContrastAuditResult { url: string; violations: ContrastViolation[]; passCount: number; score: number; // 0-100 auditedAt: string; } export async function auditColorContrast(page: Page, url: string): Promise { const auditedAt = new Date().toISOString(); const violations: ContrastViolation[] = await page.evaluate(() => { function parseRgb(color: string): [number, number, number, number] | null { const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/); return m ? [+m[1], +m[2], +m[3], m[4] !== undefined ? +m[4] : 1] : null; } function luminance([r, g, b]: [number, number, number, number]): number { const toLinear = (c: number) => { const n = c / 255; return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4); }; return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); } function contrastRatio(fg: [number,number,number,number], bg: [number,number,number,number]): number { const l1 = luminance(fg); const l2 = luminance(bg); const lighter = Math.max(l1, l2); const darker = Math.min(l1, l2); return Math.round(((lighter + 0.05) / (darker + 0.05)) * 100) / 100; } const violations: any[] = []; const elements = Array.from(document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, a, span, li, td, th, label, button')); let checked = 0; for (const el of elements as HTMLElement[]) { if (checked >= 100) break; // limit to avoid perf hit const text = (el.textContent ?? '').trim().slice(0, 60); if (!text) continue; const style = window.getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden') continue; const fg = parseRgb(style.color); const bg = parseRgb(style.backgroundColor); if (!fg || !bg || bg[3] === 0) continue; // transparent bg — can't check const fontSize = parseFloat(style.fontSize); const fontWeight = style.fontWeight; const isLargeText = fontSize >= 18 || (fontSize >= 14 && (fontWeight === 'bold' || +fontWeight >= 700)); const ratio = contrastRatio(fg, bg); const aaRequired = isLargeText ? 3.0 : 4.5; const aaaRequired = isLargeText ? 4.5 : 7.0; if (ratio < aaRequired) { violations.push({ selector: el.tagName.toLowerCase(), text: text.slice(0, 50), foreground: style.color, background: style.backgroundColor, ratio, required: aaRequired, level: 'AA', isLargeText, severity: 'fail', }); } else if (ratio < aaaRequired) { violations.push({ selector: el.tagName.toLowerCase(), text: text.slice(0, 50), foreground: style.color, background: style.backgroundColor, ratio, required: aaaRequired, level: 'AAA', isLargeText, severity: 'warn', }); } else { checked++; } } return violations; }).catch(() => [] as ContrastViolation[]); const passCount = Math.max(0, 100 - violations.length); const criticals = violations.filter(v => v.severity === 'fail').length; const score = Math.max(0, 100 - criticals * 15 - violations.filter(v => v.severity === 'warn').length * 3); return { url, violations, passCount, score, auditedAt }; }