@wcag-audit/cli 1.0.0-alpha.11

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 (79) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +110 -0
  3. package/package.json +73 -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 +111 -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 +351 -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/util/consistency-match.js +53 -0
  22. package/src/checkers/util/consistency-match.test.js +54 -0
  23. package/src/checkers/viewport.js +214 -0
  24. package/src/cli.js +169 -0
  25. package/src/commands/ci.js +63 -0
  26. package/src/commands/ci.test.js +55 -0
  27. package/src/commands/doctor.js +105 -0
  28. package/src/commands/doctor.test.js +81 -0
  29. package/src/commands/init.js +162 -0
  30. package/src/commands/init.test.js +83 -0
  31. package/src/commands/scan.js +362 -0
  32. package/src/commands/scan.test.js +139 -0
  33. package/src/commands/watch.js +89 -0
  34. package/src/config/global.js +60 -0
  35. package/src/config/global.test.js +58 -0
  36. package/src/config/project.js +35 -0
  37. package/src/config/project.test.js +44 -0
  38. package/src/devserver/spawn.js +82 -0
  39. package/src/devserver/spawn.test.js +58 -0
  40. package/src/discovery/astro.js +86 -0
  41. package/src/discovery/astro.test.js +76 -0
  42. package/src/discovery/crawl.js +93 -0
  43. package/src/discovery/crawl.test.js +93 -0
  44. package/src/discovery/dynamic-samples.js +44 -0
  45. package/src/discovery/dynamic-samples.test.js +66 -0
  46. package/src/discovery/manual.js +38 -0
  47. package/src/discovery/manual.test.js +52 -0
  48. package/src/discovery/nextjs.js +136 -0
  49. package/src/discovery/nextjs.test.js +141 -0
  50. package/src/discovery/registry.js +80 -0
  51. package/src/discovery/registry.test.js +33 -0
  52. package/src/discovery/remix.js +82 -0
  53. package/src/discovery/remix.test.js +77 -0
  54. package/src/discovery/sitemap.js +73 -0
  55. package/src/discovery/sitemap.test.js +69 -0
  56. package/src/discovery/sveltekit.js +85 -0
  57. package/src/discovery/sveltekit.test.js +76 -0
  58. package/src/discovery/vite.js +94 -0
  59. package/src/discovery/vite.test.js +144 -0
  60. package/src/license/log-usage.js +23 -0
  61. package/src/license/log-usage.test.js +45 -0
  62. package/src/license/request-free.js +46 -0
  63. package/src/license/request-free.test.js +57 -0
  64. package/src/license/validate.js +58 -0
  65. package/src/license/validate.test.js +58 -0
  66. package/src/output/agents-md.js +58 -0
  67. package/src/output/agents-md.test.js +62 -0
  68. package/src/output/cursor-rules.js +57 -0
  69. package/src/output/cursor-rules.test.js +62 -0
  70. package/src/output/excel-project.js +263 -0
  71. package/src/output/excel-project.test.js +165 -0
  72. package/src/output/markdown.js +119 -0
  73. package/src/output/markdown.test.js +95 -0
  74. package/src/report.js +239 -0
  75. package/src/util/anthropic.js +25 -0
  76. package/src/util/llm.js +159 -0
  77. package/src/util/screenshot.js +131 -0
  78. package/src/wcag-criteria.js +256 -0
  79. package/src/wcag-manual-steps.js +114 -0
@@ -0,0 +1,159 @@
1
+ // Server-side mirror of the extension's llm-client.js. Same interface,
2
+ // same provider catalogue, same call shape. Pure fetch — no SDKs — so
3
+ // the same prompts work identically in browser and Node.
4
+ //
5
+ // await callLlmJsonArray({
6
+ // provider, apiKey, model, prompt,
7
+ // images: [{ buffer, mimeType }], maxTokens
8
+ // })
9
+ // → { array, usage: { input_tokens, output_tokens }, raw }
10
+
11
+ export const PROVIDERS = {
12
+ anthropic: {
13
+ label: "Anthropic Claude",
14
+ envKey: "ANTHROPIC_API_KEY",
15
+ defaultModel: "claude-sonnet-4-6",
16
+ },
17
+ openai: {
18
+ label: "OpenAI",
19
+ envKey: "OPENAI_API_KEY",
20
+ defaultModel: "gpt-4.1-mini",
21
+ },
22
+ google: {
23
+ label: "Google Gemini",
24
+ envKey: "GOOGLE_API_KEY",
25
+ defaultModel: "gemini-2.5-flash",
26
+ },
27
+ };
28
+
29
+ function toBase64(image) {
30
+ if (!image) return "";
31
+ if (typeof image.base64 === "string") return image.base64;
32
+ if (Buffer.isBuffer(image.buffer)) return image.buffer.toString("base64");
33
+ if (image.buffer) return Buffer.from(image.buffer).toString("base64");
34
+ return "";
35
+ }
36
+
37
+ // ─── Anthropic ──────────────────────────────────────────────────────────
38
+ async function callAnthropic({ apiKey, model, prompt, images, maxTokens }) {
39
+ const content = [];
40
+ for (const img of images || []) {
41
+ content.push({
42
+ type: "image",
43
+ source: { type: "base64", media_type: img.mimeType || "image/png", data: toBase64(img) },
44
+ });
45
+ }
46
+ content.push({ type: "text", text: prompt });
47
+
48
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
49
+ method: "POST",
50
+ headers: {
51
+ "content-type": "application/json",
52
+ "x-api-key": apiKey,
53
+ "anthropic-version": "2023-06-01",
54
+ },
55
+ body: JSON.stringify({
56
+ model,
57
+ max_tokens: maxTokens || 4096,
58
+ messages: [{ role: "user", content }],
59
+ }),
60
+ });
61
+ if (!res.ok) {
62
+ const txt = await res.text();
63
+ throw new Error(`Anthropic API ${res.status}: ${txt.slice(0, 400)}`);
64
+ }
65
+ const data = await res.json();
66
+ const text = (data.content || []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
67
+ return {
68
+ text,
69
+ usage: { input_tokens: data.usage?.input_tokens || 0, output_tokens: data.usage?.output_tokens || 0 },
70
+ };
71
+ }
72
+
73
+ // ─── OpenAI ─────────────────────────────────────────────────────────────
74
+ async function callOpenAi({ apiKey, model, prompt, images, maxTokens }) {
75
+ const content = [];
76
+ for (const img of images || []) {
77
+ const b64 = toBase64(img);
78
+ content.push({
79
+ type: "image_url",
80
+ image_url: { url: `data:${img.mimeType || "image/png"};base64,${b64}` },
81
+ });
82
+ }
83
+ content.push({ type: "text", text: prompt });
84
+
85
+ const res = await fetch("https://api.openai.com/v1/chat/completions", {
86
+ method: "POST",
87
+ headers: { "content-type": "application/json", authorization: "Bearer " + apiKey },
88
+ body: JSON.stringify({
89
+ model,
90
+ max_tokens: maxTokens || 4096,
91
+ messages: [{ role: "user", content }],
92
+ }),
93
+ });
94
+ if (!res.ok) {
95
+ const txt = await res.text();
96
+ throw new Error(`OpenAI API ${res.status}: ${txt.slice(0, 400)}`);
97
+ }
98
+ const data = await res.json();
99
+ const text = data.choices?.[0]?.message?.content || "";
100
+ return {
101
+ text,
102
+ usage: { input_tokens: data.usage?.prompt_tokens || 0, output_tokens: data.usage?.completion_tokens || 0 },
103
+ };
104
+ }
105
+
106
+ // ─── Google Gemini ──────────────────────────────────────────────────────
107
+ async function callGoogle({ apiKey, model, prompt, images, maxTokens }) {
108
+ const parts = [];
109
+ for (const img of images || []) {
110
+ parts.push({ inline_data: { mime_type: img.mimeType || "image/png", data: toBase64(img) } });
111
+ }
112
+ parts.push({ text: prompt });
113
+
114
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`;
115
+ const res = await fetch(url, {
116
+ method: "POST",
117
+ headers: { "content-type": "application/json" },
118
+ body: JSON.stringify({
119
+ contents: [{ role: "user", parts }],
120
+ generationConfig: { maxOutputTokens: maxTokens || 4096 },
121
+ }),
122
+ });
123
+ if (!res.ok) {
124
+ const txt = await res.text();
125
+ throw new Error(`Google API ${res.status}: ${txt.slice(0, 400)}`);
126
+ }
127
+ const data = await res.json();
128
+ const text = (data.candidates?.[0]?.content?.parts || []).map((p) => p.text || "").join("\n");
129
+ return {
130
+ text,
131
+ usage: {
132
+ input_tokens: data.usageMetadata?.promptTokenCount || 0,
133
+ output_tokens: data.usageMetadata?.candidatesTokenCount || 0,
134
+ },
135
+ };
136
+ }
137
+
138
+ // ─── Dispatcher ─────────────────────────────────────────────────────────
139
+ export async function callLlm(opts) {
140
+ const provider = (opts.provider || "anthropic").toLowerCase();
141
+ if (provider === "anthropic") return callAnthropic(opts);
142
+ if (provider === "openai") return callOpenAi(opts);
143
+ if (provider === "google") return callGoogle(opts);
144
+ throw new Error(`Unknown LLM provider: ${provider}`);
145
+ }
146
+
147
+ // JSON-array wrapper. Strips markdown fences and extracts the first
148
+ // [...] block. Same contract as the extension's WCAG_LLM.callJsonArray.
149
+ export async function callLlmJsonArray(opts) {
150
+ const { text, usage } = await callLlm(opts);
151
+ let t = (text || "").trim();
152
+ t = t.replace(/^```json\s*/i, "").replace(/^```\s*/i, "").replace(/```\s*$/i, "").trim();
153
+ const start = t.indexOf("[");
154
+ const end = t.lastIndexOf("]");
155
+ if (start === -1 || end === -1) {
156
+ throw new Error("LLM response did not contain a JSON array. Raw: " + (text || "").slice(0, 300));
157
+ }
158
+ return { array: JSON.parse(t.slice(start, end + 1)), usage, raw: text };
159
+ }
@@ -0,0 +1,131 @@
1
+ // Screenshot helpers for the CLI. Captures cropped, annotated PNGs of
2
+ // problem elements and writes them to the configured screenshots dir so the
3
+ // report writer can embed them and reference them.
4
+
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+
8
+ let counter = 0;
9
+
10
+ export function nextScreenshotId() {
11
+ counter++;
12
+ return String(counter).padStart(4, "0");
13
+ }
14
+
15
+ export function safeFileSegment(s, max = 60) {
16
+ return (s || "")
17
+ .replace(/[^a-z0-9._-]+/gi, "_")
18
+ .replace(/^_+|_+$/g, "")
19
+ .slice(0, max) || "issue";
20
+ }
21
+
22
+ // Capture an annotated viewport screenshot of a single element. Mirrors
23
+ // the extension: scroll the element to the centre of the viewport, draw a
24
+ // bold red highlight overlay, capture the visible viewport (not clipped),
25
+ // then remove the overlay. The popup re-renders this onto its standard
26
+ // 1100x700 canvas so every embedded image in the report is identical.
27
+ export async function captureElement(page, locator, opts = {}) {
28
+ const { label = "" } = opts;
29
+ try {
30
+ const handle = await locator.elementHandle({ timeout: 2000 });
31
+ if (!handle) return null;
32
+ await handle.evaluate((el) => {
33
+ try { el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" }); } catch (_) {}
34
+ }).catch(() => {});
35
+ const box = await handle.boundingBox();
36
+ if (!box || box.width === 0 || box.height === 0) {
37
+ await handle.dispose();
38
+ return null;
39
+ }
40
+
41
+ // Bold red overlay matching the extension's content.js. Two layers of
42
+ // border + glow + a label with arrow so the highlight is unmissable
43
+ // even when the element fills most of the viewport.
44
+ await page.evaluate(
45
+ ({ x, y, w, h, label }) => {
46
+ const id = "__wcag_cli_overlay__";
47
+ const lid = "__wcag_cli_label__";
48
+ document.getElementById(id)?.remove();
49
+ document.getElementById(lid)?.remove();
50
+ const docX = x + window.scrollX;
51
+ const docY = y + window.scrollY;
52
+ const o = document.createElement("div");
53
+ o.id = id;
54
+ Object.assign(o.style, {
55
+ position: "absolute",
56
+ top: docY + "px",
57
+ left: docX + "px",
58
+ width: w + "px",
59
+ height: h + "px",
60
+ border: "5px solid #ff0033",
61
+ outline: "2px solid #ffffff",
62
+ boxShadow:
63
+ "0 0 0 4px rgba(255,255,255,0.95), " +
64
+ "0 0 0 8px rgba(255,0,51,0.6), " +
65
+ "0 0 24px 12px rgba(255,0,51,0.45)",
66
+ pointerEvents: "none",
67
+ zIndex: "2147483647",
68
+ boxSizing: "border-box",
69
+ borderRadius: "3px",
70
+ });
71
+ const t = document.createElement("div");
72
+ t.id = lid;
73
+ t.textContent = "▶ " + (label || "WCAG issue");
74
+ const labelTop = docY - 32;
75
+ const labelAbove = labelTop > 8;
76
+ Object.assign(t.style, {
77
+ position: "absolute",
78
+ top: (labelAbove ? labelTop : docY + h + 6) + "px",
79
+ left: docX + "px",
80
+ background: "#ff0033",
81
+ color: "#ffffff",
82
+ font: "bold 14px -apple-system, system-ui, sans-serif",
83
+ padding: "4px 10px",
84
+ borderRadius: "4px",
85
+ border: "2px solid #ffffff",
86
+ boxShadow: "0 2px 8px rgba(0,0,0,0.4)",
87
+ pointerEvents: "none",
88
+ zIndex: "2147483647",
89
+ whiteSpace: "nowrap",
90
+ maxWidth: "90vw",
91
+ overflow: "hidden",
92
+ textOverflow: "ellipsis",
93
+ });
94
+ document.documentElement.appendChild(o);
95
+ document.documentElement.appendChild(t);
96
+ },
97
+ { x: box.x, y: box.y, w: box.width, h: box.height, label }
98
+ );
99
+
100
+ // Tiny pause for the overlay to paint and any sticky/fixed elements
101
+ // to settle after the scroll.
102
+ await page.waitForTimeout(150);
103
+
104
+ // Capture only the visible viewport. The element is already centred,
105
+ // so the highlight is in frame. The popup will normalize this through
106
+ // its fixed-size canvas, so we don't need to crop.
107
+ const buffer = await page.screenshot({ fullPage: false, type: "png" });
108
+
109
+ await page.evaluate(() => {
110
+ document.getElementById("__wcag_cli_overlay__")?.remove();
111
+ document.getElementById("__wcag_cli_label__")?.remove();
112
+ });
113
+ await handle.dispose();
114
+ return buffer;
115
+ } catch (e) {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ export async function captureElementBySelector(page, selector, opts = {}) {
121
+ return captureElement(page, page.locator(selector).first(), opts);
122
+ }
123
+
124
+ export async function saveScreenshot(buffer, dir, ruleId) {
125
+ if (!buffer) return null;
126
+ await fs.mkdir(dir, { recursive: true });
127
+ const id = nextScreenshotId();
128
+ const file = path.join(dir, `${id}_${safeFileSegment(ruleId)}.png`);
129
+ await fs.writeFile(file, buffer);
130
+ return file;
131
+ }
@@ -0,0 +1,256 @@
1
+ // Catalogue of WCAG success criteria (2.0 / 2.1 / 2.2) with the automation
2
+ // status of each one against this CLI's three engines:
3
+ //
4
+ // automation: what axe-core can detect statically
5
+ // "automated" | "partial" | "manual"
6
+ // aiReviewable: what Claude vision can judge from a still snapshot
7
+ // playwrightCheckable: what we can verify by driving the browser (keyboard,
8
+ // viewport, hover, form submit, motion timelapse, etc.)
9
+ //
10
+ // `addedIn` is the WCAG version that introduced the criterion. `removedIn`
11
+ // marks criteria dropped in a later version (4.1.1 in 2.2).
12
+
13
+ export const WCAG_CRITERIA = [
14
+ // ─────────────────────────── 1. Perceivable ───────────────────────────
15
+ { id: "1.1.1", title: "Non-text Content", level: "A", addedIn: "2.0",
16
+ automation: "automated",
17
+ axeRules: ["image-alt", "input-image-alt", "area-alt", "role-img-alt", "svg-img-alt", "object-alt", "input-button-name"] },
18
+
19
+ { id: "1.2.1", title: "Audio-only and Video-only (Prerecorded)", level: "A", addedIn: "2.0",
20
+ automation: "partial", axeRules: ["audio-caption", "video-caption"],
21
+ playwrightCheckable: true,
22
+ notes: "CLI detects <audio>/<video> presence; transcript accuracy is human-only." },
23
+ { id: "1.2.2", title: "Captions (Prerecorded)", level: "A", addedIn: "2.0",
24
+ automation: "partial", axeRules: ["video-caption"],
25
+ playwrightCheckable: true,
26
+ notes: "CLI checks for <track kind=captions>; caption correctness is human." },
27
+ { id: "1.2.3", title: "Audio Description or Media Alternative (Prerecorded)", level: "A", addedIn: "2.0",
28
+ automation: "manual",
29
+ notes: "Quality of description is human-only." },
30
+ { id: "1.2.4", title: "Captions (Live)", level: "AA", addedIn: "2.0",
31
+ automation: "manual" },
32
+ { id: "1.2.5", title: "Audio Description (Prerecorded)", level: "AA", addedIn: "2.0",
33
+ automation: "manual" },
34
+ { id: "1.2.6", title: "Sign Language (Prerecorded)", level: "AAA", addedIn: "2.0",
35
+ automation: "manual" },
36
+ { id: "1.2.7", title: "Extended Audio Description (Prerecorded)", level: "AAA", addedIn: "2.0",
37
+ automation: "manual" },
38
+ { id: "1.2.8", title: "Media Alternative (Prerecorded)", level: "AAA", addedIn: "2.0",
39
+ automation: "manual" },
40
+ { id: "1.2.9", title: "Audio-only (Live)", level: "AAA", addedIn: "2.0",
41
+ automation: "manual" },
42
+
43
+ { id: "1.3.1", title: "Info and Relationships", level: "A", addedIn: "2.0",
44
+ automation: "automated",
45
+ axeRules: ["definition-list", "dlitem", "list", "listitem", "table-fake-caption", "td-headers-attr", "th-has-data-cells", "scope-attr-valid", "aria-required-parent", "aria-required-children", "label", "form-field-multiple-labels", "p-as-heading"] },
46
+ { id: "1.3.2", title: "Meaningful Sequence", level: "A", addedIn: "2.0",
47
+ automation: "partial", axeRules: ["tabindex"], playwrightCheckable: true,
48
+ notes: "CLI compares DOM order to visual order via bounding boxes." },
49
+ { id: "1.3.3", title: "Sensory Characteristics", level: "A", addedIn: "2.0",
50
+ automation: "manual", aiReviewable: true },
51
+ { id: "1.3.4", title: "Orientation", level: "AA", addedIn: "2.1",
52
+ automation: "automated", axeRules: ["css-orientation-lock"] },
53
+ { id: "1.3.5", title: "Identify Input Purpose", level: "AA", addedIn: "2.1",
54
+ automation: "automated", axeRules: ["autocomplete-valid"] },
55
+ { id: "1.3.6", title: "Identify Purpose", level: "AAA", addedIn: "2.1",
56
+ automation: "manual" },
57
+
58
+ { id: "1.4.1", title: "Use of Color", level: "A", addedIn: "2.0",
59
+ automation: "partial", axeRules: ["link-in-text-block"], aiReviewable: true },
60
+ { id: "1.4.2", title: "Audio Control", level: "A", addedIn: "2.0",
61
+ automation: "automated", axeRules: ["no-autoplay-audio"], playwrightCheckable: true },
62
+ { id: "1.4.3", title: "Contrast (Minimum)", level: "AA", addedIn: "2.0",
63
+ automation: "automated", axeRules: ["color-contrast"] },
64
+ { id: "1.4.4", title: "Resize Text", level: "AA", addedIn: "2.0",
65
+ automation: "partial", axeRules: ["meta-viewport"], playwrightCheckable: true,
66
+ notes: "CLI zooms to 200% and checks for content loss / overflow." },
67
+ { id: "1.4.5", title: "Images of Text", level: "AA", addedIn: "2.0",
68
+ automation: "manual", aiReviewable: true },
69
+ { id: "1.4.6", title: "Contrast (Enhanced)", level: "AAA", addedIn: "2.0",
70
+ automation: "automated", axeRules: ["color-contrast-enhanced"] },
71
+ { id: "1.4.7", title: "Low or No Background Audio", level: "AAA", addedIn: "2.0",
72
+ automation: "manual" },
73
+ { id: "1.4.8", title: "Visual Presentation", level: "AAA", addedIn: "2.0",
74
+ automation: "manual", aiReviewable: true },
75
+ { id: "1.4.9", title: "Images of Text (No Exception)", level: "AAA", addedIn: "2.0",
76
+ automation: "manual", aiReviewable: true },
77
+ { id: "1.4.10", title: "Reflow", level: "AA", addedIn: "2.1",
78
+ automation: "partial", axeRules: ["meta-viewport"], playwrightCheckable: true,
79
+ notes: "CLI shrinks viewport to 320 CSS px and checks for horizontal scroll." },
80
+ { id: "1.4.11", title: "Non-text Contrast", level: "AA", addedIn: "2.1",
81
+ automation: "manual", aiReviewable: true },
82
+ { id: "1.4.12", title: "Text Spacing", level: "AA", addedIn: "2.1",
83
+ automation: "manual", aiReviewable: true, playwrightCheckable: true,
84
+ notes: "CLI injects WCAG-required spacing CSS and checks for clipped text." },
85
+ { id: "1.4.13", title: "Content on Hover or Focus", level: "AA", addedIn: "2.1",
86
+ automation: "manual", playwrightCheckable: true,
87
+ notes: "CLI hovers tooltip triggers and verifies dismissible/persistent/hoverable." },
88
+
89
+ // ─────────────────────────── 2. Operable ───────────────────────────
90
+ { id: "2.1.1", title: "Keyboard", level: "A", addedIn: "2.0",
91
+ automation: "partial",
92
+ axeRules: ["frame-focusable-content", "scrollable-region-focusable", "nested-interactive"],
93
+ playwrightCheckable: true,
94
+ notes: "CLI tabs through every interactive element." },
95
+ { id: "2.1.2", title: "No Keyboard Trap", level: "A", addedIn: "2.0",
96
+ automation: "manual", playwrightCheckable: true,
97
+ notes: "CLI detects Tab cycles where focus never leaves a region." },
98
+ { id: "2.1.3", title: "Keyboard (No Exception)", level: "AAA", addedIn: "2.0",
99
+ automation: "manual", playwrightCheckable: true },
100
+ { id: "2.1.4", title: "Character Key Shortcuts", level: "A", addedIn: "2.1",
101
+ automation: "manual", playwrightCheckable: true,
102
+ notes: "CLI scans for global single-key keydown listeners." },
103
+
104
+ { id: "2.2.1", title: "Timing Adjustable", level: "A", addedIn: "2.0",
105
+ automation: "manual", axeRules: ["meta-refresh"], playwrightCheckable: true },
106
+ { id: "2.2.2", title: "Pause, Stop, Hide", level: "A", addedIn: "2.0",
107
+ automation: "partial", axeRules: ["blink", "marquee", "no-autoplay-audio"],
108
+ playwrightCheckable: true,
109
+ notes: "CLI records a timelapse and detects auto-updating content / autoplay." },
110
+ { id: "2.2.3", title: "No Timing", level: "AAA", addedIn: "2.0", automation: "manual" },
111
+ { id: "2.2.4", title: "Interruptions", level: "AAA", addedIn: "2.0", automation: "manual" },
112
+ { id: "2.2.5", title: "Re-authenticating", level: "AAA", addedIn: "2.0", automation: "manual" },
113
+ { id: "2.2.6", title: "Timeouts", level: "AAA", addedIn: "2.1", automation: "manual" },
114
+
115
+ { id: "2.3.1", title: "Three Flashes or Below Threshold", level: "A", addedIn: "2.0",
116
+ automation: "manual", playwrightCheckable: true,
117
+ notes: "CLI captures a 10s timelapse and counts large frame-to-frame deltas." },
118
+ { id: "2.3.2", title: "Three Flashes", level: "AAA", addedIn: "2.0",
119
+ automation: "manual", playwrightCheckable: true },
120
+ { id: "2.3.3", title: "Animation from Interactions", level: "AAA", addedIn: "2.1",
121
+ automation: "manual", playwrightCheckable: true },
122
+
123
+ { id: "2.4.1", title: "Bypass Blocks", level: "A", addedIn: "2.0",
124
+ automation: "automated", axeRules: ["bypass", "region", "landmark-one-main", "skip-link"] },
125
+ { id: "2.4.2", title: "Page Titled", level: "A", addedIn: "2.0",
126
+ automation: "automated", axeRules: ["document-title"] },
127
+ { id: "2.4.3", title: "Focus Order", level: "A", addedIn: "2.0",
128
+ automation: "manual", playwrightCheckable: true,
129
+ notes: "CLI compares Tab order to visual reading order." },
130
+ { id: "2.4.4", title: "Link Purpose (In Context)", level: "A", addedIn: "2.0",
131
+ automation: "automated", axeRules: ["link-name"] },
132
+ { id: "2.4.5", title: "Multiple Ways", level: "AA", addedIn: "2.0",
133
+ automation: "manual", aiReviewable: true, playwrightCheckable: true },
134
+ { id: "2.4.6", title: "Headings and Labels", level: "AA", addedIn: "2.0",
135
+ automation: "partial", axeRules: ["empty-heading", "heading-order", "empty-table-header"], aiReviewable: true },
136
+ { id: "2.4.7", title: "Focus Visible", level: "AA", addedIn: "2.0",
137
+ automation: "manual", playwrightCheckable: true,
138
+ notes: "CLI captures before/after focus state and pixel-diffs each control." },
139
+ { id: "2.4.8", title: "Location", level: "AAA", addedIn: "2.0",
140
+ automation: "manual", aiReviewable: true },
141
+ { id: "2.4.9", title: "Link Purpose (Link Only)", level: "AAA", addedIn: "2.0",
142
+ automation: "automated", axeRules: ["link-name"] },
143
+ { id: "2.4.10", title: "Section Headings", level: "AAA", addedIn: "2.0",
144
+ automation: "manual", aiReviewable: true },
145
+ { id: "2.4.11", title: "Focus Not Obscured (Minimum)", level: "AA", addedIn: "2.2",
146
+ automation: "manual", playwrightCheckable: true,
147
+ notes: "CLI checks focused element rect vs sticky/fixed element rects." },
148
+ { id: "2.4.12", title: "Focus Not Obscured (Enhanced)", level: "AAA", addedIn: "2.2",
149
+ automation: "manual", playwrightCheckable: true },
150
+ { id: "2.4.13", title: "Focus Appearance", level: "AAA", addedIn: "2.2",
151
+ automation: "manual", playwrightCheckable: true },
152
+
153
+ { id: "2.5.1", title: "Pointer Gestures", level: "A", addedIn: "2.1",
154
+ automation: "manual", playwrightCheckable: true,
155
+ notes: "CLI scans for multi-touch / path gesture event listeners." },
156
+ { id: "2.5.2", title: "Pointer Cancellation", level: "A", addedIn: "2.1",
157
+ automation: "manual", playwrightCheckable: true,
158
+ notes: "CLI detects elements that act on pointerdown without an undo path." },
159
+ { id: "2.5.3", title: "Label in Name", level: "A", addedIn: "2.1",
160
+ automation: "automated", axeRules: ["label-content-name-mismatch"] },
161
+ { id: "2.5.4", title: "Motion Actuation", level: "A", addedIn: "2.1",
162
+ automation: "manual", playwrightCheckable: true,
163
+ notes: "CLI scans for deviceorientation/devicemotion listeners." },
164
+ { id: "2.5.5", title: "Target Size (Enhanced)", level: "AAA", addedIn: "2.1",
165
+ automation: "automated", axeRules: ["target-size"] },
166
+ { id: "2.5.6", title: "Concurrent Input Mechanisms", level: "AAA", addedIn: "2.1",
167
+ automation: "manual" },
168
+ { id: "2.5.7", title: "Dragging Movements", level: "AA", addedIn: "2.2",
169
+ automation: "manual", playwrightCheckable: true },
170
+ { id: "2.5.8", title: "Target Size (Minimum)", level: "AA", addedIn: "2.2",
171
+ automation: "automated", axeRules: ["target-size"] },
172
+
173
+ // ─────────────────────────── 3. Understandable ───────────────────────────
174
+ { id: "3.1.1", title: "Language of Page", level: "A", addedIn: "2.0",
175
+ automation: "automated", axeRules: ["html-has-lang", "html-lang-valid", "html-xml-lang-mismatch"] },
176
+ { id: "3.1.2", title: "Language of Parts", level: "AA", addedIn: "2.0",
177
+ automation: "automated", axeRules: ["valid-lang"] },
178
+ { id: "3.1.3", title: "Unusual Words", level: "AAA", addedIn: "2.0",
179
+ automation: "manual", aiReviewable: true },
180
+ { id: "3.1.4", title: "Abbreviations", level: "AAA", addedIn: "2.0",
181
+ automation: "manual", aiReviewable: true },
182
+ { id: "3.1.5", title: "Reading Level", level: "AAA", addedIn: "2.0",
183
+ automation: "manual", aiReviewable: true },
184
+ { id: "3.1.6", title: "Pronunciation", level: "AAA", addedIn: "2.0", automation: "manual" },
185
+
186
+ { id: "3.2.1", title: "On Focus", level: "A", addedIn: "2.0",
187
+ automation: "manual", playwrightCheckable: true,
188
+ notes: "CLI focuses each control and watches for navigation / context change." },
189
+ { id: "3.2.2", title: "On Input", level: "A", addedIn: "2.0",
190
+ automation: "manual", playwrightCheckable: true,
191
+ notes: "CLI types into each input and watches for navigation / context change." },
192
+ { id: "3.2.3", title: "Consistent Navigation", level: "AA", addedIn: "2.0",
193
+ automation: "manual", playwrightCheckable: true,
194
+ notes: "CLI crawls multiple pages and compares navigation structure." },
195
+ { id: "3.2.4", title: "Consistent Identification", level: "AA", addedIn: "2.0",
196
+ automation: "manual", playwrightCheckable: true },
197
+ { id: "3.2.5", title: "Change on Request", level: "AAA", addedIn: "2.0",
198
+ automation: "manual", playwrightCheckable: true },
199
+ { id: "3.2.6", title: "Consistent Help", level: "A", addedIn: "2.2",
200
+ automation: "manual", playwrightCheckable: true,
201
+ notes: "CLI looks for a help mechanism in the same location across pages." },
202
+
203
+ { id: "3.3.1", title: "Error Identification", level: "A", addedIn: "2.0",
204
+ automation: "manual", axeRules: ["aria-required-attr"], playwrightCheckable: true,
205
+ notes: "CLI submits forms invalid and checks errors are announced." },
206
+ { id: "3.3.2", title: "Labels or Instructions", level: "A", addedIn: "2.0",
207
+ automation: "automated",
208
+ axeRules: ["label", "form-field-multiple-labels", "select-name", "input-button-name"] },
209
+ { id: "3.3.3", title: "Error Suggestion", level: "AA", addedIn: "2.0",
210
+ automation: "manual", playwrightCheckable: true,
211
+ notes: "CLI submits forms invalid and asks AI whether the error provides a fix." },
212
+ { id: "3.3.4", title: "Error Prevention (Legal, Financial, Data)", level: "AA", addedIn: "2.0",
213
+ automation: "manual", playwrightCheckable: true },
214
+ { id: "3.3.5", title: "Help", level: "AAA", addedIn: "2.0", automation: "manual" },
215
+ { id: "3.3.6", title: "Error Prevention (All)", level: "AAA", addedIn: "2.0", automation: "manual" },
216
+ { id: "3.3.7", title: "Redundant Entry", level: "A", addedIn: "2.2",
217
+ automation: "manual", playwrightCheckable: true,
218
+ notes: "CLI walks multi-step forms and detects duplicate fields." },
219
+ { id: "3.3.8", title: "Accessible Authentication (Minimum)", level: "AA", addedIn: "2.2",
220
+ automation: "manual", playwrightCheckable: true,
221
+ notes: "CLI detects CAPTCHAs and other cognitive function tests." },
222
+ { id: "3.3.9", title: "Accessible Authentication (Enhanced)", level: "AAA", addedIn: "2.2",
223
+ automation: "manual", playwrightCheckable: true },
224
+
225
+ // ─────────────────────────── 4. Robust ───────────────────────────
226
+ { id: "4.1.1", title: "Parsing", level: "A", addedIn: "2.0", removedIn: "2.2",
227
+ automation: "automated", axeRules: ["duplicate-id", "duplicate-id-active", "duplicate-id-aria"] },
228
+ { id: "4.1.2", title: "Name, Role, Value", level: "A", addedIn: "2.0",
229
+ automation: "automated",
230
+ axeRules: ["aria-allowed-attr", "aria-allowed-role", "aria-command-name", "aria-hidden-body", "aria-hidden-focus", "aria-input-field-name", "aria-meter-name", "aria-progressbar-name", "aria-required-attr", "aria-required-children", "aria-required-parent", "aria-roles", "aria-toggle-field-name", "aria-tooltip-name", "aria-valid-attr", "aria-valid-attr-value", "button-name", "link-name", "select-name", "summary-name"] },
231
+ { id: "4.1.3", title: "Status Messages", level: "AA", addedIn: "2.1",
232
+ automation: "partial", axeRules: ["aria-allowed-role", "aria-valid-attr-value"] },
233
+ ];
234
+
235
+ export function filterCriteriaForScope(version, levels) {
236
+ const upper = new Set(levels.map((l) => l.toUpperCase()));
237
+ return WCAG_CRITERIA.filter((c) => {
238
+ if (!upper.has(c.level)) return false;
239
+ if (version === "2.1" && c.addedIn === "2.2") return false;
240
+ if (version === "2.2" && c.removedIn === "2.2") return false;
241
+ return true;
242
+ });
243
+ }
244
+
245
+ export function buildAxeTags(version, levels, includeBP = false) {
246
+ const versions = version === "2.2" ? ["wcag2", "wcag21", "wcag22"] : ["wcag2", "wcag21"];
247
+ const tags = [];
248
+ for (const v of versions) {
249
+ for (const lvl of levels) {
250
+ const suffix = v === "wcag2" ? "" : v.slice(4);
251
+ tags.push(`wcag${suffix}${lvl.toLowerCase()}`);
252
+ }
253
+ }
254
+ if (includeBP) tags.push("best-practice");
255
+ return tags;
256
+ }