contrast-check.js
1 #!/usr/bin/env node 2 /** 3 * Check color contrast ratios for WCAG AA compliance 4 * Text: 4.5:1 minimum 5 * UI components: 3:1 minimum 6 */ 7 8 import fs from 'fs'; 9 import path from 'path'; 10 import { fileURLToPath } from 'url'; 11 12 const __dirname = path.dirname(fileURLToPath(import.meta.url)); 13 14 // Load themes 15 const themesDir = path.join(__dirname, '..', 'themes'); 16 const themes = ['light.json', 'dark.json', 'high-contrast.json', 'testnet.json']; 17 18 // Convert hex to RGB 19 function hexToRgb(hex) { 20 const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 21 return result ? { 22 r: parseInt(result[1], 16), 23 g: parseInt(result[2], 16), 24 b: parseInt(result[3], 16) 25 } : null; 26 } 27 28 // Calculate relative luminance 29 function luminance(r, g, b) { 30 const [rs, gs, bs] = [r, g, b].map(c => { 31 c = c / 255; 32 return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); 33 }); 34 return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; 35 } 36 37 // Calculate contrast ratio 38 function contrastRatio(hex1, hex2) { 39 const rgb1 = hexToRgb(hex1); 40 const rgb2 = hexToRgb(hex2); 41 42 if (!rgb1 || !rgb2) return 0; 43 44 const l1 = luminance(rgb1.r, rgb1.g, rgb1.b); 45 const l2 = luminance(rgb2.r, rgb2.g, rgb2.b); 46 47 const lighter = Math.max(l1, l2); 48 const darker = Math.min(l1, l2); 49 50 return (lighter + 0.05) / (darker + 0.05); 51 } 52 53 console.log('Checking color contrast (WCAG AA)...\n'); 54 55 let hasErrors = false; 56 57 for (const themeFile of themes) { 58 const themePath = path.join(themesDir, themeFile); 59 60 if (!fs.existsSync(themePath)) { 61 console.warn(`WARN: Theme not found: ${themeFile}`); 62 continue; 63 } 64 65 const theme = JSON.parse(fs.readFileSync(themePath, 'utf-8')); 66 console.log(`Theme: ${theme.displayName}`); 67 68 const bg = theme.colors.background.primary; 69 const fg = theme.colors.foreground.primary; 70 const fgMuted = theme.colors.foreground.muted; 71 72 // Check primary text contrast 73 const textRatio = contrastRatio(bg, fg); 74 if (textRatio < 4.5) { 75 console.error(` FAIL: Text contrast ${textRatio.toFixed(2)}:1 (need 4.5:1)`); 76 hasErrors = true; 77 } else { 78 console.log(` OK: Text contrast ${textRatio.toFixed(2)}:1`); 79 } 80 81 // Check muted text contrast 82 const mutedRatio = contrastRatio(bg, fgMuted); 83 if (mutedRatio < 3) { 84 console.error(` FAIL: Muted text contrast ${mutedRatio.toFixed(2)}:1 (need 3:1)`); 85 hasErrors = true; 86 } else { 87 console.log(` OK: Muted text contrast ${mutedRatio.toFixed(2)}:1`); 88 } 89 90 console.log(''); 91 } 92 93 if (hasErrors) { 94 console.error('Contrast check failed!'); 95 process.exit(1); 96 } else { 97 console.log('All contrast ratios pass WCAG AA!'); 98 }