oxedyne/daimond/dev/verify_focus_and_ink.mjs
54.4 KiB, 1 run
created by r2519314175:415, 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 | // Two classes of defect, measured in the page rather than read off a stylesheet. |
| 2 | // |
| 3 | // FOCUS. A ring drawn with `:focus` paints for a MOUSE click as well as a Tab. |
| 4 | // The ring means "the keyboard is here"; painting it when the pointer put it |
| 5 | // there says something that is not true, and the app's own convention -- 40-odd |
| 6 | // `:focus-visible` rules -- is the other way. So every control named below is |
| 7 | // driven twice, once with a real `page.mouse.click` and once with a real |
| 8 | // `page.keyboard.press('Tab')`, and the ring that PAINTS is compared. A rule |
| 9 | // grepped out of the CSS proves nothing about what the cascade resolved to. |
| 10 | // The second half of the same check is the opposite fault: a focusable control |
| 11 | // whose focused paint is identical to its resting paint has no indicator at |
| 12 | // all, which is worse than the wrong one. |
| 13 | // |
| 14 | // INK. Every visible run of text, in every palette, at both spacings, against |
| 15 | // the background actually composited under it, held to the WCAG floor its own |
| 16 | // size and weight earns (4.5, or 3.0 for large text). dev/verify_theme.mjs |
| 17 | // measures the DECLARED hex of the palette tokens; that cannot see a token |
| 18 | // drawn on a surface no palette pairs it with, a rule that puts `opacity` over |
| 19 | // a token, or a hardcoded colour. This measures what Chromium painted. |
| 20 | // |
| 21 | // Run: eval "$(bash dev/world.sh 9 --env)"; node dev/verify_focus_and_ink.mjs |
| 22 | // --quick two palettes rather than eleven |
| 23 | // --self run only the self-tests (the red proofs) |
| 24 | // --break=NAME install one real fault and stop after the floor, to SEE the |
| 25 | // floor go red: credits-view, credits-authed, graph-panel. |
| 26 | |
| 27 | // The pictures live in dev/shots_focus_and_ink.mjs -- this file measures and |
| 28 | // says so in words; nothing here writes an image. |
| 29 | import fs from 'node:fs'; |
| 30 | import path from 'node:path'; |
| 31 | import { fileURLToPath } from 'node:url'; |
| 32 | import { open } from './harness.mjs'; |
| 33 | import { GW_PORT } from './ports.mjs'; |
| 34 | |
| 35 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 36 | |
| 37 | const QUICK = process.argv.includes('--quick'); |
| 38 | const SELF = process.argv.includes('--self'); |
| 39 | const PALETTES = QUICK |
| 40 | ? ['light', 'dark'] |
| 41 | : ['light', 'mist', 'linen', 'lollypop', 'sage', 'dusk', 'dark', 'amber', 'midnight', 'forest', 'plum']; |
| 42 | const SPACINGS = ['sharp', 'warm']; |
| 43 | |
| 44 | const log = (...a) => console.log(...a); |
| 45 | let bad = 0; |
| 46 | const check = (ok, what) => { log(`${ok ? 'PASS ' : 'FAIL '} ${what}`); if (!ok) bad++; return ok; }; |
| 47 | |
| 48 | /// Shortfalls that are REPORTED rather than failed, because the remedy is a |
| 49 | /// decision about the design and not a number anyone can compute. |
| 50 | /// |
| 51 | /// `#autoreload.ar-off .ar-field { opacity: 0.55 }` (autoreload.css) dims the |
| 52 | /// three auto-reload fields while auto-reload is off. Its own comment says they |
| 53 | /// "stay editable, so it can be set up and then switched on" -- so these are |
| 54 | /// live controls, and WCAG's exemption for a disabled control does not reach |
| 55 | /// them. The labels read 2.33 (Light) and 2.90 (Dark); the hints 2.19 and 2.35. |
| 56 | /// Opacity cannot be the fix: it takes about 0.9 before the words clear 4.5, and |
| 57 | /// 0.9 is not a dim. Every real answer -- move the dim onto the boxes, drop the |
| 58 | /// ink a rung instead, state "off" some other way -- changes what "off" LOOKS |
| 59 | /// like, which is the author's call. Recorded here so the run stays honest and |
| 60 | /// green; a NEW shortfall, or one of these getting worse, still fails. |
| 61 | /// |
| 62 | /// These two are only re-measured when the AUTO-RELOAD BLOCK is built, which |
| 63 | /// needs the profile to have a signed-in gateway account; a world has no |
| 64 | /// gateway, so it usually is not. That absence is declared in `EXEMPT` with the |
| 65 | /// condition behind it, the run confirms the condition in the page before |
| 66 | /// accepting it, and it names these two rows as unmeasured. It is NOT inferred |
| 67 | /// from the block failing to appear -- see the floor. |
| 68 | /// |
| 69 | /// The Credits VIEW is a different thing and is always reachable. This comment |
| 70 | /// used to say the view needed an account, which was wrong and was the excuse |
| 71 | /// under which whole scenes went unswept. |
| 72 | /// |
| 73 | /// A THIRD instance of the same class is not in this table because this file |
| 74 | /// cannot reach it: `:root[data-skin="warm"] .top-meter { opacity: 0.6 }` |
| 75 | /// (skin-warm.css) quietens the always-on stats line, which computes to |
| 76 | /// 2.55-3.80 for `--text-secondary` and 2.38-3.15 for its `.sep`. The element is |
| 77 | /// empty under the mock, so there is no text to measure and the sweep is |
| 78 | /// honestly silent about it. That is the standing limit of an audit like this: |
| 79 | /// it can only weigh ink that is on the screen at the moment it looks. |
| 80 | const RECORDED = { |
| 81 | 'div#autoreload > div.ar-field > label.ar-label': 2.19, |
| 82 | 'div#autoreload > div.ar-field > div.ar-hint': 2.19, |
| 83 | }; |
| 84 | const DRIFT = 0.03; |
| 85 | |
| 86 | // ── What the page is asked, for both halves ────────────────────────────── |
| 87 | // |
| 88 | // One stringified function so nothing here can drift from what the browser |
| 89 | // resolved. `PAINT` reads the marks a control makes outside its own fill -- |
| 90 | // outline, box-shadow, border, background -- which together are the whole of |
| 91 | // what a focus indicator can be in this app. |
| 92 | const PAINT = function (sel) { |
| 93 | const el = document.querySelector(sel); |
| 94 | if (!el) return null; |
| 95 | const c = getComputedStyle(el); |
| 96 | return { |
| 97 | outline: `${c.outlineStyle} ${c.outlineWidth} ${c.outlineColor} @${c.outlineOffset}`, |
| 98 | shadow: c.boxShadow, |
| 99 | border: `${c.borderTopWidth} ${c.borderTopStyle} ${c.borderTopColor}`, |
| 100 | bg: c.backgroundColor, |
| 101 | focused: document.activeElement === el, |
| 102 | // Whether the UA thinks this is a keyboard focus. Chromium answers |
| 103 | // `:focus-visible` honestly, and it is the thing the whole defect is |
| 104 | // about, so it is recorded beside the paint rather than inferred from it. |
| 105 | fv: el.matches(':focus-visible'), |
| 106 | }; |
| 107 | }; |
| 108 | |
| 109 | /// True when two paint readings differ in anything a reader could see. |
| 110 | const differs = (a, b) => !a || !b |
| 111 | || a.outline !== b.outline || a.shadow !== b.shadow |
| 112 | || a.border !== b.border || a.bg !== b.bg; |
| 113 | |
| 114 | /// Whether a reading carries a real ring -- an outline or a shadow with width. |
| 115 | const hasRing = (p) => !!p && ( |
| 116 | (p.outline && !/^none /.test(p.outline) && !/ 0px /.test(p.outline)) |
| 117 | || (p.shadow && p.shadow !== 'none')); |
| 118 | |
| 119 | // ── The ink audit, in the page ─────────────────────────────────────────── |
| 120 | const INK = function () { |
| 121 | const num = (v) => parseFloat(v) || 0; |
| 122 | const parseCol = (s) => { |
| 123 | if (!s) return null; |
| 124 | const m = s.match(/rgba?\(([^)]+)\)/); |
| 125 | if (!m) return null; |
| 126 | const p = m[1].split(/[,\s/]+/).filter(Boolean).map(parseFloat); |
| 127 | return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 }; |
| 128 | }; |
| 129 | /// `top` composited over `base`, both premultiplied out to opaque. |
| 130 | const over = (top, base) => ({ |
| 131 | r: top.r * top.a + base.r * (1 - top.a), |
| 132 | g: top.g * top.a + base.g * (1 - top.a), |
| 133 | b: top.b * top.a + base.b * (1 - top.a), |
| 134 | a: 1, |
| 135 | }); |
| 136 | const lum = (c) => { |
| 137 | const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; |
| 138 | return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b); |
| 139 | }; |
| 140 | const ratio = (a, b) => { |
| 141 | const L1 = lum(a), L2 = lum(b); |
| 142 | return (Math.max(L1, L2) + 0.05) / (Math.min(L1, L2) + 0.05); |
| 143 | }; |
| 144 | /// The opacity every ancestor has already multiplied onto this element. An |
| 145 | /// `opacity` on a parent dims the text AND its own background equally, but |
| 146 | /// not the surface further up, so it has to be carried into both sides. |
| 147 | const chainAlpha = (el) => { |
| 148 | let a = 1; |
| 149 | for (let e = el; e && e !== document.documentElement; e = e.parentElement) { |
| 150 | a *= num(getComputedStyle(e).opacity); |
| 151 | } |
| 152 | return a; |
| 153 | }; |
| 154 | /// What is actually behind `el`: the first opaque background up the tree, |
| 155 | /// with every translucent one composited onto it on the way down. |
| 156 | const painted = (el) => { |
| 157 | const stack = []; |
| 158 | for (let e = el; e; e = e.parentElement) { |
| 159 | const c = getComputedStyle(e); |
| 160 | if (c.backgroundImage && c.backgroundImage !== 'none') return null; // a gradient is not one colour |
| 161 | const col = parseCol(c.backgroundColor); |
| 162 | if (col && col.a > 0) stack.push(col); |
| 163 | if (col && col.a >= 0.999) break; |
| 164 | } |
| 165 | const rootBg = parseCol(getComputedStyle(document.documentElement).backgroundColor); |
| 166 | let acc = (rootBg && rootBg.a >= 0.999) ? rootBg : { r: 255, g: 255, b: 255, a: 1 }; |
| 167 | for (let i = stack.length - 1; i >= 0; i--) acc = over(stack[i], acc); |
| 168 | return acc; |
| 169 | }; |
| 170 | const sel = (el) => { |
| 171 | const bit = (e) => { |
| 172 | if (e.id) return e.tagName.toLowerCase() + '#' + e.id; |
| 173 | const cls = (e.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2); |
| 174 | return e.tagName.toLowerCase() + cls.map((c) => '.' + c).join(''); |
| 175 | }; |
| 176 | const p = []; |
| 177 | for (let e = el; e && e !== document.body && p.length < 3; e = e.parentElement) p.unshift(bit(e)); |
| 178 | return p.join(' > '); |
| 179 | }; |
| 180 | const drawn = (el) => { |
| 181 | const c = getComputedStyle(el); |
| 182 | if (c.visibility === 'hidden' || c.display === 'none') return null; |
| 183 | const r = el.getBoundingClientRect(); |
| 184 | if (r.width < 1 || r.height < 1) return null; |
| 185 | if (r.bottom <= 0 || r.top >= innerHeight || r.right <= 0 || r.left >= innerWidth) return null; |
| 186 | return r; |
| 187 | }; |
| 188 | |
| 189 | const out = []; |
| 190 | for (const el of document.querySelectorAll('*')) { |
| 191 | const own = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim().length > 1); |
| 192 | if (!own) continue; |
| 193 | if (!drawn(el)) continue; |
| 194 | const c = getComputedStyle(el); |
| 195 | const size = num(c.fontSize); |
| 196 | if (size < 4) continue; |
| 197 | if (c.webkitTextFillColor && c.webkitTextFillColor !== c.color) continue; // gradient ink |
| 198 | if (/text/.test(c.webkitBackgroundClip || '') || /text/.test(c.backgroundClip || '')) continue; |
| 199 | const a = chainAlpha(el); |
| 200 | if (a < 0.05) continue; // effectively not painted |
| 201 | const bgOpaque = painted(el); |
| 202 | if (!bgOpaque) continue; |
| 203 | const fgRaw = parseCol(c.color); |
| 204 | if (!fgRaw) continue; |
| 205 | // The chain alpha dims the ink towards the surface behind the whole |
| 206 | // chain, which for a dimmed label IS the reader's experience of it. |
| 207 | const fg = over({ ...fgRaw, a: Math.min(1, fgRaw.a * a) }, bgOpaque); |
| 208 | const cr = ratio(fg, bgOpaque); |
| 209 | // WCAG 2.2 SC 1.4.3: 3.0 for large text -- 24px, or 18.66px at 700+ -- |
| 210 | // and 4.5 for everything else. |
| 211 | const wt = parseInt(c.fontWeight, 10) || 400; |
| 212 | const large = size >= 24 || (size >= 18.66 && wt >= 700); |
| 213 | const floor = large ? 3.0 : 4.5; |
| 214 | if (cr >= floor) continue; |
| 215 | out.push({ |
| 216 | sel: sel(el), |
| 217 | cr: +cr.toFixed(2), |
| 218 | floor, |
| 219 | size: +size.toFixed(1), |
| 220 | colour: c.color, |
| 221 | alpha: +a.toFixed(2), |
| 222 | text: (el.textContent || '').trim().slice(0, 36), |
| 223 | }); |
| 224 | } |
| 225 | |
| 226 | // ── Placeholders ───────────────────────────────────────────────── |
| 227 | // A placeholder is not a decoration: it is the sentence telling a person |
| 228 | // what the field wants, and SC 1.4.3 exempts it from nothing. It also has |
| 229 | // no text node, so the walk above cannot see it -- which is exactly how one |
| 230 | // rule in this app came to dim a placeholder with `opacity: .7` and sit at |
| 231 | // 3.00 in the light band while every measurement said the app was clean. |
| 232 | for (const el of document.querySelectorAll('input[placeholder], textarea[placeholder]')) { |
| 233 | if (!drawn(el)) continue; |
| 234 | if (el.value) continue; // a filled field shows no placeholder |
| 235 | const ph = getComputedStyle(el, '::placeholder'); |
| 236 | const fgRaw = parseCol(ph.color); |
| 237 | if (!fgRaw) continue; |
| 238 | const size = num(ph.fontSize) || num(getComputedStyle(el).fontSize); |
| 239 | const a = chainAlpha(el) * (num(ph.opacity) || 1); |
| 240 | const bgOpaque = painted(el); |
| 241 | if (!bgOpaque || a < 0.05) continue; |
| 242 | const fg = over({ ...fgRaw, a: Math.min(1, fgRaw.a * a) }, bgOpaque); |
| 243 | const cr = ratio(fg, bgOpaque); |
| 244 | const wt = parseInt(ph.fontWeight, 10) || 400; |
| 245 | const floor = (size >= 24 || (size >= 18.66 && wt >= 700)) ? 3.0 : 4.5; |
| 246 | if (cr >= floor) continue; |
| 247 | out.push({ |
| 248 | sel: sel(el) + '::placeholder', |
| 249 | cr: +cr.toFixed(2), |
| 250 | floor, |
| 251 | size: +size.toFixed(1), |
| 252 | colour: ph.color, |
| 253 | alpha: +a.toFixed(2), |
| 254 | text: (el.getAttribute('placeholder') || '').slice(0, 36), |
| 255 | }); |
| 256 | } |
| 257 | return out; |
| 258 | }; |
| 259 | |
| 260 | // ── Open ───────────────────────────────────────────────────────────────── |
| 261 | const s = await open({ name: 'focusink' }); |
| 262 | const page = s.page; |
| 263 | const calm = async () => { await page.waitForTimeout(250); }; |
| 264 | |
| 265 | const setLook = async (pal, sp) => { |
| 266 | await page.evaluate(({ pal, sp }) => { |
| 267 | window.DaimondTheme.set(pal); |
| 268 | window.DaimondSkin.set(sp); |
| 269 | }, { pal, sp }); |
| 270 | await page.waitForTimeout(400); |
| 271 | }; |
| 272 | |
| 273 | /// Shut the Admin drawer if it is open, then open it fresh on its HOME view. |
| 274 | /// |
| 275 | /// Every drawer scene starts here, because none of the ways in are idempotent: |
| 276 | /// `#user-row` toggles the drawer, the provider head toggles its provider, and |
| 277 | /// the rows that lead to Models and Credits only exist ON the home view -- so a |
| 278 | /// scene entered a second time from wherever the last one left off navigates |
| 279 | /// somewhere else entirely, and does it silently. Two runs of this file reported |
| 280 | /// a clean Credits view that was in fact the Admin home. |
| 281 | const drawerHome = async () => { |
| 282 | for (let i = 0; i < 3; i++) { |
| 283 | const open = await page.evaluate(() => { |
| 284 | const el = document.getElementById('admin-scroll'); |
| 285 | return !!(el && el.offsetParent); |
| 286 | }).catch(() => false); |
| 287 | if (!open) break; |
| 288 | await page.keyboard.press('Escape').catch(() => {}); |
| 289 | await page.waitForTimeout(250); |
| 290 | } |
| 291 | await page.click('#user-row', { force: true }).catch(() => {}); |
| 292 | await page.waitForTimeout(450); |
| 293 | }; |
| 294 | |
| 295 | // The scenes that hold the controls and the labels in question. Deliberately |
| 296 | // few: this verifier is about two classes, not a whole-app sweep. |
| 297 | const SCENES = { |
| 298 | work: async () => { |
| 299 | await calm(); |
| 300 | await page.evaluate(() => { |
| 301 | try { ['tools', 'graph', 'web'].forEach((p) => DaimondPanels.hide(p)); } catch (e) {} |
| 302 | const d = document.querySelector('.diamond-box'); |
| 303 | if (d) d.click(); |
| 304 | }); |
| 305 | await page.waitForTimeout(400); |
| 306 | }, |
| 307 | // The Models view with ONE PROVIDER OPEN. The provider's credit block is |
| 308 | // where the app's most dimmed placeholder lives, and it is built only inside |
| 309 | // an expanded provider -- a scene that stops at the provider list has not |
| 310 | // been to the place worth looking. The add-provider form is deliberately NOT |
| 311 | // raised: it replaces the provider list, taking the credit block and every |
| 312 | // field note off screen with it. |
| 313 | models: async () => { |
| 314 | await calm(); |
| 315 | await drawerHome(); |
| 316 | await page.click('#astat-model', { force: true }).catch(() => {}); |
| 317 | await page.waitForTimeout(1000); |
| 318 | await page.evaluate(() => { |
| 319 | if (document.querySelector('.models-credit')) return; |
| 320 | const h = document.querySelector('.models-prov-head'); |
| 321 | if (h) h.click(); |
| 322 | }); |
| 323 | await page.waitForTimeout(900); |
| 324 | // A drawer is a scroller: an element can exist, be laid out, and still be |
| 325 | // below the fold -- where the audit rightly ignores it, because nobody |
| 326 | // can read it there either. Bringing it into view is part of arriving. |
| 327 | await page.evaluate(() => { |
| 328 | const el = document.querySelector('.models-credit-input'); |
| 329 | if (el) el.scrollIntoView({ block: 'center' }); |
| 330 | }); |
| 331 | await page.waitForTimeout(350); |
| 332 | }, |
| 333 | menu: async () => { |
| 334 | await calm(); |
| 335 | await page.keyboard.press('Escape').catch(() => {}); |
| 336 | await page.click('#settings-menu-btn', { force: true }).catch(() => {}); |
| 337 | await page.waitForTimeout(350); |
| 338 | }, |
| 339 | // The long lists and the panels: where the quiet rung carries whole notes. |
| 340 | tools: async () => { |
| 341 | await calm(); |
| 342 | await page.evaluate(() => { try { DaimondPanels.show('tools'); } catch (e) {} }); |
| 343 | await page.waitForTimeout(600); |
| 344 | }, |
| 345 | graph: async () => { |
| 346 | await calm(); |
| 347 | await page.evaluate(() => { try { DaimondPanels.show('graph'); } catch (e) {} }); |
| 348 | await page.waitForTimeout(700); |
| 349 | }, |
| 350 | chat: async () => { |
| 351 | await calm(); |
| 352 | await page.evaluate(() => { |
| 353 | const c = document.querySelector('#session-list .session-box'); |
| 354 | if (c) c.click(); |
| 355 | }); |
| 356 | await page.waitForTimeout(450); |
| 357 | }, |
| 358 | // The Credits view. Its own view, not part of Models -- and the reason it is |
| 359 | // listed separately is that the app's lowest-contrast text lived here and no |
| 360 | // scene reached it. |
| 361 | // |
| 362 | // ENTERED BY ID. `#astat-account` is in the served HTML, is never removed, |
| 363 | // and its handler is `openCredits('')` (www/js/daimond.js) -- so the way in |
| 364 | // does not depend on what the row happens to SAY. |
| 365 | // |
| 366 | // It used to be found by scanning `.astat-row.astat-btn` for the word |
| 367 | // "credit", and that scan is the whole of how this file came to excuse |
| 368 | // itself. The row is a MONEY row: `moneyRows` draws it with whichever pot is |
| 369 | // paying, so a profile holding a provider key -- which every harness profile |
| 370 | // does -- reads "Your own key" and the word is not there. The scan found |
| 371 | // nothing, and the scene concluded the account "never builds it", when in |
| 372 | // fact the view was one click away and simply never opened. See the floor |
| 373 | // below for what that cost. |
| 374 | credits: async () => { |
| 375 | await calm(); |
| 376 | await drawerHome(); |
| 377 | await page.click('#astat-account', { force: true }).catch(() => {}); |
| 378 | await page.waitForTimeout(1000); |
| 379 | // The auto-reload block if it was built, the view's own lead if it was |
| 380 | // not, so the scene is scrolled to something real either way. |
| 381 | await page.evaluate(() => { |
| 382 | const el = document.getElementById('autoreload'); |
| 383 | const to = (el && el.getBoundingClientRect().height > 1) |
| 384 | ? el : document.getElementById('credits-section'); |
| 385 | if (to) to.scrollIntoView({ block: 'center' }); |
| 386 | }); |
| 387 | await page.waitForTimeout(350); |
| 388 | }, |
| 389 | }; |
| 390 | |
| 391 | /// What must be on screen for a scene to have arrived. A scene that quietly |
| 392 | /// lands somewhere else is the single commonest way an audit like this reports |
| 393 | /// "clean": it measured a view nobody asked for. Every entry is checked, and a |
| 394 | /// scene that does not arrive is named, loudly, in the run's output. |
| 395 | const SCENE_MARK = { |
| 396 | work: '.files-mode-chip', |
| 397 | models: '.models-credit-input', |
| 398 | menu: '#settings-menu .pop-head', |
| 399 | tools: '.tools-row, .tool-row, #panel-tools', |
| 400 | graph: '#panel-graph', |
| 401 | chat: '#chat-input', |
| 402 | // `#credits-section`, not `#autoreload`. The section is in the served HTML |
| 403 | // and is on screen whenever the view is up, with or without an account; the |
| 404 | // auto-reload block inside it is drawn only for one, and asking the block |
| 405 | // whether the SCENE arrived confused a missing account with a missing view. |
| 406 | // The block is declared separately, in EXEMPT below. |
| 407 | credits: '#credits-section', |
| 408 | }; |
| 409 | /// Is anything matching `q` laid out and inside the viewport? |
| 410 | const showing = async (q) => page.evaluate((sel) => { |
| 411 | for (const el of document.querySelectorAll(sel)) { |
| 412 | const r = el.getBoundingClientRect(); |
| 413 | if (r.width > 1 && r.height > 1 && r.bottom > 0 && r.top < innerHeight) return true; |
| 414 | } |
| 415 | return false; |
| 416 | }, q).catch(() => false); |
| 417 | const arrived = async (scene) => { |
| 418 | const q = SCENE_MARK[scene]; |
| 419 | if (!q) return true; |
| 420 | return showing(q); |
| 421 | }; |
| 422 | const missed = new Set(); |
| 423 | /// Every scene that was seen to arrive at least once. The FLOOR is counted off |
| 424 | /// this: see `SCENE_FLOOR` below. |
| 425 | const built = new Set(); |
| 426 | |
| 427 | // ── The floor: this account builds N scenes, and fewer than N is a failure ── |
| 428 | // |
| 429 | // WHAT HAPPENED, written down so nobody removes this as noise. On the night of |
| 430 | // 2026-08-14 three runs of this file were compared: |
| 431 | // |
| 432 | // run A 5 probed selectors, 77/98/26 Tab stops, 3 shortfalls -> FAILED |
| 433 | // run B 4 probed selectors, 72/93/26 Tab stops, 0 shortfalls -> ALL PASS |
| 434 | // run C 5 probed selectors, 77/98/26 Tab stops, 3 shortfalls -> FAILED |
| 435 | // |
| 436 | // Run B was the greener run BECAUSE IT SWEPT LESS INK. Its own output said so -- |
| 437 | // `UNAVAILABLE credits — this account never builds it; nothing it holds was |
| 438 | // measured` -- and every check still passed, because a scene that failed to |
| 439 | // build was excluded from the Tab walk and from the ink sweep instead of failing |
| 440 | // them. The one guard that should have caught it, "every scene that exists |
| 441 | // arrived where it meant to", is exactly vacuous about a scene that never |
| 442 | // existed: `missed` could only hold scenes that had turned up. |
| 443 | // |
| 444 | // So the count is the check. A run that measures fewer scenes than this account |
| 445 | // builds is a worse run than one that measures them all and reports shortfalls, |
| 446 | // and it must not be able to look better. |
| 447 | // |
| 448 | // UNAVAILABLE now means one thing only: DECLARED not to exist here, with the |
| 449 | // condition that makes it absent stated below and CONFIRMED in the page at the |
| 450 | // time. It may never mean "did not turn up". The two are told apart by asking |
| 451 | // the app about the condition rather than by observing the absence -- an absence |
| 452 | // is the symptom of both, which is why the old code could not tell them apart |
| 453 | // and always guessed the flattering one. |
| 454 | const SCENE_FLOOR = Object.keys(SCENES).length; |
| 455 | |
| 456 | /// Parts of a scene that this account may honestly not build, each with the |
| 457 | /// condition that makes it absent, checked in the page, and the reason in words. |
| 458 | /// |
| 459 | /// A part is declared here or it is not exemptible. There is deliberately no way |
| 460 | /// for a scene to add itself to this table at runtime: the whole defect above was |
| 461 | /// a scene concluding, from its own failure to find something, that the thing was |
| 462 | /// never there. |
| 463 | const EXEMPT = { |
| 464 | 'credits/autoreload': { |
| 465 | scene: 'credits', |
| 466 | // `#autoreload .ar-field`, not `#autoreload`. The host div is in the |
| 467 | // served HTML and carries `margin-top: 18px; padding-top: 16px; |
| 468 | // border-top` (autoreload.css), so EMPTY it still measures 17px tall and |
| 469 | // answers a bounding-box test as "on screen". Asking for the host is |
| 470 | // asking whether the app has a place to put the block, which it always |
| 471 | // has; asking for a field is asking whether the block was drawn. |
| 472 | // Caught by the stale-declaration branch below on the first run of it. |
| 473 | mark: '#autoreload .ar-field', |
| 474 | why: 'the auto-reload block is drawn only for a signed-in gateway account' |
| 475 | + ' (www/js/autoreload.js `render`: no `authed` state, `host.innerHTML = \'\'`),' |
| 476 | + ' and a world has no gateway (dev/world.sh)', |
| 477 | /// True when the app itself says the account this block needs is absent. |
| 478 | absent: async () => page.evaluate(() => |
| 479 | !(window.DaimondGateway && DaimondGateway.state && DaimondGateway.state().authed)) |
| 480 | .catch(() => false), |
| 481 | /// What goes unmeasured while it is absent, so the cost is stated rather |
| 482 | /// than implied by a missing line. |
| 483 | holds: ['div#autoreload > div.ar-field > label.ar-label', |
| 484 | 'div#autoreload > div.ar-field > div.ar-hint'], |
| 485 | }, |
| 486 | }; |
| 487 | /// Exempt parts that were seen on screen at least once, so a declaration that has |
| 488 | /// gone stale is caught rather than quietly carried. |
| 489 | const partSeen = new Set(); |
| 490 | |
| 491 | // ── Deliberate breakage, for the red proof ─────────────────────────────── |
| 492 | // |
| 493 | // `--break=<name>` installs one real fault before the sweep. The floor is then |
| 494 | // exercised through the path a genuine build failure takes -- the scene's own |
| 495 | // enter function runs, the app tries to draw, the mark is not there -- rather |
| 496 | // than by a check calling itself and agreeing. |
| 497 | const BREAKS = { |
| 498 | // The Credits view cannot build at all: the scene must FAIL, not be excused. |
| 499 | // Under the old code this is the exact shape that reported UNAVAILABLE and |
| 500 | // passed. |
| 501 | // |
| 502 | // `#credits-section`, the view's whole content, and not `#admin-credits` |
| 503 | // around it. The drawer holds a JS reference to the view element taken at |
| 504 | // init and re-appends it whenever it opens, so removing THAT is undone by |
| 505 | // the first navigation and the "break" quietly heals -- which it did, and |
| 506 | // this run reported ALL PASS with a fault installed. `renderCredits` looks |
| 507 | // the section up by id every time and returns early when it is gone, so this |
| 508 | // one stays broken. |
| 509 | 'credits-view': async () => page.evaluate(() => { |
| 510 | const v = document.getElementById('credits-section'); |
| 511 | if (v) v.remove(); |
| 512 | }), |
| 513 | // The gateway claims an account, so the auto-reload block's declared reason |
| 514 | // for being absent is FALSE -- and the block still does not build, because |
| 515 | // there is no gateway behind the claim. An absence with no valid reason is a |
| 516 | // failure to appear, and must be reported as one. |
| 517 | 'credits-authed': async () => page.evaluate(() => { |
| 518 | const G = window.DaimondGateway; |
| 519 | if (!G || !G.state) return; |
| 520 | const real = G.state.bind(G); |
| 521 | G.state = () => Object.assign({}, real(), { authed: true }); |
| 522 | }), |
| 523 | // A whole panel that fails to build, to show the floor is not about Credits. |
| 524 | 'graph-panel': async () => page.evaluate(() => { |
| 525 | const p = document.getElementById('panel-graph'); |
| 526 | if (p) p.remove(); |
| 527 | }), |
| 528 | }; |
| 529 | const BREAK = (process.argv.find((a) => a.startsWith('--break=')) || '').slice(8); |
| 530 | if (BREAK) { |
| 531 | if (!BREAKS[BREAK]) { log(`--break=${BREAK}: no such breakage; have ${Object.keys(BREAKS).join(', ')}`); process.exit(2); } |
| 532 | log(`\n!! --break=${BREAK} — a deliberate fault is installed; this run is EXPECTED to fail`); |
| 533 | await BREAKS[BREAK](); |
| 534 | await page.waitForTimeout(200); |
| 535 | } |
| 536 | |
| 537 | // What this run measured UNDER, said out loud. Two runs of this file are only |
| 538 | // comparable if they met the same app, and until 2026-08-25 one input to that was |
| 539 | // NOT part of a world: every world's `/api` went to a fixed `:9002`. On 2026-08-15 |
| 540 | // another lane's gateway answered this browser's registration mid-run with a |
| 541 | // closed-beta refusal; the app did the right thing and put the passcode card on |
| 542 | // screen; and the sweep found three contrast shortfalls in it that the next run |
| 543 | // could not reproduce, because by then the gateway was gone. Nothing was wrong |
| 544 | // with either run -- they measured two different applications -- and neither said |
| 545 | // so. |
| 546 | // |
| 547 | // The gateway is a world's own now (`dev/world.sh`, 9700 + N), so the condition |
| 548 | // below can only be this world's, and the line says which port it asked. |
| 549 | { |
| 550 | const cond = await page.evaluate(() => { |
| 551 | const g = (window.DaimondGateway && DaimondGateway.state && DaimondGateway.state()) || {}; |
| 552 | return { authed: !!g.authed, offline: !!g.offline, refused: g.refused || null }; |
| 553 | }).catch(() => ({ authed: null, offline: null, refused: null })); |
| 554 | log(`\n conditions: gateway authed=${cond.authed} offline=${cond.offline}` |
| 555 | + ` refused=${cond.refused} (this world's gateway port is :${GW_PORT})`); |
| 556 | } |
| 557 | |
| 558 | // Escape, and nothing else. An earlier version REMOVED `.modal` nodes from the |
| 559 | // document to be sure a scene started clean, which quietly deleted the Admin |
| 560 | // drawer's own markup -- so every later scene that reopened it got a partly |
| 561 | // rebuilt view, and the one control this file most needed to reach (the credit |
| 562 | // field, inside an expanded provider) was never there. A scene setup that |
| 563 | // damages the app is not a scene setup. |
| 564 | const closeAll = async () => { |
| 565 | await page.keyboard.press('Escape').catch(() => {}); |
| 566 | await page.waitForTimeout(120); |
| 567 | await page.keyboard.press('Escape').catch(() => {}); |
| 568 | await page.waitForTimeout(200); |
| 569 | }; |
| 570 | |
| 571 | // ── Half one: the focus indicator ──────────────────────────────────────── |
| 572 | // |
| 573 | // A control is clicked with a real mouse at its own centre, then blurred, then |
| 574 | // reached with a real Tab. Both readings are taken from the page. |
| 575 | // |
| 576 | // THE POINTER IS PARKED before every reading. A `page.mouse.click` leaves the |
| 577 | // cursor sitting on the control, so `:hover` is live in the "after a click" |
| 578 | // reading and a hover rule reads as a focus ring. The first run of this file |
| 579 | // reported `#astat-model` -- a `:focus-visible` control, correctly written -- |
| 580 | // as painting a ring for the mouse, and that was the whole of the reason. |
| 581 | const PARK = { x: 4, y: 4 }; |
| 582 | |
| 583 | async function mouseThenKey(sel) { |
| 584 | const box = await page.evaluate((q) => { |
| 585 | const el = document.querySelector(q); |
| 586 | if (!el) return null; |
| 587 | const r = el.getBoundingClientRect(); |
| 588 | if (r.width < 2 || r.height < 2) return null; |
| 589 | if (r.bottom <= 0 || r.top >= innerHeight || r.right <= 0 || r.left >= innerWidth) return null; |
| 590 | return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; |
| 591 | }, sel); |
| 592 | if (!box) return null; |
| 593 | |
| 594 | // Resting: nothing focused, pointer parked. |
| 595 | await page.mouse.move(PARK.x, PARK.y); |
| 596 | await page.evaluate(() => document.activeElement && document.activeElement.blur()); |
| 597 | await page.waitForTimeout(90); |
| 598 | const rest = await page.evaluate(PAINT, sel); |
| 599 | |
| 600 | // A real pointer press at the control's own centre, then the pointer moved |
| 601 | // off it before the paint is read. |
| 602 | // NOT FOLLOWED BY A KEY PRESS. Chromium's `:focus-visible` heuristic is that |
| 603 | // any keyboard interaction promotes whatever is focused, so an Escape sent |
| 604 | // to shut a <select>'s popup makes the clicked control match |
| 605 | // `:focus-visible` and the reading claims a keyboard focus that never |
| 606 | // happened. That is precisely how this file first reported every field and |
| 607 | // select as "Chromium says :focus-visible on a click": the Escape was mine. |
| 608 | await page.mouse.click(box.x, box.y); |
| 609 | await page.waitForTimeout(90); |
| 610 | await page.mouse.move(PARK.x, PARK.y); |
| 611 | await page.waitForTimeout(120); |
| 612 | const mouse = await page.evaluate(PAINT, sel); |
| 613 | |
| 614 | // A real Tab. Focus is put on the control's PREVIOUS tab stop and Tab is |
| 615 | // pressed, so the UA's own keyboard heuristic is what lands focus here -- |
| 616 | // `el.focus()` does not set `:focus-visible` and would prove nothing. |
| 617 | await page.evaluate(() => document.activeElement && document.activeElement.blur()); |
| 618 | await page.waitForTimeout(60); |
| 619 | await page.evaluate((q) => { |
| 620 | const el = document.querySelector(q); |
| 621 | if (el) el.focus({ preventScroll: true }); |
| 622 | }, sel); |
| 623 | await page.keyboard.press('Shift+Tab'); |
| 624 | await page.waitForTimeout(80); |
| 625 | await page.keyboard.press('Tab'); |
| 626 | await page.waitForTimeout(140); |
| 627 | let key = await page.evaluate(PAINT, sel); |
| 628 | // If the Shift+Tab / Tab pair did not land back here -- a scroller can move |
| 629 | // under it, and a menu can rebuild -- the reading is not evidence and is |
| 630 | // reported as such rather than counted as "no ring". |
| 631 | return { rest, mouse, key, landed: !!(key && key.focused), clicked: !!(mouse && mouse.focused) }; |
| 632 | } |
| 633 | |
| 634 | /// The controls to ask about, derived FROM THE STYLESHEETS rather than typed |
| 635 | /// out: every selector in `www/css/*.css` that carries a bare `:focus`, plus |
| 636 | /// the `:focus-visible` rules, so the two conventions are measured side by side |
| 637 | /// and a rule added later is covered without editing this file. |
| 638 | function focusSelectors() { |
| 639 | const dir = path.join(HERE, '..', 'www', 'css'); |
| 640 | const rows = []; |
| 641 | for (const f of fs.readdirSync(dir).filter((n) => n.endsWith('.css'))) { |
| 642 | const css = fs.readFileSync(path.join(dir, f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); |
| 643 | for (const m of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { |
| 644 | const body = m[2]; |
| 645 | for (const part of m[1].split(',')) { |
| 646 | const t = part.trim(); |
| 647 | if (!/:focus(?![-\w])/.test(t)) continue; |
| 648 | if (/::/.test(t)) continue; |
| 649 | const base = t.replace(/:focus(?![-\w])/g, ''); |
| 650 | // Only rules that actually PAINT something matter here. |
| 651 | if (!/outline|box-shadow|border|background|color/.test(body)) continue; |
| 652 | rows.push({ file: f, sel: base.trim(), rule: t, body: body.trim().slice(0, 90) }); |
| 653 | } |
| 654 | } |
| 655 | } |
| 656 | return rows; |
| 657 | } |
| 658 | |
| 659 | const focusRows = []; |
| 660 | if (!SELF) { |
| 661 | log('\n── focus: a real click beside a real Tab ───────────────────────────'); |
| 662 | const wanted = focusSelectors(); |
| 663 | log(` ${wanted.length} bare \`:focus\` rules that paint, across www/css/*.css`); |
| 664 | await setLook('light', 'sharp'); |
| 665 | const seen = new Set(); |
| 666 | for (const scene of ['work', 'models', 'menu']) { |
| 667 | await closeAll(); |
| 668 | await SCENES[scene](); |
| 669 | for (const w of wanted) { |
| 670 | if (seen.has(w.rule)) continue; |
| 671 | const present = await page.evaluate((q) => { |
| 672 | try { |
| 673 | for (const el of document.querySelectorAll(q)) { |
| 674 | const r = el.getBoundingClientRect(); |
| 675 | const c = getComputedStyle(el); |
| 676 | if (r.width > 2 && r.height > 2 && c.visibility !== 'hidden' |
| 677 | && r.top < innerHeight && r.bottom > 0) return true; |
| 678 | } |
| 679 | } catch (e) { return false; } |
| 680 | return false; |
| 681 | }, w.sel).catch(() => false); |
| 682 | if (!present) continue; |
| 683 | seen.add(w.rule); |
| 684 | const r = await mouseThenKey(w.sel); |
| 685 | if (!r) continue; |
| 686 | const mouseRing = differs(r.rest, r.mouse); |
| 687 | const keyRing = differs(r.rest, r.key); |
| 688 | focusRows.push({ ...w, scene, mouseRing, keyRing, r }); |
| 689 | log(` ${w.rule.slice(0, 40).padEnd(42)} ${scene.padEnd(7)}` |
| 690 | + ` click:${mouseRing ? 'RING' : '----'}${r.clicked ? '' : '?'}` |
| 691 | + ` tab:${keyRing ? 'RING' : '----'}${r.landed ? '' : '?'}` |
| 692 | + ` fv(click)=${r.mouse && r.mouse.fv}`); |
| 693 | } |
| 694 | } |
| 695 | await closeAll(); |
| 696 | log(''); |
| 697 | for (const row of focusRows) { |
| 698 | if (!row.r.landed) { log(` n/a ${row.rule} — Tab did not land back on it`); continue; } |
| 699 | check(row.keyRing, `${row.rule} paints a ring for the keyboard`); |
| 700 | // A control the UA itself calls keyboard-focused on a plain click -- |
| 701 | // every text field, by spec -- cannot be asked not to paint: it matches |
| 702 | // `:focus-visible` either way, so the ring is the UA's decision. |
| 703 | if (row.r.mouse && row.r.mouse.fv) { |
| 704 | log(` n/a ${row.rule} — Chromium reports :focus-visible on a plain click, so` |
| 705 | + ' `:focus` and `:focus-visible` paint identically here'); |
| 706 | continue; |
| 707 | } |
| 708 | check(!row.mouseRing, `${row.rule} paints NO ring for a mouse click`); |
| 709 | } |
| 710 | } |
| 711 | |
| 712 | // ── Half one and a half: does every Tab stop SHOW itself? ──────────────── |
| 713 | // |
| 714 | // The wrong ring is the lesser fault. This walks the page with a real Tab and, |
| 715 | // at each stop, compares the focused control's own paint with its own resting |
| 716 | // paint -- so a control with no indicator at all is named, whatever rule (or |
| 717 | // absence of one) put it there. Nothing is read from a stylesheet. |
| 718 | const tabDead = new Map(); |
| 719 | if (!SELF) { |
| 720 | log('\n── focus: every Tab stop, does it show itself? ──────────────────────'); |
| 721 | // Each stop is marked as it is visited, so the walk stops when Tab wraps |
| 722 | // round to something it has already seen. Deduping on a CLASS NAME instead |
| 723 | // ended the walk after six stops out of forty-odd, because a toolbar full of |
| 724 | // `button.files-btn` looks like one element to a selector. |
| 725 | const STEP = `(function () { |
| 726 | const el = document.activeElement; |
| 727 | if (!el || el === document.body) return null; |
| 728 | const already = el.hasAttribute('data-walked'); |
| 729 | el.setAttribute('data-walked', '1'); |
| 730 | const read = () => { |
| 731 | const c = getComputedStyle(el); |
| 732 | return [c.outlineStyle, c.outlineWidth, c.outlineColor, c.outlineOffset, |
| 733 | c.boxShadow, c.borderTopWidth, c.borderTopStyle, c.borderTopColor, |
| 734 | c.backgroundColor, c.color, c.textDecorationLine].join('|'); |
| 735 | }; |
| 736 | const bit = (e) => { |
| 737 | if (e.id) return e.tagName.toLowerCase() + '#' + e.id; |
| 738 | const cl = (e.getAttribute('class') || '').trim().split(/\\s+/).filter(Boolean).slice(0, 2); |
| 739 | return e.tagName.toLowerCase() + cl.map((c) => '.' + c).join(''); |
| 740 | }; |
| 741 | const r = el.getBoundingClientRect(); |
| 742 | const focused = read(); |
| 743 | // Blur to read the resting paint, then put focus back exactly where the |
| 744 | // Tab left it so the walk carries on from the same place. |
| 745 | el.blur(); |
| 746 | const rest = read(); |
| 747 | el.focus({ preventScroll: true }); |
| 748 | return { sel: bit(el), already, same: focused === rest, |
| 749 | onScreen: r.width > 1 && r.height > 1 && r.bottom > 0 && r.top < innerHeight }; |
| 750 | })()`; |
| 751 | // Tab resumes from whatever was focused last, so a fresh walk has to put the |
| 752 | // sequential-navigation starting point back at the top of the document -- |
| 753 | // otherwise the second walk over the same page silently starts halfway and |
| 754 | // never reaches the elements the first one covered. |
| 755 | const rewind = async () => page.evaluate(() => { |
| 756 | document.querySelectorAll('[data-walked]').forEach((e) => e.removeAttribute('data-walked')); |
| 757 | const b = document.body; |
| 758 | b.setAttribute('tabindex', '-1'); |
| 759 | b.focus(); |
| 760 | b.removeAttribute('tabindex'); |
| 761 | }); |
| 762 | for (const scene of ['work', 'models', 'menu']) { |
| 763 | await closeAll(); |
| 764 | await SCENES[scene](); |
| 765 | await rewind(); |
| 766 | let stops = 0; |
| 767 | for (let i = 0; i < 140; i++) { |
| 768 | await page.keyboard.press('Tab'); |
| 769 | await page.waitForTimeout(30); |
| 770 | let r; |
| 771 | try { r = await page.evaluate(STEP); } catch (e) { break; } |
| 772 | if (!r) continue; |
| 773 | if (r.already) break; // wrapped round |
| 774 | stops++; |
| 775 | if (r.same && r.onScreen) { |
| 776 | const e = tabDead.get(r.sel) || { sel: r.sel, where: new Set() }; |
| 777 | e.where.add(scene); |
| 778 | tabDead.set(r.sel, e); |
| 779 | } |
| 780 | } |
| 781 | log(` ${scene.padEnd(7)} ${stops} Tab stops visited`); |
| 782 | } |
| 783 | await closeAll(); |
| 784 | for (const d of tabDead.values()) log(` DEAD ${d.sel} — focused paint identical to resting [${[...d.where].join(',')}]`); |
| 785 | check(tabDead.size === 0, `every on-screen Tab stop paints something (${tabDead.size} do not)`); |
| 786 | } |
| 787 | |
| 788 | // ── Half two: the ink ──────────────────────────────────────────────────── |
| 789 | const inkWorst = new Map(); // key -> worst finding |
| 790 | if (!SELF) { |
| 791 | log('\n── ink: every visible text run, every palette, both spacings ────────'); |
| 792 | for (const pal of PALETTES) { |
| 793 | for (const sp of SPACINGS) { |
| 794 | await setLook(pal, sp); |
| 795 | for (const [name, enter] of Object.entries(SCENES)) { |
| 796 | await closeAll(); |
| 797 | await enter(); |
| 798 | if (await arrived(name)) built.add(name); |
| 799 | else missed.add(`${pal}/${sp}/${name}`); |
| 800 | // An exempt part is looked FOR every time, so a declaration that has |
| 801 | // stopped being true is caught by the thing turning up. |
| 802 | for (const [id, p] of Object.entries(EXEMPT)) { |
| 803 | if (p.scene === name && await showing(p.mark)) partSeen.add(id); |
| 804 | } |
| 805 | const found = await page.evaluate(INK); |
| 806 | for (const f of found) { |
| 807 | const key = f.sel + '§' + f.text; |
| 808 | const prev = inkWorst.get(key); |
| 809 | if (!prev || f.cr < prev.cr) inkWorst.set(key, { ...f, where: `${pal}/${sp}/${name}` }); |
| 810 | } |
| 811 | } |
| 812 | } |
| 813 | log(` ${pal.padEnd(9)} cumulative distinct shortfalls: ${inkWorst.size}`); |
| 814 | } |
| 815 | await closeAll(); |
| 816 | log(''); |
| 817 | const rows = [...inkWorst.values()].sort((a, b) => a.cr - b.cr); |
| 818 | const fresh = []; |
| 819 | for (const f of rows) { |
| 820 | const rec = RECORDED[f.sel]; |
| 821 | const kind = rec === undefined ? 'NEW ' : (f.cr < rec - DRIFT ? 'WORSE' : 'known'); |
| 822 | if (kind !== 'known') fresh.push(f); |
| 823 | log(` ${kind} ${String(f.cr).padStart(5)}:1 (floor ${f.floor}) ${f.sel}` |
| 824 | + ` ${f.size}px ${f.colour} α${f.alpha} [${f.where}] ${JSON.stringify(f.text)}`); |
| 825 | } |
| 826 | check(fresh.length === 0, |
| 827 | `no text run below its WCAG floor that is not already recorded` |
| 828 | + ` (${rows.length} short, ${rows.length - fresh.length} recorded)`); |
| 829 | for (const m of missed) log(` MISSED SCENE ${m} — it never arrived, so its text was not measured`); |
| 830 | check(missed.size === 0, `every scene that exists arrived where it meant to (${missed.size} did not)`); |
| 831 | |
| 832 | // ── The floor ──────────────────────────────────────────────────── |
| 833 | // |
| 834 | // How much was measured, said as a number, so a run cannot look better by |
| 835 | // looking at less. Every scene in the table must have been seen; there is no |
| 836 | // exemption for a whole scene, and if one is ever wanted it goes in EXEMPT |
| 837 | // with a condition the page can be asked about. |
| 838 | log(''); |
| 839 | const absent = Object.keys(SCENES).filter((n) => !built.has(n)); |
| 840 | for (const n of absent) log(` ABSENT SCENE ${n} — it never built, and nothing declares that it may not`); |
| 841 | check(built.size === SCENE_FLOOR, |
| 842 | `this account builds all ${SCENE_FLOOR} scenes and all ${SCENE_FLOOR} were swept (${built.size})`); |
| 843 | |
| 844 | // Each declared part: is it here, and if not, does the app itself confirm the |
| 845 | // stated reason? "Declared not to exist here, and here is why" passes; |
| 846 | // "did not turn up" fails, however alike the two look on screen. |
| 847 | for (const [id, p] of Object.entries(EXEMPT)) { |
| 848 | const here = partSeen.has(id); |
| 849 | const why = await p.absent(); |
| 850 | if (here && !why) continue; // present, as expected: measured with everything else |
| 851 | if (here && why) { |
| 852 | check(false, `EXEMPT ${id} — declared absent, yet it was on screen; the declaration is stale`); |
| 853 | continue; |
| 854 | } |
| 855 | if (!here && !why) { |
| 856 | check(false, `ABSENT ${id} — it did not turn up, and its declared reason is not true here`); |
| 857 | continue; |
| 858 | } |
| 859 | log(` UNAVAILABLE ${id} — declared absent: ${p.why}`); |
| 860 | for (const h of p.holds || []) log(` and so ${h} went unmeasured this run`); |
| 861 | check(true, `EXEMPT ${id} — absent for the reason it declares, confirmed in the page`); |
| 862 | } |
| 863 | } |
| 864 | |
| 865 | // With a fault installed the run has done its job once the floor has spoken. |
| 866 | // The sections below put the SHIPPED defects back and would be measuring a |
| 867 | // deliberately broken app. |
| 868 | if (BREAK) { |
| 869 | await s.close(); |
| 870 | log(`\n${bad ? `${bad} FAILED` : 'ALL PASS'}`); |
| 871 | process.exit(bad ? 1 : 0); |
| 872 | } |
| 873 | |
| 874 | // ── The proof that matters: the shipped defects, put back ──────────────── |
| 875 | // |
| 876 | // A synthetic probe proves the audit can see A fault. This puts back the exact |
| 877 | // declarations that were here before, on the real elements, in the real scenes, |
| 878 | // and requires each one to be named. A check that has only ever been shown a |
| 879 | // probe of its author's own design has not been shown a regression. |
| 880 | const WAS = ` |
| 881 | :root { --text-muted: #877D70; } |
| 882 | :root[data-theme="light"] { --text-muted: #787068; } |
| 883 | :root[data-theme="lollypop"] { --text-muted: #8A5E85; } |
| 884 | .files-mode-chip.ghost { opacity: 0.55; color: var(--text-secondary); } |
| 885 | .files-mode-chip.ghost .ic { opacity: 1; } |
| 886 | .settings-section .models-credit-input::placeholder { color: var(--text-secondary); opacity: .7; } |
| 887 | `; |
| 888 | { |
| 889 | log('\n── putting the shipped defects back, one stylesheet ────────────────'); |
| 890 | const sweepOnce = async () => { |
| 891 | const hits = []; |
| 892 | for (const pal of ['light', 'dark']) { |
| 893 | for (const scene of ['work', 'models', 'menu', 'credits']) { |
| 894 | await setLook(pal, 'sharp'); |
| 895 | await closeAll(); |
| 896 | await SCENES[scene](); |
| 897 | if (!await arrived(scene)) log(` !! ${pal}/${scene} did not arrive — nothing it holds was measured`); |
| 898 | for (const f of await page.evaluate(INK)) hits.push({ ...f, pal, scene }); |
| 899 | } |
| 900 | } |
| 901 | return hits; |
| 902 | }; |
| 903 | await page.evaluate((css) => { |
| 904 | const st = document.createElement('style'); |
| 905 | st.id = 'was-revert'; |
| 906 | st.textContent = css; |
| 907 | document.head.appendChild(st); |
| 908 | }, WAS); |
| 909 | await page.waitForTimeout(250); |
| 910 | const back = await sweepOnce(); |
| 911 | const shown = new Set(); |
| 912 | for (const f of back) { |
| 913 | const k = f.sel + f.pal; |
| 914 | if (shown.has(k)) continue; |
| 915 | shown.add(k); |
| 916 | log(` red: ${f.cr}:1 (floor ${f.floor}) ${f.sel} [${f.pal}/${f.scene}] ${JSON.stringify(f.text)}`); |
| 917 | } |
| 918 | check(back.some((f) => /pop-note|pop-head|cfg-.*-note|dview-btn/.test(f.sel)), |
| 919 | 'the quiet rung at its old value is named again'); |
| 920 | check(back.some((f) => /files-mode-chip/.test(f.sel)), |
| 921 | 'the ghosted Cloud chip at its old opacity is named again'); |
| 922 | check(back.some((f) => /models-credit-input::placeholder/.test(f.sel)), |
| 923 | 'the dimmed placeholder at its old declaration is named again'); |
| 924 | await page.evaluate(() => { const e = document.getElementById('was-revert'); if (e) e.remove(); }); |
| 925 | await page.waitForTimeout(250); |
| 926 | const now = await sweepOnce().then((h) => h.filter((f) => RECORDED[f.sel] === undefined)); |
| 927 | check(now.length === 0, `and silent again with the fixes in (${now.length} unrecorded findings)`); |
| 928 | for (const f of now) log(` still: ${f.cr}:1 ${f.sel} [${f.pal}/${f.scene}]`); |
| 929 | } |
| 930 | |
| 931 | // ── The red proofs ─────────────────────────────────────────────────────── |
| 932 | // |
| 933 | // Neither half is trusted until it has been shown a fault built for it, in the |
| 934 | // real page, through the same code path. |
| 935 | log('\n── proving both halves red ─────────────────────────────────────────'); |
| 936 | await closeAll(); |
| 937 | await setLook('light', 'sharp'); |
| 938 | await SCENES.work(); |
| 939 | |
| 940 | // 1. Ink: a label forced to a colour a hair off its own ground. If the audit |
| 941 | // cannot see this it cannot see anything. |
| 942 | { |
| 943 | const before = (await page.evaluate(INK)).length; |
| 944 | await page.evaluate(() => { |
| 945 | const st = document.createElement('style'); |
| 946 | st.id = 'ink-probe'; |
| 947 | // FIXED and inside the viewport. Appended to `body` in normal flow the |
| 948 | // probe lands below `#app`, which is a full-height column, so it is off |
| 949 | // screen -- `drawn()` correctly discards it and the probe proves nothing |
| 950 | // except that the audit ignores what nobody can see. That is the exact |
| 951 | // shape of the sweep's own focus-ring probe defect, and it caught this |
| 952 | // file on its first run. |
| 953 | st.textContent = '#probe-ink { position: fixed; left: 20px; top: 240px; z-index: 99999;' |
| 954 | + ' color: #F2F0EC; background: #FBF9F5; font-size: 13px; padding: 4px; }'; |
| 955 | document.head.appendChild(st); |
| 956 | const d = document.createElement('div'); |
| 957 | d.id = 'probe-ink'; |
| 958 | d.textContent = 'a label nobody can read'; |
| 959 | document.body.appendChild(d); |
| 960 | }); |
| 961 | await page.waitForTimeout(150); |
| 962 | const after = await page.evaluate(INK); |
| 963 | const caught = after.some((f) => f.sel.includes('probe-ink')); |
| 964 | check(caught, 'ink audit goes red on a planted 1.05:1 label' |
| 965 | + (caught ? ` (${after.find((f) => f.sel.includes('probe-ink')).cr}:1)` : '')); |
| 966 | await page.evaluate(() => { |
| 967 | ['ink-probe', 'probe-ink'].forEach((id) => { const e = document.getElementById(id); if (e) e.remove(); }); |
| 968 | }); |
| 969 | await page.waitForTimeout(100); |
| 970 | const restored = (await page.evaluate(INK)).length; |
| 971 | check(restored === before, `ink audit returns to ${before} findings once the probe is gone (${restored})`); |
| 972 | } |
| 973 | |
| 974 | // 2. Ink, the subtler half: a label dimmed by an ANCESTOR's opacity, which no |
| 975 | // reading of the declared colour can see. The chain-alpha code is the only |
| 976 | // thing that catches it, so it is proved separately. |
| 977 | { |
| 978 | await page.evaluate(() => { |
| 979 | const wrap = document.createElement('div'); |
| 980 | wrap.id = 'probe-dim'; |
| 981 | wrap.style.cssText = 'position:fixed; left:20px; top:300px; z-index:99999;' |
| 982 | + ' background:#FBF9F5; opacity:0.30; padding:6px;'; |
| 983 | wrap.innerHTML = '<span id="probe-dim-in" style="color:#211C18; font-size:13px">dimmed to nothing</span>'; |
| 984 | document.body.appendChild(wrap); |
| 985 | }); |
| 986 | await page.waitForTimeout(150); |
| 987 | const after = await page.evaluate(INK); |
| 988 | const hit = after.find((f) => f.sel.includes('probe-dim')); |
| 989 | check(!!hit, 'ink audit goes red on text dimmed by an ancestor opacity' |
| 990 | + (hit ? ` (${hit.cr}:1, α${hit.alpha})` : '')); |
| 991 | await page.evaluate(() => { const e = document.getElementById('probe-dim'); if (e) e.remove(); }); |
| 992 | await page.waitForTimeout(100); |
| 993 | } |
| 994 | |
| 995 | // 2b. The placeholder pass, proved on its own: a real field, given a real |
| 996 | // `::placeholder` rule of the exact shape the one defective rule had |
| 997 | // (a rung dimmed with `opacity`). The element walk cannot see this at all, |
| 998 | // so nothing but this pass can go red on it. |
| 999 | { |
| 1000 | await page.evaluate(() => { |
| 1001 | const i = document.createElement('input'); |
| 1002 | i.id = 'probe-ph'; |
| 1003 | i.type = 'text'; |
| 1004 | i.placeholder = 'what this field wants'; |
| 1005 | i.style.cssText = 'position:fixed; left:20px; top:440px; z-index:99999; width:200px;' |
| 1006 | + ' background:#FBF9F5; border:1px solid #999;'; |
| 1007 | document.body.appendChild(i); |
| 1008 | const st = document.createElement('style'); |
| 1009 | st.id = 'probe-ph-style'; |
| 1010 | st.textContent = '#probe-ph::placeholder { color: #6B635A; opacity: 0.28; }'; |
| 1011 | document.head.appendChild(st); |
| 1012 | }); |
| 1013 | await page.waitForTimeout(150); |
| 1014 | const hit = (await page.evaluate(INK)).find((f) => f.sel.includes('probe-ph')); |
| 1015 | check(!!hit, 'ink audit goes red on a placeholder dimmed by `opacity`' |
| 1016 | + (hit ? ` (${hit.cr}:1, α${hit.alpha})` : '')); |
| 1017 | |
| 1018 | await page.evaluate(() => { |
| 1019 | document.getElementById('probe-ph-style').textContent = '#probe-ph::placeholder { color: #6B635A; }'; |
| 1020 | }); |
| 1021 | await page.waitForTimeout(150); |
| 1022 | const gone = (await page.evaluate(INK)).find((f) => f.sel.includes('probe-ph')); |
| 1023 | check(!gone, '…and green once the same placeholder drops the `opacity`'); |
| 1024 | await page.evaluate(() => { |
| 1025 | ['probe-ph', 'probe-ph-style'].forEach((id) => { const e = document.getElementById(id); if (e) e.remove(); }); |
| 1026 | }); |
| 1027 | await page.waitForTimeout(100); |
| 1028 | } |
| 1029 | |
| 1030 | // 3. Focus: a BUTTON given a `:focus` ring, which must be seen to paint for a |
| 1031 | // MOUSE click; the same button moved to `:focus-visible`, which must not; |
| 1032 | // and the same button with no rule at all, which must read as ringless. |
| 1033 | // Three directions on one element, because a check that had only ever seen |
| 1034 | // the wrong-ring case would pass a control that had no ring whatever. |
| 1035 | // |
| 1036 | // A button, not a text field. Chromium matches `:focus-visible` on any |
| 1037 | // plain click of a control that takes keyboard input, so on an `<input>` |
| 1038 | // the two selectors paint identically and the probe could never go green. |
| 1039 | // That is measured below rather than asserted from the spec. |
| 1040 | { |
| 1041 | await page.evaluate(() => { |
| 1042 | const b = document.createElement('button'); |
| 1043 | b.id = 'probe-ring'; |
| 1044 | b.textContent = 'ring'; |
| 1045 | b.style.cssText = 'position:fixed; left:20px; top:360px; z-index:99999;'; |
| 1046 | document.body.appendChild(b); |
| 1047 | const st = document.createElement('style'); |
| 1048 | st.id = 'probe-ring-style'; |
| 1049 | st.textContent = '#probe-ring:focus { outline: 3px solid #C00; outline-offset: 2px; }'; |
| 1050 | document.head.appendChild(st); |
| 1051 | }); |
| 1052 | await page.waitForTimeout(150); |
| 1053 | const wrong = await mouseThenKey('#probe-ring'); |
| 1054 | check(!!wrong && differs(wrong.rest, wrong.mouse), |
| 1055 | 'focus check goes red on a `:focus` ring painted by a mouse click'); |
| 1056 | check(!!wrong && differs(wrong.rest, wrong.key), |
| 1057 | 'focus check sees the same ring for the keyboard'); |
| 1058 | |
| 1059 | await page.evaluate(() => { |
| 1060 | document.getElementById('probe-ring-style').textContent = |
| 1061 | '#probe-ring:focus { outline: none; } ' |
| 1062 | + '#probe-ring:focus-visible { outline: 3px solid #C00; outline-offset: 2px; }'; |
| 1063 | }); |
| 1064 | await page.waitForTimeout(150); |
| 1065 | const right = await mouseThenKey('#probe-ring'); |
| 1066 | check(!!right && !differs(right.rest, right.mouse), |
| 1067 | 'focus check goes GREEN once the same control uses `:focus-visible`'); |
| 1068 | check(!!right && differs(right.rest, right.key), |
| 1069 | '…and still sees the keyboard ring'); |
| 1070 | |
| 1071 | // 4. No indicator at all -- the worse fault. Both readings must be silent. |
| 1072 | await page.evaluate(() => { |
| 1073 | document.getElementById('probe-ring-style').textContent = |
| 1074 | '#probe-ring:focus, #probe-ring:focus-visible { outline: none; }'; |
| 1075 | }); |
| 1076 | await page.waitForTimeout(150); |
| 1077 | const none = await mouseThenKey('#probe-ring'); |
| 1078 | check(!!none && !differs(none.rest, none.key), |
| 1079 | 'focus check reports NO ring when a control has no indicator at all'); |
| 1080 | check(!!none && hasRing(none.rest) === false, 'the ringless probe really has no ring at rest'); |
| 1081 | |
| 1082 | await page.evaluate(() => { |
| 1083 | ['probe-ring', 'probe-ring-style'].forEach((id) => { const e = document.getElementById(id); if (e) e.remove(); }); |
| 1084 | }); |
| 1085 | |
| 1086 | // 5. The measurement the whole `:focus` question turns on: does Chromium |
| 1087 | // call a plain click on a TEXT FIELD a keyboard focus? If it does, moving |
| 1088 | // a field's rule from `:focus` to `:focus-visible` changes nothing that |
| 1089 | // paints, and the report has to say so rather than claim a fix. |
| 1090 | await page.evaluate(() => { |
| 1091 | const i = document.createElement('input'); |
| 1092 | i.id = 'probe-text'; |
| 1093 | i.type = 'text'; |
| 1094 | i.style.cssText = 'position:fixed; left:20px; top:400px; z-index:99999; width:160px;'; |
| 1095 | document.body.appendChild(i); |
| 1096 | const st = document.createElement('style'); |
| 1097 | st.id = 'probe-text-style'; |
| 1098 | st.textContent = '#probe-text:focus { outline: none; } ' |
| 1099 | + '#probe-text:focus-visible { outline: 3px solid #C00; outline-offset: 2px; }'; |
| 1100 | document.head.appendChild(st); |
| 1101 | }); |
| 1102 | await page.waitForTimeout(150); |
| 1103 | const field = await mouseThenKey('#probe-text'); |
| 1104 | log(` note a text field on a plain click: :focus-visible = ${field && field.mouse && field.mouse.fv},` |
| 1105 | + ` ring painted = ${!!(field && differs(field.rest, field.mouse))}`); |
| 1106 | check(!!field && field.mouse.fv === true && differs(field.rest, field.mouse), |
| 1107 | 'Chromium treats a plain click on a text field as keyboard focus, so `:focus-visible` still paints'); |
| 1108 | await page.evaluate(() => { |
| 1109 | ['probe-text', 'probe-text-style'].forEach((id) => { const e = document.getElementById(id); if (e) e.remove(); }); |
| 1110 | }); |
| 1111 | } |
| 1112 | |
| 1113 | // 6. The Tab walk is a DIFFERENT code path from the click/Tab pair above, so it |
| 1114 | // is proved on its own: a focusable button that paints nothing when focused, |
| 1115 | // planted as the FIRST child of the rail's own list so the walk reaches it |
| 1116 | // while it is on screen. A probe the walk never visits, or visits below the |
| 1117 | // scrollport, would prove exactly nothing -- which is the shape of the fault |
| 1118 | // the desktop sweep's own ring probe was found to have. |
| 1119 | { |
| 1120 | await page.evaluate(() => { |
| 1121 | const st = document.createElement('style'); |
| 1122 | st.id = 'probe-dead-style'; |
| 1123 | st.textContent = '#probe-dead:focus, #probe-dead:focus-visible { outline: none; }'; |
| 1124 | document.head.appendChild(st); |
| 1125 | const b = document.createElement('button'); |
| 1126 | b.id = 'probe-dead'; |
| 1127 | b.textContent = 'no ring'; |
| 1128 | const host = document.getElementById('session-list') || document.body; |
| 1129 | host.insertBefore(b, host.firstChild); |
| 1130 | }); |
| 1131 | await page.waitForTimeout(200); |
| 1132 | const onScreen = await page.evaluate(() => { |
| 1133 | const e = document.getElementById('probe-dead'); |
| 1134 | const r = e.getBoundingClientRect(); |
| 1135 | return r.width > 1 && r.height > 1 && r.bottom > 0 && r.top < innerHeight; |
| 1136 | }); |
| 1137 | check(onScreen, 'the ringless Tab-walk probe is planted ON SCREEN, where the walk can see it'); |
| 1138 | |
| 1139 | const walkFor = async (id) => { |
| 1140 | // Same rewind as the walk itself: without it the second pass starts from |
| 1141 | // wherever the first one stopped and never reaches the probe again -- |
| 1142 | // which is how this proof first "failed" on a probe that was fine. |
| 1143 | await page.evaluate(() => { |
| 1144 | const b = document.body; |
| 1145 | b.setAttribute('tabindex', '-1'); |
| 1146 | b.focus(); |
| 1147 | b.removeAttribute('tabindex'); |
| 1148 | }); |
| 1149 | const hits = []; |
| 1150 | for (let i = 0; i < 60; i++) { |
| 1151 | await page.keyboard.press('Tab'); |
| 1152 | await page.waitForTimeout(25); |
| 1153 | const r = await page.evaluate(`(function () { |
| 1154 | const el = document.activeElement; |
| 1155 | if (!el || el === document.body) return null; |
| 1156 | const read = () => { const c = getComputedStyle(el); |
| 1157 | return [c.outlineStyle, c.outlineWidth, c.outlineColor, c.outlineOffset, |
| 1158 | c.boxShadow, c.borderTopWidth, c.borderTopStyle, c.borderTopColor, |
| 1159 | c.backgroundColor, c.color, c.textDecorationLine].join('|'); }; |
| 1160 | const focused = read(); el.blur(); const rest = read(); el.focus({ preventScroll: true }); |
| 1161 | return { id: el.id, same: focused === rest }; |
| 1162 | })()`); |
| 1163 | if (r && r.id === id) { hits.push(r.same); break; } |
| 1164 | } |
| 1165 | return hits; |
| 1166 | }; |
| 1167 | const found = await walkFor('probe-dead'); |
| 1168 | check(found.length > 0 && found[0] === true, |
| 1169 | 'the Tab walk goes red on a focusable control that paints nothing when focused'); |
| 1170 | |
| 1171 | // The same probe given a real ring must NOT be flagged, or the check is just |
| 1172 | // "everything is dead". |
| 1173 | await page.evaluate(() => { |
| 1174 | document.getElementById('probe-dead-style').textContent = |
| 1175 | '#probe-dead:focus { outline: 3px solid #C00; outline-offset: 2px; }'; |
| 1176 | }); |
| 1177 | await page.waitForTimeout(150); |
| 1178 | const found2 = await walkFor('probe-dead'); |
| 1179 | check(found2.length > 0 && found2[0] === false, |
| 1180 | '…and green once the same control draws one'); |
| 1181 | |
| 1182 | await page.evaluate(() => { |
| 1183 | ['probe-dead', 'probe-dead-style'].forEach((id) => { const e = document.getElementById(id); if (e) e.remove(); }); |
| 1184 | }); |
| 1185 | } |
| 1186 | |
| 1187 | await s.close(); |
| 1188 | log(`\n${bad ? `${bad} FAILED` : 'ALL PASS'}`); |
| 1189 | process.exit(bad ? 1 : 0); |