Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_contrast_ui.mjs

21.2 KiB, 1 run

created by r2519314175:317, 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, from the pixels the browser actually paints, the parts of the palette
2// that the declared hex cannot settle.
3//
4// dev/verify_theme.mjs measures colours out of variables.css by the WCAG
5// formula, which is the right way to hold eleven palettes to a floor: it is
6// exhaustive, it needs no browser, and it cannot be fooled by a screenshot
7// taken on one machine. But three things are not decidable from the file:
8//
9// The focus indicator. Every text field in the app does `outline: none;
10// border-color: var(--accent)`, so the indicator is not a new mark, it is the
11// RESTING BORDER recoloured. Whether that reads as focus is a question about
12// the pixels in one strip of screen changing enough, which is what WCAG 2.2
13// SC 2.4.11 asks, and it is measured here by photographing that strip twice.
14//
15// A disabled control. `opacity: .5` is not a colour; it is a compositing
16// instruction, and what it yields depends on what is behind it. Reading the
17// declared fill tells you nothing about whether the button still looks like a
18// button, or whether "off" is visible at all. Disabled controls are exempt
19// from the contrast floors, which is exactly why nothing else checks them.
20//
21// The tag chips. Their colours are in no palette: the hue is hashed from the
22// tag's name in daimond.js and app.css builds `hsl(var(--tag-h) S% L%)` from
23// it. verify_theme models that arithmetic; here the browser does it, and the
24// two are compared. A model that agrees with the engine it is modelling is
25// worth something; one that has never been checked against it is not.
26//
27// Run: node dev/verify_contrast_ui.mjs (needs dev/serve.mjs -- DAIMOND_PORT,
28// default 8777)
29
30import fs from 'node:fs';
31import path from 'node:path';
32import { fileURLToPath } from 'node:url';
33import { open } from './harness.mjs';
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++; };
42const note = (what) => out.push(` ${what}`);
43// Shortfalls that dev/verify_theme.mjs already records against the declared
44// hex. Repeating them as failures here would only make one defect fail twice;
45// what matters is that the RENDERED number is no worse than the recorded one.
46// Same gate as verify_theme's: short but no worse prints SHORT, short and worse
47// (or short and unrecorded) is a hard FAIL, so a palette can improve but never
48// quietly slip. `--emit-baseline` prints the table to paste back.
49const shorts = [];
50const EMIT = process.argv.includes('--emit-baseline');
51const DRIFT = 0.05; // looser than the static gate: these are sampled pixels
52function soft(id, r, floor, what) {
53 const line = `${what} = ${r.toFixed(2)} (floor ${floor})`;
54 if (r >= floor) { say(true, line); return true; }
55 const rec = KNOWN[id];
56 if (rec === undefined) { check(false, `${line} -- a NEW shortfall, absent from the baseline`); shorts.push({ id, r, line }); return false; }
57 if (r < rec - DRIFT) { check(false, `${line} -- WORSE than the recorded ${rec.toFixed(2)}`); shorts.push({ id, r, line }); return false; }
58 out.push(`SHORT ${line}`);
59 shorts.push({ id, r, line });
60 return false;
61}
62/// What each rendered shortfall stands at now. Every one of these is written up
63/// in dev/contrast_report.md; the static twin of each is in verify_theme.mjs.
64const KNOWN = {
65 'amber/chip-no-text': 4.48,
66 'dark/chip-no-text': 4.48,
67 'dusk/chip-no-text': 2.96,
68 'dusk/field-focus': 1.52,
69 'dusk/outline-ring': 2.47,
70 'forest/chip-no-text': 4.06,
71 'forest/field-focus': 2.77,
72 'light/chip-no-text': 3.14,
73 'linen/chip-no-text': 2.88,
74 'lollypop/chip-no-text': 2.90,
75 'lollypop/field-focus': 2.85,
76 'midnight/chip-no-text': 4.17,
77 'mist/chip-no-text': 3.08,
78 'plum/chip-no-text': 4.27,
79 'sage/chip-no-text': 2.65,
80};
81
82// ── Colour, the same arithmetic verify_theme uses ───────────────
83function lum(c) {
84 const f = c.map(v => {
85 const s = v / 255;
86 return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
87 });
88 return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2];
89}
90function ratio(a, b) {
91 const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p);
92 return (x + 0.05) / (y + 0.05);
93}
94/// sRGB out of whatever `getComputedStyle` handed back -- `rgb(r, g, b)` or
95/// `rgba(r, g, b, a)`, which is every form Chromium resolves a colour to.
96function parse(css) {
97 const m = String(css).match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
98 return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
99}
100
101// ── The floors ──────────────────────────────────────────────────
102/// WCAG 2.2 SC 2.4.11, Focus Appearance: the focus indicator has to reach 3:1
103/// against the same pixels in their unfocused state. Where the indicator IS the
104/// resting border recoloured, that is the whole test.
105const RING = 3.0;
106/// SC 1.4.3 for the chips: a tag's name is text.
107const WORD = 4.5;
108/// A disabled control is EXEMPT from the contrast floors, so this is not one.
109/// It is the weaker question the exemption leaves open -- can you tell it is
110/// off? -- and 1.2:1 is about where a flat fill stops reading as the same
111/// colour as its neighbour. Anything at 1.0 is the bug this catches: a disabled
112/// rule that some later selector has already overridden.
113const OFF = 1.2;
114const NAMES = ['light', 'mist', 'linen', 'lollypop', 'sage', 'dusk', 'dark', 'amber', 'midnight', 'forest', 'plum'];
115
116/// The pixels inside a clip rectangle, as the browser painted them.
117///
118/// The screenshot comes back as PNG bytes, and the page decodes them: Node has
119/// no PNG decoder, and the browser that drew the pixels is the least arguable
120/// thing to read them back with.
121async function pixels(page, clip) {
122 const buf = await page.screenshot({ clip });
123 return page.evaluate(async (b64) => {
124 const res = await fetch('data:image/png;base64,' + b64);
125 const bm = await createImageBitmap(await res.blob());
126 const cv = new OffscreenCanvas(bm.width, bm.height);
127 const ctx = cv.getContext('2d');
128 ctx.drawImage(bm, 0, 0);
129 const d = ctx.getImageData(0, 0, bm.width, bm.height).data;
130 const px = [];
131 for (let i = 0; i < d.length; i += 4) px.push([d[i], d[i + 1], d[i + 2]]);
132 return px;
133 }, buf.toString('base64'));
134}
135
136const s = await open({ name: 'contrast-ui', signIn: false, connect: false });
137const { page } = s;
138
139/// Put the app in one palette, through the app's own service.
140async function wear(theme) {
141 await page.evaluate(t => window.DaimondTheme.set(t), theme);
142 await page.waitForTimeout(120);
143}
144
145// ════════════════════════════════════════════════════════════════
146// 1. The focus indicator, photographed
147// ════════════════════════════════════════════════════════════════
148// A real field on a real screen: the passphrase box on the identity gate, which
149// carries `.id-fields input:focus { outline: none; border-color: var(--accent) }`
150// -- the pattern every other field in the app repeats. The strip is four pixels
151// either side of the field's left edge, so it holds the border, some of the
152// panel outside it and some of the fill inside it. Photograph it blurred,
153// photograph it focused, and the largest change between corresponding pixels is
154// the indicator's contrast.
155await page.waitForSelector('#id-pass', { timeout: 15000 });
156for (const theme of NAMES) {
157 await wear(theme);
158 const box = await page.locator('#id-pass').boundingBox();
159 if (!box) { check(false, `${theme}: the passphrase field is on screen`); continue; }
160 const clip = { x: Math.round(box.x) - 4, y: Math.round(box.y + box.height / 2) - 3, width: 9, height: 6 };
161
162 await page.evaluate(() => document.activeElement && document.activeElement.blur());
163 await page.waitForTimeout(150);
164 const before = await pixels(page, clip);
165
166 await page.focus('#id-pass');
167 await page.waitForTimeout(150);
168 const after = await pixels(page, clip);
169
170 let best = 0;
171 for (let i = 0; i < Math.min(before.length, after.length); i++) {
172 const r = ratio(before[i], after[i]);
173 if (r > best) best = r;
174 }
175 soft(`${theme}/field-focus`, best, RING,
176 `${theme}: a focused field's edge against its own unfocused pixels`);
177}
178await page.evaluate(() => document.activeElement && document.activeElement.blur());
179
180// The other shape the ring takes: `outline: 2px solid var(--accent)`, drawn on
181// the surface rather than replacing anything (app.css:258, workspace.css:166,
182// files.css:39, models.css:134). A probe carrying the real `.admin-item` class,
183// focused from the keyboard so `:focus-visible` genuinely matches, then
184// photographed against the surface it sits on. Rendered, not declared: this is
185// what catches a ring that a later rule has already painted over.
186for (const theme of NAMES) {
187 await wear(theme);
188 await page.evaluate(() => {
189 let host = document.getElementById('ring-host');
190 if (!host) {
191 // The ring is drawn 2px OUTSIDE the button, so what it lands on is the
192 // button's surroundings, not the button. The probe therefore needs a
193 // panel around it: on the bare page it would sit on the identity
194 // modal's scrim, and the measurement would be of the scrim.
195 host = document.createElement('div');
196 host.id = 'ring-host';
197 host.style.cssText = 'position:fixed;left:40px;top:200px;padding:14px;z-index:99999;background:var(--bg-secondary);';
198 const p = document.createElement('button');
199 p.id = 'ring-probe';
200 p.className = 'admin-item';
201 p.textContent = 'probe';
202 p.style.width = '120px';
203 host.appendChild(p);
204 document.body.appendChild(host);
205 }
206 document.getElementById('ring-probe').blur();
207 });
208 await page.waitForTimeout(100);
209 const box = await page.locator('#ring-probe').boundingBox();
210 const clip = { x: Math.round(box.x) - 5, y: Math.round(box.y + box.height / 2) - 3, width: 8, height: 6 };
211 const before = await pixels(page, clip);
212 const visible = await page.evaluate(() => {
213 const p = document.getElementById('ring-probe');
214 p.focus();
215 return p.matches(':focus-visible');
216 });
217 await page.waitForTimeout(120);
218 const after = await pixels(page, clip);
219 let best = 0;
220 for (let i = 0; i < Math.min(before.length, after.length); i++) {
221 const r = ratio(before[i], after[i]);
222 if (r > best) best = r;
223 }
224 check(visible, `${theme}: the probe really takes :focus-visible (or the ring below means nothing)`);
225 soft(`${theme}/outline-ring`, best, RING,
226 `${theme}: the outline ring against the panel it is drawn on`);
227}
228await page.evaluate(() => { const h = document.getElementById('ring-host'); if (h) h.remove(); });
229
230// ════════════════════════════════════════════════════════════════
231// 2. A disabled control still says it is off
232// ════════════════════════════════════════════════════════════════
233// The gate's own primary button, which is genuinely disabled until the user
234// says they have written the generated passphrase down (`#id-wrote`), so this
235// is the app's real disabled state and not a class pinned on for the test.
236// `#id-primary:disabled { opacity: .5 }` composites against whatever is behind
237// it, so the answer is a photograph, not a declaration.
238//
239// A PHOTOGRAPH OF AN ELEMENT NOBODY CAN SEE IS NOT EVIDENCE. `.modal-card` scrolls
240// (`overflow-y: auto`), and on 2026-08-14 the identity card gained a 160px strip of
241// front-door links: content went to 1108px inside an 872px box and the primary
242// button dropped to y=937 with the card's visible edge at 912. Its bounding box was
243// still returned, the clip was still inside the viewport, and both photographs came
244// back as the modal backdrop -- identical, so the ratio was exactly 1.00 in all
245// eleven palettes and read as a contrast failure that no colour could have fixed.
246// So the button is scrolled to before it is photographed, the way a user would
247// reach it, and `paintedAt` asks the page whether the pixel about to be sampled
248// really belongs to the button. That turns "off screen" into its own named red
249// instead of a wrong answer about contrast.
250/// Is the element under this viewport point the button itself?
251const paintedAt = (page, x, y) => page.evaluate(([px, py]) => {
252 const b = document.getElementById('id-primary');
253 if (!b) return 'no #id-primary';
254 const hit = document.elementFromPoint(px, py);
255 if (!hit) return 'nothing is painted there -- the point is outside the viewport';
256 if (hit === b || b.contains(hit)) return '';
257 return `${hit.tagName.toLowerCase()}${hit.id ? '#' + hit.id : ''} is drawn over it`;
258}, [x, y]);
259{
260 const wrote = await page.$('#id-wrote');
261 if (!wrote || !(await wrote.isVisible())) {
262 note('the create form is not showing, so the disabled button was not measured');
263 } else {
264 for (const theme of NAMES) {
265 await wear(theme);
266 await page.evaluate(() => {
267 const w = document.getElementById('id-wrote');
268 if (w.checked) { w.checked = false; w.dispatchEvent(new Event('change', { bubbles: true })); }
269 });
270 await page.locator('#id-primary').scrollIntoViewIfNeeded();
271 await page.waitForTimeout(150);
272 const off = await page.evaluate(() => (document.getElementById('id-primary') || {}).disabled);
273 const box = await page.locator('#id-primary').boundingBox();
274 const clip = { x: Math.round(box.x + box.width / 2) - 3, y: Math.round(box.y + 4), width: 6, height: 4 };
275 const why = await paintedAt(page, clip.x + 3, clip.y + 1);
276 const dis = await pixels(page, clip);
277
278 await page.evaluate(() => {
279 const w = document.getElementById('id-wrote');
280 w.checked = true; w.dispatchEvent(new Event('change', { bubbles: true }));
281 });
282 await page.waitForTimeout(150);
283 const ena = await pixels(page, clip);
284
285 let best = 0;
286 for (let i = 0; i < Math.min(dis.length, ena.length); i++) {
287 const r = ratio(dis[i], ena[i]);
288 if (r > best) best = r;
289 }
290 check(off, `${theme}: the create button is genuinely disabled before the passphrase is acknowledged`);
291 check(why === '', `${theme}: and the button is really on screen to be photographed`
292 + (why ? ` -- ${why}` : ''));
293 if (why === '') {
294 soft(`${theme}/disabled`, best, OFF,
295 `${theme}: a disabled button against its enabled self`);
296 }
297 }
298 }
299}
300
301// ════════════════════════════════════════════════════════════════
302// 3. The generated tag chips
303// ════════════════════════════════════════════════════════════════
304// One real `.tag-chip` per hue, in the real page, with the real stylesheet
305// resolving `hsl(var(--tag-h) S% L%)`. The hues come from daimond.js, so a hue
306// added there is measured here on the next run.
307const js = fs.readFileSync(path.join(WWW, 'js', 'daimond.js'), 'utf8');
308const TAG_HUES = (() => {
309 const m = js.match(/var TAG_HUES\s*=\s*\[([^\]]*)\]/);
310 return m ? m[1].split(',').map(v => parseInt(v.trim(), 10)).filter(v => !Number.isNaN(v)) : [];
311})();
312check(TAG_HUES.length > 0, `the tag hues are readable from daimond.js (found ${TAG_HUES.length})`);
313
314/// The same hsl() arithmetic verify_theme.mjs uses, restated here only so the
315/// browser has something to be compared AGAINST.
316function hsl(h, sat, li) {
317 h = ((h % 360) + 360) % 360; sat /= 100; li /= 100;
318 const c = (1 - Math.abs(2 * li - 1)) * sat;
319 const x = c * (1 - Math.abs((h / 60) % 2 - 1));
320 const m = li - c / 2;
321 let v;
322 if (h < 60) v = [c, x, 0]; else if (h < 120) v = [x, c, 0];
323 else if (h < 180) v = [0, c, x]; else if (h < 240) v = [0, x, c];
324 else if (h < 300) v = [x, 0, c]; else v = [c, 0, x];
325 return v.map(q => Math.round((q + m) * 255));
326}
327
328for (const theme of NAMES) {
329 await wear(theme);
330 const rows = await page.evaluate((hues) => {
331 let host = document.getElementById('chip-probe');
332 if (host) host.remove();
333 host = document.createElement('div');
334 host.id = 'chip-probe';
335 host.style.cssText = 'position:fixed;left:0;top:0;z-index:99999;background:var(--bg-secondary);';
336 document.body.appendChild(host);
337 const seen = [];
338 for (const h of hues) {
339 const el = document.createElement('span');
340 el.className = 'tag-chip';
341 el.textContent = 'tag';
342 el.style.setProperty('--tag-h', String(h));
343 host.appendChild(el);
344 const cs = getComputedStyle(el);
345 // The refused chip and the selected chip are the same element wearing
346 // another class, so they are read off the same probe.
347 el.classList.add('tag-no');
348 const csNo = getComputedStyle(el);
349 const no = csNo.color;
350 el.classList.remove('tag-no');
351 el.classList.add('tag-inc');
352 const csOn = getComputedStyle(el);
353 const on = { bg: csOn.backgroundColor, fg: csOn.color };
354 el.classList.remove('tag-inc');
355 seen.push({ h, bg: cs.backgroundColor, fg: cs.color, bd: cs.borderTopColor, no, on });
356 }
357 const root = getComputedStyle(document.documentElement);
358 const surf = {
359 secondary: root.getPropertyValue('--bg-secondary').trim(),
360 tertiary: root.getPropertyValue('--bg-tertiary').trim(),
361 hover: root.getPropertyValue('--bg-hover').trim(),
362 };
363 host.remove();
364 return { seen, surf };
365 }, TAG_HUES);
366
367 let worst = { r: Infinity }, worstOn = { r: Infinity }, worstNo = { r: Infinity };
368 for (const row of rows.seen) {
369 const fg = parse(row.fg), bg = parse(row.bg);
370 if (!fg || !bg) { check(false, `${theme}: hue ${row.h} resolves to a colour`); continue; }
371 const r = ratio(fg, bg);
372 if (r < worst.r) worst = { r, h: row.h };
373 // The selected chip, as the browser composites it -- the same neutral pair
374 // for every hue, which is the claim the token was introduced to make.
375 const onFg = parse(row.on.fg), onBg = parse(row.on.bg);
376 if (onFg && onBg) {
377 const ro = ratio(onFg, onBg);
378 if (ro < worstOn.r) worstOn = { r: ro, h: row.h };
379 }
380 // A refused chip has no fill, so its label lands on the panel.
381 const noFg = parse(row.no);
382 if (noFg) {
383 for (const k of ['secondary', 'tertiary', 'hover']) {
384 const hex = rows.surf[k].replace('#', '');
385 const sc = [0, 2, 4].map(i => parseInt(hex.slice(i, i + 2), 16));
386 const rn = ratio(noFg, sc);
387 if (rn < worstNo.r) worstNo = { r: rn, h: row.h, surf: k };
388 }
389 }
390 }
391 // 4.5, because a chip carries the tag's NAME.
392 soft(`${theme}/chip-text`, worst.r, WORD,
393 `${theme}: a rendered tag chip's name, worst hue (${worst.h})`);
394 check(worstOn.r >= WORD,
395 `${theme}: a rendered SELECTED chip's lettering, worst hue (${worstOn.h}) = ${worstOn.r.toFixed(2)} (floor ${WORD})`);
396 if (worstNo.r < Infinity) {
397 soft(`${theme}/chip-no-text`, worstNo.r, WORD,
398 `${theme}: a rendered REFUSED chip's name on ${worstNo.surf}, worst hue (${worstNo.h})`);
399 }
400
401 // The engine against the model. verify_theme.mjs computes these colours
402 // itself, from the same S% and L% in app.css; if its arithmetic and
403 // Chromium's disagree, every ratio it reports is off by that much and nobody
404 // would know. Only the resting chip is compared -- one rule is enough to
405 // catch a wrong formula.
406 const ink = await page.evaluate(() => document.documentElement.getAttribute('data-ink'));
407 const app = fs.readFileSync(path.join(WWW, 'css', 'app.css'), 'utf8');
408 const sel = ink === 'dark' ? ':root[data-ink="dark"] .tag-chip' : '.tag-chip';
409 const body = app.slice(app.indexOf(sel + ' {'), app.indexOf('}', app.indexOf(sel + ' {')));
410 const m = body.match(/color:[^;]*?hsl\(var\(--tag-h[^)]*\)\s*(\d+)%\s*(\d+)%/);
411 if (m) {
412 let drift = 0, at = null;
413 for (const row of rows.seen) {
414 const model = hsl(row.h, Number(m[1]), Number(m[2]));
415 const real = parse(row.fg);
416 const d = Math.max(...model.map((v, i) => Math.abs(v - real[i])));
417 if (d > drift) { drift = d; at = row.h; }
418 }
419 check(drift <= 1,
420 `${theme}: verify_theme's hsl() matches Chromium's, worst by ${drift}/255${at === null ? '' : ` (hue ${at})`}`);
421 }
422}
423
424// Console errors are part of the result: a page that throws while being
425// measured was not in the state the measurement assumed.
426// 502s are the dev server saying there is no gateway behind it, which is how
427// this environment is meant to be run; they are not the page failing.
428const noise = [/favicon/i, /net::ERR_ABORTED/i, /502/, /Bad Gateway/i, /Failed to load resource/i];
429const errs = s.errs.filter(e => !noise.some(r => r.test(e)));
430check(errs.length === 0, `no console errors while measuring${errs.length ? `: ${errs.slice(0, 3).join(' | ')}` : ''}`);
431
432await s.close();
433
434console.log(out.join('\n'));
435if (shorts.length) {
436 console.log(`\n── short of the floor, and recorded (${shorts.length}) ──`);
437 for (const w of shorts) console.log(` ${w.line}`);
438 console.log(' See dev/contrast_report.md for the suggested colours.');
439}
440if (EMIT) {
441 console.log('\n── baseline to paste into KNOWN ──');
442 for (const w of shorts.slice().sort((a, b) => a.id.localeCompare(b.id))) {
443 console.log(`\t'${w.id}': ${(Math.floor(w.r * 100) / 100).toFixed(2)},`);
444 }
445}
446console.log(bad ? `\n${bad} FAILED` : `\nALL PASS (${out.filter(l => l.startsWith('PASS')).length} checks, ${shorts.length} short)`);
447process.exit(bad ? 1 : 0);