← Files get-fableARCHIVED FILE

assets/skills/claude-code/dataviz/scripts/validate_palette.js

18.1 KB · Oct 2, 2026 · 00:30 UTC

↓ Download file

/**
 * Validate a categorical chart palette against the computable data-viz checks.
 *
 * Design-system-agnostic: feed it ANY palette's hex values plus the mode and
 * surface, and it computes — never eyeballs — the five checks that can be
 * measured from color alone:
 *
 *   2. Lightness band   — OKLCH L within the mode's band
 *   3. Chroma floor     — OKLCH C >= floor (below it a hue reads as gray)
 *   4. CVD separation   — OKLab ΔE (×100) between slots under simulated protan/deutan
 *                         (tritan reported); adjacent pairs by default, pairs:"all"
 *                         for scatter/bubble/maps
 *   4b. Normal-vision floor — worst OKLab ΔE (×100) on the active pairlist
 *       (adjacent by default; all pairs with --pairs all) under unsimulated vision;
 *                         full-color readers must be able to tell neighbors apart too
 *   5. Contrast vs surface — WCAG ratio of each mark against the chart surface
 *
 * Checks 1 (fixed hue order) and 6 (values are from the documented palette) are
 * structural rules the skill enforces, not measurable from hexes alone.
 *
 * Usage (node):
 *   node validate_palette.js "#2a78d6,#eb6834,#1baf7a,#eda100,#e87ba4,#008300,#4a3aa7,#e34948" --mode light
 *   node validate_palette.js "#256abf,#199e70,..." --mode dark --surface "#1a1a19"
 *   node validate_palette.js "#86b6ef,#5598e7,#256abf,#104281" --ordinal
 *
 * Usage (browser — as a module script):
 *   <body data-palette="#2a78d6,#eb6834,..." data-mode="light">
 *   <script type="module" src="validate_palette.js"></script>
 *   → logs a console.table of the report and console.warn on any FAIL.
 *
 * Exit code 0 unless a check hard-FAILs; 1 on any FAIL. WARN bands do not fail:
 * adjacent CVD in the 6–8 floor band, and contrast in the sub-3:1 relief band,
 * are reported as WARNs and still exit 0 (each is legal only with mandatory
 * secondary encoding: direct labels, gaps, or texture). The normal-vision floor
 * is a hard gate: a worst unsimulated pair below 15 FAILs the run.
 */

// ── thresholds ────────────────────────────────────────────────────────────────
const BAND = { light: [0.43, 0.77], dark: [0.48, 0.67] }; // OKLCH L
const CHROMA_FLOOR = 0.10; // OKLCH C
// ΔE is Euclidean distance in OKLab ×100. The CVD thresholds are calibrated to
// the Machado-Oliveira-Fernandes (2009) severity-1.0 simulation below — the sim
// model is part of the standard, not an implementation detail (swapping in e.g.
// Viénot-1999 moves borderline pairs and would require recalibrating these).
const CVD_TARGET = 8.0, CVD_FLOOR = 6.0; // OKLab ΔE×100, min(protan, deutan), adjacent pairs
const NORMAL_FLOOR = 15.0; // OKLab ΔE×100, worst pair on the active pairlist, unsimulated vision
const CONTRAST_MIN = 3.0; // WCAG vs surface
const DEFAULT_SURFACE = { light: "#fcfcfb", dark: "#1a1a19" };
const ORDINAL_MIN_DL = 0.06; // min OKLCH ΔL between adjacent steps
const ORDINAL_LIGHT_FLOOR = 2.0; // lightest step: WCAG contrast vs surface

// Machado, Oliveira & Fernandes (2009) CVD transforms at severity 1.0 (linear RGB).
const MACHADO = {
  protan: [[0.152286, 1.052583, -0.204868],
           [0.114503, 0.786281, 0.099216],
           [-0.003882, -0.048116, 1.051998]],
  deutan: [[0.367322, 0.860646, -0.227968],
           [0.280085, 0.672501, 0.047413],
           [-0.011820, 0.042940, 0.968881]],
  tritan: [[1.255528, -0.076749, -0.178779],
           [-0.078411, 0.930809, 0.147602],
           [0.004733, 0.691367, 0.303900]],
};

// ── color conversions ──────────────────────────────────────────────────────────
const hex2srgb = (h) => { h = h.trim().replace(/^#/, ""); return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16) / 255); };

// ── input boundary ── EVERY user-supplied color string (palette entries AND
// the surface, CLI and browser alike) passes these before any math:
// unguarded, parseInt propagates NaN through every check and the run fails
// OPEN. Normalization is spelled out rather than engine-native: JS trim()
// and Python str.strip() differ at the edges (trim() strips U+FEFF;
// str.strip() strips U+001C–U+001F and U+0085), so the shared set is their
// intersection — ASCII whitespace plus the Unicode space/separator
// characters both engines strip, which also covers the NBSP/em-space
// padding picked up when copy-pasting hex lists from rendered pages. Keep
// these three definitions in lockstep with the Python twin.
const WS_RUN = "[ \\t\\n\\v\\f\\r\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000]+";
const stripWs = (v) => v.replace(new RegExp(`^${WS_RUN}|${WS_RUN}$`, "g"), "");
const splitColors = (raw) => (raw || "").split(",").map(stripWs).filter(Boolean);
const isHexColor = (v) => /^#?[0-9a-fA-F]{6}$/.test(v);
const s2lin = (c) => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
const lin2s = (c) => { c = Math.max(0, Math.min(1, c)); return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055; };
const lin = (h) => hex2srgb(h).map(s2lin);
const relLum = (h) => { const [r, g, b] = lin(h); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
export const contrast = (a, b) => { const [hi, lo] = [relLum(a), relLum(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); };

function oklabFromLin([r, g, b]) {
  const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
  const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
  const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
  return [
    0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, // L
    1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, // a
    0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s, // b
  ];
}
const oklab = (h) => oklabFromLin(lin(h));
const oklch = (h) => { const [L, a, b] = oklab(h); return [L, Math.hypot(a, b)]; };
const okhue = (h) => { const [, a, b] = oklab(h); return ((Math.atan2(b, a) * 180 / Math.PI) % 360 + 360) % 360; };

function simulate(h, kind) {
  const [r, g, b] = lin(h), M = MACHADO[kind];
  const clamp = (c) => Math.max(0, Math.min(1, c));
  return [
    clamp(M[0][0] * r + M[0][1] * g + M[0][2] * b),
    clamp(M[1][0] * r + M[1][1] * g + M[1][2] * b),
    clamp(M[2][0] * r + M[2][1] * g + M[2][2] * b),
  ];
}
function deltaE(h1, h2, kind) {
  // Euclidean distance in OKLab, ×100. No kind → unsimulated (normal) vision.
  const a = oklabFromLin(kind ? simulate(h1, kind) : lin(h1));
  const b = oklabFromLin(kind ? simulate(h2, kind) : lin(h2));
  return 100 * Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
}

// ── checks ─────────────────────────────────────────────────────────────────────
export function validate(palette, { mode = "light", surface, pairs = "adjacent" } = {}) {
  surface ??= DEFAULT_SURFACE[mode];
  const [lo, hi] = BAND[mode];
  const report = [];
  let ok = true;

  // 2. lightness band
  const offband = palette.filter(c => { const L = oklch(c)[0]; return L < lo || L > hi; })
    .map(c => [c, +oklch(c)[0].toFixed(3)]);
  if (offband.length) ok = false;
  report.push(["Lightness band", !offband.length,
    offband.length ? `outside band: ${JSON.stringify(offband)}` : `all ${palette.length} inside L ${lo}–${hi}`]);

  // 3. chroma floor
  const lowc = palette.filter(c => oklch(c)[1] < CHROMA_FLOOR).map(c => [c, +oklch(c)[1].toFixed(3)]);
  if (lowc.length) ok = false;
  report.push(["Chroma floor", !lowc.length,
    lowc.length ? `below floor (reads gray): ${JSON.stringify(lowc)}` : `all ${palette.length} >= ${CHROMA_FLOOR}`]);

  // 4. CVD separation — adjacent for stacks/bars/lines; ALL pairs for scatter/bubble/maps/small-multiples
  const n = palette.length;
  const pairlist = pairs === "all"
    ? Array.from({ length: n }, (_, i) => Array.from({ length: n - i - 1 }, (_, k) => [i, i + 1 + k])).flat()
    : Array.from({ length: n - 1 }, (_, i) => [i, i + 1]);
  const label = pairs === "all" ? "all-pairs" : "adjacent";
  let worst = null;
  for (const kind of ["protan", "deutan"]) {
    for (const [i, j] of pairlist) {
      const d = deltaE(palette[i], palette[j], kind);
      if (worst === null || d < worst[0]) worst = [d, kind, palette[i], palette[j]];
    }
  }
  const tri = pairlist.length ? Math.min(...pairlist.map(([i, j]) => deltaE(palette[i], palette[j], "tritan"))) : 99;
  const wd = worst ? worst[0] : 99;
  const cvdState = wd >= CVD_TARGET ? "pass" : wd >= CVD_FLOOR ? "floor" : "fail";
  if (cvdState === "fail") ok = false;
  report.push(["CVD separation", cvdState,
    worst ? `worst ${label} ${worst[3]}↔${worst[2]} ΔE ${wd.toFixed(1)} (${worst[1]}) · tritan ${tri.toFixed(1)}` : "n/a"]);

  // 4b. Normal-vision floor. The CVD gate protects dichromat readers; this one
  //     protects everyone else — neighbors must stay easy to tell apart under
  //     unsimulated vision too. A hard gate: secondary encoding does not
  //     excuse it, and weak pairs are not masked to keep an existing palette
  //     validating (this floor forced the first of the July 2026 re-orders
  //     of the shipped set: same steps, re-ordered, clears 19.6/19.3).
  let nworst = null;
  for (const [i, j] of pairlist) {
    const d = deltaE(palette[i], palette[j]);
    if (nworst === null || d < nworst[0]) nworst = [d, palette[i], palette[j]];
  }
  const nd = nworst ? nworst[0] : 99;
  const norState = nd >= NORMAL_FLOOR ? "pass" : "fail";
  if (norState === "fail") ok = false;
  report.push(["Normal-vision floor", norState,
    nworst ? `worst ${label} ${nworst[2]}↔${nworst[1]} ΔE ${nd.toFixed(1)} (normal)`
      + (nd >= NORMAL_FLOOR ? "" : ` — below ${NORMAL_FLOOR.toFixed(0)}, hard to tell apart even with full color vision`) : "n/a"]);

  // 5. contrast vs surface — sub-3:1 is a documented conditional relax (visible labels / table view), not a hard fail
  const low = palette.filter(c => contrast(c, surface) < CONTRAST_MIN).map(c => [c, +contrast(c, surface).toFixed(2)]);
  report.push(["Contrast vs surface", low.length ? "relief" : "pass",
    low.length ? `below ${CONTRAST_MIN}:1 — relief required (visible labels or table view): ${JSON.stringify(low)}`
               : `all ${palette.length} >= ${CONTRAST_MIN}:1`]);

  return { report, ok };
}

export function validateOrdinal(palette, { mode = "light", surface } = {}) {
  /* Ordered categories (funnel stages, size tiers, time buckets rendered as
     discrete marks) take a one-hue ramp, not categorical hues. The categorical
     checks FAIL a correct ramp by design (it spans the lightness band; light
     steps drop below the chroma floor). The ordinal checks instead verify the
     ramp reads *as a ramp*: one hue, monotone lightness with visible gaps
     between steps, and a lightest step that still clears the surface. */
  surface ??= DEFAULT_SURFACE[mode];
  const report = [];
  let ok = true;
  const Ls = palette.map(c => oklch(c)[0]);

  // Monotone lightness — sorted by L must match input order (or its reverse).
  const order = [...Ls.keys()].sort((a, b) => Ls[a] - Ls[b]);
  const fwd = order.every((v, i) => v === i);
  const rev = order.every((v, i) => v === Ls.length - 1 - i);
  const mono = fwd || rev;
  if (!mono) ok = false;
  report.push(["Lightness monotone", mono,
    mono ? "steps read light→dark" : `out of order — L values ${JSON.stringify(Ls.map(l => +l.toFixed(3)))}`]);

  // Adjacent ΔL — each step must be visibly distinct from its neighbour.
  const gaps = Ls.slice(1).map((l, i) => Math.abs(l - Ls[i]));
  // Filter on the RAW gap, then round for display — filtering the rounded
  // value passes raw gaps in [0.0595, 0.06) that the Python twin fails.
  const thin = gaps.map((g, i) => [palette[i], palette[i + 1], g]).filter(([, , g]) => g < ORDINAL_MIN_DL).map(([a, b, g]) => [a, b, +g.toFixed(3)]);
  if (thin.length) ok = false;
  report.push(["Adjacent ΔL", !thin.length,
    thin.length ? `steps too close: ${JSON.stringify(thin)}` : `all gaps >= ${ORDINAL_MIN_DL}`]);

  // Lightest step vs surface — the pale end must still read as a mark.
  const byL = [...palette].sort((a, b) => oklch(a)[0] - oklch(b)[0]);
  const lightest = mode === "light" ? byL[byL.length - 1] : byL[0];
  const cr = contrast(lightest, surface);
  if (cr < ORDINAL_LIGHT_FLOOR) ok = false;
  report.push(["Light-end contrast", cr >= ORDINAL_LIGHT_FLOOR,
    `${lightest} at ${cr.toFixed(2)}:1 vs surface` + (cr >= ORDINAL_LIGHT_FLOOR ? "" : ` — below ${ORDINAL_LIGHT_FLOOR}:1 floor`)]);

  // Single hue — an ordinal ramp is one hue; a hue jump means it's categorical.
  const hues = palette.map(okhue);
  let spread = hues.length ? Math.max(...hues) - Math.min(...hues) : 0;
  if (spread > 180) spread = 360 - spread;
  const oneHue = spread <= 40;
  if (!oneHue) ok = false;
  report.push(["Single hue", oneHue,
    `hue spread ${spread.toFixed(0)}°` + (oneHue ? "" : " — >40°, not a one-hue ramp")]);

  return { report, ok };
}

// ── entrypoints ────────────────────────────────────────────────────────────────
const GLYPH = { true: "PASS", false: "FAIL", pass: "PASS", floor: "WARN", fail: "FAIL", relief: "WARN" };

function printReport({ report, ok }, { mode, surface, ordinal, n }) {
  const kind = ordinal ? "ordinal ramp" : "categorical";
  console.log(`\nPalette (${mode}, surface ${surface}, ${kind}): ${n} slots`);
  for (const [name, state, detail] of report) {
    console.log(`  [${(GLYPH[state] ?? state).padEnd(4)}] ${name.padEnd(22)} ${detail}`);
  }
  if (ordinal) {
    console.log(`\n  → ${ok ? "ALL CHECKS PASS" : "FAILED — fix the marked checks"}`
      + "  (ordinal: one hue, monotone L, visible step gaps, light end clears surface)");
  } else {
    console.log(`\n  → ${ok ? "ALL CHECKS PASS" : "FAILED — fix the marked checks"}`
      + "  (CVD in the 6–8 floor band is legal ONLY with secondary encoding: direct labels, gaps, or texture)");
    console.log("  scope: categorical palettes only. For a lone status/text color check WCAG"
      + " text contrast; for a sequential ramp, lightness monotonicity.\n");
  }
}

// Node CLI
if (typeof process !== "undefined" && process.argv && process.argv[1] && process.argv[1].endsWith("validate_palette.js")) {
  const args = process.argv.slice(2);
  const VALUE_FLAGS = new Set(["--mode", "--surface", "--pairs"]);
  const CHOICES = { mode: ["light", "dark"], pairs: ["adjacent", "all"] };
  const opts = {}; let positional = null;
  for (let i = 0; i < args.length; i++) {
    let a = args[i], val;
    const eq = a.indexOf("="); if (eq > 0) { val = a.slice(eq + 1); a = a.slice(0, eq); }
    if (VALUE_FLAGS.has(a)) { opts[a.slice(2)] = val ?? args[++i]; }
    else if (a === "--ordinal") { opts.ordinal = true; }
    else if (a.startsWith("--")) { console.error(`unknown flag: ${a}`); process.exit(2); }
    else if (positional === null) { positional = a; }
    else { console.error(`unexpected extra positional: ${a}`); process.exit(2); }
  }
  for (const [k, allowed] of Object.entries(CHOICES)) {
    if (opts[k] != null && !allowed.includes(opts[k])) {
      console.error(`--${k} must be one of: ${allowed.join(", ")} (got ${JSON.stringify(opts[k])})`); process.exit(2);
    }
  }
  const palette = splitColors(positional);
  if (!palette.length) { console.error("usage: node validate_palette.js \"#hex,#hex,...\" [--mode light|dark] [--surface #hex] [--pairs adjacent|all] [--ordinal]"); process.exit(2); }
  const mode = opts.mode || "light";
  // An empty/whitespace-only surface counts as absent (falls back to the
  // default), preserving the pre-boundary falsy behavior.
  const rawSurface = opts.surface != null ? stripWs(opts.surface) : "";
  const surface = rawSurface || DEFAULT_SURFACE[mode];
  const badHex = [...palette, surface].filter((c) => !isHexColor(c));
  if (badHex.length) { console.error(`invalid hex value(s): ${badHex.join(", ")} — expected #rrggbb`); process.exit(2); }
  const pairs = opts.pairs || "adjacent";
  const result = opts.ordinal ? validateOrdinal(palette, { mode, surface }) : validate(palette, { mode, surface, pairs });
  printReport(result, { mode, surface, ordinal: !!opts.ordinal, n: palette.length });
  process.exit(result.ok ? 0 : 1);
}

// Browser auto-run (as a <script type="module">). Fires whenever the page has a
// data-palette attribute on <body>; omit it to import the module without auto-running.
if (typeof document !== "undefined") {
  const b = document.body;
  if (b?.dataset.palette) {
    const palette = splitColors(b.dataset.palette);
    const mode = b.dataset.mode || "light";
    const pairs = b.dataset.pairs || "adjacent";
    const rawSurface = b.dataset.surface != null ? stripWs(b.dataset.surface) : "";
    const surface = rawSurface || DEFAULT_SURFACE[mode];
    const ordinal = "ordinal" in b.dataset;
    // Same input boundary as the CLI (stripWs/splitColors/isHexColor), plus
    // the CLI's enum choices: a bad data-mode otherwise throws at BAND[mode],
    // and a bad data-pairs silently downgrades to the weaker adjacent check.
    const badEnum = !["light", "dark"].includes(mode) ? `data-mode ${JSON.stringify(mode)}`
      : !["adjacent", "all"].includes(pairs) ? `data-pairs ${JSON.stringify(pairs)}` : null;
    const badHex = [...palette, surface].filter((c) => !isHexColor(c));
    if (!palette.length || badEnum || badHex.length) {
      // Module top level — no `return` here; skip validating instead.
      console.warn(`validate_palette: ${!palette.length ? "empty palette" : badEnum ? `unrecognized ${badEnum}` : `invalid hex value(s): ${badHex.join(", ")} — expected #rrggbb`} — not validating`);
    } else {
      const result = ordinal ? validateOrdinal(palette, { mode, surface }) : validate(palette, { mode, surface, pairs });
      console.table(result.report.map(([name, state, detail]) => ({ check: name, result: GLYPH[state] ?? state, detail })));
      if (!result.ok) console.warn("validate_palette: FAILED — fix the marked checks");
    }
  }
}

SHA-256: 7e1bc6840b290d773e85fd4e16c89928bf5a9535c7359c948d2affc330105fd4