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 | |
| 38 | import fs from 'node:fs'; |
| 39 | import path from 'node:path'; |
| 40 | import { fileURLToPath } from 'node:url'; |
| 41 | import { open, chat, errors } from './harness.mjs'; |
| 42 | |
| 43 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 44 | const SHOTS = path.join(HERE, 'shots', 'sweep'); |
| 45 | fs.mkdirSync(SHOTS, { recursive: true }); |
| 46 | |
| 47 | const QUICK = process.argv.includes('--quick'); |
| 48 | const PALETTES = QUICK |
| 49 | ? ['dark', 'light'] |
| 50 | : ['light', 'mist', 'linen', 'lollypop', 'sage', 'dusk', 'dark', 'amber', 'midnight', 'forest', 'plum']; |
| 51 | const SPACINGS = ['sharp', 'warm']; // shown as Compact and Breathe |
| 52 | const 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. |
| 59 | const 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 | |
| 450 | const s = await open({ name: 'sweep' }); |
| 451 | const { page } = s; |
| 452 | const log = (...a) => console.log(...a); |
| 453 | |
| 454 | /// Anything modal, put away, so a scene starts from the same place. |
| 455 | async 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 ─────────────────────────────────── |
| 471 | log('building the workspace…'); |
| 472 | await calm(); |
| 473 | |
| 474 | async 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 | } |
| 481 | const NAMES = ['Ship a CSV parser', 'Mum birthday plan', 'Rust compiler notes']; |
| 482 | const have = await page.$$eval('.diamond-box .session-box-name', (e) => e.map((x) => x.textContent)); |
| 483 | for (const n of NAMES) if (!have.includes(n)) await newDiamond(n); |
| 484 | |
| 485 | /// Tag a Diamond through the editor the user uses. |
| 486 | async 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 | } |
| 504 | const tagged = await page.$$eval('.session-box-tags .tag-chip', (e) => e.length); |
| 505 | if (!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. |
| 511 | const chats = await page.$$eval('.session-box:not(.diamond-box)', (e) => e.length).catch(() => 0); |
| 512 | if (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. |
| 523 | await page.evaluate(() => { |
| 524 | const tog = document.querySelector('#diamond-filter .tagf-toggle'); |
| 525 | if (tog && tog.getAttribute('aria-expanded') !== 'true') tog.click(); |
| 526 | }); |
| 527 | await page.waitForTimeout(350); |
| 528 | await page.evaluate(() => { |
| 529 | const chip = [...document.querySelectorAll('#diamond-filter .tagf-pool .tag-chip')][0]; |
| 530 | if (chip) chip.click(); |
| 531 | }); |
| 532 | await page.waitForTimeout(350); |
| 533 | await page.evaluate(() => { |
| 534 | try { DaimondPanels.show('work'); DaimondPanels.show('spend'); } catch (e) {} |
| 535 | }); |
| 536 | await page.waitForTimeout(500); |
| 537 | log('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. |
| 543 | const 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 | |
| 626 | async 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. |
| 639 | log('\nproving the clipped-ink check against the two known cases…'); |
| 640 | await SCENES.work(); |
| 641 | await setLook('dark', 'warm'); |
| 642 | await page.evaluate(() => { |
| 643 | const tile = document.querySelector('.session-box'); |
| 644 | if (tile) tile.click(); |
| 645 | }); |
| 646 | await page.waitForTimeout(400); |
| 647 | const beforeSelf = (await page.evaluate(AUDIT)).out.filter((f) => f.check === 'clipped-ink'); |
| 648 | await 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 | }); |
| 659 | await page.waitForTimeout(350); |
| 660 | const afterSelf = (await page.evaluate(AUDIT)).out.filter((f) => f.check === 'clipped-ink'); |
| 661 | const caught = (frag) => afterSelf.some((f) => f.sel.includes(frag) && f.severity === 'defect') |
| 662 | && !beforeSelf.some((f) => f.sel.includes(frag) && f.severity === 'defect'); |
| 663 | const selfTile = caught('session-box'); |
| 664 | const selfChip = caught('tag-chip'); |
| 665 | for (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 | } |
| 668 | log(` selected chat tile ring: ${selfTile ? 'FLAGGED' : 'MISSED'}`); |
| 669 | log(` selected tag chip ring: ${selfChip ? 'FLAGGED' : 'MISSED'}`); |
| 670 | await page.evaluate(() => { const st = document.getElementById('sweep-revert'); if (st) st.remove(); }); |
| 671 | await 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. |
| 677 | log('proving the other four checks against a planted fault…'); |
| 678 | const 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 | }); |
| 712 | const 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 | }); |
| 717 | await page.keyboard.press('Tab'); |
| 718 | const before4 = (await page.evaluate(AUDIT)).out; |
| 719 | await plant(); |
| 720 | await page.waitForTimeout(200); |
| 721 | await page.keyboard.press('Tab'); |
| 722 | const after4 = (await page.evaluate(AUDIT)).out; |
| 723 | await clear(); |
| 724 | const went = (check, frag) => after4.some((f) => f.check === check && f.sel.includes(frag)) |
| 725 | && !before4.some((f) => f.check === check && f.sel.includes(frag)); |
| 726 | const selfOverflow = went('overflow', 'probe-clip'); |
| 727 | const selfInk = went('invisible-text', 'probe-ink'); |
| 728 | const selfLap = went('overlap', 'probe-lap'); |
| 729 | const selfOff = went('offscreen', 'probe-off'); |
| 730 | const selfRing = went('clipped-focus-ring', 'probe-focus'); |
| 731 | log(` overflow ${selfOverflow ? 'red' : 'MISSED'}, invisible-text ${selfInk ? 'red' : 'MISSED'},` |
| 732 | + ` overlap ${selfLap ? 'red' : 'MISSED'}, offscreen ${selfOff ? 'red' : 'MISSED'},` |
| 733 | + ` focus ring ${selfRing ? 'red' : 'MISSED'}`); |
| 734 | const proved = selfTile && selfChip && selfOverflow && selfInk && selfLap && selfOff && selfRing; |
| 735 | if (!proved) log('\nA CHECK NEVER SEEN RED IS NOT EVIDENCE — the sweep below is worth less than it looks.'); |
| 736 | |
| 737 | // ── The sweep ──────────────────────────────────────────────────────────── |
| 738 | const found = new Map(); // key -> { ...finding, where: Set } |
| 739 | const 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. |
| 761 | const contrast = new Map(); |
| 762 | |
| 763 | log('\nsweeping…'); |
| 764 | for (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. |
| 795 | const washed = []; |
| 796 | if (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 ──────────────────────────────────────────────────────── |
| 811 | const list = [...found.values()]; |
| 812 | const defects = list.filter((f) => f.severity === 'defect'); |
| 813 | const notes = list.filter((f) => f.severity === 'note'); |
| 814 | |
| 815 | const ORDER = { 'clipped-ink': 0, 'clipped-focus-ring': 1, offscreen: 2, 'invisible-text': 3, overlap: 4, overflow: 5 }; |
| 816 | const rank = (f) => (ORDER[f.check] ?? 9) * 1000 - f.where.length; |
| 817 | defects.sort((a, b) => rank(a) - rank(b)); |
| 818 | notes.sort((a, b) => rank(a) - rank(b)); |
| 819 | |
| 820 | /// Which looks a finding appears in, said briefly: "everywhere", or the list. |
| 821 | const 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 | |
| 830 | log(`\n${defects.length} distinct defects, ${notes.length} notes, over ${PALETTES.length * SPACINGS.length} combinations.`); |
| 831 | const sidesOf = (f) => (f.sides && f.sides.size ? ` on the ${[...f.sides].join(', ')}` : ''); |
| 832 | for (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 | } |
| 838 | if (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 | } |
| 844 | log('\n— notes —'); |
| 845 | for (const f of notes) { |
| 846 | log(`NOTE ${f.check} ${f.sel} ${f.ink ? f.ink + sidesOf(f) + ' — ' : ''}${f.detail} (${whereText(f)})`); |
| 847 | } |
| 848 | |
| 849 | const errs = errors(s).filter((e) => !/502|Bad Gateway|Failed to load resource|favicon/.test(e)); |
| 850 | log(`\nconsole: ${errs.length ? errs.slice(0, 5).join(' | ') : 'clean'}`); |
| 851 | log(`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. |
| 855 | fs.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. |
| 873 | const faults = [...new Set(defects.map((f) => `${f.check} on ${f.sel}`))]; |
| 874 | log(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 | |
| 881 | await s.close(); |
| 882 | process.exit(defects.length ? 1 : 0); |