cbrowser 19.2.2 → 19.3.0
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/analysis/accessibility-empathy.d.ts +48 -1
- package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
- package/dist/analysis/accessibility-empathy.js +766 -92
- package/dist/analysis/accessibility-empathy.js.map +1 -1
- package/dist/analysis/agent-ready-audit.d.ts +28 -0
- package/dist/analysis/agent-ready-audit.d.ts.map +1 -1
- package/dist/analysis/agent-ready-audit.js +218 -21
- package/dist/analysis/agent-ready-audit.js.map +1 -1
- package/dist/analysis/bug-hunter.d.ts +45 -0
- package/dist/analysis/bug-hunter.d.ts.map +1 -1
- package/dist/analysis/bug-hunter.js +211 -64
- package/dist/analysis/bug-hunter.js.map +1 -1
- package/dist/analysis/competitive-benchmark.d.ts.map +1 -1
- package/dist/analysis/competitive-benchmark.js +4 -2
- package/dist/analysis/competitive-benchmark.js.map +1 -1
- package/dist/browser/session-manager.d.ts.map +1 -1
- package/dist/browser/session-manager.js +4 -2
- package/dist/browser/session-manager.js.map +1 -1
- package/dist/browser.d.ts +11 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +64 -15
- package/dist/browser.js.map +1 -1
- package/dist/cli.js +2 -1
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +19 -19
- package/dist/mcp-server.js.map +1 -1
- package/dist/mcp-tools/base/artifact-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/artifact-tools.js +29 -2
- package/dist/mcp-tools/base/artifact-tools.js.map +1 -1
- package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/audit-tools.js +114 -58
- package/dist/mcp-tools/base/audit-tools.js.map +1 -1
- package/dist/mcp-tools/base/browser-state-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/browser-state-tools.js +3 -2
- package/dist/mcp-tools/base/browser-state-tools.js.map +1 -1
- package/dist/mcp-tools/base/bug-analysis-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/bug-analysis-tools.js +4 -67
- package/dist/mcp-tools/base/bug-analysis-tools.js.map +1 -1
- package/dist/mcp-tools/base/index.js +2 -2
- package/dist/mcp-tools/base/index.js.map +1 -1
- package/dist/mcp-tools/base/interaction-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/interaction-tools.js +4 -0
- package/dist/mcp-tools/base/interaction-tools.js.map +1 -1
- package/dist/mcp-tools/base/visual-testing-tools.d.ts +2 -2
- package/dist/mcp-tools/base/visual-testing-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/visual-testing-tools.js +156 -38
- package/dist/mcp-tools/base/visual-testing-tools.js.map +1 -1
- package/dist/mcp-tools/security-layer.d.ts +33 -0
- package/dist/mcp-tools/security-layer.d.ts.map +1 -1
- package/dist/mcp-tools/security-layer.js +191 -1
- package/dist/mcp-tools/security-layer.js.map +1 -1
- package/dist/navigation-errors.d.ts +21 -0
- package/dist/navigation-errors.d.ts.map +1 -0
- package/dist/navigation-errors.js +119 -0
- package/dist/navigation-errors.js.map +1 -0
- package/dist/remediation/llms-txt.d.ts.map +1 -1
- package/dist/remediation/llms-txt.js +3 -1
- package/dist/remediation/llms-txt.js.map +1 -1
- package/dist/remediation/structured-data.d.ts.map +1 -1
- package/dist/remediation/structured-data.js +3 -1
- package/dist/remediation/structured-data.js.map +1 -1
- package/dist/security/script-gate.d.ts +7 -0
- package/dist/security/script-gate.d.ts.map +1 -1
- package/dist/security/script-gate.js +7 -0
- package/dist/security/script-gate.js.map +1 -1
- package/dist/testing/flaky-detection.js +1 -1
- package/dist/testing/flaky-detection.js.map +1 -1
- package/dist/testing/nl-test-suite.d.ts +12 -0
- package/dist/testing/nl-test-suite.d.ts.map +1 -1
- package/dist/testing/nl-test-suite.js +92 -14
- package/dist/testing/nl-test-suite.js.map +1 -1
- package/dist/testing/test-repair.js +3 -3
- package/dist/testing/test-repair.js.map +1 -1
- package/dist/types.d.ts +51 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/visual/attention-transport.d.ts +89 -11
- package/dist/visual/attention-transport.d.ts.map +1 -1
- package/dist/visual/attention-transport.js +148 -53
- package/dist/visual/attention-transport.js.map +1 -1
- package/dist/visual/cross-browser.d.ts.map +1 -1
- package/dist/visual/cross-browser.js +3 -1
- package/dist/visual/cross-browser.js.map +1 -1
- package/dist/visual/distance-metrics.js +6 -6
- package/dist/visual/distance-metrics.js.map +1 -1
- package/dist/visual/perceptual-transport.d.ts +14 -3
- package/dist/visual/perceptual-transport.d.ts.map +1 -1
- package/dist/visual/perceptual-transport.js +51 -9
- package/dist/visual/perceptual-transport.js.map +1 -1
- 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
|
-
|
|
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
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
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 (
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
456
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
1069
|
-
|
|
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
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
-
|
|
2402
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
2452
|
-
const
|
|
2453
|
-
const
|
|
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:
|
|
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:
|
|
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
|