a11y-loop 0.2.5 → 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.5"
9
+ "version": "0.2.6"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "a11y-loop",
14
14
  "source": "./",
15
- "version": "0.2.5",
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",
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)
@@ -234,6 +235,10 @@ of the other ~87%.
234
235
  - axe-core's own `incomplete` results are surfaced as `needsReview`, not suppressed. See
235
236
  `evals/benchmark-results.md` for a live example of two such findings being investigated and
236
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.
237
242
  - **SARIF caveat, stated honestly:** GitHub Code Scanning only displays SARIF results that carry a
238
243
  file-path location. a11y-loop's findings are located by rendered URL + CSS selector, which
239
244
  Code Scanning drops on ingestion — a naive upload produces an empty Code Scanning view. SARIF
@@ -244,6 +249,96 @@ of the other ~87%.
244
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
245
250
  2026), NZ and UK to the full 2.2 AA set.
246
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
+
247
342
  ## 📈 Benchmark
248
343
 
249
344
  A small, illustrative comparison in [`evals/benchmark-results.md`](evals/benchmark-results.md):
@@ -293,7 +388,7 @@ graph TD
293
388
  C --> F["diff<br/>FIXED / NEW / REMAINING"]
294
389
  D --> G["Playwright + Chromium"]
295
390
  G --> H["axe-core"]
296
- 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"]
297
392
  D -->|--interact| J["Drive built states:<br/>modal open, menu expanded, form error"]
298
393
  H --> K["JSON report<br/>WCAG SC + ACT IDs + provenance"]
299
394
  I --> K
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a11y-loop",
3
- "version": "0.2.5",
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.5"
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