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.
@@ -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.4"
9
+ "version": "0.2.6"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "a11y-loop",
14
14
  "source": "./",
15
- "version": "0.2.4",
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.4",
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, 337+ tests across unit and integration suites,
267
- including an 18-fixture seeded-violation matrix, a demo end-to-end run, and a forced-colors
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, target size,<br/>reduced motion, link text, div-button"]
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.4",
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": {
@@ -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.4"
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
@@ -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 inert and cannot be reached),
196
- * otherwise the whole document. tabbable() itself has no idea a native
197
- * dialog element made the rest of the page inert, so callers must pass
198
- * this as the container rather than always walking document.body.
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 document.querySelector(':modal') || document.body;
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,
@@ -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
- // Real Chromium, for a genuinely native <dialog> opened with showModal(),
223
- // transiently moves document.activeElement to document.body for exactly
224
- // one Tab press when wrapping past the dialog's last (or before its first)
225
- // focusable descendant, then redirects back inside on the very next press —
226
- // body itself stays inert throughout, so nothing is actually reachable
227
- // there. Treating that single step as an escape would flag a correctly
228
- // trapped native dialog as broken. It is only a real failure if landing
229
- // outside the dialog is on a genuine element, or if it does not recover on
230
- // the immediately following press.
231
- let focusTrapped = true;
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 sawUnconfirmedTransient = false;
234
- for (let i = 0; i < TRAP_PROBE_STEPS; i++) {
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) return { inside: false, transient: false, label: null };
244
- if (dialog.contains(active)) return { inside: true, transient: false, label: null };
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
- const stillModal = typeof dialog.matches === 'function' && dialog.matches(':modal');
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
- sawUnconfirmedTransient = false;
366
+ insideAtLeastOnce = true;
367
+ consecutiveWrapTicks = 0;
258
368
  continue;
259
369
  }
260
- if (check.transient && !sawUnconfirmedTransient) {
261
- sawUnconfirmedTransient = true;
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 native `<dialog>`
159
- * is open, everything outside it is inert, so a sentinel inserted into `body`
160
- * could never be focused at all. The sentinel goes into whichever element
161
- * `tabbableRoot()` says is actually reachable right now.
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
  */