@junixlabs/uxcli 0.1.1 → 0.2.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.
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), a gate that proves each probe can fail, 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 can fail and can pass, a verdict card, and a second reader. 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
 
@@ -19,16 +19,17 @@ Node 20+.
19
19
  ```
20
20
  npm install -g @junixlabs/uxcli
21
21
  npx playwright-core install chromium-headless-shell # once; or set UXCLI_CHROME to a Chromium binary
22
- uxcli run journey.json # verdict card
22
+ uxcli run journey.json # verdict card for a flow
23
23
  uxcli run journey.json --json # full evidence packet
24
+ uxcli run https://example.org/login # verdict card for one screen; --state=FILE for a signed-in page (Playwright storageState)
24
25
  uxcli run journey.json --refute # a fresh second reader checks each fail from the screenshots (needs the claude CLI, or set UXCLI_REFUTER)
25
26
  uxcli gate # every probe must fail on its seeded fixture and stay silent on the clean twin
26
- uxcli why 3.3.7 # the probe's definition
27
+ uxcli why 3.3.7 # the probe's definition (also why 2.4.7, why contrast)
27
28
  ```
28
29
 
29
30
  `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`.
30
31
 
31
- 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`.
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.
32
33
 
33
34
  ## How it works
34
35
 
@@ -36,11 +37,12 @@ The one input the machine cannot derive, the journey, is written by a human. Eve
36
37
 
37
38
  | Available | Planned |
38
39
  |---|---|
39
- | `run <journey>` measure a flow; card by default, `--json`, `--refute` | `run <url>` single-screen probes (focus-visible, text-spacing, contrast) |
40
- | `gate` run every probe's falsification pair | `principles` skill: what the product commits to, as thresholds |
41
- | `why <rule>` the probe's definition | `diff a b --gate` drift between builds |
40
+ | `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
+ | `gate` run every probe's falsification pair | |
43
+ | `why <rule>` the probe's definition | |
42
44
 
43
- Every probe ships with a pair of fixtures: one where it must fail, one where it must stay silent. A probe without that pair cannot say `fail`. `gate` enforces it.
45
+ Every probe ships with a pair of fixtures: one where it must fail, one where it must pass. 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.
44
46
 
45
47
  What comes next, and in what order, is in [ROADMAP.md](ROADMAP.md).
46
48
 
@@ -54,24 +56,6 @@ What comes next, and in what order, is in [ROADMAP.md](ROADMAP.md).
54
56
  - Every exception ships with a coverage test.
55
57
  - No score, no summary line, no conformance claim.
56
58
 
57
- ## Evidence so far
58
-
59
- Method checked against public test suites and unseen sites before product code; the flow probes checked on a seeded fixture, on real apps, and on agents.
60
-
61
- | Probe | Suite | Result |
62
- |---|---|---|
63
- | focus-visible | W3C ACT, 7 cases | 7/7 |
64
- | focus-visible | GOV.UK, 22 pages, 1,244 focusable elements | 0 false fails |
65
- | text-spacing (locked) | W3C ACT, 62 cases | 62/62, after 3 definition fixes |
66
- | contrast (delegated to axe-core) | W3C ACT, 32 cases | 24/32, 0 false positives, 7 `cantTell` |
67
- | focus-visible, text-spacing | 20 unseen pages, 5 sites, definitions frozen and hashed first | 0 false fails |
68
- | flow probes 3.3.4 / 3.3.7 / 3.2.3 | seeded checkout fixture, probe frozen before the fixture existed | 3/3 caught, silent on the clean twin |
69
- | flow probes | 10 flows on 4 real apps the probes had never seen | 0 false fails; one real 3.3.7 defect found by hand that the probe then missed, now caught |
70
- | verdict card vs JSON packet | 40 fresh agent sessions fixing the seeded fixture | 0 reported done with a check failing; card sessions edited only the cited file 19/20, JSON 7/20 |
71
- | planted wrong `fail` | 20 agent sessions on the clean fixture | 2/10 rewrote the site to satisfy it without the tool; 0/10 with the tool in hand |
72
-
73
- Not yet shown: precision on more than 10 real flows; recall on defects seeded into real pages by someone who does not tune the probes; any of this on a second model.
74
-
75
59
  ## Non-goals
76
60
 
77
61
  Conformance certification. Visual regression. Scores. Design critique.
package/bin/uxcli.js CHANGED
@@ -1,25 +1,37 @@
1
1
  #!/usr/bin/env node
2
2
  // uxcli — UI/UX review for coding agents. No commitment, no verdict.
3
- import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url';
3
+ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
5
5
  const [cmd, ...rest] = process.argv.slice(2);
6
6
  const flags = new Set(rest.filter(a => a.startsWith('--') && !a.includes('='))); const args = rest.filter(a => !a.startsWith('--'));
7
7
  const opt = k => (rest.find(a => a.startsWith('--' + k + '=')) || '').split('=').slice(1).join('=') || null;
8
+ const vars = Object.fromEntries(rest.filter(a => a.startsWith('--var=')).map(a => a.slice(6).split('=')).map(([k, ...v]) => [k, v.join('=')]));
8
9
  const usage = `usage:
9
- uxcli run <journey.json> [--json] [--out=DIR] [--refute]
10
+ uxcli run <journey.json> [--json] [--out=DIR] [--refute] [--var=k=v ...]
10
11
  measure one flow; card by default, --json for the evidence packet; screenshots for fails in DIR (default .uxcli/<journey>);
11
- --refute asks a fresh second reader (UXCLI_REFUTER, default claude -p) to confirm or dispute each fail from the images alone
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
12
15
  uxcli gate run every probe's falsification pair; exit 1 unless all hold
13
- uxcli why <rule> print a probe's definition (e.g. why 3.3.7, why redundant-entry)
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
14
18
  browser: playwright-core; set UXCLI_CHROME to a Chromium binary if none is installed for playwright.`;
19
+ const isUrl = s => /^https?:\/\//i.test(s) || /\.html?$/i.test(s) || s.startsWith('file:');
15
20
  try {
16
- if (cmd === 'run' && args[0]) {
21
+ if (cmd === 'run' && args[0] && isUrl(args[0])) {
22
+ const { runPage } = await import('../src/page.js'); const { card } = await import('../src/card.js');
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') });
25
+ console.log(flags.has('--json') ? JSON.stringify(result, null, 1) : card(result));
26
+ process.exit(result.error ? 1 : result.probes.some(p => p.verdict === 'fail') ? 2 : 0);
27
+ } else if (cmd === 'run' && args[0]) {
17
28
  const { loadJourney } = await import('../src/journey.js'); const { runJourney } = await import('../src/run.js'); const { card } = await import('../src/card.js');
18
29
  const outDir = opt('out') || path.join('.uxcli', path.basename(args[0], '.json'));
19
- const result = await runJourney(loadJourney(path.resolve(args[0])), { outDir });
30
+ const result = await runJourney(loadJourney(path.resolve(args[0]), vars), { outDir });
20
31
  if (flags.has('--refute')) { const { refute } = await import('../src/refute.js'); for (const p of result.probes) if (p.verdict === 'fail') p.refute = refute(p); }
21
32
  console.log(flags.has('--json') ? JSON.stringify(result, null, 1) : card(result));
22
- process.exit(result.probes.some(p => p.verdict === 'fail') ? 2 : 0);
33
+ const couldNotRun = result.steps.some(s => s.error) || result.steps.length < result.stepCount;
34
+ process.exit(result.probes.some(p => p.verdict === 'fail') ? 2 : couldNotRun ? 1 : 0);
23
35
  } else if (cmd === 'gate') {
24
36
  const { gate } = await import('../src/gate.js'); process.exit((await gate()) ? 0 : 1);
25
37
  } else if (cmd === 'why' && args[0]) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@junixlabs/uxcli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "UI/UX review for coding agents. No commitment, no verdict.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,6 +38,7 @@
38
38
  "test": "node bin/uxcli.js gate"
39
39
  },
40
40
  "dependencies": {
41
+ "axe-core": "4.13.0",
41
42
  "playwright-core": "^1.58.0"
42
43
  }
43
44
  }
package/src/card.js CHANGED
@@ -1,18 +1,28 @@
1
1
  // The verdict card: what the working agent reads. Low hundreds of tokens; --json carries the evidence.
2
- const V = v => ({ pass: 'PASS', fail: 'FAIL', unmeasurable: 'UNMEASURABLE', 'not-applicable': 'N/A', 'not-committed': 'NOT-COMMITTED', blocked: 'BLOCKED' }[v] || String(v).toUpperCase());
2
+ const V = v => ({ pass: 'PASS', fail: 'FAIL', finding: 'FINDING', unmeasurable: 'UNMEASURABLE', 'not-applicable': 'N/A', 'not-committed': 'NOT-COMMITTED' }[v] || String(v).toUpperCase());
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
+ const name = p => p.probe.replace(/^(flow|page)\./, '');
5
+ const reason = p => (p.why || '').slice(0, 120);
6
+
4
7
  export function card(result) {
8
+ return result.url ? pageCard(result) : flowCard(result);
9
+ }
10
+
11
+ function flowCard(result) {
5
12
  const L = [`uxcli run · ${result.journey}`, ''];
13
+ for (const s of result.steps) if (s.error) L.push(` step ${s.i} could not run: ${s.error}${/Timeout|waiting for locator/i.test(s.error) ? ' (locator not found on the page)' : ''}`);
14
+ if (result.stepCount && result.steps.length < result.stepCount) L.push(` ${result.stepCount - result.steps.length} of ${result.stepCount} steps did not run`);
15
+ if (result.steps.some(s => s.error)) L.push('');
6
16
  for (const p of result.probes) {
7
17
  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;
8
- const head = `${p.sc} ${p.probe.replace('flow.', '').padEnd(22)} ${V(p.verdict).padEnd(13)}`;
18
+ const head = `${p.sc} ${name(p).padEnd(22)} ${V(p.verdict).padEnd(13)}`;
9
19
  const rule = ` rule WCAG ${p.sc} (spec)${p.override ? ` · process joined by sameProcess ${JSON.stringify(p.override.sameProcess)} (project)` : ''}`;
10
20
  if (p.verdict === 'fail' && p.sc === '3.3.7') {
11
21
  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))];
12
22
  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);
13
23
  } else if (p.verdict === 'fail' && p.sc === '3.3.4') {
14
- const c = p.branches.confirmed, ck = p.branches.checked;
15
- L.push(head, ` what ${c.missing.length} of ${c.missing.length + c.present.length} entered values are not shown on the commit screen; 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);
24
+ 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);
16
26
  } else if (p.verdict === 'fail' && p.sc === '3.2.3') {
17
27
  const x = p.inversion;
18
28
  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);
@@ -21,7 +31,32 @@ export function card(result) {
21
31
  : p.sc === '3.3.7' ? `${p.satisfied.length} matched field${p.satisfied.length > 1 ? 's' : ''}, ${[...new Set(p.satisfied.map(m => m.mechanism))].join('/')}`
22
32
  : `${p.comparedPairs} pairs, ${Object.keys(p.mechanisms || {}).length} mechanisms, no inversion`;
23
33
  L.push(`${head} ${why}`);
24
- } else L.push(`${head} ${(p.why || '').split(';')[0].slice(0, 100)}`);
34
+ } else {
35
+ let why = reason(p);
36
+ 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
+ L.push(`${head} ${why}`);
38
+ }
39
+ }
40
+ return L.join('\n');
41
+ }
42
+
43
+ function pageCard(result) {
44
+ const L = [`uxcli run · ${result.url}`, ''];
45
+ if (result.error) L.push(` could not run: ${result.error}`, '');
46
+ for (const p of result.probes) {
47
+ 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') {
50
+ 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') {
53
+ const t = p.targets;
54
+ 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') {
56
+ 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' : ''}.`);
58
+ } else L.push(`${head} ${reason(p)}`);
59
+ if (p.finding) L.push(` finding ${p.finding.why}`);
25
60
  }
26
61
  return L.join('\n');
27
62
  }
package/src/gate.js CHANGED
@@ -1,31 +1,43 @@
1
1
  // uxcli gate: every probe's falsification pair must fail where it must and stay silent where it must, and the fixture hashes must match.
2
2
  import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import crypto from 'node:crypto'; import { fileURLToPath, pathToFileURL } from 'node:url';
3
- import { loadJourney } from './journey.js'; import { runJourney, PROBES } from './run.js'; import { launch } from './browser.js';
3
+ import { loadJourney } from './journey.js'; import { runJourney, PROBES } from './run.js'; import { runPage, PAGE_PROBES } from './page.js'; import { launch } from './browser.js';
4
4
 
5
5
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
6
6
  const sha = f => crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex');
7
7
  export function hashTree(dir) { const out = {}; for (const f of fs.readdirSync(dir).sort()) { const p = path.join(dir, f); if (fs.statSync(p).isFile()) out[f] = sha(p); } return out; }
8
+ const probeDir = probe => path.join(ROOT, 'src/probes', probe.id.replace(/^(flow|page)\./, ''));
9
+ const checkHashes = (dir, sub, expected, problems) => { for (const [f, h] of Object.entries(expected || {})) if (sha(path.join(dir, sub, f)) !== h) problems.push(`${sub}/${f} hash changed`); };
8
10
 
9
11
  export async function gate({ log = console.log } = {}) {
10
12
  const browser = await launch(); let ok = true;
13
+ const report = (probe, pair, vf, vp, problems) => {
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'}`);
16
+ log(` operator: ${pair.operator}`);
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.
11
19
  const site = path.join(ROOT, 'test/fixtures/checkout'); const siteHashes = hashTree(site);
12
20
  for (const probe of PROBES) {
13
- const dir = path.join(ROOT, 'src/probes', probe.id.replace('flow.', ''));
14
- const pair = JSON.parse(fs.readFileSync(path.join(dir, 'pair.json'), 'utf8'));
15
- const problems = [];
21
+ const dir = probeDir(probe); const pair = JSON.parse(fs.readFileSync(path.join(dir, 'pair.json'), 'utf8')); const problems = [];
16
22
  for (const [f, h] of Object.entries(pair.hashes.site)) if (siteHashes[f] !== h) problems.push(`site/${f} hash changed`);
17
- for (const [f, h] of Object.entries(pair.hashes.mustFail)) if (sha(path.join(dir, 'must-fail', f)) !== h) problems.push(`must-fail/${f} hash changed`);
23
+ checkHashes(dir, 'must-fail', pair.hashes.mustFail, problems);
18
24
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'uxcli-gate-'));
19
25
  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; };
20
26
  const jPath = path.join(ROOT, pair.journey);
21
27
  const vf = (await runJourney(loadJourney(jPath, { base: build(path.join(dir, 'must-fail')) }), { browser })).probes.find(p => p.sc === probe.sc);
22
28
  const vp = (await runJourney(loadJourney(jPath, { base: build(null) }), { browser })).probes.find(p => p.sc === probe.sc);
23
29
  fs.rmSync(tmp, { recursive: true, force: true });
24
- const fOk = vf.verdict === 'fail', pOk = ['pass', 'not-applicable'].includes(vp.verdict);
25
- if (!fOk) problems.push(`must-fail returned ${vf.verdict}`); if (!pOk) problems.push(`must-pass returned ${vp.verdict}`);
26
- if (problems.length) ok = false;
27
- log(`${probe.sc} ${probe.id.padEnd(28)} must-fail: ${vf.verdict.padEnd(6)} must-pass: ${vp.verdict.padEnd(14)} ${problems.length ? 'FAIL ' + problems.join('; ') : 'ok'}`);
28
- log(` operator: ${pair.operator}`);
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}`);
31
+ report(probe, pair, vf, vp, problems);
32
+ }
33
+ // Page probes: two complete pages, one mutation between them. Must-pass has to reach the satisfied branch: `pass`, never `not-applicable`.
34
+ for (const probe of PAGE_PROBES) {
35
+ const dir = probeDir(probe); const pair = JSON.parse(fs.readFileSync(path.join(dir, 'pair.json'), 'utf8')); const problems = [];
36
+ checkHashes(dir, 'must-fail', pair.hashes.mustFail, problems); checkHashes(dir, 'must-pass', pair.hashes.mustPass, problems);
37
+ const one = async sub => (await runPage(pathToFileURL(path.join(dir, sub, 'index.html')).href, { browser, only: [probe.id] })).probes[0];
38
+ 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}`);
40
+ report(probe, pair, vf, vp, problems);
29
41
  }
30
42
  await browser.close();
31
43
  log(ok ? 'GATE PASS' : 'GATE FAIL');
package/src/journey.js CHANGED
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  export function loadJourney(file, vars = {}) {
4
4
  let text = fs.readFileSync(file, 'utf8');
5
5
  for (const [k, v] of Object.entries(vars)) text = text.split('{{' + k + '}}').join(v);
6
+ const left = [...new Set([...text.matchAll(/{{([a-zA-Z0-9_-]+)}}/g)].map(m => m[1]))]; if (left.length) throw new Error('journey has unsubstituted variables: ' + left.join(', ') + ' (pass --var=' + left[0] + '=...)');
6
7
  const J = JSON.parse(text);
7
8
  if (!Array.isArray(J.steps) || !J.steps.length) throw new Error('journey needs steps[]');
8
9
  if (!J.steps[0].url) throw new Error('first step needs a url');
package/src/page.js ADDED
@@ -0,0 +1,27 @@
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';
4
+ import focusVisible from './probes/focus-visible/probe.js';
5
+ import textSpacing from './probes/text-spacing/probe.js';
6
+ import contrast from './probes/contrast/probe.js';
7
+
8
+ export const PAGE_PROBES = [focusVisible, textSpacing, contrast];
9
+
10
+ export async function runPage(url, { browser, state, only } = {}) {
11
+ const own = !browser; if (own) browser = await launch();
12
+ const bctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, storageState: state || undefined });
13
+ const page = await bctx.newPage();
14
+ const result = { url, ranAt: new Date().toISOString(), title: null, probes: [] };
15
+ const probes = only ? PAGE_PROBES.filter(p => only.includes(p.id) || only.includes(p.sc)) : PAGE_PROBES;
16
+ try {
17
+ await page.goto(url, { waitUntil: 'load', timeout: 45000 }); await settle(page);
18
+ result.finalUrl = page.url(); result.title = await page.title();
19
+ 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}"` }); }
20
+ else for (const p of probes) {
21
+ try { result.probes.push({ probe: p.id, sc: p.sc, provenance: 'spec', ...(await p.measure(page, result)) }); }
22
+ 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) }); }
23
+ }
24
+ } 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 }); }
25
+ await bctx.close(); if (own) await browser.close();
26
+ return result;
27
+ }
@@ -0,0 +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>
@@ -0,0 +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>
@@ -0,0 +1,12 @@
1
+ {
2
+ "kind": "page",
3
+ "operator": "index.html: .hint text is #9a9a9a on #ffffff (2.81:1); the must-pass twin uses #595959 (7:1).",
4
+ "hashes": {
5
+ "mustFail": {
6
+ "index.html": "e2102560feb1edeafdc0b2cb4fbc75e375b43be9cf5d2729287c04efdb638558"
7
+ },
8
+ "mustPass": {
9
+ "index.html": "cd2dfaf536a3d6ef92e2a77e0a191c408e363468bcf12dbc8f6c5a2bc9b4f7b7"
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,27 @@
1
+ // page.contrast · WCAG 1.4.3, delegated to axe-core color-contrast. Spec in spec.md; falsification pair in pair.json.
2
+ import { createRequire } from 'node:module'; import path from 'node:path';
3
+ import { THIRD } from '../../util.js';
4
+ const require = createRequire(import.meta.url);
5
+ export const AXE_VERSION = require('axe-core/package.json').version;
6
+ const AXE_PATH = path.join(path.dirname(require.resolve('axe-core/package.json')), 'axe.min.js');
7
+
8
+ export default {
9
+ id: 'page.contrast', sc: '1.4.3', kind: 'page',
10
+ async measure(page) {
11
+ await page.addScriptTag({ path: AXE_PATH });
12
+ const r = await page.evaluate(async ([THIRD]) => {
13
+ const res = await axe.run(document, { runOnly: { type: 'rule', values: ['color-contrast'] }, resultTypes: ['violations', 'incomplete', 'passes', 'inapplicable'], reporter: 'v2' });
14
+ const nodes = arr => (arr[0]?.nodes || []).map(n => { const el = document.querySelector(n.target[0]); const d = (n.any[0] || n.all[0] || n.none[0] || {}).data || {}; return { target: n.target[0], third: !!(el && el.closest(THIRD)), fg: d.fgColor, bg: d.bgColor, ratio: d.contrastRatio, expected: d.expectedContrastRatio, fontSize: d.fontSize, fontWeight: d.fontWeight, message: (n.any[0] || n.all[0] || n.none[0] || {}).message || '' }; });
15
+ return { violations: nodes(res.violations).filter(n => !n.third), incomplete: nodes(res.incomplete).filter(n => !n.third).length, passes: nodes(res.passes).length, inapplicable: res.inapplicable.some(v => v.id === 'color-contrast') };
16
+ }, [THIRD]);
17
+ const base = { axe: AXE_VERSION, incomplete: r.incomplete, passes: r.passes };
18
+ if (r.violations.length) {
19
+ const groups = {}; for (const n of r.violations) { const k = `${n.fg} on ${n.bg}`; (groups[k] ||= { fg: n.fg, bg: n.bg, ratio: n.ratio, expected: n.expected, count: 0, example: n.target }).count++; }
20
+ const g = Object.values(groups).sort((a, b) => b.count - a.count);
21
+ return { verdict: 'fail', why: `${r.violations.length} text nodes below the ratio, ${g.length} colour pair${g.length > 1 ? 's' : ''}`, groups: g, ...base };
22
+ }
23
+ if (r.inapplicable && !r.passes && !r.incomplete) return { verdict: 'not-applicable', why: 'axe finds no text to measure', ...base };
24
+ if (!r.passes && r.incomplete) return { verdict: 'unmeasurable', why: `axe could not resolve ${r.incomplete} nodes (background image, gradient or overlap) and passed none`, ...base };
25
+ return { verdict: 'pass', why: `${r.passes} text nodes at or above the ratio${r.incomplete ? `, ${r.incomplete} unresolved by axe` : ''}`, ...base };
26
+ },
27
+ };
@@ -0,0 +1,12 @@
1
+ # page.contrast · WCAG 1.4.3 · provenance spec (delegated to axe-core 4.13.0, rule color-contrast)
2
+
3
+ - **why:** SC text: text and images of text have a contrast ratio of at least 4.5:1, 3:1 for large text. The measurement is delegated to axe-core's `color-contrast` rule, pinned to version 4.13.0; the version is part of the definition and changes only with a recorded revision.
4
+ - **applies-when:** what axe-core reports for `color-contrast` on the settled page at 1280×800, minus nodes inside third-party subtrees.
5
+ - **correct-when:** axe reports no violation node for `color-contrast`.
6
+ - **verdict:** `fail` if any violation node remains after third-party exclusion, grouped by (foreground, background) pair with a count and one example selector per group; `unmeasurable` if axe returns only `incomplete` nodes for the rule (background image, gradient, overlap) and no violation; `pass` if the rule ran with passing nodes and no violation; `not-applicable` if axe lists the rule as inapplicable.
7
+ - **invalid-if:** page fails to load; page title matches a bot-challenge pattern (`unmeasurable · blocked`).
8
+ - **known-infidelity:** everything axe-core's rule does not see: text over images, text rendered in canvas, SVG text, `incomplete` nodes. The probe adds no colour mathematics of its own; upgrading axe-core is a definition change.
9
+
10
+ ## Revisions
11
+
12
+ - 2026-09-06 first version. Bench: ACT afw4f7 cases (34) with axe-core 4.13.0, recorded in the working notes.
@@ -0,0 +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>
@@ -0,0 +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>
@@ -0,0 +1,12 @@
1
+ {
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.",
4
+ "hashes": {
5
+ "mustFail": {
6
+ "index.html": "331ed671dfa5ab5a23cc39bbf6a99973017a9f7719a3afcf3d591b42346d823e"
7
+ },
8
+ "mustPass": {
9
+ "index.html": "6ab04dd5a3dad058ca4743bcb1f1f6d1c257c60e3ffc921bf2ef81122659e4f7"
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,44 @@
1
+ // page.focus-visible · WCAG 2.4.7. Spec in spec.md; falsification pair in pair.json.
2
+ import { THIRD } from '../../util.js';
3
+
4
+ export default {
5
+ id: 'page.focus-visible', sc: '2.4.7', kind: 'page',
6
+ 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 });
33
+ }
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 };
43
+ },
44
+ };
@@ -0,0 +1,18 @@
1
+ # page.focus-visible · WCAG 2.4.7 · provenance spec
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.
9
+
10
+ ## Revisions
11
+
12
+ v1 frozen 2026-09-05 (P0-A, sha256 `375832c3…da11bc`, verdicts `passed/failed/inapplicable/blocked`). ACT oj04fd 7/7 with v1.
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.
@@ -0,0 +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>
@@ -0,0 +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>
@@ -0,0 +1,12 @@
1
+ {
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.",
4
+ "hashes": {
5
+ "mustFail": {
6
+ "index.html": "eab117b6dd089909104dd1f71ff6a10d7d02e595f35bd42fd7291e7bb6f7ceee"
7
+ },
8
+ "mustPass": {
9
+ "index.html": "46ff4433e7c20347cdf9c4fbb7c4d50f4e2ed408e0645b803e3081f7e08f3e9d"
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,58 @@
1
+ // page.text-spacing · WCAG 1.4.12 (ACT 24afc2 / 9e45ec / 78fd32). Spec in spec.md; falsification pair in pair.json.
2
+ import { THIRD } from '../../util.js';
3
+ const RULES = [['letter-spacing', 0.12, '24afc2'], ['word-spacing', 0.16, '9e45ec'], ['line-height', 1.5, '78fd32']];
4
+
5
+ export default {
6
+ id: 'page.text-spacing', sc: '1.4.12', kind: 'page',
7
+ async measure(page) {
8
+ const act = await page.evaluate(([THIRD, RULES]) => {
9
+ 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; };
10
+ const origin = (el, prop) => { for (let a = el; a && a.nodeType === 1; a = a.parentElement) {
11
+ const v = a.style.getPropertyValue(prop); const pr = a.style.getPropertyPriority(prop); const sheet = sheetDeclares(a, prop);
12
+ if (v) { if (v === 'inherit' || v === 'unset') continue;
13
+ if (pr === 'important' || !sheet || sheet !== 'important') return { src: 'style-attr', important: pr === 'important', on: a };
14
+ return { src: 'stylesheet', important: false }; }
15
+ if (sheet) return { src: 'stylesheet', important: false }; }
16
+ return { src: 'none', important: false }; };
17
+ 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('.') : '');
18
+ const out = { applicable: 0, fails: [] };
19
+ for (const el of document.querySelectorAll('body, body *')) {
20
+ if (!(el instanceof HTMLElement) || el.closest(THIRD)) continue;
21
+ if (![...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim())) continue;
22
+ const cs = getComputedStyle(el); const r = el.getBoundingClientRect();
23
+ if (!(el.getClientRects().length > 0 && cs.visibility !== 'hidden' && cs.display !== 'none' && r.right > 0 && r.bottom > 0 && parseFloat(cs.opacity) > 0)) continue;
24
+ const fs = parseFloat(cs.fontSize);
25
+ for (const [prop, k, rule] of RULES) {
26
+ const o = origin(el, prop); if (!(o.src === 'style-attr' && o.important)) continue;
27
+ let value;
28
+ if (prop === 'line-height') {
29
+ const range = document.createRange(); range.selectNodeContents(el);
30
+ const tops = new Set([...range.getClientRects()].filter(x => x.width > 0).map(x => Math.round(x.top)));
31
+ if (tops.size < 2) continue;
32
+ const span = document.createElement('span'); span.textContent = 'X'; span.style.cssText = 'display:inline-block;padding:0;margin:0;border:0;vertical-align:top';
33
+ el.appendChild(span); value = span.getBoundingClientRect().height; span.remove();
34
+ } else { const v = cs.getPropertyValue(prop); value = v === 'normal' ? 0 : parseFloat(v); }
35
+ out.applicable++;
36
+ if (!(value >= k * fs - 0.01)) out.fails.push({ sel: sel(el), lockedOn: o.on === el ? 'self' : sel(o.on), rule, property: prop, value: +value.toFixed(2), threshold: +(k * fs).toFixed(2), text: el.textContent.trim().slice(0, 40) });
37
+ }
38
+ }
39
+ return out;
40
+ }, [THIRD, RULES]);
41
+ // Override breakage: apply the 1.4.12 user styles and count newly clipped or overlapping text containers. Reported as finding only.
42
+ const over = await page.evaluate(([THIRD]) => {
43
+ const textEls = [...document.querySelectorAll('body *')].filter(el => !el.closest(THIRD) && [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim()) && el.getClientRects().length > 0).slice(0, 400);
44
+ const measure = () => textEls.map(el => { const cs = getComputedStyle(el); const r = el.getBoundingClientRect(); return { clipped: (cs.overflowX !== 'visible' || cs.overflowY !== 'visible') && (el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1), rect: [r.left, r.top, r.right, r.bottom] }; });
45
+ const overlaps = m => { let n = 0; for (let i = 0; i < m.length; i++) for (let j = i + 1; j < m.length; j++) { const a = m[i].rect, b = m[j].rect; if (!(textEls[i].contains(textEls[j]) || textEls[j].contains(textEls[i])) && a[0] < b[2] - 1 && b[0] < a[2] - 1 && a[1] < b[3] - 1 && b[1] < a[3] - 1) n++; } return n; };
46
+ const before = measure(); const ovBefore = overlaps(before);
47
+ const st = document.createElement('style'); st.textContent = '* { line-height: 1.5 !important; letter-spacing: 0.12em !important; word-spacing: 0.16em !important; } p { margin-bottom: 2em !important; }';
48
+ (document.head || document.documentElement).appendChild(st);
49
+ const after = measure(); const ovAfter = overlaps(after); st.remove();
50
+ return { textEls: textEls.length, newlyClipped: after.filter((a, i) => a.clipped && !before[i].clipped).length, newOverlaps: Math.max(0, ovAfter - ovBefore) };
51
+ }, [THIRD]);
52
+ const finding = (over.newlyClipped || over.newOverlaps) ? { kind: 'override-breakage', why: `with 1.4.12 user styles applied, ${over.newlyClipped} text containers clip and ${over.newOverlaps} new overlaps appear (not asserted by the ACT rules)` } : null;
53
+ const base = { applicable: act.applicable, override: over, ...(finding ? { finding } : {}) };
54
+ if (act.applicable === 0) return { verdict: 'not-applicable', why: 'no text whose spacing is locked by an !important style attribute', ...base };
55
+ if (act.fails.length) return { verdict: 'fail', why: `${act.fails.length} locked spacing values below the 1.4.12 minimum`, targets: act.fails, ...base };
56
+ return { verdict: 'pass', why: `${act.applicable} locked spacing values, all at or above the minimum`, ...base };
57
+ },
58
+ };
@@ -0,0 +1,15 @@
1
+ # page.text-spacing · WCAG 1.4.12 · provenance spec (ACT 24afc2 / 9e45ec / 78fd32)
2
+
3
+ - **why:** SC text: no loss of content or functionality occurs when letter-spacing is set to 0.12× font size, word-spacing to 0.16×, line-height to 1.5×. An `!important` declaration in a `style` attribute prevents user style sheets from reaching those values (ACT rules 24afc2, 9e45ec, 78fd32).
4
+ - **applies-when:** HTML element with a visible direct text node (has client rect, not hidden, not fully off-screen, opacity > 0), not inside a third-party subtree, whose cascaded value for the property originates from a `style` attribute with `!important`, on the element itself or an ancestor it inherits from. `inherit`/`unset` walk up; `initial`/`revert` count as a locked declaration. For line-height only: the text must soft-wrap to at least two line boxes.
5
+ - **correct-when:** computed letter-spacing ≥ 0.12 × font-size; word-spacing ≥ 0.16 × font-size; line-height used value (measured with an inline-block probe span) ≥ 1.5 × font-size. Tolerance 0.01px.
6
+ - **verdict:** `not-applicable` if no applicable target for any of the three properties; `fail` if any applicable target is below its threshold, listing element, property, value and threshold; `pass` otherwise.
7
+ - **invalid-if:** page fails to load; text not visible; page title matches a bot-challenge pattern (`unmeasurable · blocked`).
8
+ - **known-infidelity:** only the `style`-attribute lock is measured, per ACT. Breakage after applying user styles (clipping, overlap) is a different measurement and is reported as `finding`, never `fail`: the override-breakage check applies the 1.4.12 user styles and counts newly clipped text containers and new overlaps between text containers.
9
+
10
+ ## Revisions
11
+
12
+ v1 frozen 2026-09-05 (P0-A, sha256 `375832c3…da11bc`). ACT 62/62 with v1.
13
+
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
+ - 2026-09-06 verdict names mapped to the repo's list; override breakage attached as `finding`.
package/src/run.js CHANGED
@@ -32,7 +32,7 @@ export async function runJourney(J, { browser, outDir } = {}) {
32
32
  if (own) await browser.close();
33
33
  if (outDir) writeEvidence(ctx, probes, outDir);
34
34
  for (const s of ctx.steps) delete s.evidence;
35
- return { journey: J.name, ranAt: new Date().toISOString(), steps: ctx.steps, recorded: ctx.recorded, probes };
35
+ return { journey: J.name, ranAt: new Date().toISOString(), stepCount: J.steps.length, steps: ctx.steps, recorded: ctx.recorded, probes };
36
36
  }
37
37
 
38
38
  // Process segments: a goto or a submit that did not navigate starts a new segment; sameProcess joins ranges (provenance project).