a11y-loop 0.2.4 → 0.2.5

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.5"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "a11y-loop",
14
14
  "source": "./",
15
- "version": "0.2.4",
15
+ "version": "0.2.5",
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.5",
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
@@ -80,6 +80,13 @@ a11y-loop is two things working together:
80
80
  2. **A Node CLI** (`a11y-loop audit` / `contrast --fix` / `diff`) that verifies the result in a
81
81
  real browser, feeds failures back to the agent to fix, and re-audits until the loop converges.
82
82
 
83
+ **About the name.** `a11y` is the standard numeronym for *accessibility*: the first letter, the last
84
+ letter, and the 11 letters between them replaced by their count — the same construction as `i18n`
85
+ (internationalization) and `k8s` (Kubernetes). Spelled out it reads as "ally", which is the pun the
86
+ accessibility community has always liked, but `11` is the conventional form. `loop` is the other half
87
+ of the method: the CLI is not a one-shot audit but a write → audit → fix → re-audit cycle that runs
88
+ until the report converges.
89
+
83
90
  The skill follows the open [Agent Skills](https://agentskills.io/specification) standard, so the
84
91
  same `SKILL.md` is portable across 40+ clients — Claude Code, Cursor, GitHub Copilot, Codex,
85
92
  Gemini CLI, and more — not just one vendor's agent. The CLI runs the same checks standalone in CI
@@ -152,7 +159,9 @@ graph LR
152
159
  `2` **Checks axe-core can't run** — tab order, focus visibility (including focus-ring contrast),
153
160
  dialog focus trap / Escape / focus-return, target size (24×24 CSS px, SC 2.5.8), reduced-motion
154
161
  effectiveness, ambiguous link text, div-as-button, and positive `tabindex`. These sit alongside
155
- axe-core, not instead of it.
162
+ axe-core, not instead of it. The dialog and tab-order checks understand **portalled** dialogs —
163
+ a `<div role="dialog">` in a portal, the way Base UI, Radix, Headless UI and anything on floating-ui
164
+ ships one — not only a native `<dialog>` opened with `showModal()`.
156
165
 
157
166
  `3` **State coverage, not just page-load** — `--interact` drives the states an agent just built
158
167
  (a modal opened, a menu expanded, a form in its error state) through the same five passes, because
@@ -263,9 +272,9 @@ illustration of the effect's shape, not a controlled study or a precise effect s
263
272
  - **SARIF conversion:** [axe-sarif-converter](https://github.com/microsoft/axe-sarif-converter)
264
273
  - **Agent integration:** the open [Agent Skills](https://agentskills.io/specification) standard —
265
274
  `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
275
+ - **Tests:** the built-in `node --test` runner, 378 tests across unit and integration suites,
276
+ including a 19-fixture matrix of seeded violations and correct-behaviour regression guards, a
277
+ demo end-to-end run, and a forced-colors gradient regression test
269
278
 
270
279
  ## 🏗️ Architecture
271
280
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a11y-loop",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
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.5"
32
32
  allowed-tools: 'Bash(a11y-loop *) Bash(npx a11y-loop *) Bash(node ${CLAUDE_SKILL_DIR}/../../src/cli.js *)'
33
33
  ---
34
34
 
@@ -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
  */