Product design skills

scripts/contrast.mjs

A supporting file of the a11y-check-code skill, shown as source.

#!/usr/bin/env node
// Calculates WCAG 2.x contrast ratios.
// Used to judge color combinations during an accessibility check. Do not judge by eye or mental arithmetic.

const USAGE = `Usage:
  node contrast.mjs <foreground> <background> [options]
  node contrast.mjs --json '[{"label":"body","fg":"#767676","bg":"#fff"}, ...]'

Arguments:
  foreground / background   #rgb #rrggbb rgb() rgba() hsl() or a CSS color keyword
                             A semi-transparent foreground is composited onto the
                             background before calculating

Options:
  --size <px>       Font size (default: 16)
  --bold            Treat as bold (font-weight >= 700)
  --large           Explicitly treat as large text (takes priority over --size/--bold)
  --json <JSON>     Judge multiple combinations at once. Each element is
                    { label, fg, bg, size?, bold? }
  --help            Show this help

Criteria (WCAG 2.2):
  SC 1.4.3  Text            4.5:1 or higher (3:1 or higher for large text)
  SC 1.4.11 Non-text        3:1 or higher
  Large text = 24px or larger, or bold at 18.66px or larger
`;

const KEYWORDS = {
  black: "#000000", silver: "#c0c0c0", gray: "#808080", grey: "#808080",
  white: "#ffffff", maroon: "#800000", red: "#ff0000", purple: "#800080",
  fuchsia: "#ff00ff", magenta: "#ff00ff", green: "#008000", lime: "#00ff00",
  olive: "#808000", yellow: "#ffff00", navy: "#000080", blue: "#0000ff",
  teal: "#008080", aqua: "#00ffff", cyan: "#00ffff", orange: "#ffa500",
  transparent: "rgba(0,0,0,0)",
};

/** Converts a CSS color string to {r,g,b,a} (0-255, 0-1) */
export function parseColor(input) {
  if (typeof input !== "string") throw new TypeError(`Color value is not a string: ${input}`);
  let s = input.trim().toLowerCase();
  if (KEYWORDS[s]) s = KEYWORDS[s];

  let m = s.match(/^#([0-9a-f]{3,8})$/);
  if (m) {
    const h = m[1];
    const expand = (c) => parseInt(c + c, 16);
    if (h.length === 3 || h.length === 4) {
      return {
        r: expand(h[0]), g: expand(h[1]), b: expand(h[2]),
        a: h.length === 4 ? expand(h[3]) / 255 : 1,
      };
    }
    if (h.length === 6 || h.length === 8) {
      return {
        r: parseInt(h.slice(0, 2), 16),
        g: parseInt(h.slice(2, 4), 16),
        b: parseInt(h.slice(4, 6), 16),
        a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
      };
    }
  }

  m = s.match(/^rgba?\(([^)]+)\)$/);
  if (m) {
    const parts = m[1].split(/[\s,/]+/).filter(Boolean);
    const channel = (v) => {
      const n = v.endsWith("%") ? (parseFloat(v) / 100) * 255 : parseFloat(v);
      return Math.min(255, Math.max(0, n));
    };
    const alpha = (v) => {
      if (v === undefined) return 1;
      const n = v.endsWith("%") ? parseFloat(v) / 100 : parseFloat(v);
      return Math.min(1, Math.max(0, n));
    };
    return { r: channel(parts[0]), g: channel(parts[1]), b: channel(parts[2]), a: alpha(parts[3]) };
  }

  m = s.match(/^hsla?\(([^)]+)\)$/);
  if (m) {
    const parts = m[1].split(/[\s,/]+/).filter(Boolean);
    const h = ((parseFloat(parts[0]) % 360) + 360) % 360;
    const sat = parseFloat(parts[1]) / 100;
    const li = parseFloat(parts[2]) / 100;
    const a = parts[3] === undefined ? 1 : (parts[3].endsWith("%") ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]));
    const c = (1 - Math.abs(2 * li - 1)) * sat;
    const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
    const mm = li - c / 2;
    const seg = [[c, x, 0], [x, c, 0], [0, c, x], [0, x, c], [x, 0, c], [c, 0, x]][Math.floor(h / 60) % 6];
    return { r: (seg[0] + mm) * 255, g: (seg[1] + mm) * 255, b: (seg[2] + mm) * 255, a };
  }

  throw new Error(`Cannot parse color: ${input}`);
}

/** Composites a semi-transparent foreground color onto the background color */
export function composite(fg, bg) {
  if (fg.a >= 1) return fg;
  return {
    r: fg.r * fg.a + bg.r * (1 - fg.a),
    g: fg.g * fg.a + bg.g * (1 - fg.a),
    b: fg.b * fg.a + bg.b * (1 - fg.a),
    a: 1,
  };
}

/** WCAG 2.x relative luminance */
export function relativeLuminance({ r, g, b }) {
  const lin = (v) => {
    const c = v / 255;
    return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
  };
  return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}

/** Contrast ratio (1-21) */
export function contrastRatio(fgInput, bgInput) {
  const bg = parseColor(bgInput);
  const fg = composite(parseColor(fgInput), bg);
  const l1 = relativeLuminance(fg);
  const l2 = relativeLuminance(bg);
  const [hi, lo] = l1 >= l2 ? [l1, l2] : [l2, l1];
  return (hi + 0.05) / (lo + 0.05);
}

/** Whether this counts as large text (24px or larger, or bold at 18.66px or larger) */
export function isLargeText(size = 16, bold = false) {
  return bold ? size >= 18.66 : size >= 24;
}

export function judge({ fg, bg, size = 16, bold = false, large }) {
  const ratio = contrastRatio(fg, bg);
  const big = large === undefined ? isLargeText(size, bold) : large;
  const required = big ? 3 : 4.5;
  return {
    ratio,
    rounded: Math.floor(ratio * 100) / 100,
    large: big,
    required,
    passAA: ratio >= required,
    passAAA: ratio >= (big ? 4.5 : 7),
    passNonText: ratio >= 3, // SC 1.4.11
  };
}

function formatLine(label, r) {
  const mark = r.passAA ? "PASS" : "FAIL";
  const kind = r.large ? "large text" : "text";
  return `${mark}  ${r.rounded.toFixed(2)}:1  (${kind} / required ${r.required}:1)${label ? `  ${label}` : ""}`;
}

function main(argv) {
  if (argv.includes("--help") || argv.length === 0) {
    process.stdout.write(USAGE);
    return 0;
  }

  const jsonIndex = argv.indexOf("--json");
  if (jsonIndex !== -1) {
    const raw = argv[jsonIndex + 1];
    if (!raw) {
      process.stderr.write("--json requires a JSON array\n");
      return 1;
    }
    let items;
    try {
      items = JSON.parse(raw);
    } catch (e) {
      process.stderr.write(`Cannot parse JSON: ${e.message}\n`);
      return 1;
    }
    let failed = 0;
    for (const item of items) {
      try {
        const r = judge(item);
        if (!r.passAA) failed++;
        process.stdout.write(formatLine(item.label ?? `${item.fg} on ${item.bg}`, r) + "\n");
      } catch (e) {
        failed++;
        process.stdout.write(`ERROR ${item.label ?? ""}: ${e.message}\n`);
      }
    }
    process.stdout.write(`\n${failed} of ${items.length} combinations do not meet AA\n`);
    return failed > 0 ? 1 : 0;
  }

  const positional = [];
  let size = 16;
  let bold = false;
  let large;
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === "--size") { size = parseFloat(argv[++i]); }
    else if (a === "--bold") { bold = true; }
    else if (a === "--large") { large = true; }
    else if (a.startsWith("--")) {
      process.stderr.write(`Unknown option: ${a}\n`);
      return 1;
    } else { positional.push(a); }
  }

  if (positional.length < 2) {
    process.stderr.write("Please specify both a foreground and a background color\n\n" + USAGE);
    return 1;
  }

  let r;
  try {
    r = judge({ fg: positional[0], bg: positional[1], size, bold, large });
  } catch (e) {
    process.stderr.write(`${e.message}\n`);
    return 1;
  }

  process.stdout.write(
    `Foreground: ${positional[0]}\nBackground: ${positional[1]}\n` +
    `Contrast ratio: ${r.rounded.toFixed(2)}:1\n\n` +
    `SC 1.4.3 (AA, ${r.large ? "large text" : "text"}): ${r.passAA ? "PASS" : "FAIL"} (required ${r.required}:1)\n` +
    `SC 1.4.6 (AAA): ${r.passAAA ? "PASS" : "FAIL"}\n` +
    `SC 1.4.11 (AA, non-text): ${r.passNonText ? "PASS" : "FAIL"} (required 3:1)\n`
  );
  return r.passAA ? 0 : 1;
}

if (import.meta.url === `file://${process.argv[1]}`) {
  process.exitCode = main(process.argv.slice(2));
}