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,341 @@
1
+ /**
2
+ * Dialog behaviour and custom-control keyboard activation.
3
+ *
4
+ * These are the checks that need a driven browser rather than a DOM snapshot,
5
+ * and they are where AI-generated UI fails most reliably: a `<div role="dialog">`
6
+ * that renders correctly, traps nothing, ignores Escape, and drops focus back to
7
+ * the top of the document when it closes.
8
+ *
9
+ * "A role is a promise" — `role="button"` commits to BOTH Enter and Space. A div
10
+ * with only a click handler satisfies neither, and axe cannot tell, because
11
+ * nothing about the markup is wrong.
12
+ */
13
+
14
+ import { makeFinding, SEVERITY } from '../finding.js';
15
+
16
+ /** Tab presses used to probe a focus trap. */
17
+ export const TRAP_PROBE_STEPS = 12;
18
+
19
+ /**
20
+ * @param {object} observation
21
+ * @param {string} observation.selector
22
+ * @param {string} observation.html
23
+ * @param {boolean} observation.focusMovedIntoDialog
24
+ * @param {boolean} observation.focusTrapped
25
+ * @param {string|null} observation.escapedTo selector focus escaped to, if any
26
+ * @param {boolean|null} observation.escapeClosed
27
+ * @param {boolean|null} observation.focusReturnedToTrigger
28
+ * @param {string|null} observation.trigger
29
+ * @param {{passes:string[], state:string|null}} ctx
30
+ */
31
+ export function dialogFindings(observation, ctx) {
32
+ if (!observation) return [];
33
+ const { passes = [], state = null } = ctx ?? {};
34
+ const findings = [];
35
+ const base = {
36
+ source: 'a11y-loop',
37
+ selector: observation.selector,
38
+ html: observation.html,
39
+ passes,
40
+ state,
41
+ };
42
+
43
+ if (observation.focusMovedIntoDialog === false) {
44
+ findings.push(
45
+ makeFinding({
46
+ ...base,
47
+ ruleId: 'dialog-initial-focus',
48
+ severity: SEVERITY.VIOLATION,
49
+ impact: 'serious',
50
+ sc: '2.4.3',
51
+ message:
52
+ 'When this dialog opened, focus stayed outside it. Move focus into the dialog on ' +
53
+ 'open — to the first interactive element, or to the dialog container itself.',
54
+ }),
55
+ );
56
+ }
57
+
58
+ if (observation.focusTrapped === false) {
59
+ findings.push(
60
+ makeFinding({
61
+ ...base,
62
+ ruleId: 'dialog-focus-not-trapped',
63
+ severity: SEVERITY.VIOLATION,
64
+ impact: 'serious',
65
+ sc: '2.1.2',
66
+ message:
67
+ `Tabbing inside this dialog moved focus out of it (reached ${observation.escapedTo ?? 'content behind the dialog'}). ` +
68
+ 'A modal dialog must keep Tab and Shift+Tab within itself while it is open.',
69
+ data: { escapedTo: observation.escapedTo ?? null },
70
+ }),
71
+ );
72
+ }
73
+
74
+ if (observation.escapeClosed === false) {
75
+ findings.push(
76
+ makeFinding({
77
+ ...base,
78
+ ruleId: 'dialog-escape-does-not-close',
79
+ severity: SEVERITY.VIOLATION,
80
+ impact: 'serious',
81
+ sc: '2.1.1',
82
+ message:
83
+ 'Pressing Escape did not close this dialog. Every modal dialog must be dismissible ' +
84
+ 'from the keyboard (ARIA APG Dialog (Modal) pattern).',
85
+ }),
86
+ );
87
+ }
88
+
89
+ if (observation.escapeClosed === true && observation.focusReturnedToTrigger === false) {
90
+ findings.push(
91
+ makeFinding({
92
+ ...base,
93
+ ruleId: 'dialog-focus-not-returned',
94
+ severity: SEVERITY.VIOLATION,
95
+ impact: 'serious',
96
+ sc: '2.4.3',
97
+ message:
98
+ `The dialog closed but focus did not return to the control that opened it ` +
99
+ `(${observation.trigger ?? 'unknown trigger'}). Keyboard users are dropped back to ` +
100
+ 'the top of the document and lose their place.',
101
+ data: { trigger: observation.trigger ?? null },
102
+ }),
103
+ );
104
+ }
105
+
106
+ return findings;
107
+ }
108
+
109
+ /**
110
+ * @param {Array<{selector:string, html:string, name:string, enterActivates:boolean, spaceActivates:boolean, nativeButton:boolean}>} controls
111
+ * @param {{passes:string[], state:string|null}} ctx
112
+ */
113
+ export function roleButtonFindings(controls = [], ctx) {
114
+ const { passes = [], state = null } = ctx ?? {};
115
+ const findings = [];
116
+ for (const control of controls) {
117
+ const missing = [];
118
+ if (!control.enterActivates) missing.push('Enter');
119
+ if (!control.spaceActivates) missing.push('Space');
120
+ if (missing.length === 0) continue;
121
+
122
+ findings.push(
123
+ makeFinding({
124
+ ruleId: 'role-button-keyboard-activation',
125
+ source: 'a11y-loop',
126
+ severity: SEVERITY.VIOLATION,
127
+ impact: 'critical',
128
+ sc: '2.1.1',
129
+ selector: control.selector,
130
+ html: control.html,
131
+ message:
132
+ `This element has role="button" but does not activate on ${missing.join(' or ')}. ` +
133
+ 'role="button" is a promise to behave like a button, which means responding to both ' +
134
+ 'Enter and Space. Use a real <button>, or add keydown handling for both keys.',
135
+ passes,
136
+ state,
137
+ data: {
138
+ enterActivates: control.enterActivates,
139
+ spaceActivates: control.spaceActivates,
140
+ accessibleName: control.name,
141
+ },
142
+ }),
143
+ );
144
+ }
145
+ return findings;
146
+ }
147
+
148
+ const DIALOG_SELECTOR = '[role="dialog"], [role="alertdialog"], dialog[open]';
149
+
150
+ /** Is a dialog currently visible? Cheap enough to call on every pass. */
151
+ export async function hasVisibleDialog(page) {
152
+ return page.evaluate((selector) => {
153
+ const helpers = window.__a11yLoop;
154
+ return Array.from(document.querySelectorAll(selector)).some((el) => helpers.isVisible(el));
155
+ }, DIALOG_SELECTOR);
156
+ }
157
+
158
+ /**
159
+ * Snapshot the first visible dialog's identity and whether focus was moved
160
+ * into it — BEFORE anything else has a chance to touch focus.
161
+ *
162
+ * This must be called immediately after whatever opened the dialog (a click,
163
+ * or an --interact state's setup function), and before any other survey runs.
164
+ * The keyboard and focus-visibility surveys each walk the page with real Tab
165
+ * presses, which moves focus around; if "was focus moved into the dialog on
166
+ * open" were checked afterwards, it would be answering a different question —
167
+ * "where did those OTHER surveys leave focus" — and a page that sets initial
168
+ * focus correctly would be reported as though it had not.
169
+ *
170
+ * @param {import('playwright').Page} page
171
+ * @param {{presumedTrigger?:string|null}} [opts]
172
+ * @returns {Promise<{selector:string, html:string, trigger:string|null, focusMovedIntoDialog:boolean, initialFocus:string|null}|null>}
173
+ */
174
+ export async function captureDialogInitialState(page, opts = {}) {
175
+ const { presumedTrigger = null } = opts;
176
+
177
+ return page.evaluate(
178
+ ({ selector, presumed }) => {
179
+ const helpers = window.__a11yLoop;
180
+ const dialog = Array.from(document.querySelectorAll(selector)).find((el) =>
181
+ helpers.isVisible(el),
182
+ );
183
+ if (!dialog) return null;
184
+
185
+ // Prefer an explicit relationship over the presumed trigger.
186
+ let trigger = presumed;
187
+ if (dialog.id) {
188
+ const declared = document.querySelector(
189
+ `[aria-controls="${dialog.id}"], [data-dialog-target="${dialog.id}"]`,
190
+ );
191
+ if (declared) trigger = helpers.cssPath(declared);
192
+ }
193
+
194
+ const active = document.activeElement;
195
+ return {
196
+ selector: helpers.cssPath(dialog),
197
+ html: helpers.shortHtml(dialog),
198
+ trigger,
199
+ focusMovedIntoDialog: Boolean(active && dialog.contains(active)),
200
+ initialFocus: active ? helpers.cssPath(active) : null,
201
+ };
202
+ },
203
+ { selector: DIALOG_SELECTOR, presumed: presumedTrigger },
204
+ );
205
+ }
206
+
207
+ /**
208
+ * Probe focus trap, Escape, and focus return, given a dialog snapshot already
209
+ * captured by `captureDialogInitialState`. This drives the keyboard and
210
+ * therefore changes page state (and, if Escape closes the dialog, closes it),
211
+ * so the caller must run it last in a pass — but it must be handed the
212
+ * initial-focus fact rather than re-deriving it, for the reason above.
213
+ *
214
+ * @param {import('playwright').Page} page
215
+ * @param {Awaited<ReturnType<typeof captureDialogInitialState>>} found
216
+ */
217
+ export async function surveyDialog(page, found) {
218
+ if (!found) return null;
219
+
220
+ // Focus trap: Tab repeatedly and watch for focus leaving the dialog.
221
+ //
222
+ // Real Chromium, for a genuinely native <dialog> opened with showModal(),
223
+ // transiently moves document.activeElement to document.body for exactly
224
+ // one Tab press when wrapping past the dialog's last (or before its first)
225
+ // focusable descendant, then redirects back inside on the very next press —
226
+ // body itself stays inert throughout, so nothing is actually reachable
227
+ // there. Treating that single step as an escape would flag a correctly
228
+ // trapped native dialog as broken. It is only a real failure if landing
229
+ // outside the dialog is on a genuine element, or if it does not recover on
230
+ // the immediately following press.
231
+ let focusTrapped = true;
232
+ let escapedTo = null;
233
+ let sawUnconfirmedTransient = false;
234
+ for (let i = 0; i < TRAP_PROBE_STEPS; i++) {
235
+ await page.keyboard.press('Tab');
236
+ const check = await page.evaluate(
237
+ ({ selector }) => {
238
+ const helpers = window.__a11yLoop;
239
+ const dialog = Array.from(document.querySelectorAll(selector)).find((el) =>
240
+ helpers.isVisible(el),
241
+ );
242
+ const active = document.activeElement;
243
+ if (!dialog || !active) return { inside: false, transient: false, label: null };
244
+ if (dialog.contains(active)) return { inside: true, transient: false, label: null };
245
+ const isBodyOrRoot = active === document.body || active === document.documentElement;
246
+ const stillModal = typeof dialog.matches === 'function' && dialog.matches(':modal');
247
+ return {
248
+ inside: false,
249
+ transient: isBodyOrRoot && stillModal,
250
+ label: isBodyOrRoot ? 'the browser UI / document root' : helpers.cssPath(active),
251
+ };
252
+ },
253
+ { selector: DIALOG_SELECTOR },
254
+ );
255
+
256
+ if (check.inside) {
257
+ sawUnconfirmedTransient = false;
258
+ continue;
259
+ }
260
+ if (check.transient && !sawUnconfirmedTransient) {
261
+ sawUnconfirmedTransient = true;
262
+ continue;
263
+ }
264
+ focusTrapped = false;
265
+ escapedTo = check.label;
266
+ break;
267
+ }
268
+
269
+ await page.keyboard.press('Escape');
270
+ await page.waitForTimeout(150);
271
+
272
+ const stillOpen = await hasVisibleDialog(page);
273
+ const escapeClosed = !stillOpen;
274
+
275
+ let focusReturnedToTrigger = null;
276
+ if (escapeClosed && found.trigger) {
277
+ const activeSelector = await page.evaluate(() => {
278
+ const active = document.activeElement;
279
+ if (!active || active === document.body) return null;
280
+ return window.__a11yLoop.cssPath(active);
281
+ });
282
+ focusReturnedToTrigger = activeSelector === found.trigger;
283
+ }
284
+
285
+ return { ...found, focusTrapped, escapedTo, escapeClosed, focusReturnedToTrigger };
286
+ }
287
+
288
+ /**
289
+ * Test Enter and Space on every `role="button"` that is not a real button.
290
+ * Activation is detected by watching for a `click` event: a native button fires
291
+ * one on Enter and Space for free, a div with only an onclick handler fires
292
+ * neither.
293
+ *
294
+ * @param {import('playwright').Page} page
295
+ */
296
+ export async function surveyRoleButtons(page) {
297
+ const controls = await page.evaluate(() => {
298
+ const helpers = window.__a11yLoop;
299
+ window.__a11yLoopClicks = [];
300
+ if (!window.__a11yLoopClickListener) {
301
+ window.__a11yLoopClickListener = (event) => {
302
+ window.__a11yLoopClicks.push(helpers.cssPath(event.target));
303
+ };
304
+ document.addEventListener('click', window.__a11yLoopClickListener, true);
305
+ }
306
+ return Array.from(document.querySelectorAll('[role="button"]'))
307
+ .filter((el) => helpers.isVisible(el) && el.tagName !== 'BUTTON')
308
+ .slice(0, 20)
309
+ .map((el) => ({
310
+ selector: helpers.cssPath(el),
311
+ html: helpers.shortHtml(el),
312
+ name: helpers.accessibleName(el),
313
+ }));
314
+ });
315
+
316
+ const results = [];
317
+ for (const control of controls) {
318
+ const activation = { enterActivates: false, spaceActivates: false };
319
+ for (const [key, field] of [
320
+ ['Enter', 'enterActivates'],
321
+ [' ', 'spaceActivates'],
322
+ ]) {
323
+ const focused = await page.evaluate((selector) => {
324
+ window.__a11yLoopClicks = [];
325
+ const el = document.querySelector(selector);
326
+ if (!el) return false;
327
+ el.focus();
328
+ return document.activeElement === el;
329
+ }, control.selector);
330
+ if (!focused) continue;
331
+ await page.keyboard.press(key === ' ' ? 'Space' : key);
332
+ await page.waitForTimeout(30);
333
+ activation[field] = await page.evaluate(
334
+ (selector) => (window.__a11yLoopClicks ?? []).includes(selector),
335
+ control.selector,
336
+ );
337
+ }
338
+ results.push({ ...control, ...activation, nativeButton: false });
339
+ }
340
+ return results;
341
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Clickable non-interactive elements with no keyboard path (SC 2.1.1, Level A).
3
+ *
4
+ * The archetypal AI-codegen failure: a <div> or <span> styled and wired to act
5
+ * like a button (an onclick handler, cursor: pointer) but with no role, no
6
+ * tabindex, and no keydown handling. No axe rule fires on this — nothing about
7
+ * the markup is invalid, the element is simply unreachable by keyboard.
8
+ *
9
+ * Detected via the `onclick` IDL property rather than by firing a synthetic
10
+ * click, so running this check never triggers page side effects (navigation,
11
+ * form submission, a payment flow, ...). This catches both the inline
12
+ * `onclick="..."` attribute and `el.onclick = fn` assignment, which covers the
13
+ * two patterns seen in the fixtures and the demo. It does not catch handlers
14
+ * added with `addEventListener('click', ...)` — a known, documented gap.
15
+ *
16
+ * Deliberately narrow: an element that already carries a `role` or a
17
+ * `tabindex` is a different, already-covered failure (positive-tabindex,
18
+ * keyboard-unreachable, or role-button-keyboard-activation); flagging it again
19
+ * here would double-count the same defect under a second rule id.
20
+ */
21
+
22
+ import { makeFinding, SEVERITY } from '../finding.js';
23
+
24
+ /** Native elements that are already keyboard-operable; never flagged here. */
25
+ const NATIVELY_INTERACTIVE = [
26
+ 'A',
27
+ 'BUTTON',
28
+ 'INPUT',
29
+ 'SELECT',
30
+ 'TEXTAREA',
31
+ 'SUMMARY',
32
+ 'OPTION',
33
+ 'LABEL',
34
+ ];
35
+
36
+ /**
37
+ * @param {Array<{selector:string, html:string, tag:string, name:string}>} elements
38
+ * @param {{passes:string[], state:string|null}} ctx
39
+ */
40
+ export function divButtonFindings(elements = [], ctx) {
41
+ const { passes = [], state = null } = ctx ?? {};
42
+ return elements.map((el) =>
43
+ makeFinding({
44
+ ruleId: 'div-button',
45
+ source: 'a11y-loop',
46
+ severity: SEVERITY.VIOLATION,
47
+ impact: 'critical',
48
+ sc: '2.1.1',
49
+ selector: el.selector,
50
+ html: el.html,
51
+ message:
52
+ `This <${String(el.tag ?? '').toLowerCase()}> has a click handler but no role, no ` +
53
+ 'tabindex, and no keyboard handler. It is not reachable by Tab and does not respond to ' +
54
+ 'Enter or Space, so keyboard and screen-reader users cannot activate it. Use a real ' +
55
+ '<button> (or <a href> for navigation), or add role="button", tabindex="0", and keydown ' +
56
+ 'handling for both Enter and Space.',
57
+ passes,
58
+ state,
59
+ data: { tag: el.tag, name: el.name },
60
+ }),
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Survey the page for clickable elements with no keyboard path.
66
+ * @param {import('playwright').Page} page
67
+ */
68
+ export async function surveyClickableNonInteractive(page) {
69
+ return page.evaluate((nativeTags) => {
70
+ const helpers = window.__a11yLoop;
71
+ const results = [];
72
+ for (const el of document.querySelectorAll('*')) {
73
+ if (nativeTags.includes(el.tagName)) continue;
74
+ if (el.hasAttribute('role')) continue;
75
+ if (el.hasAttribute('tabindex')) continue;
76
+ if (typeof el.onclick !== 'function') continue;
77
+ if (!helpers.isVisible(el)) continue;
78
+ results.push({
79
+ selector: helpers.cssPath(el),
80
+ html: helpers.shortHtml(el),
81
+ tag: el.tagName,
82
+ name: helpers.accessibleName(el),
83
+ });
84
+ }
85
+ return results;
86
+ }, NATIVELY_INTERACTIVE);
87
+ }