cbrowser 18.82.2 → 19.0.1

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 (56) hide show
  1. package/README.md +42 -3
  2. package/dist/analysis/accessibility-empathy.d.ts +68 -0
  3. package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
  4. package/dist/analysis/accessibility-empathy.js +131 -6
  5. package/dist/analysis/accessibility-empathy.js.map +1 -1
  6. package/dist/analysis/agent-ready-audit.d.ts +44 -1
  7. package/dist/analysis/agent-ready-audit.d.ts.map +1 -1
  8. package/dist/analysis/agent-ready-audit.js +100 -40
  9. package/dist/analysis/agent-ready-audit.js.map +1 -1
  10. package/dist/analysis/bug-hunter.d.ts +11 -1
  11. package/dist/analysis/bug-hunter.d.ts.map +1 -1
  12. package/dist/analysis/bug-hunter.js +44 -6
  13. package/dist/analysis/bug-hunter.js.map +1 -1
  14. package/dist/analysis/competitive-benchmark.d.ts +49 -1
  15. package/dist/analysis/competitive-benchmark.d.ts.map +1 -1
  16. package/dist/analysis/competitive-benchmark.js +138 -14
  17. package/dist/analysis/competitive-benchmark.js.map +1 -1
  18. package/dist/browser.d.ts.map +1 -1
  19. package/dist/browser.js +5 -2
  20. package/dist/browser.js.map +1 -1
  21. package/dist/cli.js +10 -10
  22. package/dist/cli.js.map +1 -1
  23. package/dist/mcp-tools/base/analysis-tools.d.ts.map +1 -1
  24. package/dist/mcp-tools/base/analysis-tools.js +7 -1
  25. package/dist/mcp-tools/base/analysis-tools.js.map +1 -1
  26. package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
  27. package/dist/mcp-tools/base/audit-tools.js +10 -0
  28. package/dist/mcp-tools/base/audit-tools.js.map +1 -1
  29. package/dist/mcp-tools/base/visual-testing-tools.d.ts.map +1 -1
  30. package/dist/mcp-tools/base/visual-testing-tools.js +29 -1
  31. package/dist/mcp-tools/base/visual-testing-tools.js.map +1 -1
  32. package/dist/personas.d.ts.map +1 -1
  33. package/dist/personas.js +25 -2
  34. package/dist/personas.js.map +1 -1
  35. package/dist/remediation/patch-generator.js +2 -2
  36. package/dist/remediation/patch-generator.js.map +1 -1
  37. package/dist/types.d.ts +93 -5
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/types.js.map +1 -1
  40. package/dist/visual/attention-quality.d.ts +32 -0
  41. package/dist/visual/attention-quality.d.ts.map +1 -1
  42. package/dist/visual/attention-quality.js +41 -1
  43. package/dist/visual/attention-quality.js.map +1 -1
  44. package/dist/visual/freeze-animations.d.ts +71 -0
  45. package/dist/visual/freeze-animations.d.ts.map +1 -0
  46. package/dist/visual/freeze-animations.js +111 -0
  47. package/dist/visual/freeze-animations.js.map +1 -0
  48. package/dist/visual/llm-relevance.d.ts +6 -0
  49. package/dist/visual/llm-relevance.d.ts.map +1 -1
  50. package/dist/visual/llm-relevance.js +42 -1
  51. package/dist/visual/llm-relevance.js.map +1 -1
  52. package/dist/visual/persona-context.d.ts +18 -0
  53. package/dist/visual/persona-context.d.ts.map +1 -1
  54. package/dist/visual/persona-context.js +19 -1
  55. package/dist/visual/persona-context.js.map +1 -1
  56. package/package.json +1 -1
package/README.md CHANGED
@@ -128,7 +128,7 @@ Five tools to make your site ready for the AI agent era:
128
128
 
129
129
  | Tool | What It Does |
130
130
  |------|-------------|
131
- | `agent_ready_audit` | Score site on findability, stability, accessibility, semantics (A-F grade) |
131
+ | `agent_ready_audit` | Score site on findability, stability, agent-perceivability, semantics (A-F grade) |
132
132
  | `ai_benchmark` | Compare AI-friendliness across competitor URLs |
133
133
  | `webmcp_ready_audit` | Audit MCP server for WebMCP compatibility |
134
134
  | `remediation_patches` | Generate actionable code fixes for audit findings |
@@ -299,9 +299,36 @@ npx cbrowser competitive-benchmark \
299
299
 
300
300
  ---
301
301
 
302
+ ## Upgrading to 19.0.0
303
+
304
+ One breaking change: the agent-ready **accessibility** axis is now
305
+ **agentPerceivability**, and three deprecated aliases are removed.
306
+
307
+ | removed | use instead |
308
+ |---|---|
309
+ | `score.accessibility` | `score.agentPerceivability` |
310
+ | `summary.dynamicContentCount` | `summary.deferredLoadingPatterns` |
311
+ | `comparison.bestAccessibility` | `comparison.bestAgentPerceivability` |
312
+ | issue `category: "accessibility"` | `category: "agentPerceivability"` |
313
+
314
+ All three replacements have carried identical values since 18.82.0, so if you
315
+ upgraded within that range you can switch keys before upgrading and take this
316
+ release with no further change.
317
+
318
+ The rename is not cosmetic. That axis measures whether a *machine* can perceive
319
+ your elements — ARIA present, elements carry text. It is **not** a WCAG
320
+ conformance score, and it returned 100 on a page where `empathy_audit` found two
321
+ WCAG violations. Both numbers were right; calling one of them "accessibility" was
322
+ not. Run `empathy_audit` for conformance, and expect the two to differ.
323
+
324
+ On input, `category: "accessibility"` is still accepted and scores identically, so
325
+ tools that *send* issues need not change immediately. Tools that *read* scores do.
326
+
327
+ ---
328
+
302
329
  ## Geo Proxy
303
330
 
304
- Test from 12 global regions via residential proxies:
331
+ Test from 10 global regions via residential proxies:
305
332
 
306
333
  ```bash
307
334
  npx cbrowser cognitive-journey \
@@ -311,7 +338,19 @@ npx cbrowser cognitive-journey \
311
338
  --geo-region uk
312
339
  ```
313
340
 
314
- **Regions:** `us-west`, `us-east`, `us-central`, `uk`, `germany`, `france`, `japan`, `australia`, `brazil`, `india`, `canada`, `singapore`
341
+ **Regions:** `us-west`, `us-east`, `us-central`, `uk`, `germany`, `france`, `japan`, `australia`, `canada`, `brazil`
342
+
343
+ Geo routing needs your own residential-proxy credentials — the package ships the
344
+ mechanism and no keys. Set `IPROYAL_USERNAME` / `IPROYAL_PASSWORD`, or place a
345
+ `geo-proxy.json` at `~/.cbrowser/`. Without either, `--geo-region` fails loudly
346
+ rather than silently running from your own IP.
347
+
348
+ A `geo-proxy.json` may define additional regions; they merge over the ten above.
349
+
350
+ > **Corrected 2026-08-11.** This section previously advertised 12 regions
351
+ > including `india` and `singapore`. Those are not in the shipped defaults and
352
+ > resolve to nothing on a clean install — they existed only in a local config
353
+ > file on one machine. Advertising them was a claim the package could not keep.
315
354
 
316
355
  ---
317
356
 
@@ -4,7 +4,75 @@
4
4
  * Learn more at https://cbrowser.ai - MIT License
5
5
  */
6
6
  import type { EmpathyAuditResult, EmpathyAuditOptions } from "../types.js";
7
+ /**
8
+ * Drop barriers outside the viewport when the audit is viewport-scoped.
9
+ *
10
+ * `viewportOnly` existed and had exactly ONE consumer:
11
+ * `detectSmallTouchTargets`. The other ten detectors scanned the whole document
12
+ * regardless of scope, so `scope: "viewport"` filtered touch targets and nothing
13
+ * else.
14
+ *
15
+ * Measured on /docs/home at 1280x800: six `sensory` barriers at y=5638, seven
16
+ * screens below the fold, with x up to 2029 -- 749px past the right edge. All
17
+ * six were counted in `affectedElements`, charged as `missing_alt: -2.2`, and
18
+ * folded into `barrierOnlyScore`. The response even stamped each one
19
+ * `outsideScreenshot: true`: the geometry was computed, published, and not
20
+ * acted on.
21
+ *
22
+ * So every viewport-scoped empathy score published to date may carry demand
23
+ * from content the persona never saw. It is the mirror of the bug
24
+ * `cognitive_effort`'s scopeNote documents -- that one measured too little and
25
+ * said so; this one measured too much and said nothing.
26
+ *
27
+ * Filtered HERE, once, after every detector and before scoring, rather than in
28
+ * each detector. Eleven copies of a rule is how it came to be applied in one
29
+ * place, and adding a twelfth detector must not be able to reintroduce this.
30
+ *
31
+ * A barrier with no rect is KEPT. Page-level findings -- navigation item count,
32
+ * animation presence, reading level -- have no coordinates and are properties of
33
+ * the page rather than of a location on it. Dropping the unlocatable would trade
34
+ * this bug for its opposite. (2026-08-05)
35
+ */
36
+ export declare function filterBarriersToViewport<T extends {
37
+ rect?: {
38
+ x: number;
39
+ y: number;
40
+ width: number;
41
+ height: number;
42
+ };
43
+ }>(barriers: T[], viewport: {
44
+ width: number;
45
+ height: number;
46
+ }): {
47
+ kept: T[];
48
+ dropped: number;
49
+ };
7
50
  export declare function formatEmpathyAuditReport(result: EmpathyAuditResult): string;
8
51
  export declare function generateEmpathyAuditHtmlReport(result: EmpathyAuditResult): string;
52
+ /**
53
+ * The published WCAG list, DERIVED from the barriers rather than accumulated
54
+ * alongside them.
55
+ *
56
+ * It used to be a Set that each detector appended to by hand, in parallel with
57
+ * the barrier it was pushing. Two lists maintained by hand drift, and this pair
58
+ * drifted in the direction that matters: a barrier declared criteria that the
59
+ * adjacent `.add()` line did not repeat, so the audit DETECTED a violation,
60
+ * displayed it on the barrier, and left it out of the conformance list.
61
+ * Measured on one fixture page: 1.3.1, 2.5.5, 2.5.7 and 3.3.4 all shown as
62
+ * barriers, none listed. 1.3.1 there was an unlabelled form input -- Level A,
63
+ * and among the most common real failures there is.
64
+ *
65
+ * Deriving also fixes the reported inverse (a criterion listed with no barrier
66
+ * explaining it), because barriers dropped by the viewport filter now take
67
+ * their criteria with them instead of leaving the string behind.
68
+ *
69
+ * Criteria no barrier ever claimed are still honoured -- see
70
+ * `criteriaEverOnABarrier`. The rule is "mirrors are derived", not "throw away
71
+ * anything the barriers do not know about". (2026-08-11)
72
+ */
73
+ export declare function deriveWcagViolations(barriers: Array<{
74
+ wcagCriteria?: string[];
75
+ wcagAdvisoryCriteria?: string[];
76
+ }>, recordedByDetectors: Set<string>, criteriaEverOnABarrier: Set<string>, wcagLevel: "A" | "AA" | "AAA"): string[];
9
77
  export declare function runEmpathyAudit(url: string, options: EmpathyAuditOptions): Promise<EmpathyAuditResult>;
10
78
  //# sourceMappingURL=accessibility-empathy.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"accessibility-empathy.d.ts","sourceRoot":"","sources":["../../src/analysis/accessibility-empathy.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAmCH,OAAO,KAAK,EACV,kBAAkB,EAClB,mBAAmB,EASpB,MAAM,aAAa,CAAC;AA0mErB,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,CAmF3E;AAED,wBAAgB,8BAA8B,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,CAkTjF;AAqJD,wBAAsB,eAAe,CACnC,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,kBAAkB,CAAC,CA0Z7B"}
1
+ {"version":3,"file":"accessibility-empathy.d.ts","sourceRoot":"","sources":["../../src/analysis/accessibility-empathy.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAmCH,OAAO,KAAK,EACV,kBAAkB,EAClB,mBAAmB,EASpB,MAAM,aAAa,CAAC;AAkKrB;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,wBAAwB,CAAC,CAAC,SAAS;IAAE,IAAI,CAAC,EAAE;QAAE,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,EACnH,QAAQ,EAAE,CAAC,EAAE,EACb,QAAQ,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,CAAC,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAWhC;AAw/DD,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,CAmF3E;AAED,wBAAgB,8BAA8B,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,CAkTjF;AAqJD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,KAAK,CAAC;IAAE,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,EAC7E,mBAAmB,EAAE,GAAG,CAAC,MAAM,CAAC,EAChC,sBAAsB,EAAE,GAAG,CAAC,MAAM,CAAC,EACnC,SAAS,EAAE,GAAG,GAAG,IAAI,GAAG,KAAK,GAC5B,MAAM,EAAE,CAkBV;AAED,wBAAsB,eAAe,CACnC,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,kBAAkB,CAAC,CA+Z7B"}
@@ -28,10 +28,12 @@ const WCAG_CRITERIA = {
28
28
  "1.2.5": { level: "AA", description: "Audio Description (Prerecorded)" },
29
29
  "1.3.1": { level: "A", description: "Info and Relationships" },
30
30
  "1.4.1": { level: "A", description: "Use of Color" },
31
+ "1.4.2": { level: "A", description: "Audio Control" },
31
32
  "1.4.3": { level: "AA", description: "Contrast (Minimum)" },
32
33
  "1.4.4": { level: "AA", description: "Resize Text" },
33
34
  "1.4.6": { level: "AAA", description: "Contrast (Enhanced)" },
34
35
  "1.4.10": { level: "AA", description: "Reflow" },
36
+ "1.4.12": { level: "AA", description: "Text Spacing" },
35
37
  "2.1.1": { level: "A", description: "Keyboard" },
36
38
  "2.1.2": { level: "A", description: "No Keyboard Trap" },
37
39
  "2.2.1": { level: "A", description: "Timing Adjustable" },
@@ -42,13 +44,16 @@ const WCAG_CRITERIA = {
42
44
  "2.4.4": { level: "A", description: "Link Purpose" },
43
45
  "2.4.6": { level: "AA", description: "Headings and Labels" },
44
46
  "2.4.7": { level: "AA", description: "Focus Visible" },
45
- "2.5.5": { level: "AAA", description: "Target Size" },
47
+ "2.5.1": { level: "A", description: "Pointer Gestures" },
48
+ "2.5.5": { level: "AAA", description: "Target Size (Enhanced)" },
49
+ "2.5.7": { level: "AA", description: "Dragging Movements" },
46
50
  "2.5.8": { level: "AA", description: "Target Size (Minimum)" },
47
51
  "3.1.1": { level: "A", description: "Language of Page" },
48
52
  "3.2.1": { level: "A", description: "On Focus" },
49
53
  "3.2.2": { level: "A", description: "On Input" },
50
54
  "3.3.1": { level: "A", description: "Error Identification" },
51
55
  "3.3.2": { level: "A", description: "Labels or Instructions" },
56
+ "3.3.4": { level: "AA", description: "Error Prevention (Legal, Financial, Data)" },
52
57
  "4.1.1": { level: "A", description: "Parsing" },
53
58
  "4.1.2": { level: "A", description: "Name, Role, Value" },
54
59
  };
@@ -106,6 +111,48 @@ function adjustSeverityForLevel(severity, barrierWcagLevel, auditLevel) {
106
111
  }
107
112
  return severity;
108
113
  }
114
+ /**
115
+ * Drop barriers outside the viewport when the audit is viewport-scoped.
116
+ *
117
+ * `viewportOnly` existed and had exactly ONE consumer:
118
+ * `detectSmallTouchTargets`. The other ten detectors scanned the whole document
119
+ * regardless of scope, so `scope: "viewport"` filtered touch targets and nothing
120
+ * else.
121
+ *
122
+ * Measured on /docs/home at 1280x800: six `sensory` barriers at y=5638, seven
123
+ * screens below the fold, with x up to 2029 -- 749px past the right edge. All
124
+ * six were counted in `affectedElements`, charged as `missing_alt: -2.2`, and
125
+ * folded into `barrierOnlyScore`. The response even stamped each one
126
+ * `outsideScreenshot: true`: the geometry was computed, published, and not
127
+ * acted on.
128
+ *
129
+ * So every viewport-scoped empathy score published to date may carry demand
130
+ * from content the persona never saw. It is the mirror of the bug
131
+ * `cognitive_effort`'s scopeNote documents -- that one measured too little and
132
+ * said so; this one measured too much and said nothing.
133
+ *
134
+ * Filtered HERE, once, after every detector and before scoring, rather than in
135
+ * each detector. Eleven copies of a rule is how it came to be applied in one
136
+ * place, and adding a twelfth detector must not be able to reintroduce this.
137
+ *
138
+ * A barrier with no rect is KEPT. Page-level findings -- navigation item count,
139
+ * animation presence, reading level -- have no coordinates and are properties of
140
+ * the page rather than of a location on it. Dropping the unlocatable would trade
141
+ * this bug for its opposite. (2026-08-05)
142
+ */
143
+ export function filterBarriersToViewport(barriers, viewport) {
144
+ const kept = barriers.filter((b) => {
145
+ if (!b.rect)
146
+ return true; // page-level finding, not a located one
147
+ const { x, y, width, height } = b.rect;
148
+ // Document coordinates, and a viewport-scoped audit never scrolls, so the
149
+ // visible band is y 0..height and x 0..width.
150
+ const intersectsY = y < viewport.height && y + height > 0;
151
+ const intersectsX = x < viewport.width && x + width > 0;
152
+ return intersectsY && intersectsX;
153
+ });
154
+ return { kept, dropped: barriers.length - kept.length };
155
+ }
109
156
  /**
110
157
  * Detect small touch targets that are hard for motor-impaired users
111
158
  * v10.10.0: Always detect touch target issues regardless of persona
@@ -435,6 +482,14 @@ async function detectCognitiveLoad(ctx) {
435
482
  description: issue.description + (mapping.criteria.length === 0 ? ' (UX recommendation, not a WCAG violation)' : ''),
436
483
  affectedPersonas: ["cognitive-adhd", "dyslexic-user"],
437
484
  wcagCriteria: mapping.criteria,
485
+ // `violation: null` with a non-empty `criteria` means "relates to, does
486
+ // not violate" -- a text-wall is a readability recommendation under 1.3.1,
487
+ // not a 1.3.1 failure. Recording that on the barrier is what lets the
488
+ // published list be DERIVED from barriers without promoting
489
+ // recommendations into violations. (2026-08-11)
490
+ ...(mapping.violation === null && mapping.criteria.length > 0
491
+ ? { wcagAdvisoryCriteria: mapping.criteria }
492
+ : {}),
438
493
  severity: issue.type === "long-form" ? "major" : "minor",
439
494
  remediation: issue.type === "long-form"
440
495
  ? "Break form into multiple steps or sections"
@@ -1216,6 +1271,7 @@ async function simulateAccessibilityJourney(page, url, goal, persona, scope = "v
1216
1271
  barriers: [],
1217
1272
  frictionPoints: [],
1218
1273
  wcagViolations: new Set(),
1274
+ criteriaEverOnABarrier: new Set(),
1219
1275
  stepCount: 0,
1220
1276
  viewportOnly: scope === "viewport",
1221
1277
  wcagLevel,
@@ -1300,6 +1356,28 @@ async function simulateAccessibilityJourney(page, url, goal, persona, scope = "v
1300
1356
  await detectVisionBarriers(ctx);
1301
1357
  break;
1302
1358
  }
1359
+ // Scope is enforced HERE, once, rather than in each detector -- see
1360
+ // filterBarriersToViewport. Ten of the eleven detectors above never honoured
1361
+ // it, so a viewport audit was scoring content seven screens below the fold.
1362
+ // Snapshotted BEFORE the filter runs, so the derivation below can tell a
1363
+ // criterion that no barrier ever claimed (a genuine page-level finding,
1364
+ // keep it) from one whose barriers were all filtered away (an orphan, drop
1365
+ // it). Without the distinction the two are indistinguishable afterwards.
1366
+ for (const b of ctx.barriers) {
1367
+ for (const c of b.wcagCriteria ?? [])
1368
+ ctx.criteriaEverOnABarrier.add(c);
1369
+ }
1370
+ if (ctx.viewportOnly) {
1371
+ const vp = await page.evaluate(() => ({
1372
+ width: window.innerWidth, height: window.innerHeight,
1373
+ })).catch(() => ({ width: 1280, height: 800 }));
1374
+ const { kept, dropped } = filterBarriersToViewport(ctx.barriers, vp);
1375
+ if (dropped > 0) {
1376
+ ctx.barriers.length = 0;
1377
+ ctx.barriers.push(...kept);
1378
+ ctx.outOfViewportDropped = dropped;
1379
+ }
1380
+ }
1303
1381
  // Use cognitive journey for realistic step tracking if API key available
1304
1382
  // v18.35.0: Add hard timeout to prevent MCP proxy disconnection (~60s limit on claude.ai)
1305
1383
  const journeyTimeoutMs = Math.min(maxTime * 1000, 45000); // Hard cap at 45s to leave margin
@@ -1599,6 +1677,10 @@ async function simulateAccessibilityJourney(page, url, goal, persona, scope = "v
1599
1677
  // Deprecated alias, one release only. See EmpathyPersonaResult.
1600
1678
  goalAchieved: noBlockingBarriers,
1601
1679
  barriers: ctx.barriers,
1680
+ // Surfaced, not kept internal: a filter whose effect is invisible cannot be
1681
+ // told apart from a filter that never ran, and that is exactly the defect
1682
+ // this closes.
1683
+ ...(ctx.outOfViewportDropped ? { outOfViewportBarriersDropped: ctx.outOfViewportDropped } : {}),
1602
1684
  frictionPoints: ctx.frictionPoints,
1603
1685
  // Filtered to the audit's conformance level, exactly as the top-level
1604
1686
  // allWcagViolations is (see the filter near the end of runEmpathyAudit).
@@ -1606,11 +1688,7 @@ async function simulateAccessibilityJourney(page, url, goal, persona, scope = "v
1606
1688
  // had dropped at AA, so wcagViolationCount read 2 beside an
1607
1689
  // allWcagViolations of ["2.2.2"] — the same finding counted under two
1608
1690
  // different rules. (2026-07-29)
1609
- wcagViolations: Array.from(ctx.wcagViolations).filter((v) => {
1610
- const criteria = WCAG_CRITERIA[v];
1611
- const order = { A: 1, AA: 2, AAA: 3 };
1612
- return !criteria || order[criteria.level] <= order[wcagLevel];
1613
- }),
1691
+ wcagViolations: deriveWcagViolations(ctx.barriers, ctx.wcagViolations, ctx.criteriaEverOnABarrier, wcagLevel),
1614
1692
  remediationPriority,
1615
1693
  empathyScore,
1616
1694
  scoreContext, // v18.22.0: Added score breakdown
@@ -2371,6 +2449,48 @@ function inferGoalType(goal) {
2371
2449
  return "navigate_to";
2372
2450
  return "complete_action";
2373
2451
  }
2452
+ /**
2453
+ * The published WCAG list, DERIVED from the barriers rather than accumulated
2454
+ * alongside them.
2455
+ *
2456
+ * It used to be a Set that each detector appended to by hand, in parallel with
2457
+ * the barrier it was pushing. Two lists maintained by hand drift, and this pair
2458
+ * drifted in the direction that matters: a barrier declared criteria that the
2459
+ * adjacent `.add()` line did not repeat, so the audit DETECTED a violation,
2460
+ * displayed it on the barrier, and left it out of the conformance list.
2461
+ * Measured on one fixture page: 1.3.1, 2.5.5, 2.5.7 and 3.3.4 all shown as
2462
+ * barriers, none listed. 1.3.1 there was an unlabelled form input -- Level A,
2463
+ * and among the most common real failures there is.
2464
+ *
2465
+ * Deriving also fixes the reported inverse (a criterion listed with no barrier
2466
+ * explaining it), because barriers dropped by the viewport filter now take
2467
+ * their criteria with them instead of leaving the string behind.
2468
+ *
2469
+ * Criteria no barrier ever claimed are still honoured -- see
2470
+ * `criteriaEverOnABarrier`. The rule is "mirrors are derived", not "throw away
2471
+ * anything the barriers do not know about". (2026-08-11)
2472
+ */
2473
+ export function deriveWcagViolations(barriers, recordedByDetectors, criteriaEverOnABarrier, wcagLevel) {
2474
+ const violated = new Set();
2475
+ for (const b of barriers) {
2476
+ const advisory = new Set(b.wcagAdvisoryCriteria ?? []);
2477
+ for (const c of b.wcagCriteria ?? []) {
2478
+ if (!advisory.has(c))
2479
+ violated.add(c);
2480
+ }
2481
+ }
2482
+ // Page-level findings: recorded by a detector, never attached to any barrier.
2483
+ // Kept. A criterion whose barriers existed and were filtered away is NOT kept.
2484
+ for (const c of recordedByDetectors) {
2485
+ if (!criteriaEverOnABarrier.has(c))
2486
+ violated.add(c);
2487
+ }
2488
+ const order = { A: 1, AA: 2, AAA: 3 };
2489
+ return Array.from(violated).filter((v) => {
2490
+ const criteria = WCAG_CRITERIA[v];
2491
+ return !criteria || order[criteria.level] <= order[wcagLevel];
2492
+ });
2493
+ }
2374
2494
  export async function runEmpathyAudit(url, options) {
2375
2495
  const { goal, disabilities, wcagLevel = "AA", maxSteps = 20, maxTime = 120, headless = true, } = options;
2376
2496
  const startTime = Date.now();
@@ -2737,6 +2857,11 @@ export async function runEmpathyAudit(url, options) {
2737
2857
  topBarriers: deduplicatedBarriers, // v11.11.0: Deduplicated barriers grouped by type
2738
2858
  combinedRemediation,
2739
2859
  overallScore,
2860
+ // Summed from the per-persona audits: a filter whose effect is invisible is
2861
+ // indistinguishable from one that did not run.
2862
+ ...(results.reduce((n, r) => n + (r.outOfViewportBarriersDropped ?? 0), 0) > 0
2863
+ ? { outOfViewportBarriersDropped: results.reduce((n, r) => n + (r.outOfViewportBarriersDropped ?? 0), 0) }
2864
+ : {}),
2740
2865
  duration: Date.now() - startTime,
2741
2866
  };
2742
2867
  }