@junixlabs/uxcli 0.1.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/LICENSE +21 -0
- package/README.md +81 -0
- package/VISION.md +71 -0
- package/bin/uxcli.js +31 -0
- package/examples/sylius-guest-checkout.json +11 -0
- package/package.json +16 -0
- package/src/browser.js +103 -0
- package/src/card.js +27 -0
- package/src/gate.js +33 -0
- package/src/journey.js +13 -0
- package/src/probes/consistent-navigation/must-fail/review.html +76 -0
- package/src/probes/consistent-navigation/pair.json +23 -0
- package/src/probes/consistent-navigation/probe.js +40 -0
- package/src/probes/consistent-navigation/spec.md +24 -0
- package/src/probes/error-prevention/must-fail/review.html +67 -0
- package/src/probes/error-prevention/pair.json +23 -0
- package/src/probes/error-prevention/probe.js +90 -0
- package/src/probes/error-prevention/spec.md +35 -0
- package/src/probes/redundant-entry/must-fail/review.html +78 -0
- package/src/probes/redundant-entry/pair.json +23 -0
- package/src/probes/redundant-entry/probe.js +59 -0
- package/src/probes/redundant-entry/spec.md +37 -0
- package/src/refute.js +23 -0
- package/src/run.js +58 -0
- package/src/util.js +6 -0
- package/test/fixtures/checkout/account.html +36 -0
- package/test/fixtures/checkout/cart.html +46 -0
- package/test/fixtures/checkout/contact.html +36 -0
- package/test/fixtures/checkout/details.html +77 -0
- package/test/fixtures/checkout/done.html +46 -0
- package/test/fixtures/checkout/help.html +36 -0
- package/test/fixtures/checkout/home.html +36 -0
- package/test/fixtures/checkout/privacy.html +36 -0
- package/test/fixtures/checkout/review.html +76 -0
- package/test/fixtures/checkout/shop.html +36 -0
- package/test/fixtures/checkout/style.css +28 -0
- package/test/fixtures/checkout/terms.html +36 -0
- package/test/journeys/checkout.json +10 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 junixlabs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# UXCLI
|
|
2
|
+
|
|
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
|
+
|
|
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`.
|
|
6
|
+
|
|
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
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
- Automated accessibility tools check one page at a time. Defects that span screens (data entered on step 1 missing from the review on step 3, navigation order changing between pages) have no automated check today.
|
|
12
|
+
- Agents cannot see what they render. The common failure is not blindness but false confirmation: given a screenshot of a broken UI, the agent reports success.
|
|
13
|
+
- Existing UI review tools produce false positives, and one false positive is enough for a developer to disable the tool.
|
|
14
|
+
|
|
15
|
+
## Install and use
|
|
16
|
+
|
|
17
|
+
Node 20+.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
npm install -g @junixlabs/uxcli
|
|
21
|
+
npx playwright-core install chromium-headless-shell # once; or set UXCLI_CHROME to a Chromium binary
|
|
22
|
+
uxcli run journey.json # verdict card
|
|
23
|
+
uxcli run journey.json --json # full evidence packet
|
|
24
|
+
uxcli run journey.json --refute # a fresh second reader checks each fail from the screenshots (needs the claude CLI, or set UXCLI_REFUTER)
|
|
25
|
+
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
|
+
```
|
|
28
|
+
|
|
29
|
+
`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
|
+
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
|
+
|
|
33
|
+
## How it works
|
|
34
|
+
|
|
35
|
+
The one input the machine cannot derive, the journey, is written by a human. Everything else is measured. The agent may propose a commitment; it may not commit it.
|
|
36
|
+
|
|
37
|
+
| Available | Planned |
|
|
38
|
+
|---|---|
|
|
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 |
|
|
42
|
+
|
|
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.
|
|
44
|
+
|
|
45
|
+
What comes next, and in what order, is in [ROADMAP.md](ROADMAP.md).
|
|
46
|
+
|
|
47
|
+
## Rules of output
|
|
48
|
+
|
|
49
|
+
- Every finding carries a provenance: `spec`, `project`, or `opinion`.
|
|
50
|
+
- Every probe carries a method status: `method-validated` or `method-unproven`. Unproven probes report `finding`, never `fail`.
|
|
51
|
+
- Nine verdicts: `pass`, `fail`, `finding`, `not-applicable`, `not-committed`, `unmeasurable`, `suppressed`, `stale`, `untested`. Only `fail` blocks a merge.
|
|
52
|
+
- No threshold set means `not-committed`, not `fail`.
|
|
53
|
+
- Cannot measure means `unmeasurable`, never `pass`.
|
|
54
|
+
- Every exception ships with a coverage test.
|
|
55
|
+
- No score, no summary line, no conformance claim.
|
|
56
|
+
|
|
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
|
+
## Non-goals
|
|
76
|
+
|
|
77
|
+
Conformance certification. Visual regression. Scores. Design critique.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT. See [LICENSE](LICENSE).
|
package/VISION.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# UXCLI
|
|
2
|
+
|
|
3
|
+
**Every verdict cites the commitment it enforces: W3C's, yours, or none.**
|
|
4
|
+
**Where no one has committed, UXCLI says nothing.**
|
|
5
|
+
|
|
6
|
+
## Design should be executable.
|
|
7
|
+
|
|
8
|
+
Software has machines that refuse a build that breaks a contract. Nothing refuses a build that breaks a journey. A journey map dies at handoff. A token becomes a suggestion. A principle becomes a thread. What ships is whatever survived, and no instrument can say how far it drifted from what was decided.
|
|
9
|
+
|
|
10
|
+
UXCLI is that instrument. It measures the running product against the commitments that produced it: the journeys that matter, the tokens that define the system, the principles that govern behavior. It drives a real browser. It follows flows. It measures change over time. It locates drift between builds to the element and the file. It sees defects that exist between screens, not only inside them.
|
|
11
|
+
|
|
12
|
+
This matters now because the author of the interface is changing. Agents produce more frontend than humans can inspect. The agent that writes the interface cannot be the authority that declares it correct. It needs perception, not another opinion.
|
|
13
|
+
|
|
14
|
+
A commitment becomes a measurement. A measurement produces evidence. Evidence earns a verdict.
|
|
15
|
+
|
|
16
|
+
**Your design, executable.**
|
|
17
|
+
|
|
18
|
+
## The next layer is not generation. It is perception.
|
|
19
|
+
|
|
20
|
+
axe answers *is this page broken?* Dembrandt answers *did the tokens drift?* Nothing answers *does the flow the user came for still complete?* The difference is the input. Those tools read a page. UXCLI reads a page and the commitment, and the commitment is a journey. That input makes a new class of defect measurable.
|
|
21
|
+
|
|
22
|
+
- **Between screens.** Data entered on step one, absent from the review on step three. Navigation that reorders itself between pages. No page-level tool can see this, because no page-level tool remembers.
|
|
23
|
+
- **Across time.** A toast that lives 800 ms when the principle says four seconds. Motion is data, not noise to freeze.
|
|
24
|
+
- **Between builds.** Drift, reported at the element and the file that caused it. Never as a score.
|
|
25
|
+
- **At five scales.** One element. One screen. One flow. The whole design system. The delta between two builds. One instrument, five zoom levels.
|
|
26
|
+
|
|
27
|
+
## Who runs it
|
|
28
|
+
|
|
29
|
+
The human operating a coding agent. The pain is specific: the agent reports success with proof of failure in hand. UXCLI is the instrument an agent runs before it is allowed to say done.
|
|
30
|
+
|
|
31
|
+
Commitments are written by a human, before the build, and versioned. The agent may propose one. It may not commit it. The party being measured never authors the measure in the same run.
|
|
32
|
+
|
|
33
|
+
Developers get the same instrument as a pre-merge gate: findings that need no re-checking before fixing, and a `fail` that blocks.
|
|
34
|
+
|
|
35
|
+
## Why it stays on
|
|
36
|
+
|
|
37
|
+
A review tool that lies three times is disabled forever. UXCLI is built around one rule and its consequences.
|
|
38
|
+
|
|
39
|
+
**No commitment, no verdict.** A commitment is a threshold with a named owner and a written source. W3C is a named owner, so WCAG runs by default. Nobody signed taste, so taste never runs.
|
|
40
|
+
|
|
41
|
+
- Every finding names its provenance: `spec`, `project`, or `opinion`. Opinion without a written principle stays silent.
|
|
42
|
+
- Every probe declares its method. An unproven method can raise a `finding`, never a `fail`.
|
|
43
|
+
- No commitment yields `not-committed`, not `fail`. Cannot measure yields `unmeasurable`, never `pass`.
|
|
44
|
+
- Nine verdicts. One blocks a merge.
|
|
45
|
+
- The opening rule set contains only probes proven not to false-positive, and at least one of them is a flow rule.
|
|
46
|
+
|
|
47
|
+
A tool with no opinions of its own has nothing to be wrong about.
|
|
48
|
+
|
|
49
|
+
**UXCLI wins by saying less, and being right.**
|
|
50
|
+
|
|
51
|
+
## Shape
|
|
52
|
+
|
|
53
|
+
Two skills write commitments: `journey`, `principles`. Seven commands measure: `init`, `run`, `sheet`, `query`, `scan`, `diff --gate`, `why`. Everything that carries a name carries `id · why · correct-when · applies-when`.
|
|
54
|
+
|
|
55
|
+
UXCLI does not score design. It does not certify taste. It does not replace visual regression. It does not turn uncertainty into green checks.
|
|
56
|
+
|
|
57
|
+
## Where it stands
|
|
58
|
+
|
|
59
|
+
| Axis | Evidence | Verdict |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| Method | focus-visible 7/7 W3C ACT, 22 GOV.UK pages 0 false fails; text-spacing 62/62; axe contrast 0 FP. Definitions frozen and hashed, then 20 unseen pages on 5 sites: 0 false fails. Three independent detectors and chrome-devtools-mcp #86 converged on the same computed-style approach. | Confirmed |
|
|
62
|
+
| The flow claim | The gap is real: axe has no rules for 3.3.4 / 3.3.7 / 3.2.3; Evinced and Lighthouse aggregate per page. Three flow probes written from the WCAG text before any fixture existed. Seeded fixture: 3/3 caught, silent on the clean twin. 10 flows on 4 real apps the probes had never seen, definitions frozen: 0 false fails; one real 3.3.7 defect found by hand that the probe did not see. | Measured on fixtures. Unproven at scale. |
|
|
63
|
+
| Adoption | The strongest community pain is agents that report success with proof of failure in hand. 40 fresh Sonnet sessions on the seeded fixture, 20 of them running the tool themselves: 0 reported done while a check still failed, and none treated `unmeasurable` as a pass. Agents given the card edited only the cited file in 19 of 20 sessions; agents given the full JSON packet, in 7 of 20. Given a planted wrong `fail`, 2 of 10 agents without the tool rewrote the site to satisfy it; 0 of 10 with the tool in hand did, all re-ran it and disputed. | Measured on one model and one fixture. The resistance comes from re-running the measurement, not from the report's wording. |
|
|
64
|
+
|
|
65
|
+
Next, in order: 20 flows on apps the probes have never seen; recall from defects seeded into real pages by someone who does not tune the probes; a second model on the planted-verdict test.
|
|
66
|
+
|
|
67
|
+
**The part worth building is the part not yet proven.**
|
|
68
|
+
|
|
69
|
+
## Sources
|
|
70
|
+
|
|
71
|
+
developersdigest.tech/blog/ai-design-slop-and-how-to-spot-it · impeccable.style/slop · solodesign.cc/blog/ai-design-slop-the-tells · github.com/ChromeDevTools/chrome-devtools-mcp/issues/86 · github.com/anthropics/claude-code/issues/38948 · news.ycombinator.com/item?id=46594200 · arxiv.org/abs/2503.15885 · arxiv.org/abs/2502.10884 · dequeuniversity.com/rules/axe/4.10 · github.com/dembrandt/dembrandt
|
package/bin/uxcli.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
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';
|
|
4
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
5
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
6
|
+
const flags = new Set(rest.filter(a => a.startsWith('--') && !a.includes('='))); const args = rest.filter(a => !a.startsWith('--'));
|
|
7
|
+
const opt = k => (rest.find(a => a.startsWith('--' + k + '=')) || '').split('=').slice(1).join('=') || null;
|
|
8
|
+
const usage = `usage:
|
|
9
|
+
uxcli run <journey.json> [--json] [--out=DIR] [--refute]
|
|
10
|
+
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
|
+
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)
|
|
14
|
+
browser: playwright-core; set UXCLI_CHROME to a Chromium binary if none is installed for playwright.`;
|
|
15
|
+
try {
|
|
16
|
+
if (cmd === 'run' && args[0]) {
|
|
17
|
+
const { loadJourney } = await import('../src/journey.js'); const { runJourney } = await import('../src/run.js'); const { card } = await import('../src/card.js');
|
|
18
|
+
const outDir = opt('out') || path.join('.uxcli', path.basename(args[0], '.json'));
|
|
19
|
+
const result = await runJourney(loadJourney(path.resolve(args[0])), { outDir });
|
|
20
|
+
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
|
+
console.log(flags.has('--json') ? JSON.stringify(result, null, 1) : card(result));
|
|
22
|
+
process.exit(result.probes.some(p => p.verdict === 'fail') ? 2 : 0);
|
|
23
|
+
} else if (cmd === 'gate') {
|
|
24
|
+
const { gate } = await import('../src/gate.js'); process.exit((await gate()) ? 0 : 1);
|
|
25
|
+
} else if (cmd === 'why' && args[0]) {
|
|
26
|
+
const dirs = fs.readdirSync(path.join(ROOT, 'src/probes'));
|
|
27
|
+
const hit = dirs.find(d => d === args[0] || fs.readFileSync(path.join(ROOT, 'src/probes', d, 'spec.md'), 'utf8').split('\n')[0].includes('WCAG ' + args[0]));
|
|
28
|
+
if (!hit) { console.error('no probe for ' + args[0] + '; have: ' + dirs.join(', ')); process.exit(1); }
|
|
29
|
+
console.log(fs.readFileSync(path.join(ROOT, 'src/probes', hit, 'spec.md'), 'utf8'));
|
|
30
|
+
} else { console.log(usage); process.exit(cmd ? 1 : 0); }
|
|
31
|
+
} catch (e) { console.error('uxcli: ' + (e.message || e)); process.exit(1); }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Sylius v2 demo guest checkout (B1 round 2)",
|
|
3
|
+
"checkedPass": true,
|
|
4
|
+
"steps": [
|
|
5
|
+
{ "url": "https://v2.demo.sylius.com/en_US/products/denim-daydream", "click": "#add-to-cart-button" },
|
|
6
|
+
{ "url": "https://v2.demo.sylius.com/en_US/checkout/address", "fill": { "#sylius_shop_checkout_address_customer_email": "uxcli1788625992@example.org", "#sylius_shop_checkout_address_billingAddress_firstName": "Jane", "#sylius_shop_checkout_address_billingAddress_lastName": "Doe", "#sylius_shop_checkout_address_billingAddress_street": "12 High Street", "#sylius_shop_checkout_address_billingAddress_countryCode": "United States", "#sylius_shop_checkout_address_billingAddress_city": "New York", "#sylius_shop_checkout_address_billingAddress_postcode": "10001" }, "submit": "form[name=sylius_shop_checkout_address] button[type=submit]" },
|
|
7
|
+
{ "url": "https://v2.demo.sylius.com/en_US/checkout/select-shipping", "submit": "form[name=sylius_shop_checkout_select_shipping] button[type=submit]" },
|
|
8
|
+
{ "url": "https://v2.demo.sylius.com/en_US/checkout/select-payment", "submit": "form[name=sylius_shop_checkout_select_payment] button[type=submit]" },
|
|
9
|
+
{ "url": "https://v2.demo.sylius.com/en_US/checkout/complete", "commit": true, "submit": "form button[type=submit]:has-text('Place order')" }
|
|
10
|
+
]
|
|
11
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@junixlabs/uxcli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "UI/UX review for coding agents. No commitment, no verdict.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": { "type": "git", "url": "git+https://github.com/junixlabs/uxcli.git" },
|
|
7
|
+
"homepage": "https://github.com/junixlabs/uxcli#readme",
|
|
8
|
+
"bugs": { "url": "https://github.com/junixlabs/uxcli/issues" },
|
|
9
|
+
"keywords": ["accessibility", "wcag", "ui-review", "playwright", "coding-agents"],
|
|
10
|
+
"type": "module",
|
|
11
|
+
"bin": { "uxcli": "bin/uxcli.js" },
|
|
12
|
+
"files": ["bin", "src", "test", "examples", "README.md", "VISION.md", "LICENSE"],
|
|
13
|
+
"engines": { "node": ">=20" },
|
|
14
|
+
"scripts": { "test": "node bin/uxcli.js gate" },
|
|
15
|
+
"dependencies": { "playwright-core": "^1.58.0" }
|
|
16
|
+
}
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Browser helpers shared by the run and by probes that replay steps.
|
|
2
|
+
import { chromium } from 'playwright-core';
|
|
3
|
+
import { THIRD, CHROME, normUrl } from './util.js';
|
|
4
|
+
|
|
5
|
+
export async function launch() {
|
|
6
|
+
const executablePath = process.env.UXCLI_CHROME || process.env.CHROME_EXE || undefined;
|
|
7
|
+
try { return await chromium.launch({ executablePath, headless: true }); }
|
|
8
|
+
catch (e) { if (/executable doesn't exist|install/i.test(String(e))) throw new Error('no Chromium for playwright-core. Run: npx playwright-core install chromium-headless-shell (or set UXCLI_CHROME to a Chromium binary)'); throw e; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// In-page collectors. Injected as source so every probe reads the DOM the same way.
|
|
12
|
+
export const PAGE_FNS = `
|
|
13
|
+
const THIRD = ${JSON.stringify(THIRD)}; const CHROME = ${JSON.stringify(CHROME)};
|
|
14
|
+
const norm = s => String(s ?? '').trim().toLowerCase().replace(/\\s+/g, ' ');
|
|
15
|
+
const vis = el => { if (!el.getClientRects().length) return false; const cs = getComputedStyle(el); return cs.visibility !== 'hidden' && cs.display !== 'none' && parseFloat(cs.opacity) > 0; };
|
|
16
|
+
const third = el => !!el.closest(THIRD);
|
|
17
|
+
const label = el => {
|
|
18
|
+
if (el.getAttribute('aria-label')) return el.getAttribute('aria-label');
|
|
19
|
+
const lb = el.getAttribute('aria-labelledby'); if (lb) { const t = lb.split(/\\s+/).map(id => document.getElementById(id)?.textContent || '').join(' ').trim(); if (t) return t; }
|
|
20
|
+
if (el.id) { const l = document.querySelector('label[for="' + CSS.escape(el.id) + '"]'); if (l && l.textContent.trim()) return l.textContent; }
|
|
21
|
+
const wrap = el.closest('label'); if (wrap) { const t = wrap.textContent.trim(); if (t) return t; }
|
|
22
|
+
return el.getAttribute('title') || el.getAttribute('placeholder') || '';
|
|
23
|
+
};
|
|
24
|
+
const identity = el => {
|
|
25
|
+
const ac = (el.getAttribute('autocomplete') || '').trim().toLowerCase().split(/\\s+/).filter(Boolean);
|
|
26
|
+
const section = ac.find(t => t.startsWith('section-') || t === 'shipping' || t === 'billing') || '';
|
|
27
|
+
const token = ac.filter(t => !(t.startsWith('section-') || t === 'shipping' || t === 'billing')).pop() || '';
|
|
28
|
+
const fs = el.closest('fieldset'); const legend = fs ? norm(fs.querySelector('legend')?.textContent) : '';
|
|
29
|
+
return { autocomplete: token, section, legend, name: el.getAttribute('name') || '', type: (el.getAttribute('type') || el.tagName).toLowerCase(), label: norm(label(el)), tag: el.tagName.toLowerCase(), id: el.id || '' };
|
|
30
|
+
};
|
|
31
|
+
const editable = el => !el.disabled && !el.readOnly && vis(el) && !third(el) && !['hidden','password','submit','button','search','reset','image','file','checkbox','radio'].includes((el.getAttribute('type')||'').toLowerCase());
|
|
32
|
+
const processInputs = () => { const form = document.querySelector('[data-uxcli-scope]'); if (!form) return []; return [...form.querySelectorAll('input, textarea, select')].filter(el => editable(el) && !el.closest(CHROME)).map(el => ({ ...identity(el), value: el.tagName === 'SELECT' ? (el.selectedOptions[0]?.textContent || el.value) : el.value })); };
|
|
33
|
+
const visibleText = () => { const hidden = [...document.querySelectorAll(THIRD)]; hidden.forEach(e => e.setAttribute('data-uxcli-hide', '1')); const st = document.createElement('style'); st.textContent = '[data-uxcli-hide]{display:none!important}'; document.head.appendChild(st); const t = document.body.innerText; st.remove(); hidden.forEach(e => e.removeAttribute('data-uxcli-hide')); return t; };
|
|
34
|
+
const readTexts = () => (document.querySelector('main') || document.body).textContent + '\\u0000' + [...document.querySelectorAll('input, textarea, select')].map(e => e.value).join('\\u0001');
|
|
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
|
+
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 }; }); };
|
|
38
|
+
`;
|
|
39
|
+
export const evalIn = (page, body) => page.evaluate(new Function(PAGE_FNS + ' return (' + body + ')();'));
|
|
40
|
+
export const evalEl = (locator, body) => locator.evaluate(new Function('el', PAGE_FNS + ' return (' + body + ')(el);'));
|
|
41
|
+
|
|
42
|
+
export async function settle(page) {
|
|
43
|
+
await page.waitForLoadState('domcontentloaded', { timeout: 30000 }).catch(() => {});
|
|
44
|
+
await page.waitForLoadState('load', { timeout: 15000 }).catch(() => {});
|
|
45
|
+
await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
|
|
46
|
+
await page.waitForTimeout(300);
|
|
47
|
+
}
|
|
48
|
+
export async function arrive(page, step) {
|
|
49
|
+
if (step.url && normUrl(page.url()) !== normUrl(step.url)) { await page.goto(step.url, { waitUntil: 'load', timeout: 45000 }); await settle(page); return 'goto'; }
|
|
50
|
+
await settle(page); return 'flow';
|
|
51
|
+
}
|
|
52
|
+
export async function noise(page) { const a = await evalIn(page, '() => readTexts()'); await page.waitForTimeout(600); const b = await evalIn(page, '() => readTexts()'); return a !== b; }
|
|
53
|
+
|
|
54
|
+
// Mark the process scope for this step: the submit control's form, else the nearest region around the control.
|
|
55
|
+
export async function markScope(page, step) {
|
|
56
|
+
await page.evaluate(() => document.querySelectorAll('[data-uxcli-scope]').forEach(f => f.removeAttribute('data-uxcli-scope')));
|
|
57
|
+
const sel = step.submit || step.click; if (!sel) return null;
|
|
58
|
+
try {
|
|
59
|
+
const ctl = page.locator(sel).first(); if (!await ctl.count()) return null;
|
|
60
|
+
return await ctl.evaluate(el => { const f = el.closest('form'); if (f) { f.setAttribute('data-uxcli-scope', '1'); return 'form'; } const c = el.closest('fieldset, section, article, [role=region], [role=main], main') || document.querySelector('main') || document.body; c.setAttribute('data-uxcli-scope', '1'); return 'region:' + c.tagName.toLowerCase(); });
|
|
61
|
+
} catch { return null; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fill a step's fields. Records every non-password value with the field's identity; returns the step's auth flag.
|
|
65
|
+
export async function fillStep(page, step, idx, recorded) {
|
|
66
|
+
let auth = false;
|
|
67
|
+
for (const [sel, spec] of Object.entries(step.fill || {})) {
|
|
68
|
+
const value = typeof spec === 'object' ? spec.value : spec; const marks = typeof spec === 'object' ? { essential: !!spec.essential, security: !!spec.security } : {};
|
|
69
|
+
const loc = page.locator(sel).first();
|
|
70
|
+
const id = await evalEl(loc, 'el => { const i = identity(el); i.required = el.required || el.getAttribute("aria-required") === "true"; i.minlength = el.getAttribute("minlength") ? parseInt(el.getAttribute("minlength"), 10) : 0; return i; }');
|
|
71
|
+
if (id.tag === 'select') await loc.selectOption({ label: String(value) }).catch(() => loc.selectOption(String(value))); else await loc.fill(String(value));
|
|
72
|
+
const display = id.tag === 'select' ? await loc.evaluate(el => el.selectedOptions[0]?.textContent || el.value) : String(value);
|
|
73
|
+
if (id.type === 'password') { auth = true; continue; }
|
|
74
|
+
const credentialCandidate = /user|login|email/i.test(id.name + ' ' + id.id + ' ' + id.label) || id.type === 'email' || /^(username|email)$/.test(id.autocomplete);
|
|
75
|
+
recorded.push({ ...id, ...marks, value: String(value), display, step: idx, selector: sel, credentialCandidate });
|
|
76
|
+
}
|
|
77
|
+
return auth;
|
|
78
|
+
}
|
|
79
|
+
// Perform the step's action. Returns 'submitDidNotNavigate' when a submit left the URL unchanged.
|
|
80
|
+
export async function act(page, step) {
|
|
81
|
+
const sel = step.submit || step.click; if (!sel) return null;
|
|
82
|
+
const before = page.url();
|
|
83
|
+
await page.locator(sel).first().click({ timeout: 15000 });
|
|
84
|
+
await page.waitForFunction(u => location.href !== u, before, { timeout: 5000 }).catch(() => {});
|
|
85
|
+
await settle(page);
|
|
86
|
+
return step.submit && normUrl(page.url()) === normUrl(before) ? 'submitDidNotNavigate' : null;
|
|
87
|
+
}
|
|
88
|
+
export async function screen(page, i, rec) {
|
|
89
|
+
return { step: i, url: rec.url, noise: rec.noise, text: await evalIn(page, '() => visibleText()'), ro: await evalIn(page, '() => fields()'), links: await evalIn(page, '() => controls()') };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Evidence: screenshot of a region with the cited fields outlined. Returns a PNG buffer or null.
|
|
93
|
+
export async function shot(page, regionSelector, highlight = []) {
|
|
94
|
+
try {
|
|
95
|
+
await page.evaluate(sels => { for (const s of sels) for (const el of document.querySelectorAll(s)) { el.setAttribute('data-uxcli-mark', '1'); el.style.outline = '3px solid #d40000'; el.style.outlineOffset = '2px'; } }, highlight);
|
|
96
|
+
const loc = regionSelector ? page.locator(regionSelector).first() : null;
|
|
97
|
+
const buf = loc && await loc.count() && await loc.isVisible() ? await loc.screenshot({ timeout: 5000 }) : await page.screenshot({ timeout: 5000 });
|
|
98
|
+
await page.evaluate(() => document.querySelectorAll('[data-uxcli-mark]').forEach(el => { el.style.outline = ''; el.style.outlineOffset = ''; el.removeAttribute('data-uxcli-mark'); }));
|
|
99
|
+
return buf;
|
|
100
|
+
} catch { return null; }
|
|
101
|
+
}
|
|
102
|
+
export const fieldSelector = f => f.id ? '#' + CSS_escape(f.id) : f.name ? `[name="${f.name.replace(/"/g, '\\"')}"]` : null;
|
|
103
|
+
const CSS_escape = s => s.replace(/([^a-zA-Z0-9_-])/g, '\\$1');
|
package/src/card.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
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());
|
|
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
|
+
export function card(result) {
|
|
5
|
+
const L = [`uxcli run · ${result.journey}`, ''];
|
|
6
|
+
for (const p of result.probes) {
|
|
7
|
+
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)}`;
|
|
9
|
+
const rule = ` rule WCAG ${p.sc} (spec)${p.override ? ` · process joined by sameProcess ${JSON.stringify(p.override.sameProcess)} (project)` : ''}`;
|
|
10
|
+
if (p.verdict === 'fail' && p.sc === '3.3.7') {
|
|
11
|
+
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
|
+
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
|
+
} 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);
|
|
16
|
+
} else if (p.verdict === 'fail' && p.sc === '3.2.3') {
|
|
17
|
+
const x = p.inversion;
|
|
18
|
+
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);
|
|
19
|
+
} else if (p.verdict === 'pass') {
|
|
20
|
+
const why = p.sc === '3.3.4' ? `via ${p.branch}${p.branches?.confirmed?.changeMechanism ? ` · change control "${typeof p.branches.confirmed.changeMechanism === 'string' ? p.branches.confirmed.changeMechanism : p.branches.confirmed.changeMechanism.text}"` : ''}${p.branches?.checked?.evidence ? ` · ${p.branches.checked.evidence}` : ''}`
|
|
21
|
+
: 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
|
+
: `${p.comparedPairs} pairs, ${Object.keys(p.mechanisms || {}).length} mechanisms, no inversion`;
|
|
23
|
+
L.push(`${head} ${why}`);
|
|
24
|
+
} else L.push(`${head} ${(p.why || '').split(';')[0].slice(0, 100)}`);
|
|
25
|
+
}
|
|
26
|
+
return L.join('\n');
|
|
27
|
+
}
|
package/src/gate.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
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';
|
|
4
|
+
|
|
5
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
6
|
+
const sha = f => crypto.createHash('sha256').update(fs.readFileSync(f)).digest('hex');
|
|
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
|
+
|
|
9
|
+
export async function gate({ log = console.log } = {}) {
|
|
10
|
+
const browser = await launch(); let ok = true;
|
|
11
|
+
const site = path.join(ROOT, 'test/fixtures/checkout'); const siteHashes = hashTree(site);
|
|
12
|
+
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 = [];
|
|
16
|
+
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`);
|
|
18
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'uxcli-gate-'));
|
|
19
|
+
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
|
+
const jPath = path.join(ROOT, pair.journey);
|
|
21
|
+
const vf = (await runJourney(loadJourney(jPath, { base: build(path.join(dir, 'must-fail')) }), { browser })).probes.find(p => p.sc === probe.sc);
|
|
22
|
+
const vp = (await runJourney(loadJourney(jPath, { base: build(null) }), { browser })).probes.find(p => p.sc === probe.sc);
|
|
23
|
+
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}`);
|
|
29
|
+
}
|
|
30
|
+
await browser.close();
|
|
31
|
+
log(ok ? 'GATE PASS' : 'GATE FAIL');
|
|
32
|
+
return ok;
|
|
33
|
+
}
|
package/src/journey.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
// Journey = the commitment. { name, checkedPass?, reversible?, sameProcess?: [[from,to]], steps: [{ url?, fill?, click?, submit?, commit?, userReorder? }] }
|
|
3
|
+
export function loadJourney(file, vars = {}) {
|
|
4
|
+
let text = fs.readFileSync(file, 'utf8');
|
|
5
|
+
for (const [k, v] of Object.entries(vars)) text = text.split('{{' + k + '}}').join(v);
|
|
6
|
+
const J = JSON.parse(text);
|
|
7
|
+
if (!Array.isArray(J.steps) || !J.steps.length) throw new Error('journey needs steps[]');
|
|
8
|
+
if (!J.steps[0].url) throw new Error('first step needs a url');
|
|
9
|
+
if (J.steps.filter(s => s.commit).length > 1) throw new Error('at most one step may be marked commit');
|
|
10
|
+
for (const [a, b] of J.sameProcess || []) if (!(a >= 0 && b >= a && b < J.steps.length)) throw new Error('sameProcess range out of bounds');
|
|
11
|
+
J.name ||= file;
|
|
12
|
+
return J;
|
|
13
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
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="cart.html">Cart <span class="badge">2<span class="sr"> items</span></span></a></li>
|
|
17
|
+
<li><a href="shop.html">Shop</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
|
+
</header>
|
|
24
|
+
<main class="wrap">
|
|
25
|
+
<h1>Review your order</h1>
|
|
26
|
+
<p>Check everything is correct before you place your order.</p>
|
|
27
|
+
|
|
28
|
+
<h2>Items</h2>
|
|
29
|
+
<table>
|
|
30
|
+
<thead>
|
|
31
|
+
<tr><th scope="col">Item</th><th scope="col" class="num">Qty</th><th scope="col" class="num">Price</th></tr>
|
|
32
|
+
</thead>
|
|
33
|
+
<tbody>
|
|
34
|
+
<tr><td>Ceramic mug</td><td class="num">1</td><td class="num">£12.00</td></tr>
|
|
35
|
+
<tr><td>Notebook, A5, dotted</td><td class="num">1</td><td class="num">£8.50</td></tr>
|
|
36
|
+
</tbody>
|
|
37
|
+
</table>
|
|
38
|
+
<p class="total">Total: £20.50</p>
|
|
39
|
+
|
|
40
|
+
<h2>Your details</h2>
|
|
41
|
+
<dl id="summary">
|
|
42
|
+
<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>
|
|
43
|
+
<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>
|
|
44
|
+
<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>
|
|
45
|
+
<div><dt>Postcode</dt><dd><span id="out-postcode"></span><a href="details.html#postcode">Change<span class="sr"> postcode</span></a></dd></div>
|
|
46
|
+
<div><dt>Country</dt><dd><span id="out-country"></span><a href="details.html#country">Change<span class="sr"> country</span></a></dd></div>
|
|
47
|
+
</dl>
|
|
48
|
+
|
|
49
|
+
<form id="order-form" action="done.html" method="get">
|
|
50
|
+
<button type="submit">Place order</button>
|
|
51
|
+
</form>
|
|
52
|
+
<script>
|
|
53
|
+
(function () {
|
|
54
|
+
var fields = ['name', 'email', 'address', 'postcode', 'country'];
|
|
55
|
+
var saved = {};
|
|
56
|
+
try { saved = JSON.parse(sessionStorage.getItem('checkout') || '{}'); } catch (e) {}
|
|
57
|
+
fields.forEach(function (f) {
|
|
58
|
+
document.getElementById('out-' + f).textContent = saved[f] || '';
|
|
59
|
+
});
|
|
60
|
+
document.getElementById('order-form').addEventListener('submit', function (ev) {
|
|
61
|
+
ev.preventDefault();
|
|
62
|
+
sessionStorage.setItem('orderPlaced', '1');
|
|
63
|
+
location.href = 'done.html';
|
|
64
|
+
});
|
|
65
|
+
})();
|
|
66
|
+
</script>
|
|
67
|
+
</main>
|
|
68
|
+
<footer>
|
|
69
|
+
<div class="wrap">
|
|
70
|
+
<a href="privacy.html">Privacy</a>
|
|
71
|
+
<a href="terms.html">Terms</a>
|
|
72
|
+
<a href="contact.html">Contact</a>
|
|
73
|
+
</div>
|
|
74
|
+
</footer>
|
|
75
|
+
</body>
|
|
76
|
+
</html>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"journey": "test/journeys/checkout.json",
|
|
3
|
+
"operator": "review.html: Cart and Shop are swapped in nav[aria-label=Main]; every other page keeps Home, Shop, Cart, Account, Help",
|
|
4
|
+
"hashes": {
|
|
5
|
+
"site": {
|
|
6
|
+
"account.html": "3972383a0e127789055e87f517e5812e7d0aadf98a979748b9468cbf5cdcef29",
|
|
7
|
+
"cart.html": "c40a826acc5df143e6c8bdb8b2ad95b6c236a1bf24315ef4a729902209fa3125",
|
|
8
|
+
"contact.html": "fe0b4f737714599aea8ffc61a0e3e66e66eb2ef900f97f29970d90085bdfb457",
|
|
9
|
+
"details.html": "866dedf0b0ff5baea57490907b07d2f726f00c69add81128e7938438e8f4d987",
|
|
10
|
+
"done.html": "714bfde7c1ba4e24d162f1faf9ab781ac0d06f44b149441c4845e740dcc32102",
|
|
11
|
+
"help.html": "e457b0e9a716019065de458d89114c37fbd14e67ada1ec69c8a2f65efbd1acbf",
|
|
12
|
+
"home.html": "287874d84b918ed8d6c16ce73d9ab631a489a5f9263933811e8ea960702126b2",
|
|
13
|
+
"privacy.html": "24d287f9a4e59218d69eb38360f674a92ada08860f2b194f71b7be2b9dbbdeff",
|
|
14
|
+
"review.html": "6a2191f30dc103062e986c7fada8f036f0931bc2247a70ed599c8b7d50b361b4",
|
|
15
|
+
"shop.html": "56a4d04abc3ca851bc29aff43be027c7f6406aeb0eb90d5dccc39c6088a1e679",
|
|
16
|
+
"style.css": "7f85da7845e02428fb480f86b8bcc3e32ae491882f27bcf35c10caef88999586",
|
|
17
|
+
"terms.html": "bd6a5c83a4e786574107a3aa02dc18e5b327624ebc14739fd0ae1785d180c187"
|
|
18
|
+
},
|
|
19
|
+
"mustFail": {
|
|
20
|
+
"review.html": "0fcc81bfb34841552284d06c691824e6e2461e0ec178940dbd6c961d1097946c"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// flow.consistent-navigation · WCAG 3.2.3. Spec in spec.md; falsification pair in pair.json.
|
|
2
|
+
import { normUrl } from '../../util.js';
|
|
3
|
+
import { evalIn } from '../../browser.js';
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
id: 'flow.consistent-navigation', sc: '3.2.3',
|
|
7
|
+
async onStep(page, rec, ctx) {
|
|
8
|
+
const navs = await evalIn(page, '() => navs()');
|
|
9
|
+
(ctx.navsByStep ||= []).push({ i: rec.i, origin: new URL(rec.url).origin, userReorder: !!ctx.J.steps[rec.i].userReorder, navs });
|
|
10
|
+
const shots = {}; const els = page.locator('nav, [role="navigation"]'); const n = await els.count();
|
|
11
|
+
for (let k = 0; k < Math.min(n, navs.length); k++) { const el = els.nth(k); if (await el.isVisible().catch(() => false)) shots[navs[k].key] = await el.screenshot({ timeout: 5000 }).catch(() => null); }
|
|
12
|
+
(rec.evidence ||= {})['3.2.3'] = shots;
|
|
13
|
+
},
|
|
14
|
+
async evaluate(ctx) {
|
|
15
|
+
const navsByStep = ctx.navsByStep || [];
|
|
16
|
+
if (ctx.blocked) return { verdict: 'blocked' };
|
|
17
|
+
if (navsByStep.length < 2) return { verdict: 'unmeasurable', why: 'fewer than two steps loaded' };
|
|
18
|
+
const origin0 = navsByStep[0].origin; const same = navsByStep.filter(n => n.origin === origin0 && !n.userReorder);
|
|
19
|
+
const mechanisms = {}; let compared = 0, inversion = null;
|
|
20
|
+
for (const s of same) for (const n of s.navs) (mechanisms[n.key] ||= []).push({ step: s.i, links: n.links });
|
|
21
|
+
for (const [key, occ] of Object.entries(mechanisms)) {
|
|
22
|
+
for (let a = 0; a < occ.length && !inversion; a++) for (let b = a + 1; b < occ.length && !inversion; b++) {
|
|
23
|
+
const A = occ[a].links, B = occ[b].links;
|
|
24
|
+
const count = arr => arr.reduce((m, l) => (m[normUrl(l.href)] = (m[normUrl(l.href)] || 0) + 1, m), {});
|
|
25
|
+
const hA = count(A), hB = count(B);
|
|
26
|
+
// identity: href when unique within the mechanism on both pages, else text
|
|
27
|
+
const idOf = l => { const h = normUrl(l.href); if (hA[h] === 1 && hB[h] === 1) return 'h:' + h; return l.text ? 't:' + l.text : null; };
|
|
28
|
+
const uniq = arr => arr.filter((x, i) => arr.indexOf(x) === i);
|
|
29
|
+
const seqA = uniq(A.map(idOf).filter(Boolean)), seqB = uniq(B.map(idOf).filter(Boolean));
|
|
30
|
+
const cA = seqA.filter(x => seqB.includes(x)), cB = seqB.filter(x => cA.includes(x));
|
|
31
|
+
if (cA.length < 2) continue; compared++;
|
|
32
|
+
for (let k = 0; k < cA.length; k++) if (cA[k] !== cB[k]) { inversion = { mechanism: key, stepA: occ[a].step, stepB: occ[b].step, expected: cA, got: cB, firstInvertedPair: [cA[k], cB[k]] }; break; }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const out = { mechanisms: Object.fromEntries(Object.entries(mechanisms).map(([k, v]) => [k, v.map(o => ({ step: o.step, links: o.links.length }))])), comparedPairs: compared };
|
|
36
|
+
if (navsByStep.some(n => n.origin !== origin0)) out.note = 'steps on another origin excluded';
|
|
37
|
+
if (inversion) return { ...out, verdict: 'fail', inversion };
|
|
38
|
+
return { ...out, verdict: compared ? 'pass' : 'not-applicable' };
|
|
39
|
+
}
|
|
40
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# flow.consistent-navigation · WCAG 3.2.3 · provenance spec
|
|
2
|
+
|
|
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.
|
|
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
|
+
- **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
|
+
- **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.
|
|
9
|
+
|
|
10
|
+
## Input: Journey input (the commitment)
|
|
11
|
+
|
|
12
|
+
A journey is an ordered list of steps in one process. Each step: `url` (or an action that navigates), optional `fill` map, optional `submit` selector. One step may be marked `commit: true`: the action that causes a legal commitment, financial transaction, or modification of user data. The runner records every value the user (or the journey) enters, with the field's identity (`autocomplete`, `name`, `type`, accessible label) and the step index. If no step is marked `commit`, the 3.3.4 probe returns `not-committed`, never `fail`.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## Shared invalid-if
|
|
17
|
+
|
|
18
|
+
- Page title matches a bot-challenge pattern → `blocked`.
|
|
19
|
+
- For 3.3.4 and 3.3.7, noise is defined on what they read: if the `textContent` of `main` (or `body` when no `main`) or any input value changes over 600 ms with no interaction → `unmeasurable`. Geometry changes are not noise for these probes. 3.2.3 reads the DOM once and has no noise check.
|
|
20
|
+
- Third-party subtrees (`iframe`, `ins.adsbygoogle`, `.google-auto-placed`, `[id^=aswift]`, `[id^=google_ads]`, `.google-anno*`) are excluded from all three probes. This exclusion is carried from the P0-A finding and is part of the frozen definition.
|
|
21
|
+
|
|
22
|
+
## Falsification pair
|
|
23
|
+
|
|
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.
|