@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/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 { VIEWPORTS, ExplainUIError } from "./types.js";
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
- browser = await chromium.launch({
17
- headless: true,
18
- args: [
19
- "--no-sandbox",
20
- "--disable-setuid-sandbox",
21
- "--disable-dev-shm-usage",
22
- "--disable-gpu",
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: 15_000 });
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
- await page.waitForTimeout(300); // let layout reflow settle
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
- const maxScrolls = Math.min(vp.scrolls, Math.ceil(pageHeight / scrollStep));
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
- const scrollY = i * scrollStep;
52
- await page.evaluate((y) => window.scrollTo(0, y), scrollY);
53
- await page.waitForTimeout(150); // let lazy images load
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: 70,
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: 50,
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
- // Break if we've scrolled past the page content
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 (currentY >= maxY)
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
- const message = err.message || "";
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", `Could not connect to ${url}. Is your dev server 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("Timeout")) {
91
- throw new ExplainUIError("CAPTURE_TIMEOUT", `Page at ${url} took too long to load (>15s).`);
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
  }