cbrowser 18.78.0 → 18.80.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.
Files changed (235) hide show
  1. package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
  2. package/dist/analysis/accessibility-empathy.js +395 -66
  3. package/dist/analysis/accessibility-empathy.js.map +1 -1
  4. package/dist/analysis/agent-ready-audit.d.ts.map +1 -1
  5. package/dist/analysis/agent-ready-audit.js +78 -0
  6. package/dist/analysis/agent-ready-audit.js.map +1 -1
  7. package/dist/analysis/page-understanding.d.ts +139 -1
  8. package/dist/analysis/page-understanding.d.ts.map +1 -1
  9. package/dist/analysis/page-understanding.js +179 -12
  10. package/dist/analysis/page-understanding.js.map +1 -1
  11. package/dist/artifact-store.d.ts +19 -5
  12. package/dist/artifact-store.d.ts.map +1 -1
  13. package/dist/artifact-store.js +41 -11
  14. package/dist/artifact-store.js.map +1 -1
  15. package/dist/browser.d.ts +9 -0
  16. package/dist/browser.d.ts.map +1 -1
  17. package/dist/browser.js +81 -5
  18. package/dist/browser.js.map +1 -1
  19. package/dist/cli.js +55 -10
  20. package/dist/cli.js.map +1 -1
  21. package/dist/cognitive/index.d.ts.map +1 -1
  22. package/dist/cognitive/index.js +3 -2
  23. package/dist/cognitive/index.js.map +1 -1
  24. package/dist/config.d.ts +57 -0
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +234 -7
  27. package/dist/config.js.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/mcp-server-remote.d.ts.map +1 -1
  31. package/dist/mcp-server-remote.js +108 -4
  32. package/dist/mcp-server-remote.js.map +1 -1
  33. package/dist/mcp-server.d.ts.map +1 -1
  34. package/dist/mcp-server.js +62 -938
  35. package/dist/mcp-server.js.map +1 -1
  36. package/dist/mcp-tools/account-personas.d.ts +41 -0
  37. package/dist/mcp-tools/account-personas.d.ts.map +1 -0
  38. package/dist/mcp-tools/account-personas.js +98 -0
  39. package/dist/mcp-tools/account-personas.js.map +1 -0
  40. package/dist/mcp-tools/base/analysis-tools.d.ts.map +1 -1
  41. package/dist/mcp-tools/base/analysis-tools.js +12 -2
  42. package/dist/mcp-tools/base/analysis-tools.js.map +1 -1
  43. package/dist/mcp-tools/base/artifact-tools.d.ts +3 -0
  44. package/dist/mcp-tools/base/artifact-tools.d.ts.map +1 -0
  45. package/dist/mcp-tools/base/artifact-tools.js +78 -0
  46. package/dist/mcp-tools/base/artifact-tools.js.map +1 -0
  47. package/dist/mcp-tools/base/audit-tools.d.ts +11 -0
  48. package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
  49. package/dist/mcp-tools/base/audit-tools.js +595 -340
  50. package/dist/mcp-tools/base/audit-tools.js.map +1 -1
  51. package/dist/mcp-tools/base/browser-management-tools.d.ts.map +1 -1
  52. package/dist/mcp-tools/base/browser-management-tools.js +18 -0
  53. package/dist/mcp-tools/base/browser-management-tools.js.map +1 -1
  54. package/dist/mcp-tools/base/bug-analysis-tools.d.ts.map +1 -1
  55. package/dist/mcp-tools/base/bug-analysis-tools.js +59 -3
  56. package/dist/mcp-tools/base/bug-analysis-tools.js.map +1 -1
  57. package/dist/mcp-tools/base/capture-tools.d.ts +37 -0
  58. package/dist/mcp-tools/base/capture-tools.d.ts.map +1 -1
  59. package/dist/mcp-tools/base/capture-tools.js +267 -6
  60. package/dist/mcp-tools/base/capture-tools.js.map +1 -1
  61. package/dist/mcp-tools/base/cognitive-tools.d.ts +12 -0
  62. package/dist/mcp-tools/base/cognitive-tools.d.ts.map +1 -1
  63. package/dist/mcp-tools/base/cognitive-tools.js +63 -15
  64. package/dist/mcp-tools/base/cognitive-tools.js.map +1 -1
  65. package/dist/mcp-tools/base/extraction-tools.d.ts.map +1 -1
  66. package/dist/mcp-tools/base/extraction-tools.js +33 -2
  67. package/dist/mcp-tools/base/extraction-tools.js.map +1 -1
  68. package/dist/mcp-tools/base/index.d.ts +2 -0
  69. package/dist/mcp-tools/base/index.d.ts.map +1 -1
  70. package/dist/mcp-tools/base/index.js +9 -0
  71. package/dist/mcp-tools/base/index.js.map +1 -1
  72. package/dist/mcp-tools/base/marketing-tools.js +3 -3
  73. package/dist/mcp-tools/base/marketing-tools.js.map +1 -1
  74. package/dist/mcp-tools/base/persona-comparison-tools.d.ts.map +1 -1
  75. package/dist/mcp-tools/base/persona-comparison-tools.js +529 -40
  76. package/dist/mcp-tools/base/persona-comparison-tools.js.map +1 -1
  77. package/dist/mcp-tools/base/site-knowledge-tools.d.ts.map +1 -1
  78. package/dist/mcp-tools/base/site-knowledge-tools.js +36 -3
  79. package/dist/mcp-tools/base/site-knowledge-tools.js.map +1 -1
  80. package/dist/mcp-tools/base/values-tools.d.ts +113 -0
  81. package/dist/mcp-tools/base/values-tools.d.ts.map +1 -1
  82. package/dist/mcp-tools/base/values-tools.js +868 -78
  83. package/dist/mcp-tools/base/values-tools.js.map +1 -1
  84. package/dist/mcp-tools/base/visual-testing-tools.d.ts.map +1 -1
  85. package/dist/mcp-tools/base/visual-testing-tools.js +130 -10
  86. package/dist/mcp-tools/base/visual-testing-tools.js.map +1 -1
  87. package/dist/mcp-tools/index.d.ts +3 -1
  88. package/dist/mcp-tools/index.d.ts.map +1 -1
  89. package/dist/mcp-tools/index.js +3 -28
  90. package/dist/mcp-tools/index.js.map +1 -1
  91. package/dist/mcp-tools/persona-creation-tools.d.ts.map +1 -1
  92. package/dist/mcp-tools/persona-creation-tools.js +363 -20
  93. package/dist/mcp-tools/persona-creation-tools.js.map +1 -1
  94. package/dist/mcp-tools/persona-lifecycle-tools.d.ts +3 -0
  95. package/dist/mcp-tools/persona-lifecycle-tools.d.ts.map +1 -0
  96. package/dist/mcp-tools/persona-lifecycle-tools.js +406 -0
  97. package/dist/mcp-tools/persona-lifecycle-tools.js.map +1 -0
  98. package/dist/mcp-tools/screenshot-utils.d.ts.map +1 -1
  99. package/dist/mcp-tools/screenshot-utils.js +56 -10
  100. package/dist/mcp-tools/screenshot-utils.js.map +1 -1
  101. package/dist/mcp-tools/tier-gate.js +2 -2
  102. package/dist/mcp-tools/tier-gate.js.map +1 -1
  103. package/dist/mcp-tools/tool-categories.d.ts.map +1 -1
  104. package/dist/mcp-tools/tool-categories.js +4 -1
  105. package/dist/mcp-tools/tool-categories.js.map +1 -1
  106. package/dist/mcp-tools/ui-resources.d.ts +167 -0
  107. package/dist/mcp-tools/ui-resources.d.ts.map +1 -0
  108. package/dist/mcp-tools/ui-resources.js +707 -0
  109. package/dist/mcp-tools/ui-resources.js.map +1 -0
  110. package/dist/mcp-tools/widget-kit.d.ts +299 -0
  111. package/dist/mcp-tools/widget-kit.d.ts.map +1 -0
  112. package/dist/mcp-tools/widget-kit.js +2034 -0
  113. package/dist/mcp-tools/widget-kit.js.map +1 -0
  114. package/dist/mcp-ui-resources.d.ts.map +1 -1
  115. package/dist/mcp-ui-resources.js +14 -4
  116. package/dist/mcp-ui-resources.js.map +1 -1
  117. package/dist/persona-questionnaire.d.ts +69 -1
  118. package/dist/persona-questionnaire.d.ts.map +1 -1
  119. package/dist/persona-questionnaire.js +177 -76
  120. package/dist/persona-questionnaire.js.map +1 -1
  121. package/dist/persona-scope.d.ts +20 -0
  122. package/dist/persona-scope.d.ts.map +1 -0
  123. package/dist/persona-scope.js +59 -0
  124. package/dist/persona-scope.js.map +1 -0
  125. package/dist/personas.d.ts +51 -17
  126. package/dist/personas.d.ts.map +1 -1
  127. package/dist/personas.js +947 -286
  128. package/dist/personas.js.map +1 -1
  129. package/dist/recording/capture-player.d.ts +68 -0
  130. package/dist/recording/capture-player.d.ts.map +1 -0
  131. package/dist/recording/capture-player.js +298 -0
  132. package/dist/recording/capture-player.js.map +1 -0
  133. package/dist/recording/engine.d.ts +52 -0
  134. package/dist/recording/engine.d.ts.map +1 -1
  135. package/dist/recording/engine.js +241 -3
  136. package/dist/recording/engine.js.map +1 -1
  137. package/dist/recording/inline-player.d.ts +39 -0
  138. package/dist/recording/inline-player.d.ts.map +1 -0
  139. package/dist/recording/inline-player.js +272 -0
  140. package/dist/recording/inline-player.js.map +1 -0
  141. package/dist/recording/saliency-overlay.d.ts +133 -0
  142. package/dist/recording/saliency-overlay.d.ts.map +1 -0
  143. package/dist/recording/saliency-overlay.js +360 -0
  144. package/dist/recording/saliency-overlay.js.map +1 -0
  145. package/dist/recording/types.d.ts +32 -0
  146. package/dist/recording/types.d.ts.map +1 -1
  147. package/dist/recording/types.js +46 -0
  148. package/dist/recording/types.js.map +1 -1
  149. package/dist/site-model/manager.d.ts +20 -0
  150. package/dist/site-model/manager.d.ts.map +1 -1
  151. package/dist/site-model/manager.js +52 -0
  152. package/dist/site-model/manager.js.map +1 -1
  153. package/dist/trait-reference.d.ts +26 -2
  154. package/dist/trait-reference.d.ts.map +1 -1
  155. package/dist/trait-reference.js +129 -19
  156. package/dist/trait-reference.js.map +1 -1
  157. package/dist/types.d.ts +105 -18
  158. package/dist/types.d.ts.map +1 -1
  159. package/dist/types.js.map +1 -1
  160. package/dist/url-resolution.d.ts +58 -0
  161. package/dist/url-resolution.d.ts.map +1 -0
  162. package/dist/url-resolution.js +143 -0
  163. package/dist/url-resolution.js.map +1 -0
  164. package/dist/values/big-five-inference.d.ts +121 -0
  165. package/dist/values/big-five-inference.d.ts.map +1 -0
  166. package/dist/values/big-five-inference.js +241 -0
  167. package/dist/values/big-five-inference.js.map +1 -0
  168. package/dist/values/big-five-values.d.ts +76 -0
  169. package/dist/values/big-five-values.d.ts.map +1 -0
  170. package/dist/values/big-five-values.js +152 -0
  171. package/dist/values/big-five-values.js.map +1 -0
  172. package/dist/values/index.d.ts +1 -1
  173. package/dist/values/index.d.ts.map +1 -1
  174. package/dist/values/index.js +1 -1
  175. package/dist/values/index.js.map +1 -1
  176. package/dist/values/persona-completeness.d.ts +91 -0
  177. package/dist/values/persona-completeness.d.ts.map +1 -0
  178. package/dist/values/persona-completeness.js +114 -0
  179. package/dist/values/persona-completeness.js.map +1 -0
  180. package/dist/values/persona-diff.d.ts +50 -0
  181. package/dist/values/persona-diff.d.ts.map +1 -0
  182. package/dist/values/persona-diff.js +126 -0
  183. package/dist/values/persona-diff.js.map +1 -0
  184. package/dist/values/persona-values.d.ts +16 -0
  185. package/dist/values/persona-values.d.ts.map +1 -1
  186. package/dist/values/persona-values.js +33 -0
  187. package/dist/values/persona-values.js.map +1 -1
  188. package/dist/values/schwartz-values.d.ts +9 -1
  189. package/dist/values/schwartz-values.d.ts.map +1 -1
  190. package/dist/values/schwartz-values.js +10 -2
  191. package/dist/values/schwartz-values.js.map +1 -1
  192. package/dist/values/value-mappings.d.ts +41 -2
  193. package/dist/values/value-mappings.d.ts.map +1 -1
  194. package/dist/values/value-mappings.js +65 -8
  195. package/dist/values/value-mappings.js.map +1 -1
  196. package/dist/visual/attention-quality.d.ts +27 -0
  197. package/dist/visual/attention-quality.d.ts.map +1 -1
  198. package/dist/visual/attention-quality.js +164 -2
  199. package/dist/visual/attention-quality.js.map +1 -1
  200. package/dist/visual/attention-transport.d.ts +41 -2
  201. package/dist/visual/attention-transport.d.ts.map +1 -1
  202. package/dist/visual/attention-transport.js +98 -26
  203. package/dist/visual/attention-transport.js.map +1 -1
  204. package/dist/visual/barrier-overlay-html.d.ts +10 -0
  205. package/dist/visual/barrier-overlay-html.d.ts.map +1 -1
  206. package/dist/visual/barrier-overlay-html.js.map +1 -1
  207. package/dist/visual/cognitive-models.d.ts +109 -13
  208. package/dist/visual/cognitive-models.d.ts.map +1 -1
  209. package/dist/visual/cognitive-models.js +166 -3
  210. package/dist/visual/cognitive-models.js.map +1 -1
  211. package/dist/visual/cognitive-transport-chain.d.ts +149 -5
  212. package/dist/visual/cognitive-transport-chain.d.ts.map +1 -1
  213. package/dist/visual/cognitive-transport-chain.js +558 -30
  214. package/dist/visual/cognitive-transport-chain.js.map +1 -1
  215. package/dist/visual/cognitive-transport.d.ts +45 -3
  216. package/dist/visual/cognitive-transport.d.ts.map +1 -1
  217. package/dist/visual/cognitive-transport.js +126 -12
  218. package/dist/visual/cognitive-transport.js.map +1 -1
  219. package/dist/visual/fixation-corpus.d.ts +52 -0
  220. package/dist/visual/fixation-corpus.d.ts.map +1 -1
  221. package/dist/visual/fixation-corpus.js +95 -0
  222. package/dist/visual/fixation-corpus.js.map +1 -1
  223. package/dist/visual/llm-relevance.d.ts +193 -0
  224. package/dist/visual/llm-relevance.d.ts.map +1 -0
  225. package/dist/visual/llm-relevance.js +480 -0
  226. package/dist/visual/llm-relevance.js.map +1 -0
  227. package/dist/visual/perceptual-transport.d.ts +60 -0
  228. package/dist/visual/perceptual-transport.d.ts.map +1 -1
  229. package/dist/visual/perceptual-transport.js +228 -19
  230. package/dist/visual/perceptual-transport.js.map +1 -1
  231. package/dist/visual/persona-context.d.ts +23 -0
  232. package/dist/visual/persona-context.d.ts.map +1 -0
  233. package/dist/visual/persona-context.js +44 -0
  234. package/dist/visual/persona-context.js.map +1 -0
  235. package/package.json +2 -1
@@ -3,6 +3,7 @@
3
3
  * Copyright 2026 Alexandria Eden alexandria.shai.eden@gmail.com
4
4
  * Learn more at https://cbrowser.ai - MIT License
5
5
  */
6
+ import { weightKeyFor, barrierWeightFor, weightedSeverity } from "../visual/perceptual-transport.js";
6
7
  import { VERSION } from "../version.js";
7
8
  import { CBrowser } from "../browser.js";
8
9
  import { getAccessibilityPersona } from "../personas.js";
@@ -16,6 +17,15 @@ import { getEmotionVisualizationStyles, generateEmotionVisualizationSection, } f
16
17
  // ============================================================================
17
18
  const WCAG_CRITERIA = {
18
19
  "1.1.1": { level: "A", description: "Non-text Content" },
20
+ // The media criteria were absent from this table while 1.2.2 was already
21
+ // being emitted, and violations are filtered by looking their level up here:
22
+ // an unknown code is kept unconditionally. So an audit run at wcagLevel "A"
23
+ // still reported AA media findings, because the filter had no level to test
24
+ // them against. (2026-07-31)
25
+ "1.2.1": { level: "A", description: "Audio-only and Video-only (Prerecorded)" },
26
+ "1.2.2": { level: "A", description: "Captions (Prerecorded)" },
27
+ "1.2.3": { level: "A", description: "Audio Description or Media Alternative (Prerecorded)" },
28
+ "1.2.5": { level: "AA", description: "Audio Description (Prerecorded)" },
19
29
  "1.3.1": { level: "A", description: "Info and Relationships" },
20
30
  "1.4.1": { level: "A", description: "Use of Color" },
21
31
  "1.4.3": { level: "AA", description: "Contrast (Minimum)" },
@@ -528,10 +538,24 @@ async function detectMissingAltText(ctx) {
528
538
  // Check images without alt attribute
529
539
  const imagesWithoutAlt = await page.$$eval('img:not([alt])', (elements) => elements.map(el => {
530
540
  const imgEl = el;
541
+ // Identified by FILENAME, not by the first 50 characters of the URL.
542
+ // Barriers are deduplicated into a Set of these strings, and on a CDN
543
+ // path the first 50 characters are the part every image shares -- four
544
+ // distinct images collapsed to two keys, so the finding said "affects 2
545
+ // elements" beside four drawn rects. The tail is what differs.
546
+ // (2026-07-31)
547
+ //
548
+ // Document coordinates, matching every other rect in this file: the
549
+ // overlay maps them onto a full-page capture.
550
+ const r = imgEl.getBoundingClientRect();
531
551
  return {
532
- selector: `img[src="${imgEl.src.slice(0, 50)}..."]`,
552
+ selector: `img[src$="${(imgEl.currentSrc || imgEl.src).split("?")[0].split("/").pop() || imgEl.src.slice(-40)}"]`,
533
553
  isDecorative: imgEl.width < 20 || imgEl.height < 20,
534
554
  issue: "missing",
555
+ x: Math.round(r.left + window.scrollX),
556
+ y: Math.round(r.top + window.scrollY),
557
+ width: Math.round(r.width),
558
+ height: Math.round(r.height),
535
559
  };
536
560
  }).filter(el => !el.isDecorative).slice(0, 10));
537
561
  // v10.10.0: Also check images with empty alt text that appear non-decorative
@@ -546,9 +570,11 @@ async function detectMissingAltText(ctx) {
546
570
  String(imgEl.className?.baseVal ?? imgEl.className ?? "").includes('separator') ||
547
571
  imgEl.getAttribute('role') === 'presentation';
548
572
  return {
549
- selector: `img[src="${imgEl.src.slice(0, 50)}..."]`,
573
+ selector: `img[src$="${(imgEl.currentSrc || imgEl.src).split("?")[0].split("/").pop() || imgEl.src.slice(-40)}"]`,
550
574
  isDecorative: isLikelyDecorative,
551
575
  issue: "empty",
576
+ x: Math.round(rect.left + window.scrollX),
577
+ y: Math.round(rect.top + window.scrollY),
552
578
  width: rect.width,
553
579
  height: rect.height,
554
580
  };
@@ -558,10 +584,15 @@ async function detectMissingAltText(ctx) {
558
584
  type: "sensory",
559
585
  element: img.selector,
560
586
  description: "Image without alt text - screen reader users cannot understand the content",
561
- affectedPersonas: ["deaf-user", "low-vision-magnified"],
587
+ affectedPersonas: ["screen-reader-user", "low-vision-magnified"],
562
588
  wcagCriteria: ["1.1.1"],
563
589
  severity: "major",
564
590
  remediation: "Add descriptive alt text, or alt=\"\" if image is purely decorative",
591
+ // Without this the overlay drew ten touch targets and none of the
592
+ // findings that actually drove the score: only 2 of 22 barrier sites in
593
+ // this file recorded geometry, so the highest-weighted barriers were
594
+ // structurally undrawable. (2026-07-31)
595
+ rect: { x: img.x, y: img.y, width: img.width, height: img.height },
565
596
  });
566
597
  ctx.wcagViolations.add("1.1.1");
567
598
  }
@@ -571,31 +602,169 @@ async function detectMissingAltText(ctx) {
571
602
  type: "sensory",
572
603
  element: img.selector,
573
604
  description: `Large image (${Math.round(img.width)}x${Math.round(img.height)}px) has empty alt text - may be incorrectly marked as decorative`,
574
- affectedPersonas: ["deaf-user", "low-vision-magnified"],
605
+ affectedPersonas: ["screen-reader-user", "low-vision-magnified"],
575
606
  wcagCriteria: ["1.1.1"],
576
607
  severity: "minor",
577
608
  remediation: "Verify this image is purely decorative. If it conveys meaning, add descriptive alt text",
609
+ rect: { x: img.x, y: img.y, width: Math.round(img.width), height: Math.round(img.height) },
578
610
  });
579
611
  ctx.wcagViolations.add("1.1.1");
580
612
  }
581
- // Check for videos without captions indicator
582
- // v10.10.0: Always detect, not just for deaf-user persona
583
- const videos = await page.$$eval('video, iframe[src*="youtube"], iframe[src*="vimeo"]', (elements) => elements.map(el => ({
584
- selector: el.tagName.toLowerCase(),
585
- hasCaptions: el.querySelector('track[kind="captions"]') !== null,
586
- })).filter(el => !el.hasCaptions));
587
- if (videos.length > 0) {
613
+ // Captions, answerable only where the DOM actually holds the answer.
614
+ //
615
+ // This used to select 'video, iframe[src*="youtube"], iframe[src*="vimeo"]'
616
+ // and ask each for a track[kind="captions"] child. An iframe cannot have one
617
+ // in the host document, so every embed reported missing captions
618
+ // unconditionally -- a verdict the page could not support. It also had no
619
+ // decorative carve-out and emitted ONE barrier for every video at once, so
620
+ // the overlay drew a single box no matter how many videos failed.
621
+ // (2026-07-31)
622
+ const videos = await page.$$eval('video', (elements) => elements.map(el => {
623
+ const v = el;
624
+ const r = v.getBoundingClientRect();
625
+ const decorative = v.getAttribute("aria-hidden") === "true" ||
626
+ v.getAttribute("role") === "presentation" ||
627
+ v.getAttribute("role") === "none";
628
+ // Muted with no controls: the audio channel is not reachable by anyone,
629
+ // so there is no audio experience being withheld. Deliberately narrow --
630
+ // a muted video WITH controls can be unmuted and still needs captions.
631
+ const noAudioReachable = v.hasAttribute("muted") && !v.hasAttribute("controls");
632
+ return {
633
+ selector: v.id ? `video#${v.id}` : "video",
634
+ hasCaptions: v.querySelector('track[kind="captions"]') !== null,
635
+ // Subtitles translate dialogue for people who can hear; captions also
636
+ // carry speaker changes and non-speech audio. 1.2.2 asks for captions,
637
+ // so subtitles-only is a real gap -- just a smaller one than nothing.
638
+ hasSubtitlesOnly: v.querySelector('track[kind="captions"]') === null &&
639
+ v.querySelector('track[kind="subtitles"]') !== null,
640
+ decorative,
641
+ noAudioReachable,
642
+ x: Math.round(r.left + window.scrollX),
643
+ y: Math.round(r.top + window.scrollY),
644
+ width: Math.round(r.width),
645
+ height: Math.round(r.height),
646
+ };
647
+ }).filter(el => !el.hasCaptions && !el.decorative && !el.noAudioReachable).slice(0, 10));
648
+ // One barrier per element, each carrying its own rect, so the overlay can
649
+ // outline every failing video instead of the first one.
650
+ for (const v of videos) {
588
651
  barriers.push({
589
652
  type: "sensory",
590
- element: "video",
591
- description: `${videos.length} video(s) may not have captions - deaf users cannot access audio content`,
653
+ element: v.selector,
654
+ description: v.hasSubtitlesOnly
655
+ ? "Video has subtitles but no captions track - deaf users miss speaker changes and non-speech audio"
656
+ : "Video has no captions - deaf users cannot access its audio content",
592
657
  affectedPersonas: ["deaf-user"],
593
658
  wcagCriteria: ["1.2.2"],
594
- severity: "critical",
595
- remediation: "Add captions to all video content",
659
+ severity: v.hasSubtitlesOnly ? "major" : "critical",
660
+ remediation: v.hasSubtitlesOnly
661
+ ? 'Add a track[kind="captions"] that includes speaker identification and non-speech audio, alongside the existing subtitles'
662
+ : 'Add a captions track (<track kind="captions">) covering dialogue and meaningful non-speech audio',
663
+ rect: { x: v.x, y: v.y, width: v.width, height: v.height },
596
664
  });
597
665
  ctx.wcagViolations.add("1.2.2");
598
666
  }
667
+ // Third-party embeds: recorded, not scored, not claimed as a violation.
668
+ const embeds = await page.$$eval('iframe[src*="youtube"], iframe[src*="vimeo"], iframe[src*="wistia"], iframe[src*="brightcove"]', (elements) => elements.map(el => ({
669
+ src: (el.getAttribute("src") ?? "").slice(0, 80),
670
+ })).slice(0, 10));
671
+ for (const e of embeds) {
672
+ ctx.unverifiableMedia.push({
673
+ element: `iframe[src="${e.src}"]`,
674
+ reason: "Captions and audio description live inside a cross-origin embed, which this page cannot inspect. Absence here is not evidence of absence.",
675
+ checkAt: "The provider's own caption/description settings for this video",
676
+ });
677
+ }
678
+ await detectMissingAudioDescription(ctx);
679
+ }
680
+ /**
681
+ * Detect video that carries visual information with no audio description.
682
+ *
683
+ * The counterpart to the captions check above, and the reason it exists: they
684
+ * are opposite remedies for opposite senses. Captions carry a video's AUDIO to
685
+ * someone who cannot hear it; audio description carries its VISUALS to someone
686
+ * who cannot see it. Only captions were detected, so an audit run as a blind
687
+ * user reported "add captions" as their headline media fix -- a change that
688
+ * does nothing for them -- while the barrier they actually hit, a video whose
689
+ * on-screen content is never spoken, had no criterion in the set at all.
690
+ *
691
+ * Scoped to real <video> elements on purpose. A third-party embed is an
692
+ * <iframe>, and an iframe cannot contain a <track> child in the host document,
693
+ * so asking the host DOM whether a YouTube video has descriptions cannot
694
+ * return anything but "no". That is a guaranteed false positive rather than a
695
+ * measurement, and this detector declines to make it. (2026-07-31)
696
+ */
697
+ async function detectMissingAudioDescription(ctx) {
698
+ const { page, barriers } = ctx;
699
+ const undescribed = await page.$$eval('video', (elements) => elements
700
+ .map((el) => {
701
+ const v = el;
702
+ const rect = v.getBoundingClientRect();
703
+ // A described track satisfies the criterion outright.
704
+ const hasDescriptions = v.querySelector('track[kind="descriptions"]') !== null;
705
+ // Explicitly decorative content is exempt: it is declared to carry no
706
+ // information, so there is nothing to describe. Trusting the author's
707
+ // own declaration beats guessing from size or autoplay flags.
708
+ const decorative = v.getAttribute("aria-hidden") === "true" ||
709
+ v.getAttribute("role") === "presentation" ||
710
+ v.getAttribute("role") === "none";
711
+ // A transcript link near the video can satisfy 1.2.3 (Level A offers
712
+ // "audio description OR media alternative") while leaving 1.2.5 (AA,
713
+ // audio description specifically) unmet. Worth separating: the fix is
714
+ // different and so is the conformance claim.
715
+ const scope = v.closest("figure, section, article, div") ?? v.parentElement;
716
+ const nearbyText = (scope?.textContent ?? "").toLowerCase();
717
+ const hasTranscript = /transcript|described version|audio description/.test(nearbyText) ||
718
+ !!scope?.querySelector('a[href*="transcript"]');
719
+ return {
720
+ selector: v.id ? `video#${v.id}` : (v.getAttribute("src") ? `video[src]` : "video"),
721
+ hasDescriptions,
722
+ decorative,
723
+ hasTranscript,
724
+ visible: rect.width > 0 && rect.height > 0,
725
+ x: Math.round(rect.left + window.scrollX),
726
+ y: Math.round(rect.top + window.scrollY),
727
+ width: Math.round(rect.width),
728
+ height: Math.round(rect.height),
729
+ };
730
+ })
731
+ .filter((v) => !v.hasDescriptions && !v.decorative)
732
+ .slice(0, 10));
733
+ if (undescribed.length === 0)
734
+ return;
735
+ // Split by whether a media alternative appears to be present, because the
736
+ // two carry different criteria and different remediation.
737
+ const withTranscript = undescribed.filter((v) => v.hasTranscript);
738
+ const withNothing = undescribed.filter((v) => !v.hasTranscript);
739
+ if (withNothing.length > 0) {
740
+ barriers.push({
741
+ type: "sensory",
742
+ element: withNothing[0].selector,
743
+ description: `${withNothing.length} video(s) have no audio description and no transcript - blind and low-vision users cannot access what is shown on screen`,
744
+ affectedPersonas: ["screen-reader-user", "low-vision-magnified"],
745
+ wcagCriteria: ["1.2.3", "1.2.5"],
746
+ severity: "critical",
747
+ 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.",
748
+ affectedElementCount: withNothing.length,
749
+ rect: { x: withNothing[0].x, y: withNothing[0].y, width: withNothing[0].width, height: withNothing[0].height },
750
+ });
751
+ ctx.wcagViolations.add("1.2.3");
752
+ ctx.wcagViolations.add("1.2.5");
753
+ }
754
+ if (withTranscript.length > 0) {
755
+ barriers.push({
756
+ type: "sensory",
757
+ element: withTranscript[0].selector,
758
+ description: `${withTranscript.length} video(s) appear to have a transcript but no audio description track - meets WCAG 1.2.3 (A) but not 1.2.5 (AA)`,
759
+ affectedPersonas: ["screen-reader-user", "low-vision-magnified"],
760
+ wcagCriteria: ["1.2.5"],
761
+ severity: "major",
762
+ remediation: "Add an audio description track so on-screen information is available during playback, not only in a separate transcript",
763
+ affectedElementCount: withTranscript.length,
764
+ rect: { x: withTranscript[0].x, y: withTranscript[0].y, width: withTranscript[0].width, height: withTranscript[0].height },
765
+ });
766
+ ctx.wcagViolations.add("1.2.5");
767
+ }
599
768
  }
600
769
  /**
601
770
  * Detect missing form labels
@@ -698,10 +867,15 @@ async function detectMotorBarriers(ctx) {
698
867
  const hasClick = el.hasAttribute('onclick') ||
699
868
  el.querySelector('[onclick]') !== null ||
700
869
  el.querySelector('a, button') !== null;
870
+ const r = el.getBoundingClientRect();
701
871
  results.push({
702
872
  selector: el.tagName.toLowerCase() + (el.className ? `.${String(el.className?.baseVal ?? el.className ?? "").split(' ')[0]}` : ''),
703
873
  hasClickAlternative: hasClick,
704
874
  text: el.textContent?.trim().slice(0, 30) || '',
875
+ x: Math.round(r.left + window.scrollX),
876
+ y: Math.round(r.top + window.scrollY),
877
+ width: Math.round(r.width),
878
+ height: Math.round(r.height),
705
879
  });
706
880
  }
707
881
  return results.filter(r => !r.hasClickAlternative);
@@ -715,6 +889,7 @@ async function detectMotorBarriers(ctx) {
715
889
  wcagCriteria: ["2.1.1", "2.5.1"],
716
890
  severity: "major",
717
891
  remediation: "Add click/tap alternative to hover interactions, or make hover content accessible via keyboard focus",
892
+ rect: { x: el.x, y: el.y, width: el.width, height: el.height },
718
893
  });
719
894
  ctx.wcagViolations.add("2.1.1");
720
895
  }
@@ -1044,8 +1219,11 @@ async function simulateAccessibilityJourney(page, url, goal, persona, scope = "v
1044
1219
  stepCount: 0,
1045
1220
  viewportOnly: scope === "viewport",
1046
1221
  wcagLevel,
1222
+ unverifiableMedia: [],
1047
1223
  };
1048
- let goalAchieved = false;
1224
+ // Named for what it is computed from. See EmpathyPersonaResult in types.ts:
1225
+ // this is `blockingBarriers.length === 0`, not evidence anyone finished a task.
1226
+ let noBlockingBarriers = false;
1049
1227
  /** Set when the navigation simulation threw — see the catch block below. */
1050
1228
  let navigationError;
1051
1229
  let journeyValidation;
@@ -1125,7 +1303,10 @@ async function simulateAccessibilityJourney(page, url, goal, persona, scope = "v
1125
1303
  // Use cognitive journey for realistic step tracking if API key available
1126
1304
  // v18.35.0: Add hard timeout to prevent MCP proxy disconnection (~60s limit on claude.ai)
1127
1305
  const journeyTimeoutMs = Math.min(maxTime * 1000, 45000); // Hard cap at 45s to leave margin
1128
- if (isApiKeyConfigured()) {
1306
+ // No goal, no traversal. A journey needs something to traverse toward, and
1307
+ // inventing one would produce a goal path on the site model that nobody asked
1308
+ // for. Barrier detection above has already run and is unaffected.
1309
+ if (goal && isApiKeyConfigured()) {
1129
1310
  try {
1130
1311
  const journeyPromise = runCognitiveJourney({
1131
1312
  persona: persona.name,
@@ -1158,7 +1339,9 @@ async function simulateAccessibilityJourney(page, url, goal, persona, scope = "v
1158
1339
  accessibilityContext: `Cognitive state: patience ${Math.round(journey.finalState.patienceRemaining * 100)}%`,
1159
1340
  });
1160
1341
  }
1161
- goalAchieved = journey.goalAchieved;
1342
+ // Note this is immediately overwritten below by the barrier check —
1343
+ // the journey's success has never been what makes this field true.
1344
+ noBlockingBarriers = journey.goalAchieved;
1162
1345
  // v18.29.0: Store journey validation data for attachment to result
1163
1346
  journeyValidation = {
1164
1347
  goalAchieved: journey.goalAchieved,
@@ -1278,10 +1461,11 @@ async function simulateAccessibilityJourney(page, url, goal, persona, scope = "v
1278
1461
  }
1279
1462
  // v11.11.0: Goal is achievable unless there are truly blocking barriers
1280
1463
  // Friction (small targets, contrast, cognitive load) reduces score but doesn't block
1281
- goalAchieved = blockingBarriers.length === 0;
1282
- // If journey validation says goal failed, override barrier-based goalAchieved
1464
+ noBlockingBarriers = blockingBarriers.length === 0;
1465
+ // A journey that explicitly failed is evidence of a block, so it can pull
1466
+ // this false — but it can never pull it true.
1283
1467
  if (journeyValidation && journeyValidation.goalAchieved === false) {
1284
- goalAchieved = false;
1468
+ noBlockingBarriers = false;
1285
1469
  }
1286
1470
  }
1287
1471
  catch (e) {
@@ -1311,7 +1495,7 @@ async function simulateAccessibilityJourney(page, url, goal, persona, scope = "v
1311
1495
  // charges the customer for our failure; passing `true` would invent a
1312
1496
  // success. Treat it as not-failed and mark the result degraded instead, so
1313
1497
  // the score reflects only what was actually measured (the barriers).
1314
- navigationError ? true : goalAchieved, persona.name, persona.accessibilityTraits);
1498
+ navigationError ? true : noBlockingBarriers, persona.name, persona.accessibilityTraits);
1315
1499
  const empathyScore = perceptualResult.score;
1316
1500
  const totalDeduction = Object.values(perceptualResult.deductions).reduce((a, b) => a + Math.abs(b), 0);
1317
1501
  const scoreContext = {
@@ -1326,7 +1510,11 @@ async function simulateAccessibilityJourney(page, url, goal, persona, scope = "v
1326
1510
  cognitiveOverloadPenalty: perceptualResult.cognitiveOverloadPenalty,
1327
1511
  // The reading behind the prose above and behind cognitiveOverloadPenalty.
1328
1512
  // Named because two other numbers in this payload are also "cognitive load".
1329
- visualComplexityCognitiveLoad: perceptualResult.cognitiveLoad,
1513
+ // Rounded: 1 - 0.9 is 0.09999999999999998 in binary floating point, and
1514
+ // that reached a report a customer reads.
1515
+ visualComplexityCognitiveLoad: typeof perceptualResult.cognitiveLoad === "number"
1516
+ ? Math.round(perceptualResult.cognitiveLoad * 1000) / 1000
1517
+ : perceptualResult.cognitiveLoad,
1330
1518
  // Susceptibility weight applied per barrier type for THIS persona. Barriers
1331
1519
  // name a couple of exemplar affectedPersonas, which looks contradictory
1332
1520
  // beside a deduction charged to a different persona; the weight is what
@@ -1352,7 +1540,7 @@ async function simulateAccessibilityJourney(page, url, goal, persona, scope = "v
1352
1540
  + (perceptualResult.cognitiveLoad > 0.3 ? ` | Visual-complexity cog load: ${(perceptualResult.cognitiveLoad * 100).toFixed(0)}%` : ''),
1353
1541
  };
1354
1542
  // Generate remediation priorities
1355
- const remediationPriority = generateRemediationPriority(ctx.barriers);
1543
+ const remediationPriority = generateRemediationPriority(ctx.barriers, persona.name);
1356
1544
  // Capture page screenshot for WCAG overlay visualization
1357
1545
  let pageScreenshotBase64;
1358
1546
  let viewportSize;
@@ -1361,20 +1549,55 @@ async function simulateAccessibilityJourney(page, url, goal, persona, scope = "v
1361
1549
  // coordinates against the VIEWPORT height flagged every rect as outside.
1362
1550
  // (2026-07-29)
1363
1551
  let documentHeight;
1552
+ // The real pixel dimensions of the image above, read from the PNG header
1553
+ // rather than inferred. The overlay divides rect coordinates by this to get
1554
+ // percentages, so it has to be the height of the picture that actually
1555
+ // exists -- not a separately-measured document height that the picture may
1556
+ // not cover. (2026-07-31)
1557
+ let screenshotSize;
1558
+ // Scroll offset at the moment of capture. Document-space rects are only
1559
+ // comparable to a viewport image after subtracting this.
1560
+ let captureScroll;
1364
1561
  try {
1365
- const screenshotBuffer = await page.screenshot({ type: 'png', fullPage: false });
1562
+ // Capture the region the coordinates describe. Rects are DOCUMENT-space
1563
+ // (scroll offset added at capture), so a full_page audit needs a full_page
1564
+ // image or the two are in different spaces: measured on ucdenver.edu,
1565
+ // barriers spanned document y 6-4787 of a 5363px page while the image was
1566
+ // the top 800px, and the overlay divided by 5363. Six of ten boxes were
1567
+ // drawn over elements that had nothing to do with the barrier, and the
1568
+ // bounds test compared against documentHeight so none were flagged.
1569
+ // (2026-07-31)
1570
+ const screenshotBuffer = await page.screenshot({
1571
+ type: 'png',
1572
+ fullPage: scope === "full_page",
1573
+ });
1366
1574
  pageScreenshotBase64 = Buffer.from(screenshotBuffer).toString('base64');
1575
+ // PNG IHDR: width at byte 16, height at byte 20, both big-endian uint32.
1576
+ if (screenshotBuffer.length > 24) {
1577
+ screenshotSize = {
1578
+ width: screenshotBuffer.readUInt32BE(16),
1579
+ height: screenshotBuffer.readUInt32BE(20),
1580
+ };
1581
+ }
1367
1582
  const vp = page.viewportSize();
1368
1583
  if (vp)
1369
1584
  viewportSize = { width: vp.width, height: vp.height };
1370
- documentHeight = await page.evaluate(() => Math.max(document.body?.scrollHeight ?? 0, document.documentElement?.scrollHeight ?? 0));
1585
+ const metrics = await page.evaluate(() => ({
1586
+ docHeight: Math.max(document.body?.scrollHeight ?? 0, document.documentElement?.scrollHeight ?? 0),
1587
+ scrollX: window.scrollX,
1588
+ scrollY: window.scrollY,
1589
+ }));
1590
+ documentHeight = metrics.docHeight;
1591
+ captureScroll = { x: metrics.scrollX, y: metrics.scrollY };
1371
1592
  }
1372
1593
  catch { }
1373
1594
  return {
1374
1595
  url,
1375
1596
  persona: persona.name,
1376
1597
  disabilityType: getDisabilityType(persona),
1377
- goalAchieved,
1598
+ noBlockingBarriers,
1599
+ // Deprecated alias, one release only. See EmpathyPersonaResult.
1600
+ goalAchieved: noBlockingBarriers,
1378
1601
  barriers: ctx.barriers,
1379
1602
  frictionPoints: ctx.frictionPoints,
1380
1603
  // Filtered to the audit's conformance level, exactly as the top-level
@@ -1398,9 +1621,32 @@ async function simulateAccessibilityJourney(page, url, goal, persona, scope = "v
1398
1621
  pageScreenshot: pageScreenshotBase64, // v18.60.0: For WCAG overlay visualization
1399
1622
  viewportSize, // v18.60.0
1400
1623
  documentHeight,
1624
+ screenshotSize,
1625
+ captureScroll,
1626
+ // Present only when there is something to say, so an empty list does not
1627
+ // read as a finding.
1628
+ ...(ctx.unverifiableMedia.length > 0
1629
+ ? { unverifiableMedia: ctx.unverifiableMedia }
1630
+ : {}),
1401
1631
  };
1402
1632
  }
1403
1633
  function getDisabilityType(persona) {
1634
+ // Hearing first, and by name as well as by trait.
1635
+ //
1636
+ // Every other branch keys off a visual, motor or cognitive trait, and
1637
+ // deafness is none of those -- so deaf-user fell through to "General
1638
+ // accessibility" while carrying isDisabilityPersona: true, which reads as
1639
+ // the tool not recognising the persona it was asked to audit.
1640
+ const traits = persona.accessibilityTraits;
1641
+ if (traits?.hearingLoss || traits?.deafness || traits?.hearingImpairment) {
1642
+ return "Hearing (deaf/hard of hearing)";
1643
+ }
1644
+ if (/^(deaf|hard-of-hearing)/i.test(String(persona.name ?? ""))) {
1645
+ return "Hearing (deaf/hard of hearing)";
1646
+ }
1647
+ if (/screen-?reader|nvda|jaws|voiceover/i.test(String(persona.name ?? ""))) {
1648
+ return "Blind / screen reader";
1649
+ }
1404
1650
  if (persona.accessibilityTraits.tremor)
1405
1651
  return "Motor impairment (tremor)";
1406
1652
  if (persona.accessibilityTraits.visionLevel && persona.accessibilityTraits.visionLevel < 0.5)
@@ -1427,7 +1673,7 @@ function getDisabilityType(persona) {
1427
1673
  * - Scale by unique issues, not total element count
1428
1674
  * - Base score on accessibility, not just barrier count
1429
1675
  */
1430
- function calculateEmpathyScoreWithContext(barriers, frictionPoints, goalAchieved) {
1676
+ function calculateEmpathyScoreWithContext(barriers, frictionPoints, noBlockingBarriers) {
1431
1677
  const baseScore = 100;
1432
1678
  let score = baseScore;
1433
1679
  const deductionsByType = {};
@@ -1475,8 +1721,8 @@ function calculateEmpathyScoreWithContext(barriers, frictionPoints, goalAchieved
1475
1721
  score -= frictionDeduction;
1476
1722
  // Goal achievement affects score but doesn't zero it
1477
1723
  // v11.10.0: Reduced penalty, page can still be partially accessible
1478
- const goalDeduction = goalAchieved ? 0 : 15;
1479
- if (!goalAchieved)
1724
+ const goalDeduction = noBlockingBarriers ? 0 : 15;
1725
+ if (!noBlockingBarriers)
1480
1726
  score -= 15;
1481
1727
  // Ensure minimum score of 10 if there are any working elements
1482
1728
  // A page with issues is still more accessible than a blank/broken page
@@ -1520,21 +1766,30 @@ function calculateEmpathyScoreWithContext(barriers, frictionPoints, goalAchieved
1520
1766
  /**
1521
1767
  * Legacy wrapper for backward compatibility
1522
1768
  */
1523
- function calculateEmpathyScore(barriers, frictionPoints, goalAchieved) {
1524
- return calculateEmpathyScoreWithContext(barriers, frictionPoints, goalAchieved).score;
1769
+ function calculateEmpathyScore(barriers, frictionPoints, noBlockingBarriers) {
1770
+ return calculateEmpathyScoreWithContext(barriers, frictionPoints, noBlockingBarriers).score;
1525
1771
  }
1526
- function generateRemediationPriority(barriers) {
1772
+ function generateRemediationPriority(barriers, personaName) {
1527
1773
  const items = [];
1528
1774
  let priority = 1;
1529
- // Sort barriers by severity
1530
- const sorted = [...barriers].sort((a, b) => {
1531
- const severityOrder = {
1532
- critical: 0,
1533
- major: 1,
1534
- minor: 2,
1535
- };
1536
- return severityOrder[a.severity] - severityOrder[b.severity];
1537
- });
1775
+ // Sorted by severity FOR THIS PERSONA, not the raw WCAG grade.
1776
+ //
1777
+ // The list is capped at ten and the payload surfaces the top five, so the
1778
+ // sort decides what reaches the action list at all. Sorting by the WCAG
1779
+ // grade dropped the single highest-weighted issue in a screen-reader audit:
1780
+ // empty alt text carries severity "minor" and weight 3.0, so it graded
1781
+ // critical for the persona, sorted below five lighter findings, and fell off
1782
+ // the list -- while the payload's own field notes said severityForPersona
1783
+ // "is what should drive triage order". (2026-07-31)
1784
+ const severityOrder = { critical: 0, major: 1, minor: 2 };
1785
+ const rank = (b) => {
1786
+ if (!personaName)
1787
+ return severityOrder[b.severity] ?? 3;
1788
+ const { weight } = barrierWeightFor(personaName, b.type, b.wcagCriteria);
1789
+ const { severity } = weightedSeverity(b.severity, weight);
1790
+ return severityOrder[String(severity).toLowerCase()] ?? 3;
1791
+ };
1792
+ const sorted = [...barriers].sort((a, b) => rank(a) - rank(b));
1538
1793
  // Group by type to avoid duplicates
1539
1794
  const seen = new Set();
1540
1795
  for (const barrier of sorted) {
@@ -1542,11 +1797,32 @@ function generateRemediationPriority(barriers) {
1542
1797
  if (seen.has(key))
1543
1798
  continue;
1544
1799
  seen.add(key);
1545
- const effort = barrier.type === "contrast" ? "easy" :
1546
- barrier.type === "touch_target" ? "easy" :
1547
- barrier.type === "cognitive_load" ? "medium" :
1548
- barrier.type === "timing" ? "medium" :
1549
- "trivial";
1800
+ // Effort by what the fix actually costs. Four types were named and
1801
+ // everything else fell through to "trivial", which is how captions
1802
+ // (transcription plus a caption file per video) and hover-to-keyboard
1803
+ // alternatives (real JS on a carousel) were both rated trivial. A field
1804
+ // that says "trivial" for almost everything carries no triage signal,
1805
+ // which is the only reason it exists. Keyed on the criterion where the
1806
+ // type is too coarse to separate an alt attribute from a caption track.
1807
+ // (2026-07-31)
1808
+ const criteria = barrier.wcagCriteria ?? [];
1809
+ const has = (c) => criteria.includes(c);
1810
+ const effort =
1811
+ // Media alternatives: authoring work per asset, not a code change.
1812
+ has("1.2.1") || has("1.2.2") || has("1.2.3") || has("1.2.5") ? "hard" :
1813
+ // Replacing a hover-only affordance means new interaction code.
1814
+ barrier.type === "motor_precision" ? "hard" :
1815
+ // Restructuring navigation or reducing page-level load.
1816
+ barrier.type === "cognitive_load" ? "hard" :
1817
+ barrier.type === "timing" || barrier.type === "temporal" ? "medium" :
1818
+ // Adding a non-colour cue touches markup and styling per instance.
1819
+ has("1.4.1") ? "medium" :
1820
+ // Attribute- or token-level edits.
1821
+ has("1.1.1") ? "easy" :
1822
+ barrier.type === "contrast" ? "easy" :
1823
+ barrier.type === "touch_target" ? "easy" :
1824
+ has("3.3.2") || has("1.3.1") ? "easy" :
1825
+ "medium";
1550
1826
  items.push({
1551
1827
  priority: priority++,
1552
1828
  issue: barrier.description,
@@ -1584,8 +1860,7 @@ export function formatEmpathyAuditReport(result) {
1584
1860
  ╚══════════════════════════════════════════════════════════════════════════════╝
1585
1861
 
1586
1862
  URL: ${result.url}
1587
- Goal: "${result.goal}"
1588
- Timestamp: ${result.timestamp}
1863
+ ${result.goal ? `Goal: "${result.goal}"\n` : ""}Timestamp: ${result.timestamp}
1589
1864
  Duration: ${(result.duration / 1000).toFixed(1)}s
1590
1865
 
1591
1866
  ⚠️ METHODOLOGY: Empathy scores are heuristic estimates based on barrier detection.*
@@ -1600,14 +1875,14 @@ Duration: ${(result.duration / 1000).toFixed(1)}s
1600
1875
  `;
1601
1876
  // Results by persona
1602
1877
  for (const pr of result.results) {
1603
- const emoji = pr.goalAchieved ? '✓' : '✗';
1878
+ const emoji = pr.noBlockingBarriers ? '✓' : '✗';
1604
1879
  const scoreColor = pr.empathyScore >= 70 ? '🟢' : pr.empathyScore >= 50 ? '🟠' : '🔴';
1605
1880
  report += `
1606
1881
  ${pr.disabilityType}
1607
1882
  ${'─'.repeat(pr.disabilityType.length)}
1608
1883
  Persona: ${pr.persona}
1609
1884
  Score: ${scoreColor} ${pr.empathyScore}/100
1610
- Goal: ${emoji} ${pr.goalAchieved ? 'Achieved' : 'Not achieved'}
1885
+ Blocking barriers: ${emoji} ${pr.noBlockingBarriers ? 'None found' : 'Found'}
1611
1886
  Barriers: ${pr.barriers.length} (${pr.barriers.filter(b => b.severity === 'critical').length} critical)
1612
1887
  Friction points: ${pr.frictionPoints.length}
1613
1888
  `;
@@ -1661,7 +1936,7 @@ export function generateEmpathyAuditHtmlReport(result) {
1661
1936
  const personaGrade = getEmpathyGrade(pr.empathyScore);
1662
1937
  const pGradeColor = personaGrade === 'A' || personaGrade === 'B' ? '#10b981' : personaGrade === 'C' ? '#f59e0b' : '#ef4444';
1663
1938
  return `
1664
- <div class="persona-card ${pr.goalAchieved ? 'success' : 'failure'}">
1939
+ <div class="persona-card ${pr.noBlockingBarriers ? 'success' : 'failure'}">
1665
1940
  <div class="persona-header">
1666
1941
  <h3>${pr.disabilityType}</h3>
1667
1942
  <span class="score" style="background: ${pGradeColor}33; color: ${pGradeColor}">
@@ -1671,8 +1946,8 @@ export function generateEmpathyAuditHtmlReport(result) {
1671
1946
  <p class="persona-name">${pr.persona}</p>
1672
1947
  <div class="persona-stats">
1673
1948
  <div class="stat">
1674
- <span class="label">Goal</span>
1675
- <span class="value">${pr.goalAchieved ? '✓ Achieved' : '✗ Failed'}</span>
1949
+ <span class="label">Blocking barriers</span>
1950
+ <span class="value">${pr.noBlockingBarriers ? '✓ None found' : '✗ Found'}</span>
1676
1951
  </div>
1677
1952
  <div class="stat">
1678
1953
  <span class="label">Barriers</span>
@@ -1906,7 +2181,7 @@ export function generateEmpathyAuditHtmlReport(result) {
1906
2181
 
1907
2182
  <div class="meta">
1908
2183
  <p><strong>URL:</strong> ${result.url}</p>
1909
- <p><strong>Goal:</strong> "${result.goal}"</p>
2184
+ ${result.goal ? `<p><strong>Goal:</strong> "${result.goal}"</p>` : ""}
1910
2185
  <p><strong>Timestamp:</strong> ${result.timestamp}</p>
1911
2186
  <p><strong>Duration:</strong> ${(result.duration / 1000).toFixed(1)}s</p>
1912
2187
  </div>
@@ -1971,10 +2246,23 @@ export function generateEmpathyAuditHtmlReport(result) {
1971
2246
  */
1972
2247
  function deduplicateBarriers(_allBarriers, results) {
1973
2248
  // Group by barrier TYPE only (not element)
2249
+ // Keyed "type|weightKey" now, not a bare type.
1974
2250
  const barriersByType = new Map();
1975
2251
  for (const result of results) {
1976
2252
  for (const barrier of result.barriers) {
1977
- const existing = barriersByType.get(barrier.type);
2253
+ // Grouped by type AND the susceptibility key its criteria resolve to,
2254
+ // not by type alone.
2255
+ //
2256
+ // "sensory" covers colour-only links (1.4.1), missing alt text (1.1.1)
2257
+ // and missing captions (1.2.2) -- three different problems, three
2258
+ // different fixes, and wildly different susceptibility per persona. Type
2259
+ // alone merged them into one entry carrying one description and one
2260
+ // remediation, so on a screen-reader audit the alt-text criterion that
2261
+ // matters most arrived bundled inside a colour barrier whose fix was
2262
+ // "add patterns alongside colour". Unioning the criteria made them
2263
+ // visible; separating the groups makes them actionable.
2264
+ const groupKey = `${barrier.type}|${weightKeyFor(barrier.type, barrier.wcagCriteria) ?? ""}`;
2265
+ const existing = barriersByType.get(groupKey);
1978
2266
  // Use the barrier's OWN affectedPersonas (set by the detector — accurate)
1979
2267
  // rather than the test persona's name. Previously we overwrote with
1980
2268
  // result.persona, which made every barrier appear to affect only the
@@ -1992,7 +2280,7 @@ function deduplicateBarriers(_allBarriers, results) {
1992
2280
  }
1993
2281
  }
1994
2282
  else {
1995
- barriersByType.set(barrier.type, {
2283
+ barriersByType.set(groupKey, {
1996
2284
  barriers: [barrier],
1997
2285
  personas: new Set(barrierPersonas),
1998
2286
  elements: new Set([barrier.element]),
@@ -2001,9 +2289,12 @@ function deduplicateBarriers(_allBarriers, results) {
2001
2289
  }
2002
2290
  }
2003
2291
  }
2004
- // Create one deduplicated barrier per type
2292
+ // One deduplicated barrier per type-and-criterion group.
2005
2293
  const deduplicated = [];
2006
- for (const [type, data] of barriersByType) {
2294
+ for (const [groupKey, data] of barriersByType) {
2295
+ // The map is keyed "type|weightKey"; the emitted barrier keeps the plain
2296
+ // type, since that is what downstream weighting and styling read.
2297
+ const type = groupKey.split("|")[0];
2007
2298
  const elementCount = data.elements.size;
2008
2299
  const representative = data.barriers[0]; // Use first barrier as template
2009
2300
  // Create aggregated description
@@ -2017,7 +2308,16 @@ function deduplicateBarriers(_allBarriers, results) {
2017
2308
  element: elementCount > 1 ? `${elementCount} elements` : representative.element,
2018
2309
  description: aggregatedDescription,
2019
2310
  affectedPersonas: Array.from(data.personas),
2020
- wcagCriteria: representative.wcagCriteria,
2311
+ // Union across the group, not the representative's alone.
2312
+ //
2313
+ // Barriers are deduplicated by TYPE, and "sensory" covers colour-only
2314
+ // links, missing alt text and missing captions alike -- three different
2315
+ // criteria collapsed into one entry that kept only the first barrier's.
2316
+ // That is how 1.1.1 and 1.2.2 came to be reported in allWcagViolations
2317
+ // with no barrier explaining them: the criteria were not missing, they
2318
+ // were discarded here. On a deaf-user audit the dropped one was 1.2.2,
2319
+ // captions -- the single criterion most relevant to that persona.
2320
+ wcagCriteria: Array.from(new Set(data.barriers.flatMap((b) => b.wcagCriteria ?? []))),
2021
2321
  // This is the WORST severity across every element grouped under this
2022
2322
  // barrier type, not one element's severity — but it shipped under the same
2023
2323
  // field name `severity` that individual rects use, so the same 152x20
@@ -2168,6 +2468,16 @@ export async function runEmpathyAudit(url, options) {
2168
2468
  const hasVisionHint = name.includes("vision") || name.includes("blind") || name.includes("elderly") || name.includes("magnif");
2169
2469
  const hasMotorHint = name.includes("motor") || name.includes("tremor") || name.includes("parkinsons");
2170
2470
  const hasCognitiveHint = name.includes("adhd") || name.includes("dyslexic") || name.includes("cognitive") || name.includes("memory");
2471
+ // Assistive-technology and hearing personas were in neither bucket, so
2472
+ // screen-reader-user came back isDisabilityPersona: false -- while the
2473
+ // same payload listed it as an exemplar on a sensory barrier and
2474
+ // list_cognitive_personas filed it under accessibility. Three parts of
2475
+ // the system disagreeing about whether a screen reader user has a
2476
+ // disability, and the "no" was the one that drove the score.
2477
+ const hasScreenReaderHint = name.includes("screen-reader") || name.includes("screenreader")
2478
+ || name.includes("nvda") || name.includes("jaws") || name.includes("voiceover");
2479
+ const hasHearingHint = name.includes("deaf") || name.includes("hearing")
2480
+ || name.includes("hard-of-hearing");
2171
2481
  persona = {
2172
2482
  ...cognitivePersona,
2173
2483
  accessibilityTraits: {
@@ -2182,8 +2492,9 @@ export async function runEmpathyAudit(url, options) {
2182
2492
  },
2183
2493
  };
2184
2494
  // If the name hints at a disability, treat it as a disability persona for routing
2185
- isDisabilityPersona = hasVisionHint || hasMotorHint || hasCognitiveHint;
2186
- console.log(`[empathy_audit] "${disability}" wrapped as ${isDisabilityPersona ? "disability" : "general"} persona (vision=${hasVisionHint}, motor=${hasMotorHint}, cognitive=${hasCognitiveHint})`);
2495
+ isDisabilityPersona = hasVisionHint || hasMotorHint || hasCognitiveHint
2496
+ || hasScreenReaderHint || hasHearingHint;
2497
+ console.log(`[empathy_audit] "${disability}" wrapped as ${isDisabilityPersona ? "disability" : "general"} persona (vision=${hasVisionHint}, motor=${hasMotorHint}, cognitive=${hasCognitiveHint}, screenReader=${hasScreenReaderHint}, hearing=${hasHearingHint})`);
2187
2498
  }
2188
2499
  else {
2189
2500
  // Return an explicit error instead of silently skipping
@@ -2237,13 +2548,24 @@ export async function runEmpathyAudit(url, options) {
2237
2548
  concentration: attnAnalysis.concentration,
2238
2549
  transportCost: attnAnalysis.transportCost,
2239
2550
  };
2551
+ // Only the three values that feed the perceptual score are published.
2552
+ //
2553
+ // alignmentScore and topAttentionAreas fed nothing -- analyzePerceptual
2554
+ // Transport reads entropy, concentration and transportCost and ignores
2555
+ // the rest -- while being the parts that looked wrong: alignment 0.999
2556
+ // for one persona, and four of five "top areas" pinned to page corners
2557
+ // at saliency ~1.0, which is an edge artifact of the coarse grid rather
2558
+ // than a finding about attention. Publishing a number that drives
2559
+ // nothing and cannot be trusted is worse than not publishing it.
2560
+ //
2561
+ // Anyone who wants attention metrics should call attention_analysis,
2562
+ // which runs the same function at the finer default grid.
2240
2563
  result.attentionAnalysis = {
2241
- alignmentScore: attnAnalysis.alignmentScore,
2242
2564
  entropy: attnAnalysis.entropy,
2243
2565
  concentration: attnAnalysis.concentration,
2244
2566
  transportCost: attnAnalysis.transportCost,
2245
- topAttentionAreas: attnAnalysis.attentionCompetitors,
2246
2567
  computeTimeMs: Math.round(attnAnalysis.computeTimeMs),
2568
+ gridCellPx: 16,
2247
2569
  };
2248
2570
  try {
2249
2571
  ulAttn(attnScreenshot);
@@ -2392,8 +2714,15 @@ export async function runEmpathyAudit(url, options) {
2392
2714
  });
2393
2715
  // v11.10.0: Deduplicate barriers by element+type, list affected personas (issue #86)
2394
2716
  const deduplicatedBarriers = deduplicateBarriers(allBarriers, results);
2395
- // Generate combined remediation from deduplicated barriers
2396
- const combinedRemediation = generateRemediationPriority(deduplicatedBarriers);
2717
+ // Generate combined remediation from deduplicated barriers.
2718
+ //
2719
+ // Weighted when exactly one persona was audited -- which is what the MCP
2720
+ // tool does, and the path whose topRemediation dropped a critical-for-blind
2721
+ // alt-text finding below five lighter ones. Across several personas there is
2722
+ // no single susceptibility to sort by, so the raw WCAG grade is the honest
2723
+ // ordering and the weighting is left off rather than silently picking one.
2724
+ const soloPersona = results.length === 1 ? results[0].persona : undefined;
2725
+ const combinedRemediation = generateRemediationPriority(deduplicatedBarriers, soloPersona);
2397
2726
  // Calculate overall score
2398
2727
  const overallScore = results.length > 0
2399
2728
  ? Math.round(results.reduce((sum, r) => sum + r.empathyScore, 0) / results.length)