Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_sweep_desktop.mjs

40.2 KiB, 1 run

created by r2519314175:707, 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_sweep_desktop.mjs — every palette against every spacing, on the desktop
2// shell, checked MECHANICALLY rather than by eye.
3//
4// Eleven palettes times two spacings is twenty-two looks, and a defect that
5// only shows in one of them ships. Twice this week the same class reached the
6// user: ink drawn OUTSIDE an element's border box (a non-inset `box-shadow`
7// ring) and then sliced off by a scroll container's `overflow: hidden`. Once on
8// the selected tag chip, once on the selected chat tile under Breathe. Neither
9// is visible to a test that reads the DOM; both are obvious to one that reads
10// the GEOMETRY.
11//
12// What is checked, per combination, over every scene:
13//
14// 1. CLIPPED INK every non-inset box-shadow, against every ancestor that is
15// entitled to clip it. Hard ink (spread and offset) is a
16// defect; blur alone is a note. An axis that really scrolls
17// is a note; an axis that is `hidden`, or `auto` on a box
18// with nothing to scroll, is a defect.
19// 2. OVERFLOW scrollWidth > clientWidth while overflow-x cannot show it
20// and no ellipsis admits to the cut.
21// 3. INVISIBLE text whose colour is within a whisker of the background
22// actually painted behind it (composited up the ancestors).
23// 4. OVERLAP in-flow sibling controls whose boxes intersect.
24// 5. OFF-SCREEN ink painted past the right edge of the viewport that no
25// clipper and no scroller accounts for.
26//
27// The clipped-ink check is proved against the two known cases with their fixes
28// mentally reverted -- an outer ring injected onto `.session-box.active` and
29// onto `.tag-chip.tag-inc` -- before the sweep runs. A check never seen red is
30// not evidence.
31//
32// node dev/verify_sweep_desktop.mjs # the whole sweep
33// node dev/verify_sweep_desktop.mjs --quick # dark + light only, for edits
34//
35// Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs
36// (DAIMOND_MOCK_PORT, default 9099). No gateway.
37
38import fs from 'node:fs';
39import path from 'node:path';
40import { fileURLToPath } from 'node:url';
41import { open, chat, errors } from './harness.mjs';
42
43const HERE = path.dirname(fileURLToPath(import.meta.url));
44const SHOTS = path.join(HERE, 'shots', 'sweep');
45fs.mkdirSync(SHOTS, { recursive: true });
46
47const QUICK = process.argv.includes('--quick');
48const PALETTES = QUICK
49 ? ['dark', 'light']
50 : ['light', 'mist', 'linen', 'lollypop', 'sage', 'dusk', 'dark', 'amber', 'midnight', 'forest', 'plum'];
51const SPACINGS = ['sharp', 'warm']; // shown as Compact and Breathe
52const SPACE_WORD = { sharp: 'Compact', warm: 'Breathe' };
53
54// ── The audit, as it runs in the page ────────────────────────────────────
55//
56// One function, stringified into the page. Everything it reports is measured:
57// no rule is read from a stylesheet, so a defect introduced by a cascade nobody
58// expected is caught the same as one written down.
59const AUDIT = function () {
60 const W = window.innerWidth;
61 const out = [];
62 const samples = []; // every low-contrast text, for the cross-palette pass
63 const add = (o) => out.push(o);
64
65 /// A short, readable path to an element: enough to find it in the CSS.
66 const sel = (el) => {
67 const bit = (e) => {
68 let s = e.tagName.toLowerCase();
69 if (e.id) return s + '#' + e.id;
70 const cls = (e.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 3);
71 return s + cls.map((c) => '.' + c).join('');
72 };
73 const parts = [];
74 for (let e = el, i = 0; e && e.nodeType === 1 && i < 4; e = e.parentElement, i++) {
75 parts.unshift(bit(e));
76 if (e.id) break;
77 }
78 return parts.join(' > ');
79 };
80
81 const cs = (e) => getComputedStyle(e);
82 const num = (v) => parseFloat(v) || 0;
83
84 /// Drawn at all: has a box, is not hidden, is not transparent.
85 const drawn = (el) => {
86 const r = el.getBoundingClientRect();
87 if (r.width <= 0 || r.height <= 0) return null;
88 const c = cs(el);
89 if (c.visibility === 'hidden' || c.display === 'none') return null;
90 let op = 1;
91 for (let e = el; e && e.nodeType === 1; e = e.parentElement) op *= num(cs(e).opacity || '1');
92 if (op < 0.06) return null;
93 return r;
94 };
95
96 // ── box-shadow, parsed ────────────────────────────────────────────
97 /// Split a computed shadow list on its top-level commas -- the colours have
98 /// commas of their own, so a bare split() gets this wrong.
99 const layers = (str) => {
100 const parts = []; let depth = 0, cur = '';
101 for (const ch of str) {
102 if (ch === '(') depth++;
103 else if (ch === ')') depth--;
104 if (ch === ',' && depth === 0) { parts.push(cur); cur = ''; continue; }
105 cur += ch;
106 }
107 if (cur.trim()) parts.push(cur);
108 return parts.map((p) => p.trim()).filter(Boolean);
109 };
110 /// One layer as numbers, or null when it paints nothing.
111 const shadow = (part) => {
112 const inset = /\binset\b/.test(part);
113 // The colour comes off first: it is the only thing here with digits in
114 // brackets, and its alpha decides whether the layer paints at all.
115 let alpha = 1;
116 const rgba = part.match(/rgba?\(([^)]*)\)/);
117 if (rgba) {
118 const bits = rgba[1].split(/[,/\s]+/).filter(Boolean);
119 if (bits.length > 3) alpha = parseFloat(bits[3]);
120 }
121 const rest = part.replace(/rgba?\([^)]*\)/g, ' ').replace(/color\([^)]*\)/g, ' ')
122 .replace(/\binset\b/g, ' ');
123 const n = (rest.match(/-?\d*\.?\d+px/g) || []).map(parseFloat);
124 if (!n.length) return null;
125 const [ox = 0, oy = 0, blur = 0, spread = 0] = n;
126 return { inset, alpha, ox, oy, blur, spread };
127 };
128
129 // ── Who is entitled to clip me ────────────────────────────────────
130 /// Every ancestor that really clips `el`, nearest first, with the box it
131 /// clips to. Absolutely and fixed positioned elements escape any ancestor
132 /// that is not a containing block for them, which is what keeps menus and
133 /// dialogs out of this report.
134 const clippers = (el) => {
135 const res = [];
136 let pos = cs(el).position;
137 for (let a = el.parentElement; a && a.nodeType === 1; a = a.parentElement) {
138 const c = cs(a);
139 const cb = c.position !== 'static' || c.transform !== 'none' || c.filter !== 'none'
140 || c.perspective !== 'none' || /transform|filter/.test(c.willChange || '')
141 || /paint|strict|content/.test(c.contain || '');
142 const escapes = (pos === 'fixed' && !cb) || (pos === 'absolute' && !cb);
143 if (!escapes && (c.overflowX !== 'visible' || c.overflowY !== 'visible')) {
144 const r = a.getBoundingClientRect();
145 res.push({
146 el: a,
147 ox: c.overflowX, oy: c.overflowY,
148 // The clip is the PADDING box, so the borders come off.
149 l: r.left + num(c.borderLeftWidth),
150 r: r.right - num(c.borderRightWidth),
151 t: r.top + num(c.borderTopWidth),
152 b: r.bottom - num(c.borderBottomWidth),
153 scrollX: a.scrollWidth > a.clientWidth + 1,
154 scrollY: a.scrollHeight > a.clientHeight + 1,
155 });
156 }
157 if (cb) pos = c.position;
158 if (a === document.documentElement) break;
159 }
160 return res;
161 };
162
163 // ── colours ───────────────────────────────────────────────────────
164 const parseCol = (v) => {
165 const m = String(v).match(/rgba?\(([^)]*)\)/);
166 if (!m) return null;
167 const b = m[1].split(/[,/\s]+/).filter(Boolean).map(parseFloat);
168 return { r: b[0], g: b[1], b: b[2], a: b.length > 3 ? b[3] : 1 };
169 };
170 const over = (fg, bg) => ({ // fg composited onto bg
171 r: fg.r * fg.a + bg.r * (1 - fg.a),
172 g: fg.g * fg.a + bg.g * (1 - fg.a),
173 b: fg.b * fg.a + bg.b * (1 - fg.a),
174 a: 1,
175 });
176 const lum = (c) => {
177 const f = (x) => { x /= 255; return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); };
178 return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b);
179 };
180 const ratio = (a, b) => {
181 const la = lum(a), lb = lum(b);
182 return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
183 };
184 /// The background actually painted behind an element, or null when
185 /// something unreadable (an image, a gradient) is in the stack.
186 const painted = (el) => {
187 let acc = null;
188 for (let e = el; e && e.nodeType === 1; e = e.parentElement) {
189 const c = cs(e);
190 if (c.backgroundImage && c.backgroundImage !== 'none') return null;
191 const col = parseCol(c.backgroundColor);
192 if (!col || col.a === 0) continue;
193 acc = acc ? over(acc, col) : col;
194 if (acc.a >= 0.999) return acc;
195 }
196 // Nothing opaque all the way up: the canvas is whatever the root paints.
197 const root = parseCol(cs(document.documentElement).backgroundColor);
198 const base = root && root.a > 0 ? root : { r: 255, g: 255, b: 255, a: 1 };
199 return acc ? over(acc, base) : base;
200 };
201
202 const all = [...document.querySelectorAll('*')];
203
204 // ── 1. Ink drawn outside the box, and then cut off ────────────────
205 /// Every side of `el`'s outer shadow that an ancestor cuts off. Used for the
206 /// resting page and again for whatever has focus, because a focus ring is
207 /// exactly this shape of ink and lives exactly where lists clip.
208 const inkCut = (el, tagAs) => {
209 const r = drawn(el);
210 if (!r) return;
211 const c = cs(el);
212 const parsed = (c.boxShadow && c.boxShadow !== 'none')
213 ? layers(c.boxShadow).map(shadow).filter((s) => s && !s.inset && s.alpha > 0.02)
214 : [];
215 // An OUTLINE is the same fault in a different property: it is painted
216 // outside the border box, takes no layout space, and an ancestor's
217 // overflow cuts it exactly as it cuts a ring drawn with box-shadow. Most
218 // of this app's focus rings are outlines, so leaving them out would have
219 // meant checking the rarer half of the class.
220 if (c.outlineStyle && c.outlineStyle !== 'none' && c.outlineStyle !== 'hidden') {
221 const w = num(c.outlineWidth), off = num(c.outlineOffset);
222 if (w > 0 && w + off > 0) {
223 parsed.push({ inset: false, alpha: 1, ox: 0, oy: 0, blur: 0, spread: w + Math.max(off, 0),
224 what: `outline ${c.outline} at offset ${c.outlineOffset}` });
225 }
226 }
227 if (!parsed.length) return;
228 const clips = clippers(el);
229 if (!clips.length) return;
230 // A clipper that has already thrown the element away WHOLLY takes its ring
231 // with it: ink nobody can see is not a defect. Without this the walk skips
232 // only that clipper's side (`room < -1` below) and carries on to a farther
233 // ancestor, so `body { overflow: hidden }` gets blamed for shaving ink off
234 // a control the scroller above it discarded 180px ago. That is how
235 // `select#cfg-crystal-cap` was reported as a clipped focus ring: the Admin
236 // drawer's foot is always at least 200px above the viewport's, so whenever
237 // `body` CAN clip a ring on a drawer descendant, that descendant is off
238 // screen by construction.
239 for (const k of clips) {
240 const outY = k.oy !== 'visible' && (r.bottom <= k.t + 1 || r.top >= k.b - 1);
241 const outX = k.ox !== 'visible' && (r.right <= k.l + 1 || r.left >= k.r - 1);
242 if (outX || outY) return;
243 }
244 for (const s of parsed) {
245 // Hard ink is spread and offset: a ring, an outline, a lift with a
246 // crisp edge. Blur alone fades, so a cut in it is a note.
247 const hard = { l: s.spread - s.ox, r: s.spread + s.ox, t: s.spread - s.oy, b: s.spread + s.oy };
248 const soft = { l: hard.l + s.blur, r: hard.r + s.blur, t: hard.t + s.blur, b: hard.b + s.blur };
249 for (const k of clips) {
250 const sides = [
251 ['left', 'l', k.ox, k.scrollX, r.left - k.l],
252 ['right', 'r', k.ox, k.scrollX, k.r - r.right],
253 ['top', 't', k.oy, k.scrollY, r.top - k.t],
254 ['bottom', 'b', k.oy, k.scrollY, k.b - r.bottom],
255 ];
256 for (const [name, key, ovf, scrolls, room] of sides) {
257 if (ovf === 'visible') continue;
258 if (room < -1) continue; // the element itself is out of view here
259 const cutHard = hard[key] - Math.max(room, 0);
260 const cutSoft = soft[key] - Math.max(room, 0);
261 if (cutSoft <= 0.5) continue;
262 // An axis that really scrolls cuts its content on purpose;
263 // an axis that is `hidden`, or `auto` with nothing to
264 // scroll, cuts it forever.
265 const permanent = ovf === 'hidden' || ovf === 'clip' || !scrolls;
266 const kind = cutHard > 0.5 ? 'hard' : 'soft';
267 if (kind === 'soft' && !permanent) continue;
268 // Under 2px of hard ink is a rounded corner or the browser's
269 // own hairline ring shaved: real, but not what a reader sees.
270 // At 2px and up a ring is missing a whole side.
271 add({
272 check: tagAs || 'clipped-ink',
273 severity: kind === 'hard' && permanent && cutHard >= 2 ? 'defect' : 'note',
274 sel: sel(el),
275 by: sel(k.el),
276 // The side is kept apart from the rest so one ring cut on
277 // four sides reports as one fault, not four.
278 side: name,
279 ink: `${kind} ink, ${s.what || 'shadow ' + c.boxShadow}`,
280 detail: `cut ${(kind === 'hard' ? cutHard : cutSoft).toFixed(1)}px`
281 + ` (room ${room.toFixed(1)}px; clipper overflow-${key === 'l' || key === 'r' ? 'x' : 'y'}:`
282 + ` ${ovf}${scrolls ? ', scrolls' : ', nothing to scroll'})`,
283 });
284 }
285 }
286 }
287 };
288 // Whatever happens to hold focus is the focus pass's business, not the
289 // resting page's: counting it here would report the same ring twice and
290 // make the resting figures depend on where a Tab happened to land.
291 for (const el of all) if (el !== document.activeElement) inkCut(el, null);
292
293 // ── 2. Text cut off with nothing to say it was ────────────────────
294 for (const el of all) {
295 const r = drawn(el);
296 if (!r) continue;
297 const c = cs(el);
298 if (c.overflowX !== 'hidden' && c.overflowX !== 'clip') continue;
299 if (el.clientWidth <= 0) continue;
300 // Text meant for a screen reader and for nothing else. The visually-hidden
301 // idiom is a 1x1 box with the paint clipped away, so its content ALWAYS
302 // overflows -- that is how it works, not a defect. Matched on the shape of
303 // the idiom (a 1x1 box whose paint is clipped to nothing) rather than on a
304 // class name, so a rule cannot silence this check by borrowing a name.
305 if (el.clientWidth <= 1 && el.clientHeight <= 1
306 && (c.clipPath === 'inset(50%)' || c.clip === 'rect(0px, 0px, 0px, 0px)')) continue;
307 const over = el.scrollWidth - el.clientWidth;
308 if (over <= 1) continue;
309 if (c.textOverflow === 'ellipsis') continue; // an admitted cut
310 // Only where there is text of its own to lose; a container overflowing
311 // because of a positioned child is a different complaint.
312 const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
313 if (!own) continue;
314 add({
315 check: 'overflow',
316 severity: over > 4 ? 'defect' : 'note',
317 sel: sel(el),
318 by: '',
319 detail: `${over.toFixed(0)}px of text cut with no ellipsis `
320 + `(scrollWidth ${el.scrollWidth} > clientWidth ${el.clientWidth}): `
321 + JSON.stringify((el.textContent || '').trim().slice(0, 40)),
322 });
323 }
324
325 // ── 3. Text the same colour as what is behind it ──────────────────
326 for (const el of all) {
327 const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
328 if (!own) continue;
329 const r = drawn(el);
330 if (!r) continue;
331 const c = cs(el);
332 if (num(c.fontSize) < 4) continue;
333 if (c.webkitTextFillColor && c.webkitTextFillColor !== c.color) continue; // gradient ink
334 if (/text/.test(c.webkitBackgroundClip || '') || /text/.test(c.backgroundClip || '')) continue;
335 const fgRaw = parseCol(c.color);
336 const bg = painted(el);
337 if (!fgRaw || !bg) continue;
338 const fg = over(fgRaw, bg);
339 const cr = ratio(fg, bg);
340 // EVERY reading is kept, comfortable or not: one palette washing a label
341 // out that the other ten read fine is a defect no absolute threshold can
342 // see, and the oracle for it is the median of the others. Sampling only
343 // the low readings would take the healthy palettes out of that median
344 // and quietly compare the bad cases with each other.
345 samples.push({ sel: sel(el), cr: +cr.toFixed(2) });
346 if (cr >= 1.6) continue;
347 add({
348 check: 'invisible-text',
349 severity: cr < 1.25 ? 'defect' : 'note',
350 sel: sel(el),
351 by: '',
352 detail: `contrast ${cr.toFixed(2)}:1 — ${c.color} on rgb(${bg.r.toFixed(0)}, ${bg.g.toFixed(0)}, ${bg.b.toFixed(0)}): `
353 + JSON.stringify((el.textContent || '').trim().slice(0, 40)),
354 });
355 }
356
357 // ── 4. Controls sitting on top of one another ─────────────────────
358 const CTRL = 'button, a[href], input, select, textarea, [role="button"], .tag-chip, .chip-btn';
359 const seenPair = new Set();
360 for (const parent of all) {
361 const kids = [...parent.children].filter((k) => k.matches(CTRL));
362 if (kids.length < 2) continue;
363 const boxes = kids.map((k) => {
364 const c = cs(k);
365 if (c.position !== 'static' && c.position !== 'relative') return null;
366 return { k, r: drawn(k) };
367 }).filter((x) => x && x.r);
368 for (let i = 0; i < boxes.length; i++) {
369 for (let j = i + 1; j < boxes.length; j++) {
370 const a = boxes[i].r, b = boxes[j].r;
371 const ox = Math.min(a.right, b.right) - Math.max(a.left, b.left);
372 const oy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
373 if (ox <= 2 || oy <= 2) continue;
374 const key = sel(boxes[i].k) + '|' + sel(boxes[j].k);
375 if (seenPair.has(key)) continue;
376 seenPair.add(key);
377 add({
378 check: 'overlap',
379 severity: ox > 4 && oy > 4 ? 'defect' : 'note',
380 sel: sel(boxes[i].k),
381 by: sel(boxes[j].k),
382 detail: `two in-flow controls overlap by ${ox.toFixed(1)}x${oy.toFixed(1)}px`,
383 });
384 }
385 }
386 }
387
388 // ── 5. Ink past the right edge of the window ──────────────────────
389 for (const el of all) {
390 const r = drawn(el);
391 if (!r) continue;
392 if (r.right <= W + 1) continue;
393 if (r.width > W) continue; // a full-bleed container is not the complaint
394 // Anything a clipper already cuts inside the window is not on screen at
395 // all, and anything inside a horizontal scroller is reachable.
396 let accounted = false;
397 for (const k of clippers(el)) {
398 if (k.r <= W + 1) { accounted = true; break; }
399 if ((k.ox === 'auto' || k.ox === 'scroll') && k.scrollX) { accounted = true; break; }
400 }
401 if (accounted) continue;
402 // The nearest offender only: a row that is off the edge takes its
403 // children with it, and one line is the report.
404 if (el.parentElement) {
405 const pr = el.parentElement.getBoundingClientRect();
406 if (pr.right > W + 1 && pr.width <= W) continue;
407 }
408 add({
409 check: 'offscreen',
410 severity: r.right - W > 4 ? 'defect' : 'note',
411 sel: sel(el),
412 by: '',
413 detail: `right edge ${r.right.toFixed(0)}px, ${(r.right - W).toFixed(0)}px past the ${W}px viewport`,
414 });
415 }
416
417 // ── 6. The same question again, of whatever has focus ─────────────
418 //
419 // A focus ring is ink outside the border box by construction -- an outline
420 // here, a box-shadow there -- and the places a keyboard walks (a form inside
421 // a scrolling drawer, a search box at the top of a clipped list) are exactly
422 // the places entitled to cut it. Nothing in the resting page shows this, so
423 // each focusable is focused in turn and asked.
424 //
425 // The caller presses Tab once before this runs. That matters: Chromium only
426 // treats a scripted focus() as `:focus-visible` when the last interaction
427 // was a keypress, so without it every ring measured here reads `none` and
428 // the whole pass quietly checks nothing. Scroll positions are put back
429 // afterwards, because focusing moves them.
430 {
431 const was = document.activeElement;
432 const scrollers = all.filter((e) => e.scrollTop || e.scrollLeft)
433 .map((e) => [e, e.scrollTop, e.scrollLeft]);
434 const FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])';
435 const list = [...document.querySelectorAll(FOCUSABLE)].filter((e) => drawn(e) && !e.disabled);
436 for (const el of list.slice(0, 120)) {
437 try { el.focus({ preventScroll: true }); } catch (e) { continue; }
438 if (document.activeElement !== el) continue;
439 inkCut(el, 'clipped-focus-ring');
440 }
441 try { if (was && was.focus) was.focus({ preventScroll: true }); else document.activeElement.blur(); } catch (e) {}
442 for (const [e, t, l] of scrollers) { e.scrollTop = t; e.scrollLeft = l; }
443 }
444
445 return { out, samples };
446};
447
448// ── Driving the app ──────────────────────────────────────────────────────
449
450const s = await open({ name: 'sweep' });
451const { page } = s;
452const log = (...a) => console.log(...a);
453
454/// Anything modal, put away, so a scene starts from the same place.
455async function calm() {
456 await page.evaluate(() => {
457 document.querySelectorAll('.dlg-cancel, .modal-close').forEach((b) => {
458 if (b.offsetParent) b.click();
459 });
460 const m = document.getElementById('settings-menu');
461 if (m && !m.hidden) m.hidden = true;
462 const g = document.getElementById('panel-gallery');
463 if (g && !g.hidden) g.hidden = true;
464 const x = document.getElementById('admin-close');
465 if (x && x.offsetParent) x.click();
466 });
467 await page.waitForTimeout(250);
468}
469
470// ── Build a workspace worth looking at ───────────────────────────────────
471log('building the workspace…');
472await calm();
473
474async function newDiamond(name) {
475 await page.click('#new-diamond-btn', { force: true });
476 await page.waitForSelector('.dlg-input', { timeout: 10000 });
477 await page.fill('.dlg-input', name);
478 await page.click('.dlg-ok', { force: true });
479 await page.waitForTimeout(600);
480}
481const NAMES = ['Ship a CSV parser', 'Mum birthday plan', 'Rust compiler notes'];
482const have = await page.$$eval('.diamond-box .session-box-name', (e) => e.map((x) => x.textContent));
483for (const n of NAMES) if (!have.includes(n)) await newDiamond(n);
484
485/// Tag a Diamond through the editor the user uses.
486async function tag(name, tags) {
487 const idx = await page.$$eval('.diamond-box .session-box-name',
488 (els, n) => els.map((e) => e.textContent).indexOf(n), name);
489 if (idx < 0) return;
490 await page.$$eval('.diamond-box', (els, i) => els[i].click(), idx);
491 await page.waitForTimeout(450);
492 for (const b of await page.$$('.crystal-act')) {
493 if (((await b.textContent()) || '').includes('Tags')) { await b.click({ force: true }); break; }
494 }
495 await page.waitForSelector('.tag-editor', { timeout: 5000 }).catch(() => {});
496 for (const t of tags) {
497 await page.fill('.tag-input', t);
498 await page.keyboard.press('Enter');
499 await page.waitForTimeout(350);
500 }
501 await page.click('.crystal-act', { force: true }); // ← back to the crystal
502 await page.waitForTimeout(350);
503}
504const tagged = await page.$$eval('.session-box-tags .tag-chip', (e) => e.length);
505if (!tagged) {
506 await tag('Ship a CSV parser', ['rust', 'parser']);
507 await tag('Mum birthday plan', ['person', 'family', 'gifts', 'urgent']);
508}
509
510// Two chats, each with a real turn through the mock.
511const chats = await page.$$eval('.session-box:not(.diamond-box)', (e) => e.length).catch(() => 0);
512if (chats < 2) {
513 await chat(s, 'Write one short sentence about diamonds.');
514 await page.click('#new-session-btn', { force: true });
515 await page.waitForTimeout(400);
516 const start = page.locator('.tile-start').first();
517 if (await start.count()) await start.click({ force: true });
518 await page.waitForTimeout(400);
519 await chat(s, 'And one about rust.');
520}
521
522// The tag pool open, with a filter on it, and two panels in the dock.
523await page.evaluate(() => {
524 const tog = document.querySelector('#diamond-filter .tagf-toggle');
525 if (tog && tog.getAttribute('aria-expanded') !== 'true') tog.click();
526});
527await page.waitForTimeout(350);
528await page.evaluate(() => {
529 const chip = [...document.querySelectorAll('#diamond-filter .tagf-pool .tag-chip')][0];
530 if (chip) chip.click();
531});
532await page.waitForTimeout(350);
533await page.evaluate(() => {
534 try { DaimondPanels.show('work'); DaimondPanels.show('spend'); } catch (e) {}
535});
536await page.waitForTimeout(500);
537log('workspace ready.');
538
539// ── Scenes ───────────────────────────────────────────────────────────────
540//
541// Each is a state a user is really in, reached the way they reach it, and left
542// the way it was found so the next one starts clean.
543const SCENES = {
544 /// The rail with a live tag filter, a Diamond open on its crystal, the dock
545 /// with two panels. This is where both shipped defects lived.
546 work: async () => {
547 await calm();
548 await page.evaluate(() => {
549 // The stage scenes of the combination before this one left a guest
550 // panel open; every combination starts from the same stage, or the
551 // screenshots are not comparable across palettes.
552 try {
553 ['tools', 'graph', 'web'].forEach((p) => DaimondPanels.hide(p));
554 } catch (e) {}
555 const d = document.querySelector('.diamond-box');
556 if (d) d.click();
557 });
558 await page.waitForTimeout(500);
559 },
560 /// A chat selected, with real turns in it: the thread, the composer, the
561 /// fold controls -- none of which the crystal view puts on screen.
562 chat: async () => {
563 await calm();
564 await page.evaluate(() => {
565 const c = document.querySelector('#session-list .session-box');
566 if (c) c.click();
567 });
568 await page.waitForTimeout(500);
569 },
570 /// The Admin drawer over the rail.
571 drawer: async () => {
572 await calm();
573 await page.click('#user-row', { force: true }).catch(() => {});
574 await page.waitForTimeout(450);
575 },
576 /// The settings form inside the drawer: selects, text fields, the provider
577 /// list. The one place in the app whose focus ring is a box-shadow rather
578 /// than an outline, and it lives inside a scroller.
579 models: async () => {
580 await calm();
581 // `#astat-model` -- the "Models · N" row -- not `#settings-btn`, which
582 // opens the Admin home and leaves the form unbuilt.
583 await page.click('#astat-model', { force: true }).catch(() => {});
584 await page.waitForTimeout(700);
585 await page.evaluate(() => {
586 const add = document.getElementById('models-add');
587 if (add && add.offsetParent) add.click(); // raise the form itself
588 });
589 await page.waitForTimeout(500);
590 },
591 /// The appearance menu: spacing, palette, text size.
592 menu: async () => {
593 await calm();
594 await page.click('#settings-menu-btn', { force: true }).catch(() => {});
595 await page.waitForTimeout(400);
596 },
597 /// A dialog over everything.
598 dialog: async () => {
599 await calm();
600 await page.click('#new-diamond-btn', { force: true }).catch(() => {});
601 await page.waitForSelector('.dlg-input', { timeout: 5000 }).catch(() => {});
602 await page.fill('.dlg-input', 'A name long enough to test the field').catch(() => {});
603 await page.waitForTimeout(250);
604 },
605 /// The Tools panel: a long list of rows in a scroller.
606 tools: async () => {
607 await calm();
608 await page.evaluate(() => { try { DaimondPanels.show('tools'); } catch (e) {} });
609 await page.waitForTimeout(700);
610 },
611 /// The Graph panel: cards, edges, the stats line.
612 graph: async () => {
613 await calm();
614 await page.evaluate(() => { try { DaimondPanels.show('graph'); } catch (e) {} });
615 await page.waitForTimeout(800);
616 },
617 /// The guide in the Web panel: the app's only long prose, and the surface
618 /// where a palette's body text has to hold up paragraph after paragraph.
619 guide: async () => {
620 await calm();
621 await page.click('#guide-btn', { force: true }).catch(() => {});
622 await page.waitForTimeout(1200);
623 },
624};
625
626async function setLook(pal, sp) {
627 await page.evaluate(({ pal, sp }) => {
628 window.DaimondTheme.set(pal);
629 window.DaimondSkin.set(sp);
630 }, { pal, sp });
631 await page.waitForTimeout(450); // the warm typeface and the transitions
632}
633
634// ── The check, proved red first ──────────────────────────────────────────
635//
636// Both fixes are mentally reverted -- an OUTER ring put back on the selected
637// chat tile and on the selected tag chip -- and the clipped-ink check is asked
638// whether it minds. If it does not, nothing below it means anything.
639log('\nproving the clipped-ink check against the two known cases…');
640await SCENES.work();
641await setLook('dark', 'warm');
642await page.evaluate(() => {
643 const tile = document.querySelector('.session-box');
644 if (tile) tile.click();
645});
646await page.waitForTimeout(400);
647const beforeSelf = (await page.evaluate(AUDIT)).out.filter((f) => f.check === 'clipped-ink');
648await page.evaluate(() => {
649 const st = document.createElement('style');
650 st.id = 'sweep-revert';
651 // Exactly the two rules the fixes replaced: outer rings, no layout space.
652 st.textContent = `
653 :root[data-skin="warm"] .session-box.active { box-shadow: 0 0 0 2px red !important; }
654 :root[data-ink] .tag-chip.tag-inc { box-shadow: 0 0 0 2px red !important; }
655 :root[data-skin="warm"] .session-list, :root[data-skin="warm"] .diamond-list {
656 padding-left: 0 !important; padding-right: 0 !important; }`;
657 document.head.appendChild(st);
658});
659await page.waitForTimeout(350);
660const afterSelf = (await page.evaluate(AUDIT)).out.filter((f) => f.check === 'clipped-ink');
661const caught = (frag) => afterSelf.some((f) => f.sel.includes(frag) && f.severity === 'defect')
662 && !beforeSelf.some((f) => f.sel.includes(frag) && f.severity === 'defect');
663const selfTile = caught('session-box');
664const selfChip = caught('tag-chip');
665for (const f of afterSelf.filter((x) => /session-box|tag-chip/.test(x.sel) && x.severity === 'defect')) {
666 log(` red: ${f.severity} ${f.sel} ${f.detail}`);
667}
668log(` selected chat tile ring: ${selfTile ? 'FLAGGED' : 'MISSED'}`);
669log(` selected tag chip ring: ${selfChip ? 'FLAGGED' : 'MISSED'}`);
670await page.evaluate(() => { const st = document.getElementById('sweep-revert'); if (st) st.remove(); });
671await page.waitForTimeout(300);
672
673// The other four checks, each shown a fault built for it. A synthetic probe
674// rather than a real regression -- there is no known one to revert -- but the
675// same code path, on real elements in the real page, and every one of them is
676// silent before the probe is planted.
677log('proving the other four checks against a planted fault…');
678const plant = async () => page.evaluate(() => {
679 const box = document.createElement('div');
680 box.id = 'sweep-probe';
681 box.style.cssText = 'position:fixed; left:40px; top:40px; z-index:99999; background:#123456;';
682 box.innerHTML = `
683 <div id="probe-clip" style="width:60px; overflow-x:hidden; white-space:nowrap; text-overflow:clip;">
684 a line far longer than sixty pixels of room</div>
685 <div id="probe-ink" style="color:#123458; background:#123456;">invisible</div>
686 <div id="probe-lap"><button style="position:relative">one</button><button
687 style="position:relative; margin-left:-30px">two</button></div>`;
688 document.body.appendChild(box);
689 const off = document.createElement('button');
690 off.id = 'probe-off';
691 off.textContent = 'past the edge';
692 off.style.cssText = `position:fixed; top:200px; left:${window.innerWidth - 20}px; width:120px; z-index:99999;`;
693 document.body.appendChild(off);
694 // A focusable planted INSIDE the chats list, wearing the kind of ring the
695 // app's own controls wear. The list clips sideways and has no room to
696 // spare, so a ring drawn outside the border box has to be caught.
697 const st = document.createElement('style');
698 st.id = 'probe-style';
699 st.textContent = '#probe-focus:focus-visible { outline: 3px solid red; outline-offset: 3px; }';
700 document.head.appendChild(st);
701 const fb = document.createElement('button');
702 fb.id = 'probe-focus';
703 fb.textContent = 'ring';
704 // FIRST child, not last. Appended to a `#session-list` that the sweep's own
705 // workspace has already filled, the probe lands below the scrollport and is
706 // measured at `visible: 0` -- so the one check that proves this audit can see
707 // a clipped ring was itself only ever proved red against ink off screen. The
708 // guard above then correctly discards it, and the self-test says so.
709 const host = document.getElementById('session-list') || document.body;
710 host.insertBefore(fb, host.firstChild);
711});
712const clear = async () => page.evaluate(() => {
713 ['sweep-probe', 'probe-off', 'probe-style', 'probe-focus'].forEach((id) => {
714 const e = document.getElementById(id); if (e) e.remove();
715 });
716});
717await page.keyboard.press('Tab');
718const before4 = (await page.evaluate(AUDIT)).out;
719await plant();
720await page.waitForTimeout(200);
721await page.keyboard.press('Tab');
722const after4 = (await page.evaluate(AUDIT)).out;
723await clear();
724const went = (check, frag) => after4.some((f) => f.check === check && f.sel.includes(frag))
725 && !before4.some((f) => f.check === check && f.sel.includes(frag));
726const selfOverflow = went('overflow', 'probe-clip');
727const selfInk = went('invisible-text', 'probe-ink');
728const selfLap = went('overlap', 'probe-lap');
729const selfOff = went('offscreen', 'probe-off');
730const selfRing = went('clipped-focus-ring', 'probe-focus');
731log(` overflow ${selfOverflow ? 'red' : 'MISSED'}, invisible-text ${selfInk ? 'red' : 'MISSED'},`
732 + ` overlap ${selfLap ? 'red' : 'MISSED'}, offscreen ${selfOff ? 'red' : 'MISSED'},`
733 + ` focus ring ${selfRing ? 'red' : 'MISSED'}`);
734const proved = selfTile && selfChip && selfOverflow && selfInk && selfLap && selfOff && selfRing;
735if (!proved) log('\nA CHECK NEVER SEEN RED IS NOT EVIDENCE — the sweep below is worth less than it looks.');
736
737// ── The sweep ────────────────────────────────────────────────────────────
738const found = new Map(); // key -> { ...finding, where: Set }
739const record = (pal, sp, scene, f) => {
740 // The colours come out of the key: one ring cut in eleven palettes is one
741 // fault, and eleven rows saying so would bury the rest of the report. So do
742 // the leaf's state classes -- `.ptag.ptag-dock.on` and `.ptag.ptag-stage`
743 // are one stylesheet rule meeting one clipper, not six defects.
744 // An id names one element and is never merged; only a class list is
745 // shortened, and only to its first class.
746 const tail = f.sel.split(' > ').pop();
747 const leaf = tail.includes('#') ? tail : tail.replace(/^([a-z]+)(\.[\w-]+)?.*$/, '$1$2');
748 const key = [f.check, leaf, f.by, (f.ink || '').replace(/rgba?\([^)]*\)/g, '·'),
749 f.detail.replace(/[\d.]+/g, '#')].join('§');
750 let e = found.get(key);
751 if (!e) { e = { ...f, sides: new Set(), where: [], shots: new Set() }; found.set(key, e); }
752 if (f.side) e.sides.add(f.side);
753 if (f.severity === 'defect') e.severity = 'defect';
754 e.where.push(`${pal}/${SPACE_WORD[sp]}/${scene}`);
755 e.shots.add(`dev/shots/sweep/${pal}-${sp}-${scene}.png`);
756 // Keep the worst numbers seen, so the report quotes the bad case.
757 if (f.detail.length > e.detail.length) e.detail = f.detail;
758};
759
760/// sel -> palette/spacing -> the worst contrast that text was seen at.
761const contrast = new Map();
762
763log('\nsweeping…');
764for (const pal of PALETTES) {
765 for (const sp of SPACINGS) {
766 await setLook(pal, sp);
767 let n = 0;
768 for (const [name, enter] of Object.entries(SCENES)) {
769 await enter();
770 await setLook(pal, sp); // a scene can rebuild furniture; hold the look
771 // The picture is taken BEFORE the Tab: a focus ring in every shot is
772 // the harness's, not the app's, and it reads as a defect to the eye.
773 await page.screenshot({ path: path.join(SHOTS, `${pal}-${sp}-${name}.png`), timeout: 8000 })
774 .catch(() => {});
775 await page.keyboard.press('Tab'); // arms :focus-visible; see the focus pass
776 const res = await page.evaluate(AUDIT);
777 for (const f of res.out) { record(pal, sp, name, f); if (f.severity === 'defect') n++; }
778 for (const c of res.samples) {
779 let m = contrast.get(c.sel);
780 if (!m) { m = new Map(); contrast.set(c.sel, m); }
781 const k = `${pal}/${sp}`;
782 if (!m.has(k) || m.get(k) > c.cr) m.set(k, c.cr);
783 }
784 }
785 await calm();
786 log(` ${pal.padEnd(9)} ${SPACE_WORD[sp].padEnd(8)} ${n} defect hits`);
787 }
788}
789
790// ── One palette washing out what the others read fine ────────────────────
791//
792// No absolute threshold can find this: a muted hint at 2.6:1 is the design, and
793// the same hint at 1.9:1 in one palette is a bug in that palette. The oracle is
794// the OTHER TEN, so it only means anything on the full sweep.
795const washed = [];
796if (PALETTES.length > 4) {
797 for (const [selName, m] of contrast) {
798 if (m.size < PALETTES.length * 1.5) continue; // not seen widely enough to compare fairly
799 const vals = [...m.values()].sort((a, b) => a - b);
800 const med = vals[Math.floor(vals.length / 2)];
801 for (const [k, v] of m) {
802 if (v < med * 0.7 && v < 3.2) {
803 washed.push({ sel: selName, look: k, cr: v, med: +med.toFixed(2) });
804 }
805 }
806 }
807 washed.sort((a, b) => a.cr - b.cr);
808}
809
810// ── What came out ────────────────────────────────────────────────────────
811const list = [...found.values()];
812const defects = list.filter((f) => f.severity === 'defect');
813const notes = list.filter((f) => f.severity === 'note');
814
815const ORDER = { 'clipped-ink': 0, 'clipped-focus-ring': 1, offscreen: 2, 'invisible-text': 3, overlap: 4, overflow: 5 };
816const rank = (f) => (ORDER[f.check] ?? 9) * 1000 - f.where.length;
817defects.sort((a, b) => rank(a) - rank(b));
818notes.sort((a, b) => rank(a) - rank(b));
819
820/// Which looks a finding appears in, said briefly: "everywhere", or the list.
821const whereText = (f) => {
822 const pals = new Set(f.where.map((w) => w.split('/')[0]));
823 const sps = new Set(f.where.map((w) => w.split('/')[1]));
824 const scns = new Set(f.where.map((w) => w.split('/')[2]));
825 const p = pals.size === PALETTES.length ? 'every palette' : [...pals].join(', ');
826 const q = sps.size === 2 ? 'both spacings' : [...sps].join(', ');
827 return `${p} — ${q} — ${[...scns].join(', ')}`;
828};
829
830log(`\n${defects.length} distinct defects, ${notes.length} notes, over ${PALETTES.length * SPACINGS.length} combinations.`);
831const sidesOf = (f) => (f.sides && f.sides.size ? ` on the ${[...f.sides].join(', ')}` : '');
832for (const f of defects) {
833 log(`\nDEFECT ${f.check} ${f.sel}${f.by ? ' [clipped by ' + f.by + ']' : ''}`);
834 log(` ${f.ink ? f.ink + sidesOf(f) + ' — ' : ''}${f.detail}`);
835 log(` ${whereText(f)}`);
836 log(` ${[...f.shots][0]}`);
837}
838if (washed.length) {
839 log('\n— text one palette washes out that the rest read fine —');
840 for (const w of washed.slice(0, 25)) {
841 log(`WASH ${w.sel} ${w.cr}:1 under ${w.look} against a ${w.med}:1 median`);
842 }
843}
844log('\n— notes —');
845for (const f of notes) {
846 log(`NOTE ${f.check} ${f.sel} ${f.ink ? f.ink + sidesOf(f) + ' — ' : ''}${f.detail} (${whereText(f)})`);
847}
848
849const errs = errors(s).filter((e) => !/502|Bad Gateway|Failed to load resource|favicon/.test(e));
850log(`\nconsole: ${errs.length ? errs.slice(0, 5).join(' | ') : 'clean'}`);
851log(`self-test: ${proved ? 'every check was seen red before the sweep' : 'A CHECK WAS NEVER SEEN RED'}`);
852
853// A machine-readable copy beside the screenshots, so the report can be built
854// from what was measured rather than from what was remembered.
855fs.writeFileSync(path.join(SHOTS, 'findings.json'), JSON.stringify({
856 findings: list.map((f) => ({ ...f, shots: [...f.shots], sides: [...(f.sides || [])] })),
857 washed,
858 contrast: [...contrast].map(([k, m]) => ({ sel: k, by: [...m] })),
859}, null, 1));
860
861// The VERDICT, last, because the last line is the one that gets quoted.
862//
863// run_all.sh summarises a verifier by its final line. This one ended on "self-test: every
864// check was seen red before the sweep" — true, reassuring, and silent about the two defects
865// that had just failed the run. On the 2026-08-11 gate summary it read as a verifier that
866// had failed for no stated reason, which is the worst kind of red to hand somebody: it costs
867// a second run before anyone even knows what was found.
868// Named by the FAULT, not by the entry. One cut piece of ink is recorded once per ancestor
869// that clips it, so the crystal-cap ring at the foot of the models panel comes out as two
870// findings — `[clipped by html > body]` and `[clipped by html]` — which are one thing to fix
871// and would read on a summary as two. The blocks above still print every clipper; this line
872// counts what a person would go and mend.
873const faults = [...new Set(defects.map((f) => `${f.check} on ${f.sel}`))];
874log(defects.length
875 ? `\nFAIL ${faults.join('; ')}`
876 + `${faults.length < defects.length ? ` (${defects.length} findings, ${faults.length} fault(s))` : ''}`
877 + ` — evidence in ${path.join(SHOTS, 'findings.json')}`
878 : `\nPASS no defects over ${PALETTES.length * SPACINGS.length} combinations`
879 + `${notes.length ? `, ${notes.length} note(s) to look at` : ''}.`);
880
881await s.close();
882process.exit(defects.length ? 1 : 0);