a11y-loop 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +409 -0
  3. package/THIRD-PARTY-NOTICES.md +32 -0
  4. package/package.json +51 -0
  5. package/skill/a11y-loop/SKILL.md +332 -0
  6. package/skill/a11y-loop/evals/evals.json +168 -0
  7. package/skill/a11y-loop/evals/trigger-evals.json +20 -0
  8. package/skill/a11y-loop/references/ai-failure-modes.md +272 -0
  9. package/skill/a11y-loop/references/apg-patterns.md +264 -0
  10. package/skill/a11y-loop/references/manual-testing.md +224 -0
  11. package/skill/a11y-loop/references/wcag22-quick-ref.md +224 -0
  12. package/src/cli.js +207 -0
  13. package/src/commands/audit.js +125 -0
  14. package/src/commands/contrast.js +141 -0
  15. package/src/commands/diff.js +65 -0
  16. package/src/lib/axe-runner.js +400 -0
  17. package/src/lib/browser-utils.js +221 -0
  18. package/src/lib/checks/dialog.js +341 -0
  19. package/src/lib/checks/div-button.js +87 -0
  20. package/src/lib/checks/focus-visible.js +296 -0
  21. package/src/lib/checks/keyboard.js +235 -0
  22. package/src/lib/checks/link-text.js +83 -0
  23. package/src/lib/checks/reduced-motion.js +139 -0
  24. package/src/lib/checks/reflow.js +101 -0
  25. package/src/lib/checks/target-size.js +128 -0
  26. package/src/lib/contrast-math.js +189 -0
  27. package/src/lib/diff.js +118 -0
  28. package/src/lib/finding.js +164 -0
  29. package/src/lib/fingerprint.js +0 -0
  30. package/src/lib/format/checklist.js +281 -0
  31. package/src/lib/format/human.js +175 -0
  32. package/src/lib/format/json.js +139 -0
  33. package/src/lib/format/sarif.js +111 -0
  34. package/src/lib/serve.js +189 -0
  35. package/src/lib/suggest-color.js +169 -0
  36. package/src/lib/wcag-map.js +271 -0
@@ -0,0 +1,224 @@
1
+ # What automation cannot check, and how to hand off
2
+
3
+ A clean audit means "no automatically detectable failures in the states that
4
+ were driven". It is the starting point for human review, not a substitute for
5
+ it. This file is the material for that hand-off.
6
+
7
+ ---
8
+
9
+ ## Coverage, with denominators
10
+
11
+ The honest figures disagree because they count different things. Always quote a
12
+ figure **with its denominator**; a bare percentage is how coverage gets
13
+ overstated.
14
+
15
+ | Figure | What it actually measures | Source |
16
+ |---|---|---|
17
+ | **57%** | Volume of individual issue *instances* that automated testing found, across 2,000+ audits / 13,000+ pages / ~300,000 issues | Deque study, quoted in axe-core's README |
18
+ | **31%** (17 of 55) | WCAG 2.2 A/AA success criteria that have *any* ACT-approved automated rule — and, in his words, "it's not full coverage of that 31%" | Adrian Roselli, March 2026 |
19
+ | **13%** (7 of 55) | WCAG 2.2 A/AA criteria that automation flags *reliably*; 45% partially detectable, 42% not detectable | accessible.org |
20
+
21
+ The seven criteria accessible.org counts as reliably automatable: 1.3.5 Identify
22
+ Input Purpose, 1.4.3 Contrast (Minimum), 1.4.11 Non-text Contrast, 2.4.1 Bypass
23
+ Blocks, 2.4.2 Page Titled, 2.5.8 Target Size (Minimum), 3.1.1 Language of Page.
24
+
25
+ Karl Groves' breakdown of the rest: **9 Level A/AA criteria cannot be
26
+ meaningfully tested by any tool**, and **13 more** can be tested automatically
27
+ but need a human to confirm the result.
28
+
29
+ Engine scope, for the provenance section of a report: axe-core ships 105 rules,
30
+ of which **89 run by default** (60 WCAG 2.0 A/AA + 2 WCAG 2.1 A/AA + 27
31
+ best-practice). WCAG 2.2 coverage is a single rule, `target-size`, and it is
32
+ disabled by default upstream. **58 of the 105 rules can return an
33
+ `incomplete` result** — a place the engine knows it could not decide.
34
+ `a11y-loop` surfaces those as `needsReview` rather than folding them into
35
+ passes.
36
+
37
+ ---
38
+
39
+ ## Requires human judgment — the full list
40
+
41
+ Automation can tell you an attribute exists. It cannot tell you whether the
42
+ content is any good.
43
+
44
+ **Text alternatives and names**
45
+ - Whether `alt` text conveys what the image conveys. `alt="decorative image"`,
46
+ `alt="image"`, and `alt="hero-2.jpg"` all pass every automated check and are
47
+ worse than `alt=""`.
48
+ - Whether an accessible name is *useful*, not merely present.
49
+ - Whether a decorative image was correctly judged decorative.
50
+ - Whether link text makes sense out of context, as a screen reader user hears it
51
+ in a list of links ("read more", "here", "learn more").
52
+ - Whether the visible label and the accessible name say the same thing in a way
53
+ a voice-control user would guess (SC 2.5.3 checks containment, not sense).
54
+
55
+ **Structure and order**
56
+ - Whether the heading outline reflects the actual information hierarchy.
57
+ - Whether tab order is *logical* — a tool can only report that it diverges from
58
+ DOM order, not which order is right.
59
+ - Whether landmarks segment the page usefully.
60
+ - Whether reflow at 320px preserves meaning, or merely avoids a scrollbar.
61
+ - Whether a table's header associations describe the real data relationships.
62
+
63
+ **Forms and errors**
64
+ - Whether an error message tells the user how to fix the problem (SC 3.3.3).
65
+ - Whether required formats are explained before submission, not after.
66
+ - Whether a multi-step process re-asks for information already given (SC 3.3.7).
67
+ - Whether an authentication alternative is genuinely usable (SC 3.3.8).
68
+
69
+ **Visual and motion judgment**
70
+ - Whether a focus indicator is *visually* adequate: occluded by a sticky header
71
+ (SC 2.4.11), lost against a busy background image, or 1px on a dense table.
72
+ - Whether reduced motion still communicates state change.
73
+ - Whether contrast holds over gradients, images, video, and translucent
74
+ overlays — exactly the cases the engine returns as `incomplete`.
75
+
76
+ **Media**
77
+ - Caption accuracy, speaker identification, and timing.
78
+ - Audio description completeness.
79
+ - Transcript quality.
80
+
81
+ **Consistency and cognition**
82
+ - Consistent navigation and identification across pages (SC 3.2.3, 3.2.4) —
83
+ needs a multi-page view no single-page scan has.
84
+ - Consistent placement of help (SC 3.2.6).
85
+ - Reading level, plain language, and cognitive load (COGA). Barely covered by
86
+ any automated tool, and named as an open gap in the 2026 literature review.
87
+ - Whether ARIA is used *appropriately* rather than merely *validly*. A page can
88
+ be free of ARIA syntax errors and still misrepresent itself completely.
89
+
90
+ **Real assistive technology behavior**
91
+ - NVDA, JAWS, VoiceOver, and TalkBack diverge from each other and from the
92
+ specification. Nothing predicts their actual output except running them.
93
+
94
+ ---
95
+
96
+ ## Known engine limitations to state in a report
97
+
98
+ - `color-contrast` gives up — and returns `incomplete` — on background images,
99
+ gradients, pseudo-element backgrounds, and foreground opacity or occlusion. A
100
+ computed ratio of exactly 1:1 is usually the tell that it could not resolve
101
+ the background.
102
+ - Hover and focus state contrast is never checked unless the state is driven and
103
+ the page re-scanned.
104
+ - `target-size` has a documented false-positive history with overlapping and
105
+ translucent targets (axe-core issues #4805, #4350, #4295), which is why it is
106
+ reported as needs-review.
107
+ - Cross-origin iframes may go untested — look for the `frame-tested` result.
108
+ - Anything requiring interaction, timing, or state across multiple pages is out
109
+ of scope for a load-time scan. This is what `--interact` states exist for, and
110
+ it only covers the states someone thought to write.
111
+ - axe-core returns zero false positives by design intent ("bugs
112
+ notwithstanding"), which means it stays silent wherever it is unsure. Silence
113
+ is not a pass.
114
+
115
+ ---
116
+
117
+ ## A five-minute keyboard test anyone can run
118
+
119
+ No tools, no training. Hand this to whoever owns the feature.
120
+
121
+ 1. **Put the mouse away.** Click once in the page background, then use only the
122
+ keyboard.
123
+ 2. **Press `Tab` repeatedly through the whole page.** At every stop, ask: can I
124
+ *see* where I am? If the focus indicator vanishes anywhere, that is SC 2.4.7.
125
+ 3. **Watch the order.** Does focus move in the order things are laid out? Does
126
+ it ever jump backwards, or into something invisible?
127
+ 4. **Check for hidden stops.** If focus disappears for a press or two, it is
128
+ probably landing on an `aria-hidden` or off-screen element.
129
+ 5. **Check the sticky header.** Tab down the page — does the focused element ever
130
+ slide under a sticky header, footer, or cookie bar? That is SC 2.4.11.
131
+ 6. **Operate every control.** `Enter` on links and buttons, `Space` on buttons
132
+ and checkboxes, arrow keys in tabs, menus, radio groups, and selects. Anything
133
+ that only responds to a click is SC 2.1.1.
134
+ 7. **Open a dialog.** Does focus move into it? Does `Tab` stay inside it? Does
135
+ `Escape` close it? Does focus come back to the button that opened it? All four
136
+ must be yes.
137
+ 8. **Submit a form with mistakes.** Is the error announced or at least reachable?
138
+ Does it tell you how to fix it? Does focus go somewhere useful?
139
+ 9. **Zoom the browser to 400%** (`Ctrl`/`Cmd` and `+`). Any horizontal
140
+ scrolling, overlapping, or clipped text is SC 1.4.10 / 1.4.4.
141
+ 10. **Turn on OS dark mode and high contrast.** Does anything disappear?
142
+
143
+ Any "no" is a real defect regardless of what an automated report said.
144
+
145
+ ---
146
+
147
+ ## Assistive technology: where to start
148
+
149
+ - **NVDA** — free, open source, Windows. The most practical starting point, and
150
+ roughly the most-used screen reader worldwide.
151
+ [nvaccess.org](https://www.nvaccess.org/) · learn `Insert+Down` (read all),
152
+ `H` (next heading), `Tab`, `F` (next form field), `D` (next landmark),
153
+ `Insert+F7` (elements list — the fastest way to see how your page is really
154
+ structured).
155
+ - **VoiceOver** — built into macOS and iOS, nothing to install. `Cmd+F5` to
156
+ toggle; `Ctrl+Option+U` opens the rotor. Test iOS separately: mobile
157
+ VoiceOver plus touch gestures behaves differently from macOS.
158
+ - **JAWS** — Windows, commercial, still dominant in enterprise and government.
159
+ Worth testing if your users are in those settings; behavior differs from NVDA
160
+ often enough to matter.
161
+ - **TalkBack** — Android, built in. The mobile counterpart to the above.
162
+ - **Windows High Contrast / forced colors** and **OS text scaling** — fast, no
163
+ learning curve, and they surface hard-coded colors and fixed-height text boxes
164
+ immediately.
165
+
166
+ Do not run one screen reader and generalise. They diverge from each other and
167
+ from the specification, which is why the APG says outright that testing with
168
+ real assistive technology is essential and that its own examples target spec
169
+ compliance rather than AT bug workarounds.
170
+
171
+ ---
172
+
173
+ ## Involving disabled users
174
+
175
+ The 2026 systematic literature review of LLMs for web accessibility found that
176
+ few studies involve users with disabilities at all. Automated checks and expert
177
+ review both answer "does this conform?"; only disabled users answer "is this
178
+ usable?", and those are different questions with different answers.
179
+
180
+ Practical, in rough order of cost:
181
+
182
+ - Ask your organisation whether any employees use assistive technology daily and
183
+ would review a build.
184
+ - Contact a local disability organisation or a Disabled People's Organisation —
185
+ many run paid user-testing panels.
186
+ - Pay for a session with a screen reader user before a launch, not after. One
187
+ hour typically finds problems no scanner and no checklist surfaced.
188
+ - Provide a real accessibility feedback route in the product and route it to
189
+ someone who can act.
190
+ - Record who tested, with which assistive technology, on which version. An
191
+ honest, specific statement of who tested with what is more credible than any
192
+ score — and almost nobody publishes one.
193
+
194
+ ---
195
+
196
+ ## Wording for the hand-off
197
+
198
+ Say this:
199
+
200
+ > `a11y-loop audit` found no automatically detectable failures across the five
201
+ > rendering passes (default, dark mode, forced colors, reduced motion, 320px
202
+ > reflow) in the states driven: *default, settings-dialog-open,
203
+ > signup-form-error*. Automated testing covers a minority of WCAG failures — on
204
+ > Deque's measure, 57% of issue instances by volume; by criterion count, 17 of
205
+ > the 55 WCAG 2.2 A/AA criteria have any automated rule at all. The following
206
+ > still need human review: *[the manualChecklist from the report]*. Two
207
+ > `needsReview` findings remain, both contrast over the hero gradient. Alt text
208
+ > for three images is DRAFT and needs your confirmation. Please run a keyboard
209
+ > pass and a screen reader pass before this ships.
210
+
211
+ Not this:
212
+
213
+ > The page is now fully accessible and WCAG 2.2 AA compliant.
214
+
215
+ That second sentence is the claim shape that drew a $1,000,000 FTC penalty
216
+ against an overlay vendor in 2025, and the 800+ signatories of the
217
+ [Overlay Fact Sheet](https://overlayfactsheet.com/) exist to refute it. It is
218
+ also just untrue: what was checked is what was checked.
219
+
220
+ ## Related
221
+
222
+ - [wcag22-quick-ref.md](wcag22-quick-ref.md) — the 55 criteria and contrast thresholds
223
+ - [apg-patterns.md](apg-patterns.md) — keyboard contracts to verify by hand
224
+ - [ai-failure-modes.md](ai-failure-modes.md) — what to look for in generated code
@@ -0,0 +1,224 @@
1
+ # WCAG 2.2 Level AA — developer quick reference
2
+
3
+ Normative text: [WCAG 2.2](https://www.w3.org/TR/WCAG22/) (W3C Recommendation
4
+ 5 Oct 2023, updated 12 Dec 2024; ratified as ISO/IEC 40500:2025).
5
+ Filterable per-criterion index with techniques:
6
+ [How to Meet WCAG](https://www.w3.org/WAI/WCAG22/quickref/).
7
+
8
+ **Counting.** WCAG 2.2 has **86 success criteria** (31 A / 24 AA / 31 AAA), of
9
+ which the **55 A + AA criteria** below must all be met for Level AA
10
+ conformance. Sources claiming 87 have added the 9 new criteria without
11
+ subtracting the one that was removed — **do not repeat the 87 figure**.
12
+
13
+ **4.1.1 Parsing was removed** in WCAG 2.2, declared obsolete because assistive
14
+ technology no longer parses HTML directly. Duplicate IDs and invalid nesting
15
+ are still bugs worth fixing (they break `aria-labelledby`, label association,
16
+ and `getElementById`), but they are no longer a WCAG failure. Never cite 4.1.1.
17
+
18
+ **Target Level AA.** Every major regulation references AA: New Zealand Web
19
+ Accessibility Standard 1.2 and UK public sector at WCAG 2.2 AA, US ADA Title II
20
+ and EU EN 301 549 v3.2.1 at 2.1 AA (EU moves to 2.2 AA with v4.1.1), US Section
21
+ 508 at 2.0 AA. WCAG 2.2 is backward compatible, so hitting 2.2 AA satisfies all
22
+ of them at once. W3C states explicitly that AAA is not a realistic
23
+ whole-site target; treat individual AAA criteria as opt-in advisories.
24
+
25
+ ---
26
+
27
+ ## The 55 criteria (Level A and AA)
28
+
29
+ `2.2` marks a criterion new in WCAG 2.2.
30
+
31
+ ### 1. Perceivable
32
+
33
+ | SC | Name | Lvl | | What it means when you are writing code |
34
+ |---|---|---|---|---|
35
+ | 1.1.1 | Non-text Content | A | | Every image, icon, chart, and control has a text alternative; purely decorative images get `alt=""` |
36
+ | 1.2.1 | Audio-only and Video-only (Prerecorded) | A | | Transcript for audio-only; transcript or audio track for video-only |
37
+ | 1.2.2 | Captions (Prerecorded) | A | | Captions on all prerecorded video with sound |
38
+ | 1.2.3 | Audio Description or Media Alternative | A | | Visual information in video is available in audio or text |
39
+ | 1.2.4 | Captions (Live) | AA | | Live captions on live audio content |
40
+ | 1.2.5 | Audio Description (Prerecorded) | AA | | An audio description track for prerecorded video |
41
+ | 1.3.1 | Info and Relationships | A | | Structure you convey visually exists in markup: headings, lists, `<th>`, `<fieldset>`, label association |
42
+ | 1.3.2 | Meaningful Sequence | A | | DOM order matches reading order; CSS reordering must not break it |
43
+ | 1.3.3 | Sensory Characteristics | A | | No instructions that rely only on shape, size, position, or sound ("the round button on the right") |
44
+ | 1.3.4 | Orientation | AA | | Do not lock to portrait or landscape unless essential |
45
+ | 1.3.5 | Identify Input Purpose | AA | | `autocomplete` tokens on common fields (`name`, `email`, `tel`, address parts) |
46
+ | 1.4.1 | Use of Color | A | | Color is never the only carrier of meaning; body-text links need a non-color cue |
47
+ | 1.4.2 | Audio Control | A | | Audio over 3s has a pause/stop control or independent volume |
48
+ | 1.4.3 | Contrast (Minimum) | AA | | 4.5:1 text, 3:1 large text — in light **and** dark mode |
49
+ | 1.4.4 | Resize Text | AA | | Text scales to 200% without loss; relative units, no fixed-height text boxes |
50
+ | 1.4.5 | Images of Text | AA | | Real text, not pictures of text (logos excepted) |
51
+ | 1.4.10 | Reflow | AA | | No horizontal scroll at 320 CSS px wide (= 400% zoom of a 1280px viewport) |
52
+ | 1.4.11 | Non-text Contrast | AA | | 3:1 for control boundaries, focus rings, icons, meaningful graphics |
53
+ | 1.4.12 | Text Spacing | AA | | No loss when users override line-height 1.5, paragraph 2em, letter 0.12em, word 0.16em |
54
+ | 1.4.13 | Content on Hover or Focus | AA | | Hover/focus popups are dismissible, hoverable, and persistent |
55
+
56
+ ### 2. Operable
57
+
58
+ | SC | Name | Lvl | | What it means when you are writing code |
59
+ |---|---|---|---|---|
60
+ | 2.1.1 | Keyboard | A | | Every function works from the keyboard alone |
61
+ | 2.1.2 | No Keyboard Trap | A | | Focus can always leave a component by keyboard |
62
+ | 2.1.4 | Character Key Shortcuts | A | | Single-key shortcuts can be disabled, remapped, or fire only when the control has focus |
63
+ | 2.2.1 | Timing Adjustable | A | | Time limits can be turned off, adjusted, or extended |
64
+ | 2.2.2 | Pause, Stop, Hide | A | | Motion or auto-updating content over 5s can be paused, stopped, or hidden |
65
+ | 2.3.1 | Three Flashes or Below Threshold | A | | Nothing flashes more than three times per second |
66
+ | 2.4.1 | Bypass Blocks | A | | A skip link, or landmarks plus headings, to jump past repeated navigation |
67
+ | 2.4.2 | Page Titled | A | | Every page and route has a unique, descriptive `<title>` |
68
+ | 2.4.3 | Focus Order | A | | Tab order is meaningful; dialogs and inserted content receive focus where expected |
69
+ | 2.4.4 | Link Purpose (In Context) | A | | Link text says where it goes; "read more" needs context or a fuller accessible name |
70
+ | 2.4.5 | Multiple Ways | AA | | More than one route to a page: nav, search, sitemap |
71
+ | 2.4.6 | Headings and Labels | AA | | Headings and labels actually describe what follows |
72
+ | 2.4.7 | Focus Visible | AA | | A visible focus indicator on every keyboard-focusable element |
73
+ | 2.4.11 | Focus Not Obscured (Minimum) | AA | `2.2` | Sticky headers, footers, and cookie bars must not entirely hide the focused element |
74
+ | 2.5.1 | Pointer Gestures | A | | Pinch/swipe/path gestures have a single-pointer alternative |
75
+ | 2.5.2 | Pointer Cancellation | A | | Act on pointer-up, not pointer-down; allow abort by moving away |
76
+ | 2.5.3 | Label in Name | A | | The visible label text is contained in the accessible name |
77
+ | 2.5.4 | Motion Actuation | A | | Shake/tilt triggers have a UI equivalent and can be disabled |
78
+ | 2.5.7 | Dragging Movements | AA | `2.2` | Every drag interaction has a single-pointer alternative |
79
+ | 2.5.8 | Target Size (Minimum) | AA | `2.2` | Targets >= 24x24 CSS px, or spaced so a 24px circle does not overlap a neighbour |
80
+
81
+ ### 3. Understandable
82
+
83
+ | SC | Name | Lvl | | What it means when you are writing code |
84
+ |---|---|---|---|---|
85
+ | 3.1.1 | Language of Page | A | | `<html lang="en">` — trivially emitted, still missing on 13.5% of pages |
86
+ | 3.1.2 | Language of Parts | AA | | Mark inline language changes with `lang` |
87
+ | 3.2.1 | On Focus | A | | Focusing something never changes context (no auto-submit, no popup on focus) |
88
+ | 3.2.2 | On Input | A | | Changing a value never changes context unless the user was warned first |
89
+ | 3.2.3 | Consistent Navigation | AA | | Repeated navigation keeps the same relative order across pages |
90
+ | 3.2.4 | Consistent Identification | AA | | The same function gets the same name and icon everywhere |
91
+ | 3.2.6 | Consistent Help | A | `2.2` | Help mechanisms appear in the same relative order on every page that has them |
92
+ | 3.3.1 | Error Identification | A | | Errors are described in text and name the field — not just a red border |
93
+ | 3.3.2 | Labels or Instructions | A | | Inputs have labels; required formats stated up front, not only after failure |
94
+ | 3.3.3 | Error Suggestion | AA | | Suggest a correction when you can determine one |
95
+ | 3.3.4 | Error Prevention (Legal, Financial, Data) | AA | | Consequential submissions are reversible, checked, or confirmed |
96
+ | 3.3.7 | Redundant Entry | A | `2.2` | Do not ask for the same information twice in one process — auto-fill or offer it for selection |
97
+ | 3.3.8 | Accessible Authentication (Minimum) | AA | `2.2` | No cognitive-function test in login without an alternative; allow paste and password managers |
98
+
99
+ ### 4. Robust
100
+
101
+ | SC | Name | Lvl | | What it means when you are writing code |
102
+ |---|---|---|---|---|
103
+ | 4.1.2 | Name, Role, Value | A | | Every control exposes an accessible name, the correct role, and its current state |
104
+ | 4.1.3 | Status Messages | AA | | Status changes that do not move focus are announced via `role="status"` / `role="alert"` |
105
+
106
+ Understanding documents live at
107
+ `https://www.w3.org/WAI/WCAG22/Understanding/<slug>`, where the slug is the
108
+ criterion name lowercased and hyphenated — `non-text-content`,
109
+ `contrast-minimum`, `name-role-value`, `status-messages`. When unsure, start
110
+ from the [Understanding index](https://www.w3.org/WAI/WCAG22/Understanding/).
111
+
112
+ ---
113
+
114
+ ## The 9 criteria new in WCAG 2.2
115
+
116
+ Six are at A/AA and therefore obligations; three are AAA. These are the ones
117
+ generated code misses most reliably, because most training data predates them.
118
+
119
+ **2.4.11 Focus Not Obscured (Minimum) — AA.** When an element receives keyboard
120
+ focus, no author-created content may hide it entirely. The usual culprits are
121
+ sticky headers, sticky footers, cookie banners, and chat widgets. Fix with
122
+ `scroll-margin-top` matching the sticky header height on focusable elements, and
123
+ by giving overlays a real close control.
124
+ [Understanding](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html)
125
+
126
+ **2.4.12 Focus Not Obscured (Enhanced) — AAA.** As above, but *no part* of the
127
+ focused element may be obscured.
128
+ [Understanding](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-enhanced.html)
129
+
130
+ **2.4.13 Focus Appearance — AAA.** The focus indicator must be at least as large
131
+ as a 2px-thick perimeter of the component and have at least 3:1 contrast between
132
+ focused and unfocused states. Useful as a design target even though it is AAA;
133
+ AA still requires a visible indicator via 2.4.7 and 3:1 via 1.4.11.
134
+ [Understanding](https://www.w3.org/WAI/WCAG22/Understanding/focus-appearance.html)
135
+
136
+ **2.5.7 Dragging Movements — AA.** Any functionality that uses a dragging
137
+ movement must also be operable with a single pointer without dragging. Sortable
138
+ lists need move-up/move-down buttons; sliders need clickable track and arrow
139
+ keys; kanban cards need a "move to" menu; drag-to-upload needs a file input.
140
+ [Understanding](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html)
141
+
142
+ **2.5.8 Target Size (Minimum) — AA.** Pointer targets are at least 24x24 CSS px.
143
+ Exceptions: spacing (a 24px-diameter circle centred on the target overlaps no
144
+ other target's circle), inline targets inside a sentence, targets whose size is
145
+ browser-determined, and where a conforming equivalent exists elsewhere on the
146
+ page. Icon buttons at 16px and dense table-row action links are the common
147
+ failures. Note that axe-core's `target-size` rule is **disabled by default** and
148
+ has a documented false-positive history with overlapping and translucent
149
+ elements — `a11y-loop` enables it but reports it as needs-review, never as a
150
+ hard failure.
151
+ [Understanding](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html)
152
+
153
+ **3.2.6 Consistent Help — A.** If a help mechanism (contact details, human
154
+ contact, a self-help link, an automated chat) repeats across pages, it must
155
+ appear in the same relative order in the page. It does not require that you
156
+ *provide* help — only that existing help is placed consistently.
157
+ [Understanding](https://www.w3.org/WAI/WCAG22/Understanding/consistent-help.html)
158
+
159
+ **3.3.7 Redundant Entry — A.** Information the user already supplied in the same
160
+ process is either auto-populated or available to select — no retyping an address
161
+ at step 4 that was entered at step 2. Exceptions: re-entry that is essential
162
+ (confirming a password), where the earlier information is no longer valid, or
163
+ for security. Multi-step checkout and signup wizards are the common failures.
164
+ [Understanding](https://www.w3.org/WAI/WCAG22/Understanding/redundant-entry.html)
165
+
166
+ **3.3.8 Accessible Authentication (Minimum) — AA.** No step in an
167
+ authentication process may require a cognitive function test (remembering a
168
+ password, transcribing characters, solving a puzzle, identifying objects in
169
+ images) unless there is an alternative, or a mechanism to assist. In practice:
170
+ never block paste into password or one-time-code fields, never disable password
171
+ managers, use `autocomplete="current-password"` / `one-time-code`, and offer a
172
+ non-puzzle option alongside any CAPTCHA. Object-recognition CAPTCHAs are
173
+ explicitly permitted only as the *alternative*, not as the sole path.
174
+ [Understanding](https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-minimum.html)
175
+
176
+ **3.3.9 Accessible Authentication (Enhanced) — AAA.** As above with the
177
+ object-recognition and personal-content exceptions removed.
178
+ [Understanding](https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-enhanced.html)
179
+
180
+ ---
181
+
182
+ ## Contrast thresholds
183
+
184
+ | | Normal text | Large text | Non-text / UI |
185
+ |---|---|---|---|
186
+ | **AA** | **4.5:1** (SC 1.4.3) | **3:1** (SC 1.4.3) | **3:1** (SC 1.4.11) |
187
+ | AAA | 7:1 (SC 1.4.6) | 4.5:1 (SC 1.4.6) | — |
188
+
189
+ **Large text is 18pt regular or 14pt bold.** At the CSS default of 1pt =
190
+ 1.333px that is **>= 24px regular, or >= 18.5px bold**. Get this boundary right:
191
+ it is the most common source of disagreement between contrast tools. A 20px
192
+ heading is *not* large text and needs 4.5:1.
193
+
194
+ SC 1.4.11 applies to the visual boundary of controls (input borders, button
195
+ edges, checkbox outlines), focus indicators, and graphics required to understand
196
+ content (chart lines, meaningful icons) — not to decorative graphics or to
197
+ inactive controls.
198
+
199
+ Reference math, matching browsers, axe-core, and the
200
+ [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/):
201
+
202
+ ```
203
+ c_srgb = c_8bit / 255
204
+ c = c_srgb <= 0.04045 ? c_srgb / 12.92 : ((c_srgb + 0.055) / 1.055) ** 2.4
205
+ L = 0.2126*R + 0.7152*G + 0.0722*B
206
+ ratio = (L_lighter + 0.05) / (L_darker + 0.05)
207
+ ```
208
+
209
+ Composite translucent foregrounds over their background in non-linear sRGB
210
+ *before* linearizing. Run `a11y-loop contrast <fg> <bg> [--large] [--ui] --fix`
211
+ rather than doing this by hand; `--fix` returns both a lighter and a darker
212
+ passing candidate with hue and chroma preserved.
213
+
214
+ **APCA is not a WCAG algorithm.** It was removed from the WCAG 3 draft in July
215
+ 2023, and the April 2026 WCAG 3 editor's draft states the contrast algorithm is
216
+ "yet to be determined". APCA's technical critique of WCAG 2 is legitimate —
217
+ WCAG 2 overstates contrast for near-black pairs — but Lc values have no
218
+ normative or legal standing. Never let an APCA pass excuse a WCAG 2 failure.
219
+
220
+ ## Related
221
+
222
+ - ARIA and widget behavior: [apg-patterns.md](apg-patterns.md)
223
+ - What generated code gets wrong: [ai-failure-modes.md](ai-failure-modes.md)
224
+ - What none of this can check: [manual-testing.md](manual-testing.md)
package/src/cli.js ADDED
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * a11y-loop CLI.
4
+ *
5
+ * Exit codes are the loop's contract:
6
+ * 0 — no violations (audit) / threshold met (contrast) / no regression (diff)
7
+ * 1 — violations found / threshold missed / regression introduced
8
+ * 2 — tool error (bad arguments, missing browser, unreadable report)
9
+ */
10
+
11
+ import { parseArgs } from 'node:util';
12
+ import { pathToFileURL } from 'node:url';
13
+ import { readFile } from 'node:fs/promises';
14
+
15
+ import { runAuditCommand } from './commands/audit.js';
16
+ import { runContrastCommand } from './commands/contrast.js';
17
+ import { runDiffCommand } from './commands/diff.js';
18
+ import { ToolError } from './lib/axe-runner.js';
19
+
20
+ export const EXIT = { OK: 0, FINDINGS: 1, ERROR: 2 };
21
+
22
+ const OPTIONS = {
23
+ json: { type: 'boolean', default: false },
24
+ out: { type: 'string' },
25
+ sarif: { type: 'string' },
26
+ headed: { type: 'boolean', default: false },
27
+ // parseArgs has no --no-* negation, so the negative form is its own option.
28
+ 'no-best-practice': { type: 'boolean', default: false },
29
+ interact: { type: 'string' },
30
+ quiet: { type: 'boolean', default: false },
31
+ file: { type: 'string' },
32
+ html: { type: 'string' },
33
+ large: { type: 'boolean', default: false },
34
+ ui: { type: 'boolean', default: false },
35
+ fix: { type: 'boolean', default: false },
36
+ before: { type: 'string' },
37
+ after: { type: 'string' },
38
+ help: { type: 'boolean', default: false, short: 'h' },
39
+ version: { type: 'boolean', default: false },
40
+ };
41
+
42
+ const USAGE = `a11y-loop — accessibility verification for AI coding agents
43
+
44
+ USAGE
45
+ a11y-loop audit <url> audit a running page
46
+ a11y-loop audit --file <path> audit an HTML file (served over http, never file://)
47
+ a11y-loop audit --html "<button>" audit a fragment (the usual entry point for an agent)
48
+ a11y-loop contrast <fg> <bg> check a colour pair against WCAG 2.x
49
+ a11y-loop diff --before a.json --after b.json compare two audits
50
+
51
+ AUDIT OPTIONS
52
+ --json machine-readable report on stdout
53
+ --out <path> write the JSON report to a file
54
+ --sarif <path> also write SARIF v2.1 (see notes in the report)
55
+ --interact <path.mjs> audit interaction states: export const states = { name: async (page) => {} }
56
+ --headed run a visible browser (debugging)
57
+ --no-best-practice omit axe best-practice rules (they are never blocking either way)
58
+ --quiet one-line summary only
59
+
60
+ CONTRAST OPTIONS
61
+ --large large-scale text thresholds (>=24px, or >=18.5px bold)
62
+ --ui non-text / UI component threshold (3:1, SC 1.4.11)
63
+ --fix suggest passing colours, lighter and darker, in OKLCh
64
+ --json machine-readable result
65
+
66
+ EXIT CODES
67
+ 0 no violations / threshold met / no regression
68
+ 1 violations found / threshold missed / new violations introduced
69
+ 2 tool error
70
+
71
+ Five rendering passes run per audit: default, dark, forced-colors, reduced-motion,
72
+ and a 320x256 viewport (the WCAG-sanctioned 400% zoom equivalent for SC 1.4.10).
73
+
74
+ Automated checks cover a subset of WCAG (Deque: ~57% of issues by volume; ~31% of AA
75
+ criteria have any automated rule). This is not an audit or conformance claim.
76
+ `;
77
+
78
+ /**
79
+ * Turn argv into a command plus normalised flags.
80
+ * @param {string[]} argv
81
+ */
82
+ export function parseCli(argv) {
83
+ const { values, positionals } = parseArgs({
84
+ args: argv,
85
+ options: OPTIONS,
86
+ allowPositionals: true,
87
+ strict: true,
88
+ });
89
+
90
+ const flags = {
91
+ ...values,
92
+ bestPractice: !values['no-best-practice'],
93
+ };
94
+ delete flags['no-best-practice'];
95
+
96
+ return { command: positionals[0] ?? null, positionals: positionals.slice(1), flags };
97
+ }
98
+
99
+ /**
100
+ * Work out what `audit` should audit.
101
+ * @returns {{type:'url'|'file'|'html', value:string}}
102
+ */
103
+ export function resolveAuditTarget(positionals, flags) {
104
+ const given = [
105
+ positionals[0] ? { type: 'url', value: positionals[0] } : null,
106
+ flags.file ? { type: 'file', value: flags.file } : null,
107
+ flags.html !== undefined ? { type: 'html', value: flags.html } : null,
108
+ ].filter(Boolean);
109
+
110
+ if (given.length === 0) {
111
+ throw new ToolError('audit needs a target.', {
112
+ hint: 'Use one of:\n a11y-loop audit http://localhost:3000\n a11y-loop audit --file page.html\n a11y-loop audit --html "<button></button>"',
113
+ });
114
+ }
115
+ if (given.length > 1) {
116
+ throw new ToolError(
117
+ `audit takes exactly one target, but got ${given.map((g) => g.type).join(' and ')}.`,
118
+ );
119
+ }
120
+
121
+ const target = given[0];
122
+ if (target.type === 'url' && !/^https?:\/\//i.test(target.value)) {
123
+ if (/^file:\/\//i.test(target.value)) {
124
+ throw new ToolError('file:// URLs are not supported.', {
125
+ hint:
126
+ 'A null origin breaks axe frame injection, ES modules and fetch. Use --file <path> ' +
127
+ 'instead — a11y-loop serves it over http on 127.0.0.1.',
128
+ });
129
+ }
130
+ throw new ToolError(`"${target.value}" is not an http(s) URL.`, {
131
+ hint: 'Did you mean --file or --html?',
132
+ });
133
+ }
134
+ return target;
135
+ }
136
+
137
+ /**
138
+ * @param {string[]} argv
139
+ * @param {{write:Function, writeError:Function}} [io]
140
+ * @returns {Promise<number>} exit code
141
+ */
142
+ export async function main(argv, io) {
143
+ const out = io ?? {
144
+ write: (text) => process.stdout.write(text),
145
+ writeError: (text) => process.stderr.write(text),
146
+ };
147
+
148
+ let parsed;
149
+ try {
150
+ parsed = parseCli(argv);
151
+ } catch (error) {
152
+ out.writeError(`a11y-loop: ${error.message}\n\n${USAGE}`);
153
+ return EXIT.ERROR;
154
+ }
155
+
156
+ const { command, positionals, flags } = parsed;
157
+
158
+ if (flags.version) {
159
+ const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
160
+ out.write(`${pkg.version}\n`);
161
+ return EXIT.OK;
162
+ }
163
+
164
+ if (flags.help || command === 'help' || command === null) {
165
+ out.write(USAGE);
166
+ return command === null && !flags.help ? EXIT.ERROR : EXIT.OK;
167
+ }
168
+
169
+ try {
170
+ if (command === 'audit') {
171
+ const target = resolveAuditTarget(positionals, flags);
172
+ return await runAuditCommand({ target, flags }, out);
173
+ }
174
+
175
+ if (command === 'contrast') {
176
+ const [fg, bg] = positionals;
177
+ if (!fg || !bg) {
178
+ throw new ToolError('contrast needs two colours.', {
179
+ hint: 'a11y-loop contrast "#777777" "#ffffff" [--large] [--ui] [--fix]',
180
+ });
181
+ }
182
+ return await runContrastCommand({ fg, bg, flags }, out);
183
+ }
184
+
185
+ if (command === 'diff') {
186
+ return await runDiffCommand({ flags }, out);
187
+ }
188
+
189
+ out.writeError(`a11y-loop: unknown command "${command}"\n\n${USAGE}`);
190
+ return EXIT.ERROR;
191
+ } catch (error) {
192
+ if (error instanceof ToolError) {
193
+ out.writeError(`a11y-loop: ${error.message}\n`);
194
+ if (error.hint) out.writeError(`\n${error.hint}\n`);
195
+ return EXIT.ERROR;
196
+ }
197
+ out.writeError(`a11y-loop: unexpected error: ${error?.message ?? error}\n`);
198
+ if (process.env.A11Y_LOOP_DEBUG) out.writeError(`${error?.stack ?? ''}\n`);
199
+ else out.writeError('Set A11Y_LOOP_DEBUG=1 for a stack trace.\n');
200
+ return EXIT.ERROR;
201
+ }
202
+ }
203
+
204
+ // Only run when invoked as a program, so tests can import `main` freely.
205
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
206
+ process.exitCode = await main(process.argv.slice(2));
207
+ }