1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39const BAND = { light: [0.43, 0.77], dark: [0.48, 0.67] };
40const CHROMA_FLOOR = 0.10;
41
42
43
44
45const CVD_TARGET = 8.0, CVD_FLOOR = 6.0;
46const NORMAL_FLOOR = 15.0;
47const CONTRAST_MIN = 3.0;
48const DEFAULT_SURFACE = { light: "#fcfcfb", dark: "#1a1a19" };
49const ORDINAL_MIN_DL = 0.06;
50const ORDINAL_LIGHT_FLOOR = 2.0;
51
52
53const MACHADO = {
54 protan: [[0.152286, 1.052583, -0.204868],
55 [0.114503, 0.786281, 0.099216],
56 [-0.003882, -0.048116, 1.051998]],
57 deutan: [[0.367322, 0.860646, -0.227968],
58 [0.280085, 0.672501, 0.047413],
59 [-0.011820, 0.042940, 0.968881]],
60 tritan: [[1.255528, -0.076749, -0.178779],
61 [-0.078411, 0.930809, 0.147602],
62 [0.004733, 0.691367, 0.303900]],
63};
64
65
66const hex2srgb = (h) => { h = h.trim().replace(/^#/, ""); return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16) / 255); };
67
68
69
70
71
72
73
74
75
76
77
78const WS_RUN = "[ \\t\\n\\v\\f\\r\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000]+";
79const stripWs = (v) => v.replace(new RegExp(`^${WS_RUN}|${WS_RUN}$`, "g"), "");
80const splitColors = (raw) => (raw || "").split(",").map(stripWs).filter(Boolean);
81const isHexColor = (v) => /^#?[0-9a-fA-F]{6}$/.test(v);
82const s2lin = (c) => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
83const 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; };
84const lin = (h) => hex2srgb(h).map(s2lin);
85const relLum = (h) => { const [r, g, b] = lin(h); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
86export const contrast = (a, b) => { const [hi, lo] = [relLum(a), relLum(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05); };
87
88function oklabFromLin([r, g, b]) {
89 const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
90 const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
91 const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
92 return [
93 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
94 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
95 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
96 ];
97}
98const oklab = (h) => oklabFromLin(lin(h));
99const oklch = (h) => { const [L, a, b] = oklab(h); return [L, Math.hypot(a, b)]; };
100const okhue = (h) => { const [, a, b] = oklab(h); return ((Math.atan2(b, a) * 180 / Math.PI) % 360 + 360) % 360; };
101
102function simulate(h, kind) {
103 const [r, g, b] = lin(h), M = MACHADO[kind];
104 const clamp = (c) => Math.max(0, Math.min(1, c));
105 return [
106 clamp(M[0][0] * r + M[0][1] * g + M[0][2] * b),
107 clamp(M[1][0] * r + M[1][1] * g + M[1][2] * b),
108 clamp(M[2][0] * r + M[2][1] * g + M[2][2] * b),
109 ];
110}
111function deltaE(h1, h2, kind) {
112
113 const a = oklabFromLin(kind ? simulate(h1, kind) : lin(h1));
114 const b = oklabFromLin(kind ? simulate(h2, kind) : lin(h2));
115 return 100 * Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
116}
117
118
119export function validate(palette, { mode = "light", surface, pairs = "adjacent" } = {}) {
120 surface ??= DEFAULT_SURFACE[mode];
121 const [lo, hi] = BAND[mode];
122 const report = [];
123 let ok = true;
124
125
126 const offband = palette.filter(c => { const L = oklch(c)[0]; return L < lo || L > hi; })
127 .map(c => [c, +oklch(c)[0].toFixed(3)]);
128 if (offband.length) ok = false;
129 report.push(["Lightness band", !offband.length,
130 offband.length ? `outside band: ${JSON.stringify(offband)}` : `all ${palette.length} inside L ${lo}–${hi}`]);
131
132
133 const lowc = palette.filter(c => oklch(c)[1] < CHROMA_FLOOR).map(c => [c, +oklch(c)[1].toFixed(3)]);
134 if (lowc.length) ok = false;
135 report.push(["Chroma floor", !lowc.length,
136 lowc.length ? `below floor (reads gray): ${JSON.stringify(lowc)}` : `all ${palette.length} >= ${CHROMA_FLOOR}`]);
137
138
139 const n = palette.length;
140 const pairlist = pairs === "all"
141 ? Array.from({ length: n }, (_, i) => Array.from({ length: n - i - 1 }, (_, k) => [i, i + 1 + k])).flat()
142 : Array.from({ length: n - 1 }, (_, i) => [i, i + 1]);
143 const label = pairs === "all" ? "all-pairs" : "adjacent";
144 let worst = null;
145 for (const kind of ["protan", "deutan"]) {
146 for (const [i, j] of pairlist) {
147 const d = deltaE(palette[i], palette[j], kind);
148 if (worst === null || d < worst[0]) worst = [d, kind, palette[i], palette[j]];
149 }
150 }
151 const tri = pairlist.length ? Math.min(...pairlist.map(([i, j]) => deltaE(palette[i], palette[j], "tritan"))) : 99;
152 const wd = worst ? worst[0] : 99;
153 const cvdState = wd >= CVD_TARGET ? "pass" : wd >= CVD_FLOOR ? "floor" : "fail";
154 if (cvdState === "fail") ok = false;
155 report.push(["CVD separation", cvdState,
156 worst ? `worst ${label} ${worst[3]}↔${worst[2]} ΔE ${wd.toFixed(1)} (${worst[1]}) · tritan ${tri.toFixed(1)}` : "n/a"]);
157
158
159
160
161
162
163
164 let nworst = null;
165 for (const [i, j] of pairlist) {
166 const d = deltaE(palette[i], palette[j]);
167 if (nworst === null || d < nworst[0]) nworst = [d, palette[i], palette[j]];
168 }
169 const nd = nworst ? nworst[0] : 99;
170 const norState = nd >= NORMAL_FLOOR ? "pass" : "fail";
171 if (norState === "fail") ok = false;
172 report.push(["Normal-vision floor", norState,
173 nworst ? `worst ${label} ${nworst[2]}↔${nworst[1]} ΔE ${nd.toFixed(1)} (normal)`
174 + (nd >= NORMAL_FLOOR ? "" : ` — below ${NORMAL_FLOOR.toFixed(0)}, hard to tell apart even with full color vision`) : "n/a"]);
175
176
177 const low = palette.filter(c => contrast(c, surface) < CONTRAST_MIN).map(c => [c, +contrast(c, surface).toFixed(2)]);
178 report.push(["Contrast vs surface", low.length ? "relief" : "pass",
179 low.length ? `below ${CONTRAST_MIN}:1 — relief required (visible labels or table view): ${JSON.stringify(low)}`
180 : `all ${palette.length} >= ${CONTRAST_MIN}:1`]);
181
182 return { report, ok };
183}
184
185export function validateOrdinal(palette, { mode = "light", surface } = {}) {
186
187
188
189
190
191
192 surface ??= DEFAULT_SURFACE[mode];
193 const report = [];
194 let ok = true;
195 const Ls = palette.map(c => oklch(c)[0]);
196
197
198 const order = [...Ls.keys()].sort((a, b) => Ls[a] - Ls[b]);
199 const fwd = order.every((v, i) => v === i);
200 const rev = order.every((v, i) => v === Ls.length - 1 - i);
201 const mono = fwd || rev;
202 if (!mono) ok = false;
203 report.push(["Lightness monotone", mono,
204 mono ? "steps read light→dark" : `out of order — L values ${JSON.stringify(Ls.map(l => +l.toFixed(3)))}`]);
205
206
207 const gaps = Ls.slice(1).map((l, i) => Math.abs(l - Ls[i]));
208
209
210 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)]);
211 if (thin.length) ok = false;
212 report.push(["Adjacent ΔL", !thin.length,
213 thin.length ? `steps too close: ${JSON.stringify(thin)}` : `all gaps >= ${ORDINAL_MIN_DL}`]);
214
215
216 const byL = [...palette].sort((a, b) => oklch(a)[0] - oklch(b)[0]);
217 const lightest = mode === "light" ? byL[byL.length - 1] : byL[0];
218 const cr = contrast(lightest, surface);
219 if (cr < ORDINAL_LIGHT_FLOOR) ok = false;
220 report.push(["Light-end contrast", cr >= ORDINAL_LIGHT_FLOOR,
221 `${lightest} at ${cr.toFixed(2)}:1 vs surface` + (cr >= ORDINAL_LIGHT_FLOOR ? "" : ` — below ${ORDINAL_LIGHT_FLOOR}:1 floor`)]);
222
223
224 const hues = palette.map(okhue);
225 let spread = hues.length ? Math.max(...hues) - Math.min(...hues) : 0;
226 if (spread > 180) spread = 360 - spread;
227 const oneHue = spread <= 40;
228 if (!oneHue) ok = false;
229 report.push(["Single hue", oneHue,
230 `hue spread ${spread.toFixed(0)}°` + (oneHue ? "" : " — >40°, not a one-hue ramp")]);
231
232 return { report, ok };
233}
234
235
236const GLYPH = { true: "PASS", false: "FAIL", pass: "PASS", floor: "WARN", fail: "FAIL", relief: "WARN" };
237
238function printReport({ report, ok }, { mode, surface, ordinal, n }) {
239 const kind = ordinal ? "ordinal ramp" : "categorical";
240 console.log(`\nPalette (${mode}, surface ${surface}, ${kind}): ${n} slots`);
241 for (const [name, state, detail] of report) {
242 console.log(` [${(GLYPH[state] ?? state).padEnd(4)}] ${name.padEnd(22)} ${detail}`);
243 }
244 if (ordinal) {
245 console.log(`\n → ${ok ? "ALL CHECKS PASS" : "FAILED — fix the marked checks"}`
246 + " (ordinal: one hue, monotone L, visible step gaps, light end clears surface)");
247 } else {
248 console.log(`\n → ${ok ? "ALL CHECKS PASS" : "FAILED — fix the marked checks"}`
249 + " (CVD in the 6–8 floor band is legal ONLY with secondary encoding: direct labels, gaps, or texture)");
250 console.log(" scope: categorical palettes only. For a lone status/text color check WCAG"
251 + " text contrast; for a sequential ramp, lightness monotonicity.\n");
252 }
253}
254
255
256if (typeof process !== "undefined" && process.argv && process.argv[1] && (process.argv[1].endsWith("validate_palette.js") || process.argv[1].endsWith("validate_palette.mjs"))) {
257 const args = process.argv.slice(2);
258 const VALUE_FLAGS = new Set(["--mode", "--surface", "--pairs"]);
259 const CHOICES = { mode: ["light", "dark"], pairs: ["adjacent", "all"] };
260 const opts = {}; let positional = null;
261 for (let i = 0; i < args.length; i++) {
262 let a = args[i], val;
263 const eq = a.indexOf("="); if (eq > 0) { val = a.slice(eq + 1); a = a.slice(0, eq); }
264 if (VALUE_FLAGS.has(a)) { opts[a.slice(2)] = val ?? args[++i]; }
265 else if (a === "--ordinal") { opts.ordinal = true; }
266 else if (a.startsWith("--")) { console.error(`unknown flag: ${a}`); process.exit(2); }
267 else if (positional === null) { positional = a; }
268 else { console.error(`unexpected extra positional: ${a}`); process.exit(2); }
269 }
270 for (const [k, allowed] of Object.entries(CHOICES)) {
271 if (opts[k] != null && !allowed.includes(opts[k])) {
272 console.error(`--${k} must be one of: ${allowed.join(", ")} (got ${JSON.stringify(opts[k])})`); process.exit(2);
273 }
274 }
275 const palette = splitColors(positional);
276 if (!palette.length) { console.error("usage: node validate_palette.js \"#hex,#hex,...\" [--mode light|dark] [--surface #hex] [--pairs adjacent|all] [--ordinal]"); process.exit(2); }
277 const mode = opts.mode || "light";
278
279
280 const rawSurface = opts.surface != null ? stripWs(opts.surface) : "";
281 const surface = rawSurface || DEFAULT_SURFACE[mode];
282 const badHex = [...palette, surface].filter((c) => !isHexColor(c));
283 if (badHex.length) { console.error(`invalid hex value(s): ${badHex.join(", ")} — expected #rrggbb`); process.exit(2); }
284 const pairs = opts.pairs || "adjacent";
285 const result = opts.ordinal ? validateOrdinal(palette, { mode, surface }) : validate(palette, { mode, surface, pairs });
286 printReport(result, { mode, surface, ordinal: !!opts.ordinal, n: palette.length });
287 process.exit(result.ok ? 0 : 1);
288}
289
290
291
292if (typeof document !== "undefined") {
293 const b = document.body;
294 if (b?.dataset.palette) {
295 const palette = splitColors(b.dataset.palette);
296 const mode = b.dataset.mode || "light";
297 const pairs = b.dataset.pairs || "adjacent";
298 const rawSurface = b.dataset.surface != null ? stripWs(b.dataset.surface) : "";
299 const surface = rawSurface || DEFAULT_SURFACE[mode];
300 const ordinal = "ordinal" in b.dataset;
301
302
303
304 const badEnum = !["light", "dark"].includes(mode) ? `data-mode ${JSON.stringify(mode)}`
305 : !["adjacent", "all"].includes(pairs) ? `data-pairs ${JSON.stringify(pairs)}` : null;
306 const badHex = [...palette, surface].filter((c) => !isHexColor(c));
307 if (!palette.length || badEnum || badHex.length) {
308
309 console.warn(`validate_palette: ${!palette.length ? "empty palette" : badEnum ? `unrecognized ${badEnum}` : `invalid hex value(s): ${badHex.join(", ")} — expected #rrggbb`} — not validating`);
310 } else {
311 const result = ordinal ? validateOrdinal(palette, { mode, surface }) : validate(palette, { mode, surface, pairs });
312 console.table(result.report.map(([name, state, detail]) => ({ check: name, result: GLYPH[state] ?? state, detail })));
313 if (!result.ok) console.warn("validate_palette: FAILED — fix the marked checks");
314 }
315 }
316}
317
Discussion
1 comment on this trajectory. Recorded by @patrick-toulme.