cbrowser 19.2.2 → 19.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/analysis/accessibility-empathy.d.ts +48 -1
  2. package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
  3. package/dist/analysis/accessibility-empathy.js +766 -92
  4. package/dist/analysis/accessibility-empathy.js.map +1 -1
  5. package/dist/analysis/agent-ready-audit.d.ts +28 -0
  6. package/dist/analysis/agent-ready-audit.d.ts.map +1 -1
  7. package/dist/analysis/agent-ready-audit.js +218 -21
  8. package/dist/analysis/agent-ready-audit.js.map +1 -1
  9. package/dist/analysis/bug-hunter.d.ts +45 -0
  10. package/dist/analysis/bug-hunter.d.ts.map +1 -1
  11. package/dist/analysis/bug-hunter.js +211 -64
  12. package/dist/analysis/bug-hunter.js.map +1 -1
  13. package/dist/analysis/competitive-benchmark.d.ts.map +1 -1
  14. package/dist/analysis/competitive-benchmark.js +4 -2
  15. package/dist/analysis/competitive-benchmark.js.map +1 -1
  16. package/dist/browser/session-manager.d.ts.map +1 -1
  17. package/dist/browser/session-manager.js +4 -2
  18. package/dist/browser/session-manager.js.map +1 -1
  19. package/dist/browser.d.ts +11 -2
  20. package/dist/browser.d.ts.map +1 -1
  21. package/dist/browser.js +64 -15
  22. package/dist/browser.js.map +1 -1
  23. package/dist/cli.js +2 -1
  24. package/dist/cli.js.map +1 -1
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +1 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/mcp-server.d.ts.map +1 -1
  30. package/dist/mcp-server.js +19 -19
  31. package/dist/mcp-server.js.map +1 -1
  32. package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
  33. package/dist/mcp-tools/base/audit-tools.js +114 -58
  34. package/dist/mcp-tools/base/audit-tools.js.map +1 -1
  35. package/dist/mcp-tools/base/browser-state-tools.d.ts.map +1 -1
  36. package/dist/mcp-tools/base/browser-state-tools.js +3 -2
  37. package/dist/mcp-tools/base/browser-state-tools.js.map +1 -1
  38. package/dist/mcp-tools/base/bug-analysis-tools.d.ts.map +1 -1
  39. package/dist/mcp-tools/base/bug-analysis-tools.js +4 -67
  40. package/dist/mcp-tools/base/bug-analysis-tools.js.map +1 -1
  41. package/dist/mcp-tools/base/interaction-tools.d.ts.map +1 -1
  42. package/dist/mcp-tools/base/interaction-tools.js +4 -0
  43. package/dist/mcp-tools/base/interaction-tools.js.map +1 -1
  44. package/dist/mcp-tools/security-layer.d.ts +33 -0
  45. package/dist/mcp-tools/security-layer.d.ts.map +1 -1
  46. package/dist/mcp-tools/security-layer.js +191 -1
  47. package/dist/mcp-tools/security-layer.js.map +1 -1
  48. package/dist/navigation-errors.d.ts +21 -0
  49. package/dist/navigation-errors.d.ts.map +1 -0
  50. package/dist/navigation-errors.js +119 -0
  51. package/dist/navigation-errors.js.map +1 -0
  52. package/dist/remediation/llms-txt.d.ts.map +1 -1
  53. package/dist/remediation/llms-txt.js +3 -1
  54. package/dist/remediation/llms-txt.js.map +1 -1
  55. package/dist/remediation/structured-data.d.ts.map +1 -1
  56. package/dist/remediation/structured-data.js +3 -1
  57. package/dist/remediation/structured-data.js.map +1 -1
  58. package/dist/security/script-gate.d.ts +7 -0
  59. package/dist/security/script-gate.d.ts.map +1 -1
  60. package/dist/security/script-gate.js +7 -0
  61. package/dist/security/script-gate.js.map +1 -1
  62. package/dist/testing/flaky-detection.js +1 -1
  63. package/dist/testing/flaky-detection.js.map +1 -1
  64. package/dist/testing/nl-test-suite.d.ts +12 -0
  65. package/dist/testing/nl-test-suite.d.ts.map +1 -1
  66. package/dist/testing/nl-test-suite.js +92 -14
  67. package/dist/testing/nl-test-suite.js.map +1 -1
  68. package/dist/testing/test-repair.js +3 -3
  69. package/dist/testing/test-repair.js.map +1 -1
  70. package/dist/types.d.ts +51 -0
  71. package/dist/types.d.ts.map +1 -1
  72. package/dist/types.js.map +1 -1
  73. package/dist/visual/attention-transport.d.ts +89 -11
  74. package/dist/visual/attention-transport.d.ts.map +1 -1
  75. package/dist/visual/attention-transport.js +148 -53
  76. package/dist/visual/attention-transport.js.map +1 -1
  77. package/dist/visual/cross-browser.d.ts.map +1 -1
  78. package/dist/visual/cross-browser.js +3 -1
  79. package/dist/visual/cross-browser.js.map +1 -1
  80. package/dist/visual/distance-metrics.js +6 -6
  81. package/dist/visual/distance-metrics.js.map +1 -1
  82. package/dist/visual/perceptual-transport.d.ts +14 -3
  83. package/dist/visual/perceptual-transport.d.ts.map +1 -1
  84. package/dist/visual/perceptual-transport.js +51 -9
  85. package/dist/visual/perceptual-transport.js.map +1 -1
  86. package/package.json +1 -1
@@ -6,6 +6,7 @@
6
6
  import { weightKeyFor, barrierWeightFor, weightedSeverity } from "../visual/perceptual-transport.js";
7
7
  import { VERSION } from "../version.js";
8
8
  import { CBrowser } from "../browser.js";
9
+ import { navigationError as toNavigationError } from "../navigation-errors.js";
9
10
  import { getAccessibilityPersona, PERSONA_ALIASES } from "../personas.js";
10
11
  import { runCognitiveJourney, isApiKeyConfigured, } from "../cognitive/index.js";
11
12
  import { calculatePerceptualScore, analyzePerceptualTransport, } from "../visual/perceptual-transport.js";
@@ -116,6 +117,124 @@ function adjustSeverityForLevel(severity, barrierWcagLevel, auditLevel) {
116
117
  }
117
118
  return severity;
118
119
  }
120
+ /**
121
+ * Runs IN THE PAGE. Installs `window.__cbrowserUniqueSelector(el)`, the one
122
+ * selector builder every detector uses.
123
+ *
124
+ * Detectors each built their own `tag + (#id)` string, so every id-less button
125
+ * on a page became "button": two different targets merged during
126
+ * deduplication, and a selector a reader could paste into querySelector
127
+ * resolved to neither. The only unique-selector code in the package lived
128
+ * inside two other modules' page scripts. (B6/B7, 2026-10-09)
129
+ *
130
+ * Order: a unique `tag#id`, then a unique `tag.firstClass` (the readable form
131
+ * detectors used to emit), then a unique `tag[aria-label="..."]`, then a
132
+ * structural `nth-of-type` path up to the nearest ancestor with a unique id.
133
+ * Every candidate is checked with querySelectorAll before it is returned.
134
+ */
135
+ function installUniqueSelector() {
136
+ const esc = (s) => (typeof CSS !== "undefined" && CSS.escape) ? CSS.escape(s) : s.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`);
137
+ const isUnique = (sel, el) => {
138
+ try {
139
+ const m = document.querySelectorAll(sel);
140
+ return m.length === 1 && m[0] === el;
141
+ }
142
+ catch {
143
+ return false;
144
+ }
145
+ };
146
+ window.__cbrowserUniqueSelector =
147
+ (el) => {
148
+ const tag = el.tagName.toLowerCase();
149
+ if (el.id) {
150
+ const s = `${tag}#${esc(el.id)}`;
151
+ if (isUnique(s, el))
152
+ return s;
153
+ }
154
+ // The readable form detectors used to emit, kept when it IS unique.
155
+ const cls = String(el.className?.baseVal ?? el.className ?? "")
156
+ .trim().split(/\s+/)[0];
157
+ if (cls) {
158
+ const s = `${tag}.${esc(cls)}`;
159
+ if (isUnique(s, el))
160
+ return s;
161
+ }
162
+ const label = el.getAttribute("aria-label");
163
+ if (label) {
164
+ const s = `${tag}[aria-label="${label.replace(/["\\]/g, "\\$&")}"]`;
165
+ if (isUnique(s, el))
166
+ return s;
167
+ }
168
+ const parts = [];
169
+ let cur = el;
170
+ while (cur) {
171
+ const t = cur.tagName.toLowerCase();
172
+ if (cur !== el && cur.id) {
173
+ const s = `${t}#${esc(cur.id)}`;
174
+ if (isUnique(s, cur)) {
175
+ parts.unshift(s);
176
+ break;
177
+ }
178
+ }
179
+ const parent = cur.parentElement;
180
+ if (!parent) {
181
+ parts.unshift(t);
182
+ break;
183
+ }
184
+ const same = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
185
+ parts.unshift(same.length > 1 ? `${t}:nth-of-type(${same.indexOf(cur) + 1})` : t);
186
+ cur = parent;
187
+ }
188
+ return parts.join(" > ");
189
+ };
190
+ }
191
+ /** Lowest-to-highest WCAG level order. */
192
+ const LEVEL_ORDER = { A: 1, AA: 2, AAA: 3 };
193
+ /**
194
+ * Move findings ABOVE the audited WCAG level out of the scored set.
195
+ *
196
+ * Policy (Alexa, 2026-10-09): an above-level finding is advisory. It carries
197
+ * `aboveAuditLevel`, is reported in a separate `advisories` list, takes no
198
+ * score deduction, is never escalated by persona weight, and is never
199
+ * "critical".
200
+ *
201
+ * Before this an AAA-only 40x40 target in an AA audit was the largest single
202
+ * deduction on the reported page and showed severityForPersona "critical":
203
+ * adjustSeverityForLevel capped it at minor, and weightedSeverity -- which has
204
+ * no idea what level is being audited -- moved it up two steps at weight 3.0.
205
+ *
206
+ * Applied once, after every detector, for the same reason the viewport filter
207
+ * is: one rule in one place cannot be forgotten by a twelfth detector.
208
+ *
209
+ * A barrier with no KNOWN criterion is not a WCAG finding (navigation item
210
+ * count, the spacing heuristic before it related to 2.5.8) and so is not above
211
+ * any level; it stays scored. getBarrierWcagLevel treats [] as AAA for
212
+ * severity purposes, which is why it is not reused for this question as-is.
213
+ */
214
+ export function splitAboveAuditLevel(barriers, auditLevel) {
215
+ const scored = [];
216
+ const advisories = [];
217
+ for (const b of barriers) {
218
+ const known = (b.wcagCriteria ?? []).filter((c) => WCAG_CRITERIA[c]);
219
+ if (known.length === 0) {
220
+ scored.push(b);
221
+ continue;
222
+ }
223
+ const level = getBarrierWcagLevel(known);
224
+ if (LEVEL_ORDER[level] > LEVEL_ORDER[auditLevel]) {
225
+ advisories.push({
226
+ ...b,
227
+ severity: adjustSeverityForLevel(b.severity, level, auditLevel),
228
+ aboveAuditLevel: true,
229
+ wcagLevelOfFinding: level,
230
+ });
231
+ }
232
+ else {
233
+ scored.push(b);
234
+ }
235
+ }
236
+ return { scored, advisories };
237
+ }
119
238
  /**
120
239
  * Drop barriers outside the viewport when the audit is viewport-scoped.
121
240
  *
@@ -210,8 +329,11 @@ async function detectSmallTouchTargets(ctx) {
210
329
  (rect.width <= 1 && rect.height <= 1 && parseInt(style.overflow) === 0)) ||
211
330
  el.classList.contains('sr-only') || el.classList.contains('visually-hidden') ||
212
331
  el.classList.contains('screen-reader-text');
332
+ const uniq = window.__cbrowserUniqueSelector;
213
333
  return {
214
- selector: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : ''),
334
+ // Unique, not tag + #id: two id-less buttons were both "button" and
335
+ // merged into one finding. (B6, 2026-10-09)
336
+ selector: uniq ? uniq(el) : el.tagName.toLowerCase() + (el.id ? `#${el.id}` : ''),
215
337
  width: rect.width,
216
338
  height: rect.height,
217
339
  // DOCUMENT coordinates. getBoundingClientRect is viewport-relative, and
@@ -231,31 +353,39 @@ async function detectSmallTouchTargets(ctx) {
231
353
  return false;
232
354
  if (vpOnly && !el.inViewport)
233
355
  return false;
234
- // Only flag if the SMALLEST dimension is under the threshold
235
- // A 1280x36px element is perfectly tappable — the 36px height is fine
236
- // WCAG 2.5.8 AA: 24x24px minimum for the target area
237
- const minDim = Math.min(el.width, el.height);
238
- const maxDim = Math.max(el.width, el.height);
239
- // Skip if the element is wide/tall enough to be easily tappable
240
- // (one dimension >= 44px AND the other >= 24px = AA compliant)
241
- if (minDim >= 24 && maxDim >= 44)
242
- return false;
243
- // Flag if both dimensions are small
244
- return minDim < 44;
356
+ // Candidates: anything under 44px on EITHER axis. Classified per axis
357
+ // below, in Node, on the rounded size the description prints.
358
+ return el.width < 44 || el.height < 44;
245
359
  });
246
360
  }, viewportOnly);
247
361
  // WCAG 2.5.8 (AA) minimum: 24x24px
248
362
  // WCAG 2.5.5 (AAA) target: 44x44px
363
+ //
364
+ // Checked PER AXIS. The old filter exempted anything whose larger side was
365
+ // >= 44 and smaller side >= 24 ("one axis rescues the other"), which is not
366
+ // what 2.5.5 says -- it asks for 44 on both axes. So a 51x28 "Change
367
+ // language" button passed while a 40x40 button failed, and of two targets
368
+ // that both miss 2.5.5 only one was reported. (B6, 2026-10-09)
249
369
  const aaMinimum = 24;
250
370
  const aaaTarget = 44;
251
- for (const target of smallTargets.slice(0, 10)) {
252
- const w = Math.round(target.width);
253
- const h = Math.round(target.height);
371
+ const classified = smallTargets
372
+ .map((t) => {
373
+ const w = Math.round(t.width);
374
+ const h = Math.round(t.height);
375
+ return { ...t, w, h, failsAA: w < aaMinimum || h < aaMinimum, failsAAA: w < aaaTarget || h < aaaTarget };
376
+ })
377
+ .filter((t) => t.failsAAA);
378
+ // AA failures first, THEN the cap, so AAA-only candidates (now more numerous
379
+ // under the per-axis rule) cannot push an AA failure off the list.
380
+ // Array.prototype.sort is stable, so document order holds within each class.
381
+ classified.sort((a, b) => Number(b.failsAA) - Number(a.failsAA));
382
+ for (const target of classified.slice(0, 10)) {
383
+ const { w, h } = target;
254
384
  // Determine which WCAG criteria are violated
255
385
  const wcagCriteria = [];
256
386
  let description;
257
387
  let rawSeverity;
258
- if (w < aaMinimum || h < aaMinimum) {
388
+ if (target.failsAA) {
259
389
  // Fails WCAG 2.5.8 AA (24x24px minimum)
260
390
  wcagCriteria.push("2.5.8", "2.5.5");
261
391
  description = `Touch target too small (${w}x${h}px) — fails WCAG 2.5.8 AA minimum (24x24px)`;
@@ -263,9 +393,9 @@ async function detectSmallTouchTargets(ctx) {
263
393
  ctx.wcagViolations.add("2.5.8");
264
394
  }
265
395
  else {
266
- // Passes AA but fails AAA (24-44px range)
396
+ // Passes AA but fails AAA: at least 24 on both axes, under 44 on one.
267
397
  wcagCriteria.push("2.5.5");
268
- description = `Touch target below AAA target (${w}x${h}px) — passes AA (24px) but below AAA target (44px)`;
398
+ description = `Touch target below AAA target (${w}x${h}px) — passes AA (24x24px) but under 44px on at least one axis (2.5.5 asks for 44x44px)`;
269
399
  rawSeverity = "minor";
270
400
  ctx.wcagViolations.add("2.5.5");
271
401
  }
@@ -365,8 +495,9 @@ async function detectLowContrast(ctx) {
365
495
  const aaThreshold = isLargeText ? 3 : 4.5;
366
496
  const aaaThreshold = isLargeText ? 4.5 : 7;
367
497
  if (ratio < aaaThreshold) {
498
+ const uniq = window.__cbrowserUniqueSelector;
368
499
  results.push({
369
- selector: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : el.className ? `.${String(el.className?.baseVal ?? el.className ?? "").split(' ')[0]}` : ''),
500
+ selector: uniq ? uniq(el) : el.tagName.toLowerCase() + (el.id ? `#${el.id}` : el.className ? `.${String(el.className?.baseVal ?? el.className ?? "").split(' ')[0]}` : ''),
370
501
  text: text.slice(0, 30),
371
502
  fontSize,
372
503
  ratio: Math.round(ratio * 10) / 10,
@@ -385,7 +516,11 @@ async function detectLowContrast(ctx) {
385
516
  });
386
517
  // AA threshold: 4.5:1 normal, 3:1 large text (WCAG 1.4.3)
387
518
  // AAA threshold: 7:1 normal, 4.5:1 large text (WCAG 1.4.6)
388
- for (const el of lowContrastElements.slice(0, 8)) {
519
+ // AA failures before the cap, as for touch targets: an AAA-only shortfall
520
+ // earlier in the document must not push an AA failure off the list. Stable
521
+ // sort keeps document order within each class. (B6 sibling, 2026-10-09)
522
+ const failsAAFirst = [...lowContrastElements].sort((a, b) => Number(b.ratio < (b.isLargeText ? 3 : 4.5)) - Number(a.ratio < (a.isLargeText ? 3 : 4.5)));
523
+ for (const el of failsAAFirst.slice(0, 8)) {
389
524
  const aaThreshold = el.isLargeText ? 3 : 4.5;
390
525
  const failsAA = el.ratio < aaThreshold;
391
526
  const wcagCriteria = failsAA ? ["1.4.3", "1.4.6"] : ["1.4.6"];
@@ -429,7 +564,13 @@ async function detectCognitiveLoad(ctx) {
429
564
  const cognitiveIssues = await page.evaluate(() => {
430
565
  const issues = [];
431
566
  const inVp = window.__cbrowserInViewport || (() => true);
432
- // Check for long forms (in scope)
567
+ const uniq = window.__cbrowserUniqueSelector || ((el) => el.tagName.toLowerCase());
568
+ const docRect = (el) => {
569
+ const r = el.getBoundingClientRect();
570
+ return { x: Math.round(r.left + window.scrollX), y: Math.round(r.top + window.scrollY),
571
+ width: Math.round(r.width), height: Math.round(r.height) };
572
+ };
573
+ // Check for long forms (in scope). Located: the form is the finding.
433
574
  const forms = Array.from(document.querySelectorAll('form')).filter(inVp);
434
575
  for (const form of forms) {
435
576
  const inputs = Array.from(form.querySelectorAll('input:not([type="hidden"]), textarea, select')).filter(inVp);
@@ -438,29 +579,26 @@ async function detectCognitiveLoad(ctx) {
438
579
  type: "long-form",
439
580
  description: `Form with ${inputs.length} fields may overwhelm users with attention difficulties`,
440
581
  count: inputs.length,
582
+ selector: uniq(form),
583
+ rect: docRect(form),
441
584
  });
442
585
  }
443
586
  }
444
- // Check for text walls (in scope)
587
+ // Check for text walls (in scope). Located: the first long paragraph.
445
588
  const paragraphs = Array.from(document.querySelectorAll('p')).filter(inVp);
446
589
  for (const p of paragraphs) {
447
590
  if (p.textContent && p.textContent.length > 500) {
448
591
  issues.push({
449
592
  type: "text-wall",
450
593
  description: "Long paragraph without breaks may be difficult to process",
594
+ selector: uniq(p),
595
+ rect: docRect(p),
451
596
  });
452
597
  break;
453
598
  }
454
599
  }
455
- // Check for animations/movement (in scope)
456
- const animations = Array.from(document.querySelectorAll('[class*="animate"], [class*="slider"], [class*="carousel"]')).filter(inVp);
457
- if (animations.length > 0) {
458
- issues.push({
459
- type: "animation",
460
- description: "Animated content may distract users with attention difficulties",
461
- count: animations.length,
462
- });
463
- }
600
+ // Animation/movement is detectMotionWithoutPause's job (2.2.2, located,
601
+ // pause controls credited). It was a class-name count here. (B5)
464
602
  // Check for complex navigation (in scope)
465
603
  const navItems = Array.from(document.querySelectorAll('nav a, header a, [role="navigation"] a')).filter(inVp);
466
604
  if (navItems.length > 10) {
@@ -477,17 +615,15 @@ async function detectCognitiveLoad(ctx) {
477
615
  const wcagMap = {
478
616
  "long-form": { criteria: ["3.3.2"], violation: "3.3.2" }, // Labels or Instructions
479
617
  "text-wall": { criteria: ["1.3.1"], violation: null }, // Info and Relationships (recommendation, not violation)
480
- // Pause, Stop, Hide only. 2.3.1 (Three Flashes) is a seizure-safety
481
- // criterion about flash RATE, and nothing here measures flashing -- this
482
- // is a class-name match. Citing it published a Level A violation for
483
- // every page with an animate-* utility. (2026-10-07)
484
- "animation": { criteria: ["2.2.2"], violation: "2.2.2" },
485
618
  "complex-nav": { criteria: [], violation: null }, // No WCAG criterion for nav item count — UX recommendation only
486
619
  };
487
620
  const mapping = wcagMap[issue.type] || { criteria: [], violation: null };
488
621
  barriers.push({
489
622
  type: "cognitive_load",
490
- element: issue.type,
623
+ // A located issue names its element. The navigation count is a property
624
+ // of the page, said so with pageLevel rather than by a rect's absence.
625
+ element: issue.selector ?? issue.type,
626
+ ...(issue.rect ? { rect: issue.rect } : { pageLevel: true }),
491
627
  description: issue.description + (mapping.criteria.length === 0 ? ' (UX recommendation, not a WCAG violation)' : ''),
492
628
  affectedPersonas: ["cognitive-adhd", "dyslexic-user"],
493
629
  wcagCriteria: mapping.criteria,
@@ -504,13 +640,288 @@ async function detectCognitiveLoad(ctx) {
504
640
  ? "Break form into multiple steps or sections"
505
641
  : issue.type === "text-wall"
506
642
  ? "Break text into smaller paragraphs with headings"
507
- : issue.type === "animation"
508
- ? "Provide controls to pause/stop animations, or use prefers-reduced-motion"
509
- : "Consider simplifying navigation structure for cognitive accessibility",
643
+ : "Consider simplifying navigation structure for cognitive accessibility",
510
644
  });
511
645
  if (mapping.violation)
512
646
  ctx.wcagViolations.add(mapping.violation);
513
647
  }
648
+ await detectMotionWithoutPause(ctx);
649
+ }
650
+ /**
651
+ * WCAG 2.2.2 Pause, Stop, Hide: moving content that starts on its own and
652
+ * keeps going, with no control to pause, stop or hide it.
653
+ *
654
+ * This was a class-name count inside detectCognitiveLoad, `[class*=animate|
655
+ * slider|carousel]`, returning {type, description, count}. The barrier was
656
+ * built with `element: "animation"` -- the literal string -- no rect, and the
657
+ * count thrown away, so deduplication counted it as one element and the
658
+ * overlay could not draw it. Nothing anywhere looked for a pause control, so a
659
+ * carousel with a correctly labelled, correctly associated "Pause auto-play"
660
+ * button was reported as a 2.2.2 violation exactly like one with none. (B5,
661
+ * 2026-10-09)
662
+ *
663
+ * Candidates: running animations from document.getAnimations() that loop
664
+ * forever or run longer than five seconds, UNIONED with the old class match,
665
+ * because a JavaScript-timer carousel never appears in getAnimations(). Only
666
+ * rendered, in-scope elements count.
667
+ *
668
+ * Grouped by their NEAREST motion container (carousel, slider, or
669
+ * aria-roledescription="carousel"), else, for a running animation, its
670
+ * outermost animated ancestor. A class-only group nested in another group
671
+ * joins it (a widget's inner/item/control classes are parts of the widget),
672
+ * and a bare class-named wrapper around another group is not a group.
673
+ *
674
+ * A group is CREDITED -- no barrier -- when a visible, enabled button,
675
+ * role=button or role=switch whose name says pause, stop or autoplay
676
+ * (a) sits inside the group's container and in no other group nested in
677
+ * it (a control belongs to its innermost group), or
678
+ * (b) names the container or one of its members in aria-controls, or
679
+ * (c) sits in the container's parent, when that parent is not body/html
680
+ * and holds no OTHER motion group.
681
+ * Credited groups are listed in `motionControlsCredited`, so a crediting that
682
+ * happened can be told from a detector that saw nothing. (Round 2: the parent
683
+ * rule credited a marquee with its neighbour carousel's button, and the
684
+ * outermost container let a page wrapper absorb every animation.)
685
+ *
686
+ * An uncredited animation-backed group is one barrier: a unique selector for
687
+ * the container, its document-space rect, and the number of animated members.
688
+ * Uncredited class-only groups together are one page-level barrier listing
689
+ * them (see pushClassOnlyMotionBarrier).
690
+ */
691
+ async function detectMotionWithoutPause(ctx) {
692
+ const groups = await ctx.page.evaluate(() => {
693
+ const w = window;
694
+ const inVp = w.__cbrowserInViewport ?? (() => true);
695
+ const uniq = w.__cbrowserUniqueSelector ?? ((el) => el.tagName.toLowerCase());
696
+ const rendered = (el) => {
697
+ const r = el.getBoundingClientRect();
698
+ return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== "hidden";
699
+ };
700
+ const docRect = (el) => {
701
+ const r = el.getBoundingClientRect();
702
+ return {
703
+ x: Math.round(r.left + window.scrollX), y: Math.round(r.top + window.scrollY),
704
+ width: Math.round(r.width), height: Math.round(r.height),
705
+ };
706
+ };
707
+ // 1. Running animations that loop forever or last more than 5 s.
708
+ const animated = new Set();
709
+ try {
710
+ for (const a of document.getAnimations()) {
711
+ if (a.playState !== "running")
712
+ continue;
713
+ const effect = a.effect;
714
+ const target = effect?.target;
715
+ if (!effect || !target)
716
+ continue;
717
+ const t = effect.getComputedTiming();
718
+ const active = Number(t.activeDuration);
719
+ if (t.iterations === Infinity || active > 5000)
720
+ animated.add(target);
721
+ }
722
+ }
723
+ catch { /* no getAnimations: the class fallback below still runs */ }
724
+ // 2. The class-match fallback, for timer-driven carousels: slider/carousel
725
+ // only. An `animate*` class is a CSS animation, which getAnimations() above
726
+ // already sees while it runs; one that is not running is not moving (on
727
+ // cbrowser.ai, 50 finished tailwindcss-animate entrance classes inflated a
728
+ // one-element finding to "affects 53 elements"). (2026-10-09)
729
+ const classMatched = Array.from(document.querySelectorAll('[class*="slider"], [class*="carousel"]'));
730
+ const candidates = Array.from(new Set([...animated, ...classMatched]))
731
+ .filter((el) => rendered(el) && inVp(el));
732
+ if (candidates.length === 0)
733
+ return [];
734
+ const CONTAINER = '[class*="carousel"], [class*="slider"], [aria-roledescription="carousel"]';
735
+ const isPageRoot = (el) => !el || el === document.body || el === document.documentElement;
736
+ // The NEAREST motion container, ancestor-or-self. Outermost let a wrapper
737
+ // whose class happened to contain "slider" absorb every animated element
738
+ // on the page into one group, credited by one button anywhere inside it.
739
+ // With no container: an animated element joins its outermost ANIMATED
740
+ // ancestor (a ticker and its moving spans are one thing), never a merely
741
+ // class-named one, so a static `.animate-in` wrapper cannot absorb either.
742
+ const rootOf = (el) => {
743
+ for (let cur = el; !isPageRoot(cur); cur = cur.parentElement) {
744
+ if (cur.matches(CONTAINER))
745
+ return cur;
746
+ }
747
+ if (!animated.has(el))
748
+ return el;
749
+ let outer = el;
750
+ for (let cur = el.parentElement; !isPageRoot(cur); cur = cur.parentElement) {
751
+ if (animated.has(cur))
752
+ outer = cur;
753
+ }
754
+ return outer;
755
+ };
756
+ const byRoot = new Map();
757
+ for (const el of candidates) {
758
+ const root = rootOf(el);
759
+ const list = byRoot.get(root);
760
+ if (list)
761
+ list.push(el);
762
+ else
763
+ byRoot.set(root, [el]);
764
+ }
765
+ // A class-only group (no running animation among its members) nested
766
+ // inside another group is part of that group: Bootstrap's carousel-inner,
767
+ // carousel-item and carousel-control-* all match the container selector,
768
+ // and as separate groups a pause button in .carousel could credit only
769
+ // the outer one. Class names are not motion; a running animation is, so
770
+ // an animation-backed group is never folded into another.
771
+ const classOnly = (members) => !members.some((m) => animated.has(m));
772
+ {
773
+ const initial = Array.from(byRoot.keys());
774
+ const enclosing = (root) => {
775
+ let best = null;
776
+ for (const r of initial) {
777
+ if (r !== root && r.contains(root) && (!best || best.contains(r)))
778
+ best = r;
779
+ }
780
+ return best;
781
+ };
782
+ const finalRoot = new Map();
783
+ const resolve = (root) => {
784
+ const known = finalRoot.get(root);
785
+ if (known)
786
+ return known;
787
+ const outer = classOnly(byRoot.get(root)) ? enclosing(root) : null;
788
+ const f = outer ? resolve(outer) : root;
789
+ finalRoot.set(root, f);
790
+ return f;
791
+ };
792
+ for (const root of initial) {
793
+ const f = resolve(root);
794
+ if (f === root)
795
+ continue;
796
+ byRoot.get(f).push(...byRoot.get(root));
797
+ byRoot.delete(root);
798
+ }
799
+ // A bare class-named wrapper -- class-only, nothing in the group but
800
+ // itself, around another group -- is not moving content of its own; its
801
+ // class describes what is inside it. As a group it would claim the
802
+ // controls inside it and block the parent rule for the real one.
803
+ for (const [root, members] of Array.from(byRoot)) {
804
+ if (members.length === 1 && members[0] === root && classOnly(members) &&
805
+ Array.from(byRoot.keys()).some((r) => r !== root && root.contains(r))) {
806
+ byRoot.delete(root);
807
+ }
808
+ }
809
+ }
810
+ const PAUSE = /\b(pause|stop|autoplay|auto-play)\b/i;
811
+ const nameOf = (c) => [
812
+ c.getAttribute("aria-label"),
813
+ ...(c.getAttribute("aria-labelledby") ?? "").split(/\s+/).filter(Boolean)
814
+ .map((id) => document.getElementById(id)?.textContent ?? ""),
815
+ c.getAttribute("title"),
816
+ c instanceof HTMLInputElement ? c.value : "",
817
+ c.textContent,
818
+ ].filter(Boolean).join(" ");
819
+ const pauseControls = Array.from(document.querySelectorAll('button, [role="button"], [role="switch"]'))
820
+ .filter((c) => PAUSE.test(nameOf(c)) && rendered(c) &&
821
+ !c.disabled && c.getAttribute("aria-disabled") !== "true");
822
+ const roots = Array.from(byRoot.keys()).sort((a, b) => a === b ? 0 : (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
823
+ return roots.map((root) => {
824
+ const members = byRoot.get(root);
825
+ const moving = members.filter((m) => animated.has(m));
826
+ const counted = moving.length > 0 ? moving : [root];
827
+ // Credit needs evidence a control is FOR this motion, and a class name
828
+ // is not that evidence. Two rounds of class-based rules (nearest
829
+ // container, parent-holds-no-other-group, class-only merging) each let a
830
+ // carousel's own Pause button credit a ticker or a second carousel beside
831
+ // it, hiding a real Level A 2.2.2 failure. Hiding a violation costs more
832
+ // than reporting one that has a control, so credit only on the two
833
+ // explicit signals: aria-controls naming the moving content (or an
834
+ // ancestor of it), or the ARIA carousel pattern -- the control and the
835
+ // motion inside the same [aria-roledescription="carousel"] region.
836
+ // A class-named carousel with no ARIA is reported even when a pause
837
+ // button exists (named limit). (2026-10-09, round 3)
838
+ const ids = new Set();
839
+ for (const m of [root, ...members])
840
+ if (m.id)
841
+ ids.add(m.id);
842
+ for (let cur = root.parentElement; !isPageRoot(cur); cur = cur.parentElement) {
843
+ if (cur.id)
844
+ ids.add(cur.id);
845
+ }
846
+ const region = root.closest('[aria-roledescription="carousel"]');
847
+ const credit = pauseControls.find((c) => (c.getAttribute("aria-controls") ?? "").split(/\s+/).some((id) => ids.has(id)) ||
848
+ (region !== null && region.contains(c)));
849
+ return {
850
+ selector: uniq(root),
851
+ rect: docRect(root),
852
+ memberCount: counted.length,
853
+ members: counted.slice(0, 10).map(uniq),
854
+ detectedBy: moving.length > 0 ? "getAnimations" : "class name",
855
+ credited: credit ? uniq(credit) : null,
856
+ };
857
+ });
858
+ });
859
+ // Animation-backed groups: one located barrier each, capped at ten in
860
+ // document order like the other located detectors. Class-only groups (the
861
+ // JavaScript-timer fallback: a class name and no running animation) are
862
+ // ONE page-level barrier listing them. One per group turned four bare
863
+ // .animate-pulse divs into four 2.2.2 barriers where main reported one,
864
+ // and a class name is weak evidence to multiply. (Round 2, 2026-10-09)
865
+ let pushed = 0;
866
+ const classOnly = [];
867
+ for (const g of groups) {
868
+ if (g.credited) {
869
+ ctx.motionControlsCredited.push({ content: g.selector, control: g.credited });
870
+ continue;
871
+ }
872
+ if (g.detectedBy === "class name") {
873
+ classOnly.push(g);
874
+ continue;
875
+ }
876
+ if (pushed >= 10)
877
+ continue;
878
+ pushMotionBarrier(ctx, g);
879
+ pushed++;
880
+ }
881
+ if (classOnly.length > 0)
882
+ pushClassOnlyMotionBarrier(ctx, classOnly.map((g) => g.selector));
883
+ }
884
+ /**
885
+ * The one 2.2.2 barrier for every uncredited class-only motion group: a page
886
+ * with elements whose class names say carousel, slider or animate, and no
887
+ * running animation was seen (a JavaScript timer may drive them).
888
+ */
889
+ function pushClassOnlyMotionBarrier(ctx, selectors) {
890
+ const n = selectors.length;
891
+ ctx.barriers.push({
892
+ type: "cognitive_load",
893
+ element: `${n} class-named motion element${n === 1 ? "" : "s"}`,
894
+ // A page-wide aggregate; each element is in members.
895
+ pageLevel: true,
896
+ members: selectors.slice(0, 10),
897
+ affectedElementCount: n,
898
+ description: `${n} element${n === 1 ? "" : "s"} whose class names suggest moving content (carousel, slider, animate), with no running animation seen (a JavaScript timer may drive ${n === 1 ? "it" : "them"}), ${n === 1 ? "has" : "have"} no associated pause, stop or hide control - may distract users with attention difficulties`,
899
+ affectedPersonas: ["cognitive-adhd", "dyslexic-user"],
900
+ wcagCriteria: ["2.2.2"],
901
+ severity: "minor",
902
+ remediation: "If these move on their own, add a visible pause/stop control inside each (or name it with aria-controls), or honour prefers-reduced-motion",
903
+ });
904
+ ctx.wcagViolations.add("2.2.2");
905
+ }
906
+ /** One 2.2.2 barrier for an uncredited motion group. */
907
+ function pushMotionBarrier(ctx, g) {
908
+ ctx.barriers.push({
909
+ type: "cognitive_load",
910
+ element: g.selector,
911
+ description: `Moving content (${g.memberCount} animated element${g.memberCount === 1 ? "" : "s"}, found by ${g.detectedBy}) has no associated pause, stop or hide control - may distract users with attention difficulties`,
912
+ affectedPersonas: ["cognitive-adhd", "dyslexic-user"],
913
+ // Pause, Stop, Hide only. 2.3.1 (Three Flashes) is a seizure-safety
914
+ // criterion about flash RATE, and nothing here measures flashing. Citing
915
+ // it published a Level A violation for every page with an animate-*
916
+ // utility. (2026-10-07)
917
+ wcagCriteria: ["2.2.2"],
918
+ severity: "minor",
919
+ remediation: "Add a visible pause/stop control inside the moving content (or name it with aria-controls), or honour prefers-reduced-motion",
920
+ rect: g.rect,
921
+ affectedElementCount: g.memberCount,
922
+ members: g.members,
923
+ });
924
+ ctx.wcagViolations.add("2.2.2");
514
925
  }
515
926
  /**
516
927
  * Detect timing-based barriers
@@ -534,11 +945,19 @@ async function detectTimingIssues(ctx) {
534
945
  // includes time-cue language (countdown, expires, remaining, seconds).
535
946
  const hasTimeCueText = /\b(countdown|expir|remaining|time\s*left|seconds?\s+left|minutes?\s+left)\b/i.test(text);
536
947
  const isTimingRelevant = dataTimeout || role === "timer" || httpRefresh || hasTimeCueText;
948
+ const uniq = window.__cbrowserUniqueSelector;
949
+ const r = el.getBoundingClientRect();
537
950
  return {
538
951
  isTimingRelevant,
539
- selector: el.tagName.toLowerCase() + (el.className && typeof el.className === "string"
952
+ selector: uniq ? uniq(el) : el.tagName.toLowerCase() + (el.className && typeof el.className === "string"
540
953
  ? `.${String(el.className?.baseVal ?? el.className ?? "").split(" ")[0]}` : ""),
541
954
  text,
955
+ // A meta refresh is the page timing out, not an element on it.
956
+ pageLevel: httpRefresh,
957
+ x: Math.round(r.left + window.scrollX),
958
+ y: Math.round(r.top + window.scrollY),
959
+ width: Math.round(r.width),
960
+ height: Math.round(r.height),
542
961
  };
543
962
  })
544
963
  .filter((el) => el.isTimingRelevant));
@@ -555,6 +974,9 @@ async function detectTimingIssues(ctx) {
555
974
  wcagCriteria,
556
975
  severity,
557
976
  remediation: "Allow users to extend, adjust, or disable time limits",
977
+ ...(el.pageLevel
978
+ ? { pageLevel: true }
979
+ : { rect: { x: el.x, y: el.y, width: el.width, height: el.height } }),
558
980
  });
559
981
  ctx.wcagViolations.add("2.2.1");
560
982
  }
@@ -572,12 +994,19 @@ async function detectColorOnlyInfo(ctx) {
572
994
  const styles = window.getComputedStyle(el);
573
995
  const hasIcon = el.querySelector('svg, i, [class*="icon"]') !== null;
574
996
  const hasText = (el.textContent?.trim() || '').length > 0;
997
+ const uniq = window.__cbrowserUniqueSelector;
998
+ const r = el.getBoundingClientRect();
575
999
  return {
576
- selector: el.tagName.toLowerCase() + (el.className ? `.${String(el.className?.baseVal ?? el.className ?? "").split(' ')[0]}` : ''),
1000
+ // Unique and located: five same-class swatches were one "span.red".
1001
+ selector: uniq ? uniq(el) : el.tagName.toLowerCase() + (el.className ? `.${String(el.className?.baseVal ?? el.className ?? "").split(' ')[0]}` : ''),
577
1002
  hasIcon,
578
1003
  hasText,
579
1004
  color: styles.color,
580
1005
  bgColor: styles.backgroundColor,
1006
+ x: Math.round(r.left + window.scrollX),
1007
+ y: Math.round(r.top + window.scrollY),
1008
+ width: Math.round(r.width),
1009
+ height: Math.round(r.height),
581
1010
  };
582
1011
  }).filter(el => !el.hasIcon && !el.hasText));
583
1012
  for (const el of colorOnlyElements.slice(0, 5)) {
@@ -589,6 +1018,7 @@ async function detectColorOnlyInfo(ctx) {
589
1018
  wcagCriteria: ["1.4.1"],
590
1019
  severity: "major",
591
1020
  remediation: "Add icons, patterns, or text labels in addition to color",
1021
+ rect: { x: el.x, y: el.y, width: el.width, height: el.height },
592
1022
  });
593
1023
  ctx.wcagViolations.add("1.4.1");
594
1024
  }
@@ -693,8 +1123,10 @@ async function detectMissingAltText(ctx) {
693
1123
  // so there is no audio experience being withheld. Deliberately narrow --
694
1124
  // a muted video WITH controls can be unmuted and still needs captions.
695
1125
  const noAudioReachable = v.hasAttribute("muted") && !v.hasAttribute("controls");
1126
+ const uniq = window.__cbrowserUniqueSelector;
696
1127
  return {
697
- selector: v.id ? `video#${v.id}` : "video",
1128
+ // Two id-less videos were both "video" and merged into one finding.
1129
+ selector: uniq ? uniq(v) : v.id ? `video#${v.id}` : "video",
698
1130
  hasCaptions: v.querySelector('track[kind="captions"]') !== null,
699
1131
  // Subtitles translate dialogue for people who can hear; captions also
700
1132
  // carry speaker changes and non-speech audio. 1.2.2 asks for captions,
@@ -780,8 +1212,9 @@ async function detectMissingAudioDescription(ctx) {
780
1212
  const nearbyText = (scope?.textContent ?? "").toLowerCase();
781
1213
  const hasTranscript = /transcript|described version|audio description/.test(nearbyText) ||
782
1214
  !!scope?.querySelector('a[href*="transcript"]');
1215
+ const uniq = window.__cbrowserUniqueSelector;
783
1216
  return {
784
- selector: v.id ? `video#${v.id}` : (v.getAttribute("src") ? `video[src]` : "video"),
1217
+ selector: uniq ? uniq(v) : v.id ? `video#${v.id}` : (v.getAttribute("src") ? `video[src]` : "video"),
785
1218
  hasDescriptions,
786
1219
  decorative,
787
1220
  hasTranscript,
@@ -810,6 +1243,7 @@ async function detectMissingAudioDescription(ctx) {
810
1243
  severity: "critical",
811
1244
  remediation: "Add an audio description track (<track kind=\"descriptions\">) narrating on-screen information that is not already spoken, or publish a described version. A full text transcript satisfies 1.2.3 but not 1.2.5.",
812
1245
  affectedElementCount: withNothing.length,
1246
+ members: withNothing.map((v) => v.selector),
813
1247
  rect: { x: withNothing[0].x, y: withNothing[0].y, width: withNothing[0].width, height: withNothing[0].height },
814
1248
  });
815
1249
  ctx.wcagViolations.add("1.2.3");
@@ -825,6 +1259,7 @@ async function detectMissingAudioDescription(ctx) {
825
1259
  severity: "major",
826
1260
  remediation: "Add an audio description track so on-screen information is available during playback, not only in a separate transcript",
827
1261
  affectedElementCount: withTranscript.length,
1262
+ members: withTranscript.map((v) => v.selector),
828
1263
  rect: { x: withTranscript[0].x, y: withTranscript[0].y, width: withTranscript[0].width, height: withTranscript[0].height },
829
1264
  });
830
1265
  ctx.wcagViolations.add("1.2.5");
@@ -838,6 +1273,7 @@ async function detectMissingFormLabels(ctx) {
838
1273
  const { page, barriers } = ctx;
839
1274
  const unlabeledInputs = await page.$$eval('input:not([type="hidden"]):not([type="submit"]):not([type="button"]):not([type="image"]), textarea, select', (elements) => {
840
1275
  const results = [];
1276
+ const uniq = window.__cbrowserUniqueSelector;
841
1277
  for (const el of elements.slice(0, 50)) {
842
1278
  const input = el;
843
1279
  const id = input.id;
@@ -848,12 +1284,17 @@ async function detectMissingFormLabels(ctx) {
848
1284
  // Check for placeholder (not a valid label, but informative)
849
1285
  const hasPlaceholder = input.hasAttribute('placeholder');
850
1286
  if (!hasLabel && !hasAriaLabel) {
1287
+ const r = input.getBoundingClientRect();
851
1288
  results.push({
852
- selector: input.tagName.toLowerCase() + (id ? `#${id}` : '') + (input.name ? `[name="${input.name}"]` : ''),
1289
+ selector: uniq ? uniq(input) : input.tagName.toLowerCase() + (id ? `#${id}` : '') + (input.name ? `[name="${input.name}"]` : ''),
853
1290
  type: input.type || 'text',
854
1291
  hasLabel,
855
1292
  hasAriaLabel,
856
1293
  hasPlaceholder,
1294
+ x: Math.round(r.left + window.scrollX),
1295
+ y: Math.round(r.top + window.scrollY),
1296
+ width: Math.round(r.width),
1297
+ height: Math.round(r.height),
857
1298
  });
858
1299
  }
859
1300
  }
@@ -871,6 +1312,7 @@ async function detectMissingFormLabels(ctx) {
871
1312
  wcagCriteria: ["1.3.1", "3.3.2", "4.1.2"],
872
1313
  severity: "major",
873
1314
  remediation: "Add a <label for=\"id\"> element or aria-label attribute to identify the input's purpose",
1315
+ rect: { x: input.x, y: input.y, width: input.width, height: input.height },
874
1316
  });
875
1317
  ctx.wcagViolations.add("3.3.2");
876
1318
  ctx.wcagViolations.add("4.1.2");
@@ -975,8 +1417,9 @@ async function detectMotorBarriers(ctx) {
975
1417
  isKeyboardPath = false;
976
1418
  }
977
1419
  const r = el.getBoundingClientRect();
1420
+ const uniq = window.__cbrowserUniqueSelector;
978
1421
  results.push({
979
- selector: el.tagName.toLowerCase() + (el.className ? `.${String(el.className?.baseVal ?? el.className ?? "").split(' ')[0]}` : ''),
1422
+ selector: uniq ? uniq(el) : el.tagName.toLowerCase() + (el.className ? `.${String(el.className?.baseVal ?? el.className ?? "").split(' ')[0]}` : ''),
980
1423
  hasClickAlternative: hasClick,
981
1424
  isKeyboardPath,
982
1425
  text: el.textContent?.trim().slice(0, 30) || '',
@@ -1009,11 +1452,19 @@ async function detectMotorBarriers(ctx) {
1009
1452
  ctx.wcagViolations.add("2.1.1");
1010
1453
  }
1011
1454
  // Check for drag-and-drop without keyboard alternative
1012
- const dragDropElements = await page.$$eval('[draggable="true"], [class*="drag"], [class*="sortable"], [class*="reorder"]', (elements) => elements.map(el => ({
1013
- selector: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : ''),
1014
- hasAriaGrabbed: el.hasAttribute('aria-grabbed'),
1015
- hasKeyboardHandler: el.hasAttribute('onkeydown') || el.hasAttribute('onkeyup'),
1016
- })));
1455
+ const dragDropElements = await page.$$eval('[draggable="true"], [class*="drag"], [class*="sortable"], [class*="reorder"]', (elements) => elements.map(el => {
1456
+ const uniq = window.__cbrowserUniqueSelector;
1457
+ const r = el.getBoundingClientRect();
1458
+ return {
1459
+ selector: uniq ? uniq(el) : el.tagName.toLowerCase() + (el.id ? `#${el.id}` : ''),
1460
+ hasAriaGrabbed: el.hasAttribute('aria-grabbed'),
1461
+ hasKeyboardHandler: el.hasAttribute('onkeydown') || el.hasAttribute('onkeyup'),
1462
+ x: Math.round(r.left + window.scrollX),
1463
+ y: Math.round(r.top + window.scrollY),
1464
+ width: Math.round(r.width),
1465
+ height: Math.round(r.height),
1466
+ };
1467
+ }));
1017
1468
  for (const el of dragDropElements.slice(0, 3)) {
1018
1469
  if (!el.hasKeyboardHandler) {
1019
1470
  barriers.push({
@@ -1025,14 +1476,21 @@ async function detectMotorBarriers(ctx) {
1025
1476
  wcagCriteria: ["2.1.1", "2.5.7"],
1026
1477
  severity: "critical",
1027
1478
  remediation: "Provide keyboard-accessible alternative for drag-and-drop (arrow keys, or explicit move buttons)",
1479
+ rect: { x: el.x, y: el.y, width: el.width, height: el.height },
1028
1480
  });
1029
1481
  ctx.wcagViolations.add("2.1.1");
1030
1482
  }
1031
1483
  }
1032
1484
  // Check for very small spacing between interactive elements (motor precision issue)
1485
+ // Viewport-filtered. It scanned the first 30 targets in DOM order with no
1486
+ // scope test and carried no rect, so a viewport audit counted pairs seven
1487
+ // screens down and the central filter could not drop them. (B7, 2026-10-09)
1033
1488
  const closeElements = await page.$$eval('button, a, input[type="checkbox"], input[type="radio"]', (elements) => {
1034
1489
  const closeGroups = [];
1035
- const elArray = Array.from(elements);
1490
+ const inVp = window.__cbrowserInViewport ?? (() => true);
1491
+ const uniq = window.__cbrowserUniqueSelector
1492
+ ?? ((e) => e.tagName.toLowerCase() + (e.id ? `#${e.id}` : ''));
1493
+ const elArray = Array.from(elements).filter(inVp);
1036
1494
  for (let i = 0; i < Math.min(elArray.length, 30); i++) {
1037
1495
  const rect1 = elArray[i].getBoundingClientRect();
1038
1496
  if (rect1.width === 0)
@@ -1048,10 +1506,7 @@ async function detectMotorBarriers(ctx) {
1048
1506
  // Flag elements less than 8px apart
1049
1507
  if (distance < 8 && distance >= 0) {
1050
1508
  closeGroups.push({
1051
- selectors: [
1052
- elArray[i].tagName.toLowerCase() + (elArray[i].id ? `#${elArray[i].id}` : ''),
1053
- elArray[j].tagName.toLowerCase() + (elArray[j].id ? `#${elArray[j].id}` : '')
1054
- ],
1509
+ selectors: [uniq(elArray[i]), uniq(elArray[j])],
1055
1510
  spacing: Math.round(distance),
1056
1511
  });
1057
1512
  }
@@ -1060,13 +1515,32 @@ async function detectMotorBarriers(ctx) {
1060
1515
  return closeGroups.slice(0, 5);
1061
1516
  });
1062
1517
  if (closeElements.length > 0) {
1518
+ // Spacing is not 2.5.5. That criterion is Target SIZE, and citing it put
1519
+ // this heuristic in the touch_target bucket, graded "major" and never
1520
+ // level-adjusted: on the reported page it was most of a -20 touch_target
1521
+ // deduction. Spacing RELATES to 2.5.8, whose exception lets an undersized
1522
+ // target pass when it is far enough from its neighbours, but closeness on
1523
+ // its own fails no criterion (two 44px targets 4px apart conform). So:
1524
+ // 2.5.8 as a related, advisory criterion; minor, like the other
1525
+ // relates-to findings; its own weight bucket; and run through
1526
+ // adjustSeverityForLevel like every located finding. (B6, 2026-10-09)
1527
+ const spacingCriteria = ["2.5.8"];
1528
+ // The elements involved, each once: three targets in a row are two pairs
1529
+ // and three elements, not "2 element groups" counted as one.
1530
+ const spacingMembers = Array.from(new Set(closeElements.flatMap((g) => g.selectors)));
1063
1531
  barriers.push({
1064
1532
  type: "motor_precision",
1065
1533
  element: `${closeElements.length} element groups`,
1066
- description: `${closeElements.length} groups of interactive elements are very close together (< 8px spacing), making them difficult to target for users with tremors`,
1534
+ // A page-wide aggregate of pairs, with no single location to draw.
1535
+ pageLevel: true,
1536
+ members: spacingMembers.slice(0, 10),
1537
+ affectedElementCount: spacingMembers.length,
1538
+ description: `${closeElements.length} groups of interactive elements are very close together (< 8px spacing), making them difficult to target for users with tremors (relates to the WCAG 2.5.8 spacing exception; not a violation on its own)`,
1067
1539
  affectedPersonas: ["motor-impairment-tremor"],
1068
- wcagCriteria: ["2.5.5"],
1069
- severity: "major",
1540
+ wcagCriteria: spacingCriteria,
1541
+ wcagAdvisoryCriteria: spacingCriteria,
1542
+ weightKey: "target_spacing",
1543
+ severity: adjustSeverityForLevel("minor", getBarrierWcagLevel(spacingCriteria), ctx.wcagLevel),
1070
1544
  remediation: "Increase spacing between interactive elements to at least 8-12px",
1071
1545
  });
1072
1546
  }
@@ -1085,21 +1559,35 @@ async function detectMotorBarriers(ctx) {
1085
1559
  async function detectCognitiveBarriers(ctx) {
1086
1560
  const { page, barriers } = ctx;
1087
1561
  // Check for auto-playing media (distraction for ADHD)
1088
- const autoPlayMedia = await page.$$eval('video[autoplay], audio[autoplay], [class*="autoplay"]', (elements) => elements.map(el => ({
1089
- selector: el.tagName.toLowerCase(),
1090
- hasControls: el.hasAttribute('controls'),
1091
- hasMuted: el.hasAttribute('muted'),
1092
- })));
1562
+ const autoPlayMedia = await page.$$eval('video[autoplay], audio[autoplay], [class*="autoplay"]', (elements) => elements.map(el => {
1563
+ const uniq = window.__cbrowserUniqueSelector;
1564
+ return {
1565
+ tag: el.tagName.toLowerCase(),
1566
+ // Was the bare tag name: every autoplaying video was "video".
1567
+ selector: uniq ? uniq(el) : el.tagName.toLowerCase(),
1568
+ hasControls: el.hasAttribute('controls'),
1569
+ hasMuted: el.hasAttribute('muted'),
1570
+ };
1571
+ }));
1093
1572
  for (const media of autoPlayMedia) {
1094
- if (!media.hasMuted) {
1573
+ // Native `controls` ARE a pause/stop mechanism -- this barrier's own
1574
+ // remediation says so -- and satisfy both 1.4.2 and 2.2.2. hasControls was
1575
+ // computed and never read, so a video with a play/pause bar was reported
1576
+ // as uncontrollable. Same defect as the uncredited carousel. (B5 sibling,
1577
+ // 2026-10-09)
1578
+ if (!media.hasMuted && !media.hasControls) {
1095
1579
  barriers.push({
1096
1580
  type: "cognitive_load",
1097
1581
  element: media.selector,
1098
- description: `Auto-playing ${media.selector} with sound can be highly distracting for users with ADHD`,
1582
+ description: `Auto-playing ${media.tag} with sound can be highly distracting for users with ADHD`,
1099
1583
  affectedPersonas: ["cognitive-adhd"],
1100
1584
  wcagCriteria: ["1.4.2", "2.2.2"],
1101
1585
  severity: "critical",
1102
1586
  remediation: "Add muted attribute to autoplay media, or provide user controls to pause/stop",
1587
+ // Deliberately no rect. This is a finding about SOUND, which is heard
1588
+ // on first paint wherever the element sits; a rect would let the
1589
+ // viewport filter drop an autoplaying video below the fold.
1590
+ pageLevel: true,
1103
1591
  });
1104
1592
  ctx.wcagViolations.add("1.4.2");
1105
1593
  }
@@ -1107,15 +1595,21 @@ async function detectCognitiveBarriers(ctx) {
1107
1595
  // Check for multi-step forms without progress indicator
1108
1596
  const multiStepForms = await page.$$eval('form', (forms) => {
1109
1597
  const results = [];
1598
+ const uniq = window.__cbrowserUniqueSelector;
1110
1599
  for (const form of forms) {
1111
1600
  const inputs = form.querySelectorAll('input:not([type="hidden"]):not([type="submit"]), textarea, select');
1112
1601
  const hasProgress = form.querySelector('[class*="progress"], [class*="stepper"], [role="progressbar"]') !== null ||
1113
1602
  document.querySelector('[class*="step-indicator"], [class*="wizard"]') !== null;
1603
+ const r = form.getBoundingClientRect();
1114
1604
  results.push({
1115
- selector: 'form' + (form.id ? `#${form.id}` : ''),
1605
+ selector: uniq ? uniq(form) : 'form' + (form.id ? `#${form.id}` : ''),
1116
1606
  fieldCount: inputs.length,
1117
1607
  hasProgress,
1118
1608
  hasStepIndicator: hasProgress,
1609
+ x: Math.round(r.left + window.scrollX),
1610
+ y: Math.round(r.top + window.scrollY),
1611
+ width: Math.round(r.width),
1612
+ height: Math.round(r.height),
1119
1613
  });
1120
1614
  }
1121
1615
  return results;
@@ -1134,23 +1628,30 @@ async function detectCognitiveBarriers(ctx) {
1134
1628
  wcagCriteria: ["3.3.4"],
1135
1629
  severity: "major",
1136
1630
  remediation: "Add progress indicator showing steps completed and remaining, or break form into clearly numbered sections",
1631
+ rect: { x: form.x, y: form.y, width: form.width, height: form.height },
1137
1632
  });
1138
1633
  }
1139
1634
  }
1140
1635
  // Check for dense text blocks (dyslexia barrier)
1141
1636
  const denseTextBlocks = await page.$$eval('p, article, section', (elements) => {
1142
1637
  const results = [];
1638
+ const uniq = window.__cbrowserUniqueSelector;
1143
1639
  for (const el of elements.slice(0, 20)) {
1144
1640
  const text = el.textContent || '';
1145
1641
  const wordCount = text.split(/\s+/).filter(w => w.length > 0).length;
1146
1642
  const styles = window.getComputedStyle(el);
1147
1643
  // Flag blocks with 200+ words AND tight line spacing
1148
1644
  if (wordCount > 200) {
1645
+ const r = el.getBoundingClientRect();
1149
1646
  results.push({
1150
- selector: el.tagName.toLowerCase() + (el.className ? `.${String(el.className?.baseVal ?? el.className ?? "").split(' ')[0]}` : ''),
1647
+ selector: uniq ? uniq(el) : el.tagName.toLowerCase() + (el.className ? `.${String(el.className?.baseVal ?? el.className ?? "").split(' ')[0]}` : ''),
1151
1648
  wordCount,
1152
1649
  lineHeight: styles.lineHeight,
1153
1650
  fontSize: styles.fontSize,
1651
+ x: Math.round(r.left + window.scrollX),
1652
+ y: Math.round(r.top + window.scrollY),
1653
+ width: Math.round(r.width),
1654
+ height: Math.round(r.height),
1154
1655
  });
1155
1656
  }
1156
1657
  }
@@ -1170,17 +1671,21 @@ async function detectCognitiveBarriers(ctx) {
1170
1671
  wcagCriteria: ["1.4.12"],
1171
1672
  severity: "major",
1172
1673
  remediation: "Increase line-height to at least 1.5x font size, and consider breaking text into shorter paragraphs with headings",
1674
+ rect: { x: block.x, y: block.y, width: block.width, height: block.height },
1173
1675
  });
1174
1676
  ctx.wcagViolations.add("1.4.12");
1175
1677
  }
1176
1678
  }
1177
1679
  // Check for justified text (dyslexia barrier)
1680
+ // In scope only: an aggregate has no rect for the central viewport filter.
1178
1681
  const justifiedText = await page.$$eval('p, article, div', (elements) => {
1179
1682
  const results = [];
1683
+ const inVp = window.__cbrowserInViewport ?? (() => true);
1684
+ const uniq = window.__cbrowserUniqueSelector;
1180
1685
  for (const el of elements.slice(0, 50)) {
1181
1686
  const styles = window.getComputedStyle(el);
1182
- if (styles.textAlign === 'justify') {
1183
- results.push(el.tagName.toLowerCase() + (el.className ? `.${String(el.className?.baseVal ?? el.className ?? "").split(' ')[0]}` : ''));
1687
+ if (styles.textAlign === 'justify' && inVp(el)) {
1688
+ results.push(uniq ? uniq(el) : el.tagName.toLowerCase() + (el.className ? `.${String(el.className?.baseVal ?? el.className ?? "").split(' ')[0]}` : ''));
1184
1689
  }
1185
1690
  }
1186
1691
  return results.slice(0, 5);
@@ -1189,6 +1694,9 @@ async function detectCognitiveBarriers(ctx) {
1189
1694
  barriers.push({
1190
1695
  type: "cognitive_load",
1191
1696
  element: `${justifiedText.length} elements`,
1697
+ pageLevel: true,
1698
+ members: justifiedText,
1699
+ affectedElementCount: justifiedText.length,
1192
1700
  description: `Justified text creates uneven word spacing that makes reading difficult for dyslexic users`,
1193
1701
  affectedPersonas: ["dyslexic-user"],
1194
1702
  wcagCriteria: ["1.4.12"],
@@ -1212,15 +1720,19 @@ async function detectCognitiveBarriers(ctx) {
1212
1720
  async function detectVisionBarriers(ctx) {
1213
1721
  const { page, barriers } = ctx;
1214
1722
  // Check for small base font sizes (vision impairment)
1723
+ // The four aggregates below have no rect for the central viewport filter
1724
+ // to test, so each scan applies the scope itself. (B7, 2026-10-09)
1215
1725
  const smallFontElements = await page.$$eval('body, p, span, div, li, td', (elements) => {
1216
1726
  const results = [];
1727
+ const inVp = window.__cbrowserInViewport ?? (() => true);
1728
+ const uniq = window.__cbrowserUniqueSelector;
1217
1729
  for (const el of elements.slice(0, 100)) {
1218
1730
  const styles = window.getComputedStyle(el);
1219
1731
  const fontSize = parseFloat(styles.fontSize);
1220
1732
  // Flag fonts smaller than 14px as problematic for low vision
1221
- if (fontSize > 0 && fontSize < 14) {
1733
+ if (fontSize > 0 && fontSize < 14 && inVp(el)) {
1222
1734
  results.push({
1223
- selector: el.tagName.toLowerCase() + (el.className ? `.${String(el.className?.baseVal ?? el.className ?? "").split(' ')[0]}` : ''),
1735
+ selector: uniq ? uniq(el) : el.tagName.toLowerCase() + (el.className ? `.${String(el.className?.baseVal ?? el.className ?? "").split(' ')[0]}` : ''),
1224
1736
  fontSize: styles.fontSize,
1225
1737
  fontSizeNum: fontSize,
1226
1738
  });
@@ -1233,6 +1745,9 @@ async function detectVisionBarriers(ctx) {
1233
1745
  barriers.push({
1234
1746
  type: "visual_clarity",
1235
1747
  element: `${smallFontElements.length} elements`,
1748
+ pageLevel: true,
1749
+ members: smallFontElements.map((e) => e.selector),
1750
+ affectedElementCount: smallFontElements.length,
1236
1751
  description: `${smallFontElements.length} text elements use small font sizes (avg ${avgSize.toFixed(0)}px) that may be difficult for low-vision users`,
1237
1752
  affectedPersonas: ["low-vision-magnified", "elderly-low-vision"],
1238
1753
  wcagCriteria: ["1.4.4"],
@@ -1244,12 +1759,14 @@ async function detectVisionBarriers(ctx) {
1244
1759
  // Check for thin fonts (hard for low vision)
1245
1760
  const thinFontElements = await page.$$eval('body, h1, h2, h3, p, span', (elements) => {
1246
1761
  const results = [];
1762
+ const inVp = window.__cbrowserInViewport ?? (() => true);
1763
+ const uniq = window.__cbrowserUniqueSelector;
1247
1764
  for (const el of elements.slice(0, 50)) {
1248
1765
  const styles = window.getComputedStyle(el);
1249
1766
  const fontWeight = parseInt(styles.fontWeight, 10) || 400;
1250
1767
  // Font weight < 400 is thin and harder to read
1251
- if (fontWeight < 400 && el.textContent && el.textContent.trim().length > 0) {
1252
- results.push(el.tagName.toLowerCase());
1768
+ if (fontWeight < 400 && el.textContent && el.textContent.trim().length > 0 && inVp(el)) {
1769
+ results.push(uniq ? uniq(el) : el.tagName.toLowerCase());
1253
1770
  }
1254
1771
  }
1255
1772
  return results;
@@ -1258,6 +1775,9 @@ async function detectVisionBarriers(ctx) {
1258
1775
  barriers.push({
1259
1776
  type: "visual_clarity",
1260
1777
  element: `${thinFontElements.length} text elements`,
1778
+ pageLevel: true,
1779
+ members: thinFontElements.slice(0, 10),
1780
+ affectedElementCount: thinFontElements.length,
1261
1781
  description: `Multiple elements use thin font weights (< 400) which are harder to read for low-vision users`,
1262
1782
  affectedPersonas: ["low-vision-magnified", "elderly-low-vision"],
1263
1783
  wcagCriteria: ["1.4.12"],
@@ -1268,13 +1788,16 @@ async function detectVisionBarriers(ctx) {
1268
1788
  // Check for links distinguished only by color (color blindness)
1269
1789
  const colorOnlyLinks = await page.$$eval('a', (links) => {
1270
1790
  const results = [];
1791
+ const inVp = window.__cbrowserInViewport ?? (() => true);
1792
+ const uniq = window.__cbrowserUniqueSelector;
1271
1793
  for (const link of links.slice(0, 30)) {
1272
1794
  const styles = window.getComputedStyle(link);
1273
1795
  const hasUnderline = styles.textDecoration.includes('underline');
1274
1796
  const hasIcon = link.querySelector('svg, i, [class*="icon"]') !== null;
1275
- if (!hasUnderline && !hasIcon) {
1797
+ if (!hasUnderline && !hasIcon && inVp(link)) {
1276
1798
  results.push({
1277
- selector: link.textContent?.trim().slice(0, 20) || 'link',
1799
+ // Was the link's text, which is not a selector.
1800
+ selector: uniq ? uniq(link) : link.textContent?.trim().slice(0, 20) || 'link',
1278
1801
  hasUnderline,
1279
1802
  hasIcon,
1280
1803
  });
@@ -1286,6 +1809,9 @@ async function detectVisionBarriers(ctx) {
1286
1809
  barriers.push({
1287
1810
  type: "sensory",
1288
1811
  element: `${colorOnlyLinks.length} links`,
1812
+ pageLevel: true,
1813
+ members: colorOnlyLinks.slice(0, 10).map((l) => l.selector),
1814
+ affectedElementCount: colorOnlyLinks.length,
1289
1815
  description: `${colorOnlyLinks.length} links are distinguished only by color, without underline or icon - color-blind users may not identify them as links`,
1290
1816
  affectedPersonas: ["color-blind-deuteranopia"],
1291
1817
  wcagCriteria: ["1.4.1"],
@@ -1297,7 +1823,11 @@ async function detectVisionBarriers(ctx) {
1297
1823
  // Check for status indicators using only red/green (color blindness)
1298
1824
  const redGreenIndicators = await page.$$eval('[class*="status"], [class*="indicator"], [class*="badge"], [class*="alert"]', (elements) => {
1299
1825
  const problematic = [];
1826
+ const inVp = window.__cbrowserInViewport ?? (() => true);
1827
+ const uniq = window.__cbrowserUniqueSelector;
1300
1828
  for (const el of elements.slice(0, 20)) {
1829
+ if (!inVp(el))
1830
+ continue;
1301
1831
  const styles = window.getComputedStyle(el);
1302
1832
  const bgColor = styles.backgroundColor;
1303
1833
  const color = styles.color;
@@ -1307,7 +1837,7 @@ async function detectVisionBarriers(ctx) {
1307
1837
  const hasIcon = el.querySelector('svg, i, [class*="icon"]') !== null;
1308
1838
  const hasText = (el.textContent?.trim() || '').length > 1;
1309
1839
  if (hasRedGreen && !hasIcon && !hasText) {
1310
- problematic.push(el.className?.split(' ')[0] || 'indicator');
1840
+ problematic.push(uniq ? uniq(el) : (String(el.className?.baseVal ?? el.className ?? "").split(' ')[0] || 'indicator'));
1311
1841
  }
1312
1842
  }
1313
1843
  return problematic;
@@ -1316,6 +1846,9 @@ async function detectVisionBarriers(ctx) {
1316
1846
  barriers.push({
1317
1847
  type: "sensory",
1318
1848
  element: `${redGreenIndicators.length} status indicators`,
1849
+ pageLevel: true,
1850
+ members: redGreenIndicators.slice(0, 10),
1851
+ affectedElementCount: redGreenIndicators.length,
1319
1852
  description: `Status indicators using red/green without additional cues may be indistinguishable for color-blind users`,
1320
1853
  affectedPersonas: ["color-blind-deuteranopia"],
1321
1854
  wcagCriteria: ["1.4.1"],
@@ -1341,6 +1874,8 @@ async function simulateAccessibilityJourney(page, url, goal, persona, scope = "v
1341
1874
  viewportOnly: scope === "viewport",
1342
1875
  wcagLevel,
1343
1876
  unverifiableMedia: [],
1877
+ advisories: [],
1878
+ motionControlsCredited: [],
1344
1879
  };
1345
1880
  // Named for what it is computed from. See EmpathyPersonaResult in types.ts:
1346
1881
  // this is `blockingBarriers.length === 0`, not evidence anyone finished a task.
@@ -1352,8 +1887,10 @@ async function simulateAccessibilityJourney(page, url, goal, persona, scope = "v
1352
1887
  let finalEmotionalState;
1353
1888
  let emotionalEvents;
1354
1889
  try {
1355
- // Navigate to URL
1356
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 });
1890
+ // Navigate to URL. A failed load is reported in plain words (unresolvable
1891
+ // host, refused...) rather than Playwright's raw error and Call log.
1892
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 10000 })
1893
+ .catch((e) => { throw toNavigationError(e, url); });
1357
1894
  await page.waitForTimeout(1500);
1358
1895
  // Scope-dependent page discovery
1359
1896
  if (scope === "full_page") {
@@ -1392,6 +1929,8 @@ async function simulateAccessibilityJourney(page, url, goal, persona, scope = "v
1392
1929
  window.__cbrowserViewportOnly = false;
1393
1930
  });
1394
1931
  }
1932
+ // One selector builder for every detector. See installUniqueSelector.
1933
+ await page.evaluate(installUniqueSelector);
1395
1934
  // Run barrier detection
1396
1935
  // v10.10.0: All general detectors run unconditionally regardless of persona
1397
1936
  await detectSmallTouchTargets(ctx);
@@ -1443,6 +1982,14 @@ async function simulateAccessibilityJourney(page, url, goal, persona, scope = "v
1443
1982
  ctx.outOfViewportDropped = dropped;
1444
1983
  }
1445
1984
  }
1985
+ // Above-level findings leave the scored set here, once, before scoring,
1986
+ // friction and remediation read it. See splitAboveAuditLevel.
1987
+ {
1988
+ const { scored, advisories } = splitAboveAuditLevel(ctx.barriers, wcagLevel);
1989
+ ctx.barriers.length = 0;
1990
+ ctx.barriers.push(...scored);
1991
+ ctx.advisories.push(...advisories);
1992
+ }
1446
1993
  // Use cognitive journey for realistic step tracking if API key available
1447
1994
  // v18.35.0: Add hard timeout to prevent MCP proxy disconnection (~60s limit on claude.ai)
1448
1995
  const journeyTimeoutMs = Math.min(maxTime * 1000, 45000); // Hard cap at 45s to leave margin
@@ -1742,6 +2289,11 @@ async function simulateAccessibilityJourney(page, url, goal, persona, scope = "v
1742
2289
  // Deprecated alias, one release only. See EmpathyPersonaResult.
1743
2290
  goalAchieved: noBlockingBarriers,
1744
2291
  barriers: ctx.barriers,
2292
+ // Above the audited level: reported, never scored. See splitAboveAuditLevel.
2293
+ ...(ctx.advisories.length > 0 ? { advisories: ctx.advisories } : {}),
2294
+ // Moving content that would have been a 2.2.2 barrier but has an
2295
+ // associated pause control. Listed so a credit is visible as one.
2296
+ ...(ctx.motionControlsCredited.length > 0 ? { motionControlsCredited: ctx.motionControlsCredited } : {}),
1745
2297
  // Surfaced, not kept internal: a filter whose effect is invisible cannot be
1746
2298
  // told apart from a filter that never ran, and that is exactly the defect
1747
2299
  // this closes.
@@ -1935,8 +2487,8 @@ function generateRemediationPriority(barriers, personaName) {
1935
2487
  const rank = (b) => {
1936
2488
  if (!personaName)
1937
2489
  return severityOrder[b.severity] ?? 3;
1938
- const { weight } = barrierWeightFor(personaName, b.type, b.wcagCriteria);
1939
- const { severity } = weightedSeverity(b.severity, weight);
2490
+ const { weight } = barrierWeightFor(personaName, b.type, b.wcagCriteria, b.weightKey);
2491
+ const { severity } = weightedSeverity(b.severity, weight, { aboveAuditLevel: b.aboveAuditLevel });
1940
2492
  return severityOrder[String(severity).toLowerCase()] ?? 3;
1941
2493
  };
1942
2494
  const sorted = [...barriers].sort((a, b) => rank(a) - rank(b));
@@ -2002,6 +2554,22 @@ function getEmpathyGrade(score) {
2002
2554
  return "D";
2003
2555
  return "F";
2004
2556
  }
2557
+ /** Escape text for HTML element and double-quoted attribute context. */
2558
+ function escapeHtml(v) {
2559
+ return String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
2560
+ }
2561
+ /**
2562
+ * The criteria an advisory is filed under, with the level that put it above
2563
+ * the audit: "2.5.5 (Level AAA)". Shared by the text and HTML reports.
2564
+ */
2565
+ function advisoryCriteriaLabel(a) {
2566
+ const known = (a.wcagCriteria ?? []).filter((c) => WCAG_CRITERIA[c]);
2567
+ const codes = known.length > 0 ? known : (a.wcagCriteria ?? []);
2568
+ const level = a.wcagLevelOfFinding ?? (known.length > 0 ? getBarrierWcagLevel(known) : undefined);
2569
+ return `${codes.join(", ") || "no criterion"}${level ? ` (Level ${level})` : ""}`;
2570
+ }
2571
+ /** How many advisories each report lists before saying how many more there are. */
2572
+ const REPORT_ADVISORY_CAP = 20;
2005
2573
  export function formatEmpathyAuditReport(result) {
2006
2574
  const grade = getEmpathyGrade(result.overallScore);
2007
2575
  let report = `
@@ -2057,6 +2625,25 @@ WCAG VIOLATIONS
2057
2625
  report += ` ${violation}\n`;
2058
2626
  }
2059
2627
  }
2628
+ // Above-level findings. splitAboveAuditLevel moves them out of `barriers`,
2629
+ // so without this section an AAA-only finding that main showed as a minor
2630
+ // barrier vanished from the text report altogether. (Round 2, 2026-10-09)
2631
+ const advisories = result.advisories ?? [];
2632
+ if (advisories.length > 0) {
2633
+ report += `
2634
+ ADVISORIES (${advisories.length}) - above the audited WCAG level: reported, not scored
2635
+ ───────────────────────────────────────────────────────────────────────────
2636
+ `;
2637
+ for (const a of advisories.slice(0, REPORT_ADVISORY_CAP)) {
2638
+ report += ` ${advisoryCriteriaLabel(a)}, ${a.severity}: ${a.description}
2639
+ Element: ${a.element}
2640
+ Fix: ${a.remediation}
2641
+ `;
2642
+ }
2643
+ if (advisories.length > REPORT_ADVISORY_CAP) {
2644
+ report += ` (+${advisories.length - REPORT_ADVISORY_CAP} more advisories)\n`;
2645
+ }
2646
+ }
2060
2647
  // Top remediations
2061
2648
  report += `
2062
2649
  TOP REMEDIATION PRIORITIES
@@ -2129,6 +2716,21 @@ export function generateEmpathyAuditHtmlReport(result) {
2129
2716
  const criteria = WCAG_CRITERIA[v];
2130
2717
  return `<li><strong>${v}</strong> (Level ${criteria?.level || '?'}): ${criteria?.description || 'Unknown'}</li>`;
2131
2718
  }).join('');
2719
+ // Above-level findings, escaped: element names and descriptions carry page
2720
+ // text (aria-labels, selectors). See the text report for why this exists.
2721
+ const advisories = result.advisories ?? [];
2722
+ const advisoryItems = advisories.slice(0, REPORT_ADVISORY_CAP).map((a) => `
2723
+ <li><strong>${escapeHtml(advisoryCriteriaLabel(a))}</strong>, ${escapeHtml(a.severity)}: ${escapeHtml(a.description)}
2724
+ <br><small>Element: <code>${escapeHtml(a.element)}</code>; Fix: ${escapeHtml(a.remediation)}</small></li>`).join('');
2725
+ const advisoriesSection = advisories.length === 0 ? '' : `
2726
+ <h2>Advisories (${advisories.length})</h2>
2727
+ <div class="wcag-list advisories">
2728
+ <p class="advisories-note">Findings above the audited WCAG level. Reported for information: not scored, not counted as violations, never escalated by persona weight.</p>
2729
+ <ul>${advisoryItems}
2730
+ </ul>${advisories.length > REPORT_ADVISORY_CAP ? `
2731
+ <p class="advisories-note">+${advisories.length - REPORT_ADVISORY_CAP} more advisories</p>` : ''}
2732
+ </div>
2733
+ `;
2132
2734
  const remediationRows = result.combinedRemediation.slice(0, 10).map(rem => `
2133
2735
  <tr>
2134
2736
  <td>${rem.priority}</td>
@@ -2293,6 +2895,14 @@ export function generateEmpathyAuditHtmlReport(result) {
2293
2895
  .wcag-list li {
2294
2896
  margin: 0.5rem 0;
2295
2897
  }
2898
+ .advisories-note {
2899
+ margin: 0 0 0.5rem 0;
2900
+ font-size: 0.875rem;
2901
+ color: #94a3b8;
2902
+ }
2903
+ .advisories small, .advisories code {
2904
+ color: #cbd5e1;
2905
+ }
2296
2906
  .disclaimer {
2297
2907
  background: #1e3a5f;
2298
2908
  border-left: 4px solid #8b5cf6;
@@ -2354,6 +2964,7 @@ export function generateEmpathyAuditHtmlReport(result) {
2354
2964
  </ul>
2355
2965
  </div>
2356
2966
 
2967
+ ${advisoriesSection}
2357
2968
  <h2>Remediation Priorities</h2>
2358
2969
  <table>
2359
2970
  <thead>
@@ -2395,11 +3006,39 @@ export function generateEmpathyAuditHtmlReport(result) {
2395
3006
  * - Highest severity kept
2396
3007
  */
2397
3008
  function deduplicateBarriers(_allBarriers, results) {
3009
+ return groupBarriers(results.flatMap((r) => r.barriers));
3010
+ }
3011
+ /** How many elements one barrier stands for: its member count, else 1. */
3012
+ export function barrierElementCount(b) {
3013
+ const n = b.affectedElementCount;
3014
+ return typeof n === "number" && Number.isFinite(n) && n > 0 ? Math.round(n) : 1;
3015
+ }
3016
+ /**
3017
+ * Affected elements in a barrier set, counted exactly as topBarriers counts
3018
+ * them: the sum of the grouped entries' affectedElementCount.
3019
+ *
3020
+ * resultsSummary.affectedElements was `barriers.length` -- barrier RECORDS,
3021
+ * not elements -- while each topBarriers entry counted distinct element
3022
+ * STRINGS, so "button", "animation" and "5 element groups" each collapsed many
3023
+ * elements into one and a group barrier counted 1. The two numbers on the
3024
+ * same page could not be reconciled. One function now produces both. (B7,
3025
+ * 2026-10-09)
3026
+ */
3027
+ export function countAffectedElements(barriers) {
3028
+ return groupBarriers(barriers).reduce((n, b) => n + (b.affectedElementCount ?? 0), 0);
3029
+ }
3030
+ function groupBarriers(allBarriers) {
2398
3031
  // Group by barrier TYPE only (not element)
2399
3032
  // Keyed "type|weightKey" now, not a bare type.
2400
3033
  const barriersByType = new Map();
2401
- for (const result of results) {
2402
- for (const barrier of result.barriers) {
3034
+ const locationKey = (b) => `${b.element}|${b.rect ? `${b.rect.x},${b.rect.y},${b.rect.width},${b.rect.height}` : ""}`;
3035
+ const addElement = (m, b) => {
3036
+ const k = locationKey(b);
3037
+ const prev = m.get(k);
3038
+ m.set(k, { element: b.element, count: Math.max(prev?.count ?? 0, barrierElementCount(b)) });
3039
+ };
3040
+ {
3041
+ for (const barrier of allBarriers) {
2403
3042
  // Grouped by type AND the susceptibility key its criteria resolve to,
2404
3043
  // not by type alone.
2405
3044
  //
@@ -2411,7 +3050,7 @@ function deduplicateBarriers(_allBarriers, results) {
2411
3050
  // matters most arrived bundled inside a colour barrier whose fix was
2412
3051
  // "add patterns alongside colour". Unioning the criteria made them
2413
3052
  // visible; separating the groups makes them actionable.
2414
- const groupKey = `${barrier.type}|${weightKeyFor(barrier.type, barrier.wcagCriteria) ?? ""}`;
3053
+ const groupKey = `${barrier.type}|${weightKeyFor(barrier.type, barrier.wcagCriteria, barrier.weightKey) ?? ""}`;
2415
3054
  const existing = barriersByType.get(groupKey);
2416
3055
  // Use the barrier's OWN affectedPersonas (set by the detector — accurate)
2417
3056
  // rather than the test persona's name. Previously we overwrote with
@@ -2422,7 +3061,7 @@ function deduplicateBarriers(_allBarriers, results) {
2422
3061
  existing.barriers.push(barrier);
2423
3062
  for (const p of barrierPersonas)
2424
3063
  existing.personas.add(p);
2425
- existing.elements.add(barrier.element);
3064
+ addElement(existing.elements, barrier);
2426
3065
  // Track highest severity
2427
3066
  const severityOrder = { critical: 3, major: 2, minor: 1 };
2428
3067
  if (severityOrder[barrier.severity] > severityOrder[existing.highestSeverity]) {
@@ -2430,10 +3069,12 @@ function deduplicateBarriers(_allBarriers, results) {
2430
3069
  }
2431
3070
  }
2432
3071
  else {
3072
+ const elements = new Map();
3073
+ addElement(elements, barrier);
2433
3074
  barriersByType.set(groupKey, {
2434
3075
  barriers: [barrier],
2435
3076
  personas: new Set(barrierPersonas),
2436
- elements: new Set([barrier.element]),
3077
+ elements,
2437
3078
  highestSeverity: barrier.severity,
2438
3079
  });
2439
3080
  }
@@ -2445,17 +3086,23 @@ function deduplicateBarriers(_allBarriers, results) {
2445
3086
  // The map is keyed "type|weightKey"; the emitted barrier keeps the plain
2446
3087
  // type, since that is what downstream weighting and styling read.
2447
3088
  const type = groupKey.split("|")[0];
2448
- const elementCount = data.elements.size;
3089
+ // Elements, not records: the sum of each location's member count.
3090
+ const elementCount = Array.from(data.elements.values()).reduce((n, e) => n + e.count, 0);
3091
+ const locations = data.elements.size;
2449
3092
  const representative = data.barriers[0]; // Use first barrier as template
2450
3093
  // Create aggregated description
2451
- const elementList = Array.from(data.elements).slice(0, 5);
2452
- const moreCount = elementCount > 5 ? ` (+${elementCount - 5} more)` : "";
2453
- const aggregatedDescription = elementCount > 1
3094
+ const names = Array.from(new Set(Array.from(data.elements.values()).map((e) => e.element)));
3095
+ const elementList = names.slice(0, 5);
3096
+ const moreCount = names.length > 5 ? ` (+${names.length - 5} more)` : "";
3097
+ const aggregatedDescription = locations > 1
2454
3098
  ? `${representative.description.split(" - ")[0]} - affects ${elementCount} elements: ${elementList.join(", ")}${moreCount}`
2455
3099
  : representative.description;
2456
3100
  deduplicated.push({
2457
3101
  type,
2458
- element: elementCount > 1 ? `${elementCount} elements` : representative.element,
3102
+ element: locations > 1 ? `${elementCount} elements` : representative.element,
3103
+ // One location keeps its members (a carousel's animated slides).
3104
+ ...(locations === 1 && representative.members ? { members: representative.members } : {}),
3105
+ ...(data.barriers.every((b) => b.pageLevel) ? { pageLevel: true } : {}),
2459
3106
  description: aggregatedDescription,
2460
3107
  affectedPersonas: Array.from(data.personas),
2461
3108
  // Union across the group, not the representative's alone.
@@ -2476,8 +3123,19 @@ function deduplicateBarriers(_allBarriers, results) {
2476
3123
  // hid that one is an aggregate. (2026-07-29)
2477
3124
  severity: data.highestSeverity,
2478
3125
  severityIsGroupMax: true,
2479
- affectedElementCount: data.elements.size,
3126
+ affectedElementCount: elementCount,
2480
3127
  remediation: representative.remediation,
3128
+ // Every member of a group shares the key it was grouped under, so the
3129
+ // representative's is the group's. Without it the deduplicated entry
3130
+ // would re-resolve from its criteria and land in a different bucket.
3131
+ ...(representative.weightKey ? { weightKey: representative.weightKey } : {}),
3132
+ // Advisory-ness of the related criteria, unioned like wcagCriteria, so a
3133
+ // relates-to criterion does not read as violated on the grouped entry.
3134
+ ...(() => {
3135
+ const adv = Array.from(new Set(data.barriers.flatMap((b) => b.wcagAdvisoryCriteria ?? [])))
3136
+ .filter((c) => data.barriers.every((b) => !(b.wcagCriteria ?? []).includes(c) || (b.wcagAdvisoryCriteria ?? []).includes(c)));
3137
+ return adv.length > 0 ? { wcagAdvisoryCriteria: adv } : {};
3138
+ })(),
2481
3139
  });
2482
3140
  }
2483
3141
  return deduplicated;
@@ -2870,6 +3528,21 @@ export async function runEmpathyAudit(url, options) {
2870
3528
  const overallScore = results.length > 0
2871
3529
  ? Math.round(results.reduce((sum, r) => sum + r.empathyScore, 0) / results.length)
2872
3530
  : 0;
3531
+ // Above-level findings across personas, one entry per finding: the same
3532
+ // element seen by two personas is one advisory.
3533
+ const advisories = [];
3534
+ {
3535
+ const seen = new Set();
3536
+ for (const r of results) {
3537
+ for (const a of r.advisories ?? []) {
3538
+ const k = `${a.type}|${a.element}|${a.rect ? `${a.rect.x},${a.rect.y},${a.rect.width},${a.rect.height}` : ""}|${a.description}`;
3539
+ if (seen.has(k))
3540
+ continue;
3541
+ seen.add(k);
3542
+ advisories.push(a);
3543
+ }
3544
+ }
3545
+ }
2873
3546
  return {
2874
3547
  url,
2875
3548
  goal,
@@ -2878,6 +3551,7 @@ export async function runEmpathyAudit(url, options) {
2878
3551
  allWcagViolations: filteredViolations,
2879
3552
  allBarriers,
2880
3553
  topBarriers: deduplicatedBarriers, // v11.11.0: Deduplicated barriers grouped by type
3554
+ ...(advisories.length > 0 ? { advisories } : {}),
2881
3555
  combinedRemediation,
2882
3556
  overallScore,
2883
3557
  // Summed from the per-persona audits: a filter whose effect is invisible is