@explainui/mcp 0.1.0-beta.1 → 0.1.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.
- package/README.md +71 -13
- package/dist/capture.d.ts +1 -0
- package/dist/capture.d.ts.map +1 -1
- package/dist/capture.js +2077 -27
- package/dist/capture.js.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +20 -4
- package/dist/client.js.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/index.js +63 -30
- package/dist/index.js.map +1 -1
- package/dist/tools.d.ts +1 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +139 -31
- package/dist/tools.js.map +1 -1
- package/dist/types.d.ts +195 -11
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -3
- package/dist/types.js.map +1 -1
- package/package.json +4 -1
package/dist/capture.js
CHANGED
|
@@ -8,29 +8,2006 @@
|
|
|
8
8
|
* - Auto-relaunches if browser disconnects
|
|
9
9
|
* - Clean shutdown on SIGINT/SIGTERM
|
|
10
10
|
*/
|
|
11
|
+
import { execFileSync } from "child_process";
|
|
12
|
+
import { createRequire } from "module";
|
|
11
13
|
import { chromium } from "playwright";
|
|
12
|
-
import {
|
|
14
|
+
import { AxeBuilder } from "@axe-core/playwright";
|
|
15
|
+
import { VIEWPORTS, ExplainUIError, } from "./types.js";
|
|
13
16
|
let browser = null;
|
|
17
|
+
/**
|
|
18
|
+
* Quick HTTP reachability check before Playwright tries to load the URL.
|
|
19
|
+
* Fails fast (<3s) with a clear message instead of waiting 15s for Playwright timeout.
|
|
20
|
+
*/
|
|
21
|
+
async function assertUrlReachable(url) {
|
|
22
|
+
let timedOut = false;
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const timer = setTimeout(() => {
|
|
25
|
+
timedOut = true;
|
|
26
|
+
controller.abort();
|
|
27
|
+
}, 3_000);
|
|
28
|
+
try {
|
|
29
|
+
await fetch(url, { method: "HEAD", signal: controller.signal });
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
if (timedOut) {
|
|
33
|
+
throw new ExplainUIError("LOCAL_SERVER_NOT_RUNNING", `Cannot reach ${url} (no response within 3s).\n\n` +
|
|
34
|
+
`Make sure your dev server is running:\n` +
|
|
35
|
+
` npm run dev (or: yarn dev / pnpm dev)\n\n` +
|
|
36
|
+
`Then call explainui_analyze again.`);
|
|
37
|
+
}
|
|
38
|
+
// ECONNREFUSED or similar — server is definitely not running
|
|
39
|
+
throw new ExplainUIError("LOCAL_SERVER_NOT_RUNNING", `Cannot connect to ${url}.\n\n` +
|
|
40
|
+
`Your dev server is not running. Start it with:\n` +
|
|
41
|
+
` npm run dev (or: yarn dev / pnpm dev)\n\n` +
|
|
42
|
+
`Then call explainui_analyze again.`);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
14
48
|
async function getBrowser() {
|
|
15
49
|
if (!browser || !browser.isConnected()) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
50
|
+
try {
|
|
51
|
+
browser = await chromium.launch({
|
|
52
|
+
headless: true,
|
|
53
|
+
args: [
|
|
54
|
+
"--no-sandbox",
|
|
55
|
+
"--disable-setuid-sandbox",
|
|
56
|
+
"--disable-dev-shm-usage",
|
|
57
|
+
"--disable-gpu",
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
const msg = err.message ?? "";
|
|
63
|
+
if (msg.includes("Executable doesn't exist") ||
|
|
64
|
+
msg.includes("Failed to launch") ||
|
|
65
|
+
msg.includes("chromium")) {
|
|
66
|
+
// Auto-install Chromium on first use using the bundled playwright-core CLI
|
|
67
|
+
try {
|
|
68
|
+
const require = createRequire(import.meta.url);
|
|
69
|
+
const cliPath = require.resolve("playwright-core/cli");
|
|
70
|
+
execFileSync(process.execPath, [cliPath, "install", "chromium"], {
|
|
71
|
+
stdio: "pipe",
|
|
72
|
+
timeout: 120_000,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new ExplainUIError("BROWSER_INSTALL_FAILED", `Playwright Chromium is not installed and auto-install failed.\n\n` +
|
|
77
|
+
`Run this manually to fix it:\n` +
|
|
78
|
+
` npx playwright install chromium\n\n` +
|
|
79
|
+
`Then call explainui_analyze again.`);
|
|
80
|
+
}
|
|
81
|
+
// Retry launch after install
|
|
82
|
+
browser = await chromium.launch({
|
|
83
|
+
headless: true,
|
|
84
|
+
args: [
|
|
85
|
+
"--no-sandbox",
|
|
86
|
+
"--disable-setuid-sandbox",
|
|
87
|
+
"--disable-dev-shm-usage",
|
|
88
|
+
"--disable-gpu",
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
return browser;
|
|
92
|
+
}
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
25
95
|
}
|
|
26
96
|
return browser;
|
|
27
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Run deterministic DOM checks before any LLM call.
|
|
100
|
+
* These are objective measurements — not vision estimates.
|
|
101
|
+
* Fails gracefully: returns safe defaults if page.evaluate() throws.
|
|
102
|
+
*/
|
|
103
|
+
async function runDeterministicChecks(page) {
|
|
104
|
+
try {
|
|
105
|
+
return await page.evaluate(() => {
|
|
106
|
+
const body = document.body;
|
|
107
|
+
const bodyText = (body.innerText || "").trim();
|
|
108
|
+
const visibleEls = document.querySelectorAll("h1,h2,h3,h4,p,img,button,a,input,nav");
|
|
109
|
+
// content_readable: check font sizes on text elements
|
|
110
|
+
const textEls = document.querySelectorAll("p,span,li,td,th,label,a");
|
|
111
|
+
let minFontSize = 999;
|
|
112
|
+
const maxCheck = Math.min(textEls.length, 30);
|
|
113
|
+
for (let i = 0; i < maxCheck; i++) {
|
|
114
|
+
const el = textEls[i];
|
|
115
|
+
if (el.offsetWidth === 0 && el.offsetHeight === 0)
|
|
116
|
+
continue;
|
|
117
|
+
const size = parseFloat(window.getComputedStyle(el).fontSize);
|
|
118
|
+
if (!isNaN(size) && size > 0 && size < minFontSize)
|
|
119
|
+
minFontSize = size;
|
|
120
|
+
}
|
|
121
|
+
if (minFontSize === 999)
|
|
122
|
+
minFontSize = 16; // default if no text found
|
|
123
|
+
return {
|
|
124
|
+
page_renders: {
|
|
125
|
+
pass: bodyText.length > 50 && visibleEls.length > 0,
|
|
126
|
+
body_text_length: bodyText.length,
|
|
127
|
+
visible_element_count: visibleEls.length,
|
|
128
|
+
},
|
|
129
|
+
layout_intact: {
|
|
130
|
+
pass: document.body.scrollWidth <= window.innerWidth + 1,
|
|
131
|
+
overflow_delta: Math.max(0, document.body.scrollWidth - window.innerWidth),
|
|
132
|
+
},
|
|
133
|
+
content_readable: {
|
|
134
|
+
min_font_size: Math.round(minFontSize * 10) / 10,
|
|
135
|
+
has_text_below_12px: minFontSize < 12,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Graceful fallback — don't block capture if DOM query fails
|
|
142
|
+
return {
|
|
143
|
+
page_renders: { pass: true, body_text_length: -1, visible_element_count: -1 },
|
|
144
|
+
layout_intact: { pass: true, overflow_delta: 0 },
|
|
145
|
+
content_readable: { min_font_size: 16, has_text_below_12px: false },
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Run accessibility checks via DOM inspection.
|
|
151
|
+
* These are deterministic — no LLM needed. Catches missing labels, alt text,
|
|
152
|
+
* lang attribute, outline:none, small touch targets, input type mismatches,
|
|
153
|
+
* and WCAG contrast ratio failures.
|
|
154
|
+
*/
|
|
155
|
+
async function runAccessibilityChecks(page) {
|
|
156
|
+
try {
|
|
157
|
+
return await page.evaluate(() => {
|
|
158
|
+
// ── Helper: parse CSS color to {r, g, b, a} ──
|
|
159
|
+
function parseColor(color) {
|
|
160
|
+
if (!color || color === "transparent" || color === "rgba(0, 0, 0, 0)")
|
|
161
|
+
return null;
|
|
162
|
+
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
|
163
|
+
if (m)
|
|
164
|
+
return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
|
|
165
|
+
// hex
|
|
166
|
+
const hm = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
|
|
167
|
+
if (hm)
|
|
168
|
+
return { r: parseInt(hm[1], 16), g: parseInt(hm[2], 16), b: parseInt(hm[3], 16), a: 1 };
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
// ── Helper: linearize sRGB channel for luminance calc ──
|
|
172
|
+
function linearize(c) {
|
|
173
|
+
const s = c / 255;
|
|
174
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
175
|
+
}
|
|
176
|
+
// ── Helper: relative luminance ──
|
|
177
|
+
function luminance(r, g, b) {
|
|
178
|
+
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
|
|
179
|
+
}
|
|
180
|
+
// ── Helper: contrast ratio ──
|
|
181
|
+
function contrastRatio(fg, bg) {
|
|
182
|
+
const l1 = luminance(fg.r, fg.g, fg.b);
|
|
183
|
+
const l2 = luminance(bg.r, bg.g, bg.b);
|
|
184
|
+
const lighter = Math.max(l1, l2);
|
|
185
|
+
const darker = Math.min(l1, l2);
|
|
186
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
187
|
+
}
|
|
188
|
+
// ── Helper: get effective background color walking up ancestors ──
|
|
189
|
+
function getEffectiveBg(el) {
|
|
190
|
+
let current = el;
|
|
191
|
+
for (let depth = 0; current && depth < 10; depth++) {
|
|
192
|
+
const style = window.getComputedStyle(current);
|
|
193
|
+
const bgColor = parseColor(style.backgroundColor);
|
|
194
|
+
if (bgColor && bgColor.a > 0.1) {
|
|
195
|
+
// Blend with white if semi-transparent
|
|
196
|
+
if (bgColor.a < 1) {
|
|
197
|
+
return {
|
|
198
|
+
r: Math.round(bgColor.r * bgColor.a + 255 * (1 - bgColor.a)),
|
|
199
|
+
g: Math.round(bgColor.g * bgColor.a + 255 * (1 - bgColor.a)),
|
|
200
|
+
b: Math.round(bgColor.b * bgColor.a + 255 * (1 - bgColor.a)),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
return bgColor;
|
|
204
|
+
}
|
|
205
|
+
current = current.parentElement;
|
|
206
|
+
}
|
|
207
|
+
return { r: 255, g: 255, b: 255 }; // default white
|
|
208
|
+
}
|
|
209
|
+
// ── Helper: simple CSS selector for an element ──
|
|
210
|
+
function getSelector(el) {
|
|
211
|
+
if (el.id)
|
|
212
|
+
return `#${el.id}`;
|
|
213
|
+
const tag = el.tagName.toLowerCase();
|
|
214
|
+
const cls = el.className && typeof el.className === "string"
|
|
215
|
+
? "." + el.className.trim().split(/\s+/).slice(0, 2).join(".")
|
|
216
|
+
: "";
|
|
217
|
+
return tag + cls;
|
|
218
|
+
}
|
|
219
|
+
const results = {
|
|
220
|
+
missing_labels: [],
|
|
221
|
+
missing_alt: [],
|
|
222
|
+
missing_lang: !document.documentElement.lang,
|
|
223
|
+
outline_none_focusable: [],
|
|
224
|
+
small_touch_targets: [],
|
|
225
|
+
input_type_mismatches: [],
|
|
226
|
+
contrast_failures: [],
|
|
227
|
+
};
|
|
228
|
+
// 1. Missing labels on form fields
|
|
229
|
+
const formFields = document.querySelectorAll("input:not([type='hidden']):not([type='submit']):not([type='button']), select, textarea");
|
|
230
|
+
for (let i = 0; i < Math.min(formFields.length, 30); i++) {
|
|
231
|
+
const el = formFields[i];
|
|
232
|
+
if (el.offsetWidth === 0 && el.offsetHeight === 0)
|
|
233
|
+
continue;
|
|
234
|
+
const hasLabel = el.id && document.querySelector(`label[for="${el.id}"]`);
|
|
235
|
+
const hasAria = el.getAttribute("aria-label") || el.getAttribute("aria-labelledby");
|
|
236
|
+
const hasParentLabel = el.closest("label");
|
|
237
|
+
if (!hasLabel && !hasAria && !hasParentLabel) {
|
|
238
|
+
results.missing_labels.push({
|
|
239
|
+
selector: getSelector(el),
|
|
240
|
+
tag: el.tagName.toLowerCase(),
|
|
241
|
+
text: el.placeholder || el.name || el.type || "",
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// 2. Missing alt on images
|
|
246
|
+
const images = document.querySelectorAll("img");
|
|
247
|
+
for (let i = 0; i < Math.min(images.length, 30); i++) {
|
|
248
|
+
const img = images[i];
|
|
249
|
+
if (img.offsetWidth === 0 && img.offsetHeight === 0)
|
|
250
|
+
continue;
|
|
251
|
+
const alt = img.getAttribute("alt");
|
|
252
|
+
if (alt === null || alt === "") {
|
|
253
|
+
results.missing_alt.push({
|
|
254
|
+
selector: getSelector(img),
|
|
255
|
+
src: (img.src || "").slice(0, 100),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// 3. outline:none on focusable elements (keyboard nav broken)
|
|
260
|
+
const focusable = document.querySelectorAll("a[href], button, input, select, textarea, [tabindex]");
|
|
261
|
+
for (let i = 0; i < Math.min(focusable.length, 30); i++) {
|
|
262
|
+
const el = focusable[i];
|
|
263
|
+
if (el.offsetWidth === 0 && el.offsetHeight === 0)
|
|
264
|
+
continue;
|
|
265
|
+
const style = window.getComputedStyle(el);
|
|
266
|
+
const outlineNone = style.outlineStyle === "none" || style.outlineWidth === "0px";
|
|
267
|
+
// Check if there's a custom focus indicator (box-shadow or border might serve)
|
|
268
|
+
// Only flag if outline is removed AND there's no visible replacement
|
|
269
|
+
if (outlineNone) {
|
|
270
|
+
// We can't trigger :focus in evaluate, so we check for global outline:none rules
|
|
271
|
+
// by looking at the computed style — if outline is none at rest, it's likely none on focus too
|
|
272
|
+
const hasGlobalOutlineNone = Array.from(document.styleSheets).some(sheet => {
|
|
273
|
+
try {
|
|
274
|
+
return Array.from(sheet.cssRules || []).some(rule => rule.cssText.includes(":focus") && rule.cssText.includes("outline") &&
|
|
275
|
+
(rule.cssText.includes("none") || rule.cssText.includes("0")));
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return false;
|
|
279
|
+
} // cross-origin sheets
|
|
280
|
+
});
|
|
281
|
+
if (hasGlobalOutlineNone) {
|
|
282
|
+
results.outline_none_focusable.push({
|
|
283
|
+
selector: getSelector(el),
|
|
284
|
+
tag: el.tagName.toLowerCase(),
|
|
285
|
+
text: (el.innerText || el.getAttribute("aria-label") || "").slice(0, 60),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Deduplicate outline findings — if we found the global rule, just report once
|
|
291
|
+
if (results.outline_none_focusable.length > 5) {
|
|
292
|
+
results.outline_none_focusable = results.outline_none_focusable.slice(0, 3);
|
|
293
|
+
results.outline_none_focusable.push({
|
|
294
|
+
selector: "*:focus",
|
|
295
|
+
tag: "*",
|
|
296
|
+
text: `Global *:focus{outline:none} detected — affects all ${focusable.length} focusable elements`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
// 4. Small touch targets (< 44px)
|
|
300
|
+
const clickable = document.querySelectorAll("a[href], button, input[type='submit'], input[type='button'], [role='button']");
|
|
301
|
+
for (let i = 0; i < Math.min(clickable.length, 30); i++) {
|
|
302
|
+
const el = clickable[i];
|
|
303
|
+
if (el.offsetWidth === 0 && el.offsetHeight === 0)
|
|
304
|
+
continue;
|
|
305
|
+
const rect = el.getBoundingClientRect();
|
|
306
|
+
if (rect.width < 44 || rect.height < 44) {
|
|
307
|
+
// Only flag if both dimensions are really small — it's not an issue if width=300 height=30
|
|
308
|
+
if (rect.width < 44 && rect.height < 44) {
|
|
309
|
+
results.small_touch_targets.push({
|
|
310
|
+
selector: getSelector(el),
|
|
311
|
+
tag: el.tagName.toLowerCase(),
|
|
312
|
+
text: (el.innerText || el.getAttribute("aria-label") || "").slice(0, 60),
|
|
313
|
+
width: Math.round(rect.width),
|
|
314
|
+
height: Math.round(rect.height),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// 5. Input type mismatches (email-like fields with type="text")
|
|
320
|
+
const textInputs = document.querySelectorAll("input[type='text'], input:not([type])");
|
|
321
|
+
const emailPatterns = /email|e-mail/i;
|
|
322
|
+
const phonePatterns = /phone|tel|mobile/i;
|
|
323
|
+
const urlPatterns = /website|url|homepage/i;
|
|
324
|
+
for (let i = 0; i < Math.min(textInputs.length, 20); i++) {
|
|
325
|
+
const el = textInputs[i];
|
|
326
|
+
const label = el.placeholder || el.name || el.getAttribute("aria-label") || "";
|
|
327
|
+
const labelEl = el.id ? document.querySelector(`label[for="${el.id}"]`) : null;
|
|
328
|
+
const fullLabel = label + (labelEl ? " " + labelEl.textContent : "");
|
|
329
|
+
let suggested = "";
|
|
330
|
+
if (emailPatterns.test(fullLabel))
|
|
331
|
+
suggested = "email";
|
|
332
|
+
else if (phonePatterns.test(fullLabel))
|
|
333
|
+
suggested = "tel";
|
|
334
|
+
else if (urlPatterns.test(fullLabel))
|
|
335
|
+
suggested = "url";
|
|
336
|
+
if (suggested) {
|
|
337
|
+
results.input_type_mismatches.push({
|
|
338
|
+
selector: getSelector(el),
|
|
339
|
+
current_type: el.type || "text",
|
|
340
|
+
suggested_type: suggested,
|
|
341
|
+
label_text: fullLabel.slice(0, 80),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// 6. WCAG contrast ratio failures
|
|
346
|
+
// Use TreeWalker to find ALL text nodes, not just hardcoded tags.
|
|
347
|
+
// This catches <div>, <section>, custom elements — anything with visible text.
|
|
348
|
+
const textNodes = [];
|
|
349
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
|
|
350
|
+
acceptNode(node) {
|
|
351
|
+
const text = (node.textContent || "").trim();
|
|
352
|
+
if (text.length < 2)
|
|
353
|
+
return NodeFilter.FILTER_REJECT;
|
|
354
|
+
const parent = node.parentElement;
|
|
355
|
+
if (!parent)
|
|
356
|
+
return NodeFilter.FILTER_REJECT;
|
|
357
|
+
if (parent.offsetWidth === 0 && parent.offsetHeight === 0)
|
|
358
|
+
return NodeFilter.FILTER_REJECT;
|
|
359
|
+
// Skip script/style/noscript
|
|
360
|
+
const tag = parent.tagName.toLowerCase();
|
|
361
|
+
if (tag === "script" || tag === "style" || tag === "noscript")
|
|
362
|
+
return NodeFilter.FILTER_REJECT;
|
|
363
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
const seenEls = new Set();
|
|
367
|
+
while (walker.nextNode()) {
|
|
368
|
+
const parent = walker.currentNode.parentElement;
|
|
369
|
+
if (!seenEls.has(parent)) {
|
|
370
|
+
seenEls.add(parent);
|
|
371
|
+
textNodes.push(parent);
|
|
372
|
+
}
|
|
373
|
+
if (textNodes.length >= 100)
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
let checked = 0;
|
|
377
|
+
for (let i = 0; i < textNodes.length && checked < 80; i++) {
|
|
378
|
+
const el = textNodes[i];
|
|
379
|
+
const text = (el.innerText || "").trim();
|
|
380
|
+
if (text.length < 2)
|
|
381
|
+
continue;
|
|
382
|
+
checked++;
|
|
383
|
+
const style = window.getComputedStyle(el);
|
|
384
|
+
const fg = parseColor(style.color);
|
|
385
|
+
const bg = getEffectiveBg(el);
|
|
386
|
+
if (!fg || !bg)
|
|
387
|
+
continue;
|
|
388
|
+
// Handle semi-transparent foreground
|
|
389
|
+
let effectiveFg = fg;
|
|
390
|
+
if (fg.a < 1) {
|
|
391
|
+
effectiveFg = {
|
|
392
|
+
r: Math.round(fg.r * fg.a + bg.r * (1 - fg.a)),
|
|
393
|
+
g: Math.round(fg.g * fg.a + bg.g * (1 - fg.a)),
|
|
394
|
+
b: Math.round(fg.b * fg.a + bg.b * (1 - fg.a)),
|
|
395
|
+
a: 1,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
const ratio = contrastRatio(effectiveFg, bg);
|
|
399
|
+
const fontSize = parseFloat(style.fontSize);
|
|
400
|
+
const isBold = parseInt(style.fontWeight) >= 700 || style.fontWeight === "bold";
|
|
401
|
+
const isLargeText = fontSize >= 18 || (fontSize >= 14 && isBold);
|
|
402
|
+
const requiredAA = isLargeText ? 3 : 4.5;
|
|
403
|
+
if (ratio < requiredAA) {
|
|
404
|
+
results.contrast_failures.push({
|
|
405
|
+
selector: getSelector(el),
|
|
406
|
+
text: text.slice(0, 60),
|
|
407
|
+
fg: style.color,
|
|
408
|
+
bg: style.backgroundColor || "inherited",
|
|
409
|
+
ratio: Math.round(ratio * 100) / 100,
|
|
410
|
+
required: requiredAA,
|
|
411
|
+
level: "AA",
|
|
412
|
+
font_size: Math.round(fontSize * 10) / 10,
|
|
413
|
+
is_bold: isBold,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// Cap contrast failures to most severe
|
|
418
|
+
if (results.contrast_failures.length > 10) {
|
|
419
|
+
results.contrast_failures.sort((a, b) => a.ratio - b.ratio);
|
|
420
|
+
results.contrast_failures = results.contrast_failures.slice(0, 10);
|
|
421
|
+
}
|
|
422
|
+
return results;
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
return {
|
|
427
|
+
missing_labels: [],
|
|
428
|
+
missing_alt: [],
|
|
429
|
+
missing_lang: false,
|
|
430
|
+
outline_none_focusable: [],
|
|
431
|
+
small_touch_targets: [],
|
|
432
|
+
input_type_mismatches: [],
|
|
433
|
+
contrast_failures: [],
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Detect dark UX patterns via DOM/JS inspection.
|
|
439
|
+
* Checks for: countdown timers, urgency/scarcity language, confirmshaming.
|
|
440
|
+
*/
|
|
441
|
+
async function runDarkPatternChecks(page) {
|
|
442
|
+
try {
|
|
443
|
+
return await page.evaluate(() => {
|
|
444
|
+
const findings = [];
|
|
445
|
+
function getSelector(el) {
|
|
446
|
+
if (el.id)
|
|
447
|
+
return `#${el.id}`;
|
|
448
|
+
const tag = el.tagName.toLowerCase();
|
|
449
|
+
const cls = el.className && typeof el.className === "string"
|
|
450
|
+
? "." + el.className.trim().split(/\s+/).slice(0, 2).join(".")
|
|
451
|
+
: "";
|
|
452
|
+
return tag + cls;
|
|
453
|
+
}
|
|
454
|
+
// 1. Countdown timers — elements with timer-like text patterns
|
|
455
|
+
const allEls = document.querySelectorAll("*");
|
|
456
|
+
const timerPattern = /\d{1,2}\s*:\s*\d{2}\s*:\s*\d{2}|\d{1,2}h\s*\d{1,2}m|\d+\s*hours?\s*\d+\s*min/i;
|
|
457
|
+
for (let i = 0; i < allEls.length; i++) {
|
|
458
|
+
const el = allEls[i];
|
|
459
|
+
if (el.children.length > 3)
|
|
460
|
+
continue; // skip containers
|
|
461
|
+
const text = (el.innerText || "").trim();
|
|
462
|
+
if (text.length > 0 && text.length < 100 && timerPattern.test(text)) {
|
|
463
|
+
findings.push({ type: "countdown_timer", text: text.slice(0, 80), selector: getSelector(el) });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// 2. Urgency/scarcity language
|
|
467
|
+
const urgencyPatterns = [
|
|
468
|
+
/only\s+\d+\s*(left|remaining|available)/i,
|
|
469
|
+
/offer\s*(expires?|ends?)\s*(in|soon|today|tonight)/i,
|
|
470
|
+
/limited\s*time\s*(offer|deal|only)/i,
|
|
471
|
+
/act\s*now/i,
|
|
472
|
+
/don'?t\s*(miss|get\s*left|wait)/i,
|
|
473
|
+
/hurry/i,
|
|
474
|
+
/\d+\s*people\s*(are\s*)?(viewing|watching|looking)/i,
|
|
475
|
+
/selling\s*fast/i,
|
|
476
|
+
/last\s*chance/i,
|
|
477
|
+
/today\s*only/i,
|
|
478
|
+
];
|
|
479
|
+
const textEls = document.querySelectorAll("h1,h2,h3,h4,h5,h6,p,span,div,a,button,strong,em");
|
|
480
|
+
for (let i = 0; i < Math.min(textEls.length, 200); i++) {
|
|
481
|
+
const el = textEls[i];
|
|
482
|
+
if (el.children.length > 2)
|
|
483
|
+
continue;
|
|
484
|
+
const text = (el.innerText || "").trim();
|
|
485
|
+
if (text.length < 5 || text.length > 200)
|
|
486
|
+
continue;
|
|
487
|
+
for (const pattern of urgencyPatterns) {
|
|
488
|
+
if (pattern.test(text)) {
|
|
489
|
+
findings.push({ type: "urgency_language", text: text.slice(0, 80), selector: getSelector(el) });
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
// 3. Confirmshaming — dismiss/decline buttons with guilt language
|
|
495
|
+
const shamingPatterns = [
|
|
496
|
+
/no,?\s*i\s*(don'?t|do\s*not)\s*want/i,
|
|
497
|
+
/i('?ll|\s*will)\s*(stay|remain)\s*(un|dis)/i,
|
|
498
|
+
/no\s*thanks?,?\s*i\s*(prefer|like|enjoy|want)\s*(to\s*)?(pay|lose|miss|be\s*(poor|broke|stupid))/i,
|
|
499
|
+
/i\s*don'?t\s*(care|need|like)\s*(about\s*)?(sav|money|deal|discount)/i,
|
|
500
|
+
];
|
|
501
|
+
const buttons = document.querySelectorAll("a, button, [role='button']");
|
|
502
|
+
for (let i = 0; i < buttons.length; i++) {
|
|
503
|
+
const el = buttons[i];
|
|
504
|
+
const text = (el.innerText || "").trim();
|
|
505
|
+
if (text.length < 10 || text.length > 100)
|
|
506
|
+
continue;
|
|
507
|
+
for (const pattern of shamingPatterns) {
|
|
508
|
+
if (pattern.test(text)) {
|
|
509
|
+
findings.push({ type: "confirmshaming", text: text.slice(0, 80), selector: getSelector(el) });
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
// 4. Pre-checked opt-in checkboxes (dark pattern — GDPR/consent violation)
|
|
515
|
+
const checkboxes = document.querySelectorAll("input[type='checkbox']");
|
|
516
|
+
for (let i = 0; i < checkboxes.length; i++) {
|
|
517
|
+
const el = checkboxes[i];
|
|
518
|
+
if (!el.checked && !el.defaultChecked)
|
|
519
|
+
continue;
|
|
520
|
+
const labelEl = el.id ? document.querySelector(`label[for="${el.id}"]`) : el.closest("label");
|
|
521
|
+
const labelText = (labelEl ? labelEl.textContent : el.getAttribute("aria-label") || "").trim();
|
|
522
|
+
const isConsent = /newsletter|marketing|offer|email|subscribe|opt.?in|promo|deal/i.test(labelText);
|
|
523
|
+
if (isConsent && labelText.length > 5) {
|
|
524
|
+
findings.push({ type: "pre_checked_opt_in", text: labelText.slice(0, 80), selector: getSelector(el) });
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
// 5. Anchor pricing — crossed-out price with no evidence it was ever valid
|
|
528
|
+
const strikeTags = document.querySelectorAll("del, s, strike, [class*='original-price'], [class*='was-price'], [class*='old-price'], [class*='crossed']");
|
|
529
|
+
for (let i = 0; i < strikeTags.length; i++) {
|
|
530
|
+
const el = strikeTags[i];
|
|
531
|
+
if (el.offsetWidth === 0)
|
|
532
|
+
continue;
|
|
533
|
+
const text = (el.innerText || "").trim();
|
|
534
|
+
if (/[$£€¥₹]?\s*\d+(?:[.,]\d+)?/.test(text) && text.length < 20) {
|
|
535
|
+
findings.push({ type: "anchor_pricing", text: text.slice(0, 40), selector: getSelector(el) });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return findings;
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
return [];
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* WCAG 1.4.12 — Text Spacing violations.
|
|
547
|
+
* Checks line-height, word-spacing, letter-spacing for values that fail WCAG 1.4.12.
|
|
548
|
+
*/
|
|
549
|
+
async function runTextSpacingChecks(page) {
|
|
550
|
+
try {
|
|
551
|
+
return await page.evaluate(() => {
|
|
552
|
+
const violations = [];
|
|
553
|
+
function getSelector(el) {
|
|
554
|
+
if (el.id)
|
|
555
|
+
return `#${el.id}`;
|
|
556
|
+
const tag = el.tagName.toLowerCase();
|
|
557
|
+
const cls = el.className && typeof el.className === "string"
|
|
558
|
+
? "." + el.className.trim().split(/\s+/).slice(0, 2).join(".") : "";
|
|
559
|
+
return tag + cls;
|
|
560
|
+
}
|
|
561
|
+
const textEls = document.querySelectorAll("p, li, td, th, label, span");
|
|
562
|
+
const seenSelectors = new Set();
|
|
563
|
+
for (let i = 0; i < Math.min(textEls.length, 80); i++) {
|
|
564
|
+
const el = textEls[i];
|
|
565
|
+
if (el.offsetWidth === 0 || el.offsetHeight === 0)
|
|
566
|
+
continue;
|
|
567
|
+
if (el.children.length > 5)
|
|
568
|
+
continue;
|
|
569
|
+
const text = (el.innerText || "").trim();
|
|
570
|
+
if (text.length < 20)
|
|
571
|
+
continue;
|
|
572
|
+
const style = window.getComputedStyle(el);
|
|
573
|
+
const fontSize = parseFloat(style.fontSize);
|
|
574
|
+
const sel = getSelector(el);
|
|
575
|
+
if (seenSelectors.has(sel))
|
|
576
|
+
continue;
|
|
577
|
+
seenSelectors.add(sel);
|
|
578
|
+
// line-height
|
|
579
|
+
const lh = style.lineHeight;
|
|
580
|
+
if (lh !== "normal" && !isNaN(parseFloat(lh))) {
|
|
581
|
+
const ratio = parseFloat(lh) / fontSize;
|
|
582
|
+
if (ratio < 1.2 && ratio > 0) {
|
|
583
|
+
violations.push({ selector: sel, property: "line-height", value: lh,
|
|
584
|
+
issue: `line-height ${lh} is ${ratio.toFixed(2)}× font-size (WCAG 1.4.12 requires users can override to ≥1.5×). Hard-coded px values below 1.2× block this.` });
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// word-spacing: negative values collapse words
|
|
588
|
+
const ws = style.wordSpacing;
|
|
589
|
+
if (ws && ws !== "normal" && ws !== "0px" && parseFloat(ws) / fontSize < -0.05) {
|
|
590
|
+
violations.push({ selector: sel, property: "word-spacing", value: ws,
|
|
591
|
+
issue: `word-spacing ${ws} (${(parseFloat(ws) / fontSize).toFixed(3)}em) is negative, collapsing word gaps. WCAG 1.4.12 requires no constraint preventing ≥0.16em spacing.` });
|
|
592
|
+
}
|
|
593
|
+
// letter-spacing: extreme negative values overlap characters
|
|
594
|
+
const ls = style.letterSpacing;
|
|
595
|
+
if (ls && ls !== "normal" && ls !== "0px" && parseFloat(ls) / fontSize < -0.05) {
|
|
596
|
+
violations.push({ selector: sel, property: "letter-spacing", value: ls,
|
|
597
|
+
issue: `letter-spacing ${ls} (${(parseFloat(ls) / fontSize).toFixed(3)}em) is negative, overlapping characters. WCAG 1.4.12 requires no constraint preventing ≥0.12em spacing.` });
|
|
598
|
+
}
|
|
599
|
+
if (violations.length >= 8)
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
// CSS rule scan: hard-coded line-height in px
|
|
603
|
+
try {
|
|
604
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
605
|
+
try {
|
|
606
|
+
for (const rule of Array.from(sheet.cssRules || [])) {
|
|
607
|
+
const css = rule.cssText || "";
|
|
608
|
+
const m = css.match(/line-height\s*:\s*(\d+(?:\.\d+)?)px/);
|
|
609
|
+
if (m && parseFloat(m[1]) < 18) {
|
|
610
|
+
violations.push({ selector: rule.selectorText || "unknown",
|
|
611
|
+
property: "line-height (CSS rule)", value: `${m[1]}px`,
|
|
612
|
+
issue: `Hard-coded line-height: ${m[1]}px. Fails WCAG 1.4.12 — use unitless (e.g. 1.5) or em so it scales with user font-size preferences.` });
|
|
613
|
+
}
|
|
614
|
+
if (violations.length >= 8)
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
catch { /* cross-origin */ }
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
catch { /* ignore */ }
|
|
622
|
+
return { violations: violations.slice(0, 8) };
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
return { violations: [] };
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Deep form validation checks invisible to vision models.
|
|
631
|
+
* Catches: duplicate IDs, onpaste=false, wrong autocomplete, broken label[for],
|
|
632
|
+
* missing required attributes, radio groups without fieldset/legend.
|
|
633
|
+
*/
|
|
634
|
+
async function runFormDeepChecks(page) {
|
|
635
|
+
try {
|
|
636
|
+
return await page.evaluate(() => {
|
|
637
|
+
const findings = [];
|
|
638
|
+
function getSelector(el) {
|
|
639
|
+
if (el.id)
|
|
640
|
+
return `#${el.id}`;
|
|
641
|
+
const tag = el.tagName.toLowerCase();
|
|
642
|
+
const cls = el.className && typeof el.className === "string"
|
|
643
|
+
? "." + el.className.trim().split(/\s+/).slice(0, 2).join(".") : "";
|
|
644
|
+
return tag + cls;
|
|
645
|
+
}
|
|
646
|
+
// 1. Duplicate IDs
|
|
647
|
+
const allIds = new Map();
|
|
648
|
+
document.querySelectorAll("[id]").forEach(el => { allIds.set(el.id, (allIds.get(el.id) || 0) + 1); });
|
|
649
|
+
allIds.forEach((count, id) => {
|
|
650
|
+
if (count > 1)
|
|
651
|
+
findings.push({ type: "duplicate_id", selector: `#${id}`,
|
|
652
|
+
detail: `id="${id}" appears ${count} times. Duplicate IDs break label associations, aria-labelledby, and getElementById. Each ID must be unique.` });
|
|
653
|
+
});
|
|
654
|
+
// 2. onpaste blocked (dark pattern + a11y)
|
|
655
|
+
document.querySelectorAll("input, textarea").forEach(elRaw => {
|
|
656
|
+
const el = elRaw;
|
|
657
|
+
const op = el.getAttribute("onpaste");
|
|
658
|
+
if (op && /return\s+false|preventDefault|false/.test(op)) {
|
|
659
|
+
findings.push({ type: "onpaste_blocked", selector: getSelector(el),
|
|
660
|
+
detail: `onpaste="return false" blocks paste on this field. This frustrates password-manager users and fails WCAG 2.1 SC 3.3.1. Remove the paste restriction.` });
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
// 3. Wrong autocomplete values
|
|
664
|
+
document.querySelectorAll("input[autocomplete], input[name], input[id]").forEach(elRaw => {
|
|
665
|
+
const el = elRaw;
|
|
666
|
+
const ac = el.getAttribute("autocomplete") || "";
|
|
667
|
+
const nameHint = (el.name || el.id || el.placeholder || "").toLowerCase();
|
|
668
|
+
if (ac === "off" && /name|email|phone|address/.test(nameHint)) {
|
|
669
|
+
findings.push({ type: "wrong_autocomplete", selector: getSelector(el),
|
|
670
|
+
detail: `autocomplete="off" on "${nameHint}" field prevents autofill. WCAG 1.3.5 requires correct autocomplete tokens. Use "name" / "email" / "tel" etc.` });
|
|
671
|
+
}
|
|
672
|
+
if (ac === "cc-name" && !/card|credit|payment|cc/.test(nameHint)) {
|
|
673
|
+
findings.push({ type: "wrong_autocomplete", selector: getSelector(el),
|
|
674
|
+
detail: `autocomplete="cc-name" on field "${nameHint}" is incorrect — browsers will fill credit card data into a non-payment field.` });
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
// 4. Broken label[for]↔input[id]
|
|
678
|
+
document.querySelectorAll("label[for]").forEach(elRaw => {
|
|
679
|
+
const label = elRaw;
|
|
680
|
+
const forAttr = label.getAttribute("for");
|
|
681
|
+
const target = document.getElementById(forAttr);
|
|
682
|
+
if (!target) {
|
|
683
|
+
findings.push({ type: "broken_label_for", selector: `label[for="${forAttr}"]`,
|
|
684
|
+
detail: `<label for="${forAttr}"> points to id="${forAttr}" but no element with that id exists. Screen readers cannot associate this label with any input.` });
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
// 5. Required fields with only visual asterisk, no required/aria-required
|
|
688
|
+
document.querySelectorAll("input:not([type='hidden']):not([type='submit']), select, textarea").forEach(elRaw => {
|
|
689
|
+
const el = elRaw;
|
|
690
|
+
if (el.offsetWidth === 0)
|
|
691
|
+
return;
|
|
692
|
+
const hasRequired = el.hasAttribute("required") || el.getAttribute("aria-required") === "true";
|
|
693
|
+
const labelEl = el.id ? document.querySelector(`label[for="${el.id}"]`) : el.closest("label");
|
|
694
|
+
const labelText = labelEl ? (labelEl.textContent || "") : "";
|
|
695
|
+
if ((labelText.includes("*") || labelText.toLowerCase().includes("required")) && !hasRequired) {
|
|
696
|
+
findings.push({ type: "missing_required", selector: getSelector(el),
|
|
697
|
+
detail: `Field "${labelText.trim().slice(0, 60)}" has visual asterisk but no required or aria-required="true". Screen readers won't announce this as required.` });
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
// 6. Radio groups without fieldset/legend
|
|
701
|
+
const radioGroups = new Map();
|
|
702
|
+
document.querySelectorAll("input[type='radio']").forEach(elRaw => {
|
|
703
|
+
const el = elRaw;
|
|
704
|
+
const n = el.name || "__unnamed__";
|
|
705
|
+
if (!radioGroups.has(n))
|
|
706
|
+
radioGroups.set(n, []);
|
|
707
|
+
radioGroups.get(n).push(el);
|
|
708
|
+
});
|
|
709
|
+
radioGroups.forEach((radios, groupName) => {
|
|
710
|
+
if (radios.length < 2)
|
|
711
|
+
return;
|
|
712
|
+
const inFieldset = radios[0].closest("fieldset");
|
|
713
|
+
if (!inFieldset) {
|
|
714
|
+
findings.push({ type: "radio_no_fieldset", selector: `input[name="${groupName}"]`,
|
|
715
|
+
detail: `Radio group "${groupName}" (${radios.length} options) is not in a <fieldset>/<legend>. Screen readers cannot announce the group question. Wrap in <fieldset><legend>Question</legend>...</fieldset>.` });
|
|
716
|
+
}
|
|
717
|
+
else if (!inFieldset.querySelector("legend")?.textContent?.trim()) {
|
|
718
|
+
findings.push({ type: "radio_no_fieldset", selector: `fieldset > input[name="${groupName}"]`,
|
|
719
|
+
detail: `Radio group "${groupName}" has a <fieldset> but missing or empty <legend>. Screen readers need the legend to announce the group question.` });
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
// 7. Broken IDREF targets (aria-describedby, aria-labelledby, aria-controls pointing to non-existent IDs)
|
|
723
|
+
const idrefAttrs = ["aria-describedby", "aria-labelledby", "aria-controls", "aria-owns", "aria-flowto"];
|
|
724
|
+
for (const attr of idrefAttrs) {
|
|
725
|
+
document.querySelectorAll(`[${attr}]`).forEach(elRaw => {
|
|
726
|
+
const el = elRaw;
|
|
727
|
+
const ids = (el.getAttribute(attr) || "").split(/\s+/).filter(Boolean);
|
|
728
|
+
for (const id of ids) {
|
|
729
|
+
if (!document.getElementById(id)) {
|
|
730
|
+
findings.push({ type: "broken_idref", selector: getSelector(el),
|
|
731
|
+
detail: `${attr}="${id}" points to a non-existent element. Screen readers will silently ignore this reference. Either add an element with id="${id}" or remove the ${attr} attribute.` });
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
// 8. Empty critical attribute values (<meta charset="">, empty href, etc.)
|
|
737
|
+
const emptyChecks = [
|
|
738
|
+
{ sel: "meta[charset]", attr: "charset", why: "Empty charset causes encoding issues — set to 'UTF-8'." },
|
|
739
|
+
{ sel: "meta[name='viewport']", attr: "content", why: "Empty viewport content breaks mobile layout." },
|
|
740
|
+
{ sel: "a[href='']", attr: "href", why: "Empty href reloads the page — use href='#' or a button instead." },
|
|
741
|
+
{ sel: "img[alt]", attr: "alt", why: "Empty alt on a non-decorative image hides it from screen readers." },
|
|
742
|
+
];
|
|
743
|
+
for (const { sel, attr, why } of emptyChecks) {
|
|
744
|
+
document.querySelectorAll(sel).forEach(elRaw => {
|
|
745
|
+
const val = elRaw.getAttribute(attr);
|
|
746
|
+
if (val !== null && val.trim() === "") {
|
|
747
|
+
findings.push({ type: "empty_attribute", selector: getSelector(elRaw),
|
|
748
|
+
detail: `${elRaw.tagName.toLowerCase()} has empty ${attr}="". ${why}` });
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
// 9. select[onchange] auto-navigation (WCAG 3.2.2 — no context change on input)
|
|
753
|
+
document.querySelectorAll("select[onchange]").forEach(elRaw => {
|
|
754
|
+
const el = elRaw;
|
|
755
|
+
const onchange = el.getAttribute("onchange") || "";
|
|
756
|
+
if (/location|navigate|redirect|submit|window\.open/i.test(onchange)) {
|
|
757
|
+
findings.push({ type: "select_onchange_nav", selector: getSelector(el),
|
|
758
|
+
detail: `<select onchange="${onchange.slice(0, 60)}"> auto-navigates on selection. This violates WCAG 3.2.2 (no change of context on input). Users must be able to review their selection before the page changes. Add a separate "Go" button instead.` });
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
// 10. target="_blank" links without "(opens in new tab)" or external indicator
|
|
762
|
+
document.querySelectorAll("a[target='_blank']").forEach(elRaw => {
|
|
763
|
+
const el = elRaw;
|
|
764
|
+
if (el.offsetWidth === 0)
|
|
765
|
+
return;
|
|
766
|
+
const text = (el.textContent || "").toLowerCase();
|
|
767
|
+
const ariaLabel = (el.getAttribute("aria-label") || "").toLowerCase();
|
|
768
|
+
const title = (el.getAttribute("title") || "").toLowerCase();
|
|
769
|
+
const combined = text + " " + ariaLabel + " " + title;
|
|
770
|
+
const hasWarning = /new (tab|window)|external|opens in/i.test(combined);
|
|
771
|
+
// Check for an external link icon (svg, icon class, or screen-reader-only text)
|
|
772
|
+
const hasSrText = el.querySelector(".sr-only, .visually-hidden, [class*='screen-reader']");
|
|
773
|
+
if (!hasWarning && !hasSrText) {
|
|
774
|
+
findings.push({ type: "new_tab_no_warning", selector: getSelector(el),
|
|
775
|
+
detail: `Link "${(el.textContent || "").trim().slice(0, 40)}" opens in a new tab (target="_blank") without warning the user. WCAG 3.2.5 requires users be informed of context changes. Add "(opens in new tab)" text, an external link icon, or visually-hidden helper text.` });
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
return { findings: findings.slice(0, 20) };
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
return { findings: [] };
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* CSS interaction bug detection.
|
|
787
|
+
* Catches: overflow:hidden clipping focus rings, z-index click intercepts,
|
|
788
|
+
* missing prefers-reduced-motion guard on CSS animations.
|
|
789
|
+
*/
|
|
790
|
+
async function runCssInteractionChecks(page) {
|
|
791
|
+
try {
|
|
792
|
+
return await page.evaluate(() => {
|
|
793
|
+
const findings = [];
|
|
794
|
+
function getSelector(el) {
|
|
795
|
+
if (el.id)
|
|
796
|
+
return `#${el.id}`;
|
|
797
|
+
const tag = el.tagName.toLowerCase();
|
|
798
|
+
const cls = el.className && typeof el.className === "string"
|
|
799
|
+
? "." + el.className.trim().split(/\s+/).slice(0, 2).join(".") : "";
|
|
800
|
+
return tag + cls;
|
|
801
|
+
}
|
|
802
|
+
// 1. overflow:hidden on parent of focusable elements — clips focus rings
|
|
803
|
+
const focusable = document.querySelectorAll("a[href], button, input, select, textarea, [tabindex]");
|
|
804
|
+
const checkedParents = new Set();
|
|
805
|
+
for (let i = 0; i < Math.min(focusable.length, 40); i++) {
|
|
806
|
+
const el = focusable[i];
|
|
807
|
+
if (el.offsetWidth === 0)
|
|
808
|
+
continue;
|
|
809
|
+
let parent = el.parentElement;
|
|
810
|
+
for (let d = 0; parent && d < 4; d++, parent = parent.parentElement) {
|
|
811
|
+
if (checkedParents.has(parent))
|
|
812
|
+
break;
|
|
813
|
+
const style = window.getComputedStyle(parent);
|
|
814
|
+
if (style.overflow === "hidden" || style.overflowX === "hidden" || style.overflowY === "hidden") {
|
|
815
|
+
checkedParents.add(parent);
|
|
816
|
+
findings.push({ type: "overflow_clips_focus", selector: getSelector(parent),
|
|
817
|
+
detail: `overflow:hidden on ${getSelector(parent)} may clip the focus ring of child ${getSelector(el)}. Focus indicators extend outside element bounds and get cut off. Use padding + overflow:clip or outline-offset to preserve visible focus rings.` });
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
if (findings.filter(f => f.type === "overflow_clips_focus").length >= 3)
|
|
822
|
+
break;
|
|
823
|
+
}
|
|
824
|
+
// 2. Animated absolute/fixed elements with z-index > 1 and no pointer-events:none
|
|
825
|
+
const highZ = document.querySelectorAll("[style*='z-index'], [class*='overlay'], [class*='backdrop'], [class*='decoration']");
|
|
826
|
+
for (let i = 0; i < Math.min(highZ.length, 20); i++) {
|
|
827
|
+
const el = highZ[i];
|
|
828
|
+
if (el.offsetWidth === 0 || el.offsetHeight === 0)
|
|
829
|
+
continue;
|
|
830
|
+
const style = window.getComputedStyle(el);
|
|
831
|
+
if (style.pointerEvents === "none")
|
|
832
|
+
continue;
|
|
833
|
+
const zIndex = parseInt(style.zIndex);
|
|
834
|
+
if (isNaN(zIndex) || zIndex < 2)
|
|
835
|
+
continue;
|
|
836
|
+
const pos = style.position;
|
|
837
|
+
if ((pos === "absolute" || pos === "fixed") &&
|
|
838
|
+
style.animationName && style.animationName !== "none") {
|
|
839
|
+
findings.push({ type: "z_index_intercept", selector: getSelector(el),
|
|
840
|
+
detail: `Animated element (z-index:${zIndex}, position:${pos}) may intercept clicks on content below it. Add pointer-events:none if this is a decorative animation.` });
|
|
841
|
+
}
|
|
842
|
+
if (findings.filter(f => f.type === "z_index_intercept").length >= 3)
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
// 3. Missing / wrong prefers-reduced-motion guard
|
|
846
|
+
let hasAnimation = false;
|
|
847
|
+
let hasReducedMotionGuard = false;
|
|
848
|
+
let reducedMotionInPrintOnly = false;
|
|
849
|
+
try {
|
|
850
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
851
|
+
try {
|
|
852
|
+
for (const rule of Array.from(sheet.cssRules || [])) {
|
|
853
|
+
const css = rule.cssText || "";
|
|
854
|
+
if (/@keyframes|animation\s*:|animation-name\s*:/.test(css))
|
|
855
|
+
hasAnimation = true;
|
|
856
|
+
// Valid guard: @media that includes prefers-reduced-motion but is NOT scoped to print only
|
|
857
|
+
if (/@media[^{]*prefers-reduced-motion/.test(css)) {
|
|
858
|
+
// Check if this is wrongly nested inside @media print
|
|
859
|
+
const isPrintScoped = /^@media\s+print/.test(css.trim()) ||
|
|
860
|
+
/^@media[^{]*\bprint\b[^{]*prefers-reduced-motion/.test(css);
|
|
861
|
+
if (!isPrintScoped) {
|
|
862
|
+
hasReducedMotionGuard = true;
|
|
863
|
+
}
|
|
864
|
+
else {
|
|
865
|
+
reducedMotionInPrintOnly = true;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
catch { /* cross-origin */ }
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
catch { /* ignore */ }
|
|
874
|
+
if (hasAnimation && !hasReducedMotionGuard) {
|
|
875
|
+
findings.push({ type: "missing_reduced_motion", selector: "@keyframes (global)",
|
|
876
|
+
detail: "CSS animations are present but there is no @media (prefers-reduced-motion: reduce) guard. Users with vestibular disorders can experience nausea/seizures. Add: @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }" });
|
|
877
|
+
}
|
|
878
|
+
if (hasAnimation && reducedMotionInPrintOnly && !hasReducedMotionGuard) {
|
|
879
|
+
findings.push({ type: "reduced_motion_wrong_media", selector: "@media print (misplaced)",
|
|
880
|
+
detail: "@media (prefers-reduced-motion) guard is nested inside @media print — it will never apply for screen users. Move it to a standalone @media (prefers-reduced-motion: reduce) block." });
|
|
881
|
+
}
|
|
882
|
+
// 4. transition: all on interactive elements — delays focus ring appearance
|
|
883
|
+
const interactive = document.querySelectorAll("a[href], button, input, select, textarea, [tabindex], [role='button']");
|
|
884
|
+
for (let i = 0; i < Math.min(interactive.length, 30); i++) {
|
|
885
|
+
const el = interactive[i];
|
|
886
|
+
if (el.offsetWidth === 0)
|
|
887
|
+
continue;
|
|
888
|
+
const style = window.getComputedStyle(el);
|
|
889
|
+
const tp = style.transitionProperty || "";
|
|
890
|
+
const td = style.transitionDuration || "0s";
|
|
891
|
+
// Flag `transition: all` with duration >= 0.3s — the focus ring transition is invisible/delayed
|
|
892
|
+
if (/\ball\b/.test(tp)) {
|
|
893
|
+
const durationMs = parseFloat(td) * (/ms/.test(td) ? 1 : 1000);
|
|
894
|
+
if (durationMs >= 300) {
|
|
895
|
+
findings.push({ type: "transition_all_focus", selector: getSelector(el),
|
|
896
|
+
detail: `transition: all ${td} on interactive element ${getSelector(el)}. The "all" keyword includes outline/box-shadow, making the focus indicator fade in slowly instead of appearing instantly. Keyboard users may not see which element is focused. Use specific properties (e.g. transition: background-color 0.3s) instead of "all", or exclude outline: transition: all 0.3s, outline 0s.` });
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (findings.filter(f => f.type === "transition_all_focus").length >= 3)
|
|
900
|
+
break;
|
|
901
|
+
}
|
|
902
|
+
// 5. Focus indicator replaced with border-color only (WCAG 2.4.11 — focus not visible enough)
|
|
903
|
+
// Check CSS rules for :focus styles that only change border-color without outline
|
|
904
|
+
try {
|
|
905
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
906
|
+
try {
|
|
907
|
+
for (const rule of Array.from(sheet.cssRules || [])) {
|
|
908
|
+
const css = rule.cssText || "";
|
|
909
|
+
if (/:focus/.test(css) && /border-color/.test(css) && /outline\s*:\s*(none|0)/.test(css)) {
|
|
910
|
+
findings.push({ type: "focus_border_only", selector: rule.selectorText || ":focus rule",
|
|
911
|
+
detail: `CSS rule "${rule.selectorText || ":focus"}" replaces outline with border-color on focus. Border changes are often too subtle for keyboard users — WCAG 2.4.11 requires a visible focus indicator with at least 2px thickness and sufficient contrast. Keep the outline or add a visible box-shadow alongside the border change.` });
|
|
912
|
+
}
|
|
913
|
+
if (findings.filter(f => f.type === "focus_border_only").length >= 2)
|
|
914
|
+
break;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
catch { /* cross-origin */ }
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
catch { /* ignore */ }
|
|
921
|
+
// 6. pointer-events:none on <label> — breaks click-to-focus affordance
|
|
922
|
+
const labels = document.querySelectorAll("label");
|
|
923
|
+
for (let i = 0; i < Math.min(labels.length, 20); i++) {
|
|
924
|
+
const lbl = labels[i];
|
|
925
|
+
if (lbl.offsetWidth === 0 || lbl.offsetHeight === 0)
|
|
926
|
+
continue;
|
|
927
|
+
const cs = window.getComputedStyle(lbl);
|
|
928
|
+
if (cs.pointerEvents === "none") {
|
|
929
|
+
findings.push({ type: "label_pointer_events_none", selector: getSelector(lbl),
|
|
930
|
+
detail: `<label> has pointer-events:none — clicking the label will not focus or activate its associated input. Remove pointer-events:none from labels; if you need to prevent text selection use user-select:none instead.` });
|
|
931
|
+
}
|
|
932
|
+
if (findings.filter(f => f.type === "label_pointer_events_none").length >= 3)
|
|
933
|
+
break;
|
|
934
|
+
}
|
|
935
|
+
// 7. user-select:none on interactive buttons/links — prevents text copy for AT and users
|
|
936
|
+
const interactiveEls = document.querySelectorAll("button, [role='button'], a[href]");
|
|
937
|
+
for (let i = 0; i < Math.min(interactiveEls.length, 30); i++) {
|
|
938
|
+
const el = interactiveEls[i];
|
|
939
|
+
if (el.offsetWidth === 0 || el.offsetHeight === 0)
|
|
940
|
+
continue;
|
|
941
|
+
const cs = window.getComputedStyle(el);
|
|
942
|
+
if (cs.userSelect === "none" || cs.webkitUserSelect === "none") {
|
|
943
|
+
// Only flag if the button has visible text (not icon-only)
|
|
944
|
+
const text = (el.textContent || "").trim();
|
|
945
|
+
if (text.length > 2) {
|
|
946
|
+
findings.push({ type: "user_select_none_interactive", selector: getSelector(el),
|
|
947
|
+
detail: `Interactive element "${text.slice(0, 40)}" has user-select:none. This prevents users (including those using zoom/magnification software) from selecting and copying button text. Remove user-select:none from interactive controls.` });
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
if (findings.filter(f => f.type === "user_select_none_interactive").length >= 3)
|
|
951
|
+
break;
|
|
952
|
+
}
|
|
953
|
+
return { findings: findings.slice(0, 18) };
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
catch {
|
|
957
|
+
return { findings: [] };
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Color-only state detection and aria-hidden with focusable children.
|
|
962
|
+
* Catches: aria-hidden on ancestors of focusable elements, nav active state
|
|
963
|
+
* communicated only by color, success/error states with no role=status/alert.
|
|
964
|
+
*/
|
|
965
|
+
async function runColorOnlyStateChecks(page) {
|
|
966
|
+
try {
|
|
967
|
+
return await page.evaluate(() => {
|
|
968
|
+
const findings = [];
|
|
969
|
+
function getSelector(el) {
|
|
970
|
+
if (el.id)
|
|
971
|
+
return `#${el.id}`;
|
|
972
|
+
const tag = el.tagName.toLowerCase();
|
|
973
|
+
const cls = el.className && typeof el.className === "string"
|
|
974
|
+
? "." + el.className.trim().split(/\s+/).slice(0, 2).join(".") : "";
|
|
975
|
+
return tag + cls;
|
|
976
|
+
}
|
|
977
|
+
// 1. aria-hidden="true" on ancestors of focusable elements — keyboard trap
|
|
978
|
+
const focusable = document.querySelectorAll("a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])");
|
|
979
|
+
const flaggedHidden = new Set();
|
|
980
|
+
for (let i = 0; i < Math.min(focusable.length, 60); i++) {
|
|
981
|
+
const el = focusable[i];
|
|
982
|
+
if (el.offsetWidth === 0)
|
|
983
|
+
continue;
|
|
984
|
+
let ancestor = el.parentElement;
|
|
985
|
+
while (ancestor) {
|
|
986
|
+
if (flaggedHidden.has(ancestor))
|
|
987
|
+
break;
|
|
988
|
+
if (ancestor.getAttribute("aria-hidden") === "true") {
|
|
989
|
+
flaggedHidden.add(ancestor);
|
|
990
|
+
findings.push({ type: "aria_hidden_focusable", selector: getSelector(ancestor),
|
|
991
|
+
detail: `aria-hidden="true" on ${getSelector(ancestor)} contains focusable ${getSelector(el)}. Critical a11y failure: keyboard users tab into content screen readers skip. Remove aria-hidden or add tabindex="-1" to all child interactive elements.` });
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
ancestor = ancestor.parentElement;
|
|
995
|
+
}
|
|
996
|
+
if (findings.filter(f => f.type === "aria_hidden_focusable").length >= 3)
|
|
997
|
+
break;
|
|
998
|
+
}
|
|
999
|
+
// 2. Color-only active navigation state (no aria-current)
|
|
1000
|
+
const navLinks = document.querySelectorAll("nav a, nav button, [role='navigation'] a, header nav a");
|
|
1001
|
+
const activePat = /\bactive\b|\bcurrent\b|\bselected\b|is-active|nav-active/i;
|
|
1002
|
+
for (let i = 0; i < Math.min(navLinks.length, 30); i++) {
|
|
1003
|
+
const el = navLinks[i];
|
|
1004
|
+
if (el.offsetWidth === 0)
|
|
1005
|
+
continue;
|
|
1006
|
+
const cls = el.className || "";
|
|
1007
|
+
if (activePat.test(cls) && !el.getAttribute("aria-current")) {
|
|
1008
|
+
findings.push({ type: "color_only_active_nav", selector: getSelector(el),
|
|
1009
|
+
detail: `Nav link "${(el.innerText || "").trim().slice(0, 40)}" uses CSS class "${cls.match(activePat)?.[0]}" for active state but has no aria-current="page". Color-only active indicators fail WCAG 1.4.1. Add aria-current="page" to the active nav link.` });
|
|
1010
|
+
}
|
|
1011
|
+
if (findings.filter(f => f.type === "color_only_active_nav").length >= 2)
|
|
1012
|
+
break;
|
|
1013
|
+
}
|
|
1014
|
+
// 3. Success/error states with no role=status/alert or aria-live
|
|
1015
|
+
const statusEls = document.querySelectorAll("[class*='success'], [class*='error'], [class*='field-error'], [class*='alert'], [class*='toast'], [class*='feedback'], [class*='message']");
|
|
1016
|
+
for (let i = 0; i < Math.min(statusEls.length, 15); i++) {
|
|
1017
|
+
const el = statusEls[i];
|
|
1018
|
+
if (el.offsetWidth === 0)
|
|
1019
|
+
continue;
|
|
1020
|
+
const text = (el.innerText || "").trim();
|
|
1021
|
+
if (text.length < 2)
|
|
1022
|
+
continue;
|
|
1023
|
+
const role = el.getAttribute("role");
|
|
1024
|
+
const ariaLive = el.getAttribute("aria-live");
|
|
1025
|
+
if (role !== "status" && role !== "alert" && ariaLive !== "polite" && ariaLive !== "assertive") {
|
|
1026
|
+
const cls = (el.className || "").toLowerCase();
|
|
1027
|
+
const isError = /error|invalid|fail|danger/.test(cls);
|
|
1028
|
+
const isSuccess = /success|valid|confirm/.test(cls);
|
|
1029
|
+
if (isError || isSuccess) {
|
|
1030
|
+
findings.push({ type: isError ? "color_only_error" : "color_only_success", selector: getSelector(el),
|
|
1031
|
+
detail: `${isError ? "Error" : "Success"} state "${text.slice(0, 60)}" has no role="${isError ? "alert" : "status"}" or aria-live. Screen readers won't announce this change. Add role="${isError ? "alert" : "status"}" or aria-live="${isError ? "assertive" : "polite"}".` });
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
if (findings.filter(f => ["color_only_error", "color_only_success"].includes(f.type)).length >= 3)
|
|
1035
|
+
break;
|
|
1036
|
+
}
|
|
1037
|
+
// 4. aria-live="assertive" on content present at page load (not dynamically inserted)
|
|
1038
|
+
// assertive is for urgent live updates — on static content it's ARIA misuse
|
|
1039
|
+
document.querySelectorAll("[aria-live='assertive']").forEach(elRaw => {
|
|
1040
|
+
const el = elRaw;
|
|
1041
|
+
if (el.offsetWidth === 0)
|
|
1042
|
+
return;
|
|
1043
|
+
const text = (el.textContent || "").trim();
|
|
1044
|
+
if (text.length < 5)
|
|
1045
|
+
return;
|
|
1046
|
+
const role = el.getAttribute("role") || "";
|
|
1047
|
+
if (role === "alert" || role === "log")
|
|
1048
|
+
return; // intentional live region
|
|
1049
|
+
findings.push({ type: "aria_live_on_static", selector: getSelector(el),
|
|
1050
|
+
detail: `aria-live="assertive" on static content: "${text.slice(0, 50)}". Assertive live regions interrupt screen readers immediately — they should only be used for urgent updates (errors, warnings). Static content present at page load should not have aria-live="assertive". Remove it, or use aria-live="polite" if updates will be dynamic.` });
|
|
1051
|
+
});
|
|
1052
|
+
// 5. role="alert" on elements present in initial DOM (should only be injected dynamically)
|
|
1053
|
+
document.querySelectorAll("[role='alert']").forEach(elRaw => {
|
|
1054
|
+
const el = elRaw;
|
|
1055
|
+
if (el.offsetWidth === 0)
|
|
1056
|
+
return;
|
|
1057
|
+
const text = (el.textContent || "").trim();
|
|
1058
|
+
if (text.length < 5)
|
|
1059
|
+
return;
|
|
1060
|
+
// If this element has visible static text, it's likely misused
|
|
1061
|
+
// Real alerts should be empty at load and populated by JS on error
|
|
1062
|
+
findings.push({ type: "role_alert_on_static", selector: getSelector(el),
|
|
1063
|
+
detail: `role="alert" on element with static content: "${text.slice(0, 50)}". Alerts are announced immediately by screen readers when they appear in the DOM. If this content is present at page load, it will be announced disruptively every time the page loads. Move role="alert" to a container that starts empty and gets populated by JavaScript when an actual alert occurs.` });
|
|
1064
|
+
});
|
|
1065
|
+
// 6. Hover-only content (data-tooltip, title with critical info, CSS :hover-only visibility)
|
|
1066
|
+
// data-tooltip attributes or [title] on elements that contain pricing/important info
|
|
1067
|
+
const tooltipEls = document.querySelectorAll("[data-tooltip], [data-tip], [title]:not(svg):not(meta):not(link)");
|
|
1068
|
+
for (let i = 0; i < Math.min(tooltipEls.length, 20); i++) {
|
|
1069
|
+
const el = tooltipEls[i];
|
|
1070
|
+
if (el.offsetWidth === 0)
|
|
1071
|
+
continue;
|
|
1072
|
+
const tip = el.getAttribute("data-tooltip") || el.getAttribute("data-tip") || el.getAttribute("title") || "";
|
|
1073
|
+
if (tip.length < 10)
|
|
1074
|
+
continue;
|
|
1075
|
+
// Flag if tooltip contains critical information (pricing, billing, legal, requirements)
|
|
1076
|
+
const isCritical = /pric|bill|charg|fee|cost|requir|important|must|legal|terms|cancel|refund|\$/i.test(tip);
|
|
1077
|
+
if (isCritical) {
|
|
1078
|
+
findings.push({ type: "hover_only_content", selector: getSelector(el),
|
|
1079
|
+
detail: `Critical information hidden in hover-only tooltip: "${tip.slice(0, 60)}". Tooltip content is inaccessible to keyboard users, touch devices, and screen readers. Important information about pricing, billing, or requirements must be visible at all times — move it to visible page content or add it as an expandable/accessible element.` });
|
|
1080
|
+
}
|
|
1081
|
+
if (findings.filter(f => f.type === "hover_only_content").length >= 3)
|
|
1082
|
+
break;
|
|
1083
|
+
}
|
|
1084
|
+
return { findings: findings.slice(0, 18) };
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
catch {
|
|
1088
|
+
return { findings: [] };
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Document semantics and intent checks.
|
|
1093
|
+
* Catches a11y/semantic blind spots often missed by visual-only analysis.
|
|
1094
|
+
*/
|
|
1095
|
+
async function runDocumentSemanticsChecks(page) {
|
|
1096
|
+
try {
|
|
1097
|
+
return await page.evaluate(() => {
|
|
1098
|
+
const findings = [];
|
|
1099
|
+
function getSelector(el) {
|
|
1100
|
+
if (el.id)
|
|
1101
|
+
return `#${el.id}`;
|
|
1102
|
+
const tag = el.tagName.toLowerCase();
|
|
1103
|
+
const cls = el.className && typeof el.className === "string"
|
|
1104
|
+
? "." + el.className.trim().split(/\s+/).slice(0, 2).join(".") : "";
|
|
1105
|
+
return tag + cls;
|
|
1106
|
+
}
|
|
1107
|
+
const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
1108
|
+
function hasLangContext(el) {
|
|
1109
|
+
let cur = el;
|
|
1110
|
+
for (let i = 0; cur && i < 6; i++, cur = cur.parentElement) {
|
|
1111
|
+
const lang = (cur.getAttribute("lang") || "").trim();
|
|
1112
|
+
if (lang)
|
|
1113
|
+
return true;
|
|
1114
|
+
}
|
|
1115
|
+
return false;
|
|
1116
|
+
}
|
|
1117
|
+
function detectScript(text) {
|
|
1118
|
+
if (/[\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]/.test(text))
|
|
1119
|
+
return "cjk";
|
|
1120
|
+
if (/[\u0600-\u06FF]/.test(text))
|
|
1121
|
+
return "arabic";
|
|
1122
|
+
if (/[\u0400-\u04FF]/.test(text))
|
|
1123
|
+
return "cyrillic";
|
|
1124
|
+
if (/[\u0900-\u097F]/.test(text))
|
|
1125
|
+
return "devanagari";
|
|
1126
|
+
return "latin";
|
|
1127
|
+
}
|
|
1128
|
+
// 1) aria-label contradicting visible text
|
|
1129
|
+
const labeled = document.querySelectorAll("a, button, [role='button'], input[type='button'], input[type='submit']");
|
|
1130
|
+
for (let i = 0; i < Math.min(labeled.length, 60); i++) {
|
|
1131
|
+
const el = labeled[i];
|
|
1132
|
+
if (el.offsetWidth === 0)
|
|
1133
|
+
continue;
|
|
1134
|
+
const visible = norm((el.innerText || el.value || "").slice(0, 80));
|
|
1135
|
+
const aria = norm((el.getAttribute("aria-label") || "").slice(0, 80));
|
|
1136
|
+
if (visible.length < 2 || aria.length < 2)
|
|
1137
|
+
continue;
|
|
1138
|
+
if (!visible.includes(aria) && !aria.includes(visible)) {
|
|
1139
|
+
findings.push({
|
|
1140
|
+
type: "aria_label_mismatch",
|
|
1141
|
+
selector: getSelector(el),
|
|
1142
|
+
detail: `aria-label "${aria}" does not match visible text "${visible}". Voice control and screen reader users may hear different control names than what sighted users see.`,
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
if (findings.filter(f => f.type === "aria_label_mismatch").length >= 3)
|
|
1146
|
+
break;
|
|
1147
|
+
}
|
|
1148
|
+
// 2) data table without <th>
|
|
1149
|
+
document.querySelectorAll("table").forEach((tableRaw) => {
|
|
1150
|
+
const table = tableRaw;
|
|
1151
|
+
if (table.offsetWidth === 0)
|
|
1152
|
+
return;
|
|
1153
|
+
const rowCount = table.querySelectorAll("tr").length;
|
|
1154
|
+
const tdCount = table.querySelectorAll("td").length;
|
|
1155
|
+
const hasTh = table.querySelectorAll("th").length > 0;
|
|
1156
|
+
if (rowCount >= 2 && tdCount >= 2 && !hasTh) {
|
|
1157
|
+
findings.push({
|
|
1158
|
+
type: "table_missing_th",
|
|
1159
|
+
selector: getSelector(table),
|
|
1160
|
+
detail: "Data table appears to have rows/columns but no <th> headers. Screen readers cannot announce column context when navigating cells.",
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
});
|
|
1164
|
+
// 3) SVG with no accessible title/name
|
|
1165
|
+
document.querySelectorAll("svg").forEach((svgRaw) => {
|
|
1166
|
+
const svg = svgRaw;
|
|
1167
|
+
const hidden = svg.getAttribute("aria-hidden") === "true";
|
|
1168
|
+
if (hidden)
|
|
1169
|
+
return;
|
|
1170
|
+
const hasTitle = !!svg.querySelector("title");
|
|
1171
|
+
const hasName = !!(svg.getAttribute("aria-label") || svg.getAttribute("aria-labelledby"));
|
|
1172
|
+
if (!hasTitle && !hasName) {
|
|
1173
|
+
findings.push({
|
|
1174
|
+
type: "svg_missing_title",
|
|
1175
|
+
selector: getSelector(svg),
|
|
1176
|
+
detail: "SVG graphic has no <title> and no aria-label/aria-labelledby. Non-decorative SVGs need an accessible name.",
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
// 4) <dialog> without aria-modal
|
|
1181
|
+
document.querySelectorAll("dialog").forEach((dRaw) => {
|
|
1182
|
+
const d = dRaw;
|
|
1183
|
+
if (!d.hasAttribute("open"))
|
|
1184
|
+
return;
|
|
1185
|
+
if (d.getAttribute("aria-modal") !== "true") {
|
|
1186
|
+
findings.push({
|
|
1187
|
+
type: "dialog_missing_aria_modal",
|
|
1188
|
+
selector: getSelector(d),
|
|
1189
|
+
detail: "Open <dialog> is missing aria-modal=\"true\". Assistive tech may not treat this as a modal context.",
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
// 5) <iframe> without title — skip only truly invisible iframes (both dims zero or hidden)
|
|
1194
|
+
document.querySelectorAll("iframe").forEach((fRaw) => {
|
|
1195
|
+
const f = fRaw;
|
|
1196
|
+
const s = window.getComputedStyle(f);
|
|
1197
|
+
if (s.display === "none" || s.visibility === "hidden")
|
|
1198
|
+
return;
|
|
1199
|
+
if (f.offsetWidth === 0 && f.offsetHeight === 0)
|
|
1200
|
+
return;
|
|
1201
|
+
const title = (f.getAttribute("title") || "").trim();
|
|
1202
|
+
if (!title) {
|
|
1203
|
+
findings.push({
|
|
1204
|
+
type: "iframe_missing_title",
|
|
1205
|
+
selector: getSelector(f),
|
|
1206
|
+
detail: "iframe is missing a title attribute. Screen readers announce iframes by title for context. Even 1×1 tracking iframes should have title=\"\" or role=\"presentation\".",
|
|
1207
|
+
});
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
// 6) Positive tabindex trap
|
|
1211
|
+
document.querySelectorAll("[tabindex]").forEach((elRaw) => {
|
|
1212
|
+
const el = elRaw;
|
|
1213
|
+
const t = parseInt(el.getAttribute("tabindex") || "0", 10);
|
|
1214
|
+
if (!Number.isNaN(t) && t > 0) {
|
|
1215
|
+
findings.push({
|
|
1216
|
+
type: "positive_tabindex",
|
|
1217
|
+
selector: getSelector(el),
|
|
1218
|
+
detail: `tabindex=\"${t}\" creates custom tab order and can break expected keyboard navigation flow.`,
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
// 7) Figure without figcaption
|
|
1223
|
+
document.querySelectorAll("figure").forEach((figRaw) => {
|
|
1224
|
+
const fig = figRaw;
|
|
1225
|
+
if (fig.offsetWidth === 0)
|
|
1226
|
+
return;
|
|
1227
|
+
const hasMedia = !!fig.querySelector("img, svg, video, canvas");
|
|
1228
|
+
const cap = fig.querySelector("figcaption");
|
|
1229
|
+
const hasCap = !!cap && !!(cap.textContent || "").trim();
|
|
1230
|
+
if (hasMedia && !hasCap) {
|
|
1231
|
+
findings.push({
|
|
1232
|
+
type: "figure_missing_figcaption",
|
|
1233
|
+
selector: getSelector(fig),
|
|
1234
|
+
detail: "Figure contains media but has no <figcaption>. Context/caption text improves semantics and accessibility.",
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
});
|
|
1238
|
+
// 8) <time> without datetime
|
|
1239
|
+
document.querySelectorAll("time").forEach((tRaw) => {
|
|
1240
|
+
const t = tRaw;
|
|
1241
|
+
const dt = (t.getAttribute("datetime") || "").trim();
|
|
1242
|
+
if (!dt) {
|
|
1243
|
+
findings.push({
|
|
1244
|
+
type: "time_missing_datetime",
|
|
1245
|
+
selector: getSelector(t),
|
|
1246
|
+
detail: "<time> element is missing datetime attribute. Machine-readable date/time is required for robust semantics.",
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
});
|
|
1250
|
+
// 9) Sensory-characteristic instructions
|
|
1251
|
+
const bodyText = (document.body?.innerText || "").slice(0, 20000).toLowerCase();
|
|
1252
|
+
const sensory = /(click|tap|press)\s+the\s+(blue|green|red|left|right|top|bottom)\s+(button|link|icon)/i;
|
|
1253
|
+
const m = bodyText.match(sensory);
|
|
1254
|
+
if (m) {
|
|
1255
|
+
findings.push({
|
|
1256
|
+
type: "sensory_instruction",
|
|
1257
|
+
selector: "body",
|
|
1258
|
+
detail: `Instruction appears to rely on sensory cues only: "${m[0]}". Add non-sensory identifiers (name/label/position-independent instruction).`,
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
// 10) Mixed-language text without local lang annotation
|
|
1262
|
+
const docLang = (document.documentElement.lang || "").toLowerCase();
|
|
1263
|
+
const assumeLatinPrimary = /^(en|fr|de|es|pt|it|nl|sv|no|da|fi|pl)\b/.test(docLang) || docLang === "";
|
|
1264
|
+
if (assumeLatinPrimary) {
|
|
1265
|
+
const textEls = document.querySelectorAll("p, li, span, h1, h2, h3, h4, a, button");
|
|
1266
|
+
for (let i = 0; i < Math.min(textEls.length, 120); i++) {
|
|
1267
|
+
const el = textEls[i];
|
|
1268
|
+
if (el.offsetWidth === 0)
|
|
1269
|
+
continue;
|
|
1270
|
+
const txt = (el.innerText || "").trim();
|
|
1271
|
+
if (txt.length < 10)
|
|
1272
|
+
continue;
|
|
1273
|
+
const script = detectScript(txt);
|
|
1274
|
+
if (script === "latin")
|
|
1275
|
+
continue;
|
|
1276
|
+
if (!hasLangContext(el)) {
|
|
1277
|
+
findings.push({
|
|
1278
|
+
type: "lang_mismatch_missing_lang",
|
|
1279
|
+
selector: getSelector(el),
|
|
1280
|
+
detail: `Text appears to use ${script} script under document lang="${docLang || "unspecified"}" but no local lang attribute is set on or above this element. Add lang on the mixed-language segment so screen readers switch pronunciation correctly.`,
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
if (findings.filter(f => f.type === "lang_mismatch_missing_lang").length >= 3)
|
|
1284
|
+
break;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
// 11) Open modal/dialog with no obvious escape/close control
|
|
1288
|
+
const modalCandidates = document.querySelectorAll("dialog[open], [role='dialog'][aria-modal='true'], [role='alertdialog'][aria-modal='true']");
|
|
1289
|
+
modalCandidates.forEach((mRaw) => {
|
|
1290
|
+
const modal = mRaw;
|
|
1291
|
+
if (modal.offsetWidth === 0 || modal.offsetHeight === 0)
|
|
1292
|
+
return;
|
|
1293
|
+
const rect = modal.getBoundingClientRect();
|
|
1294
|
+
const modalAreaRatio = Math.max(0, rect.width * rect.height) / Math.max(1, window.innerWidth * window.innerHeight);
|
|
1295
|
+
const ariaModal = modal.getAttribute("aria-modal") === "true";
|
|
1296
|
+
const bodyStyle = window.getComputedStyle(document.body);
|
|
1297
|
+
const bodyLocked = /hidden|clip/.test(`${bodyStyle.overflow} ${bodyStyle.overflowX} ${bodyStyle.overflowY}`);
|
|
1298
|
+
const isLikelyBlockingModal = ariaModal || modalAreaRatio >= 0.12;
|
|
1299
|
+
const closeControl = modal.querySelector("button[aria-label*='close' i], button[title*='close' i], [data-dismiss], [data-close], [aria-controls][aria-label*='close' i], .close, .btn-close");
|
|
1300
|
+
const hasCloseTextBtn = Array.from(modal.querySelectorAll("button, a, [role='button']"))
|
|
1301
|
+
.some((el) => /close|dismiss|cancel|×/i.test((el.textContent || "").trim()));
|
|
1302
|
+
if (!closeControl && !hasCloseTextBtn) {
|
|
1303
|
+
findings.push({
|
|
1304
|
+
type: "modal_escape_missing",
|
|
1305
|
+
selector: getSelector(modal),
|
|
1306
|
+
detail: "Modal/dialog appears open but has no obvious close/dismiss control. Keyboard and assistive-technology users may be trapped if Escape handling is missing. Provide a visible close button and ensure Escape closes the modal.",
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
const hasAccessibleName = !!((modal.getAttribute("aria-label") || "").trim()
|
|
1310
|
+
|| (modal.getAttribute("aria-labelledby") || "").trim());
|
|
1311
|
+
const hasHeading = !!modal.querySelector("h1, h2, h3, [role='heading']");
|
|
1312
|
+
if (!hasAccessibleName && !hasHeading) {
|
|
1313
|
+
findings.push({
|
|
1314
|
+
type: "modal_missing_accessible_name",
|
|
1315
|
+
selector: getSelector(modal),
|
|
1316
|
+
detail: "Open modal/dialog has no aria-label/aria-labelledby and no visible heading. Screen reader users may not get context for what this dialog is about.",
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
const outsideFocusable = (isLikelyBlockingModal && (ariaModal || bodyLocked))
|
|
1320
|
+
? Array.from(document.querySelectorAll("a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])")).filter((el) => {
|
|
1321
|
+
if (modal.contains(el))
|
|
1322
|
+
return false;
|
|
1323
|
+
const h = el;
|
|
1324
|
+
if (h.offsetWidth === 0 || h.offsetHeight === 0)
|
|
1325
|
+
return false;
|
|
1326
|
+
if (h.closest("[aria-hidden='true'], [inert]"))
|
|
1327
|
+
return false;
|
|
1328
|
+
if (h.matches("a.skip-link, .skip-link, [data-skip-link]"))
|
|
1329
|
+
return false;
|
|
1330
|
+
const href = (h.getAttribute("href") || "").trim();
|
|
1331
|
+
if (href === "#" || href.startsWith("#"))
|
|
1332
|
+
return false;
|
|
1333
|
+
return true;
|
|
1334
|
+
})
|
|
1335
|
+
: [];
|
|
1336
|
+
if (outsideFocusable.length >= 3) {
|
|
1337
|
+
findings.push({
|
|
1338
|
+
type: "modal_background_focusable",
|
|
1339
|
+
selector: getSelector(modal),
|
|
1340
|
+
detail: `Open modal/dialog has ${outsideFocusable.length} potentially focusable elements outside the dialog that are not inert/aria-hidden. Keyboard focus may escape the modal instead of staying trapped.`,
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
});
|
|
1344
|
+
// 12) Critical above-the-fold image marked lazy
|
|
1345
|
+
const viewportArea = Math.max(1, window.innerWidth * window.innerHeight);
|
|
1346
|
+
document.querySelectorAll("img[loading='lazy']").forEach((imgRaw) => {
|
|
1347
|
+
const img = imgRaw;
|
|
1348
|
+
if (img.offsetWidth === 0 || img.offsetHeight === 0)
|
|
1349
|
+
return;
|
|
1350
|
+
const rect = img.getBoundingClientRect();
|
|
1351
|
+
if (rect.bottom <= 0 || rect.top >= window.innerHeight)
|
|
1352
|
+
return; // not above fold
|
|
1353
|
+
const area = Math.max(0, rect.width * rect.height);
|
|
1354
|
+
const areaRatio = area / viewportArea;
|
|
1355
|
+
if (areaRatio >= 0.08) {
|
|
1356
|
+
findings.push({
|
|
1357
|
+
type: "above_fold_lazy_critical_image",
|
|
1358
|
+
selector: getSelector(img),
|
|
1359
|
+
detail: `Large above-the-fold image (${Math.round(areaRatio * 100)}% of viewport) uses loading="lazy". This can delay LCP and cause visible pop-in. Use eager loading for critical hero/media imagery.`,
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
});
|
|
1363
|
+
// 13) 320px reflow risk via fixed/min widths that cannot shrink
|
|
1364
|
+
const widthLockedEls = document.querySelectorAll("body *");
|
|
1365
|
+
for (let i = 0; i < Math.min(widthLockedEls.length, 220); i++) {
|
|
1366
|
+
const el = widthLockedEls[i];
|
|
1367
|
+
if (el.offsetWidth === 0 || el.offsetHeight === 0)
|
|
1368
|
+
continue;
|
|
1369
|
+
if (el.closest("pre, code, kbd, samp, table, [role='table']"))
|
|
1370
|
+
continue;
|
|
1371
|
+
const cs = window.getComputedStyle(el);
|
|
1372
|
+
const display = cs.display || "";
|
|
1373
|
+
if (display === "inline" || display === "contents")
|
|
1374
|
+
continue;
|
|
1375
|
+
if (cs.overflowX === "auto" || cs.overflowX === "scroll")
|
|
1376
|
+
continue;
|
|
1377
|
+
const minW = parseFloat(cs.minWidth || "0");
|
|
1378
|
+
const declaredW = parseFloat(cs.width || "0");
|
|
1379
|
+
const noShrink = cs.flexShrink === "0";
|
|
1380
|
+
const nowrap = cs.whiteSpace === "nowrap";
|
|
1381
|
+
const isContainerLike = el.children.length > 0 ||
|
|
1382
|
+
display.includes("block") ||
|
|
1383
|
+
display.includes("flex") ||
|
|
1384
|
+
display.includes("grid") ||
|
|
1385
|
+
display.includes("table");
|
|
1386
|
+
if (!isContainerLike)
|
|
1387
|
+
continue;
|
|
1388
|
+
if ((minW > 320 || (declaredW > 360 && noShrink) || (declaredW > 320 && nowrap))) {
|
|
1389
|
+
findings.push({
|
|
1390
|
+
type: "reflow_320_fixed_width",
|
|
1391
|
+
selector: getSelector(el),
|
|
1392
|
+
detail: `Element appears width-locked (min-width ${Math.round(minW)}px, width ${Math.round(declaredW)}px, flex-shrink ${cs.flexShrink}, white-space ${cs.whiteSpace}). This risks horizontal scrolling/failure at 320px viewport width (WCAG reflow).`,
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
if (findings.filter(f => f.type === "reflow_320_fixed_width").length >= 3)
|
|
1396
|
+
break;
|
|
1397
|
+
}
|
|
1398
|
+
// 14) 320px reflow risk via unbreakable nowrap text
|
|
1399
|
+
const textRiskEls = document.querySelectorAll("p, span, a, button, th, td, label");
|
|
1400
|
+
for (let i = 0; i < Math.min(textRiskEls.length, 180); i++) {
|
|
1401
|
+
const el = textRiskEls[i];
|
|
1402
|
+
if (el.offsetWidth === 0 || el.offsetHeight === 0)
|
|
1403
|
+
continue;
|
|
1404
|
+
const cs = window.getComputedStyle(el);
|
|
1405
|
+
if (cs.whiteSpace !== "nowrap")
|
|
1406
|
+
continue;
|
|
1407
|
+
if (el.closest("pre, code, kbd, samp, [role='code']"))
|
|
1408
|
+
continue;
|
|
1409
|
+
const txt = (el.innerText || "").trim();
|
|
1410
|
+
const tokenMatches = txt.match(/[A-Za-z0-9_-]{28,}/g) || [];
|
|
1411
|
+
const longestTokenLen = tokenMatches.reduce((m, t) => Math.max(m, t.length), 0);
|
|
1412
|
+
const hasVeryLongToken = longestTokenLen >= 36;
|
|
1413
|
+
if (!hasVeryLongToken)
|
|
1414
|
+
continue;
|
|
1415
|
+
const fontSize = parseFloat(cs.fontSize || "16") || 16;
|
|
1416
|
+
const approxTokenWidth = longestTokenLen * fontSize * 0.56;
|
|
1417
|
+
if (approxTokenWidth < 260 && el.scrollWidth <= 320)
|
|
1418
|
+
continue;
|
|
1419
|
+
findings.push({
|
|
1420
|
+
type: "reflow_320_unbreakable_text",
|
|
1421
|
+
selector: getSelector(el),
|
|
1422
|
+
detail: "Element uses white-space: nowrap with a long unbroken text token. At 320px viewport width this can force horizontal scrolling or clipped content. Allow wrapping or add overflow-wrap/word-break.",
|
|
1423
|
+
});
|
|
1424
|
+
if (findings.filter(f => f.type === "reflow_320_unbreakable_text").length >= 3)
|
|
1425
|
+
break;
|
|
1426
|
+
}
|
|
1427
|
+
// 15) Wrong html[lang] locale — lang exists but doesn't match detected content
|
|
1428
|
+
// Heuristic: if lang="en-*" but majority of text is non-Latin script, flag it
|
|
1429
|
+
// Also catches lang="en-GB"/"en-AU" mismatches by validating against known locale tags
|
|
1430
|
+
const htmlLang = (document.documentElement.lang || "").toLowerCase().trim();
|
|
1431
|
+
if (htmlLang) {
|
|
1432
|
+
// Check for empty lang="" on any element (invalid per WCAG — empty string is meaningless)
|
|
1433
|
+
document.querySelectorAll("[lang]").forEach((elRaw) => {
|
|
1434
|
+
const el = elRaw;
|
|
1435
|
+
const langVal = (el.getAttribute("lang") || "").trim();
|
|
1436
|
+
if (langVal === "" && el !== document.documentElement) {
|
|
1437
|
+
findings.push({
|
|
1438
|
+
type: "empty_lang_attr",
|
|
1439
|
+
selector: getSelector(el),
|
|
1440
|
+
detail: `Element has lang="" (empty string). An empty lang attribute is invalid — it must be a valid BCP 47 language tag (e.g. lang="fr") or omitted entirely.`,
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
});
|
|
1444
|
+
if (findings.filter(f => f.type === "empty_lang_attr").length === 0) {
|
|
1445
|
+
// Check for suspicious locale mismatches (e.g. lang="en-GB" on a clearly US-focused site)
|
|
1446
|
+
// Detect Cyrillic/CJK/Arabic/Hebrew/Greek majority text under a Latin lang
|
|
1447
|
+
const isLatinLang = /^(en|fr|de|es|pt|it|nl|sv|no|da|fi|pl|cs|sk|ro|hr|hu)\b/.test(htmlLang);
|
|
1448
|
+
if (isLatinLang) {
|
|
1449
|
+
const allText = (document.body?.innerText || "").slice(0, 2000);
|
|
1450
|
+
const nonLatinChars = (allText.match(/[\u0400-\u04FF\u4E00-\u9FFF\u0600-\u06FF\u0590-\u05FF\u0370-\u03FF]/g) || []).length;
|
|
1451
|
+
const ratio = nonLatinChars / Math.max(1, allText.replace(/\s/g, "").length);
|
|
1452
|
+
if (ratio > 0.3) {
|
|
1453
|
+
findings.push({
|
|
1454
|
+
type: "wrong_lang_locale",
|
|
1455
|
+
selector: "html",
|
|
1456
|
+
detail: `html[lang="${htmlLang}"] appears to be set to a Latin/English locale but the majority of visible text uses non-Latin characters. Update the lang attribute to match the actual content language for correct screen reader pronunciation.`,
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
// 16) Fake div/section modal — fixed-position overlay with no role="dialog" or aria-modal
|
|
1463
|
+
const fixedEls = document.querySelectorAll("div, section, aside");
|
|
1464
|
+
let fakeModalCount = 0;
|
|
1465
|
+
for (let i = 0; i < Math.min(fixedEls.length, 60) && fakeModalCount < 2; i++) {
|
|
1466
|
+
const el = fixedEls[i];
|
|
1467
|
+
if (el.offsetWidth === 0 || el.offsetHeight === 0)
|
|
1468
|
+
continue;
|
|
1469
|
+
const cs = window.getComputedStyle(el);
|
|
1470
|
+
if (cs.position !== "fixed" && cs.position !== "absolute")
|
|
1471
|
+
continue;
|
|
1472
|
+
if (cs.zIndex === "auto" || parseInt(cs.zIndex || "0") < 10)
|
|
1473
|
+
continue;
|
|
1474
|
+
// Must cover substantial portion of screen to be modal-like
|
|
1475
|
+
const rect = el.getBoundingClientRect();
|
|
1476
|
+
const coverageX = rect.width / Math.max(1, window.innerWidth);
|
|
1477
|
+
const coverageY = rect.height / Math.max(1, window.innerHeight);
|
|
1478
|
+
if (coverageX < 0.3 || coverageY < 0.2)
|
|
1479
|
+
continue;
|
|
1480
|
+
// Skip elements that already have a valid dialog role
|
|
1481
|
+
const role = (el.getAttribute("role") || "").toLowerCase();
|
|
1482
|
+
if (role === "dialog" || role === "alertdialog")
|
|
1483
|
+
continue;
|
|
1484
|
+
if (el.getAttribute("aria-modal"))
|
|
1485
|
+
continue;
|
|
1486
|
+
if (el.tagName.toLowerCase() === "dialog")
|
|
1487
|
+
continue;
|
|
1488
|
+
// Must be visible
|
|
1489
|
+
if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity || "1") < 0.1)
|
|
1490
|
+
continue;
|
|
1491
|
+
findings.push({
|
|
1492
|
+
type: "fake_modal_div",
|
|
1493
|
+
selector: getSelector(el),
|
|
1494
|
+
detail: `Large fixed/absolute-positioned element (${Math.round(coverageX * 100)}% wide, ${Math.round(coverageY * 100)}% tall, z-index ${cs.zIndex}) appears to be a modal overlay but has no role="dialog", aria-modal="true", or focus trap. Screen reader users may not know a modal is open.`,
|
|
1495
|
+
});
|
|
1496
|
+
fakeModalCount++;
|
|
1497
|
+
}
|
|
1498
|
+
// 17) <a> with no href used as interactive CTA (onclick-only fake button)
|
|
1499
|
+
document.querySelectorAll("a:not([href])").forEach((elRaw) => {
|
|
1500
|
+
const el = elRaw;
|
|
1501
|
+
if (el.offsetWidth === 0 || el.offsetHeight === 0)
|
|
1502
|
+
return;
|
|
1503
|
+
const hasOnclick = el.hasAttribute("onclick") || el.getAttribute("role") === "button";
|
|
1504
|
+
const text = (el.textContent || el.getAttribute("aria-label") || "").trim();
|
|
1505
|
+
if (hasOnclick || text.length > 0) {
|
|
1506
|
+
findings.push({
|
|
1507
|
+
type: "anchor_no_href",
|
|
1508
|
+
selector: getSelector(el),
|
|
1509
|
+
detail: `<a> element "${text.slice(0, 50)}" has no href attribute. Without href, screen readers do not announce it as a link and keyboard users cannot Tab to it. Use <button> for actions, or add href for navigation. If JS-driven, add href="#" and handle keyboard events.`,
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
if (findings.filter(f => f.type === "anchor_no_href").length >= 3)
|
|
1513
|
+
return;
|
|
1514
|
+
});
|
|
1515
|
+
// 18) <details>/<summary> misused as navigation widget
|
|
1516
|
+
document.querySelectorAll("details").forEach((detRaw) => {
|
|
1517
|
+
const det = detRaw;
|
|
1518
|
+
const links = det.querySelectorAll("a[href]");
|
|
1519
|
+
const nonSummaryLinks = Array.from(links).filter((l) => !det.querySelector("summary")?.contains(l));
|
|
1520
|
+
if (nonSummaryLinks.length >= 2) {
|
|
1521
|
+
findings.push({
|
|
1522
|
+
type: "details_misused_as_nav",
|
|
1523
|
+
selector: getSelector(det),
|
|
1524
|
+
detail: `<details>/<summary> contains ${nonSummaryLinks.length} navigation links. This element has disclosure semantics (show/hide), not navigation semantics. Use <nav> with a visible list of links instead. Screen readers announce <details> as a disclosure widget, confusing users expecting a navigation menu.`,
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
});
|
|
1528
|
+
// 19) aria-label on price/value element announcing different value than visible text
|
|
1529
|
+
const priceRe = /\$[\d,]+|\d+(?:\.\d+)?\s*(?:USD|EUR|GBP|\/mo|per month|\/year)/i;
|
|
1530
|
+
document.querySelectorAll("[aria-label]").forEach((elRaw) => {
|
|
1531
|
+
const el = elRaw;
|
|
1532
|
+
if (el.offsetWidth === 0 || el.offsetHeight === 0)
|
|
1533
|
+
return;
|
|
1534
|
+
const ariaLabel = (el.getAttribute("aria-label") || "").trim();
|
|
1535
|
+
const visibleText = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ");
|
|
1536
|
+
if (!ariaLabel || !visibleText || ariaLabel.toLowerCase() === visibleText.toLowerCase())
|
|
1537
|
+
return;
|
|
1538
|
+
// Only flag if both aria-label and visible text contain price-like values that differ
|
|
1539
|
+
const ariaPrice = ariaLabel.match(priceRe)?.[0];
|
|
1540
|
+
const visiblePrice = visibleText.match(priceRe)?.[0];
|
|
1541
|
+
if (ariaPrice && visiblePrice && ariaPrice !== visiblePrice) {
|
|
1542
|
+
findings.push({
|
|
1543
|
+
type: "aria_label_price_mismatch",
|
|
1544
|
+
selector: getSelector(el),
|
|
1545
|
+
detail: `aria-label announces "${ariaLabel}" but visible text shows "${visibleText.slice(0, 80)}". The price/value differs between what screen readers hear and what sighted users see. This can be used to deceive AT users. Ensure aria-label matches visible content, or remove aria-label and rely on visible text.`,
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
});
|
|
1549
|
+
// 20) <progress> with no accessible name
|
|
1550
|
+
document.querySelectorAll("progress").forEach((elRaw) => {
|
|
1551
|
+
const el = elRaw;
|
|
1552
|
+
if (el.offsetWidth === 0 || el.offsetHeight === 0)
|
|
1553
|
+
return;
|
|
1554
|
+
const hasLabel = el.getAttribute("aria-label") ||
|
|
1555
|
+
el.getAttribute("aria-labelledby") ||
|
|
1556
|
+
el.getAttribute("title") ||
|
|
1557
|
+
el.id && document.querySelector(`label[for="${el.id}"]`);
|
|
1558
|
+
if (!hasLabel) {
|
|
1559
|
+
findings.push({
|
|
1560
|
+
type: "progress_no_name",
|
|
1561
|
+
selector: getSelector(el),
|
|
1562
|
+
detail: `<progress> element has no accessible name (no aria-label, aria-labelledby, or associated <label>). Screen readers will announce "progress bar" with no context. Add aria-label="Loading..." or a visible <label> element.`,
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
});
|
|
1566
|
+
// 21) Error summary placed after submit button in DOM order
|
|
1567
|
+
const forms = document.querySelectorAll("form");
|
|
1568
|
+
forms.forEach((form) => {
|
|
1569
|
+
const submitBtn = form.querySelector("button[type='submit'], input[type='submit'], button:not([type])");
|
|
1570
|
+
if (!submitBtn)
|
|
1571
|
+
return;
|
|
1572
|
+
const allEls = Array.from(form.querySelectorAll("*"));
|
|
1573
|
+
const submitIdx = allEls.indexOf(submitBtn);
|
|
1574
|
+
for (let i = submitIdx + 1; i < allEls.length; i++) {
|
|
1575
|
+
const el = allEls[i];
|
|
1576
|
+
const role = (el.getAttribute("role") || "").toLowerCase();
|
|
1577
|
+
const hasAriaLive = el.getAttribute("aria-live");
|
|
1578
|
+
if ((role === "alert" || role === "status" || hasAriaLive) && el.offsetWidth > 0) {
|
|
1579
|
+
findings.push({
|
|
1580
|
+
type: "error_summary_below_submit",
|
|
1581
|
+
selector: getSelector(el),
|
|
1582
|
+
detail: `Error/status container (role="${role || "aria-live"}" ) appears after the submit button in DOM order. When a form is submitted and errors appear, focus must be programmatically moved to the error summary — if it's below the submit button, users may miss it. Place the error summary above the first form field or move focus explicitly on submission.`,
|
|
1583
|
+
});
|
|
1584
|
+
break;
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
1588
|
+
// 22) aria-owns pointing to elements that are already DOM children (double-ownership)
|
|
1589
|
+
document.querySelectorAll("[aria-owns]").forEach((elRaw) => {
|
|
1590
|
+
const el = elRaw;
|
|
1591
|
+
const ownsIds = (el.getAttribute("aria-owns") || "").split(/\s+/).filter(Boolean);
|
|
1592
|
+
for (const id of ownsIds) {
|
|
1593
|
+
const owned = document.getElementById(id);
|
|
1594
|
+
if (owned && el.contains(owned)) {
|
|
1595
|
+
findings.push({
|
|
1596
|
+
type: "aria_owns_invalid",
|
|
1597
|
+
selector: getSelector(el),
|
|
1598
|
+
detail: `aria-owns="${id}" claims ownership of an element that is already a DOM child. aria-owns is only needed for elements outside the DOM subtree. Remove the redundant aria-owns attribute to avoid confusing AT about the element's location in the accessibility tree.`,
|
|
1599
|
+
});
|
|
1600
|
+
break;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
if (findings.filter(f => f.type === "aria_owns_invalid").length >= 3)
|
|
1604
|
+
return;
|
|
1605
|
+
});
|
|
1606
|
+
// 23) Skip link target missing tabindex="-1" (non-focusable destination)
|
|
1607
|
+
const skipLinks = document.querySelectorAll("a[href^='#']");
|
|
1608
|
+
for (let i = 0; i < Math.min(skipLinks.length, 10); i++) {
|
|
1609
|
+
const link = skipLinks[i];
|
|
1610
|
+
const text = (link.textContent || link.getAttribute("aria-label") || "").toLowerCase();
|
|
1611
|
+
if (!/skip|jump|main content|content/i.test(text))
|
|
1612
|
+
continue;
|
|
1613
|
+
const targetId = (link.getAttribute("href") || "").slice(1);
|
|
1614
|
+
if (!targetId)
|
|
1615
|
+
continue;
|
|
1616
|
+
const target = document.getElementById(targetId);
|
|
1617
|
+
if (!target)
|
|
1618
|
+
continue;
|
|
1619
|
+
// Natively focusable elements are fine; non-focusable ones need tabindex="-1"
|
|
1620
|
+
const nativeFocusable = /^(a|button|input|select|textarea)$/i.test(target.tagName);
|
|
1621
|
+
if (!nativeFocusable && target.getAttribute("tabindex") === null) {
|
|
1622
|
+
findings.push({
|
|
1623
|
+
type: "skip_link_target_no_tabindex",
|
|
1624
|
+
selector: getSelector(link),
|
|
1625
|
+
detail: `Skip link points to #${targetId} but that element (${target.tagName.toLowerCase()}) is not natively focusable and has no tabindex="-1". In some browsers, programmatic focus will fail silently. Add tabindex="-1" to the skip link target: <${target.tagName.toLowerCase()} id="${targetId}" tabindex="-1">.`,
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
if (findings.filter(f => f.type === "skip_link_target_no_tabindex").length >= 2)
|
|
1629
|
+
break;
|
|
1630
|
+
}
|
|
1631
|
+
// 24) visibility:hidden on role="alert" or role="status" — in AT tree but invisible
|
|
1632
|
+
document.querySelectorAll("[role='alert'], [role='status'], [aria-live]").forEach((elRaw) => {
|
|
1633
|
+
const el = elRaw;
|
|
1634
|
+
const cs = window.getComputedStyle(el);
|
|
1635
|
+
if (cs.visibility === "hidden") {
|
|
1636
|
+
const role = el.getAttribute("role") || `aria-live="${el.getAttribute("aria-live")}"`;
|
|
1637
|
+
findings.push({
|
|
1638
|
+
type: "visibility_hidden_alert",
|
|
1639
|
+
selector: getSelector(el),
|
|
1640
|
+
detail: `Element with ${role} has visibility:hidden — it remains in the accessibility tree and screen readers may announce it, but it is visually invisible to sighted users. Use display:none to fully hide elements that should not be announced, or remove the ARIA role/live attribute when hiding.`,
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
if (findings.filter(f => f.type === "visibility_hidden_alert").length >= 3)
|
|
1644
|
+
return;
|
|
1645
|
+
});
|
|
1646
|
+
// 25) Disabled input with adjacent visible error text — invisible to AT
|
|
1647
|
+
const disabledInputs = document.querySelectorAll("input[disabled], textarea[disabled]");
|
|
1648
|
+
for (let i = 0; i < Math.min(disabledInputs.length, 20); i++) {
|
|
1649
|
+
const inp = disabledInputs[i];
|
|
1650
|
+
// Look for a sibling or parent-child error message
|
|
1651
|
+
const parent = inp.parentElement;
|
|
1652
|
+
if (!parent)
|
|
1653
|
+
continue;
|
|
1654
|
+
// Check siblings and nearby elements for error-like text
|
|
1655
|
+
const siblings = Array.from(parent.children);
|
|
1656
|
+
const errorSibling = siblings.find((sib) => {
|
|
1657
|
+
if (sib === inp)
|
|
1658
|
+
return false;
|
|
1659
|
+
const sibEl = sib;
|
|
1660
|
+
if (sibEl.offsetWidth === 0 || sibEl.offsetHeight === 0)
|
|
1661
|
+
return false;
|
|
1662
|
+
const text = (sibEl.textContent || "").toLowerCase().trim();
|
|
1663
|
+
return /\b(error|invalid|required|must|cannot|not allowed|please)\b/.test(text) && text.length > 5;
|
|
1664
|
+
});
|
|
1665
|
+
if (errorSibling) {
|
|
1666
|
+
findings.push({
|
|
1667
|
+
type: "disabled_input_adjacent_error",
|
|
1668
|
+
selector: getSelector(inp),
|
|
1669
|
+
detail: `Disabled input has an adjacent error/validation message ("${(errorSibling.textContent || "").trim().slice(0, 60)}") but the input is disabled — screen readers may not associate the error with a focusable element, leaving AT users without context. Avoid attaching errors to disabled inputs; show the reason inline instead.`,
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
return { findings: findings.slice(0, 28) };
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
catch {
|
|
1677
|
+
return { findings: [] };
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* Detect structural UX issues via DOM measurement.
|
|
1682
|
+
* Catches: CTA competition, ghost buttons, spacing inconsistencies, heading hierarchy violations.
|
|
1683
|
+
* These are issues that vision models struggle with but DOM measurements make definitive.
|
|
1684
|
+
*/
|
|
1685
|
+
async function runStructuralUXChecks(page) {
|
|
1686
|
+
try {
|
|
1687
|
+
return await page.evaluate(() => {
|
|
1688
|
+
function getSelector(el) {
|
|
1689
|
+
if (el.id)
|
|
1690
|
+
return `#${el.id}`;
|
|
1691
|
+
const tag = el.tagName.toLowerCase();
|
|
1692
|
+
const cls = el.className && typeof el.className === "string"
|
|
1693
|
+
? "." + el.className.trim().split(/\s+/).slice(0, 2).join(".")
|
|
1694
|
+
: "";
|
|
1695
|
+
return tag + cls;
|
|
1696
|
+
}
|
|
1697
|
+
function parseColor(color) {
|
|
1698
|
+
if (!color || color === "transparent" || color === "rgba(0, 0, 0, 0)")
|
|
1699
|
+
return null;
|
|
1700
|
+
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
|
|
1701
|
+
if (m)
|
|
1702
|
+
return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
|
|
1703
|
+
const hm = color.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
|
|
1704
|
+
if (hm)
|
|
1705
|
+
return { r: parseInt(hm[1], 16), g: parseInt(hm[2], 16), b: parseInt(hm[3], 16), a: 1 };
|
|
1706
|
+
return null;
|
|
1707
|
+
}
|
|
1708
|
+
function linearize(c) {
|
|
1709
|
+
const s = c / 255;
|
|
1710
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
1711
|
+
}
|
|
1712
|
+
function luminance(r, g, b) {
|
|
1713
|
+
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
|
|
1714
|
+
}
|
|
1715
|
+
function contrastRatio(c1, c2) {
|
|
1716
|
+
const l1 = luminance(c1.r, c1.g, c1.b);
|
|
1717
|
+
const l2 = luminance(c2.r, c2.g, c2.b);
|
|
1718
|
+
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
1719
|
+
}
|
|
1720
|
+
function getEffectiveBg(el) {
|
|
1721
|
+
let current = el;
|
|
1722
|
+
for (let depth = 0; current && depth < 10; depth++) {
|
|
1723
|
+
const bgColor = parseColor(window.getComputedStyle(current).backgroundColor);
|
|
1724
|
+
if (bgColor && bgColor.a > 0.1) {
|
|
1725
|
+
if (bgColor.a < 1) {
|
|
1726
|
+
return {
|
|
1727
|
+
r: Math.round(bgColor.r * bgColor.a + 255 * (1 - bgColor.a)),
|
|
1728
|
+
g: Math.round(bgColor.g * bgColor.a + 255 * (1 - bgColor.a)),
|
|
1729
|
+
b: Math.round(bgColor.b * bgColor.a + 255 * (1 - bgColor.a)),
|
|
1730
|
+
};
|
|
1731
|
+
}
|
|
1732
|
+
return bgColor;
|
|
1733
|
+
}
|
|
1734
|
+
current = current.parentElement;
|
|
1735
|
+
}
|
|
1736
|
+
return { r: 255, g: 255, b: 255 };
|
|
1737
|
+
}
|
|
1738
|
+
const results = {
|
|
1739
|
+
cta_competition: [],
|
|
1740
|
+
ghost_buttons: [],
|
|
1741
|
+
spacing_inconsistencies: [],
|
|
1742
|
+
heading_hierarchy_violations: [],
|
|
1743
|
+
};
|
|
1744
|
+
// ── 1. CTA Competition — multiple primary-styled buttons in one section ──
|
|
1745
|
+
const sections = document.querySelectorAll("section, [role='region'], main > div, .section, .container");
|
|
1746
|
+
for (let i = 0; i < Math.min(sections.length, 20); i++) {
|
|
1747
|
+
const section = sections[i];
|
|
1748
|
+
if (section.offsetWidth === 0 && section.offsetHeight === 0)
|
|
1749
|
+
continue;
|
|
1750
|
+
const buttons = section.querySelectorAll("a, button, [role='button']");
|
|
1751
|
+
const primaryCTAs = [];
|
|
1752
|
+
for (let j = 0; j < buttons.length; j++) {
|
|
1753
|
+
const btn = buttons[j];
|
|
1754
|
+
const text = (btn.innerText || "").trim();
|
|
1755
|
+
if (text.length < 2 || text.length > 60)
|
|
1756
|
+
continue;
|
|
1757
|
+
const style = window.getComputedStyle(btn);
|
|
1758
|
+
const bg = parseColor(style.backgroundColor);
|
|
1759
|
+
const hasBg = bg && bg.a > 0.5 && !(bg.r > 240 && bg.g > 240 && bg.b > 240); // not white/transparent
|
|
1760
|
+
const fontSize = parseFloat(style.fontSize);
|
|
1761
|
+
const isBold = parseInt(style.fontWeight) >= 600;
|
|
1762
|
+
// A "primary" CTA: has non-white background, decent size, bold-ish
|
|
1763
|
+
if (hasBg && (fontSize >= 14 || isBold)) {
|
|
1764
|
+
primaryCTAs.push(text.slice(0, 40));
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
if (primaryCTAs.length >= 3) {
|
|
1768
|
+
results.cta_competition.push({
|
|
1769
|
+
section_selector: getSelector(section),
|
|
1770
|
+
cta_count: primaryCTAs.length,
|
|
1771
|
+
cta_texts: primaryCTAs.slice(0, 5),
|
|
1772
|
+
});
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
// ── 2. Ghost Buttons — near-invisible buttons on their background ──
|
|
1776
|
+
const allButtons = document.querySelectorAll("a, button, [role='button']");
|
|
1777
|
+
for (let i = 0; i < Math.min(allButtons.length, 40); i++) {
|
|
1778
|
+
const btn = allButtons[i];
|
|
1779
|
+
if (btn.offsetWidth === 0 && btn.offsetHeight === 0)
|
|
1780
|
+
continue;
|
|
1781
|
+
const text = (btn.innerText || "").trim();
|
|
1782
|
+
if (text.length < 2)
|
|
1783
|
+
continue;
|
|
1784
|
+
const style = window.getComputedStyle(btn);
|
|
1785
|
+
const btnBg = parseColor(style.backgroundColor);
|
|
1786
|
+
const btnColor = parseColor(style.color);
|
|
1787
|
+
const btnBorder = parseColor(style.borderColor);
|
|
1788
|
+
const parentBg = getEffectiveBg(btn.parentElement || btn);
|
|
1789
|
+
// Ghost button pattern: transparent/no bg, relies on border/text color
|
|
1790
|
+
const isGhost = !btnBg || btnBg.a < 0.3;
|
|
1791
|
+
if (isGhost && parentBg) {
|
|
1792
|
+
// Check if button text/border contrasts enough with parent background
|
|
1793
|
+
const textContrast = btnColor ? contrastRatio(btnColor, parentBg) : 21;
|
|
1794
|
+
const borderContrast = btnBorder && btnBorder.a > 0.3 ? contrastRatio(btnBorder, parentBg) : 21;
|
|
1795
|
+
const effectiveContrast = Math.min(textContrast, borderContrast);
|
|
1796
|
+
if (effectiveContrast < 2.5) {
|
|
1797
|
+
results.ghost_buttons.push({
|
|
1798
|
+
selector: getSelector(btn),
|
|
1799
|
+
text: text.slice(0, 40),
|
|
1800
|
+
opacity: btnBg ? btnBg.a : 0,
|
|
1801
|
+
bg_contrast: Math.round(effectiveContrast * 100) / 100,
|
|
1802
|
+
});
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
// ── 3. Spacing Inconsistencies — siblings with wildly different gaps ──
|
|
1807
|
+
// Check flex/grid containers for children with inconsistent margin/padding
|
|
1808
|
+
const containers = document.querySelectorAll("[class*='grid'], [class*='flex'], [class*='card'], [class*='feature']");
|
|
1809
|
+
const checkedParents = new Set();
|
|
1810
|
+
for (let i = 0; i < Math.min(containers.length, 30); i++) {
|
|
1811
|
+
const container = containers[i];
|
|
1812
|
+
const parent = container.parentElement;
|
|
1813
|
+
if (!parent || checkedParents.has(parent))
|
|
1814
|
+
continue;
|
|
1815
|
+
// Find siblings of the same type (cards, items, etc.)
|
|
1816
|
+
const siblings = Array.from(parent.children).filter(child => {
|
|
1817
|
+
const ch = child;
|
|
1818
|
+
return ch.offsetWidth > 0 && ch.offsetHeight > 0 &&
|
|
1819
|
+
ch.tagName === container.tagName &&
|
|
1820
|
+
ch.className === container.className;
|
|
1821
|
+
});
|
|
1822
|
+
if (siblings.length < 3)
|
|
1823
|
+
continue;
|
|
1824
|
+
checkedParents.add(parent);
|
|
1825
|
+
// Check margin-bottom and padding-bottom consistency
|
|
1826
|
+
for (const prop of ["marginBottom", "paddingBottom", "marginTop", "paddingTop"]) {
|
|
1827
|
+
const values = [];
|
|
1828
|
+
for (const sib of siblings) {
|
|
1829
|
+
values.push(parseFloat(window.getComputedStyle(sib)[prop]) || 0);
|
|
1830
|
+
}
|
|
1831
|
+
// Find if any value is an outlier (differs from median by >50% AND >10px)
|
|
1832
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
1833
|
+
const median = sorted[Math.floor(sorted.length / 2)];
|
|
1834
|
+
if (median < 8)
|
|
1835
|
+
continue; // skip trivial spacing
|
|
1836
|
+
for (let k = 0; k < values.length; k++) {
|
|
1837
|
+
const diff = Math.abs(values[k] - median);
|
|
1838
|
+
if (diff > median * 0.5 && diff > 10) {
|
|
1839
|
+
const cssProp = prop.replace(/([A-Z])/g, "-$1").toLowerCase();
|
|
1840
|
+
results.spacing_inconsistencies.push({
|
|
1841
|
+
parent_selector: getSelector(parent),
|
|
1842
|
+
property: cssProp,
|
|
1843
|
+
values_px: values.map(v => Math.round(v)),
|
|
1844
|
+
outlier_index: k,
|
|
1845
|
+
outlier_selector: getSelector(siblings[k]),
|
|
1846
|
+
});
|
|
1847
|
+
break; // one finding per property per container
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
// ── 4. Heading Hierarchy Violations — h2 smaller than h3, etc. ──
|
|
1853
|
+
const headings = document.querySelectorAll("h1, h2, h3, h4, h5, h6");
|
|
1854
|
+
for (let i = 0; i < headings.length - 1; i++) {
|
|
1855
|
+
const current = headings[i];
|
|
1856
|
+
const next = headings[i + 1];
|
|
1857
|
+
if (current.offsetWidth === 0 || next.offsetWidth === 0)
|
|
1858
|
+
continue;
|
|
1859
|
+
const currentLevel = parseInt(current.tagName[1]);
|
|
1860
|
+
const nextLevel = parseInt(next.tagName[1]);
|
|
1861
|
+
// Only check when next heading SHOULD be smaller (higher number = lower priority)
|
|
1862
|
+
if (nextLevel <= currentLevel)
|
|
1863
|
+
continue;
|
|
1864
|
+
const currentSize = parseFloat(window.getComputedStyle(current).fontSize);
|
|
1865
|
+
const nextSize = parseFloat(window.getComputedStyle(next).fontSize);
|
|
1866
|
+
// Violation: lower-priority heading is visually larger
|
|
1867
|
+
if (nextSize > currentSize + 2) { // 2px tolerance
|
|
1868
|
+
results.heading_hierarchy_violations.push({
|
|
1869
|
+
larger_selector: getSelector(next),
|
|
1870
|
+
larger_tag: next.tagName.toLowerCase(),
|
|
1871
|
+
larger_size_px: Math.round(nextSize * 10) / 10,
|
|
1872
|
+
smaller_selector: getSelector(current),
|
|
1873
|
+
smaller_tag: current.tagName.toLowerCase(),
|
|
1874
|
+
smaller_size_px: Math.round(currentSize * 10) / 10,
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
// Also check heading vs adjacent non-heading text that's visually larger
|
|
1879
|
+
for (let i = 0; i < headings.length; i++) {
|
|
1880
|
+
const heading = headings[i];
|
|
1881
|
+
if (heading.offsetWidth === 0)
|
|
1882
|
+
continue;
|
|
1883
|
+
const headingSize = parseFloat(window.getComputedStyle(heading).fontSize);
|
|
1884
|
+
// Check next visible sibling
|
|
1885
|
+
let nextSib = heading.nextElementSibling;
|
|
1886
|
+
while (nextSib && nextSib.offsetWidth === 0) {
|
|
1887
|
+
nextSib = nextSib.nextElementSibling;
|
|
1888
|
+
}
|
|
1889
|
+
if (!nextSib || ["H1", "H2", "H3", "H4", "H5", "H6"].includes(nextSib.tagName))
|
|
1890
|
+
continue;
|
|
1891
|
+
const sibSize = parseFloat(window.getComputedStyle(nextSib).fontSize);
|
|
1892
|
+
if (sibSize > headingSize + 4) { // 4px tolerance for non-heading vs heading
|
|
1893
|
+
results.heading_hierarchy_violations.push({
|
|
1894
|
+
larger_selector: getSelector(nextSib),
|
|
1895
|
+
larger_tag: nextSib.tagName.toLowerCase(),
|
|
1896
|
+
larger_size_px: Math.round(sibSize * 10) / 10,
|
|
1897
|
+
smaller_selector: getSelector(heading),
|
|
1898
|
+
smaller_tag: heading.tagName.toLowerCase(),
|
|
1899
|
+
smaller_size_px: Math.round(headingSize * 10) / 10,
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
// Cap results
|
|
1904
|
+
if (results.cta_competition.length > 5)
|
|
1905
|
+
results.cta_competition = results.cta_competition.slice(0, 5);
|
|
1906
|
+
if (results.ghost_buttons.length > 5)
|
|
1907
|
+
results.ghost_buttons = results.ghost_buttons.slice(0, 5);
|
|
1908
|
+
if (results.spacing_inconsistencies.length > 5)
|
|
1909
|
+
results.spacing_inconsistencies = results.spacing_inconsistencies.slice(0, 5);
|
|
1910
|
+
if (results.heading_hierarchy_violations.length > 5)
|
|
1911
|
+
results.heading_hierarchy_violations = results.heading_hierarchy_violations.slice(0, 5);
|
|
1912
|
+
return results;
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
catch {
|
|
1916
|
+
return {
|
|
1917
|
+
cta_competition: [],
|
|
1918
|
+
ghost_buttons: [],
|
|
1919
|
+
spacing_inconsistencies: [],
|
|
1920
|
+
heading_hierarchy_violations: [],
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
/**
|
|
1925
|
+
* Extract visible DOM elements at the current scroll position.
|
|
1926
|
+
* Returns elements with viewport-relative normalized bboxes.
|
|
1927
|
+
* Cap at 50 elements per screenshot to control token cost.
|
|
1928
|
+
*/
|
|
1929
|
+
async function extractVisibleElements(page, vpWidth, vpHeight) {
|
|
1930
|
+
try {
|
|
1931
|
+
const raw = await page.evaluate(({ vw, vh }) => {
|
|
1932
|
+
const selectors = "h1,h2,h3,h4,h5,h6,p,a,button,img,input,select,textarea," +
|
|
1933
|
+
"nav,header,footer,section,main,[role='button'],[role='navigation']";
|
|
1934
|
+
const els = document.querySelectorAll(selectors);
|
|
1935
|
+
const results = [];
|
|
1936
|
+
for (let i = 0; i < els.length && results.length < 50; i++) {
|
|
1937
|
+
const el = els[i];
|
|
1938
|
+
if (el.offsetWidth === 0 && el.offsetHeight === 0)
|
|
1939
|
+
continue;
|
|
1940
|
+
const rect = el.getBoundingClientRect();
|
|
1941
|
+
// Skip elements fully outside the viewport
|
|
1942
|
+
if (rect.bottom < 0 || rect.top > vh || rect.right < 0 || rect.left > vw)
|
|
1943
|
+
continue;
|
|
1944
|
+
const text = (el.innerText || el.getAttribute("alt") || el.getAttribute("aria-label") || "")
|
|
1945
|
+
.trim()
|
|
1946
|
+
.slice(0, 80);
|
|
1947
|
+
results.push({
|
|
1948
|
+
tag: el.tagName.toLowerCase(),
|
|
1949
|
+
text,
|
|
1950
|
+
x: Math.max(0, rect.left) / vw,
|
|
1951
|
+
y: Math.max(0, rect.top) / vh,
|
|
1952
|
+
w: Math.min(rect.width, vw - Math.max(0, rect.left)) / vw,
|
|
1953
|
+
h: Math.min(rect.height, vh - Math.max(0, rect.top)) / vh,
|
|
1954
|
+
});
|
|
1955
|
+
}
|
|
1956
|
+
return results;
|
|
1957
|
+
}, { vw: vpWidth, vh: vpHeight });
|
|
1958
|
+
// Assign sequential IDs and clamp bbox values
|
|
1959
|
+
return raw.map((el, idx) => ({
|
|
1960
|
+
id: `el_${idx + 1}`,
|
|
1961
|
+
tag: el.tag,
|
|
1962
|
+
text: el.text,
|
|
1963
|
+
bbox: {
|
|
1964
|
+
x: Math.round(Math.min(1, Math.max(0, el.x)) * 100) / 100,
|
|
1965
|
+
y: Math.round(Math.min(1, Math.max(0, el.y)) * 100) / 100,
|
|
1966
|
+
w: Math.round(Math.min(1, Math.max(0, el.w)) * 100) / 100,
|
|
1967
|
+
h: Math.round(Math.min(1, Math.max(0, el.h)) * 100) / 100,
|
|
1968
|
+
},
|
|
1969
|
+
}));
|
|
1970
|
+
}
|
|
1971
|
+
catch {
|
|
1972
|
+
return []; // Graceful fallback
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
/**
|
|
1976
|
+
* Run axe-core accessibility audit on the page.
|
|
1977
|
+
* Returns structured violations that complement our manual checks.
|
|
1978
|
+
* Catches ARIA role issues, heading order, focus management, etc.
|
|
1979
|
+
*/
|
|
1980
|
+
async function runAxeChecks(page) {
|
|
1981
|
+
try {
|
|
1982
|
+
const results = await new AxeBuilder({ page })
|
|
1983
|
+
.withTags(["wcag2a", "wcag2aa", "best-practice"])
|
|
1984
|
+
.analyze();
|
|
1985
|
+
return results.violations.map((v) => ({
|
|
1986
|
+
id: v.id,
|
|
1987
|
+
impact: v.impact || "moderate",
|
|
1988
|
+
description: v.description,
|
|
1989
|
+
help_url: v.helpUrl,
|
|
1990
|
+
nodes: v.nodes.slice(0, 5).map((n) => ({
|
|
1991
|
+
selector: Array.isArray(n.target) ? n.target.flat().join(" ") : String(n.target),
|
|
1992
|
+
html: (n.html || "").slice(0, 200),
|
|
1993
|
+
failure_summary: (n.failureSummary || "").slice(0, 200),
|
|
1994
|
+
})),
|
|
1995
|
+
}));
|
|
1996
|
+
}
|
|
1997
|
+
catch (err) {
|
|
1998
|
+
// axe-core can fail on some pages — don't block capture
|
|
1999
|
+
return [];
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
28
2002
|
/**
|
|
29
2003
|
* Capture screenshots at all configured viewports for a given URL.
|
|
2004
|
+
* Also runs deterministic DOM checks and extracts element maps.
|
|
30
2005
|
* Returns base64-encoded JPEG screenshots with labels.
|
|
31
2006
|
*/
|
|
32
2007
|
export async function captureAllViewports(url) {
|
|
33
2008
|
const startTime = Date.now();
|
|
2009
|
+
// Fast reachability check — fail in <3s instead of waiting 15s for Playwright
|
|
2010
|
+
await assertUrlReachable(url);
|
|
34
2011
|
const b = await getBrowser();
|
|
35
2012
|
const context = await b.newContext({
|
|
36
2013
|
ignoreHTTPSErrors: true,
|
|
@@ -39,56 +2016,129 @@ export async function captureAllViewports(url) {
|
|
|
39
2016
|
});
|
|
40
2017
|
const page = await context.newPage();
|
|
41
2018
|
try {
|
|
42
|
-
await page.goto(url, { waitUntil: "networkidle", timeout:
|
|
2019
|
+
await page.goto(url, { waitUntil: "networkidle", timeout: 20_000 });
|
|
2020
|
+
// Extra wait for animations, lazy content, and JS rendering to settle
|
|
2021
|
+
await page.waitForTimeout(2000);
|
|
2022
|
+
// Run all deterministic checks in parallel (~100-500ms total)
|
|
2023
|
+
const [baseChecks, a11yChecks, darkPatterns, axeViolations, structuralUX, textSpacing, formIssues, cssInteractions, colorOnlyStates, documentSemantics] = await Promise.all([
|
|
2024
|
+
runDeterministicChecks(page),
|
|
2025
|
+
runAccessibilityChecks(page),
|
|
2026
|
+
runDarkPatternChecks(page),
|
|
2027
|
+
runAxeChecks(page),
|
|
2028
|
+
runStructuralUXChecks(page),
|
|
2029
|
+
runTextSpacingChecks(page),
|
|
2030
|
+
runFormDeepChecks(page),
|
|
2031
|
+
runCssInteractionChecks(page),
|
|
2032
|
+
runColorOnlyStateChecks(page),
|
|
2033
|
+
runDocumentSemanticsChecks(page),
|
|
2034
|
+
]);
|
|
2035
|
+
const deterministicChecks = {
|
|
2036
|
+
...baseChecks,
|
|
2037
|
+
accessibility: a11yChecks,
|
|
2038
|
+
dark_patterns: darkPatterns.length > 0 ? { findings: darkPatterns } : undefined,
|
|
2039
|
+
structural_ux: structuralUX,
|
|
2040
|
+
text_spacing: Array.isArray(textSpacing?.violations)
|
|
2041
|
+
&& textSpacing.violations.length > 0
|
|
2042
|
+
? textSpacing
|
|
2043
|
+
: undefined,
|
|
2044
|
+
form_issues: Array.isArray(formIssues?.findings)
|
|
2045
|
+
&& formIssues.findings.length > 0
|
|
2046
|
+
? formIssues
|
|
2047
|
+
: undefined,
|
|
2048
|
+
css_interactions: Array.isArray(cssInteractions?.findings)
|
|
2049
|
+
&& cssInteractions.findings.length > 0
|
|
2050
|
+
? cssInteractions
|
|
2051
|
+
: undefined,
|
|
2052
|
+
color_only_states: Array.isArray(colorOnlyStates?.findings)
|
|
2053
|
+
&& colorOnlyStates.findings.length > 0
|
|
2054
|
+
? colorOnlyStates
|
|
2055
|
+
: undefined,
|
|
2056
|
+
document_semantics: Array.isArray(documentSemantics?.findings)
|
|
2057
|
+
&& documentSemantics.findings.length > 0
|
|
2058
|
+
? documentSemantics
|
|
2059
|
+
: undefined,
|
|
2060
|
+
};
|
|
43
2061
|
const screenshots = [];
|
|
44
2062
|
for (const vp of VIEWPORTS) {
|
|
45
2063
|
await page.setViewportSize({ width: vp.width, height: vp.height });
|
|
46
|
-
|
|
2064
|
+
// Force scroll to absolute top FIRST — before anything else at this viewport
|
|
2065
|
+
await page.evaluate(() => {
|
|
2066
|
+
document.documentElement.style.scrollBehavior = "auto";
|
|
2067
|
+
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
|
|
2068
|
+
});
|
|
2069
|
+
await page.waitForTimeout(300);
|
|
2070
|
+
// Trigger lazy-loaded content: scroll to bottom, then back to top
|
|
2071
|
+
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
2072
|
+
await page.waitForTimeout(1000);
|
|
2073
|
+
await page.evaluate(() => {
|
|
2074
|
+
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
|
|
2075
|
+
});
|
|
2076
|
+
await page.waitForTimeout(500);
|
|
47
2077
|
const pageHeight = await page.evaluate(() => document.body.scrollHeight);
|
|
48
2078
|
const scrollStep = vp.height;
|
|
49
|
-
|
|
2079
|
+
// How many viewport-height slices cover the page, capped by config
|
|
2080
|
+
const maxScrolls = Math.min(vp.scrolls, Math.max(1, Math.ceil(pageHeight / scrollStep)));
|
|
50
2081
|
for (let i = 0; i < maxScrolls; i++) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
2082
|
+
// Scroll to exact position; snap last scroll to page bottom
|
|
2083
|
+
const scrollY = (i === maxScrolls - 1 && i > 0)
|
|
2084
|
+
? Math.max(0, pageHeight - vp.height)
|
|
2085
|
+
: i * scrollStep;
|
|
2086
|
+
await page.evaluate((y) => {
|
|
2087
|
+
window.scrollTo({ top: y, left: 0, behavior: "instant" });
|
|
2088
|
+
}, scrollY);
|
|
2089
|
+
await page.waitForTimeout(500); // let lazy images + animations settle
|
|
2090
|
+
// Verify we actually scrolled to the right position
|
|
2091
|
+
const actualY = await page.evaluate(() => window.scrollY);
|
|
2092
|
+
// Screenshot captures the visible viewport (no fullPage flag)
|
|
54
2093
|
let bytes = await page.screenshot({
|
|
55
2094
|
type: "jpeg",
|
|
56
|
-
quality:
|
|
57
|
-
clip: { x: 0, y: 0, width: vp.width, height: vp.height },
|
|
2095
|
+
quality: 80,
|
|
58
2096
|
});
|
|
59
2097
|
// Size guard: if screenshot > 5MB, reduce quality
|
|
60
2098
|
if (bytes.length > 5 * 1024 * 1024) {
|
|
61
2099
|
bytes = await page.screenshot({
|
|
62
2100
|
type: "jpeg",
|
|
63
|
-
quality:
|
|
64
|
-
clip: { x: 0, y: 0, width: vp.width, height: vp.height },
|
|
2101
|
+
quality: 60,
|
|
65
2102
|
});
|
|
66
2103
|
}
|
|
2104
|
+
// Extract visible DOM elements at this scroll position
|
|
2105
|
+
const elements = await extractVisibleElements(page, vp.width, vp.height);
|
|
67
2106
|
screenshots.push({
|
|
68
2107
|
label: `${vp.label}_scroll_${i}`,
|
|
69
2108
|
viewport: vp.label,
|
|
70
2109
|
scroll_index: i,
|
|
71
2110
|
data: bytes.toString("base64"),
|
|
2111
|
+
elements,
|
|
72
2112
|
});
|
|
73
|
-
//
|
|
74
|
-
const currentY = await page.evaluate(() => window.scrollY);
|
|
2113
|
+
// Stop if we've reached the bottom of the page
|
|
75
2114
|
const maxY = await page.evaluate(() => document.body.scrollHeight - window.innerHeight);
|
|
76
|
-
if (
|
|
2115
|
+
if (actualY >= maxY - 10)
|
|
77
2116
|
break;
|
|
78
2117
|
}
|
|
79
2118
|
}
|
|
80
2119
|
return {
|
|
81
2120
|
screenshots,
|
|
82
2121
|
capture_duration_ms: Date.now() - startTime,
|
|
2122
|
+
deterministic_checks: deterministicChecks,
|
|
2123
|
+
axe_violations: axeViolations.length > 0 ? axeViolations : undefined,
|
|
83
2124
|
};
|
|
84
2125
|
}
|
|
85
2126
|
catch (err) {
|
|
86
|
-
|
|
2127
|
+
// Re-throw ExplainUIErrors unchanged (already have good messages)
|
|
2128
|
+
if (err instanceof ExplainUIError)
|
|
2129
|
+
throw err;
|
|
2130
|
+
const message = err.message ?? "";
|
|
87
2131
|
if (message.includes("ERR_CONNECTION_REFUSED")) {
|
|
88
|
-
throw new ExplainUIError("LOCAL_SERVER_NOT_RUNNING", `
|
|
2132
|
+
throw new ExplainUIError("LOCAL_SERVER_NOT_RUNNING", `Lost connection to ${url} during screenshot capture.\n` +
|
|
2133
|
+
`Make sure your dev server stays running while ExplainUI is analyzing.`);
|
|
89
2134
|
}
|
|
90
|
-
if (message.includes("
|
|
91
|
-
throw new ExplainUIError("CAPTURE_TIMEOUT", `Page at ${url} took too long to
|
|
2135
|
+
if (message.toLowerCase().includes("timeout") || message.includes("TimeoutError")) {
|
|
2136
|
+
throw new ExplainUIError("CAPTURE_TIMEOUT", `Page at ${url} took too long to render screenshots (>15s).\n\n` +
|
|
2137
|
+
`This can happen with:\n` +
|
|
2138
|
+
` • Slow API calls blocking page render\n` +
|
|
2139
|
+
` • Large images or assets loading\n` +
|
|
2140
|
+
` • Infinite loading states\n\n` +
|
|
2141
|
+
`Try calling analyze again. If it keeps timing out, check your page's network tab.`);
|
|
92
2142
|
}
|
|
93
2143
|
throw err;
|
|
94
2144
|
}
|