Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_theme.mjs

37.5 KiB, 1 run

created by r2519314175:737, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// Verify the palettes: every one declares every token, every text rung clears
2// its contrast floor on every surface it is drawn on, and the pre-paint table in
3// index.html says the same thing as the registry in daimond.js.
4//
5// A palette is offered from a list, so nobody reads it before choosing it. That
6// makes the floor a property of the LIST and not of any one palette: a picker
7// with ten entries is ten chances to hand someone grey-on-grey. The ratios here
8// are computed from the declared hex, by the WCAG formula, not eyeballed off a
9// screenshot -- a screenshot proves one palette on one machine.
10//
11// The floors are the ones the first three palettes were tuned to, and the
12// comments in variables.css record why the muted rung has its own: it carries
13// whole explanatory paragraphs, and it was found sitting at 2.75 when nobody was
14// measuring it.
15//
16// ── Beyond the lettering ─────────────────────────────────────────────
17// Text was only ever half of it. WCAG 2.2 SC 1.4.11 asks 3:1 of the parts of a
18// control that are needed to IDENTIFY it -- the box round an input, the ring
19// that says where the keyboard is, the fill that says a filter is on -- and SC
20// 1.4.3 asks 4.5 of any state colour that is drawn as words rather than as a
21// dot. None of that was measured, so the second half of this file measures it.
22//
23// A colour that falls short today is not failed, it is RECORDED. `KNOWN` below
24// carries the ratio each shortfall stands at now; a check that is short but no
25// worse than its record prints SHORT and the suite stays green, while a check
26// that drops below its record, or that is short and has no record at all, is a
27// hard FAIL. Palette debt can therefore be paid off but never quietly added to,
28// which is the only property that matters for a picker with eleven entries.
29// `node dev/verify_theme.mjs --emit-baseline` prints the table to paste back.
30
31import fs from 'node:fs';
32import path from 'node:path';
33import { fileURLToPath } from 'node:url';
34
35const HERE = path.dirname(fileURLToPath(import.meta.url));
36const WWW = path.join(HERE, '..', 'www');
37
38const out = [];
39let bad = 0;
40const say = (ok, what) => { out.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); return ok; };
41const check = (ok, what) => { if (!say(ok, what)) bad++; };
42
43// Shortfalls that stand today, so a pre-existing debt does not fail the suite
44// while still being gated against getting worse. See the header.
45const shorts = [];
46const EMIT = process.argv.includes('--emit-baseline');
47/// How far a recorded shortfall may drift before it counts as a regression.
48/// Big enough to absorb the last digit of a hex nudge, small enough that any
49/// real darkening trips it.
50const DRIFT = 0.02;
51/// A contrast check that may be short, provided it is no shorter than recorded.
52///
53/// `id` is the stable key into KNOWN; keep it free of measured numbers so the
54/// record survives a colour being improved.
55function soft(id, r, floor, what) {
56 const line = `${what} = ${r.toFixed(2)} (floor ${floor})`;
57 if (r >= floor) { say(true, line); return true; }
58 const rec = KNOWN[id];
59 if (rec === undefined) {
60 check(false, `${line} -- a NEW shortfall, absent from the baseline`);
61 shorts.push({ id, r, floor, what, fresh: true });
62 return false;
63 }
64 if (r < rec - DRIFT) {
65 check(false, `${line} -- WORSE than the recorded ${rec.toFixed(2)}`);
66 shorts.push({ id, r, floor, what, worse: true });
67 return false;
68 }
69 out.push(`SHORT ${line}`);
70 shorts.push({ id, r, floor, what });
71 return false;
72}
73
74// ── The record of what is short today ───────────────────────────
75// Every entry is a real defect, written up in dev/contrast_report.md with a
76// suggested colour. The number is what the ratio stands at now; `soft` above
77// refuses to let it fall further, and refuses a shortfall that is not here at
78// all, so the list can only be shortened.
79const KNOWN = {
80 'amber/accent-vs-border2': 2.99,
81 'amber/border-2-vs-surface': 1.23,
82 'amber/border-vs-surface': 1.04,
83 'amber/border2-vs-border': 1.18,
84 'amber/chip-edge': 1.82,
85 'amber/chip-no-text': 4.48,
86 'amber/tag-on-vs-surface': 1.70,
87 'amber/white-on-accent-hover': 3.05,
88 'amber/white-on-danger': 2.99,
89 'amber/white-on-ok': 2.25,
90 'dark/border-2-vs-surface': 1.29,
91 'dark/border-vs-surface': 1.10,
92 'dark/border2-vs-border': 1.17,
93 'dark/chip-edge': 1.77,
94 'dark/chip-no-text': 4.48,
95 'dark/tag-on-vs-surface': 1.70,
96 'dark/white-on-accent-hover': 2.93,
97 'dark/white-on-danger': 2.93,
98 'dark/white-on-ok': 2.36,
99 'dusk/accent-ring-vs-surface': 1.91,
100 'dusk/accent-vs-border': 1.52,
101 'dusk/accent-vs-border2': 1.08,
102 'dusk/bgprimary-on-accent': 2.76,
103 'dusk/border-2-vs-surface': 1.76,
104 'dusk/border-vs-surface': 1.25,
105 'dusk/border2-vs-border': 1.40,
106 'dusk/chip-edge': 1.27,
107 'dusk/chip-no-text': 2.96,
108 'dusk/tag-on-vs-surface': 1.13,
109 'dusk/white-on-accent-hover': 3.88,
110 'dusk/white-on-danger': 2.56,
111 'dusk/white-on-ok': 2.15,
112 'forest/accent-ring-vs-surface': 2.85,
113 'forest/accent-vs-border': 2.77,
114 'forest/accent-vs-border2': 2.14,
115 'forest/bgprimary-on-accent': 3.84,
116 'forest/border-2-vs-surface': 1.32,
117 'forest/border-vs-surface': 1.03,
118 'forest/border2-vs-border': 1.29,
119 'forest/chip-edge': 1.71,
120 'forest/chip-no-text': 4.06,
121 'forest/tag-on-vs-surface': 1.54,
122 'forest/white-on-accent-hover': 3.46,
123 'forest/white-on-danger': 2.69,
124 'forest/white-on-ok': 2.28,
125 'ink-light/tag-on-vs-chip': 1.19,
126 'light/border-2-vs-surface': 1.28,
127 'light/border-vs-surface': 1.07,
128 'light/border2-vs-border': 1.20,
129 'light/chip-edge': 1.19,
130 'light/chip-no-text': 3.14,
131 'light/chip-text-hover': 4.47,
132 'light/ok-on-ok-bg': 4.47,
133 'light/warn-on-warn-bg': 4.26,
134 'linen/border-2-vs-surface': 1.35,
135 'linen/border-vs-surface': 1.04,
136 'linen/border2-vs-border': 1.29,
137 'linen/chip-edge': 1.14,
138 'linen/chip-no-text': 2.88,
139 'linen/chip-text-hover': 4.47,
140 'linen/white-on-accent-hover': 4.48,
141 'lollypop/accent-ring-vs-surface': 2.99,
142 'lollypop/accent-vs-border': 2.85,
143 'lollypop/accent-vs-border2': 2.22,
144 'lollypop/bgprimary-on-accent': 3.53,
145 'lollypop/border-2-vs-surface': 1.34,
146 'lollypop/border-vs-surface': 1.04,
147 'lollypop/border2-vs-border': 1.28,
148 'lollypop/chip-edge': 1.13,
149 'lollypop/chip-no-text': 2.90,
150 'lollypop/chip-text-hover': 4.47,
151 'lollypop/danger-on-warn-bg': 3.15,
152 'lollypop/danger-vs-surface': 2.77,
153 'lollypop/success-vs-surface': 2.09,
154 'lollypop/warn-on-warn-bg': 2.21,
155 'lollypop/warn-vs-surface': 1.95,
156 'lollypop/white-on-accent-hover': 2.96,
157 'lollypop/white-on-danger': 3.64,
158 'midnight/accent-vs-border2': 2.42,
159 'midnight/bgprimary-on-accent': 4.19,
160 'midnight/border-2-vs-surface': 1.29,
161 'midnight/border-vs-surface': 1.04,
162 'midnight/border2-vs-border': 1.24,
163 'midnight/chip-edge': 1.74,
164 'midnight/chip-no-text': 4.17,
165 'midnight/tag-on-vs-surface': 1.59,
166 'midnight/white-on-accent-hover': 3.33,
167 'midnight/white-on-danger': 2.88,
168 'midnight/white-on-ok': 2.29,
169 'mist/border-2-vs-surface': 1.30,
170 'mist/border-vs-surface': 1.04,
171 'mist/border2-vs-border': 1.24,
172 'mist/chip-edge': 1.19,
173 'mist/chip-no-text': 3.08,
174 'mist/chip-text-hover': 4.47,
175 'mist/ok-on-ok-bg': 4.44,
176 'mist/white-on-accent-hover': 3.92,
177 'motion/infinite-uncovered': 8.00,
178 'plum/border-2-vs-surface': 1.26,
179 'plum/border-vs-surface': 1.03,
180 'plum/border2-vs-border': 1.22,
181 'plum/chip-edge': 1.77,
182 'plum/chip-no-text': 4.27,
183 'plum/tag-on-vs-surface': 1.63,
184 'plum/white-on-accent-hover': 2.64,
185 'plum/white-on-danger': 2.65,
186 'plum/white-on-ok': 2.25,
187 'sage/border-2-vs-surface': 1.32,
188 'sage/border-vs-surface': 1.02,
189 'sage/border2-vs-border': 1.29,
190 'sage/chip-edge': 1.05,
191 'sage/chip-no-text': 2.65,
192 'sage/chip-text-hover': 4.47,
193 'sage/white-on-accent-hover': 4.09,
194};
195
196// ── The declared palettes ────────────────────────────────────────
197const css = fs.readFileSync(path.join(WWW, 'css', 'variables.css'), 'utf8');
198
199/// Every `:root[data-theme="x"] { ... }` block, plus the bare `:root` block,
200/// which is the dark palette's home and the source of every default.
201function blocks(src) {
202 const found = {};
203 const re = /:root(\[data-theme="([a-z]+)"\])?\s*\{([^}]*)\}/g;
204 let m;
205 while ((m = re.exec(src))) {
206 const name = m[2] || (m[1] ? null : 'dark');
207 if (!name) continue;
208 const decls = {};
209 for (const line of m[3].split(';')) {
210 const d = line.match(/(--[a-z0-9-]+)\s*:\s*([^;]+)/i);
211 if (d) decls[d[1]] = d[2].trim();
212 }
213 found[name] = Object.assign(found[name] || {}, decls);
214 }
215 return found;
216}
217const declared = blocks(css);
218
219// A palette inherits everything it does not restate from the bare :root block.
220const base = declared.dark || {};
221const NAMES = ['light', 'mist', 'linen', 'lollypop', 'sage', 'dusk', 'dark', 'amber', 'midnight', 'forest', 'plum'];
222const palette = (n) => Object.assign({}, base, declared[n] || {});
223
224/// Each palette's band and ink, read from the registry in daimond.js. Needed
225/// before the contrast checks, because whether a filled button letters in white
226/// or in the palette's own dark surface depends on the ink.
227const THEME_SPEC = {};
228{
229 const js0 = fs.readFileSync(path.join(WWW, 'js', 'daimond.js'), 'utf8');
230 const tbl = js0.match(/var THEMES = \{([\s\S]*?)\n\t\};/);
231 if (tbl) {
232 const re = /([a-z]+)\s*:\s*\{\s*tone:\s*'([a-z]+)'\s*,\s*ink:\s*'([a-z]+)'\s*\}/g;
233 let m;
234 while ((m = re.exec(tbl[1]))) THEME_SPEC[m[1]] = { tone: m[2], ink: m[3] };
235 }
236}
237
238// ── Colour ──────────────────────────────────────────────────────
239function rgb(hex) {
240 const h = hex.trim().replace('#', '');
241 if (!/^[0-9a-f]{6}$/i.test(h)) return null;
242 return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16));
243}
244/// Relative luminance, sRGB, exactly as WCAG defines it.
245function lum(c) {
246 const f = c.map(v => {
247 const s = v / 255;
248 return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
249 });
250 return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2];
251}
252function ratio(a, b) {
253 const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);
254 return (x + 0.05) / (y + 0.05);
255}
256
257// ── Every palette declares every token ──────────────────────────
258const REQUIRED = [
259 '--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-hover',
260 '--border', '--border-2',
261 '--text-primary', '--text-secondary', '--text-muted',
262 '--accent', '--accent-hover', '--accent-soft', '--accent-text',
263 '--ok', '--ok-bg', '--warn', '--warn-bg', '--danger', '--success',
264];
265for (const n of NAMES) {
266 const p = palette(n);
267 const missing = REQUIRED.filter(k => !p[k]);
268 check(missing.length === 0, `${n}: declares every token${missing.length ? ` (missing ${missing.join(', ')})` : ''}`);
269 const notHex = REQUIRED.filter(k => p[k] && !rgb(p[k]));
270 check(notHex.length === 0, `${n}: every token is a plain hex colour${notHex.length ? ` (bad: ${notHex.map(k => `${k}=${p[k]}`).join(', ')})` : ''}`);
271}
272
273// ── The floors ──────────────────────────────────────────────────
274// The three surfaces text is READ on, and --bg-hover, which it is only passed
275// over. They do not get the same floor, and the reason is not convenience: the
276// three palettes that predate the picker were tuned against the stable three
277// and sit at 3.75-4.02 on hover, so holding new palettes to a bar the shipped
278// ones never met would be an arbitrary line, while dropping the surface
279// entirely would let a palette put unreadable text under the pointer. Hover is
280// checked, one rung lower.
281const STABLE = ['--bg-primary', '--bg-secondary', '--bg-tertiary'];
282const HOVER = '--bg-hover';
283const RUNGS = [
284 ['--text-primary', 4.5],
285 ['--text-secondary', 4.5],
286 // The quiet rung, held to 4.0: it is meant to be lighter than secondary and
287 // still carries prose, so it gets its own floor rather than an exemption.
288 ['--text-muted', 4.0],
289];
290/// How far a floor is relaxed on the transient surface.
291const HOVER_DROP = 0.5;
292
293for (const n of NAMES) {
294 const p = palette(n);
295 let worst = { r: Infinity };
296 for (const [rung, floor] of RUNGS) {
297 for (const surf of STABLE.concat([HOVER])) {
298 const a = rgb(p[rung] || ''), b = rgb(p[surf] || '');
299 if (!a || !b) continue;
300 const r = ratio(a, b);
301 const f = surf === HOVER ? floor - HOVER_DROP : floor;
302 if (r < worst.r) worst = { r, rung, surf, floor: f };
303 check(r >= f,
304 `${n}: ${rung.replace('--text-', '')} on ${surf.replace('--bg-', '')} = ${r.toFixed(2)} (floor ${f})`);
305 }
306 }
307 if (worst.r < Infinity) {
308 out.push(` ${n}: worst rung ${worst.rung.replace('--text-', '')} on ${worst.surf.replace('--bg-', '')} at ${worst.r.toFixed(2)}`);
309 }
310}
311
312/// What a filled control letters in, on this palette.
313///
314/// Not always white. A filled button on a light palette is a deep accent and
315/// takes white; on a dark palette it is a light mint or salmon and takes the
316/// palette's own darkest surface, which is what `--on-fill` resolves to there.
317/// Measuring white everywhere would measure a colour the app does not paint.
318const onFill = (n) => {
319 const p = palette(n);
320 const spec = THEME_SPEC[n];
321 return (spec && spec.ink === 'light') ? rgb(p['--bg-primary'] || '') : [255, 255, 255];
322};
323
324// The accent carries button labels, and its own text rung on a surface.
325for (const n of NAMES) {
326 const p = palette(n);
327 const acc = rgb(p['--accent'] || ''), lettering = onFill(n);
328 if (acc && lettering) {
329 // 4.5, not 3.5: .tile-start is 12px and .admin-cta is 13px, so these are
330 // ordinary words and not large text.
331 check(ratio(acc, lettering) >= 4.5,
332 `${n}: the label on a filled accent button = ${ratio(acc, lettering).toFixed(2)} (floor 4.5)`);
333 }
334 const at = rgb(p['--accent-text'] || ''), bg3 = rgb(p['--bg-tertiary'] || '');
335 if (at && bg3) {
336 check(ratio(at, bg3) >= 4.0,
337 `${n}: accent-text on tertiary = ${ratio(at, bg3).toFixed(2)} (floor 4.0)`);
338 }
339}
340
341// ── The selected-chip pair is uniform ───────────────────────────
342// The whole point of the neutral chip is that it does not vary, so no palette
343// may quietly restate it.
344for (const n of NAMES) {
345 const own = declared[n] || {};
346 const restated = ['--tag-on-bg', '--tag-on-fg', '--tag-on-hover'].filter(k => own[k]);
347 check(n === 'dark' || restated.length === 0,
348 `${n}: does not restate the selected-chip colours${restated.length ? ` (${restated.join(', ')})` : ''}`);
349}
350{
351 const p = palette('dark');
352 const on = rgb(p['--tag-on-bg'] || ''), fg = rgb(p['--tag-on-fg'] || '');
353 check(!!on && !!fg && ratio(on, fg) >= 4.5,
354 `the selected chip's own lettering = ${on && fg ? ratio(on, fg).toFixed(2) : '?'} (floor 4.5)`);
355}
356
357// ── The two registries agree ────────────────────────────────────
358// index.html carries a copy of the palette table so the first paint is right;
359// a copy that drifts shows a returning user the wrong band for a frame, or
360// leaves data-ink saying the opposite of the palette actually loaded.
361const html = fs.readFileSync(path.join(WWW, 'index.html'), 'utf8');
362const js = fs.readFileSync(path.join(WWW, 'js', 'daimond.js'), 'utf8');
363
364const fromHtml = {};
365{
366 const tbl = html.match(/var P = \{([\s\S]*?)\};/);
367 if (tbl) {
368 const re = /([a-z]+)\s*:\s*\[\s*'([a-z]+)'\s*,\s*'([a-z]+)'\s*\]/g;
369 let m;
370 while ((m = re.exec(tbl[1]))) fromHtml[m[1]] = { tone: m[2], ink: m[3] };
371 }
372}
373const fromJs = {};
374{
375 const tbl = js.match(/var THEMES = \{([\s\S]*?)\n\t\};/);
376 if (tbl) {
377 const re = /([a-z]+)\s*:\s*\{\s*tone:\s*'([a-z]+)'\s*,\s*ink:\s*'([a-z]+)'\s*\}/g;
378 let m;
379 while ((m = re.exec(tbl[1]))) fromJs[m[1]] = { tone: m[2], ink: m[3] };
380 }
381}
382// Amber exists to keep short-wavelength light down, so that is a property and
383// not a matter of taste: every colour it paints must be blue-poor, or a later
384// edit "brightening it up" would quietly undo the only reason it is offered.
385{
386 const p = palette('amber');
387 const loud = [];
388 for (const k of REQUIRED) {
389 const c = rgb(p[k] || '');
390 if (!c) continue;
391 // Blue may not lead. Allowing it to equal the smaller of red and green
392 // keeps neutral greys legal and rules out anything that reads as cool.
393 if (c[2] > Math.min(c[0], c[1])) loud.push(`${k}=${p[k]} (b=${c[2]} > min(r,g)=${Math.min(c[0], c[1])})`);
394 }
395 check(loud.length === 0, `amber: no colour lets blue lead${loud.length ? `: ${loud.join(', ')}` : ''}`);
396}
397
398check(Object.keys(fromJs).length === NAMES.length,
399 `the registry in daimond.js holds all ${NAMES.length} palettes (found ${Object.keys(fromJs).length})`);
400check(Object.keys(fromHtml).length === NAMES.length,
401 `the pre-paint table in index.html holds all ${NAMES.length} palettes (found ${Object.keys(fromHtml).length})`);
402for (const n of NAMES) {
403 const a = fromJs[n], b = fromHtml[n];
404 check(!!a && !!b && a.tone === b.tone && a.ink === b.ink,
405 `${n}: the two tables agree (js=${a ? a.tone + '/' + a.ink : '-'} html=${b ? b.tone + '/' + b.ink : '-'})`);
406}
407// Every declared palette must actually exist in the stylesheet, and vice versa.
408for (const n of NAMES) check(!!declared[n], `${n}: has a palette block in variables.css`);
409for (const n of Object.keys(declared)) {
410 check(NAMES.includes(n), `variables.css declares no palette the picker cannot offer (${n})`);
411}
412
413// ── The ink axis is what the rules key on ───────────────────────
414// A rule naming a palette is a rule that a new palette silently misses, which
415// is exactly how ten palettes would ship half-styled.
416for (const file of ['app.css', 'render.css', 'guide.css']) {
417 const src = fs.readFileSync(path.join(WWW, 'css', file), 'utf8');
418 const named = [...src.matchAll(/\[data-theme="([a-z]+)"\]/g)].map(m => m[1]);
419 check(named.length === 0,
420 `${file}: no rule keys on a palette NAME${named.length ? ` (${[...new Set(named)].join(', ')})` : ''}`);
421}
422
423// ════════════════════════════════════════════════════════════════
424// Everything that is not lettering
425// ════════════════════════════════════════════════════════════════
426// Two floors, and which one applies is a question about what the colour is
427// DOING, not about what kind of token it is:
428//
429// CTRL 3.0 WCAG 2.2 SC 1.4.11, Non-text Contrast. The parts of a control
430// needed to identify it or its state -- the box round an input, the
431// ring that says where the keyboard is, the fill that says a filter
432// is on -- and any graphical object that carries meaning, such as a
433// status dot. Measured against the surface ADJACENT to it, which for
434// a border is the panel on one side and the control's own fill on
435// the other, so the worst of the surfaces is the one that counts.
436//
437// WORD 4.5 SC 1.4.3, Contrast (Minimum). A state colour stops being a dot
438// and becomes text the moment a rule says `color:`. --warn is words
439// in .spend-governor.amber and .ti-label; --ok is words in .pill.ok;
440// #fff is words on every filled button. Those are text, and text
441// does not get the non-text floor.
442//
443// Nothing here relaxes on --bg-hover the way the text rungs do. A text rung is
444// only PASSED OVER on hover; a chip, a border and a focus ring all sit on the
445// hovered surface for as long as the pointer does, and a control that vanishes
446// under the pointer is worse than one that never showed.
447const SURF = ['--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-hover'];
448const CTRL = 3.0;
449const WORD = 4.5;
450const WHITE = [255, 255, 255];
451const nm = (s) => s.replace('--bg-', '').replace(/^--/, '');
452
453/// The worst ratio a colour reaches against any of the given surfaces.
454function worstOn(col, p, surfaces = SURF) {
455 let w = { r: Infinity, surf: null };
456 if (!col) return w;
457 for (const s of surfaces) {
458 const b = rgb(p[s] || '');
459 if (!b) continue;
460 const r = ratio(col, b);
461 if (r < w.r) w = { r, surf: s };
462 }
463 return w;
464}
465
466// ── Borders ─────────────────────────────────────────────────────
467// There are two border tokens doing two different jobs, and only one of them is
468// a control's boundary.
469//
470// --border-strong IS the boundary. Every text field, select and outlined button
471// in the app now reads `border: 1px solid var(--border-strong)`, and the fill
472// inside a field is within 1.2:1 of the panel outside it on every palette, so
473// that line is the only thing saying the control is there. Squarely SC 1.4.11,
474// and therefore a HARD check: it may not be short on any palette, on any
475// surface, ever. There is no baseline entry to fall back on and there must
476// never be one -- the whole token exists because the debt was allowed to sit.
477//
478// --border and --border-2 are dividers: they separate two things that are both
479// visible without them, and they are drawn quietly on purpose. They stay
480// recorded rather than failed. --border-2 is still checked against --border
481// because where it survives as a hover promotion, a hover that changes a
482// boundary by less than 3:1 has not shown a state change.
483for (const n of NAMES) {
484 const p = palette(n);
485 const strong = rgb(p['--border-strong'] || '');
486 check(!!strong, `${n}: declares --border-strong`);
487 if (strong) {
488 const w = worstOn(strong, p);
489 check(w.r >= CTRL,
490 `${n}: --border-strong against its worst surface (${nm(w.surf)}) = ${w.r.toFixed(2)} (floor ${CTRL})`);
491 }
492 for (const tok of ['--border', '--border-2']) {
493 const w = worstOn(rgb(p[tok] || ''), p);
494 if (w.surf) soft(`${n}/${nm(tok)}-vs-surface`, w.r, CTRL,
495 `${n}: ${nm(tok)} against its worst surface (${nm(w.surf)})`);
496 }
497 const b1 = rgb(p['--border'] || ''), b2 = rgb(p['--border-2'] || '');
498 if (b1 && b2) soft(`${n}/border2-vs-border`, ratio(b1, b2), CTRL,
499 `${n}: border-2 against border (the hover promotion of a boundary)`);
500 // The field's own fill against the panel it is let into. Not a floor of its
501 // own -- the border is allowed to be the boundary -- but recorded, because a
502 // fill that is invisible is why the boundary has to carry the whole job.
503 const t = rgb(p['--bg-tertiary'] || ''), s = rgb(p['--bg-secondary'] || '');
504 if (t && s) out.push(` ${n}: an input's fill against its panel = ${ratio(t, s).toFixed(2)} (no floor; it is why --border-strong matters)`);
505}
506
507// No control may quietly go back to a divider for its boundary. The tokens are
508// only as good as the rules that use them, and the failure this catches is a new
509// input written by copying an old one.
510{
511 // Comments are stripped FIRST. Without that, the prose above a rule is part of
512 // what the selector pattern sees, and a paragraph explaining why an input sits
513 // where it does named a box that is not a control at all.
514 const css = ['app.css', 'files.css', 'mail.css', 'models.css', 'workspace.css',
515 'autoreload.css', 'mobile.css', 'render.css', 'spend.css']
516 .map((f) => fs.readFileSync(path.join(WWW, 'css', f), 'utf8'))
517 .join('\n').replace(/\/\*[\s\S]*?\*\//g, ' ');
518 const CONTROL = /\b(input|select|textarea)\b|(btn|button)\b/i;
519 const stragglers = [];
520 for (const m of css.matchAll(/([^{}]*)\{([^{}]*)\}/g)) {
521 if (!/border:\s*1px (solid|dashed) var\(--border(-2)?\)/.test(m[2])) continue;
522 const sel = m[1].trim().split('\n').pop().trim().replace(/\s+/g, ' ');
523 if (CONTROL.test(sel)) stragglers.push(sel);
524 }
525 check(stragglers.length === 0,
526 `no control takes a divider for its boundary${stragglers.length ? ' -- ' + JSON.stringify(stragglers) : ''}`);
527}
528
529// ── The accent, as the focus indicator ──────────────────────────
530// Two shapes, both of them the accent. `outline: 2px solid var(--accent)` draws
531// a ring on whatever surface the control sits on (app.css:258, workspace.css:166
532// and :218, files.css:39, models.css:134), so the ring is measured against the
533// surfaces. Every text field instead does `outline: none; border-color:
534// var(--accent)` -- the focus indicator is the SAME PIXELS as the resting
535// border, recoloured -- so what has to clear 3:1 there is accent against
536// --border, which is WCAG 2.2 SC 2.4.11 Focus Appearance: the indicator has to
537// contrast with the unfocused state of the pixels it replaces. A palette can
538// pass one of these and fail the other, so both are checked.
539for (const n of NAMES) {
540 const p = palette(n);
541 const acc = rgb(p['--accent'] || '');
542 if (!acc) continue;
543 const w = worstOn(acc, p);
544 soft(`${n}/accent-ring-vs-surface`, w.r, CTRL,
545 `${n}: the focus ring on its worst surface (${nm(w.surf)})`);
546 const b1 = rgb(p['--border'] || '');
547 if (b1) soft(`${n}/accent-vs-border`, ratio(acc, b1), CTRL,
548 `${n}: a focused field's border against its resting border`);
549 const b2 = rgb(p['--border-2'] || '');
550 if (b2) soft(`${n}/accent-vs-border2`, ratio(acc, b2), CTRL,
551 `${n}: a focused button's border against its resting border-2`);
552}
553
554// ── The selected chip, on every palette ─────────────────────────
555// One neutral pair shared by all eleven, which is the point of it -- so it has
556// to be checked ELEVEN times, once per palette, and the two questions are
557// different. Against the surface: the chip is a filled control, SC 1.4.11.
558// Against the chip it replaces: this is the state that says a filter is on, and
559// a state nobody can see is not a state. The unselected fill is generated per
560// hue, so the second question is asked at every hue the app can hash to.
561{
562 const p = palette('dark');
563 const on = rgb(p['--tag-on-bg'] || '');
564 if (on) for (const n of NAMES) {
565 const w = worstOn(on, palette(n));
566 soft(`${n}/tag-on-vs-surface`, w.r, CTRL,
567 `${n}: the selected chip against its worst surface (${nm(w.surf)})`);
568 }
569}
570
571// ── The state colours ───────────────────────────────────────────
572// Their paired *-bg first, because that pairing is only ever used one way: the
573// colour is the LETTERING and the -bg is the pill behind it (.pill.ok,
574// .spend-governor.amber, .ti-label over .turn-interrupted's --warn-bg). 4.5.
575// --danger over --warn-bg is the tripped governor, same rule.
576for (const n of NAMES) {
577 const p = palette(n);
578 const pairs = [
579 ['--ok', '--ok-bg', 'ok on its pill (.pill.ok)'],
580 ['--warn', '--warn-bg', 'warn on its pill (.spend-governor.amber)'],
581 ['--danger', '--warn-bg', 'danger on the warn pill (.spend-governor.tripped)'],
582 ];
583 for (const [fg, bg, what] of pairs) {
584 const a = rgb(p[fg] || ''), b = rgb(p[bg] || '');
585 if (a && b) soft(`${n}/${nm(fg)}-on-${nm(bg)}`, ratio(a, b), WORD, `${n}: ${what}`);
586 }
587 // The same colours as dots and rules on the main surfaces -- .astat-dot.ok,
588 // .astat-dot.warn, .ctx-fill.high, .turn-interrupted's dashed edge. Graphical
589 // objects that carry the whole meaning, so 3:1.
590 for (const tok of ['--ok', '--warn', '--danger', '--success']) {
591 const w = worstOn(rgb(p[tok] || ''), p);
592 if (w.surf) soft(`${n}/${nm(tok)}-vs-surface`, w.r, CTRL,
593 `${n}: ${nm(tok)} as a dot on its worst surface (${nm(w.surf)})`);
594 }
595 // White lettering on a filled state button. .diff-accept fills with --ok,
596 // .dlg-ok.danger and .admin-item.danger:hover and .abtn.stop:hover fill with
597 // --danger, and every primary button's HOVER fills with --accent-hover while
598 // keeping the same white label. All words, all 4.5.
599 const fills = [
600 ['--ok', 'the label on the Accept button (.diff-accept)'],
601 ['--danger', 'the label on a filled danger button (.dlg-ok.danger)'],
602 ['--accent-hover', 'the label on a primary button, hovered (.admin-cta:hover)'],
603 ];
604 const lettering = onFill(n);
605 for (const [tok, what] of fills) {
606 const c = rgb(p[tok] || '');
607 if (c && lettering) soft(`${n}/white-on-${nm(tok)}`, ratio(c, lettering), WORD, `${n}: ${what}`);
608 }
609 // The one filled control that letters in the surface colour rather than white.
610 const acc = rgb(p['--accent'] || ''), bg1 = rgb(p['--bg-primary'] || '');
611 if (acc && bg1) soft(`${n}/bgprimary-on-accent`, ratio(acc, bg1), WORD,
612 `${n}: the lit All/Any label (.tag-mode-btn.on)`);
613}
614
615// ── The generated tag chips ─────────────────────────────────────
616// These colours are in no palette. A tag's hue is hashed from its name in
617// daimond.js and handed to app.css as --tag-h; saturation and lightness come
618// from the ink axis. So the chip is a colour NOBODY DECLARED, and it is text on
619// a fill, which is 4.5 -- and it varies with the hue, because HSL lightness is
620// not luminance: at 94% lightness a yellow is far brighter than a blue.
621//
622// The hues and the formulas are read out of the source rather than restated, so
623// that adding a hue to TAG_HUES or retuning a chip is measured on the next run
624// instead of drifting away from a copy kept here.
625const appCss = fs.readFileSync(path.join(WWW, 'css', 'app.css'), 'utf8');
626const TAG_HUES = (() => {
627 const m = js.match(/var TAG_HUES\s*=\s*\[([^\]]*)\]/);
628 return m ? m[1].split(',').map(s => parseInt(s.trim(), 10)).filter(v => !Number.isNaN(v)) : [];
629})();
630check(TAG_HUES.length > 0, `the tag hues are readable from daimond.js (found ${TAG_HUES.length})`);
631
632/// sRGB for an `hsl(h s% l%)`, the way a browser resolves it.
633function hsl(h, s, l) {
634 h = ((h % 360) + 360) % 360; s /= 100; l /= 100;
635 const c = (1 - Math.abs(2 * l - 1)) * s;
636 const x = c * (1 - Math.abs((h / 60) % 2 - 1));
637 const m = l - c / 2;
638 let v;
639 if (h < 60) v = [c, x, 0]; else if (h < 120) v = [x, c, 0];
640 else if (h < 180) v = [0, c, x]; else if (h < 240) v = [0, x, c];
641 else if (h < 300) v = [x, 0, c]; else v = [c, 0, x];
642 return v.map(q => Math.round((q + m) * 255));
643}
644/// The `hsl(var(--tag-h, 0) S% L%)` triples declared by one rule in app.css.
645///
646/// THE SELECTOR MUST START ITS OWN LINE, and that is the whole of this comment.
647/// This was a bare `indexOf(selector + ' {')` until 2026-08-21, so ANY more
648/// specific rule written earlier in the file shadowed it: `.session-box-tags
649/// .tag-chip { flex: none; }` arrived at line 700 and the search stopped there,
650/// on a rule with no colour in it at all. Every formula then read null and the
651/// whole tag-chip section of this file went dark -- reported as "the formulas are
652/// not readable", which sounds like a stylesheet fault and was a parser fault.
653/// Anchoring to a line start makes a shadowing rule impossible rather than
654/// unlikely: a nested selector cannot begin a line with the bare one.
655function chipRule(selector) {
656 const at = appCss.indexOf('\n' + selector + ' {');
657 const i = at < 0 ? -1 : at + 1;
658 if (i < 0) return null;
659 const body = appCss.slice(i, appCss.indexOf('}', i));
660 const grab = (prop) => {
661 const m = body.match(new RegExp(prop + ':[^;]*?hsl\\(var\\(--tag-h[^)]*\\)\\s*(\\d+)%\\s*(\\d+)%'));
662 return m ? [Number(m[1]), Number(m[2])] : null;
663 };
664 return { bg: grab('background'), fg: grab('color'), bd: grab('border(?:-color)?') };
665}
666const CHIP = {
667 light: { rest: chipRule('.tag-chip'), hover: chipRule('button.tag-chip:hover'), no: chipRule('.tag-no') },
668 dark: { rest: chipRule(':root[data-ink="dark"] .tag-chip'), hover: chipRule(':root[data-ink="dark"] button.tag-chip:hover'), no: chipRule(':root[data-ink="dark"] .tag-no') },
669};
670check(!!(CHIP.light.rest && CHIP.light.rest.bg && CHIP.light.rest.fg && CHIP.dark.rest && CHIP.dark.rest.bg && CHIP.dark.rest.fg),
671 'the tag chip formulas are readable from app.css');
672
673// The ink each palette takes, from the registry already parsed above -- not a
674// second copy of the table, which is the thing this file exists to prevent.
675const inkOf = (n) => (fromJs[n] || {}).ink;
676for (const n of NAMES) {
677 const p = palette(n), ink = inkOf(n), c = CHIP[ink];
678 if (!c || !c.rest || !c.rest.bg || !c.rest.fg) continue;
679 // The chip's own lettering, at every hue. 4.5: it is the tag's name.
680 let wt = { r: Infinity }, wh = { r: Infinity }, wb = { r: Infinity }, wn = { r: Infinity };
681 for (const h of TAG_HUES) {
682 const bg = hsl(h, ...c.rest.bg), fg = hsl(h, ...c.rest.fg);
683 const r = ratio(fg, bg);
684 if (r < wt.r) wt = { r, h };
685 if (c.hover && c.hover.bg) {
686 const rh = ratio(fg, hsl(h, ...c.hover.bg));
687 if (rh < wh.r) wh = { r: rh, h };
688 }
689 // The chip against the panel. A chip is identifiable if EITHER its fill or
690 // its edge is far enough from the surface -- it does not need both -- so
691 // the better of the two is what has to clear 3:1, and a chip that clears
692 // it on neither is a control nobody can see the extent of.
693 {
694 const surfs = ['--bg-secondary', '--bg-tertiary'];
695 const fw = worstOn(bg, p, surfs);
696 const bw = c.rest.bd ? worstOn(hsl(h, ...c.rest.bd), p, surfs) : { r: 0, surf: fw.surf };
697 const best = fw.r >= bw.r ? fw : bw;
698 if (best.r < wb.r) wb = { r: best.r, h, surf: best.surf };
699 }
700 // The REFUSED chip has no fill at all -- `background: transparent` -- so
701 // its label is drawn straight onto the surface. Text, 4.5.
702 if (c.no && c.no.fg) {
703 const w = worstOn(hsl(h, ...c.no.fg), p);
704 if (w.r < wn.r) wn = { r: w.r, h, surf: w.surf };
705 }
706 }
707 if (wt.r < Infinity) soft(`${n}/chip-text`, wt.r, WORD, `${n}: a tag chip's name, worst hue (${wt.h})`);
708 if (wh.r < Infinity) soft(`${n}/chip-text-hover`, wh.r, WORD, `${n}: a tag chip's name while hovered, worst hue (${wh.h})`);
709 if (wb.r < Infinity) soft(`${n}/chip-edge`, wb.r, CTRL, `${n}: a tag chip's extent against the panel, worst hue (${wb.h})`);
710 if (wn.r < Infinity) soft(`${n}/chip-no-text`, wn.r, WORD, `${n}: a REFUSED tag's name on ${nm(wn.surf)}, worst hue (${wn.h})`);
711}
712// The selected chip against the chip it replaces. The fill is the only thing
713// that changes, so this ratio IS the state change, and it is asked per hue and
714// per ink because the fill it replaces is generated.
715{
716 const on = rgb(palette('dark')['--tag-on-bg'] || '');
717 for (const ink of ['light', 'dark']) {
718 const c = CHIP[ink];
719 if (!on || !c || !c.rest || !c.rest.bg) continue;
720 let w = { r: Infinity };
721 for (const h of TAG_HUES) {
722 const r = ratio(on, hsl(h, ...c.rest.bg));
723 if (r < w.r) w = { r, h };
724 }
725 soft(`ink-${ink}/tag-on-vs-chip`, w.r, CTRL,
726 `ink=${ink}: a selected chip against the unselected one, worst hue (${w.h})`);
727 }
728}
729
730// ── Reduced motion ──────────────────────────────────────────────
731// Not a contrast question, but the same kind of question: something the
732// stylesheets do that nobody was checking. Every rule that moves has to have a
733// `@media (prefers-reduced-motion: reduce)` answer somewhere in the same file,
734// and an INFINITE animation is the one that cannot be argued away -- a 0.15s
735// transition ends, a `2.2s infinite` pulse never does, and SC 2.2.2 asks that
736// anything moving for more than five seconds can be stopped.
737{
738 const MOTION = fs.readdirSync(path.join(WWW, 'css')).filter(f => f.endsWith('.css'));
739 const infinite = [];
740 for (const f of MOTION) {
741 const src = fs.readFileSync(path.join(WWW, 'css', f), 'utf8');
742 const rm = src.includes('prefers-reduced-motion');
743 // Which animations does that file's reduce block actually turn off?
744 const stopped = new Set();
745 for (const m of src.matchAll(/@media\s*\(prefers-reduced-motion:\s*reduce\)\s*\{([\s\S]*?)\n\t*\}/g)) {
746 for (const s of m[1].matchAll(/([^{}]+)\{[^{}]*(?:animation|transition)\s*:\s*none/g)) {
747 for (const sel of s[1].split(',')) stopped.add(sel.trim());
748 }
749 }
750 const lines = src.split('\n');
751 lines.forEach((line, i) => {
752 if (!/animation[^:]*:\s*[^;]*infinite/.test(line)) return;
753 // The selector is what is left of the brace on the same line for a
754 // one-line rule, and otherwise the nearest line above that opens a
755 // block -- stopping at the previous rule's close, so a declaration
756 // buried in a multi-line rule still finds its own selector.
757 let sel = '';
758 if (line.includes('{')) sel = line.split('{')[0].trim();
759 else for (let k = i - 1; k >= 0; k--) {
760 if (lines[k].includes('}')) break;
761 if (lines[k].includes('{')) { sel = lines[k].split('{')[0].trim(); break; }
762 }
763 const covered = [...stopped].some(s => s === sel || sel.startsWith(s) || s.startsWith(sel));
764 if (!covered) infinite.push(`${f}:${i + 1} ${sel || '(?)'}`);
765 });
766 if (!rm) {
767 const moves = (src.match(/transition\s*:/g) || []).length + (src.match(/animation\s*:/g) || []).length;
768 if (moves) out.push(` ${f}: ${moves} moving rule(s), no prefers-reduced-motion block`);
769 }
770 }
771 // Scored as a countdown from ten rather than a ratio, so the same gate does
772 // the same job: covering one more animation raises the score and covering
773 // them all clears the floor, while ADDING an uncovered one drops below the
774 // record and fails outright.
775 soft('motion/infinite-uncovered', 10 - infinite.length, 10,
776 `every infinite animation has a prefers-reduced-motion answer${infinite.length ? ` (uncovered: ${infinite.join('; ')})` : ''}`);
777}
778
779
780console.log(out.join('\n'));
781if (shorts.length) {
782 console.log(`\n── short of the floor, and recorded (${shorts.length}) ──`);
783 for (const s of shorts) console.log(` ${s.what} = ${s.r.toFixed(2)} < ${s.floor}${s.fresh ? ' [NEW]' : ''}${s.worse ? ' [WORSE]' : ''}`);
784 console.log(' See dev/contrast_report.md for the suggested colours.');
785}
786if (EMIT) {
787 console.log('\n── baseline to paste into KNOWN ──');
788 for (const s of shorts.slice().sort((a, b) => a.id.localeCompare(b.id))) {
789 console.log(`\t'${s.id}': ${(Math.floor(s.r * 100) / 100).toFixed(2)},`);
790 }
791}
792console.log(bad ? `\n${bad} FAILED` : `\nALL PASS (${out.filter(l => l.startsWith('PASS')).length} checks, ${shorts.length} short and recorded)`);
793process.exit(bad ? 1 : 0);