@junixlabs/uxcli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +8 -5
  2. package/bin/uxcli.js +6 -4
  3. package/package.json +1 -1
  4. package/src/browser.js +9 -2
  5. package/src/card.js +18 -11
  6. package/src/gate.js +9 -6
  7. package/src/page.js +20 -5
  8. package/src/png.js +13 -0
  9. package/src/probes/consistent-navigation/must-fail-buttons/review.html +80 -0
  10. package/src/probes/consistent-navigation/pair.json +13 -5
  11. package/src/probes/consistent-navigation/probe.js +1 -0
  12. package/src/probes/consistent-navigation/spec.md +11 -2
  13. package/src/probes/contrast/must-fail/index.html +1 -1
  14. package/src/probes/contrast/must-pass/index.html +1 -1
  15. package/src/probes/contrast/pair.json +3 -3
  16. package/src/probes/contrast/probe.js +1 -0
  17. package/src/probes/contrast/spec.md +5 -0
  18. package/src/probes/error-prevention/pair.json +5 -5
  19. package/src/probes/error-prevention/probe.js +8 -4
  20. package/src/probes/error-prevention/spec.md +11 -2
  21. package/src/probes/focus-visible/must-fail/index.html +1 -1
  22. package/src/probes/focus-visible/must-pass/index.html +1 -1
  23. package/src/probes/focus-visible/pair.json +3 -3
  24. package/src/probes/focus-visible/probe.js +91 -35
  25. package/src/probes/focus-visible/spec.md +30 -12
  26. package/src/probes/redundant-entry/pair.json +6 -6
  27. package/src/probes/redundant-entry/probe.js +4 -0
  28. package/src/probes/redundant-entry/spec.md +9 -1
  29. package/src/probes/text-spacing/must-fail/index.html +1 -1
  30. package/src/probes/text-spacing/must-pass/index.html +1 -1
  31. package/src/probes/text-spacing/pair.json +3 -3
  32. package/src/probes/text-spacing/probe.js +1 -0
  33. package/src/probes/text-spacing/spec.md +4 -0
  34. package/src/refute.js +2 -0
  35. package/src/run.js +10 -2
  36. package/src/tokens.js +11 -0
  37. package/test/fixtures/checkout/cart.html +1 -0
  38. package/test/fixtures/checkout/details.html +1 -0
  39. package/test/fixtures/checkout/done.html +1 -0
  40. package/test/fixtures/checkout/review.html +4 -0
@@ -3,11 +3,11 @@
3
3
  - **why:** SC text: at least one of *reversible*, *checked*, *confirmed* must be true for pages that cause legal/financial commitments or modify user data.
4
4
  - **applies-when:** the journey has a step marked `commit`, and at least one value was entered in an earlier step of the same process. Values of `type=password` are excluded. Credential fields are identity, not the information being submitted, and are excluded from the *confirmed* comparison: `type=password`, and, on a step whose fill includes a password, fields with `type=email`, `autocomplete` username/email, or name/id/label matching /user|login|email/i. Other fields on that step (a sign-up form's address) count. Credentials stay recorded for 3.3.7. If no non-credential value was recorded before the commit step (single-screen commit), the probe is `not-applicable`: it measures cross-screen review only.
5
5
  - **correct-when (any one branch):**
6
- - *confirmed*: on the screen holding the commit control, or on the screen of the step immediately before it when that step entered no values (a review step "before finalizing the submission", SC text), every recorded non-secret value is present as visible text or as the value of a read-only/disabled/pre-populated field (comparison after trimming, case-folding, collapsing whitespace; for `select`, the option's display text also counts), **and** a mechanism to correct exists: a link or button, outside site chrome (`header`, `nav`, `footer`, landmarks), whose href or text targets an earlier step that recorded non-authentication values (`href` equals that step's URL, or text matches /change|edit|back|modify/i), or the values are editable on the same screen. A logo or Home link to the journey's first step is not a correction mechanism.
6
+ - *confirmed*: on the screen holding the commit control, or on the screen of the step immediately before it when that step entered no values (a review step "before finalizing the submission", SC text), every recorded non-secret value is present as visible text or as the value of a read-only/disabled/pre-populated field (comparison after trimming, case-folding, collapsing whitespace; for `select`, the option's display text also counts), **and** a mechanism to correct exists: a link or button, outside site chrome (`header`, `nav`, `footer`, landmarks), whose href or text targets an earlier step that recorded non-authentication values (`href` equals that step's URL, or text matches /\b(change|edit|modify)\b/i, or is exactly "back" / "go back"), or every recorded value is editable on the same screen (one editable copy of one value is not a correction mechanism). A logo or Home link to the journey's first step is not a correction mechanism.
7
7
  - *checked*: the runner, in a separate pass, submits the entry step with one recorded field carrying a deliberate input error and reads the outcome. Field choice: a `required`/`aria-required` field emptied; else a `type=email` field set to `not-an-email`, `type=url` to `not-a-url`, a `minlength` field to one character; else the first recorded field emptied. Outcome: the step does not advance (same URL, or the same form still present) **and** either new visible text appears or native constraint validation fired (`:invalid` with a non-empty `validationMessage`) → *checked* holds. The step advances → *checked* is false only when the probed field was declared constrained (required, typed, minlength); an undeclared field may simply be optional, so advancing proves nothing and *checked* is `untested`. Programmatic association of the message is SC 3.3.1, not 3.3.4. The pass mutates the site and runs only where the journey marks `checkedPass: true`; otherwise `untested`.
8
8
  - *reversible*: declared in the journey (`reversible: <url or text>`), provenance `project`; the runner does not measure it.
9
9
  - **verdict:** `pass` with the satisfied branch named; `fail` only if *confirmed* is false, *checked* was **tested** and false, and *reversible* is not declared, listing the missing values; `unmeasurable` with `checked: untested` if *confirmed* is false and the checked pass was not allowed; `not-committed` if no `commit` step; `not-applicable` if no non-credential value was recorded before the commit step; `unmeasurable` if the commit screen could not be reached, was reached by direct navigation after the previous step's submit did not change the page (flow break: recorded values may not have carried), or the recorded values are empty.
10
- - **invalid-if:** a value was reformatted by the site (dates, phone numbers, card numbers) so that normalized comparison fails; then that value is reported as `finding`, not counted as missing. Screen reached by a redirect to a different origin (third-party payment) is out of scope for this probe and reported as `unmeasurable`.
10
+ - **invalid-if:** a value was reformatted by the site (dates, phone numbers, card numbers, reordered names such as "Doe, Jane") so that normalized comparison fails while every word of it is present; then that value is reported as `finding`, not counted as missing. Screen reached by a redirect to a different origin (third-party payment) is out of scope for this probe and reported as `unmeasurable`.
11
11
  - **known-infidelity:** matching is textual, not semantic (a review that shows "J. Smith" for "John Smith" fails); *checked* mutates the flow and is skipped when the entry step has side effects declared in the journey; *reversible* is trusted, not measured.
12
12
 
13
13
  ## Input: Journey input (the commitment)
@@ -33,3 +33,12 @@ Acceptance (from P0-B round 2, runner v6 issued `fail`): Sylius register→login
33
33
  ## Falsification pair
34
34
 
35
35
  See `pair.json`: one fixture where this probe must return `fail` for the stated operator, one where it must stay silent. `uxcli gate` runs both and checks the hashes. Written from the WCAG 2.2 Understanding text before any fixture or site was opened; revision history is kept outside the package.
36
+
37
+ ## Method status
38
+
39
+ `method-unproven`. Validated means: a recorded run with the packaged code on at least 20 pages or flows the probe had not seen when its definition was last revised, 0 false fails, plus a recall record (ACT cases or seeded defects). Until then a would-be `fail` is reported as `finding`. Record so far: P0-B ran pre-package code on 20 flows with two contested fails; no unseen-flow run with the packaged code yet.
40
+
41
+ ## Revisions
42
+
43
+ - 2026-09-06 change-control text rule: `back` only as the whole label ("Back to shop" is a logo-class link, a constructed false pass); a value whose every word is present in another order is reformatted (`finding`), not missing (a constructed false fail); a flow break joined by `sameProcess` reaches the verdict instead of `unmeasurable`. A submit that leaves the URL unchanged no longer breaks the process when the journey's `expect` selector appears or the submitted form is gone / the main heading changed (same-URL wizards).
44
+ - 2026-09-06 'editable on the same screen' requires every recorded value, not any. Reason: a seeded removal of all Change links was missed because the pre-populated receipt email (one value) was editable on the review screen.
@@ -1 +1 @@
1
- <!doctype html><html lang="en"><head><meta charset="utf-8"><title>Account settings</title><style>body{font:16px/1.4 system-ui,sans-serif;margin:2rem;color:#222;background:#fff}form{display:grid;gap:.75rem;max-width:22rem}a,button{color:#1a4fd6}input,select{transition:box-shadow .3s}input:focus-visible,select:focus-visible{box-shadow:0 0 0 3px #9cc4ff}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#1a9c4a;animation:pulse 1.2s ease-in-out infinite}@keyframes pulse{50%{opacity:.4;transform:scale(.8)}}*:focus,*:focus-visible{outline:none}</style></head><body><h1>Account settings</h1><p><span class="dot" aria-hidden="true"></span> Signed in</p><nav><a href="#profile">Profile</a> · <a href="#security">Security</a></nav><form><label>Email <input id="email" type="email" autofocus value="jane@example.com"></label><label>Display name <input id="name" value="Jane"></label><label>Language <select id="lang"><option>English</option><option>Tiếng Việt</option></select></label><button type="submit">Save</button></form></body></html>
1
+ <!doctype html><html lang="en"><head><meta charset="utf-8"><title>Account settings</title><style>body{font:16px/1.4 system-ui,sans-serif;margin:2rem;color:#222;background:#fff}form{display:grid;gap:.75rem;max-width:22rem}a,button{color:#1a4fd6}input,select{transition:box-shadow .3s}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#1a9c4a;animation:pulse 1.2s ease-in-out infinite}@keyframes pulse{50%{opacity:.4;transform:scale(.8)}}*:focus,*:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(0,0,0,0)}:focus{outline:none;box-shadow:none}.kb-focus{outline:2px solid rgba(0,0,0,0);outline-offset:2px}.late.kb-focus{outline:none}.late.kb-late{outline:2px solid rgba(0,0,0,0);outline-offset:2px}.stack{position:relative;height:0;overflow:hidden}.ghost{opacity:0}</style><script>let kb=0;addEventListener('keydown',()=>{kb=performance.now()},true);addEventListener('focusin',e=>{if(performance.now()-kb<100)e.target.classList.add('kb-focus')},true);addEventListener('focusin',e=>{if(e.target.classList.contains('late')&&performance.now()-kb<100)setTimeout(()=>{if(document.activeElement===e.target)e.target.classList.add('kb-late')},200)},true);addEventListener('focusout',e=>{e.target.classList.remove('kb-focus');e.target.classList.remove('kb-late')},true)</script></head><body><h1>Account settings</h1><p><span class="dot" aria-hidden="true"></span> Signed in</p><nav><a href="#profile">Profile</a> · <a href="#security">Security</a></nav><form><label>Email <input id="email" type="email" autofocus value="jane@example.com"></label><label>Display name <input id="name" value="Jane"></label><label>Language <select id="lang"><option>English</option><option>Tiếng Việt</option></select></label><button type="submit" class="late">Save</button></form><div class="stack"><button type="button">Copy code</button></div><p class="ghost"><a href="#promo">Promotion (hidden panel)</a></p><ins class="adsbygoogle" style="display:block;margin-top:2rem;padding:1rem;background:#fff"><a href="#ad" style="outline:none;color:#999">Sponsored: a link with no focus style</a><p style="letter-spacing:0 !important;color:#9a9a9a">Ad copy locked at zero letter spacing in low contrast, inside a third-party container.</p></ins></body></html>
@@ -1 +1 @@
1
- <!doctype html><html lang="en"><head><meta charset="utf-8"><title>Account settings</title><style>body{font:16px/1.4 system-ui,sans-serif;margin:2rem;color:#222;background:#fff}form{display:grid;gap:.75rem;max-width:22rem}a,button{color:#1a4fd6}input,select{transition:box-shadow .3s}input:focus-visible,select:focus-visible{box-shadow:0 0 0 3px #9cc4ff}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#1a9c4a;animation:pulse 1.2s ease-in-out infinite}@keyframes pulse{50%{opacity:.4;transform:scale(.8)}}</style></head><body><h1>Account settings</h1><p><span class="dot" aria-hidden="true"></span> Signed in</p><nav><a href="#profile">Profile</a> · <a href="#security">Security</a></nav><form><label>Email <input id="email" type="email" autofocus value="jane@example.com"></label><label>Display name <input id="name" value="Jane"></label><label>Language <select id="lang"><option>English</option><option>Tiếng Việt</option></select></label><button type="submit">Save</button></form></body></html>
1
+ <!doctype html><html lang="en"><head><meta charset="utf-8"><title>Account settings</title><style>body{font:16px/1.4 system-ui,sans-serif;margin:2rem;color:#222;background:#fff}form{display:grid;gap:.75rem;max-width:22rem}a,button{color:#1a4fd6}input,select{transition:box-shadow .3s}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#1a9c4a;animation:pulse 1.2s ease-in-out infinite}@keyframes pulse{50%{opacity:.4;transform:scale(.8)}}:focus{outline:none;box-shadow:none}.kb-focus{outline:2px solid #1a4fd6;outline-offset:2px}.late.kb-focus{outline:none}.late.kb-late{outline:2px solid #1a4fd6;outline-offset:2px}.stack{position:relative;height:0;overflow:hidden}.ghost{opacity:0}</style><script>let kb=0;addEventListener('keydown',()=>{kb=performance.now()},true);addEventListener('focusin',e=>{if(performance.now()-kb<100)e.target.classList.add('kb-focus')},true);addEventListener('focusin',e=>{if(e.target.classList.contains('late')&&performance.now()-kb<100)setTimeout(()=>{if(document.activeElement===e.target)e.target.classList.add('kb-late')},200)},true);addEventListener('focusout',e=>{e.target.classList.remove('kb-focus');e.target.classList.remove('kb-late')},true)</script></head><body><h1>Account settings</h1><p><span class="dot" aria-hidden="true"></span> Signed in</p><nav><a href="#profile">Profile</a> · <a href="#security">Security</a></nav><form><label>Email <input id="email" type="email" autofocus value="jane@example.com"></label><label>Display name <input id="name" value="Jane"></label><label>Language <select id="lang"><option>English</option><option>Tiếng Việt</option></select></label><button type="submit" class="late">Save</button></form><div class="stack"><button type="button">Copy code</button></div><p class="ghost"><a href="#promo">Promotion (hidden panel)</a></p><ins class="adsbygoogle" style="display:block;margin-top:2rem;padding:1rem;background:#fff"><a href="#ad" style="outline:none;color:#999">Sponsored: a link with no focus style</a><p style="letter-spacing:0 !important;color:#9a9a9a">Ad copy locked at zero letter spacing in low contrast, inside a third-party container.</p></ins></body></html>
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "kind": "page",
3
- "operator": "index.html: a stylesheet rule *:focus,*:focus-visible{outline:none} removes the default focus ring from every control; the must-pass twin is identical without that rule. Both carry an autofocus input whose focus ring transitions over 300 ms and an infinitely pulsing status dot.",
3
+ "operator": "index.html: a stylesheet rule *:focus,*:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(0,0,0,0)} replaces the focus ring with a fully transparent shadow: the computed style changes, no pixel does. The must-fail twin's detector class is a transparent outline too. The must-pass twin draws its ring through a keyboard-mode detector: a class added on focusin only when a keydown happened within 100 ms, as focus-visible polyfills do, so focus that does not arrive by a key press shows nothing. The Save button draws its ring 200 ms after keyboard focus, by script. Both twins carry an autofocus input with a 300 ms box-shadow transition, an infinitely pulsing status dot, a button inside a zero-height overflow:hidden container and a link inside an opacity:0 paragraph (in the tab order, never painted: reported as a finding, not measured), and an ins.adsbygoogle block with a focus-less link, a zero letter-spacing lock and low-contrast text: the third-party exclusion must keep all three probes silent on it.",
4
4
  "hashes": {
5
5
  "mustFail": {
6
- "index.html": "331ed671dfa5ab5a23cc39bbf6a99973017a9f7719a3afcf3d591b42346d823e"
6
+ "index.html": "526b20d6322d02032535585237cc613a88eac6210fd1dee774f5ee1c32f99bce"
7
7
  },
8
8
  "mustPass": {
9
- "index.html": "6ab04dd5a3dad058ca4743bcb1f1f6d1c257c60e3ffc921bf2ef81122659e4f7"
9
+ "index.html": "ce9f4f891ed9651acfa96f952d28e1f227752dcf147fba1307204a556b189e38"
10
10
  }
11
11
  }
12
12
  }
@@ -1,44 +1,100 @@
1
1
  // page.focus-visible · WCAG 2.4.7. Spec in spec.md; falsification pair in pair.json.
2
2
  import { THIRD } from '../../util.js';
3
+ import { PNG } from '../../png.js';
4
+ const PAD = 48, VW = 1280, VH = 800;
3
5
 
4
6
  export default {
5
7
  id: 'page.focus-visible', sc: '2.4.7', kind: 'page',
8
+ method: { status: 'method-validated', record: '20 unseen pages (list 4, 2026-09-06, drawn after the v3.8 definition was committed): 62 failing controls on 7 pages, 58 re-measured by a real Tab press and a whole-viewport diff, 4 by the viewport diff alone (the check could not land Tab on them), 0 false fails, 2 pages no verdict; 60 earlier unseen pages found and fixed seven false-fail classes (spec Revisions); ACT oj04fd 7/7 with the packaged code' },
6
9
  async measure(page) {
7
- const r = await page.evaluate(async ([THIRD, maxTargets]) => {
8
- const PROPS = ['outline-style', 'outline-width', 'outline-color', 'box-shadow', 'border-color', 'background-color', 'color', 'text-decoration-line', 'opacity', 'visibility', 'display'];
9
- const raf2 = () => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
10
- // Elements with an infinitely repeating animation (pulses, spinners) are excluded from the document diff; a target is always diffed itself.
11
- const animating = new Set(document.getAnimations().filter(a => a.effect?.getTiming?.().iterations === Infinity).map(a => a.effect.target).filter(Boolean));
12
- const all = keep => [...document.querySelectorAll('body, body *')].filter(e => e === keep || !animating.has(e));
13
- const snap = els => els.map(el => { const cs = getComputedStyle(el); const r = el.getBoundingClientRect(); const pinned = cs.position === 'fixed' || cs.position === 'sticky'; const zero = r.width === 0 && r.height === 0; const rect = zero ? [0] : pinned ? [r.width, r.height] : [r.x + scrollX, r.y + scrollY, r.width, r.height]; return PROPS.map(p => cs.getPropertyValue(p)).join('|') + '|' + rect.map(v => Math.round(v)).join(','); });
14
- const sel = el => el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : '');
15
- const cands = [...document.querySelectorAll('a[href],button,input,select,textarea,summary,[tabindex],[contenteditable]')]
16
- .filter(e => { const ti = e.getAttribute('tabindex'); if (ti !== null && parseInt(ti) < 0) return false; if (e.disabled) return false; if (e.closest(THIRD)) return false; const cs = getComputedStyle(e); if (cs.visibility === 'hidden' || cs.display === 'none') return false; return e.getClientRects().length > 0; });
17
- const targets = cands.slice(0, maxTargets);
18
- // Revision 2026-09-06: blur first, so an autofocused control is not already focused in its own before-snapshot.
19
- if (document.activeElement && document.activeElement !== document.body) document.activeElement.blur(); await raf2();
20
- // Quiesce: wait for a 400 ms window with no change (up to six windows, transitions from the blur included) before the noise window is judged.
21
- const els0 = all(); let noise = 0, quiet = 0;
22
- for (let w = 0; w < 6; w++) { const n0 = snap(els0); await new Promise(r => setTimeout(r, 400)); const n1 = snap(els0); noise = 0; for (let i = 0; i < n0.length; i++) if (n0[i] !== n1[i]) noise++; if (noise === 0) break; quiet = w + 1; }
23
- const items = [];
24
- for (const el of targets) {
25
- const els = all(el); const before = snap(els);
26
- el.focus({ focusVisible: true, preventScroll: false }); await raf2();
27
- if (document.activeElement !== el) { items.push({ sel: sel(el), skipped: 'no-focus' }); continue; }
28
- const after = snap(els);
29
- let changed = 0, selfChanged = false; for (let i = 0; i < before.length; i++) if (before[i] !== after[i] && els[i].isConnected) { changed++; if (els[i] === el) selfChanged = true; }
30
- el.blur(); await raf2();
31
- const r = el.getBoundingClientRect();
32
- items.push({ sel: sel(el), text: (el.textContent || el.value || '').trim().slice(0, 40), changed, selfChanged, shift: !selfChanged && changed > Math.max(20, els.length * 0.3), inViewport: r.bottom > 0 && r.top < innerHeight });
10
+ const n = await page.evaluate(THIRD => {
11
+ window.__uxfv = [...document.querySelectorAll('a[href],button,input,select,textarea,summary,[tabindex],[contenteditable]')]
12
+ .filter(e => { const ti = e.getAttribute('tabindex'); if (ti !== null && parseInt(ti) < 0) return false; if (e.disabled) return false; if (e.closest(THIRD)) return false; const cs = getComputedStyle(e); if (cs.visibility === 'hidden' || cs.display === 'none') return false; return e.getClientRects().length > 0; })
13
+ .slice(0, 80);
14
+ if (document.activeElement && document.activeElement !== document.body) document.activeElement.blur();
15
+ return window.__uxfv.length;
16
+ }, THIRD);
17
+ if (n === 0) return { verdict: 'not-applicable', why: 'no focusable element', candidates: 0 };
18
+ const shot = clip => page.screenshot({ clip, animations: 'disabled', caret: 'hide', timeout: 5000 });
19
+ const full = () => page.screenshot({ animations: 'disabled', caret: 'hide', timeout: 8000 });
20
+ const frames = () => page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
21
+ const blur = () => page.evaluate(() => { if (document.activeElement && document.activeElement !== document.body) document.activeElement.blur(); }).catch(() => {});
22
+ const items = [], hidden = [];
23
+ for (let i = 0; i < n; i++) {
24
+ const readBox = (i, scroll) => page.evaluate(async ([i, scroll]) => {
25
+ const el = window.__uxfv[i]; if (scroll) { el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); await new Promise(r => requestAnimationFrame(r)); }
26
+ // Finish finite animations (scroll-reveal transforms) so the box is read in the state the screenshots freeze to.
27
+ for (const a of document.getAnimations()) { try { const tm = a.effect?.getTiming?.(); if (tm && tm.iterations !== Infinity) a.finish(); } catch {} } await new Promise(r => requestAnimationFrame(r));
28
+ // The box is the union of the control's own box and its content's box: an inline link wrapping an image has a one-line box while its ring is drawn around the image.
29
+ const r0 = el.getBoundingClientRect(); const rg = document.createRange(); rg.selectNodeContents(el); const rc = rg.getBoundingClientRect();
30
+ const x1 = Math.min(r0.left, rc.width ? rc.left : r0.left), y1 = Math.min(r0.top, rc.height ? rc.top : r0.top), x2 = Math.max(r0.right, rc.width ? rc.right : r0.right), y2 = Math.max(r0.bottom, rc.height ? rc.bottom : r0.bottom);
31
+ const r = { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
32
+ const sel = el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : '');
33
+ // Painted: effective opacity above zero, and a hit test at the box centre or one of four inner points lands on the control or its content.
34
+ // Clipped: an overflow-hidden ancestor whose box the control lies outside of. A hit on an ancestor means the control is not painted there (hidden tab panel).
35
+ let opacity = 1, clipped = false; for (let a = el; a; a = a.parentElement) { const cs = getComputedStyle(a); opacity *= parseFloat(cs.opacity); if (a !== el && /hidden|clip|scroll|auto/.test(cs.overflow + cs.overflowX + cs.overflowY)) { const b = a.getBoundingClientRect(); if (Math.min(r.x + r.width, b.right) - Math.max(r.x, b.left) <= 0 || Math.min(r.y + r.height, b.bottom) - Math.max(r.y, b.top) <= 0) clipped = true; } }
36
+ const pts = [[.5, .5], [.25, .5], [.75, .5], [.5, .25], [.5, .75]].map(([fx, fy]) => [Math.min(innerWidth - 1, Math.max(0, r.x + r.width * fx)), Math.min(innerHeight - 1, Math.max(0, r.y + r.height * fy))]);
37
+ const hits = pts.map(([x, y]) => document.elementFromPoint(x, y)); const own = hits.some(h => h && (h === el || el.contains(h)));
38
+ const covered = !own && hits.some(h => h && !h.contains(el));
39
+ return { sel, text: (el.textContent || el.value || el.getAttribute('aria-label') || '').trim().slice(0, 40), x: r.x, y: r.y, w: r.width, h: r.height, opacity, clipped, own, covered };
40
+ }, [i, scroll]);
41
+ const cropOf = t => { const x0 = Math.max(0, t.x - PAD), y0 = Math.max(0, t.y - PAD); return { x: x0, y: y0, width: Math.min(VW - x0, t.x + t.w + PAD - x0), height: Math.min(VH - y0, t.y + t.h + PAD - y0) }; };
42
+ const offscreen = (t, clip) => clip.width < 1 || clip.height < 1 || t.x + t.w < 0 || t.y + t.h < 0 || t.x > VW || t.y > VH;
43
+ const t = await readBox(i, true); let clip = cropOf(t);
44
+ const item = { sel: t.sel, text: t.text };
45
+ if (t.w === 0 || t.h === 0) { items.push({ ...item, unmeasured: 'zero-size' }); continue; }
46
+ if (offscreen(t, clip)) { items.push({ ...item, unmeasured: 'outside the viewport after scrolling' }); continue; }
47
+ if (t.opacity === 0 || t.clipped || (!t.own && !t.covered)) { hidden.push({ ...item, how: t.opacity === 0 ? 'opacity 0 on the control or an ancestor' : t.clipped ? 'clipped by an overflow-hidden ancestor it lies outside of' : 'not painted at its own box' }); items.push({ ...item, unmeasured: 'not painted' }); continue; }
48
+ if (t.covered) { items.push({ ...item, unmeasured: 'covered by another element' }); continue; }
49
+ // Focus arrives by a real Tab key press: focus the control, Shift+Tab to whatever precedes it, Tab back. Pages that draw rings only when
50
+ // focus follows a key event are measured as a keyboard user meets them.
51
+ const tabTo = async i => {
52
+ const landed = await page.evaluate(i => { const el = window.__uxfv[i]; el.focus({ preventScroll: true }); return document.activeElement === el; }, i);
53
+ if (!landed) return 'did not take focus';
54
+ await page.keyboard.press('Shift+Tab'); await page.keyboard.press('Tab');
55
+ return (await page.evaluate(i => document.activeElement === window.__uxfv[i], i)) ? null : 'Tab did not reach the control';
56
+ };
57
+ const notReached = await tabTo(i); if (notReached) { await blur(); items.push({ ...item, unmeasured: notReached }); continue; }
58
+ await frames();
59
+ // The predecessor's focus may have opened something or scrolled the page: read the box again in the focused state.
60
+ const t2 = await readBox(i, false); clip = cropOf(t2);
61
+ if (t2.w === 0 || t2.h === 0 || offscreen(t2, clip)) { await blur(); items.push({ ...item, unmeasured: 'outside the viewport after Tab' }); continue; }
62
+ if (t2.opacity === 0 || t2.clipped || (!t2.own && !t2.covered)) { await blur(); items.push({ ...item, unmeasured: 'not painted after Tab' }); continue; }
63
+ if (t2.covered) { await blur(); items.push({ ...item, unmeasured: 'covered after Tab' }); continue; }
64
+ let b, bv; try { b = await shot(clip); bv = await full(); } catch { await blur(); items.push({ ...item, unmeasured: 'screenshot timed out' }); continue; }
65
+ await blur(); await frames();
66
+ // A focus trap that takes focus back on blur would put the ring into the "unfocused" shots.
67
+ const stillFocused = await page.evaluate(() => document.activeElement && document.activeElement !== document.body && document.activeElement !== document.documentElement);
68
+ if (stillFocused) { items.push({ ...item, unmeasured: 'focus returned on blur' }); continue; }
69
+ let a1, a2; try { a1 = await shot(clip); a2 = await shot(clip); } catch { items.push({ ...item, unmeasured: 'screenshot timed out' }); continue; }
70
+ if (PNG.diff(a1, a2) > 0) { items.push({ ...item, unmeasured: 'changes with no interaction' }); continue; }
71
+ let changed = PNG.diff(a1, b), av = null;
72
+ if (changed === 0) {
73
+ // Nothing changed near the control. Before calling that a fail, look at the whole viewport: a change away from the control
74
+ // (a card highlighted, a heading underlined, a menu opening) may be the indicator, or may be unrelated; either way it is not a measured absence.
75
+ try { av = await full(); } catch { items.push({ ...item, unmeasured: 'screenshot timed out' }); continue; }
76
+ const away = PNG.diff(av, bv);
77
+ if (away > 0) { items.push({ ...item, unmeasured: 'changes away from the control', awayPixels: away }); continue; }
33
78
  }
34
- const focusable = items.filter(i => !i.skipped);
35
- return { candidates: cands.length, tested: targets.length, focusable: focusable.length, noise, settledAfter: quiet, animatingExcluded: animating.size, fails: focusable.filter(i => i.changed === 0), shifted: focusable.filter(i => i.shift).length };
36
- }, [THIRD, 80]);
37
- const base = { candidates: r.candidates, tested: r.tested, noise: r.noise, settledAfter: r.settledAfter, animatingExcluded: r.animatingExcluded, shifted: r.shifted };
38
- if (r.focusable === 0) return { verdict: 'not-applicable', why: 'no focusable element', ...base };
39
- if (r.noise > 0) return { verdict: 'unmeasurable', why: `document keeps changing with no interaction (${r.noise} elements in the last 400 ms window of 2.4 s)`, ...base };
40
- if (r.fails.length) return { verdict: 'fail', why: `${r.fails.length} of ${r.focusable} focusable elements change nothing on focus`, targets: r.fails, ...base };
41
- if (r.shifted) return { verdict: 'unmeasurable', why: `${r.shifted} targets move most of the page without changing themselves (layout shift)`, ...base };
42
- return { verdict: 'pass', why: `${r.focusable} focusable elements each change on focus`, ...base };
79
+ if (changed === 0) {
80
+ // Still nothing. An indicator drawn by script on a timer would be missed two frames after focus: Tab again, wait 250 ms, look once more.
81
+ const again = await tabTo(i); if (again) { await blur(); items.push({ ...item, unmeasured: again }); continue; }
82
+ await page.waitForTimeout(250);
83
+ let b2, bv2; try { b2 = await shot(clip); bv2 = await full(); } catch { await blur(); items.push({ ...item, unmeasured: 'screenshot timed out' }); continue; }
84
+ await blur(); await frames();
85
+ changed = PNG.diff(a1, b2); if (changed > 0) b = b2;
86
+ else if (PNG.diff(av, bv2) > 0) { items.push({ ...item, unmeasured: 'changes away from the control', awayPixels: PNG.diff(av, bv2) }); continue; }
87
+ }
88
+ items.push({ ...item, changedPixels: changed, crop: clip, before: a1, after: b });
89
+ }
90
+ const measured = items.filter(i => i.changedPixels !== undefined), unmeasured = items.filter(i => i.unmeasured);
91
+ const fails = measured.filter(i => i.changedPixels === 0);
92
+ const reasons = {}; for (const u of unmeasured) reasons[u.unmeasured] = (reasons[u.unmeasured] || 0) + 1;
93
+ const pub = i => ({ sel: i.sel, text: i.text, changedPixels: i.changedPixels });
94
+ const base = { candidates: n, measured: measured.length, unmeasured: reasons };
95
+ if (hidden.length) base.finding = { kind: 'hidden-focusable', why: `${hidden.length} control${hidden.length > 1 ? 's are' : ' is'} in the tab order but not painted (${[...new Set(hidden.map(h => h.how))].join('; ')}); not measured for 2.4.7`, targets: hidden.slice(0, 5) };
96
+ if (fails.length) return { verdict: 'fail', why: `${fails.length} of ${measured.length} measured controls show no pixel change on focus`, targets: fails.map(pub), evidence: fails.map(i => ({ sel: i.sel, before: i.before, after: i.after })), ...base };
97
+ if (measured.length < Math.max(1, (n - hidden.length) / 2)) return { verdict: 'unmeasurable', why: `only ${measured.length} of ${n - hidden.length} controls could be measured (${Object.entries(reasons).map(([k, v]) => `${v} ${k}`).join(', ')})`, ...base };
98
+ return { verdict: 'pass', why: `${measured.length} controls each change visibly on focus${unmeasured.length ? `, ${unmeasured.length} not measured` : ''}`, ...base };
43
99
  },
44
100
  };
@@ -1,18 +1,36 @@
1
1
  # page.focus-visible · WCAG 2.4.7 · provenance spec
2
2
 
3
- - **why:** SC text: any keyboard operable user interface has a mode of operation where the keyboard focus indicator is visible. A focusable element that changes nothing when it receives focus has no indicator.
4
- - **applies-when:** element is in the sequential focus order (`a[href], button, input, select, textarea, summary, [tabindex>=0], [contenteditable]`), not disabled, computed `visibility != hidden`, `display != none`, at least one client rect, and not inside a third-party subtree (`iframe`, ad containers). First 80 such elements per page.
5
- - **correct-when:** after the active element is blurred and the document has been quiet for one 400 ms window (up to six windows are waited), then `focus({focusVisible:true})` on the target and two animation frames, at least one element in the document changed any of: `outline-style, outline-width, outline-color, box-shadow, border-color, background-color, color, text-decoration-line, opacity, visibility, display`, or its document-relative rect. Rect is document-relative (`+scrollX/Y`); fixed/sticky elements compare size only; 0×0 elements are recorded as a constant. Detached nodes and elements carrying an infinitely repeating animation (`getAnimations()` with infinite iterations) are excluded from the diff; the focused target itself is always compared. The count of excluded animating elements is reported.
6
- - **verdict:** `fail` if any target changes nothing, listing the targets; `unmeasurable` if the document still changes with no interaction after six 400 ms windows (noise > 0) or a target changes more than max(20, 30%) of elements without changing itself (layout shift); `pass` otherwise; `not-applicable` if no focusable element.
7
- - **invalid-if:** element still outside the viewport after focus; static CSSOM read instead of runtime; document self-mutating; mass change with target unchanged; page title matches a bot-challenge pattern (`unmeasurable · blocked`).
8
- - **known-infidelity:** computed style pixels (a border→outline swap of the same colour passes); AT-specific focus not simulated; a change on a sibling counts as an indicator even when it is not near the target.
3
+ - **why:** SC text: any keyboard operable user interface has a mode of operation where the keyboard focus indicator is visible. A focusable control whose rendered pixels do not change when it receives focus has no visible indicator.
4
+ - **applies-when:** element is in the sequential focus order (`a[href], button, input, select, textarea, summary, [tabindex>=0], [contenteditable]`), not disabled, computed `visibility != hidden`, `display != none`, at least one client rect, not inside a third-party subtree (`iframe`, ad containers), and painted: effective opacity above zero (product over the control and its ancestors), its box intersects every `overflow` hidden/clip/scroll ancestor's box, and a hit test at the centre of its box or at one of four inner points lands on the control or its content. First 80 such elements per page, in document order. A control in the tab order that is not painted (opacity 0, clipped by a zero-height `overflow:hidden` ancestor, behind a hidden tab panel) is reported as a `finding` (`hidden-focusable`) and not measured.
5
+ - **correct-when:** per control: the active element is blurred; the control is scrolled to the centre of a 1280×800 viewport (instant scroll, so a page's smooth-scroll setting cannot leave it in flight) and every finite animation is finished, so the box is read in the state the screenshots freeze to. Focus then arrives by a real key press: the control is focused, Shift+Tab moves to whatever precedes it, Tab moves back; the control must be the active element afterwards. Two animation frames pass; the box and the hit test are read again in this state (the predecessor's focus may have opened or scrolled something); a screenshot of the control's box (the union of its own box and its content's box) padded by 48 px and a whole-viewport screenshot are taken in the focused state; the control is blurred, two frames pass, nothing may hold focus, and the crop is taken twice in the unfocused state. When the crop and the viewport show no change, focus is given the same way once more, 250 ms pass, and both are read again, so an indicator drawn by script on a timer is not missed. Screenshots freeze CSS animations and transitions at their end state and hide the caret. The control is *indicated* when at least one pixel of the crop differs by more than 8/255 in any RGB channel between the focused and unfocused screenshots. When the crop shows no change, the whole viewport is compared the same way: a change anywhere else on screen means the control is *not measured* (the change may be the indicator drawn elsewhere, or unrelated); only a control whose focus changes no pixel in the entire viewport is *not indicated*.
6
+ - **verdict:** `fail` if any measured control is not indicated, listing the controls with their before and after crops; `pass` if every measured control is indicated and at least half of the candidates could be measured; `unmeasurable` if fewer than half could be measured, with the reasons; `not-applicable` if no focusable element.
7
+ - **invalid-if (per control, reported as not measured, never as fail):** focus changes pixels away from the control but none near it; zero-size box; box outside the viewport after scrolling; centre of the box covered by another element (`elementFromPoint`); the two unfocused screenshots differ (content changing with no interaction); the control did not take focus; Tab did not reach the control (a focus trap, a widget that moves focus on its own); after Tab the control is off-screen, not painted or covered (the predecessor's focus changed the page); focus returned on blur (a trap); a screenshot timed out (fonts still loading). Page title matches a bot-challenge pattern `unmeasurable · blocked`.
8
+ - **known-infidelity:** a control with no indicator whose focus changes something else on screen is not measured, not failed; an unrelated change within 48 px of the control (a tooltip, a menu opening, a layout shift) counts as indicated; an indicator drawn only in the unmeasured region of a partly visible control is missed; the 8/255 threshold treats a ring of nearly the background colour as no indicator; focus is measured as it arrives by Tab, never by mouse or script; a change that persists after blur (a tooltip that stays) is not seen; AT-specific focus is not simulated. A painted control whose five-point hit test lands on an ancestor is reported as `hidden-focusable` and not measured (six carousel cards on one unseen page, 2026-09-07): a false finding, never a false fail.
9
9
 
10
10
  ## Revisions
11
11
 
12
- v1 frozen 2026-09-05 (P0-A, sha256 `375832c3…da11bc`, verdicts `passed/failed/inapplicable/blocked`). ACT oj04fd 7/7 with v1.
12
+ v1 frozen 2026-09-05 (P0-A, sha256 `375832c3…da11bc`): computed-style and rect diff over the whole document after focus; verdicts `passed/failed/inapplicable/blocked`. ACT oj04fd 7/7.
13
13
 
14
- - 2026-09-06 **blur before the before-snapshot.** v1 took the before-snapshot with the page's autofocused control already focused, so that control read as "changes nothing" (false fail on two real login screens). The must-pass twin carries an `autofocus` control so the gate guards this.
15
- - 2026-09-06 **quiesce before the noise window.** The blur itself starts the focus ring's fade-out transition, which v1's single 400 ms window read as noise (two real screens went `unmeasurable`). Up to six 400 ms windows are waited for one quiet window; the must-pass twin transitions its focus ring so the gate guards this. Pages that never go quiet stay `unmeasurable`.
16
- - 2026-09-06 **infinitely animating elements excluded from the diff.** A pulsing status dot kept one real screen `unmeasurable` after quiesce; such elements can never be quiet and carry no focus information unless they are the target. The must-pass twin carries a pulsing dot so the gate guards this.
17
- - 2026-09-06 **third-party subtrees excluded** from targets, carried from the P0-A text-spacing finding.
18
- - 2026-09-06 verdict names mapped to the repo's list: `pass`, `fail`, `not-applicable`, `unmeasurable`; `blocked` is an `unmeasurable` reason.
14
+ v2 2026-09-06 (0.2.0, 0.2.1): v1 plus blur before the snapshot (autofocus false fail on two real screens), quiesce over up to six 400 ms windows, infinitely animating elements excluded from the diff, third-party subtrees excluded, verdict names mapped to the repo's list. ACT 7/7; 20 P0-A pages 0 false fails.
15
+
16
+ v3 2026-09-06: **the measurement is pixels, not computed style.** An independent screenshot check of v2's results found 17 of 64 controls on one page and 4 of 18 on another whose computed style changed on focus while no pixel did (a transparent box-shadow, a colour change under `opacity:0`), which v2 called indicated. v3 measures the rendered crop of each control; per-control conditions that make a real ring invisible in a crop (off-viewport, covered, zero-size, changing on its own, no focus taken) are reported as not measured, never as fail. Document-wide noise, the quiesce wait and the animation exclusion are retired: screenshots freeze animations and each control is checked for self-change. The must-fail twin's mutation is a focus style that changes computed values without pixels, which v2 passed and v3 fails.
17
+
18
+ ## Method status
19
+
20
+ `method-validated` (2026-09-06). Validated means: a recorded run with the packaged code on at least 20 pages or flows the probe had not seen when its definition was last revised, 0 false fails, plus a recall record (ACT cases or seeded defects). Record: 20 unseen pages (list 4, 2026-09-06, drawn after the v3.8 definition was committed): 62 failing controls on 7 pages, 58 re-measured by a real Tab press and a whole-viewport diff, 4 by the viewport diff alone (the check could not land Tab on them), 0 false fails, 2 pages no verdict; 60 earlier unseen pages found and fixed seven false-fail classes (spec Revisions); ACT oj04fd 7/7 with the packaged code. The lists, their hashes and the per-page output are in the working notes, not in this repository.
21
+
22
+ v3.1 2026-09-06: **instant scroll.** On pages with `scroll-behavior: smooth` the control was still in flight when its box was read, so the crop covered the wrong region and a real ring fell outside it: one false fail (Wikipedia home) and 66 of 80 controls reported off-viewport on MDN. Scrolling is instant and one frame is waited before the box is read.
23
+
24
+ v3.2 2026-09-06: **the box includes the content.** An inline link wrapping an image has a one-line box in the middle of the image while its focus ring is drawn around the image; the crop missed the ring on three image links of one page (a false fail). The crop is now the union of the control's own box and its content's box.
25
+
26
+ v3.3 2026-09-06: **48 px padding.** On the first unseen-page run a link whose focus ring is drawn on an ancestor card (GitHub home) showed 0 changed pixels within 12 px and 694 within 40 px: a false fail. Rings drawn by wrappers (input pills, cards) sit outside a tight crop. Padding is 48 px; the cost, unrelated changes near the control counting as an indicator, is listed under known-infidelity. The first unseen run (20 domains, `unseen/out-unseen.json`) is kept as a record but does not count toward validation: its second-reader confirmations were made on 12 px crops that could not show such rings. A second list is drawn from the next Tranco entries.
27
+
28
+ v3.4 2026-09-06: **finite animations are finished before the box is read.** On the second unseen run four links on one page (scroll-reveal transform still running) showed 0 changed pixels in their crop while the whole viewport changed 870 pixels: the screenshots freeze animations to their end state, so the ring was drawn 100 px above the box read mid-animation. A false fail. Every finite animation is finished before the box is read, matching the screenshot state.
29
+
30
+ v3.5 2026-09-06: **fail means no pixel changed anywhere in the viewport.** On the second unseen run four card links on one page drew their ring 100 px above the control's box (on a `:focus-within` ancestor), outside the 48 px crop: a false fail under v3.3/v3.4. A crop that changes is an indicator; a crop that does not change while the viewport does is now *not measured*; only a focus that changes nothing on the whole screen is a fail. The cost is recall (a control with no ring on a page where focus also opens a menu elsewhere is not measured), listed under known-infidelity.
31
+
32
+ v3.6 2026-09-06: **one real Tab key press before measuring.** A real Tab press on every seen focus-visible fail (`fv-tab-check.mjs`) found one page (Spotify home, 19 controls) whose `<html>` carries a `no-focus-outline` class removed on the first keydown: programmatic focus with no prior key event shows nothing, a real Tab shows the ring, and after one Tab press programmatic focus shows the same ring (0 pixels between the two). Every v3.x fail on that page was a false fail; the other pages checked (SharePoint 13, Cloudflare DNS 12, Zoom 8) show 0 pixels under Tab too. The probe now presses Tab once and blurs before measuring. Both twins start in such a mode, so v3.5 fails the must-pass twin and v3.6 is needed to pass the gate. The third unseen run had started under v3.5 and was stopped after 4 of 20 pages; its partial output is kept unread as a record and the third list is run again under v3.6 (see the workspace rule).
33
+
34
+ v3.7 2026-09-06: **focus arrives by a real Tab press; unpainted controls are a finding, not a measurement.** The third unseen run (20 pages under v3.6) found two more false-fail classes. (1) One page draws its ring only when the focus event follows a key event (a per-event keyboard detector, as focus-visible polyfills do): after v3.6's single priming press, programmatic focus still changed 0 pixels while a real Tab drew 411 to 1597 pixels on three controls. Focus is now given by focusing the control, Shift+Tab and Tab, and the control must be the active element afterwards. (2) Controls in the tab order that are never painted: 37 copy buttons inside zero-height `overflow:hidden` snippets on one page, 41 controls inside `opacity:0` panels on another, 2 on a third. Their centre hit test lands on an ancestor, which v3.x counted as not covered. Such controls are excluded from applies-when and reported as a `hidden-focusable` finding with their reason. The unfocused screenshots are now taken after blur (so the predecessor's ring is gone in both states). The must-pass twin draws its ring only through a per-event detector and both twins carry a zero-height button and an opacity-0 link; v3.6 fails the must-pass twin, v3.7 passes. List 3 was read to find these classes, so a fourth list is drawn.
35
+
36
+ v3.8 2026-09-06: **three false-fail paths closed by analysis, before the fourth list was drawn.** (1) The predecessor's focus (Shift+Tab) can open a menu that covers the control or scroll the page, so the crop read before the key presses is stale: the box and the hit test are read again after Tab, and a control now off-screen, unpainted or covered is not measured. (2) A focus trap that takes focus back on blur would put the ring into the unfocused shots: nothing may hold focus after blur, else not measured. (3) An indicator drawn by script on a timer later than two frames was invisible to the focused shot: when crop and viewport show no change, focus is given again and both are re-read after 250 ms. The must-pass twin's Save button now draws its ring 200 ms after keyboard focus; v3.7 fails it, v3.8 passes. No page informed this revision.
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "journey": "test/journeys/checkout.json",
3
- "operator": "review.html: a \"Confirm your email for the receipt\" field is added inside the order form, empty, not pre-populated, no way to select the value entered on details.html",
3
+ "operator": "review.html: the \"Email for the receipt\" field, pre-populated from details.html in the clean fixture, is replaced by an empty \"Confirm your email for the receipt\" field with no way to select the value entered earlier",
4
4
  "hashes": {
5
5
  "site": {
6
6
  "account.html": "3972383a0e127789055e87f517e5812e7d0aadf98a979748b9468cbf5cdcef29",
7
- "cart.html": "c40a826acc5df143e6c8bdb8b2ad95b6c236a1bf24315ef4a729902209fa3125",
7
+ "cart.html": "a0fca9a16b3311361845c593b6000fd845112a9474e1dbd327166f985cd17db0",
8
8
  "contact.html": "fe0b4f737714599aea8ffc61a0e3e66e66eb2ef900f97f29970d90085bdfb457",
9
- "details.html": "866dedf0b0ff5baea57490907b07d2f726f00c69add81128e7938438e8f4d987",
10
- "done.html": "714bfde7c1ba4e24d162f1faf9ab781ac0d06f44b149441c4845e740dcc32102",
9
+ "details.html": "2e26fd9639b1838cc3477b1c9063b36558fada4d9ffd4590d95131ed95eb5578",
10
+ "done.html": "edf53d01f5ecd357656926e96eb4c2f38cc692ddcc685bac82ad7af1938498a1",
11
11
  "help.html": "e457b0e9a716019065de458d89114c37fbd14e67ada1ec69c8a2f65efbd1acbf",
12
12
  "home.html": "287874d84b918ed8d6c16ce73d9ab631a489a5f9263933811e8ea960702126b2",
13
13
  "privacy.html": "24d287f9a4e59218d69eb38360f674a92ada08860f2b194f71b7be2b9dbbdeff",
14
- "review.html": "6a2191f30dc103062e986c7fada8f036f0931bc2247a70ed599c8b7d50b361b4",
14
+ "review.html": "5b251489b736f55ae1fab7f91f196c32d65696ded0b4d16b26e40c71489a2c02",
15
15
  "shop.html": "56a4d04abc3ca851bc29aff43be027c7f6406aeb0eb90d5dccc39c6088a1e679",
16
16
  "style.css": "7f85da7845e02428fb480f86b8bcc3e32ae491882f27bcf35c10caef88999586",
17
17
  "terms.html": "bd6a5c83a4e786574107a3aa02dc18e5b327624ebc14739fd0ae1785d180c187"
@@ -20,4 +20,4 @@
20
20
  "review.html": "ff15027d89634b159a4e8a73475c9282cfd668a10301a68174cc7904c6bb505b"
21
21
  }
22
22
  }
23
- }
23
+ }
@@ -23,10 +23,14 @@ function match(inputs, recorded, idx) {
23
23
 
24
24
  export default {
25
25
  id: 'flow.redundant-entry', sc: '3.3.7',
26
+ method: { status: 'method-unproven', record: 'P0-B ran pre-package code on 20 flows; no unseen-flow run with the packaged code yet' },
26
27
  async onStep(page, rec, ctx) {
27
28
  if (rec.i === 0 || !ctx.recorded.length) return;
28
29
  const details = [];
30
+ const step = ctx.J.steps[rec.i]; let passwordStep = false;
31
+ for (const sel of Object.keys(step.fill || {})) { try { if (await page.locator(sel).first().evaluate(el => el.type === 'password')) { passwordStep = true; break; } } catch {} }
29
32
  for (const m of match(rec.inputsOnArrival, ctx.recorded, rec.i)) {
33
+ if (passwordStep && (m.field.type === 'email' || /^(username|email)$/.test(m.field.autocomplete) || /user|login|email/i.test(m.field.name + ' ' + m.field.id + ' ' + m.field.label))) { details.push({ field: { name: m.field.name, id: m.field.id, type: m.field.type, label: m.field.label, autocomplete: m.field.autocomplete }, matchedBy: m.by, firstEnteredStep: m.prior.step, currentValue: m.field.value, mechanism: 'security: login identity on a step that enters a password (spec)' }); continue; }
30
34
  const f = m.field, p = m.prior; let mechanism = null;
31
35
  if (norm(f.value) === norm(p.value) || norm(f.value) === norm(p.display)) mechanism = 'auto-populated';
32
36
  else if (p.essential) mechanism = 'essential (project)'; else if (p.security) mechanism = 'security (project)';
@@ -2,7 +2,7 @@
2
2
 
3
3
  - **why:** SC text: information previously entered or provided in the same process that is required again must be auto-populated or available to select, except when re-entry is essential, required for security, or the earlier information is no longer valid.
4
4
  - **applies-when:** step index > 1 in the journey, evaluated **on arrival at the step, before that step's own fill**; a visible, enabled, editable input (`input` not hidden/password/submit/button/search, `textarea`, `select`) inside the process form — the `form` ancestor of the control the journey submits on that step. A step with no `submit`, or whose control is not inside a form, contributes no inputs: other forms on the page (product review, newsletter, search, site chrome) are not part of the process. The input's identity matches a value recorded in an earlier step. Identity match, in order: same non-empty `autocomplete` token; same `name`; same `type` in {email, tel, url}; accessible label equal after normalization.
5
- - **correct-when:** for each matched field, one of: its current value equals the earlier value (auto-populated); or a control on the same page offers the earlier value for selection (a `select`, `datalist`, radio group, or checkbox whose option/label text contains the earlier value, or a checkbox/radio whose label matches /same as|use (my|the|this) .*(address|details|information)/i); or the journey marks the field `essential` or `security` (provenance `project`).
5
+ - **correct-when:** for each matched field, one of: its current value equals the earlier value (auto-populated); or a control on the same page offers the earlier value for selection (a `select`, `datalist`, radio group, or checkbox whose option/label text contains the earlier value, or a checkbox/radio whose label matches /same as|use (my|the|this) .*(address|details|information)/i); or the journey marks the field `essential` or `security` (provenance `project`); or the step enters a password and the field is login identity (`type=email`, `autocomplete` username/email, or name/id/label matching /user|login|email/i): re-entering the identifier next to a password is the SC's security exception (provenance `spec`).
6
6
  - **verdict:** `pass` if no matched field lacks a mechanism; `fail` listing each re-asked field with the step where it was first entered; `not-applicable` if no field matches; `unmeasurable` if earlier steps recorded no values.
7
7
  - **invalid-if:** the earlier step's values were not recorded (journey started mid-process); the field is inside a third-party subtree (`iframe`, ad container) — excluded.
8
8
  - **known-infidelity:** identity by `name`/label can conflate distinct fields (billing vs shipping address) — a `fieldset` legend or `autocomplete` section token (`shipping`, `billing`) that differs between the two occurrences suppresses the match; "same as" detection is a text heuristic and is reported in `why`.
@@ -35,3 +35,11 @@ Acceptance: Toolshop register→checkout billing step must report the house-numb
35
35
  ## Falsification pair
36
36
 
37
37
  See `pair.json`: one fixture where this probe must return `fail` for the stated operator, one where it must stay silent. `uxcli gate` runs both and checks the hashes. Written from the WCAG 2.2 Understanding text before any fixture or site was opened; revision history is kept outside the package.
38
+
39
+ ## Method status
40
+
41
+ `method-unproven`. Validated means: a recorded run with the packaged code on at least 20 pages or flows the probe had not seen when its definition was last revised, 0 false fails, plus a recall record (ACT cases or seeded defects). Until then a would-be `fail` is reported as `finding`. Record so far: P0-B ran pre-package code on 20 flows; no unseen-flow run with the packaged code yet.
42
+
43
+ ## Revisions
44
+
45
+ - 2026-09-06 login identity on a password step is the security exception, not a re-ask. Reason: sign-up → sign-in on the first real project read the email field as redundant entry.
@@ -1 +1 @@
1
- <!doctype html><html lang="en"><head><meta charset="utf-8"><title>Delivery terms</title><style>body{font:16px/1.4 system-ui,sans-serif;margin:2rem;max-width:34rem;color:#222;background:#fff}</style></head><body><h1>Delivery terms</h1><p style="line-height:1.6 !important">Orders placed before noon leave the warehouse the same day. Deliveries to the islands take one extra working day and are not available on public holidays. Signatures are required for parcels above two hundred euro.</p><p style="letter-spacing:0 !important">Returns are accepted within thirty days of delivery when the seal is unbroken.</p></body></html>
1
+ <!doctype html><html lang="en"><head><meta charset="utf-8"><title>Delivery terms</title><style>body{font:16px/1.4 system-ui,sans-serif;margin:2rem;max-width:34rem;color:#222;background:#fff}</style></head><body><h1>Delivery terms</h1><p style="line-height:1.6 !important">Orders placed before noon leave the warehouse the same day. Deliveries to the islands take one extra working day and are not available on public holidays. Signatures are required for parcels above two hundred euro.</p><p style="letter-spacing:0 !important">Returns are accepted within thirty days of delivery when the seal is unbroken.</p><ins class="adsbygoogle" style="display:block;margin-top:2rem;padding:1rem;background:#fff"><a href="#ad" style="outline:none;color:#999">Sponsored: a link with no focus style</a><p style="letter-spacing:0 !important;color:#9a9a9a">Ad copy locked at zero letter spacing in low contrast, inside a third-party container.</p></ins></body></html>
@@ -1 +1 @@
1
- <!doctype html><html lang="en"><head><meta charset="utf-8"><title>Delivery terms</title><style>body{font:16px/1.4 system-ui,sans-serif;margin:2rem;max-width:34rem;color:#222;background:#fff}</style></head><body><h1>Delivery terms</h1><p style="line-height:1.6 !important">Orders placed before noon leave the warehouse the same day. Deliveries to the islands take one extra working day and are not available on public holidays. Signatures are required for parcels above two hundred euro.</p><p style="letter-spacing:0.15em !important">Returns are accepted within thirty days of delivery when the seal is unbroken.</p></body></html>
1
+ <!doctype html><html lang="en"><head><meta charset="utf-8"><title>Delivery terms</title><style>body{font:16px/1.4 system-ui,sans-serif;margin:2rem;max-width:34rem;color:#222;background:#fff}</style></head><body><h1>Delivery terms</h1><p style="line-height:1.6 !important">Orders placed before noon leave the warehouse the same day. Deliveries to the islands take one extra working day and are not available on public holidays. Signatures are required for parcels above two hundred euro.</p><p style="letter-spacing:0.15em !important">Returns are accepted within thirty days of delivery when the seal is unbroken.</p><ins class="adsbygoogle" style="display:block;margin-top:2rem;padding:1rem;background:#fff"><a href="#ad" style="outline:none;color:#999">Sponsored: a link with no focus style</a><p style="letter-spacing:0 !important;color:#9a9a9a">Ad copy locked at zero letter spacing in low contrast, inside a third-party container.</p></ins></body></html>
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "kind": "page",
3
- "operator": "index.html: the second paragraph locks letter-spacing at 0 with an !important style attribute; the must-pass twin locks it at 0.15em. Both lock line-height at 1.6 on a wrapped paragraph.",
3
+ "operator": "index.html: the second paragraph locks letter-spacing at 0 with an !important style attribute; the must-pass twin locks it at 0.15em. Both lock line-height at 1.6 on a wrapped paragraph. Both twins carry an ins.adsbygoogle block with a focus-less link, a zero letter-spacing lock and low-contrast text: the third-party exclusion must keep all three probes silent on it.",
4
4
  "hashes": {
5
5
  "mustFail": {
6
- "index.html": "eab117b6dd089909104dd1f71ff6a10d7d02e595f35bd42fd7291e7bb6f7ceee"
6
+ "index.html": "b7d41185ce29efdf75afbc2a82ab7625c1a8ec916b51038a0d6a97febaad4567"
7
7
  },
8
8
  "mustPass": {
9
- "index.html": "46ff4433e7c20347cdf9c4fbb7c4d50f4e2ed408e0645b803e3081f7e08f3e9d"
9
+ "index.html": "7ed2f5bfb357713da324fac24ddbaf243c3cb0a81ef591cee592f5493422c494"
10
10
  }
11
11
  }
12
12
  }
@@ -4,6 +4,7 @@ const RULES = [['letter-spacing', 0.12, '24afc2'], ['word-spacing', 0.16, '9e45e
4
4
 
5
5
  export default {
6
6
  id: 'page.text-spacing', sc: '1.4.12', kind: 'page',
7
+ method: { status: 'method-validated', record: '80 unseen pages (lists 1 to 4, 2026-09-06, definitions unchanged since 65ded6c, drawn before every list): 1 fail (reproduced), 0 false fails; ACT 24afc2/9e45ec/78fd32 62/62 with the packaged code' },
7
8
  async measure(page) {
8
9
  const act = await page.evaluate(([THIRD, RULES]) => {
9
10
  const sheetDeclares = (a, prop) => { try { for (const ss of document.styleSheets) for (const r of ss.cssRules || []) if (r.style && r.style.getPropertyValue(prop) && a.matches(r.selectorText)) return r.style.getPropertyPriority(prop) || 'normal'; } catch { } return null; };
@@ -13,3 +13,7 @@ v1 frozen 2026-09-05 (P0-A, sha256 `375832c3…da11bc`). ACT 62/62 with v1.
13
13
 
14
14
  - 2026-09-06 **third-party subtrees excluded** from targets: on ad-carrying pages every `!important` spacing lock sat inside AdSense containers (P0-A finding).
15
15
  - 2026-09-06 verdict names mapped to the repo's list; override breakage attached as `finding`.
16
+
17
+ ## Method status
18
+
19
+ `method-validated` (2026-09-06). Validated means: a recorded run with the packaged code on at least 20 pages or flows the probe had not seen when its definition was last revised, 0 false fails, plus a recall record (ACT cases or seeded defects). Record: 80 unseen pages (lists 1 to 4, 2026-09-06, definitions unchanged since 65ded6c, drawn before every list): 1 fail (reproduced), 0 false fails; ACT 24afc2/9e45ec/78fd32 62/62 with the packaged code. The lists, their hashes and the per-page output are in the working notes, not in this repository.
package/src/refute.js CHANGED
@@ -6,6 +6,7 @@ export function refuteQuestion(p) {
6
6
  if (p.sc === '3.3.7') { const fields = [...new Set(p.reasked.map(m => m.field.name || m.field.id))]; return `The tool claims: on the screen shown, the field(s) ${fields.join(', ')} (outlined in red) are empty although the user typed the value(s) earlier in the same process. Looking only at the image(s), is that claim supported?`; }
7
7
  if (p.sc === '3.3.4') { const c = p.branches.confirmed; return `The tool claims: on the screen shown (the one that places the order), the previously entered values ${c.missing.slice(0, 3).map(m => JSON.stringify(m.value)).join(', ')} are not shown and there is no control to change them before committing. Looking only at the image, is that claim supported?`; }
8
8
  if (p.sc === '3.2.3') { const x = p.inversion; return `The tool claims: the two navigation menus shown (from two pages of the same site) list the same items but in a different relative order; specifically ${x.firstInvertedPair.map(s => s.replace(/^[ht]:/, '').split('/').pop()).join(' and ')} are swapped. Looking only at the images, is that claim supported?`; }
9
+ if (p.sc === '2.4.7') { const t = p.targets[0]; return `The tool claims: the two images are the same crop of one control (${t.sel}${t.text ? ' "' + t.text + '"' : ''}) on a web page, the first before it received keyboard focus and the second after. It claims nothing visible changes between them, so a keyboard user cannot see that this control has focus. Look at both images. Is there any visible focus indicator (outline, ring, glow, underline, colour change) in the second image that is absent from the first?`; }
9
10
  return null;
10
11
  }
11
12
 
@@ -14,6 +15,7 @@ export function refute(p, { cmd = process.env.UXCLI_REFUTER || 'claude -p --mode
14
15
  const prompt = `You are an independent second reader checking one claim made by an automated UI checker. You have not seen the checker. Do not trust the claim.\nOpen each image with the Read tool: ${p.proof.join(' , ')}\nQuestion: ${q}\nReply with JSON only, on one line: {"supported": true|false, "reason": "<one sentence, what you saw>"}`;
15
16
  const [bin, ...args] = cmd.split(/\s+/);
16
17
  const r = spawnSync(bin, [...args, prompt], { encoding: 'utf8', timeout: 180000, stdio: ['ignore', 'pipe', 'pipe'] });
18
+ if (r.error) return { tested: false, why: `refuter command not found: ${bin} (set UXCLI_REFUTER)` };
17
19
  if (r.status !== 0) return { tested: false, why: 'refuter exit ' + r.status + ': ' + (r.stderr || '').slice(0, 200) };
18
20
  let text = r.stdout;
19
21
  try { const j = JSON.parse(text); text = j.result ?? j.content ?? text; } catch {}
package/src/run.js CHANGED
@@ -8,10 +8,18 @@ import consistentNavigation from './probes/consistent-navigation/probe.js';
8
8
 
9
9
  export const PROBES = [errorPrevention, redundantEntry, consistentNavigation];
10
10
 
11
+ // README rule: an unproven method reports `finding` where it would say `fail`. The probe's own verdict is kept as rawVerdict.
12
+ export function withMethod(probe, out) {
13
+ const r = { probe: probe.id, sc: probe.sc, provenance: 'spec', method: probe.method?.status || 'method-unproven', ...out };
14
+ if (r.verdict === 'fail' && r.method !== 'method-validated') { r.rawVerdict = 'fail'; r.verdict = 'finding'; }
15
+ return r;
16
+ }
17
+
11
18
  export async function runJourney(J, { browser, outDir } = {}) {
12
19
  const own = !browser; if (own) browser = await launch();
13
20
  const ctx = { J, steps: [], recorded: [], authSteps: new Set(), commitIdx: J.steps.findIndex(s => s.commit), blocked: false, browser };
14
21
  const bctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
22
+ bctx.setDefaultTimeout(10000); // a selector that is not on the page fails in 10 s, not 30
15
23
  const page = await bctx.newPage();
16
24
  for (let i = 0; i < J.steps.length; i++) {
17
25
  const step = J.steps[i]; const rec = { i, url: null, arrivedBy: null, title: null, noise: null, inputsOnArrival: [] };
@@ -28,7 +36,7 @@ export async function runJourney(J, { browser, outDir } = {}) {
28
36
  await bctx.close();
29
37
  segment(ctx);
30
38
  const probes = [];
31
- for (const p of PROBES) probes.push({ probe: p.id, sc: p.sc, provenance: 'spec', ...(await p.evaluate(ctx)) });
39
+ for (const p of PROBES) probes.push(withMethod(p, await p.evaluate(ctx)));
32
40
  if (own) await browser.close();
33
41
  if (outDir) writeEvidence(ctx, probes, outDir);
34
42
  for (const s of ctx.steps) delete s.evidence;
@@ -38,7 +46,7 @@ export async function runJourney(J, { browser, outDir } = {}) {
38
46
  // Process segments: a goto or a submit that did not navigate starts a new segment; sameProcess joins ranges (provenance project).
39
47
  function segment(ctx) {
40
48
  const { steps, J } = ctx; const segOf = []; let seg = 0;
41
- for (let i = 0; i < steps.length; i++) { if (i > 0 && (steps[i].arrivedBy === 'goto' || steps[i - 1].flowBreak)) seg++; segOf[i] = seg; }
49
+ for (let i = 0; i < steps.length; i++) { if (i > 0 && (steps[i].arrivedBy === 'goto' || steps[i - 1].flowBreak === 'submitDidNotNavigate')) seg++; segOf[i] = seg; }
42
50
  for (const [from, to] of J.sameProcess || []) { const target = segOf[from]; for (let i = from; i <= to && i < segOf.length; i++) { const s0 = segOf[i]; for (let k = 0; k < segOf.length; k++) if (segOf[k] === s0) segOf[k] = target; } }
43
51
  steps.forEach((s, i) => { s.segment = segOf[i]; });
44
52
  ctx.segOf = segOf;
package/src/tokens.js ADDED
@@ -0,0 +1,11 @@
1
+ // Design-token lookup: which custom property in the project's stylesheets declares a colour. Read-only scan of --src.
2
+ import fs from 'node:fs'; import path from 'node:path';
3
+ const EXT = new Set(['.css', '.scss', '.sass', '.less', '.pcss', '.styl']);
4
+ export function tokenIndex(root) {
5
+ const idx = {}; if (!root || !fs.existsSync(root)) return idx;
6
+ const walk = d => { for (const f of fs.readdirSync(d, { withFileTypes: true })) { if (f.name === 'node_modules' || f.name.startsWith('.')) continue; const p = path.join(d, f.name); if (f.isDirectory()) walk(p); else if (EXT.has(path.extname(f.name))) scan(p); } };
7
+ const scan = file => { const text = fs.readFileSync(file, 'utf8'); for (const m of text.matchAll(/(--[a-zA-Z0-9_-]+)\s*:\s*(#[0-9a-fA-F]{3,8})\b/g)) { const hex = expand(m[2]); if (hex) (idx[hex] ||= []).push({ token: m[1], file: path.relative(root, file) }); } };
8
+ walk(root); return idx;
9
+ }
10
+ const expand = h => { h = h.toLowerCase(); if (h.length === 4) return '#' + [...h.slice(1)].map(c => c + c).join(''); if (h.length === 7) return h; if (h.length === 9) return h.slice(0, 7); return null; };
11
+ export const tokenFor = (idx, hex) => (idx[String(hex || '').toLowerCase()] || [])[0]?.token || null;
@@ -20,6 +20,7 @@
20
20
  </ul>
21
21
  </nav>
22
22
  </div>
23
+ <nav aria-label="Account" class="account-nav"><button type="button" data-go="account.html#orders">Orders</button><button type="button" data-go="account.html#addresses">Addresses</button><button type="button" data-go="account.html#payment">Payment</button></nav>
23
24
  </header>
24
25
  <main class="wrap">
25
26
  <h1>Your cart</h1>
@@ -20,6 +20,7 @@
20
20
  </ul>
21
21
  </nav>
22
22
  </div>
23
+ <nav aria-label="Account" class="account-nav"><button type="button" data-go="account.html#orders">Orders</button><button type="button" data-go="account.html#addresses">Addresses</button><button type="button" data-go="account.html#payment">Payment</button></nav>
23
24
  </header>
24
25
  <main class="wrap">
25
26
  <h1>Your details</h1>
@@ -20,6 +20,7 @@
20
20
  </ul>
21
21
  </nav>
22
22
  </div>
23
+ <nav aria-label="Account" class="account-nav"><button type="button" data-go="account.html#orders">Orders</button><button type="button" data-go="account.html#addresses">Addresses</button><button type="button" data-go="account.html#payment">Payment</button></nav>
23
24
  </header>
24
25
  <main class="wrap">
25
26
  <h1>Order placed</h1>
@@ -20,6 +20,7 @@
20
20
  </ul>
21
21
  </nav>
22
22
  </div>
23
+ <nav aria-label="Account" class="account-nav"><button type="button" data-go="account.html#orders">Orders</button><button type="button" data-go="account.html#addresses">Addresses</button><button type="button" data-go="account.html#payment">Payment</button></nav>
23
24
  </header>
24
25
  <main class="wrap">
25
26
  <h1>Review your order</h1>
@@ -47,6 +48,8 @@
47
48
  </dl>
48
49
 
49
50
  <form id="order-form" action="done.html" method="get">
51
+ <label for="receipt-email">Email for the receipt</label>
52
+ <input id="receipt-email" name="receipt-email" type="email" autocomplete="email">
50
53
  <button type="submit">Place order</button>
51
54
  </form>
52
55
  <script>
@@ -57,6 +60,7 @@
57
60
  fields.forEach(function (f) {
58
61
  document.getElementById('out-' + f).textContent = saved[f] || '';
59
62
  });
63
+ document.getElementById('receipt-email').value = saved.email || '';
60
64
  document.getElementById('order-form').addEventListener('submit', function (ev) {
61
65
  ev.preventDefault();
62
66
  sessionStorage.setItem('orderPlaced', '1');