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.
- package/LICENSE +22 -0
- package/README.md +409 -0
- package/THIRD-PARTY-NOTICES.md +32 -0
- package/package.json +51 -0
- package/skill/a11y-loop/SKILL.md +332 -0
- package/skill/a11y-loop/evals/evals.json +168 -0
- package/skill/a11y-loop/evals/trigger-evals.json +20 -0
- package/skill/a11y-loop/references/ai-failure-modes.md +272 -0
- package/skill/a11y-loop/references/apg-patterns.md +264 -0
- package/skill/a11y-loop/references/manual-testing.md +224 -0
- package/skill/a11y-loop/references/wcag22-quick-ref.md +224 -0
- package/src/cli.js +207 -0
- package/src/commands/audit.js +125 -0
- package/src/commands/contrast.js +141 -0
- package/src/commands/diff.js +65 -0
- package/src/lib/axe-runner.js +400 -0
- package/src/lib/browser-utils.js +221 -0
- package/src/lib/checks/dialog.js +341 -0
- package/src/lib/checks/div-button.js +87 -0
- package/src/lib/checks/focus-visible.js +296 -0
- package/src/lib/checks/keyboard.js +235 -0
- package/src/lib/checks/link-text.js +83 -0
- package/src/lib/checks/reduced-motion.js +139 -0
- package/src/lib/checks/reflow.js +101 -0
- package/src/lib/checks/target-size.js +128 -0
- package/src/lib/contrast-math.js +189 -0
- package/src/lib/diff.js +118 -0
- package/src/lib/finding.js +164 -0
- package/src/lib/fingerprint.js +0 -0
- package/src/lib/format/checklist.js +281 -0
- package/src/lib/format/human.js +175 -0
- package/src/lib/format/json.js +139 -0
- package/src/lib/format/sarif.js +111 -0
- package/src/lib/serve.js +189 -0
- package/src/lib/suggest-color.js +169 -0
- 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
|
+
}
|