a11y-loop 0.2.4 → 0.2.6
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +109 -5
- package/package.json +1 -1
- package/skill/a11y-loop/SKILL.md +19 -1
- package/skill/a11y-loop/references/manual-testing.md +71 -0
- package/src/lib/browser-utils.js +57 -5
- package/src/lib/checks/dialog.js +150 -19
- package/src/lib/checks/keyboard.js +6 -4
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "Makes AI coding agents decide accessibility while the work is still being planned and write accessible UI by default, then verify it in a real browser across the states they built — and say exactly what could not be checked. Ships the a11y-loop Agent Skill, a /a11y-plan command, and a plan-mode gate that declines a UI plan with no accessibility content in it.",
|
|
9
|
-
"version": "0.2.
|
|
9
|
+
"version": "0.2.6"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
13
13
|
"name": "a11y-loop",
|
|
14
14
|
"source": "./",
|
|
15
|
-
"version": "0.2.
|
|
15
|
+
"version": "0.2.6",
|
|
16
16
|
"description": "Accessibility in the plan, not after the pull request. Adds §0 plan rules (conformance target, per-component criteria, the product decisions that foreclose accessibility, color tokens before components), standing WCAG 2.2 AA generation rules, and a PreToolUse hook on ExitPlanMode that declines a UI-touching plan with no accessibility content — once, handing back the section to fill in. Verification is the a11y-loop CLI: axe-core in Chromium across default, dark, forced-colors, reduced-motion and 320px passes.",
|
|
17
17
|
"author": {
|
|
18
18
|
"name": "Chan Meng",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "a11y-loop",
|
|
4
4
|
"displayName": "a11y-loop",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.6",
|
|
6
6
|
"description": "Hard-gates plan mode: a plan that changes UI has to say what it decided about accessibility before it can be approved. Ships the a11y-loop skill and a /a11y-plan command alongside it.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Chan Meng",
|
package/README.md
CHANGED
|
@@ -49,6 +49,7 @@ check.
|
|
|
49
49
|
- [🎥 Promo Video](#-promo-video)
|
|
50
50
|
- [✨ Key Features](#-key-features)
|
|
51
51
|
- [📊 Honest Coverage](#-honest-coverage)
|
|
52
|
+
- [🔬 Field Notes](#-field-notes)
|
|
52
53
|
- [📈 Benchmark](#-benchmark)
|
|
53
54
|
- [🛠️ Tech Stack](#-tech-stack)
|
|
54
55
|
- [🏗️ Architecture](#-architecture)
|
|
@@ -80,6 +81,13 @@ a11y-loop is two things working together:
|
|
|
80
81
|
2. **A Node CLI** (`a11y-loop audit` / `contrast --fix` / `diff`) that verifies the result in a
|
|
81
82
|
real browser, feeds failures back to the agent to fix, and re-audits until the loop converges.
|
|
82
83
|
|
|
84
|
+
**About the name.** `a11y` is the standard numeronym for *accessibility*: the first letter, the last
|
|
85
|
+
letter, and the 11 letters between them replaced by their count — the same construction as `i18n`
|
|
86
|
+
(internationalization) and `k8s` (Kubernetes). Spelled out it reads as "ally", which is the pun the
|
|
87
|
+
accessibility community has always liked, but `11` is the conventional form. `loop` is the other half
|
|
88
|
+
of the method: the CLI is not a one-shot audit but a write → audit → fix → re-audit cycle that runs
|
|
89
|
+
until the report converges.
|
|
90
|
+
|
|
83
91
|
The skill follows the open [Agent Skills](https://agentskills.io/specification) standard, so the
|
|
84
92
|
same `SKILL.md` is portable across 40+ clients — Claude Code, Cursor, GitHub Copilot, Codex,
|
|
85
93
|
Gemini CLI, and more — not just one vendor's agent. The CLI runs the same checks standalone in CI
|
|
@@ -152,7 +160,9 @@ graph LR
|
|
|
152
160
|
`2` **Checks axe-core can't run** — tab order, focus visibility (including focus-ring contrast),
|
|
153
161
|
dialog focus trap / Escape / focus-return, target size (24×24 CSS px, SC 2.5.8), reduced-motion
|
|
154
162
|
effectiveness, ambiguous link text, div-as-button, and positive `tabindex`. These sit alongside
|
|
155
|
-
axe-core, not instead of it.
|
|
163
|
+
axe-core, not instead of it. The dialog and tab-order checks understand **portalled** dialogs —
|
|
164
|
+
a `<div role="dialog">` in a portal, the way Base UI, Radix, Headless UI and anything on floating-ui
|
|
165
|
+
ships one — not only a native `<dialog>` opened with `showModal()`.
|
|
156
166
|
|
|
157
167
|
`3` **State coverage, not just page-load** — `--interact` drives the states an agent just built
|
|
158
168
|
(a modal opened, a menu expanded, a form in its error state) through the same five passes, because
|
|
@@ -225,6 +235,10 @@ of the other ~87%.
|
|
|
225
235
|
- axe-core's own `incomplete` results are surfaced as `needsReview`, not suppressed. See
|
|
226
236
|
`evals/benchmark-results.md` for a live example of two such findings being investigated and
|
|
227
237
|
resolved rather than dismissed.
|
|
238
|
+
- **The engine is wrong sometimes, and the honest thing is to say where.** A handful of rows have
|
|
239
|
+
a known non-defect explanation — contrast in the forced-colors pass being the loudest. They are
|
|
240
|
+
written up in [Field Notes](#-field-notes), with what to check before dismissing one. Verifying a
|
|
241
|
+
row is not the same as suppressing it, and a11y-loop suppresses nothing.
|
|
228
242
|
- **SARIF caveat, stated honestly:** GitHub Code Scanning only displays SARIF results that carry a
|
|
229
243
|
file-path location. a11y-loop's findings are located by rendered URL + CSS selector, which
|
|
230
244
|
Code Scanning drops on ingestion — a naive upload produces an empty Code Scanning view. SARIF
|
|
@@ -235,6 +249,96 @@ of the other ~87%.
|
|
|
235
249
|
Section 508 to the 2.0/2.1 AA subset, EU EN 301 549 to 2.1 AA (moving to 2.2 AA around October
|
|
236
250
|
2026), NZ and UK to the full 2.2 AA set.
|
|
237
251
|
|
|
252
|
+
## 🔬 Field Notes
|
|
253
|
+
|
|
254
|
+
Things a report says that are not what they look like. These came out of running a11y-loop against
|
|
255
|
+
a production app across a full audit-fix cycle; each one cost an hour to work out from the report
|
|
256
|
+
alone, so it is written down here. Every number below is reproducible from a checkout at axe-core
|
|
257
|
+
4.12.1.
|
|
258
|
+
|
|
259
|
+
### `color-contrast` in the forced-colors pass is usually not a defect
|
|
260
|
+
|
|
261
|
+
axe-core reads an element's foreground from `-webkit-text-fill-color`, falling back to `color` only
|
|
262
|
+
when that is unset. Chromium's forced-colors emulation forces `color` and the background, and
|
|
263
|
+
leaves `-webkit-text-fill-color` at the author's value — on every element, including ones that
|
|
264
|
+
never declared it. So in the forced-colors pass axe compares the author's foreground against the
|
|
265
|
+
forced background.
|
|
266
|
+
|
|
267
|
+
Dark-on-light text survives that comparison. Light-on-dark text collapses:
|
|
268
|
+
|
|
269
|
+
| Element | Default pass | forced-colors pass |
|
|
270
|
+
|---|---|---|
|
|
271
|
+
| `#6d6975` text on `#ffffff` | 4.9:1, silent | silent |
|
|
272
|
+
| `#e8e6ef` text on a `#141218` card | 14:1, silent | `color-contrast`, 1.23:1 — *"foreground color: #e8e6ef, background color: #ffffff"* |
|
|
273
|
+
|
|
274
|
+
The tell is inside the finding: the reported foreground is the author's color while the background
|
|
275
|
+
is the forced `#ffffff` or `#000000`. On a page with any dark surface this arrives as a cluster
|
|
276
|
+
covering every line of text on it. On one production page it accounted for the large majority of a
|
|
277
|
+
153-finding run.
|
|
278
|
+
|
|
279
|
+
**Do not apply the suggestion.** It is computed from the same mismatched pair: for the row above it
|
|
280
|
+
proposes darkening `#e8e6ef` to `#77757d`, which then fails in the default pass. **Do not suppress
|
|
281
|
+
the rule either.** Confirm the pair in the default and dark passes, check forced colors by eye or
|
|
282
|
+
with the OS setting, and say in the hand-off that forced colors was reviewed rather than measured.
|
|
283
|
+
|
|
284
|
+
### `div-button` on `<body>` or a portal root is an outside-press handler
|
|
285
|
+
|
|
286
|
+
Popup libraries dismiss on a press outside the popup by attaching a click handler to the document
|
|
287
|
+
body or to the portal container. `div-button` looks for exactly that shape — a click handler on an
|
|
288
|
+
element with no role, no `tabindex` and no keyboard path — so while a popup is open it reports
|
|
289
|
+
`html > body` and the portal root.
|
|
290
|
+
|
|
291
|
+
The keyboard equivalent of "press outside to dismiss" is Escape, which those libraries wire
|
|
292
|
+
separately. So the question the row should send you to is `dialog-escape-does-not-close`: if
|
|
293
|
+
Escape dismisses the popup, the `div-button` row on the body is a false positive of this check.
|
|
294
|
+
It is a real failure only when that handler is the only way to activate something.
|
|
295
|
+
|
|
296
|
+
### Contrast sampled mid-animation measures a frame, not the design
|
|
297
|
+
|
|
298
|
+
An element fading in is composited at partial opacity, and axe flattens that against what is behind
|
|
299
|
+
it. Measured on a production page: `#6d6975` text at opacity 0.917 was read as `#797580`. The
|
|
300
|
+
difference is small, but it is enough to move a value that sits near 4.5:1 across the line, and
|
|
301
|
+
enough for two runs of the same page to disagree.
|
|
302
|
+
|
|
303
|
+
If a contrast finding names a color you cannot find anywhere in the source, this is usually why.
|
|
304
|
+
`openPage()` waits 120 ms after load for webfonts and short entrance animations; a longer reveal
|
|
305
|
+
outlives it. Re-run, have the `--interact` state wait for the animation to settle before it
|
|
306
|
+
returns, or read the number off the reduced-motion pass — which is the stable one, provided the
|
|
307
|
+
app honors the preference rather than ignoring it.
|
|
308
|
+
|
|
309
|
+
### `keyboard-unreachable` can mean the Tab budget ran out
|
|
310
|
+
|
|
311
|
+
The keyboard survey presses Tab up to `MAX_TAB_STEPS` (60) times, then reports everything
|
|
312
|
+
`tabbable()` expected but the walk never reached. Past 60 tab stops in one state, the tail of the
|
|
313
|
+
document is reported unreachable because the budget ended, not because focus could not get there.
|
|
314
|
+
The rule's own message names the budget.
|
|
315
|
+
|
|
316
|
+
A cluster of unreachable findings that is contiguous and sits at the end of document order is the
|
|
317
|
+
signature. Confirm by hand with the five-minute keyboard test in
|
|
318
|
+
[`references/manual-testing.md`](skill/a11y-loop/references/manual-testing.md), and audit narrower
|
|
319
|
+
states so each walk fits.
|
|
320
|
+
|
|
321
|
+
### Invoking the CLI through a Windows junction silently does nothing
|
|
322
|
+
|
|
323
|
+
`src/cli.js` runs `main()` only when `import.meta.url === pathToFileURL(process.argv[1]).href`.
|
|
324
|
+
Node resolves `import.meta.url` through a junction to the real path while `process.argv[1]` keeps
|
|
325
|
+
the literal path you typed, so the guard is false, nothing runs, and the process exits **0 with no
|
|
326
|
+
output** — which reads like a pass:
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
node D:\link-to-repo\src\cli.js --version # prints nothing, exit 0
|
|
330
|
+
node D:\github_repository\a11y-loop\src\cli.js --version # 0.2.6
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
This bites git worktrees and any checkout reached through a junction or symlink. Use the real path,
|
|
334
|
+
the installed `a11y-loop` binary, or import the entry point and call it directly — `main` is
|
|
335
|
+
exported for this:
|
|
336
|
+
|
|
337
|
+
```js
|
|
338
|
+
import { main } from './src/cli.js';
|
|
339
|
+
process.exitCode = await main(['audit', 'http://localhost:3000', '--json']);
|
|
340
|
+
```
|
|
341
|
+
|
|
238
342
|
## 📈 Benchmark
|
|
239
343
|
|
|
240
344
|
A small, illustrative comparison in [`evals/benchmark-results.md`](evals/benchmark-results.md):
|
|
@@ -263,9 +367,9 @@ illustration of the effect's shape, not a controlled study or a precise effect s
|
|
|
263
367
|
- **SARIF conversion:** [axe-sarif-converter](https://github.com/microsoft/axe-sarif-converter)
|
|
264
368
|
- **Agent integration:** the open [Agent Skills](https://agentskills.io/specification) standard —
|
|
265
369
|
`SKILL.md` + `references/`, no client-proprietary format
|
|
266
|
-
- **Tests:** the built-in `node --test` runner,
|
|
267
|
-
including
|
|
268
|
-
gradient regression test
|
|
370
|
+
- **Tests:** the built-in `node --test` runner, 378 tests across unit and integration suites,
|
|
371
|
+
including a 19-fixture matrix of seeded violations and correct-behaviour regression guards, a
|
|
372
|
+
demo end-to-end run, and a forced-colors gradient regression test
|
|
269
373
|
|
|
270
374
|
## 🏗️ Architecture
|
|
271
375
|
|
|
@@ -284,7 +388,7 @@ graph TD
|
|
|
284
388
|
C --> F["diff<br/>FIXED / NEW / REMAINING"]
|
|
285
389
|
D --> G["Playwright + Chromium"]
|
|
286
390
|
G --> H["axe-core"]
|
|
287
|
-
G --> I["a11y-loop's own checks<br/>focus, dialog trap
|
|
391
|
+
G --> I["a11y-loop's own checks<br/>focus, dialog trap (native and portalled),<br/>target size, reduced motion,<br/>link text, div-button"]
|
|
288
392
|
D -->|--interact| J["Drive built states:<br/>modal open, menu expanded, form error"]
|
|
289
393
|
H --> K["JSON report<br/>WCAG SC + ACT IDs + provenance"]
|
|
290
394
|
I --> K
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "a11y-loop",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Makes AI coding agents decide accessibility while the work is still being planned and write accessible UI by default, then proves what it can prove with a real browser audit across the states they built — and tells you exactly what it could not check.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/skill/a11y-loop/SKILL.md
CHANGED
|
@@ -28,7 +28,7 @@ compatibility: >-
|
|
|
28
28
|
The generation and honesty rules apply with or without the CLI; every step
|
|
29
29
|
that says "audit" requires it.
|
|
30
30
|
metadata:
|
|
31
|
-
"a11y-loop/version": "0.2.
|
|
31
|
+
"a11y-loop/version": "0.2.6"
|
|
32
32
|
allowed-tools: 'Bash(a11y-loop *) Bash(npx a11y-loop *) Bash(node ${CLAUDE_SKILL_DIR}/../../src/cli.js *)'
|
|
33
33
|
---
|
|
34
34
|
|
|
@@ -274,6 +274,13 @@ ignored reduced motion, SPA focus loss, unannounced updates, positive
|
|
|
274
274
|
Opened a modal? Added an error state? Added a route, a tab panel, an
|
|
275
275
|
expanded menu, an async list? Each one is a state. Audit it.
|
|
276
276
|
|
|
277
|
+
While a modal dialog is open the keyboard survey is scoped to that dialog —
|
|
278
|
+
a native `<dialog>` opened with `showModal()`, or a portalled
|
|
279
|
+
`<div role="dialog">` that has hidden the page around it, which is what
|
|
280
|
+
every popup library ships. That is deliberate: inside an open modal, the
|
|
281
|
+
page behind it is not supposed to be reachable. Give the page behind it its
|
|
282
|
+
own state if you need it audited.
|
|
283
|
+
|
|
277
284
|
```
|
|
278
285
|
A11Y audit http://localhost:5173 --interact .a11y/states.mjs --json --out .a11y/run-1.json
|
|
279
286
|
```
|
|
@@ -282,6 +289,17 @@ ignored reduced motion, SPA focus loss, unannounced updates, positive
|
|
|
282
289
|
needsReview item is a place the engine knows it could not decide — usually
|
|
283
290
|
contrast over a gradient, image, or translucent layer. Resolve it by
|
|
284
291
|
reasoning about the source, not by ignoring it.
|
|
292
|
+
|
|
293
|
+
A few rows have a known non-defect explanation: a `color-contrast` cluster
|
|
294
|
+
in the forced-colors pass, a `div-button` on `<body>` while a popup is open,
|
|
295
|
+
a contrast value sampled while something was animating, a run of
|
|
296
|
+
`keyboard-unreachable` after a long Tab walk.
|
|
297
|
+
[references/manual-testing.md](references/manual-testing.md) lists each with
|
|
298
|
+
its tell. Check a row against it before changing code — fixing a false
|
|
299
|
+
positive breaks working UI, and the suggested color on a forced-colors
|
|
300
|
+
contrast row fails in the default pass. Verifying a row is not suppressing
|
|
301
|
+
it: never disable a rule, and say in your report which rows you verified and
|
|
302
|
+
how.
|
|
285
303
|
4. **Fix in the source files**, never in the report or by suppressing a rule.
|
|
286
304
|
For contrast findings, use the report's `suggestions[]` or
|
|
287
305
|
`A11Y contrast <fg> <bg> --fix`, which returns both a lighter and a darker
|
|
@@ -112,6 +112,44 @@ content is any good.
|
|
|
112
112
|
notwithstanding"), which means it stays silent wherever it is unsure. Silence
|
|
113
113
|
is not a pass.
|
|
114
114
|
|
|
115
|
+
### Rows with a known non-defect explanation
|
|
116
|
+
|
|
117
|
+
Four of these have been seen on a production app. Check a row against them
|
|
118
|
+
before changing code for it — fixing a false positive breaks working UI — and
|
|
119
|
+
then say in the report which rows you verified and how. Verifying a row is not
|
|
120
|
+
the same as suppressing it, and nothing here is a reason to disable a rule.
|
|
121
|
+
|
|
122
|
+
- **`color-contrast` in the forced-colors pass is usually not a defect.** axe
|
|
123
|
+
reads an element's foreground from `-webkit-text-fill-color` before `color`.
|
|
124
|
+
Chromium's forced-colors emulation forces `color` and the background but
|
|
125
|
+
leaves `-webkit-text-fill-color` at the author's value, on every element,
|
|
126
|
+
so the pass compares the author's foreground against the forced background.
|
|
127
|
+
Dark-on-light text survives that; light-on-dark text reads near 1:1 and every
|
|
128
|
+
line on a dark surface is reported. The tell is a finding whose foreground is
|
|
129
|
+
a color you can find in your source and whose background is `#ffffff` or
|
|
130
|
+
`#000000`. Confirm the pair in the default and dark passes, check forced
|
|
131
|
+
colors by eye or with the OS setting, and report it as reviewed rather than
|
|
132
|
+
measured. **Never apply the suggested color** — it is derived from the same
|
|
133
|
+
mismatched pair and fails in the default pass.
|
|
134
|
+
- **A click handler on `<body>` or on a portal root is how popup libraries
|
|
135
|
+
dismiss on an outside press**, and a keyboard-path check sees a clickable
|
|
136
|
+
element with no role, no `tabindex` and no keyboard handling. The keyboard
|
|
137
|
+
equivalent of "press outside" is Escape, wired separately. Check that Escape
|
|
138
|
+
closes the popup; if it does, the row on the body is explained.
|
|
139
|
+
- **Contrast measured while something is animating is a measurement of one
|
|
140
|
+
frame.** An element fading in is composited at partial opacity and flattened
|
|
141
|
+
against what is behind it: at opacity 0.917, `#6d6975` was read as `#797580`.
|
|
142
|
+
That is enough to move a value near 4.5:1 across the line and enough for two
|
|
143
|
+
runs of the same page to disagree. If a finding names a color that is nowhere
|
|
144
|
+
in your source, this is usually why. Let entrance animations finish before
|
|
145
|
+
the state returns, or read the number from the reduced-motion pass if the
|
|
146
|
+
page honors the preference.
|
|
147
|
+
- **A Tab walk has a budget** — 60 presses. Anything the walk did not reach
|
|
148
|
+
inside it is reported unreachable because the budget ended, not because focus
|
|
149
|
+
could not get there. A contiguous cluster at the end of document order is the
|
|
150
|
+
signature; confirm it by hand with the keyboard test below, and audit
|
|
151
|
+
narrower states so each walk fits.
|
|
152
|
+
|
|
115
153
|
---
|
|
116
154
|
|
|
117
155
|
## A five-minute keyboard test anyone can run
|
|
@@ -168,6 +206,39 @@ from the specification, which is why the APG says outright that testing with
|
|
|
168
206
|
real assistive technology is essential and that its own examples target spec
|
|
169
207
|
compliance rather than AT bug workarounds.
|
|
170
208
|
|
|
209
|
+
### Automating a screen-reader pass
|
|
210
|
+
|
|
211
|
+
A screen-reader pass can be scripted — [Guidepup](https://www.guidepup.dev/)
|
|
212
|
+
drives NVDA on Windows and VoiceOver on macOS — and it is worth doing, because
|
|
213
|
+
it turns "focus moves somewhere and something is announced" into a transcript
|
|
214
|
+
you can quote and re-run. Four conditions, each of which decides whether the
|
|
215
|
+
output is evidence or decoration:
|
|
216
|
+
|
|
217
|
+
- **Drive a portable copy of the screen reader, not the installed one**, so a
|
|
218
|
+
run cannot inherit a person's own settings. Pin its language explicitly
|
|
219
|
+
(NVDA: `general.language`, e.g. `en`) — it otherwise follows the OS locale,
|
|
220
|
+
and a transcript in an unexpected language is unreadable as evidence.
|
|
221
|
+
- **Set the synthesizer to `silence`.** The spoken text is what you are
|
|
222
|
+
capturing; the audio is not, and rendering it makes runs slow and
|
|
223
|
+
machine-dependent.
|
|
224
|
+
- **A driver's relay log is focus speech, not everything spoken.** Guidepup's
|
|
225
|
+
`spokenPhraseLog()` reports what it was handed as focus moved. Live-region
|
|
226
|
+
announcements — `role="status"`, `role="alert"`, `aria-live` — do not
|
|
227
|
+
reliably appear there, so their absence proves nothing. A claim that a live
|
|
228
|
+
region spoke has to come from the screen reader's own debug log (NVDA:
|
|
229
|
+
`nvda.log` at DEBUG level), where the announcement is recorded by the screen
|
|
230
|
+
reader itself. Anything less is an assumption about the one mechanism most
|
|
231
|
+
likely to silently not fire.
|
|
232
|
+
- **Copy the log out before stopping the screen reader.** `nvda.stop()` removes
|
|
233
|
+
the temporary config directory the log lives in; read it afterwards and you
|
|
234
|
+
will find nothing.
|
|
235
|
+
|
|
236
|
+
What this buys you is a regression you can detect and a transcript you can
|
|
237
|
+
quote. What it does not buy you is a judgment about whether the interface is
|
|
238
|
+
usable — that is still answered only by people who use a screen reader every
|
|
239
|
+
day. Report an automated pass as what it is: one screen reader, one version,
|
|
240
|
+
one set of states.
|
|
241
|
+
|
|
171
242
|
---
|
|
172
243
|
|
|
173
244
|
## Involving disabled users
|
package/src/lib/browser-utils.js
CHANGED
|
@@ -133,6 +133,10 @@ export const HELPERS_SOURCE = `
|
|
|
133
133
|
// this, every check that surveys "visible" elements would flood a report
|
|
134
134
|
// with the rest of the page once a modal is open — not a real defect,
|
|
135
135
|
// just this helper not knowing the platform already handled it.
|
|
136
|
+
//
|
|
137
|
+
// A PORTALLED dialog needs no equivalent clause here: it makes the rest of
|
|
138
|
+
// the page unreachable by marking it aria-hidden, which the check above
|
|
139
|
+
// already catches. modalDialogRoot() is what reads that shape.
|
|
136
140
|
var modal = document.querySelector(':modal');
|
|
137
141
|
if (modal && !modal.contains(el)) return false;
|
|
138
142
|
return true;
|
|
@@ -190,15 +194,62 @@ export const HELPERS_SOURCE = `
|
|
|
190
194
|
return 'rgb(255, 255, 255)';
|
|
191
195
|
}
|
|
192
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Has the page been taken out of reach around this element? That is how a
|
|
199
|
+
* portalled dialog declares modality when it does not set aria-modal: the
|
|
200
|
+
* library marks the dialog's body-level SIBLINGS aria-hidden (or inert) and
|
|
201
|
+
* leaves its own portal alone. Base UI sets no aria-modal attribute at all
|
|
202
|
+
* and relies on this entirely, which is the currently recommended pattern.
|
|
203
|
+
*/
|
|
204
|
+
function outsideIsHidden(el) {
|
|
205
|
+
var top = el;
|
|
206
|
+
while (top.parentElement && top.parentElement !== document.body) top = top.parentElement;
|
|
207
|
+
return Array.prototype.some.call(document.body.children, function (sibling) {
|
|
208
|
+
if (sibling === top || sibling.contains(el)) return false;
|
|
209
|
+
if (!isRendered(sibling)) return false;
|
|
210
|
+
return sibling.getAttribute('aria-hidden') === 'true' || sibling.hasAttribute('inert');
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The open modal dialog, or null. The :modal pseudo-class is the cheap
|
|
216
|
+
* answer and the right one for a native <dialog> opened with showModal() —
|
|
217
|
+
* but it is ONLY that. Every React popup library (Base UI, Radix, Headless
|
|
218
|
+
* UI, floating-ui) ships a portalled <div role="dialog"> instead, which
|
|
219
|
+
* :modal never matches, so anything relying on it alone silently treats an
|
|
220
|
+
* open sheet as though no dialog were there at all.
|
|
221
|
+
*
|
|
222
|
+
* The portalled shape is recognised by what it actually does: a visible
|
|
223
|
+
* dialog-role element that is itself exposed, and that has either declared
|
|
224
|
+
* aria-modal="true" or hidden the rest of the page around itself. The LAST
|
|
225
|
+
* such element in document order wins, because a portal appends to <body> —
|
|
226
|
+
* so the most recently opened dialog is the topmost one.
|
|
227
|
+
*/
|
|
228
|
+
function modalDialogRoot() {
|
|
229
|
+
var native = document.querySelector(':modal');
|
|
230
|
+
if (native) return native;
|
|
231
|
+
|
|
232
|
+
var open = Array.prototype.filter.call(
|
|
233
|
+
document.querySelectorAll('[role="dialog"], [role="alertdialog"], [aria-modal="true"]'),
|
|
234
|
+
function (el) {
|
|
235
|
+
if (el.getAttribute('aria-modal') === 'false') return false;
|
|
236
|
+
if (!isVisible(el)) return false;
|
|
237
|
+
return el.getAttribute('aria-modal') === 'true' || outsideIsHidden(el);
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
return open.length ? open[open.length - 1] : null;
|
|
241
|
+
}
|
|
242
|
+
|
|
193
243
|
/**
|
|
194
244
|
* Where a forward Tab walk should be scoped: inside the open modal dialog
|
|
195
|
-
* if there is one (everything outside it is
|
|
196
|
-
* otherwise the whole document. tabbable()
|
|
197
|
-
*
|
|
198
|
-
* this as the container rather than always
|
|
245
|
+
* if there is one (everything outside it is unreachable while it is open),
|
|
246
|
+
* otherwise the whole document. tabbable() has no idea a dialog is open —
|
|
247
|
+
* it reads display and visibility, not aria-hidden, and knows nothing about
|
|
248
|
+
* :modal — so callers must pass this as the container rather than always
|
|
249
|
+
* walking document.body.
|
|
199
250
|
*/
|
|
200
251
|
function tabbableRoot() {
|
|
201
|
-
return
|
|
252
|
+
return modalDialogRoot() || document.body;
|
|
202
253
|
}
|
|
203
254
|
|
|
204
255
|
/** The CSS path of whatever was last clicked, or null if nothing was. */
|
|
@@ -212,6 +263,7 @@ export const HELPERS_SOURCE = `
|
|
|
212
263
|
accessibleName: accessibleName,
|
|
213
264
|
isRendered: isRendered,
|
|
214
265
|
isVisible: isVisible,
|
|
266
|
+
modalDialogRoot: modalDialogRoot,
|
|
215
267
|
tabbableRoot: tabbableRoot,
|
|
216
268
|
lastClickSelector: lastClickSelector,
|
|
217
269
|
interactiveElements: interactiveElements,
|
package/src/lib/checks/dialog.js
CHANGED
|
@@ -16,6 +16,28 @@ import { makeFinding, SEVERITY } from '../finding.js';
|
|
|
16
16
|
/** Tab presses used to probe a focus trap. */
|
|
17
17
|
export const TRAP_PROBE_STEPS = 12;
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* How many consecutive presses may land outside the dialog on something no user
|
|
21
|
+
* can act on before it stops counting as a wrap and starts counting as an escape.
|
|
22
|
+
*
|
|
23
|
+
* Not a fudge factor — it is the length of a real wrap, measured. A portalled
|
|
24
|
+
* dialog wraps through a CHAIN: floating-ui (Base UI, Radix) renders a focus
|
|
25
|
+
* guard beside the floating element and another pair at the edges of <body>, and
|
|
26
|
+
* hands focus back an animation frame later, so a forward Tab off the end of the
|
|
27
|
+
* sheet goes guard → body → back inside. Observed on a live Base UI sheet, three
|
|
28
|
+
* runs: two of them spent two presses out (guard, then body) and one spent a
|
|
29
|
+
* single press. Allowing exactly one press, as this check used to, therefore
|
|
30
|
+
* reported a perfectly trapped dialog as broken about two runs in three — which
|
|
31
|
+
* is what made the row look flaky rather than wrong.
|
|
32
|
+
*
|
|
33
|
+
* It stays small on purpose, and it only applies AFTER focus has been inside the
|
|
34
|
+
* dialog at least once: presses spent outside before that are the probe finding
|
|
35
|
+
* its way in from wherever the earlier surveys left focus, not a wrap. Landing on
|
|
36
|
+
* a genuine, exposed element outside the dialog is an escape on the first press
|
|
37
|
+
* either way, whatever this bound says.
|
|
38
|
+
*/
|
|
39
|
+
export const MAX_WRAP_TICKS = 3;
|
|
40
|
+
|
|
19
41
|
/**
|
|
20
42
|
* @param {object} observation
|
|
21
43
|
* @param {string} observation.selector
|
|
@@ -196,6 +218,11 @@ export async function captureDialogInitialState(page, opts = {}) {
|
|
|
196
218
|
selector: helpers.cssPath(dialog),
|
|
197
219
|
html: helpers.shortHtml(dialog),
|
|
198
220
|
trigger,
|
|
221
|
+
// Was this dialog MODAL at the moment it opened? Recorded now because
|
|
222
|
+
// it is the only way to tell, later, between a dialog that never
|
|
223
|
+
// trapped anything and one whose modal treatment was torn down while
|
|
224
|
+
// the probe was running. See surveyDialog.
|
|
225
|
+
modal: helpers.modalDialogRoot() === dialog,
|
|
199
226
|
focusMovedIntoDialog: Boolean(active && dialog.contains(active)),
|
|
200
227
|
initialFocus: active ? helpers.cssPath(active) : null,
|
|
201
228
|
};
|
|
@@ -204,6 +231,26 @@ export async function captureDialogInitialState(page, opts = {}) {
|
|
|
204
231
|
);
|
|
205
232
|
}
|
|
206
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Let the page finish reacting to a key before reading where focus went.
|
|
236
|
+
*
|
|
237
|
+
* A focus trap built on requestAnimationFrame — floating-ui's enqueueFocus, and
|
|
238
|
+
* so Base UI's and Radix's — hands focus back one frame after a guard receives
|
|
239
|
+
* it, and cancels a pending hand-back when another arrives. Synthetic Tab
|
|
240
|
+
* presses can be dispatched faster than a frame, which cancels the redirect over
|
|
241
|
+
* and over and leaves focus parked outside the dialog: the probe outruns the
|
|
242
|
+
* page and then reports the page for not keeping up. No keyboard user can press
|
|
243
|
+
* Tab twice inside one frame, so waiting two frames does not weaken the check —
|
|
244
|
+
* it stops it measuring itself.
|
|
245
|
+
*
|
|
246
|
+
* @param {import('playwright').Page} page
|
|
247
|
+
*/
|
|
248
|
+
async function settleFrames(page) {
|
|
249
|
+
await page.evaluate(
|
|
250
|
+
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
207
254
|
/**
|
|
208
255
|
* Probe focus trap, Escape, and focus return, given a dialog snapshot already
|
|
209
256
|
* captured by `captureDialogInitialState`. This drives the keyboard and
|
|
@@ -217,22 +264,66 @@ export async function captureDialogInitialState(page, opts = {}) {
|
|
|
217
264
|
export async function surveyDialog(page, found) {
|
|
218
265
|
if (!found) return null;
|
|
219
266
|
|
|
267
|
+
// The probe asks exactly one question: with focus INSIDE this dialog, can Tab
|
|
268
|
+
// take it out? So it has to start inside. By the time it runs, three other
|
|
269
|
+
// surveys have each walked the page with real Tab presses and left focus
|
|
270
|
+
// wherever they happened to finish — and on a portalled dialog, focus sitting
|
|
271
|
+
// outside is itself enough for the library's focus-out handling to begin
|
|
272
|
+
// dismantling the modal treatment it put up. The probe would then walk a page
|
|
273
|
+
// that is no longer behind a modal and report a working trap as broken.
|
|
274
|
+
//
|
|
275
|
+
// Measured on a live Base UI sheet, ten runs: without this, four began outside
|
|
276
|
+
// the dialog and every one of those four reported the trap broken. With it,
|
|
277
|
+
// ten of ten began inside and none did.
|
|
278
|
+
const enteredDialog = await page.evaluate(
|
|
279
|
+
({ selector }) => {
|
|
280
|
+
const helpers = window.__a11yLoop;
|
|
281
|
+
const dialog = Array.from(document.querySelectorAll(selector)).find((el) =>
|
|
282
|
+
helpers.isVisible(el),
|
|
283
|
+
);
|
|
284
|
+
if (!dialog) return false;
|
|
285
|
+
if (dialog.contains(document.activeElement)) return true;
|
|
286
|
+
const stops = window.tabbable ? window.tabbable.tabbable(dialog) : [];
|
|
287
|
+
if (!stops.length) return false;
|
|
288
|
+
stops[0].focus();
|
|
289
|
+
return dialog.contains(document.activeElement);
|
|
290
|
+
},
|
|
291
|
+
{ selector: DIALOG_SELECTOR },
|
|
292
|
+
);
|
|
293
|
+
|
|
220
294
|
// Focus trap: Tab repeatedly and watch for focus leaving the dialog.
|
|
221
295
|
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
296
|
+
// Every modal dialog spends one tick outside itself when it wraps, and the
|
|
297
|
+
// shape of that tick depends on who implements the trap:
|
|
298
|
+
//
|
|
299
|
+
// - Real Chromium, for a genuinely native <dialog> opened with showModal(),
|
|
300
|
+
// transiently moves document.activeElement to document.body for exactly
|
|
301
|
+
// one Tab press when wrapping past the dialog's last (or before its first)
|
|
302
|
+
// focusable descendant, then redirects back inside on the very next press.
|
|
303
|
+
// Body itself stays inert throughout, so nothing is reachable there.
|
|
304
|
+
// - A portalled <div role="dialog"> — Base UI, Radix, Headless UI, anything
|
|
305
|
+
// on floating-ui — wraps through a focus guard instead: a focusable span
|
|
306
|
+
// outside the dialog, hidden from assistive technology, whose whole job is
|
|
307
|
+
// to catch the wrap and hand focus back. Nothing is reachable there either.
|
|
308
|
+
//
|
|
309
|
+
// Treating either as an escape flags a correctly trapped dialog as broken,
|
|
310
|
+
// and that is precisely what happened to portalled dialogs while this gate
|
|
311
|
+
// asked `:modal` — which matches only the native case. It is a real failure
|
|
312
|
+
// if focus lands outside the dialog on a genuine, exposed element, or if it
|
|
313
|
+
// does not come back within MAX_WRAP_TICKS presses: a portalled dialog wraps
|
|
314
|
+
// through a chain of those ticks, not a single one.
|
|
315
|
+
|
|
316
|
+
// A dialog focus cannot be placed inside is a dialog whose trap this check
|
|
317
|
+
// cannot judge, so it says so with null rather than guessing — dialogFindings
|
|
318
|
+
// reports only on an explicit false.
|
|
319
|
+
let focusTrapped = enteredDialog ? true : null;
|
|
232
320
|
let escapedTo = null;
|
|
233
|
-
let
|
|
234
|
-
|
|
321
|
+
let insideAtLeastOnce = false;
|
|
322
|
+
let consecutiveWrapTicks = 0;
|
|
323
|
+
let tornDownMidProbe = false;
|
|
324
|
+
for (let i = 0; enteredDialog && i < TRAP_PROBE_STEPS; i++) {
|
|
235
325
|
await page.keyboard.press('Tab');
|
|
326
|
+
await settleFrames(page);
|
|
236
327
|
const check = await page.evaluate(
|
|
237
328
|
({ selector }) => {
|
|
238
329
|
const helpers = window.__a11yLoop;
|
|
@@ -240,13 +331,31 @@ export async function surveyDialog(page, found) {
|
|
|
240
331
|
helpers.isVisible(el),
|
|
241
332
|
);
|
|
242
333
|
const active = document.activeElement;
|
|
243
|
-
if (!dialog || !active)
|
|
244
|
-
|
|
334
|
+
if (!dialog || !active) {
|
|
335
|
+
return { inside: false, transient: false, label: null, stillModalRoot: false };
|
|
336
|
+
}
|
|
337
|
+
if (dialog.contains(active)) {
|
|
338
|
+
return { inside: true, transient: false, label: null, stillModalRoot: true };
|
|
339
|
+
}
|
|
245
340
|
const isBodyOrRoot = active === document.body || active === document.documentElement;
|
|
246
|
-
|
|
341
|
+
// A focus guard is an EMPTY element that is not exposed to assistive
|
|
342
|
+
// technology — every library builds one the same way, as a bare
|
|
343
|
+
// aria-hidden span with nothing in it. Emptiness is what separates it
|
|
344
|
+
// from the page behind the dialog, which is also aria-hidden but is
|
|
345
|
+
// full of real content: focus landing THERE is a trap that failed, and
|
|
346
|
+
// must still be reported.
|
|
347
|
+
const isFocusGuard =
|
|
348
|
+
!isBodyOrRoot &&
|
|
349
|
+
!helpers.isVisible(active) &&
|
|
350
|
+
active.children.length === 0 &&
|
|
351
|
+
!(active.textContent || '').trim();
|
|
352
|
+
const stillModal =
|
|
353
|
+
(typeof dialog.matches === 'function' && dialog.matches(':modal')) ||
|
|
354
|
+
helpers.modalDialogRoot() === dialog;
|
|
247
355
|
return {
|
|
248
356
|
inside: false,
|
|
249
|
-
transient: isBodyOrRoot && stillModal,
|
|
357
|
+
transient: (isBodyOrRoot || isFocusGuard) && stillModal,
|
|
358
|
+
stillModalRoot: helpers.modalDialogRoot() === dialog,
|
|
250
359
|
label: isBodyOrRoot ? 'the browser UI / document root' : helpers.cssPath(active),
|
|
251
360
|
};
|
|
252
361
|
},
|
|
@@ -254,18 +363,40 @@ export async function surveyDialog(page, found) {
|
|
|
254
363
|
);
|
|
255
364
|
|
|
256
365
|
if (check.inside) {
|
|
257
|
-
|
|
366
|
+
insideAtLeastOnce = true;
|
|
367
|
+
consecutiveWrapTicks = 0;
|
|
258
368
|
continue;
|
|
259
369
|
}
|
|
260
|
-
if (check.transient && !
|
|
261
|
-
|
|
370
|
+
if (check.transient && (!insideAtLeastOnce || consecutiveWrapTicks < MAX_WRAP_TICKS)) {
|
|
371
|
+
if (insideAtLeastOnce) consecutiveWrapTicks += 1;
|
|
262
372
|
continue;
|
|
263
373
|
}
|
|
374
|
+
// The dialog was modal when it opened and is not any more: the page took
|
|
375
|
+
// the modal treatment down — the backdrop, the aria-hidden on everything
|
|
376
|
+
// behind it — while this probe was running, usually because the sheet is
|
|
377
|
+
// closing. Focus is free to walk the page because the page let it, not
|
|
378
|
+
// because a trap failed. There is nothing to judge here, so judge nothing:
|
|
379
|
+
// reporting a trap failure off a dialog that stopped being modal mid-probe
|
|
380
|
+
// is how a correctly trapped sheet ends up accused about one run in seven.
|
|
381
|
+
if (found.modal && !check.stillModalRoot) {
|
|
382
|
+
tornDownMidProbe = true;
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
264
385
|
focusTrapped = false;
|
|
265
386
|
escapedTo = check.label;
|
|
266
387
|
break;
|
|
267
388
|
}
|
|
268
389
|
|
|
390
|
+
if (tornDownMidProbe) {
|
|
391
|
+
return {
|
|
392
|
+
...found,
|
|
393
|
+
focusTrapped: null,
|
|
394
|
+
escapedTo: null,
|
|
395
|
+
escapeClosed: null,
|
|
396
|
+
focusReturnedToTrigger: null,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
269
400
|
await page.keyboard.press('Escape');
|
|
270
401
|
await page.waitForTimeout(150);
|
|
271
402
|
|
|
@@ -155,10 +155,12 @@ export function keyboardFindings(survey, ctx) {
|
|
|
155
155
|
* matters whenever a state setup function has clicked something, which is most of
|
|
156
156
|
* the time. A focused sentinel at the start of the body fixes the origin.
|
|
157
157
|
*
|
|
158
|
-
* "Top of the document" is not always `document.body`: if a
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
158
|
+
* "Top of the document" is not always `document.body`: if a modal dialog is
|
|
159
|
+
* open, everything outside it is out of reach, so a sentinel inserted into
|
|
160
|
+
* `body` is either unfocusable (a native `<dialog>`) or sits in the very region
|
|
161
|
+
* the dialog's focus trap exists to keep focus out of (a portalled one — as far
|
|
162
|
+
* from the sheet as the document allows). The sentinel goes into whichever
|
|
163
|
+
* element `tabbableRoot()` says is actually reachable right now.
|
|
162
164
|
*
|
|
163
165
|
* @param {import('playwright').Page} page
|
|
164
166
|
*/
|