@wcag-audit/cli 1.0.0-alpha.3

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 (71) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +110 -0
  3. package/package.json +72 -0
  4. package/patches/@guidepup+guidepup+0.24.1.patch +30 -0
  5. package/src/__tests__/sanity.test.js +7 -0
  6. package/src/ai-fix-json.js +321 -0
  7. package/src/audit.js +199 -0
  8. package/src/cache/route-cache.js +46 -0
  9. package/src/cache/route-cache.test.js +96 -0
  10. package/src/checkers/ai-vision.js +102 -0
  11. package/src/checkers/auth.js +98 -0
  12. package/src/checkers/axe.js +65 -0
  13. package/src/checkers/consistency.js +222 -0
  14. package/src/checkers/forms.js +149 -0
  15. package/src/checkers/interaction.js +142 -0
  16. package/src/checkers/keyboard.js +347 -0
  17. package/src/checkers/media.js +102 -0
  18. package/src/checkers/motion.js +155 -0
  19. package/src/checkers/pointer.js +128 -0
  20. package/src/checkers/screen-reader.js +522 -0
  21. package/src/checkers/viewport.js +202 -0
  22. package/src/cli.js +156 -0
  23. package/src/commands/ci.js +63 -0
  24. package/src/commands/ci.test.js +55 -0
  25. package/src/commands/doctor.js +105 -0
  26. package/src/commands/doctor.test.js +81 -0
  27. package/src/commands/init.js +126 -0
  28. package/src/commands/init.test.js +83 -0
  29. package/src/commands/scan.js +322 -0
  30. package/src/commands/scan.test.js +139 -0
  31. package/src/config/global.js +60 -0
  32. package/src/config/global.test.js +58 -0
  33. package/src/config/project.js +35 -0
  34. package/src/config/project.test.js +44 -0
  35. package/src/devserver/spawn.js +82 -0
  36. package/src/devserver/spawn.test.js +58 -0
  37. package/src/discovery/astro.js +86 -0
  38. package/src/discovery/astro.test.js +76 -0
  39. package/src/discovery/crawl.js +93 -0
  40. package/src/discovery/crawl.test.js +93 -0
  41. package/src/discovery/dynamic-samples.js +44 -0
  42. package/src/discovery/dynamic-samples.test.js +66 -0
  43. package/src/discovery/manual.js +38 -0
  44. package/src/discovery/manual.test.js +52 -0
  45. package/src/discovery/nextjs.js +136 -0
  46. package/src/discovery/nextjs.test.js +141 -0
  47. package/src/discovery/registry.js +80 -0
  48. package/src/discovery/registry.test.js +33 -0
  49. package/src/discovery/remix.js +82 -0
  50. package/src/discovery/remix.test.js +77 -0
  51. package/src/discovery/sitemap.js +73 -0
  52. package/src/discovery/sitemap.test.js +69 -0
  53. package/src/discovery/sveltekit.js +85 -0
  54. package/src/discovery/sveltekit.test.js +76 -0
  55. package/src/discovery/vite.js +94 -0
  56. package/src/discovery/vite.test.js +144 -0
  57. package/src/license/log-usage.js +23 -0
  58. package/src/license/log-usage.test.js +45 -0
  59. package/src/license/validate.js +58 -0
  60. package/src/license/validate.test.js +58 -0
  61. package/src/output/agents-md.js +58 -0
  62. package/src/output/agents-md.test.js +62 -0
  63. package/src/output/cursor-rules.js +57 -0
  64. package/src/output/cursor-rules.test.js +62 -0
  65. package/src/output/markdown.js +119 -0
  66. package/src/output/markdown.test.js +95 -0
  67. package/src/report.js +235 -0
  68. package/src/util/anthropic.js +25 -0
  69. package/src/util/llm.js +159 -0
  70. package/src/util/screenshot.js +131 -0
  71. package/src/wcag-criteria.js +256 -0
@@ -0,0 +1,347 @@
1
+ // Keyboard / focus checker. Covers (substantially):
2
+ //
3
+ // 2.1.1 Keyboard — Tab through every focusable element
4
+ // 2.1.2 No Keyboard Trap — detect Tab cycles that never escape a region
5
+ // 2.1.4 Character Key Shortcuts — detect global single-key listeners
6
+ // 2.4.3 Focus Order — Tab order vs visual reading order (top→bot, l→r)
7
+ // 2.4.7 Focus Visible — pixel-diff before/after focus per control
8
+ // 2.4.11 Focus Not Obscured — focused rect vs sticky/fixed element rects
9
+ // 2.4.12 Focus Not Obscured (Enhanced) — stricter version
10
+ //
11
+ // We also cap the walk at MAX_TAB_STOPS to keep large pages tractable.
12
+
13
+ import { PNG } from "pngjs";
14
+ import pixelmatch from "pixelmatch";
15
+ import { saveScreenshot, captureElement } from "../util/screenshot.js";
16
+
17
+ const MAX_TAB_STOPS = 200;
18
+ const FOCUS_PIXEL_DIFF_THRESHOLD = 25; // changed pixels needed to call focus "visible"
19
+
20
+ export async function runKeyboard(ctx) {
21
+ const { page } = ctx;
22
+ const findings = [];
23
+
24
+ // ── 2.1.4 Character key shortcuts ────────────────────────────────────
25
+ const singleKeyListeners = await page.evaluate(() => {
26
+ // Heuristic: look for window/document keydown listeners. We can't read
27
+ // installed listeners, but we can scan inline scripts for hotkey
28
+ // libraries (mousetrap, hotkeys-js, tinykeys) and bare event handlers.
29
+ const scripts = Array.from(document.scripts).map((s) => s.textContent || "").join("\n");
30
+ const hits = [];
31
+ const patterns = [
32
+ [/Mousetrap\.bind\(['"`]([a-z0-9])['"`]/gi, "Mousetrap.bind('$1')"],
33
+ [/hotkeys\(['"`]([a-z0-9])['"`]/gi, "hotkeys('$1')"],
34
+ [/tinykeys\([^,]+,\s*\{\s*['"`]([a-z0-9])['"`]/gi, "tinykeys {'$1'}"],
35
+ [/addEventListener\(['"`]keydown['"`][^)]*key\s*[=]==?\s*['"`]([a-z0-9])['"`]/gi, "keydown key=='$1'"],
36
+ ];
37
+ for (const [re, label] of patterns) {
38
+ let m;
39
+ while ((m = re.exec(scripts))) hits.push(`${label.replace("$1", m[1])}`);
40
+ }
41
+ return Array.from(new Set(hits)).slice(0, 20);
42
+ });
43
+ if (singleKeyListeners.length) {
44
+ findings.push({
45
+ source: "playwright",
46
+ ruleId: "kbd/single-key-shortcut",
47
+ criteria: "2.1.4",
48
+ level: "A",
49
+ impact: "moderate",
50
+ description: "Single-character keyboard shortcut(s) detected. WCAG 2.1.4 requires that such shortcuts be turn-off-able, remappable, or only active on focus.",
51
+ help: "Detected handlers: " + singleKeyListeners.join(", "),
52
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/character-key-shortcuts.html",
53
+ selector: "",
54
+ evidence: singleKeyListeners.join("\n"),
55
+ failureSummary: "Confirm shortcuts can be disabled or are only active when component has focus.",
56
+ screenshotFile: null,
57
+ });
58
+ }
59
+
60
+ // ── Tab walk for 2.1.1 / 2.1.2 / 2.4.3 / 2.4.7 / 2.4.11 ─────────────
61
+ await page.evaluate(() => {
62
+ if (document.activeElement && document.activeElement !== document.body) {
63
+ document.activeElement.blur();
64
+ }
65
+ window.scrollTo(0, 0);
66
+ });
67
+ await page.waitForTimeout(100);
68
+
69
+ // Get initial reference screenshot of the empty-focus state for diffing.
70
+ const baselineShot = await page.screenshot({ fullPage: false, type: "png" });
71
+ const baselinePng = PNG.sync.read(baselineShot);
72
+
73
+ const stops = [];
74
+ let prevSelector = null;
75
+ let trapDetected = false;
76
+
77
+ for (let i = 0; i < MAX_TAB_STOPS; i++) {
78
+ await page.keyboard.press("Tab");
79
+ await page.waitForTimeout(30);
80
+
81
+ const info = await page.evaluate(() => {
82
+ const el = document.activeElement;
83
+ if (!el || el === document.body || el === document.documentElement) return null;
84
+ function cssPath(node) {
85
+ if (!(node instanceof Element)) return "";
86
+ const path = [];
87
+ while (node && node.nodeType === Node.ELEMENT_NODE && path.length < 6) {
88
+ let sel = node.nodeName.toLowerCase();
89
+ if (node.id) { sel += "#" + node.id; path.unshift(sel); break; }
90
+ let sib = node, nth = 1;
91
+ while ((sib = sib.previousElementSibling)) if (sib.nodeName === node.nodeName) nth++;
92
+ if (nth !== 1) sel += `:nth-of-type(${nth})`;
93
+ path.unshift(sel);
94
+ node = node.parentElement;
95
+ }
96
+ return path.join(" > ");
97
+ }
98
+ const r = el.getBoundingClientRect();
99
+ return {
100
+ tag: el.tagName.toLowerCase(),
101
+ text: (el.innerText || el.value || el.getAttribute("aria-label") || "").trim().slice(0, 80),
102
+ selector: cssPath(el),
103
+ rect: { x: r.x, y: r.y, w: r.width, h: r.height },
104
+ outerHTML: el.outerHTML.slice(0, 200),
105
+ };
106
+ });
107
+
108
+ if (!info) break;
109
+ if (info.selector === prevSelector) {
110
+ // Same element after a Tab — likely a trap or end of doc
111
+ trapDetected = true;
112
+ break;
113
+ }
114
+ stops.push(info);
115
+ prevSelector = info.selector;
116
+
117
+ // Check if we've cycled back to the first element (normal end of tab order)
118
+ if (stops.length > 1 && info.selector === stops[0].selector) {
119
+ stops.pop();
120
+ break;
121
+ }
122
+ }
123
+
124
+ // ── 2.1.2 Keyboard trap ─────────────────────────────────────────────
125
+ if (trapDetected) {
126
+ findings.push({
127
+ source: "playwright",
128
+ ruleId: "kbd/trap",
129
+ criteria: "2.1.2",
130
+ level: "A",
131
+ impact: "critical",
132
+ description: "Suspected keyboard trap: pressing Tab from a focused control did not advance focus.",
133
+ help: "Verify the user can leave this region using Tab/Shift+Tab. WCAG 2.1.2 forbids any region that requires non-standard keys to escape.",
134
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/no-keyboard-trap.html",
135
+ selector: prevSelector || "",
136
+ evidence: prevSelector || "",
137
+ failureSummary: "Tab key did not move focus.",
138
+ screenshotFile: null,
139
+ });
140
+ }
141
+
142
+ // ── 2.1.1 Keyboard ─ if no Tab stops found, the page is unusable by kbd
143
+ if (stops.length === 0) {
144
+ findings.push({
145
+ source: "playwright",
146
+ ruleId: "kbd/no-focusable",
147
+ criteria: "2.1.1",
148
+ level: "A",
149
+ impact: "serious",
150
+ description: "No focusable elements were reached via the Tab key.",
151
+ help: "Either the page has no interactive content, or interactive controls are not keyboard reachable.",
152
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html",
153
+ selector: "",
154
+ evidence: "",
155
+ failureSummary: "Tab walk reached zero stops.",
156
+ screenshotFile: null,
157
+ });
158
+ }
159
+
160
+ // ── 2.4.3 Focus order vs visual order ───────────────────────────────
161
+ // Sort stops by visual position (top-to-bottom, left-to-right with row tolerance)
162
+ // and compare to actual Tab order. Mismatches → fail.
163
+ const visualOrder = [...stops]
164
+ .map((s, i) => ({ s, i }))
165
+ .sort((a, b) => {
166
+ const ay = a.s.rect.y, by = b.s.rect.y;
167
+ if (Math.abs(ay - by) > 20) return ay - by;
168
+ return a.s.rect.x - b.s.rect.x;
169
+ })
170
+ .map((x) => x.i);
171
+ const expected = stops.map((_, i) => i);
172
+ const mismatches = [];
173
+ for (let i = 0; i < expected.length; i++) {
174
+ if (visualOrder[i] !== expected[i]) {
175
+ mismatches.push({ tabPosition: i + 1, expectedSelector: stops[visualOrder[i]]?.selector, actualSelector: stops[i].selector });
176
+ }
177
+ }
178
+ // Only fail if there are several misorderings (a single one near a fixed-pos
179
+ // element is usually fine). Threshold: more than 15% of stops out of order.
180
+ if (mismatches.length > Math.max(3, stops.length * 0.15)) {
181
+ findings.push({
182
+ source: "playwright",
183
+ ruleId: "kbd/focus-order",
184
+ criteria: "2.4.3",
185
+ level: "A",
186
+ impact: "serious",
187
+ description: `Tab order does not match visual reading order at ${mismatches.length} of ${stops.length} stops.`,
188
+ help: "Tab order should follow the visible reading sequence. Reorder DOM or use tabindex carefully (avoid positive tabindex values).",
189
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html",
190
+ selector: mismatches.slice(0, 5).map((m) => m.actualSelector).join(", "),
191
+ evidence: JSON.stringify(mismatches.slice(0, 10), null, 2),
192
+ failureSummary: `${mismatches.length} stops out of visual order`,
193
+ screenshotFile: null,
194
+ });
195
+ }
196
+
197
+ // ── 2.4.7 Focus Visible ─ pixel diff each focused stop against baseline
198
+ // Sample only the first ~30 stops to keep things fast.
199
+ const focusFails = [];
200
+ const sampled = stops.slice(0, 30);
201
+ for (let i = 0; i < sampled.length; i++) {
202
+ const stop = sampled[i];
203
+ // Re-focus by clicking via JS (avoid triggering navigation by using focus())
204
+ await page.evaluate((sel) => {
205
+ try { document.querySelector(sel)?.focus({ preventScroll: false }); } catch (_) {}
206
+ }, stop.selector);
207
+ await page.waitForTimeout(40);
208
+
209
+ const focusedShot = await page.screenshot({ fullPage: false, type: "png" });
210
+ const focusedPng = PNG.sync.read(focusedShot);
211
+ if (focusedPng.width !== baselinePng.width || focusedPng.height !== baselinePng.height) continue;
212
+ const diff = new PNG({ width: focusedPng.width, height: focusedPng.height });
213
+ const changed = pixelmatch(
214
+ baselinePng.data,
215
+ focusedPng.data,
216
+ diff.data,
217
+ focusedPng.width,
218
+ focusedPng.height,
219
+ { threshold: 0.25 }
220
+ );
221
+ if (changed < FOCUS_PIXEL_DIFF_THRESHOLD) {
222
+ focusFails.push(stop);
223
+ }
224
+ }
225
+ if (focusFails.length) {
226
+ for (const f of focusFails.slice(0, 10)) {
227
+ let screenshotFile = null;
228
+ try {
229
+ const buf = await captureElement(ctx.page, ctx.page.locator(f.selector).first(), {
230
+ label: "Focus invisible",
231
+ });
232
+ screenshotFile = await saveScreenshot(buf, ctx.screenshotsDir, "kbd_focus_visible");
233
+ } catch (_) {}
234
+ findings.push({
235
+ source: "playwright",
236
+ ruleId: "kbd/focus-visible",
237
+ criteria: "2.4.7",
238
+ level: "AA",
239
+ impact: "serious",
240
+ description: "No visible focus indicator detected when this control received keyboard focus.",
241
+ help: "Ensure :focus or :focus-visible styles produce a clearly visible outline / change. Default browser outline must not be removed without a replacement.",
242
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html",
243
+ selector: f.selector,
244
+ evidence: f.outerHTML,
245
+ failureSummary: "Pixel diff between unfocused and focused state was below threshold.",
246
+ screenshotFile,
247
+ });
248
+ }
249
+ }
250
+
251
+ // ── 2.4.11 / 2.4.12 Focus Not Obscured ───────────────────────────────
252
+ // Find sticky/fixed-position elements and check whether the focused element
253
+ // intersects them while being below the sticky band.
254
+ //
255
+ // Skip cases that are NOT actually obscured:
256
+ // 1. The focused element is INSIDE the sticky element (e.g. nav links
257
+ // inside a sticky nav) — they overlap geometrically but the user
258
+ // can still see/interact with them.
259
+ // 2. The focused element is more than 95% covered AND the sticky is
260
+ // small (< 10% of viewport) — likely a toast or banner that
261
+ // should get its own fix, not a layout violation.
262
+ const stickyRects = await page.evaluate(() => {
263
+ const out = [];
264
+ document.querySelectorAll("*").forEach((el, idx) => {
265
+ const cs = getComputedStyle(el);
266
+ if ((cs.position === "fixed" || cs.position === "sticky") && cs.visibility !== "hidden" && cs.display !== "none") {
267
+ const r = el.getBoundingClientRect();
268
+ if (r.width > 0 && r.height > 0) {
269
+ // Tag the element so the containment check can find it again
270
+ const marker = `data-wcag-sticky-${idx}`;
271
+ el.setAttribute(marker, "1");
272
+ out.push({
273
+ x: r.x,
274
+ y: r.y,
275
+ w: r.width,
276
+ h: r.height,
277
+ tag: el.tagName.toLowerCase(),
278
+ marker,
279
+ });
280
+ }
281
+ }
282
+ });
283
+ return out.slice(0, 30);
284
+ });
285
+
286
+ const obscured = [];
287
+ for (const stop of sampled) {
288
+ for (const sr of stickyRects) {
289
+ const sx1 = sr.x, sy1 = sr.y, sx2 = sr.x + sr.w, sy2 = sr.y + sr.h;
290
+ const fx1 = stop.rect.x, fy1 = stop.rect.y, fx2 = stop.rect.x + stop.rect.w, fy2 = stop.rect.y + stop.rect.h;
291
+ const overlapX = Math.max(0, Math.min(sx2, fx2) - Math.max(sx1, fx1));
292
+ const overlapY = Math.max(0, Math.min(sy2, fy2) - Math.max(sy1, fy1));
293
+ const overlap = overlapX * overlapY;
294
+ const focusedArea = stop.rect.w * stop.rect.h;
295
+ // WCAG 2.4.11 says the focused element must not be ENTIRELY hidden.
296
+ // Allow up to 75% overlap before flagging — partial obscuring is
297
+ // acceptable per the spec.
298
+ if (focusedArea > 0 && overlap / focusedArea > 0.75) {
299
+ // Skip if the focused element is a descendant of the sticky
300
+ // element. Example: nav logo focused inside a sticky nav.
301
+ const isDescendant = await page.evaluate(
302
+ ({ sel, marker }) => {
303
+ try {
304
+ const sticky = document.querySelector(`[${marker}]`);
305
+ const focused = sel ? document.querySelector(sel) : null;
306
+ if (!sticky || !focused) return false;
307
+ return sticky.contains(focused);
308
+ } catch {
309
+ return false;
310
+ }
311
+ },
312
+ { sel: stop.selector, marker: sr.marker },
313
+ );
314
+ if (isDescendant) continue;
315
+ obscured.push({ stop, sticky: sr });
316
+ break;
317
+ }
318
+ }
319
+ }
320
+
321
+ // Clean up the marker attributes we added
322
+ await page.evaluate(() => {
323
+ document.querySelectorAll("[data-wcag-sticky-0],[data-wcag-sticky-1],[data-wcag-sticky-2],[data-wcag-sticky-3],[data-wcag-sticky-4],[data-wcag-sticky-5],[data-wcag-sticky-6],[data-wcag-sticky-7],[data-wcag-sticky-8],[data-wcag-sticky-9]").forEach((el) => {
324
+ [...el.attributes].forEach((a) => {
325
+ if (a.name.startsWith("data-wcag-sticky-")) el.removeAttribute(a.name);
326
+ });
327
+ });
328
+ }).catch(() => {});
329
+ for (const o of obscured.slice(0, 10)) {
330
+ findings.push({
331
+ source: "playwright",
332
+ ruleId: "kbd/focus-not-obscured",
333
+ criteria: "2.4.11",
334
+ level: "AA",
335
+ impact: "serious",
336
+ description: `Focused element is more than 50% obscured by a sticky/fixed element (${o.sticky.tag}).`,
337
+ help: "When a control receives keyboard focus, it must not be entirely hidden behind sticky headers, cookie banners, or other fixed UI.",
338
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html",
339
+ selector: o.stop.selector,
340
+ evidence: `Focused: ${JSON.stringify(o.stop.rect)} sticky: ${JSON.stringify(o.sticky)}`,
341
+ failureSummary: "Focused element overlapped sticky element by >50%.",
342
+ screenshotFile: null,
343
+ });
344
+ }
345
+
346
+ return { findings };
347
+ }
@@ -0,0 +1,102 @@
1
+ // Media presence checker. Covers (substantially):
2
+ //
3
+ // 1.2.1 Audio-only / Video-only — detects <audio>/<video>; transcript
4
+ // accuracy is human-only
5
+ // 1.2.2 Captions (Prerecorded) — checks <video> for <track kind=captions>
6
+ //
7
+ // We do NOT pretend to verify caption accuracy or audio description quality.
8
+ // Those criteria are flagged as "needs human review" with a screenshot of
9
+ // the media element so the human reviewer has a starting point.
10
+
11
+ import { captureElement, saveScreenshot } from "../util/screenshot.js";
12
+
13
+ export async function runMedia(ctx) {
14
+ const { page } = ctx;
15
+ const findings = [];
16
+
17
+ const media = await page.evaluate(() => {
18
+ const out = { videos: [], audios: [], iframes: [] };
19
+ document.querySelectorAll("video").forEach((v, i) => {
20
+ out.videos.push({
21
+ idx: i,
22
+ src: v.currentSrc || v.src || "",
23
+ hasCaptionTrack: !!v.querySelector("track[kind='captions']"),
24
+ hasSubtitleTrack: !!v.querySelector("track[kind='subtitles']"),
25
+ hasDescTrack: !!v.querySelector("track[kind='descriptions']"),
26
+ autoplay: v.autoplay,
27
+ muted: v.muted,
28
+ });
29
+ });
30
+ document.querySelectorAll("audio").forEach((a, i) => {
31
+ out.audios.push({
32
+ idx: i,
33
+ src: a.currentSrc || a.src || "",
34
+ autoplay: a.autoplay,
35
+ });
36
+ });
37
+ // Embedded YouTube/Vimeo iframes — we can't introspect their captions
38
+ // from outside the iframe, so we surface them for human review.
39
+ document.querySelectorAll("iframe[src*='youtube'],iframe[src*='vimeo'],iframe[src*='wistia']").forEach((f, i) => {
40
+ out.iframes.push({ idx: i, src: f.src });
41
+ });
42
+ return out;
43
+ });
44
+
45
+ for (let i = 0; i < media.videos.length; i++) {
46
+ const v = media.videos[i];
47
+ if (!v.hasCaptionTrack && !v.hasSubtitleTrack) {
48
+ const buf = await captureElement(page, page.locator("video").nth(i), { label: "Video w/o captions" });
49
+ const screenshotFile = await saveScreenshot(buf, ctx.screenshotsDir, "media_no_captions");
50
+ findings.push({
51
+ source: "playwright",
52
+ ruleId: "media/no-captions",
53
+ criteria: "1.2.2",
54
+ level: "A",
55
+ impact: "serious",
56
+ description: "Video element has no <track kind='captions'> or <track kind='subtitles'>.",
57
+ help: "Provide a captions track for any prerecorded video with audio. WCAG 1.2.2.",
58
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/captions-prerecorded.html",
59
+ selector: `video:nth-of-type(${i + 1})`,
60
+ evidence: v.src || "(no src)",
61
+ failureSummary: "Missing captions track",
62
+ screenshotFile,
63
+ });
64
+ }
65
+ if (!v.hasDescTrack) {
66
+ findings.push({
67
+ source: "playwright",
68
+ ruleId: "media/no-audio-description",
69
+ criteria: "1.2.5",
70
+ level: "AA",
71
+ impact: "moderate",
72
+ description: "Video element has no <track kind='descriptions'> for audio description (and no human review confirms a media alternative is provided).",
73
+ help: "Provide audio description for video with significant visual content. WCAG 1.2.5. Mark as 'reviewed - not applicable' if a separate descriptive transcript or media alternative is offered.",
74
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/audio-description-prerecorded.html",
75
+ selector: `video:nth-of-type(${i + 1})`,
76
+ evidence: v.src || "(no src)",
77
+ failureSummary: "No descriptions track — needs human review",
78
+ screenshotFile: null,
79
+ });
80
+ }
81
+ }
82
+
83
+ // Embedded video iframes: surface for human review.
84
+ for (const iframe of media.iframes) {
85
+ findings.push({
86
+ source: "playwright",
87
+ ruleId: "media/embedded-iframe",
88
+ criteria: "1.2.2",
89
+ level: "A",
90
+ impact: "moderate",
91
+ description: "Embedded media iframe (YouTube/Vimeo/Wistia). Caption presence cannot be verified from outside the iframe.",
92
+ help: "Confirm captions are enabled in the embedded player. Most providers support a captions track URL parameter (e.g. YouTube cc_load_policy=1).",
93
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/captions-prerecorded.html",
94
+ selector: `iframe[src*='${iframe.src.split("/")[2] || ""}']`,
95
+ evidence: iframe.src,
96
+ failureSummary: "Embedded video — needs human review",
97
+ screenshotFile: null,
98
+ });
99
+ }
100
+
101
+ return { findings };
102
+ }
@@ -0,0 +1,155 @@
1
+ // Motion / animation / autoplay checker. Covers:
2
+ //
3
+ // 2.2.2 Pause, Stop, Hide — autoplay video, marquee, animations
4
+ // 2.3.1 Three Flashes (or below) — pixel-diff a 5s screenshot timelapse
5
+ // 2.3.3 Animation from Interactions — partial: detect prefers-reduced-motion handling
6
+ //
7
+ // Flash detection method: capture ~30 screenshots at 6 fps, compute the
8
+ // fraction of pixels that changed between consecutive frames using
9
+ // pixelmatch. A flash is a frame where >10% of pixels change. >3 flashes
10
+ // in any 1-second window = fail.
11
+
12
+ import { PNG } from "pngjs";
13
+ import pixelmatch from "pixelmatch";
14
+ import { saveScreenshot } from "../util/screenshot.js";
15
+
16
+ const FRAMES = 30;
17
+ const FRAME_INTERVAL_MS = 167; // ~6 fps → ~5s total
18
+ const FLASH_PIXEL_FRACTION = 0.10; // 10% of pixels changed = 1 flash
19
+
20
+ export async function runMotion(ctx) {
21
+ const { page } = ctx;
22
+ const findings = [];
23
+
24
+ // ── 2.2.2 Autoplay video / animations ───────────────────────────────
25
+ const motionInfo = await page.evaluate(() => {
26
+ const out = { autoplayVideos: [], marquee: [], blink: [], animatedSvg: 0, cssAnimations: 0 };
27
+ document.querySelectorAll("video[autoplay]").forEach((v) => {
28
+ if (v.paused === false || v.autoplay) {
29
+ out.autoplayVideos.push({ src: v.currentSrc || v.src, muted: v.muted, loop: v.loop });
30
+ }
31
+ });
32
+ document.querySelectorAll("marquee").forEach((m) => out.marquee.push(m.outerHTML.slice(0, 200)));
33
+ document.querySelectorAll("blink").forEach((b) => out.blink.push(b.outerHTML.slice(0, 200)));
34
+ document.querySelectorAll("svg animate, svg animateTransform, svg animateMotion").forEach(() => out.animatedSvg++);
35
+ document.querySelectorAll("*").forEach((el) => {
36
+ const cs = getComputedStyle(el);
37
+ if (cs.animationName && cs.animationName !== "none" && cs.animationDuration !== "0s") out.cssAnimations++;
38
+ });
39
+ return out;
40
+ });
41
+
42
+ if (motionInfo.autoplayVideos.length) {
43
+ findings.push({
44
+ source: "playwright",
45
+ ruleId: "motion/autoplay-video",
46
+ criteria: "2.2.2",
47
+ level: "A",
48
+ impact: "serious",
49
+ description: `${motionInfo.autoplayVideos.length} autoplay video element(s) detected.`,
50
+ help: "WCAG 2.2.2: any moving content that starts automatically and lasts more than 5 seconds must be pausable. Consider muted+looped decorative video only, with a pause control.",
51
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html",
52
+ selector: "video[autoplay]",
53
+ evidence: JSON.stringify(motionInfo.autoplayVideos.slice(0, 5), null, 2),
54
+ failureSummary: `${motionInfo.autoplayVideos.length} autoplay video(s)`,
55
+ screenshotFile: null,
56
+ });
57
+ }
58
+ if (motionInfo.marquee.length || motionInfo.blink.length) {
59
+ findings.push({
60
+ source: "playwright",
61
+ ruleId: "motion/deprecated-marquee-blink",
62
+ criteria: "2.2.2",
63
+ level: "A",
64
+ impact: "moderate",
65
+ description: `Deprecated <marquee>/<blink> elements detected. These cannot be paused via standard mechanisms.`,
66
+ help: "Replace with CSS or remove. WCAG 2.2.2.",
67
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html",
68
+ selector: motionInfo.marquee.length ? "marquee" : "blink",
69
+ evidence: [...motionInfo.marquee, ...motionInfo.blink].join("\n"),
70
+ failureSummary: "Deprecated motion element",
71
+ screenshotFile: null,
72
+ });
73
+ }
74
+
75
+ // ── 2.3.1 Flash detection via timelapse ─────────────────────────────
76
+ const frames = [];
77
+ for (let i = 0; i < FRAMES; i++) {
78
+ const buf = await page.screenshot({ fullPage: false, type: "png" });
79
+ frames.push(PNG.sync.read(buf));
80
+ await page.waitForTimeout(FRAME_INTERVAL_MS);
81
+ }
82
+
83
+ let totalFlashes = 0;
84
+ const flashTimestamps = [];
85
+ for (let i = 1; i < frames.length; i++) {
86
+ const a = frames[i - 1], b = frames[i];
87
+ if (a.width !== b.width || a.height !== b.height) continue;
88
+ const total = a.width * a.height;
89
+ const diff = new PNG({ width: a.width, height: a.height });
90
+ const changed = pixelmatch(a.data, b.data, diff.data, a.width, a.height, { threshold: 0.3 });
91
+ if (changed / total > FLASH_PIXEL_FRACTION) {
92
+ totalFlashes++;
93
+ flashTimestamps.push(i * FRAME_INTERVAL_MS);
94
+ }
95
+ }
96
+
97
+ // Look for any 1-second window with > 3 flashes
98
+ let maxIn1s = 0;
99
+ for (const t of flashTimestamps) {
100
+ const inWindow = flashTimestamps.filter((x) => x >= t && x < t + 1000).length;
101
+ if (inWindow > maxIn1s) maxIn1s = inWindow;
102
+ }
103
+
104
+ if (maxIn1s > 3) {
105
+ findings.push({
106
+ source: "playwright",
107
+ ruleId: "motion/flashing",
108
+ criteria: "2.3.1",
109
+ level: "A",
110
+ impact: "critical",
111
+ description: `Detected ${maxIn1s} large screen changes within a 1-second window during a 5-second observation. WCAG 2.3.1 forbids more than 3 flashes per second.`,
112
+ help: "Reduce flashing animations. Use prefers-reduced-motion to disable. Consider seizure-safety carefully.",
113
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/three-flashes-or-below-threshold.html",
114
+ selector: "",
115
+ evidence: `Total large frame transitions: ${totalFlashes}; max in 1s window: ${maxIn1s}`,
116
+ failureSummary: "Possible photosensitive seizure risk",
117
+ screenshotFile: null,
118
+ });
119
+ }
120
+
121
+ // ── 2.3.3 Animation from interactions ─ partial check ───────────────
122
+ // Detect whether the site has any prefers-reduced-motion media query at
123
+ // all. If it has many CSS animations but no reduced-motion handling, flag.
124
+ const hasReducedMotionMQ = await page.evaluate(() => {
125
+ let found = false;
126
+ for (const sheet of Array.from(document.styleSheets)) {
127
+ try {
128
+ for (const rule of Array.from(sheet.cssRules || [])) {
129
+ if (rule.type === CSSRule.MEDIA_RULE && rule.conditionText.includes("prefers-reduced-motion")) {
130
+ found = true;
131
+ }
132
+ }
133
+ } catch (_) {}
134
+ }
135
+ return found;
136
+ });
137
+ if (motionInfo.cssAnimations > 5 && !hasReducedMotionMQ) {
138
+ findings.push({
139
+ source: "playwright",
140
+ ruleId: "motion/no-reduced-motion-mq",
141
+ criteria: "2.3.3",
142
+ level: "AAA",
143
+ impact: "moderate",
144
+ description: `${motionInfo.cssAnimations} CSS animations detected but no @media (prefers-reduced-motion) rule found in any stylesheet.`,
145
+ help: "Honor the user's reduced-motion preference. Wrap non-essential animations in @media (prefers-reduced-motion: no-preference) or set animation-duration: 0 inside (prefers-reduced-motion: reduce).",
146
+ helpUrl: "https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html",
147
+ selector: "",
148
+ evidence: `CSS animations: ${motionInfo.cssAnimations}`,
149
+ failureSummary: "No reduced-motion media query",
150
+ screenshotFile: null,
151
+ });
152
+ }
153
+
154
+ return { findings };
155
+ }