@junixlabs/uxcli 0.2.1 → 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 +18 -4
  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 +4 -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
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  `uxcli` measures a running UI against its design commitments. Built for AI coding agents that need to verify the frontend they just wrote.
4
4
 
5
- **Status:** early. Three flow probes (WCAG 3.3.4, 3.3.7, 3.2.3), three single-screen probes (2.4.7, 1.4.12, 1.4.3), a gate that proves each probe can fail on a seeded fixture and stays silent or passes on its clean twin, a verdict card, and a second reader. On npm as `@junixlabs/uxcli`; the command is `uxcli`.
5
+ **Status:** early. Three flow probes (WCAG 3.3.4, 3.3.7, 3.2.3), three single-screen probes (2.4.7, 1.4.12, 1.4.3), a gate that proves each probe fails on a seeded fixture and passes on its clean twin, a verdict card, and a second reader. The three single-screen probes are `method-validated` (focus-visible: 20 unseen pages after eight recorded revisions; text-spacing and contrast: 80 unseen pages; 0 false fails each). The three flow probes are `method-unproven` and report `finding` instead of `fail` until their unseen-flow run is recorded. On npm as `@junixlabs/uxcli`; the command is `uxcli`.
6
6
 
7
7
  **Core rule: no commitment, no verdict.** Every finding cites the commitment it enforces: W3C's, yours, or none. Where no one has committed, `uxcli` says nothing.
8
8
 
@@ -22,6 +22,7 @@ npx playwright-core install chromium-headless-shell # once; or set UXCLI_CHROM
22
22
  uxcli run journey.json # verdict card for a flow
23
23
  uxcli run journey.json --json # full evidence packet
24
24
  uxcli run https://example.org/login # verdict card for one screen; --state=FILE for a signed-in page (Playwright storageState)
25
+ uxcli run https://example.org/ --src=./web/src # name the design token behind each failing colour pair
25
26
  uxcli run journey.json --refute # a fresh second reader checks each fail from the screenshots (needs the claude CLI, or set UXCLI_REFUTER)
26
27
  uxcli gate # every probe must fail on its seeded fixture and stay silent on the clean twin
27
28
  uxcli why 3.3.7 # the probe's definition (also why 2.4.7, why contrast)
@@ -29,7 +30,7 @@ uxcli why 3.3.7 # the probe's definition (
29
30
 
30
31
  `npx @junixlabs/uxcli <command>` works without the global install. To hack on it: `git clone https://github.com/junixlabs/uxcli && cd uxcli && npm install`, then `node bin/uxcli.js` in place of `uxcli`. An example journey is in `examples/sylius-guest-checkout.json`.
31
32
 
32
- A journey is the commitment: the steps of one process, which step commits, and what the human declares (`sameProcess`, `checkedPass`, `reversible`). See `test/journeys/checkout.json`. Selectors are Playwright locator strings. Omit `url` for a page reached by the previous step's submit. A step whose `url` differs from the page the previous step left is opened directly and starts a new process segment.
33
+ A journey is the commitment: the steps of one process, which step commits, and what the human declares (`sameProcess`, `checkedPass`, `reversible`). See `test/journeys/checkout.json`. Selectors are Playwright locator strings. Omit `url` for a page reached by the previous step's submit. A wizard step that keeps the URL may declare `expect`, a selector that is visible once the step has advanced. A step whose `url` differs from the page the previous step left is opened directly and starts a new process segment.
33
34
 
34
35
  ## How it works
35
36
 
@@ -38,19 +39,21 @@ The one input the machine cannot derive, the journey, is written by a human. Eve
38
39
  | Available | Planned |
39
40
  |---|---|
40
41
  | `run <journey>` measure a flow; card by default, `--json`, `--refute`, `--var=k=v` | `principles` skill: what the product commits to, as thresholds |
41
- | `run <url>` measure one screen: focus-visible, text-spacing, contrast (axe-core, pinned); `--state` | `diff a b --gate` drift between builds |
42
+ | `run <url>` measure one screen: focus-visible, text-spacing, contrast (axe-core, pinned); `--state`, `--src`, `--out`, `--refute` | `diff a b --gate` drift between builds |
42
43
  | `gate` run every probe's falsification pair | |
43
44
  | `why <rule>` the probe's definition | |
44
45
 
45
- Every probe ships with a pair of fixtures: one where it must fail and a clean twin. The twin of a single-screen probe must reach `pass`; the twins of the flow probes may still be silent (`not-applicable`) until each has a twin on its satisfied branch, tracked in [ROADMAP.md](ROADMAP.md). A probe without that pair cannot say `fail`. `gate` enforces it. Exit codes: 0 no fail, 2 at least one fail, 1 the run could not be carried out.
46
+ Every probe ships with a pair of fixtures: one where it must fail, one where it must reach `pass` through its satisfied branch. Silence (`not-applicable`) on the clean twin does not count. A probe without that pair cannot say `fail`. `gate` enforces it. Exit codes: 0 no fail, 2 at least one fail, 1 the run could not be carried out.
46
47
 
47
48
  What comes next, and in what order, is in [ROADMAP.md](ROADMAP.md).
48
49
 
49
50
  ## Rules of output
50
51
 
51
52
  - Every finding carries a provenance: `spec`, `project`, or `opinion`.
52
- - Every probe carries a method status: `method-validated` or `method-unproven`. Unproven probes report `finding`, never `fail`.
53
+ - Every probe carries a method status: `method-validated` or `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). Unproven probes report `finding`, never `fail`. `uxcli why <rule>` prints the status and its record.
53
54
  - Nine verdicts: `pass`, `fail`, `finding`, `not-applicable`, `not-committed`, `unmeasurable`, `suppressed`, `stale`, `untested`. Only `fail` blocks a merge.
55
+ - `finding` is also used for what a probe saw but does not assert under its rule: a value shown in another format (3.3.4), text that clips under user spacing (1.4.12), a control in the tab order that is never painted (2.4.7, `hidden-focusable`). Findings never change the exit code.
56
+ - What the exit code means today: a validated probe's `fail` exits 2 (focus-visible, text-spacing, contrast, so a control with no focus ring or a project's low-contrast token pairs block); an unproven probe's would-be fail prints `FINDING`, keeps `rawVerdict: fail` in `--json`, and exits 0. `uxcli why <rule>` prints each probe's status and what its validation still needs.
54
57
  - No threshold set means `not-committed`, not `fail`.
55
58
  - Cannot measure means `unmeasurable`, never `pass`.
56
59
  - Every exception ships with a coverage test.
package/bin/uxcli.js CHANGED
@@ -10,18 +10,20 @@ const usage = `usage:
10
10
  uxcli run <journey.json> [--json] [--out=DIR] [--refute] [--var=k=v ...]
11
11
  measure one flow; card by default, --json for the evidence packet; screenshots for fails in DIR (default .uxcli/<journey>);
12
12
  --var substitutes {{k}} in the journey; --refute asks a fresh second reader (UXCLI_REFUTER, default claude -p) to confirm or dispute each fail from the images alone
13
- uxcli run <url> [--json] [--state=FILE]
14
- measure one screen: focus-visible (2.4.7), text-spacing (1.4.12), contrast (1.4.3, axe-core); --state is a Playwright storageState file for signed-in pages
13
+ uxcli run <url> [--json] [--out=DIR] [--refute] [--state=FILE] [--src=DIR]
14
+ measure one screen: focus-visible (2.4.7), text-spacing (1.4.12), contrast (1.4.3, axe-core); --state is a Playwright storageState file for signed-in pages; --src is the project's source tree, used to name the design token behind a colour
15
15
  uxcli gate run every probe's falsification pair; exit 1 unless all hold
16
16
  uxcli why <rule> print a probe's definition (e.g. why 3.3.7, why redundant-entry, why 2.4.7)
17
- exit: 0 no fail · 2 at least one fail · 1 the run could not be carried out
17
+ exit: 0 no fail (findings included) · 2 at least one fail · 1 the run could not be carried out
18
18
  browser: playwright-core; set UXCLI_CHROME to a Chromium binary if none is installed for playwright.`;
19
19
  const isUrl = s => /^https?:\/\//i.test(s) || /\.html?$/i.test(s) || s.startsWith('file:');
20
20
  try {
21
21
  if (cmd === 'run' && args[0] && isUrl(args[0])) {
22
22
  const { runPage } = await import('../src/page.js'); const { card } = await import('../src/card.js');
23
23
  const url = /^(https?|file):/i.test(args[0]) ? args[0] : pathToFileURL(path.resolve(args[0])).href;
24
- const result = await runPage(url, { state: opt('state') });
24
+ const outDir = opt('out') || path.join('.uxcli', new URL(url).hostname || 'page');
25
+ const result = await runPage(url, { state: opt('state'), outDir, src: opt('src') });
26
+ if (flags.has('--refute')) { const { refute } = await import('../src/refute.js'); for (const p of result.probes) if (p.verdict === 'fail' && p.proof?.length) p.refute = refute(p); }
25
27
  console.log(flags.has('--json') ? JSON.stringify(result, null, 1) : card(result));
26
28
  process.exit(result.error ? 1 : result.probes.some(p => p.verdict === 'fail') ? 2 : 0);
27
29
  } else if (cmd === 'run' && args[0]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junixlabs/uxcli",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "UI/UX review for coding agents. No commitment, no verdict.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/browser.js CHANGED
@@ -34,7 +34,7 @@ export const PAGE_FNS = `
34
34
  const readTexts = () => (document.querySelector('main') || document.body).textContent + '\\u0000' + [...document.querySelectorAll('input, textarea, select')].map(e => e.value).join('\\u0001');
35
35
  const fields = () => [...document.querySelectorAll('input, textarea, select')].filter(e => vis(e) && !third(e)).map(e => ({ value: e.tagName === 'SELECT' ? (e.selectedOptions[0]?.textContent || e.value) : e.value, ro: e.readOnly || e.disabled, editable: editable(e) }));
36
36
  const controls = () => [...document.querySelectorAll('a[href], button, [role=button], input[type=submit], input[type=button]')].filter(e => vis(e) && !third(e) && !e.closest(CHROME)).map(e => ({ href: e.href || '', text: norm(e.textContent || e.value || e.getAttribute('aria-label')) }));
37
- const navs = () => { const all = [...document.querySelectorAll('nav, [role="navigation"]')].filter(n => !third(n)); let un = 0; return all.map(n => { const al = n.getAttribute('aria-label'); const lb = n.getAttribute('aria-labelledby'); let name = al || (lb ? lb.split(/\\s+/).map(id => document.getElementById(id)?.textContent || '').join(' ') : ''); name = norm(name); const key = name ? 'name:' + name : 'unnamed:' + (un++); const links = [...n.querySelectorAll('a[href]')].filter(a => !third(a)).map(a => ({ href: a.href, text: norm(a.textContent) || norm(a.getAttribute('aria-label')) || '' })); return { key, links }; }); };
37
+ const navs = () => { const all = [...document.querySelectorAll('nav, [role="navigation"], footer, [role="contentinfo"], header, [role="banner"]')].filter(n => !third(n) && !n.closest('nav *, [role="navigation"] *')); let un = 0; return all.map(n => { const landmark = /^(FOOTER|HEADER)$/.test(n.tagName) || /contentinfo|banner/.test(n.getAttribute('role') || ''); const al = n.getAttribute('aria-label'); const lb = n.getAttribute('aria-labelledby'); let name = al || (lb ? lb.split(/\\s+/).map(id => document.getElementById(id)?.textContent || '').join(' ') : ''); name = norm(name); const key = landmark ? 'landmark:' + n.tagName.toLowerCase() + (name ? ':' + name : '') : name ? 'name:' + name : 'unnamed:' + (un++); const links = [...n.querySelectorAll('a[href], button, [role="link"], [role="menuitem"], [role="tab"]')].filter(a => !third(a) && vis(a) && !a.closest('a[href] *, button *') && (!landmark || a.closest('nav, [role="navigation"]') === null || !n.contains(a.closest('nav, [role="navigation"]')))).map(a => ({ href: a.href || '', text: norm(a.textContent) || norm(a.getAttribute('aria-label')) || '', kind: a.tagName === 'A' ? 'link' : 'control' })); return { key, links }; }); };
38
38
  `;
39
39
  export const evalIn = (page, body) => page.evaluate(new Function(PAGE_FNS + ' return (' + body + ')();'));
40
40
  export const evalEl = (locator, body) => locator.evaluate(new Function('el', PAGE_FNS + ' return (' + body + ')(el);'));
@@ -80,10 +80,17 @@ export async function fillStep(page, step, idx, recorded) {
80
80
  export async function act(page, step) {
81
81
  const sel = step.submit || step.click; if (!sel) return null;
82
82
  const before = page.url();
83
+ const mark = await page.evaluate(() => { const f = document.querySelector('[data-uxcli-scope]'); if (f) f.setAttribute('data-uxcli-scope-before', '1'); const h = document.querySelector('main h1, h1, main h2, h2'); return h ? h.textContent.trim() : ''; });
83
84
  await page.locator(sel).first().click({ timeout: 15000 });
84
85
  await page.waitForFunction(u => location.href !== u, before, { timeout: 5000 }).catch(() => {});
86
+ if (step.expect) await page.locator(step.expect).first().waitFor({ state: 'visible', timeout: 15000 }).catch(() => {});
85
87
  await settle(page);
86
- return step.submit && normUrl(page.url()) === normUrl(before) ? 'submitDidNotNavigate' : null;
88
+ if (!step.submit || normUrl(page.url()) !== normUrl(before)) return null;
89
+ // Same URL after a submit: the step still advanced when the journey's `expect` is visible (project), or the submitted form is gone / the heading changed (spec).
90
+ if (step.expect && await page.locator(step.expect).first().isVisible().catch(() => false)) return 'advancedWithoutNavigation (expect, project)';
91
+ const adv = await page.evaluate(h0 => { const f = document.querySelector('[data-uxcli-scope-before]'); const gone = !f || !f.isConnected || getComputedStyle(f).display === 'none'; const h = document.querySelector('main h1, h1, main h2, h2'); const h1 = h ? h.textContent.trim() : ''; return { gone, heading: h1 !== h0 }; }, mark);
92
+ if (adv.gone || adv.heading) return `advancedWithoutNavigation (${adv.gone ? 'submitted form gone' : 'heading changed'})`;
93
+ return 'submitDidNotNavigate';
87
94
  }
88
95
  export async function screen(page, i, rec) {
89
96
  return { step: i, url: rec.url, noise: rec.noise, text: await evalIn(page, '() => visibleText()'), ro: await evalIn(page, '() => fields()'), links: await evalIn(page, '() => controls()') };
package/src/card.js CHANGED
@@ -3,6 +3,8 @@ const V = v => ({ pass: 'PASS', fail: 'FAIL', finding: 'FINDING', unmeasurable:
3
3
  const short = s => { s = String(s); if (s.startsWith('t:')) return s.slice(2); s = s.slice(2); const i = s.lastIndexOf('/'); return (i >= 0 ? s.slice(i + 1) : s).slice(0, 60) || s.slice(0, 60); };
4
4
  const name = p => p.probe.replace(/^(flow|page)\./, '');
5
5
  const reason = p => (p.why || '').slice(0, 120);
6
+ const failLike = p => p.verdict === 'fail' || p.rawVerdict === 'fail';
7
+ const methodLine = p => p.rawVerdict === 'fail' ? ` method ${p.method}: reported as finding, not fail; see \`uxcli why ${p.sc}\` for what validation needs` : null;
6
8
 
7
9
  export function card(result) {
8
10
  return result.url ? pageCard(result) : flowCard(result);
@@ -16,14 +18,14 @@ function flowCard(result) {
16
18
  for (const p of result.probes) {
17
19
  const proof = p.proof?.length ? ` proof ${p.proof.join(' ')}` + (p.refute ? `\n reader ${p.refute.tested ? (p.refute.parsed ? (p.refute.agrees ? 'agrees' : 'DISPUTES') + ' — ' + p.refute.reason : 'unparsed: ' + p.refute.raw) : 'not run: ' + p.refute.why}` : '') : null;
18
20
  const head = `${p.sc} ${name(p).padEnd(22)} ${V(p.verdict).padEnd(13)}`;
19
- const rule = ` rule WCAG ${p.sc} (spec)${p.override ? ` · process joined by sameProcess ${JSON.stringify(p.override.sameProcess)} (project)` : ''}`;
20
- if (p.verdict === 'fail' && p.sc === '3.3.7') {
21
+ const rule = ` rule WCAG ${p.sc} (spec, ${p.method})${p.override ? ` · process joined by sameProcess ${JSON.stringify(p.override.sameProcess)} (project)` : ''}`;
22
+ if (failLike(p) && p.sc === '3.3.7') {
21
23
  const fields = [...new Set(p.reasked.map(m => m.field.name || m.field.id))], steps = [...new Set(p.reasked.map(m => m.step))], first = [...new Set(p.reasked.map(m => m.firstEnteredStep))];
22
24
  L.push(head, ` what ${fields.join(', ')} asked again on step ${steps.join(',')}; first entered on step ${first.join(',')}`, ` where ${p.reasked[0].url} ${fields.map(f => '#' + f).join(', ')}`, rule, ` check Reach this screen through the earlier steps. ${fields.length > 1 ? 'Are these fields' : 'Is this field'} empty although you typed the value${fields.length > 1 ? 's' : ''} earlier?`); if (proof) L.push(proof);
23
- } else if (p.verdict === 'fail' && p.sc === '3.3.4') {
25
+ } else if (failLike(p) && p.sc === '3.3.4') {
24
26
  const c = p.branches.confirmed, ck = p.branches.checked; const cm = c.changeMechanism;
25
- L.push(head, ` what ${c.missing.length} of ${c.missing.length + c.present.length} entered values are not shown on the commit screen; ${cm ? `change control "${typeof cm === 'string' ? cm : cm.text}"` : 'no change control'}; validation ${ck.tested ? 'tested: ' + ck.evidence : 'untested' + (p.checkedPassHint ? '' : ' (journey may allow it with checkedPass: true)')}`, ` where ${result.steps.find(s => s.i === c.screen)?.url}`, rule, ` check On this screen, can you see ${c.missing.slice(0, 3).map(m => JSON.stringify(m.value)).join(', ')} and a way to change them before committing?`); if (proof) L.push(proof);
26
- } else if (p.verdict === 'fail' && p.sc === '3.2.3') {
27
+ L.push(head, ` what ${c.missing.length} of ${c.missing.length + c.present.length} entered values are not shown on the commit screen; ${cm ? `change control "${typeof cm === 'string' ? cm : cm.text}"` : 'no change control'}; validation ${ck.tested ? 'tested: ' + ck.evidence : 'untested'}`, ` where ${result.steps.find(s => s.i === c.screen)?.url}`, rule, ` check On this screen, can you see ${c.missing.slice(0, 3).map(m => JSON.stringify(m.value)).join(', ')} and a way to change them before committing?`); if (proof) L.push(proof);
28
+ } else if (failLike(p) && p.sc === '3.2.3') {
27
29
  const x = p.inversion;
28
30
  L.push(head, ` what ${x.mechanism} order differs between steps ${x.stepA} and ${x.stepB}; first inverted pair ${x.firstInvertedPair.map(short).join(' / ')}`, rule, ` check Compare the ${x.mechanism.replace(/^name:/, '')} menu on both pages; are those two items in swapped order?`); if (proof) L.push(proof);
29
31
  } else if (p.verdict === 'pass') {
@@ -36,6 +38,7 @@ function flowCard(result) {
36
38
  if (p.sc === '3.3.4' && p.verdict === 'unmeasurable' && p.branches?.confirmed?.missing?.length) why += `; not shown: ${p.branches.confirmed.missing.slice(0, 3).map(m => JSON.stringify(m.value)).join(', ')}`;
37
39
  L.push(`${head} ${why}`);
38
40
  }
41
+ if (methodLine(p)) L.push(methodLine(p));
39
42
  }
40
43
  return L.join('\n');
41
44
  }
@@ -45,18 +48,22 @@ function pageCard(result) {
45
48
  if (result.error) L.push(` could not run: ${result.error}`, '');
46
49
  for (const p of result.probes) {
47
50
  const head = `${p.sc.padEnd(6)} ${name(p).padEnd(16)} ${V(p.verdict).padEnd(13)}`;
48
- const rule = ` rule WCAG ${p.sc} (spec${p.axe ? `, axe-core ${p.axe}` : ''})`;
49
- if (p.verdict === 'fail' && p.sc === '2.4.7') {
51
+ const rule = ` rule WCAG ${p.sc} (spec, ${p.method}${p.axe ? `, axe-core ${p.axe}` : ''})`;
52
+ if (failLike(p) && p.sc === '2.4.7') {
50
53
  const t = p.targets;
51
- L.push(head, ` what ${t.length} of ${p.tested} focusable elements change nothing on focus: ${t.slice(0, 4).map(x => x.sel + (x.text ? ` "${x.text.slice(0, 20)}"` : '')).join(', ')}${t.length > 4 ? ', …' : ''}`, ` where ${result.finalUrl || result.url}`, rule, ` check Press Tab until ${t[0].sel}${t[0].text ? ` "${t[0].text.slice(0, 20)}"` : ''} should have focus. Can you see where focus is?`);
52
- } else if (p.verdict === 'fail' && p.sc === '1.4.12') {
54
+ L.push(head, ` what ${t.length} of ${p.measured} measured controls show no pixel change on focus: ${t.slice(0, 4).map(x => x.sel + (x.text ? ` "${x.text.slice(0, 20)}"` : '')).join(', ')}${t.length > 4 ? ', …' : ''}`, ` where ${result.finalUrl || result.url}`, rule, ` check Press Tab until ${t[0].sel}${t[0].text ? ` "${t[0].text.slice(0, 20)}"` : ''} should have focus. Can you see where focus is?`);
55
+ if (p.proof?.length) L.push(` proof ${p.proof.slice(0, 4).join(' ')}${p.proof.length > 4 ? ' …' : ''}`);
56
+ if (p.refute) L.push(` reader ${p.refute.tested ? (p.refute.parsed ? (p.refute.agrees ? 'agrees' : 'DISPUTES') + ' — ' + p.refute.reason : 'unparsed: ' + p.refute.raw) : 'not run: ' + p.refute.why}`);
57
+ } else if (failLike(p) && p.sc === '1.4.12') {
53
58
  const t = p.targets;
54
59
  L.push(head, ` what ${t.length} locked value${t.length > 1 ? 's' : ''} below the minimum: ${t.slice(0, 3).map(x => `${x.sel} ${x.property} ${x.value}px < ${x.threshold}px (ACT ${x.rule})`).join('; ')}${t.length > 3 ? '; …' : ''}`, ` where ${result.finalUrl || result.url}`, rule, ` check Does the style attribute on ${t[0].lockedOn === 'self' ? t[0].sel : t[0].lockedOn} set ${t[0].property} with !important?`);
55
- } else if (p.verdict === 'fail' && p.sc === '1.4.3') {
60
+ } else if (failLike(p) && p.sc === '1.4.3') {
56
61
  const g = p.groups;
57
- L.push(head, ` what ${g.reduce((n, x) => n + x.count, 0)} text nodes in ${g.length} colour pair${g.length > 1 ? 's' : ''}: ${g.slice(0, 4).map(x => `${x.fg} on ${x.bg} ${x.ratio}:1 ×${x.count} (e.g. ${x.example})`).join('; ')}${g.length > 4 ? '; …' : ''}`, ` where ${result.finalUrl || result.url}`, rule, ` check Is ${g[0].fg} on ${g[0].bg} a design token? One change there fixes ${g[0].count} node${g[0].count > 1 ? 's' : ''}.`);
62
+ const col = (hex, tok) => tok ? `${hex} (${tok})` : hex;
63
+ L.push(head, ` what ${g.reduce((n, x) => n + x.count, 0)} text nodes in ${g.length} colour pair${g.length > 1 ? 's' : ''}: ${g.slice(0, 4).map(x => `${col(x.fg, x.fgToken)} on ${col(x.bg, x.bgToken)} ${x.ratio}:1 ×${x.count} (e.g. ${x.example})`).join('; ')}${g.length > 4 ? '; …' : ''}`, ` where ${result.finalUrl || result.url}`, rule, g[0].fgToken || g[0].bgToken ? ` check ${[g[0].fgToken, g[0].bgToken].filter(Boolean).join(' and ')} declared in ${result.src}; one change there fixes ${g[0].count} node${g[0].count > 1 ? 's' : ''}.` : ` check Is ${g[0].fg} on ${g[0].bg} a design token? One change there fixes ${g[0].count} node${g[0].count > 1 ? 's' : ''}. Pass --src=DIR to name it.`);
58
64
  } else L.push(`${head} ${reason(p)}`);
59
65
  if (p.finding) L.push(` finding ${p.finding.why}`);
66
+ if (methodLine(p)) L.push(methodLine(p));
60
67
  }
61
68
  return L.join('\n');
62
69
  }
package/src/gate.js CHANGED
@@ -12,23 +12,26 @@ export async function gate({ log = console.log } = {}) {
12
12
  const browser = await launch(); let ok = true;
13
13
  const report = (probe, pair, vf, vp, problems) => {
14
14
  if (problems.length) ok = false;
15
- log(`${probe.sc.padEnd(6)} ${probe.id.padEnd(28)} must-fail: ${vf.verdict.padEnd(6)} must-pass: ${vp.verdict.padEnd(14)} ${problems.length ? 'FAIL ' + problems.join('; ') : 'ok'}`);
15
+ log(`${probe.sc.padEnd(6)} ${probe.id.padEnd(28)} must-fail: ${(vf.rawVerdict || vf.verdict).padEnd(6)} must-pass: ${vp.verdict.padEnd(14)} ${(probe.method?.status || 'method-unproven').padEnd(17)} ${problems.length ? 'FAIL ' + problems.join('; ') : 'ok'}`);
16
16
  log(` operator: ${pair.operator}`);
17
17
  };
18
- // Flow probes: one shared fixture site, the must-fail overlay replaces one file. Must-pass may be silent (not-applicable) until each probe has a twin that reaches its satisfied branch.
18
+ // Flow probes: one shared fixture site, the must-fail overlay replaces one file. The clean site must reach every probe's satisfied branch: `pass`, never `not-applicable`.
19
19
  const site = path.join(ROOT, 'test/fixtures/checkout'); const siteHashes = hashTree(site);
20
20
  for (const probe of PROBES) {
21
21
  const dir = probeDir(probe); const pair = JSON.parse(fs.readFileSync(path.join(dir, 'pair.json'), 'utf8')); const problems = [];
22
22
  for (const [f, h] of Object.entries(pair.hashes.site)) if (siteHashes[f] !== h) problems.push(`site/${f} hash changed`);
23
- checkHashes(dir, 'must-fail', pair.hashes.mustFail, problems);
23
+ const variants = fs.readdirSync(dir).filter(f => /^must-fail(-|$)/.test(f)).sort();
24
+ for (const v of variants) checkHashes(dir, v, v === 'must-fail' ? pair.hashes.mustFail : pair.variants?.[v]?.hashes, problems);
24
25
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'uxcli-gate-'));
25
26
  const build = (overlay) => { const d = fs.mkdtempSync(path.join(tmp, 'v-')); fs.cpSync(site, d, { recursive: true }); if (overlay) fs.cpSync(overlay, d, { recursive: true }); return pathToFileURL(d + '/').href; };
26
27
  const jPath = path.join(ROOT, pair.journey);
27
- const vf = (await runJourney(loadJourney(jPath, { base: build(path.join(dir, 'must-fail')) }), { browser })).probes.find(p => p.sc === probe.sc);
28
+ let vf;
29
+ for (const v of variants) { const r = (await runJourney(loadJourney(jPath, { base: build(path.join(dir, v)) }), { browser })).probes.find(p => p.sc === probe.sc); if (v === 'must-fail') vf = r; if ((r.rawVerdict || r.verdict) !== 'fail') problems.push(`${v} returned ${r.verdict}`); }
28
30
  const vp = (await runJourney(loadJourney(jPath, { base: build(null) }), { browser })).probes.find(p => p.sc === probe.sc);
29
31
  fs.rmSync(tmp, { recursive: true, force: true });
30
- if (vf.verdict !== 'fail') problems.push(`must-fail returned ${vf.verdict}`); if (!['pass', 'not-applicable'].includes(vp.verdict)) problems.push(`must-pass returned ${vp.verdict}`);
32
+ if (vp.verdict !== 'pass') problems.push(`must-pass returned ${vp.verdict}`);
31
33
  report(probe, pair, vf, vp, problems);
34
+ for (const v of variants.filter(v => v !== 'must-fail')) log(` ${v}: ${pair.variants?.[v]?.operator || '(no operator recorded)'}`);
32
35
  }
33
36
  // Page probes: two complete pages, one mutation between them. Must-pass has to reach the satisfied branch: `pass`, never `not-applicable`.
34
37
  for (const probe of PAGE_PROBES) {
@@ -36,7 +39,7 @@ export async function gate({ log = console.log } = {}) {
36
39
  checkHashes(dir, 'must-fail', pair.hashes.mustFail, problems); checkHashes(dir, 'must-pass', pair.hashes.mustPass, problems);
37
40
  const one = async sub => (await runPage(pathToFileURL(path.join(dir, sub, 'index.html')).href, { browser, only: [probe.id] })).probes[0];
38
41
  const vf = await one('must-fail'), vp = await one('must-pass');
39
- if (vf.verdict !== 'fail') problems.push(`must-fail returned ${vf.verdict}`); if (vp.verdict !== 'pass') problems.push(`must-pass returned ${vp.verdict}`);
42
+ if ((vf.rawVerdict || vf.verdict) !== 'fail') problems.push(`must-fail returned ${vf.verdict}`); if (vp.verdict !== 'pass') problems.push(`must-pass returned ${vp.verdict}`);
40
43
  report(probe, pair, vf, vp, problems);
41
44
  }
42
45
  await browser.close();
package/src/page.js CHANGED
@@ -1,13 +1,14 @@
1
1
  // Loads one URL once and gives every page probe the settled document. Returns the verdicts.
2
- import { launch, settle } from './browser.js';
3
- import { BOT } from './util.js';
2
+ import fs from 'node:fs'; import path from 'node:path';
3
+ import { launch, settle } from './browser.js'; import { tokenIndex, tokenFor } from './tokens.js';
4
+ import { BOT } from './util.js'; import { withMethod } from './run.js';
4
5
  import focusVisible from './probes/focus-visible/probe.js';
5
6
  import textSpacing from './probes/text-spacing/probe.js';
6
7
  import contrast from './probes/contrast/probe.js';
7
8
 
8
9
  export const PAGE_PROBES = [focusVisible, textSpacing, contrast];
9
10
 
10
- export async function runPage(url, { browser, state, only } = {}) {
11
+ export async function runPage(url, { browser, state, only, outDir, src } = {}) {
11
12
  const own = !browser; if (own) browser = await launch();
12
13
  // bypassCSP: the contrast probe injects axe-core; a page's Content-Security-Policy would otherwise block it (instrument setting, recorded in contrast/spec.md).
13
14
  const bctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, storageState: state || undefined, bypassCSP: true });
@@ -19,10 +20,23 @@ export async function runPage(url, { browser, state, only } = {}) {
19
20
  result.finalUrl = page.url(); result.title = await page.title();
20
21
  if (BOT.test(result.title)) { result.blocked = true; for (const p of probes) result.probes.push({ probe: p.id, sc: p.sc, provenance: 'spec', verdict: 'unmeasurable', why: `blocked: page title "${result.title}"` }); }
21
22
  else for (const p of probes) {
22
- try { result.probes.push({ probe: p.id, sc: p.sc, provenance: 'spec', ...(await p.measure(page, result)) }); }
23
+ try { result.probes.push(withMethod(p, await p.measure(page, result))); }
23
24
  catch (e) { result.probes.push({ probe: p.id, sc: p.sc, provenance: 'spec', verdict: 'unmeasurable', why: 'probe error: ' + String(e.message || e).slice(0, 160) }); }
24
25
  }
25
26
  } catch (e) { result.error = 'load: ' + String(e.message || e).slice(0, 160); for (const p of probes) result.probes.push({ probe: p.id, sc: p.sc, provenance: 'spec', verdict: 'unmeasurable', why: result.error }); }
26
27
  await bctx.close(); if (own) await browser.close();
28
+ writeEvidence(result, outDir);
29
+ if (src) { const idx = tokenIndex(src); for (const p of result.probes) for (const g of p.groups || []) { g.fgToken = tokenFor(idx, g.fg); g.bgToken = tokenFor(idx, g.bg); } result.src = src; }
27
30
  return result;
28
31
  }
32
+
33
+ // Evidence files are written only for fails: the before and after crops of each cited control.
34
+ function writeEvidence(result, outDir) {
35
+ for (const p of result.probes) {
36
+ if ((p.verdict === 'fail' || p.verdict === 'finding') && p.evidence?.length && outDir) {
37
+ fs.mkdirSync(outDir, { recursive: true }); p.proof = [];
38
+ p.evidence.forEach((e, k) => { for (const side of ['before', 'after']) { const f = path.join(outDir, `${p.sc}-${k}-${side}.png`); fs.writeFileSync(f, e[side]); p.proof.push(f); } });
39
+ }
40
+ delete p.evidence;
41
+ }
42
+ }
package/src/png.js ADDED
@@ -0,0 +1,13 @@
1
+ // Minimal PNG decode (8-bit RGB/RGBA, non-interlaced) and pixel diff for screenshot crops.
2
+ import zlib from 'node:zlib';
3
+ function decode(buf) {
4
+ let p = 8; const chunks = []; let w, h, ct, bd;
5
+ while (p < buf.length) { const len = buf.readUInt32BE(p); const type = buf.toString('ascii', p + 4, p + 8); const data = buf.subarray(p + 8, p + 8 + len); if (type === 'IHDR') { w = data.readUInt32BE(0); h = data.readUInt32BE(4); bd = data[8]; ct = data[9]; } if (type === 'IDAT') chunks.push(data); p += 12 + len; }
6
+ const bpp = ct === 6 ? 4 : ct === 2 ? 3 : 1; const raw = zlib.inflateSync(Buffer.concat(chunks)); const stride = w * bpp; const px = Buffer.alloc(w * h * bpp);
7
+ let prev = Buffer.alloc(stride); for (let y = 0; y < h; y++) { const f = raw[y * (stride + 1)]; const line = Buffer.from(raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)));
8
+ for (let i = 0; i < stride; i++) { const a = i >= bpp ? line[i - bpp] : 0, b = prev[i], c = i >= bpp ? prev[i - bpp] : 0; let v = line[i];
9
+ if (f === 1) v += a; else if (f === 2) v += b; else if (f === 3) v += (a + b) >> 1; else if (f === 4) { const pp = a + b - c, pa = Math.abs(pp - a), pb = Math.abs(pp - b), pc = Math.abs(pp - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c; } line[i] = v & 255; }
10
+ line.copy(px, y * stride); prev = line; }
11
+ return { w, h, bpp, px };
12
+ }
13
+ export const PNG = { diff(a, b) { const A = decode(a), B = decode(b); if (A.w !== B.w || A.h !== B.h) return -1; let n = 0; for (let i = 0; i < A.w * A.h; i++) { for (let k = 0; k < Math.min(3, A.bpp); k++) if (Math.abs(A.px[i * A.bpp + k] - B.px[i * B.bpp + k]) > 8) { n++; break; } } return n; } };
@@ -0,0 +1,80 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Review your order – Fixture Shop</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ </head>
9
+ <body>
10
+ <header>
11
+ <div class="wrap">
12
+ <a class="brand" href="home.html">Fixture Shop</a>
13
+ <nav aria-label="Main">
14
+ <ul>
15
+ <li><a href="home.html">Home</a></li>
16
+ <li><a href="shop.html">Shop</a></li>
17
+ <li><a href="cart.html">Cart <span class="badge">2<span class="sr"> items</span></span></a></li>
18
+ <li><a href="account.html">Account</a></li>
19
+ <li><a href="help.html">Help</a></li>
20
+ </ul>
21
+ </nav>
22
+ </div>
23
+ <nav aria-label="Account" class="account-nav"><button type="button" data-go="account.html#addresses">Addresses</button><button type="button" data-go="account.html#orders">Orders</button><button type="button" data-go="account.html#payment">Payment</button></nav>
24
+ </header>
25
+ <main class="wrap">
26
+ <h1>Review your order</h1>
27
+ <p>Check everything is correct before you place your order.</p>
28
+
29
+ <h2>Items</h2>
30
+ <table>
31
+ <thead>
32
+ <tr><th scope="col">Item</th><th scope="col" class="num">Qty</th><th scope="col" class="num">Price</th></tr>
33
+ </thead>
34
+ <tbody>
35
+ <tr><td>Ceramic mug</td><td class="num">1</td><td class="num">£12.00</td></tr>
36
+ <tr><td>Notebook, A5, dotted</td><td class="num">1</td><td class="num">£8.50</td></tr>
37
+ </tbody>
38
+ </table>
39
+ <p class="total">Total: £20.50</p>
40
+
41
+ <h2>Your details</h2>
42
+ <dl id="summary">
43
+ <div><dt>Full name</dt><dd><span id="out-name"></span><a href="details.html#name">Change<span class="sr"> full name</span></a></dd></div>
44
+ <div><dt>Email address</dt><dd><span id="out-email"></span><a href="details.html#email">Change<span class="sr"> email address</span></a></dd></div>
45
+ <div><dt>Street address</dt><dd><span id="out-address"></span><a href="details.html#address">Change<span class="sr"> street address</span></a></dd></div>
46
+ <div><dt>Postcode</dt><dd><span id="out-postcode"></span><a href="details.html#postcode">Change<span class="sr"> postcode</span></a></dd></div>
47
+ <div><dt>Country</dt><dd><span id="out-country"></span><a href="details.html#country">Change<span class="sr"> country</span></a></dd></div>
48
+ </dl>
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">
53
+ <button type="submit">Place order</button>
54
+ </form>
55
+ <script>
56
+ (function () {
57
+ var fields = ['name', 'email', 'address', 'postcode', 'country'];
58
+ var saved = {};
59
+ try { saved = JSON.parse(sessionStorage.getItem('checkout') || '{}'); } catch (e) {}
60
+ fields.forEach(function (f) {
61
+ document.getElementById('out-' + f).textContent = saved[f] || '';
62
+ });
63
+ document.getElementById('receipt-email').value = saved.email || '';
64
+ document.getElementById('order-form').addEventListener('submit', function (ev) {
65
+ ev.preventDefault();
66
+ sessionStorage.setItem('orderPlaced', '1');
67
+ location.href = 'done.html';
68
+ });
69
+ })();
70
+ </script>
71
+ </main>
72
+ <footer>
73
+ <div class="wrap">
74
+ <a href="privacy.html">Privacy</a>
75
+ <a href="terms.html">Terms</a>
76
+ <a href="contact.html">Contact</a>
77
+ </div>
78
+ </footer>
79
+ </body>
80
+ </html>
@@ -4,14 +4,14 @@
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"
@@ -19,5 +19,13 @@
19
19
  "mustFail": {
20
20
  "review.html": "0fcc81bfb34841552284d06c691824e6e2461e0ec178940dbd6c961d1097946c"
21
21
  }
22
+ },
23
+ "variants": {
24
+ "must-fail-buttons": {
25
+ "operator": "review.html: Orders and Addresses are swapped in the button-based nav[aria-label=Account]; the link nav is untouched. Items that are not links must be compared by text.",
26
+ "hashes": {
27
+ "review.html": "c95b02796a7f3f21f377f1b9e03e4455bcb35711bbeef59db35f3e77db89182f"
28
+ }
29
+ }
22
30
  }
23
- }
31
+ }
@@ -4,6 +4,7 @@ import { evalIn } from '../../browser.js';
4
4
 
5
5
  export default {
6
6
  id: 'flow.consistent-navigation', sc: '3.2.3',
7
+ method: { status: 'method-unproven', record: 'P0-B ran pre-package code on 20 flows; no unseen-flow run with the packaged code yet' },
7
8
  async onStep(page, rec, ctx) {
8
9
  const navs = await evalIn(page, '() => navs()');
9
10
  (ctx.navsByStep ||= []).push({ i: rec.i, origin: new URL(rec.url).origin, userReorder: !!ctx.J.steps[rec.i].userReorder, navs });
@@ -1,8 +1,8 @@
1
1
  # flow.consistent-navigation · WCAG 3.2.3 · provenance spec
2
2
 
3
3
  - **why:** SC text: navigational mechanisms repeated across a set of web pages occur in the same relative order unless the user initiated a change. Insertions and removals are allowed (Understanding); only inversions fail (F66).
4
- - **applies-when:** at least two steps on the same origin (proxy for "set of web pages"); a navigational mechanism = `nav` element or `[role=navigation]`, identified across pages by accessible name (`aria-label` / `aria-labelledby`), or when unnamed, by its index among unnamed navs. Repeated = the same mechanism appears on ≥ 2 steps and shares ≥ 2 links.
5
- - **correct-when:** for each repeated mechanism, take the links common to two pages. A link's identity is its normalized `href` when that href is unique within the mechanism on both pages; otherwise its normalized text (a logo and Home sharing `/` must not collapse into one link). The common links appear in the same relative order on both pages: the sequence on page B, restricted to common links, is order-identical to the sequence on page A. Any inversion is a failure.
4
+ - **applies-when:** at least two steps on the same origin (proxy for "set of web pages"); a navigational mechanism = `nav` element or `[role=navigation]`, or the link group of a `footer`/`header` landmark outside any nested `nav` (identified as the landmark), identified across pages by accessible name (`aria-label` / `aria-labelledby`), or when unnamed, by its index among unnamed navs. Items of a mechanism are its visible `a[href]`, `button`, `[role=link]`, `[role=menuitem]` and `[role=tab]` descendants (SPA shells build menus from buttons). Repeated = the same mechanism appears on ≥ 2 steps and shares ≥ 2 items.
5
+ - **correct-when:** for each repeated mechanism, take the links common to two pages. An item's identity is its normalized `href` when it has one that is unique within the mechanism on both pages; otherwise its normalized text (all non-link items) (a logo and Home sharing `/` must not collapse into one link). The common links appear in the same relative order on both pages: the sequence on page B, restricted to common links, is order-identical to the sequence on page A. Any inversion is a failure.
6
6
  - **verdict:** `pass`; `fail` listing the mechanism, the two steps, and the first inverted pair; `not-applicable` if no repeated mechanism; `unmeasurable` if a step is on another origin or failed to load.
7
7
  - **invalid-if:** the journey marks a step as user-initiated reorder (e.g. sort preference) — skipped for that step; a mechanism with fewer than 2 common links — not compared.
8
8
  - **known-infidelity:** same-origin is a proxy for the WCAG "set of web pages"; matching by text may miss links whose text is icon-only (matched by `aria-label` if present, else ignored); a mechanism that changes accessible name between pages is treated as two mechanisms.
@@ -22,3 +22,12 @@ A journey is an ordered list of steps in one process. Each step: `url` (or an ac
22
22
  ## Falsification pair
23
23
 
24
24
  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.
25
+
26
+ ## Method status
27
+
28
+ `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.
29
+
30
+ ## Revisions
31
+
32
+ - 2026-09-06 items extended from `a[href]` to buttons and link/menuitem/tab roles inside the mechanism, identified by text. Reason: on the first real project every navigation mechanism was built from buttons and the probe saw 0 links on every page. Guarded by the `must-fail-buttons` variant.
33
+ - 2026-09-06 footer and header landmarks count as mechanisms (their links outside nested navs). Reason: a seeded reorder of the footer links (Privacy, Terms, Contact) was missed because the footer is not a `nav`; the Understanding document's example of a repeated mechanism is exactly such a link group.
@@ -1 +1 @@
1
- <!doctype html><html lang="en"><head><meta charset="utf-8"><title>Order summary</title><style>body{font:16px/1.4 system-ui,sans-serif;margin:2rem;color:#222;background:#fff}h1{color:#222}.hint{color:#9a9a9a}.total{font-weight:700}</style></head><body><h1>Order summary</h1><p>Two items, delivered Thursday.</p><p class="hint">Prices include VAT. Delivery is free above fifty euro.</p><p class="total">Total 64.00 €</p></body></html>
1
+ <!doctype html><html lang="en"><head><meta charset="utf-8"><title>Order summary</title><style>body{font:16px/1.4 system-ui,sans-serif;margin:2rem;color:#222;background:#fff}h1{color:#222}.hint{color:#9a9a9a}.total{font-weight:700}</style></head><body><h1>Order summary</h1><p>Two items, delivered Thursday.</p><p class="hint">Prices include VAT. Delivery is free above fifty euro.</p><p class="total">Total 64.00 €</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>Order summary</title><style>body{font:16px/1.4 system-ui,sans-serif;margin:2rem;color:#222;background:#fff}h1{color:#222}.hint{color:#595959}.total{font-weight:700}</style></head><body><h1>Order summary</h1><p>Two items, delivered Thursday.</p><p class="hint">Prices include VAT. Delivery is free above fifty euro.</p><p class="total">Total 64.00 €</p></body></html>
1
+ <!doctype html><html lang="en"><head><meta charset="utf-8"><title>Order summary</title><style>body{font:16px/1.4 system-ui,sans-serif;margin:2rem;color:#222;background:#fff}h1{color:#222}.hint{color:#595959}.total{font-weight:700}</style></head><body><h1>Order summary</h1><p>Two items, delivered Thursday.</p><p class="hint">Prices include VAT. Delivery is free above fifty euro.</p><p class="total">Total 64.00 €</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: .hint text is #9a9a9a on #ffffff (2.81:1); the must-pass twin uses #595959 (7:1).",
3
+ "operator": "index.html: .hint text is #9a9a9a on #ffffff (2.81:1); the must-pass twin uses #595959 (7:1). 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": "e2102560feb1edeafdc0b2cb4fbc75e375b43be9cf5d2729287c04efdb638558"
6
+ "index.html": "bf3687126ad406598a9e03302f1f6b3b8c6b17ed9b46026d0bd18c97e2391fc9"
7
7
  },
8
8
  "mustPass": {
9
- "index.html": "cd2dfaf536a3d6ef92e2a77e0a191c408e363468bcf12dbc8f6c5a2bc9b4f7b7"
9
+ "index.html": "1411f761781a81ad1b0022d123fd86f879bf0c9e8f2d97764c0c9c69a5a4c95d"
10
10
  }
11
11
  }
12
12
  }
@@ -7,6 +7,7 @@ const AXE_PATH = path.join(path.dirname(require.resolve('axe-core/package.json')
7
7
 
8
8
  export default {
9
9
  id: 'page.contrast', sc: '1.4.3', kind: 'page',
10
+ method: { status: 'method-validated', record: '80 unseen pages (lists 1 to 4, 2026-09-06, definitions unchanged since 65ded6c, drawn before every list): 29 fails, 41 cited colour pairs re-read off the rendered page, 0 contradict axe-core, 0 false fails; ACT afw4f7 25/34, identical to axe-core alone' },
10
11
  async measure(page) {
11
12
  await page.addScriptTag({ path: AXE_PATH });
12
13
  const r = await page.evaluate(async ([THIRD]) => {
@@ -11,3 +11,7 @@
11
11
 
12
12
  - 2026-09-06 **page context bypasses Content-Security-Policy.** GOV.UK's `script-src` blocked the axe-core injection and the probe returned `unmeasurable · probe error`; the context is now created with `bypassCSP: true`, an instrument setting that does not change what is measured.
13
13
  - 2026-09-06 first version. Bench: ACT afw4f7 cases (34) with axe-core 4.13.0, recorded in the working notes.
14
+
15
+ ## Method status
16
+
17
+ `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): 29 fails, 41 cited colour pairs re-read off the rendered page, 0 contradict axe-core, 0 false fails; ACT afw4f7 25/34, identical to axe-core alone. The lists, their hashes and the per-page output are in the working notes, not in this repository.
@@ -4,14 +4,14 @@
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": "b223ad7ac8884c95b04e29fa5e6adef45194919f8360e07153907eb8c6ae74bd"
21
21
  }
22
22
  }
23
- }
23
+ }
@@ -4,6 +4,7 @@ import { screen, arrive, fillStep, act, evalIn, shot } from '../../browser.js';
4
4
 
5
5
  export default {
6
6
  id: 'flow.error-prevention', sc: '3.3.4',
7
+ method: { status: 'method-unproven', record: 'P0-B ran pre-package code on 20 flows with two contested fails; no unseen-flow run with the packaged code yet' },
7
8
  async onStep(page, rec, ctx) {
8
9
  const i = rec.i, step = ctx.J.steps[i];
9
10
  if (i === ctx.commitIdx - 1 && !step.fill) ctx.reviewScreen = await screen(page, i, rec);
@@ -22,7 +23,7 @@ export default {
22
23
  if (!commitScreen) return { ...ep, verdict: 'unmeasurable', why: 'commit screen not reached' };
23
24
  if (!priorVals.length && priorOutside.length) return { ...ep, verdict: 'unmeasurable', why: 'premise broken at step ' + breakAfter(priorOutside[priorOutside.length - 1].step, commitIdx) + ': recorded values belong to another process segment (direct navigation or a submit that did not navigate); declare sameProcess to override' };
24
25
  if (!priorVals.length) return { ...ep, verdict: 'not-applicable', why: 'no non-credential value recorded before the commit step (single-screen commit); probe measures cross-screen review only' };
25
- if (commitScreen.flowBreak) return { ...ep, verdict: 'unmeasurable', why: 'flow break: previous submit did not change the page and the commit screen was reached by direct navigation' };
26
+ if (commitScreen.flowBreak && segOf[commitIdx - 1] !== segOf[commitIdx]) return { ...ep, verdict: 'unmeasurable', why: 'flow break: previous submit did not change the page and the commit screen was reached by direct navigation; declare sameProcess to override' };
26
27
  if (commitScreen.noise) return { ...ep, verdict: 'unmeasurable', why: 'commit screen text mutating with no interaction (600 ms)' };
27
28
  if (new URL(commitScreen.url).origin !== new URL(J.steps[0].url).origin) return { ...ep, verdict: 'unmeasurable', why: 'commit screen on another origin' };
28
29
 
@@ -35,10 +36,12 @@ export default {
35
36
  const inText = cands.some(v => T.includes(v)), inField = scr.ro.some(f => cands.includes(norm(f.value)));
36
37
  if (inText || inField) present.push({ value: r.value, where: inText ? 'text' : 'field' });
37
38
  else if (cands.some(v => alnum(v).length >= 3 && TA.includes(alnum(v)))) reformatted.push({ value: r.value, note: 'present after stripping punctuation/whitespace: reformatted by site (invalid-if), reported as finding' });
39
+ else if (cands.some(v => { const toks = v.split(/[^a-z0-9]+/).filter(t => t.length >= 2); return toks.length >= 2 && toks.every(t => T.includes(t)); })) reformatted.push({ value: r.value, note: 'every word present but in another order or format (e.g. "Doe, Jane"): reformatted by site (invalid-if), reported as finding' });
38
40
  else missing.push({ value: r.value, field: r.name || r.label || r.selector, step: r.step });
39
41
  }
40
- const changeCtl = scr.links.find(l => (l.href && earlierUrls.has(normUrl(l.href))) || /\b(change|edit|back|modify)\b/.test(l.text));
41
- const editableHere = scr.ro.some(f => f.editable && priorVals.some(r => norm(f.value) === norm(r.value)));
42
+ const changeCtl = scr.links.find(l => (l.href && earlierUrls.has(normUrl(l.href))) || /\b(change|edit|modify)\b/.test(l.text) || /^(go |« |< )?back$/.test(l.text));
43
+ // every recorded value must be correctable on this screen for 'editable here' to be a correction mechanism; one editable copy of one value is not
44
+ const editableHere = priorVals.length > 0 && priorVals.every(r => scr.ro.some(f => f.editable && [r.value, r.display].map(norm).includes(norm(f.value))));
42
45
  return { holds: missing.length === 0 && (!!changeCtl || editableHere), screen: scr.step, present, reformatted, missing, changeMechanism: changeCtl || (editableHere ? 'values editable on this screen' : null) };
43
46
  };
44
47
  let conf = evalConfirmed(commitScreen);
@@ -47,11 +50,12 @@ export default {
47
50
  branches.checked = (!conf.holds && J.checkedPass) ? await checkedPass(ctx) : { holds: null, tested: false, why: conf.holds ? 'not needed' : 'journey does not allow the mutating checked pass' };
48
51
  branches.reversible = { holds: !!J.reversible, provenance: 'project', declared: J.reversible || null };
49
52
  ep.branches = branches;
53
+ if (conf.reformatted?.length) ep.finding = { kind: 'reformatted', why: `${conf.reformatted.length} value${conf.reformatted.length > 1 ? 's' : ''} shown in another format (${conf.reformatted.slice(0, 2).map(x => JSON.stringify(x.value)).join(', ')}); not counted as missing` };
50
54
  if (conf.holds) return { ...ep, verdict: 'pass', branch: conf.note ? 'confirmed (review on preceding step)' : 'confirmed' };
51
55
  if (branches.checked.holds) return { ...ep, verdict: 'pass', branch: 'checked' };
52
56
  if (J.reversible) return { ...ep, verdict: 'pass', branch: 'reversible (project)' };
53
57
  if (branches.checked.tested) return { ...ep, verdict: 'fail', why: 'no branch holds', missing: conf.missing };
54
- return { ...ep, verdict: 'unmeasurable', why: 'confirmed false; checked untested; reversible not declared' };
58
+ return { ...ep, verdict: 'unmeasurable', why: 'confirmed false; checked untested (the journey does not declare checkedPass: true, so no error was planted); reversible not declared' };
55
59
  }
56
60
  };
57
61