Oregami
Repositories/oxedyne/daimond

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.
29import fs from 'node:fs';
30import path from 'node:path';
31import { fileURLToPath } from 'node:url';
32import { open } from './harness.mjs';
33import { GW_PORT } from './ports.mjs';
34
35const HERE = path.dirname(fileURLToPath(import.meta.url));
36
37const QUICK = process.argv.includes('--quick');
38const SELF = process.argv.includes('--self');
39const PALETTES = QUICK
40 ? ['light', 'dark']
41 : ['light', 'mist', 'linen', 'lollypop', 'sage', 'dusk', 'dark', 'amber', 'midnight', 'forest', 'plum'];
42const SPACINGS = ['sharp', 'warm'];
43
44const log = (...a) => console.log(...a);
45let bad = 0;
46const 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.
80const RECORDED = {
81 'div#autoreload > div.ar-field > label.ar-label': 2.19,
82 'div#autoreload > div.ar-field > div.ar-hint': 2.19,
83};
84const 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.
92const 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.
110const 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.
115const 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 ───────────────────────────────────────────
120const 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 ─────────────────────────────────────────────────────────────────
261const s = await open({ name: 'focusink' });
262const page = s.page;
263const calm = async () => { await page.waitForTimeout(250); };
264
265const 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.
281const 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.
297const 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.
395const 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?
410const 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);
417const arrived = async (scene) => {
418 const q = SCENE_MARK[scene];
419 if (!q) return true;
420 return showing(q);
421};
422const 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.
425const 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.
454const 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.
463const 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.
489const 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.
497const 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};
529const BREAK = (process.argv.find((a) => a.startsWith('--break=')) || '').slice(8);
530if (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.
564const 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.
581const PARK = { x: 4, y: 4 };
582
583async 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.
638function 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
659const focusRows = [];
660if (!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.
718const tabDead = new Map();
719if (!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 ────────────────────────────────────────────────────
789const inkWorst = new Map(); // key -> worst finding
790if (!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.
868if (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.
880const 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.
935log('\n── proving both halves red ─────────────────────────────────────────');
936await closeAll();
937await setLook('light', 'sharp');
938await 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
1187await s.close();
1188log(`\n${bad ? `${bad} FAILED` : 'ALL PASS'}`);
1189process.exit(bad ? 1 : 0);