contrast-check.js 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. #!/usr/bin/env node
  2. // Contrast checker - WCAG 2.x ratio + pass/fail for AA/AAA
  3. // Usage: node contrast-check.js <color1> <color2>
  4. // Accepts: hex (#fff, #ffffff), rgb(r,g,b), oklch(l c h)
  5. const args = process.argv.slice(2);
  6. if (args.length < 2) {
  7. console.log(`Usage: node contrast-check.js <color1> <color2>
  8. Examples:
  9. node contrast-check.js "#1a1a2e" "#e0e0e0"
  10. node contrast-check.js "rgb(26,26,46)" "rgb(224,224,224)"
  11. node contrast-check.js "oklch(0.15 0.02 250)" "oklch(0.9 0.01 250)"
  12. node contrast-check.js "#1a1a2e" "oklch(0.9 0.01 250)"`);
  13. process.exit(1);
  14. }
  15. // --- Color parsing ---
  16. function parseHex(hex) {
  17. hex = hex.replace('#', '');
  18. if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
  19. const n = parseInt(hex, 16);
  20. return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  21. }
  22. function parseRgb(str) {
  23. const m = str.match(/rgb\(\s*(\d+)\s*[,\s]\s*(\d+)\s*[,\s]\s*(\d+)\s*\)/i);
  24. if (!m) return null;
  25. return [+m[1], +m[2], +m[3]];
  26. }
  27. function parseOklch(str) {
  28. const m = str.match(/oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\)/i);
  29. if (!m) return null;
  30. return oklchToSrgb(+m[1], +m[2], +m[3]);
  31. }
  32. function parseColor(str) {
  33. str = str.trim();
  34. if (str.startsWith('#')) return parseHex(str);
  35. if (str.startsWith('rgb')) return parseRgb(str);
  36. if (str.startsWith('oklch')) return parseOklch(str);
  37. // Try as bare hex
  38. if (/^[0-9a-f]{3,8}$/i.test(str)) return parseHex(str);
  39. return null;
  40. }
  41. // --- OKLCH -> sRGB conversion ---
  42. function oklchToOklab(L, C, H) {
  43. const hRad = H * Math.PI / 180;
  44. return [L, C * Math.cos(hRad), C * Math.sin(hRad)];
  45. }
  46. function oklabToLinearRgb(L, a, b) {
  47. const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
  48. const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
  49. const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
  50. const l = l_ * l_ * l_;
  51. const m = m_ * m_ * m_;
  52. const s = s_ * s_ * s_;
  53. return [
  54. +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
  55. -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
  56. -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
  57. ];
  58. }
  59. function linearToSrgb(c) {
  60. return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
  61. }
  62. function oklchToSrgb(L, C, H) {
  63. const [labL, labA, labB] = oklchToOklab(L, C, H);
  64. const [lr, lg, lb] = oklabToLinearRgb(labL, labA, labB);
  65. return [
  66. Math.round(Math.min(255, Math.max(0, linearToSrgb(lr) * 255))),
  67. Math.round(Math.min(255, Math.max(0, linearToSrgb(lg) * 255))),
  68. Math.round(Math.min(255, Math.max(0, linearToSrgb(lb) * 255))),
  69. ];
  70. }
  71. // --- Contrast calculation ---
  72. function srgbToLinear(c) {
  73. c /= 255;
  74. return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
  75. }
  76. function relativeLuminance(r, g, b) {
  77. return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
  78. }
  79. function contrastRatio(l1, l2) {
  80. const lighter = Math.max(l1, l2);
  81. const darker = Math.min(l1, l2);
  82. return (lighter + 0.05) / (darker + 0.05);
  83. }
  84. function rgbToHex(r, g, b) {
  85. return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
  86. }
  87. // --- Main ---
  88. // Rejoin args that might have been split by spaces (e.g. "oklch(0.5" "0.1" "250)")
  89. const raw = args.join(' ');
  90. const colors = [];
  91. const patterns = [
  92. /oklch\(\s*[\d.]+\s+[\d.]+\s+[\d.]+\s*\)/gi,
  93. /rgb\(\s*\d+\s*[,\s]\s*\d+\s*[,\s]\s*\d+\s*\)/gi,
  94. /#[0-9a-f]{3,8}/gi,
  95. ];
  96. let remaining = raw;
  97. for (const pat of patterns) {
  98. const matches = remaining.match(pat);
  99. if (matches) {
  100. for (const m of matches) {
  101. colors.push(m);
  102. remaining = remaining.replace(m, '');
  103. }
  104. }
  105. }
  106. // Pick up any bare tokens left
  107. const bare = remaining.trim().split(/\s+/).filter(s => s.length > 0);
  108. colors.push(...bare);
  109. if (colors.length < 2) {
  110. console.error('Error: Could not parse two colors from input.');
  111. process.exit(1);
  112. }
  113. const rgb1 = parseColor(colors[0]);
  114. const rgb2 = parseColor(colors[1]);
  115. if (!rgb1 || !rgb2) {
  116. console.error(`Error: Could not parse color${!rgb1 ? ' 1: ' + colors[0] : ''}${!rgb2 ? ' 2: ' + colors[1] : ''}`);
  117. process.exit(1);
  118. }
  119. const l1 = relativeLuminance(...rgb1);
  120. const l2 = relativeLuminance(...rgb2);
  121. const ratio = contrastRatio(l1, l2);
  122. const pass = (threshold) => ratio >= threshold ? 'PASS' : 'FAIL';
  123. console.log(`
  124. Color 1: ${colors[0].trim()} -> rgb(${rgb1.join(', ')}) ${rgbToHex(...rgb1)}
  125. Color 2: ${colors[1].trim()} -> rgb(${rgb2.join(', ')}) ${rgbToHex(...rgb2)}
  126. Contrast ratio: ${ratio.toFixed(2)}:1
  127. WCAG AA normal text (4.5:1) ${pass(4.5)}
  128. WCAG AA large text (3:1) ${pass(3)}
  129. WCAG AAA normal text (7:1) ${pass(7)}
  130. WCAG AAA large text (4.5:1) ${pass(4.5)}
  131. `.trim());