/ scripts / contrast-check.js
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  }