@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
package/src/report.js ADDED
@@ -0,0 +1,235 @@
1
+ // Excel report writer. Mirrors the extension's three-sheet format
2
+ // (Summary / Coverage / Issues) but with an additional Source column and
3
+ // embedded screenshots loaded from disk rather than from in-memory buffers.
4
+
5
+ import ExcelJS from "exceljs";
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+
9
+ const SHOT_MAX_W = 1000;
10
+ const SHOT_MAX_H = 800;
11
+
12
+ function pxToPt(px) {
13
+ return Math.ceil(px * 0.75) + 6;
14
+ }
15
+
16
+ async function imageDimensions(buffer) {
17
+ // Lightweight PNG dimension parse — avoids pulling in another dependency.
18
+ if (buffer[0] === 0x89 && buffer[1] === 0x50) {
19
+ const w = buffer.readUInt32BE(16);
20
+ const h = buffer.readUInt32BE(20);
21
+ return { width: w, height: h };
22
+ }
23
+ return { width: 800, height: 600 };
24
+ }
25
+
26
+ function fitDims(w, h) {
27
+ const scale = Math.min(SHOT_MAX_W / w, SHOT_MAX_H / h, 1);
28
+ return { width: Math.max(1, Math.round(w * scale)), height: Math.max(1, Math.round(h * scale)) };
29
+ }
30
+
31
+ export async function writeReport({ findings, meta, outPath }) {
32
+ const wb = new ExcelJS.Workbook();
33
+ wb.creator = "WCAG Documentation Creator CLI";
34
+ wb.created = new Date();
35
+
36
+ // ── Summary sheet ──
37
+ const sum = wb.addWorksheet("Summary");
38
+ sum.columns = [
39
+ { header: "", key: "k", width: 28 },
40
+ { header: "", key: "v", width: 90 },
41
+ ];
42
+ const rows = [
43
+ ["WCAG Documentation Creator — End-to-End Audit Report"],
44
+ [],
45
+ ["Page URL", meta.url],
46
+ ["WCAG version", meta.wcagVersion],
47
+ ["Levels", meta.levels.join(", ")],
48
+ ["Started", meta.startedAt],
49
+ ["Finished", meta.finishedAt],
50
+ ["Total findings", String(meta.totalFindings)],
51
+ [],
52
+ ["Per-checker timings (s)"],
53
+ ];
54
+ rows.forEach((r) => sum.addRow(r));
55
+ sum.getRow(1).font = { bold: true, size: 14, color: { argb: "FF0969DA" } };
56
+ for (const [k, v] of Object.entries(meta.perCheckerTimings || {})) {
57
+ sum.addRow([` ${k}`, String(v)]);
58
+ }
59
+ if (Object.keys(meta.perCheckerErrors || {}).length) {
60
+ sum.addRow([]);
61
+ sum.addRow(["Checker errors"]);
62
+ for (const [k, v] of Object.entries(meta.perCheckerErrors)) sum.addRow([` ${k}`, v]);
63
+ }
64
+ if (meta.aiEnabled) {
65
+ sum.addRow([]);
66
+ sum.addRow(["AI model", meta.aiModel || ""]);
67
+ if (meta.aiUsage) {
68
+ sum.addRow([
69
+ "AI tokens (in / out)",
70
+ `${meta.aiUsage.input_tokens || 0} / ${meta.aiUsage.output_tokens || 0}`,
71
+ ]);
72
+ }
73
+ }
74
+
75
+ // ── Coverage sheet ──
76
+ const cov = wb.addWorksheet("Coverage");
77
+ cov.columns = [
78
+ { header: "Criterion", key: "id", width: 12 },
79
+ { header: "Title", key: "title", width: 42 },
80
+ { header: "Level", key: "level", width: 8 },
81
+ { header: "Added in", key: "added", width: 12 },
82
+ { header: "axe", key: "axe", width: 14 },
83
+ { header: "Playwright", key: "pw", width: 14 },
84
+ { header: "AI vision", key: "ai", width: 14 },
85
+ { header: "Findings", key: "found", width: 12 },
86
+ { header: "Notes", key: "notes", width: 60 },
87
+ ];
88
+ const covHeader = cov.getRow(1);
89
+ covHeader.font = { bold: true, color: { argb: "FFFFFFFF" } };
90
+ covHeader.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF0969DA" } };
91
+ cov.views = [{ state: "frozen", ySplit: 1 }];
92
+
93
+ // Build per-criterion finding counts
94
+ const counts = Object.create(null);
95
+ for (const f of findings) {
96
+ const ids = String(f.criteria || "").split(",").map((s) => s.trim()).filter(Boolean);
97
+ for (const id of ids) counts[id] = (counts[id] || 0) + 1;
98
+ }
99
+
100
+ for (const c of meta.scopeCriteria) {
101
+ const axeStatus =
102
+ c.automation === "automated" ? "Automated" :
103
+ c.automation === "partial" ? "Partial" : "—";
104
+ const pwStatus = c.playwrightCheckable ? "Checked" : "—";
105
+ const aiStatus = c.aiReviewable ? (meta.aiEnabled ? "Reviewed" : "available") : "—";
106
+
107
+ const row = cov.addRow({
108
+ id: c.id,
109
+ title: c.title,
110
+ level: c.level,
111
+ added: c.addedIn + (c.removedIn ? ` (rm ${c.removedIn})` : ""),
112
+ axe: axeStatus,
113
+ pw: pwStatus,
114
+ ai: aiStatus,
115
+ found: counts[c.id] || 0,
116
+ notes: c.notes || "",
117
+ });
118
+ row.alignment = { vertical: "top", wrapText: true };
119
+
120
+ const colorCell = (key, label) => {
121
+ const cell = row.getCell(key);
122
+ const colors = {
123
+ Automated: "FF116329", Partial: "FFCA8A04",
124
+ Checked: "FF0969DA",
125
+ Reviewed: "FF6D28D9", available: "FF8C959F",
126
+ };
127
+ if (colors[label]) {
128
+ cell.font = { bold: true, color: { argb: "FFFFFFFF" } };
129
+ cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: colors[label] } };
130
+ cell.alignment = { vertical: "middle", horizontal: "center" };
131
+ }
132
+ };
133
+ colorCell("axe", axeStatus);
134
+ colorCell("pw", pwStatus);
135
+ colorCell("ai", aiStatus);
136
+ if (counts[c.id]) row.getCell("found").font = { bold: true, color: { argb: "FFB91C1C" } };
137
+ }
138
+
139
+ // ── Issues sheet ──
140
+ const ws = wb.addWorksheet("Issues");
141
+ ws.columns = [
142
+ { header: "Issue #", key: "n", width: 8 },
143
+ { header: "Source", key: "src", width: 12 },
144
+ { header: "Rule ID", key: "rule", width: 28 },
145
+ { header: "WCAG", key: "crit", width: 12 },
146
+ { header: "Level", key: "level", width: 8 },
147
+ { header: "Severity", key: "sev", width: 12 },
148
+ { header: "Description", key: "desc", width: 50 },
149
+ { header: "How to fix", key: "help", width: 50 },
150
+ { header: "Help URL", key: "url", width: 36 },
151
+ ];
152
+ const wsHeader = ws.getRow(1);
153
+ wsHeader.font = { bold: true, color: { argb: "FFFFFFFF" } };
154
+ wsHeader.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF0969DA" } };
155
+ ws.views = [{ state: "frozen", ySplit: 1 }];
156
+
157
+ for (const f of findings) {
158
+ const metaRow = ws.addRow({
159
+ n: f.issueNumber,
160
+ src: f.source,
161
+ rule: f.ruleId,
162
+ crit: f.criteria,
163
+ level: f.level,
164
+ sev: f.impact,
165
+ desc: f.description + (f.help ? `\n\n${f.help}` : ""),
166
+ help: f.failureSummary || "",
167
+ url: f.helpUrl,
168
+ });
169
+ metaRow.alignment = { vertical: "top", wrapText: true };
170
+ metaRow.height = 90;
171
+
172
+ // Source colour-code
173
+ const srcCell = metaRow.getCell("src");
174
+ const srcColors = { axe: "FF0969DA", playwright: "FF116329", ai: "FF6D28D9" };
175
+ if (srcColors[f.source]) {
176
+ srcCell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: srcColors[f.source] } };
177
+ srcCell.font = { bold: true, color: { argb: "FFFFFFFF" } };
178
+ srcCell.alignment = { horizontal: "center", vertical: "middle" };
179
+ }
180
+
181
+ // Severity colour-code
182
+ const sevCell = metaRow.getCell("sev");
183
+ const sevColors = { critical: "FFB91C1C", serious: "FFEA580C", moderate: "FFCA8A04", minor: "FF6B7280" };
184
+ if (sevColors[f.impact]) {
185
+ sevCell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: sevColors[f.impact] } };
186
+ sevCell.font = { bold: true, color: { argb: "FFFFFFFF" } };
187
+ sevCell.alignment = { horizontal: "center", vertical: "middle" };
188
+ }
189
+
190
+ if (f.helpUrl) {
191
+ metaRow.getCell("url").value = { text: f.helpUrl, hyperlink: f.helpUrl };
192
+ metaRow.getCell("url").font = { color: { argb: "FF0969DA" }, underline: true };
193
+ }
194
+
195
+ // Selector + evidence row
196
+ const selRow = ws.addRow(["", "", "Selector:", f.selector || "", "", "", f.evidence || "", "", ""]);
197
+ ws.mergeCells(selRow.number, 4, selRow.number, 6);
198
+ ws.mergeCells(selRow.number, 7, selRow.number, 9);
199
+ selRow.getCell(3).font = { bold: true, italic: true, color: { argb: "FF57606A" } };
200
+ selRow.getCell(4).font = { name: "Menlo", size: 10 };
201
+ selRow.getCell(7).font = { name: "Menlo", size: 10 };
202
+ selRow.alignment = { vertical: "top", wrapText: true };
203
+ selRow.height = 60;
204
+
205
+ // Screenshot row
206
+ if (f.screenshotFile) {
207
+ try {
208
+ const buffer = await fs.readFile(f.screenshotFile);
209
+ const dims = await imageDimensions(buffer);
210
+ const fit = fitDims(dims.width, dims.height);
211
+ const imgRow = ws.addRow(["", "", "Screenshot", "", "", "", "", "", ""]);
212
+ imgRow.getCell(3).font = { bold: true, italic: true, color: { argb: "FF57606A" } };
213
+ ws.mergeCells(imgRow.number, 4, imgRow.number, 9);
214
+ imgRow.height = pxToPt(fit.height);
215
+ const imageId = wb.addImage({ buffer, extension: "png" });
216
+ ws.addImage(imageId, {
217
+ tl: { col: 3, row: imgRow.number - 1 },
218
+ ext: { width: fit.width, height: fit.height },
219
+ editAs: "oneCell",
220
+ });
221
+ } catch (_) {
222
+ const imgRow = ws.addRow(["", "", "Screenshot", "(load failed)"]);
223
+ imgRow.height = 24;
224
+ }
225
+ }
226
+
227
+ // Spacer
228
+ const spacer = ws.addRow([]);
229
+ spacer.height = 8;
230
+ spacer.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFEAEEF2" } };
231
+ }
232
+
233
+ await fs.mkdir(path.dirname(path.resolve(outPath)), { recursive: true });
234
+ await wb.xlsx.writeFile(outPath);
235
+ }
@@ -0,0 +1,25 @@
1
+ // Backwards-compatible thin wrapper. The original API was Anthropic-only;
2
+ // the codebase now supports Anthropic / OpenAI / Google via callLlmJsonArray
3
+ // in ./llm.js. We keep `askClaudeForJsonArray` as a delegating shim so
4
+ // every existing checker (screen-reader, forms, ai-vision) keeps working
5
+ // without an edit. The shim accepts an optional `provider` field — if
6
+ // omitted it defaults to anthropic, matching the historical behaviour.
7
+
8
+ import { callLlmJsonArray } from "./llm.js";
9
+
10
+ export async function askClaudeForJsonArray({
11
+ provider = "anthropic",
12
+ apiKey,
13
+ model,
14
+ prompt,
15
+ images = [],
16
+ maxTokens = 8192,
17
+ }) {
18
+ if (!apiKey) {
19
+ throw new Error(
20
+ `LLM API key required (provider=${provider}). Set --anthropic-api-key, ` +
21
+ `pass it in the audit request, or set the corresponding env var.`
22
+ );
23
+ }
24
+ return callLlmJsonArray({ provider, apiKey, model, prompt, images, maxTokens });
25
+ }
@@ -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
+ }