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.
- package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
- package/dist/analysis/accessibility-empathy.js +395 -66
- package/dist/analysis/accessibility-empathy.js.map +1 -1
- package/dist/analysis/agent-ready-audit.d.ts.map +1 -1
- package/dist/analysis/agent-ready-audit.js +78 -0
- package/dist/analysis/agent-ready-audit.js.map +1 -1
- package/dist/analysis/page-understanding.d.ts +139 -1
- package/dist/analysis/page-understanding.d.ts.map +1 -1
- package/dist/analysis/page-understanding.js +179 -12
- package/dist/analysis/page-understanding.js.map +1 -1
- package/dist/artifact-store.d.ts +19 -5
- package/dist/artifact-store.d.ts.map +1 -1
- package/dist/artifact-store.js +41 -11
- package/dist/artifact-store.js.map +1 -1
- package/dist/browser.d.ts +9 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +81 -5
- package/dist/browser.js.map +1 -1
- package/dist/cli.js +55 -10
- package/dist/cli.js.map +1 -1
- package/dist/cognitive/index.d.ts.map +1 -1
- package/dist/cognitive/index.js +3 -2
- package/dist/cognitive/index.js.map +1 -1
- package/dist/config.d.ts +57 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +234 -7
- package/dist/config.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server-remote.d.ts.map +1 -1
- package/dist/mcp-server-remote.js +108 -4
- package/dist/mcp-server-remote.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +62 -938
- package/dist/mcp-server.js.map +1 -1
- package/dist/mcp-tools/account-personas.d.ts +41 -0
- package/dist/mcp-tools/account-personas.d.ts.map +1 -0
- package/dist/mcp-tools/account-personas.js +98 -0
- package/dist/mcp-tools/account-personas.js.map +1 -0
- package/dist/mcp-tools/base/analysis-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/analysis-tools.js +12 -2
- package/dist/mcp-tools/base/analysis-tools.js.map +1 -1
- package/dist/mcp-tools/base/artifact-tools.d.ts +3 -0
- package/dist/mcp-tools/base/artifact-tools.d.ts.map +1 -0
- package/dist/mcp-tools/base/artifact-tools.js +78 -0
- package/dist/mcp-tools/base/artifact-tools.js.map +1 -0
- package/dist/mcp-tools/base/audit-tools.d.ts +11 -0
- package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/audit-tools.js +595 -340
- package/dist/mcp-tools/base/audit-tools.js.map +1 -1
- package/dist/mcp-tools/base/browser-management-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/browser-management-tools.js +18 -0
- package/dist/mcp-tools/base/browser-management-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 +59 -3
- package/dist/mcp-tools/base/bug-analysis-tools.js.map +1 -1
- package/dist/mcp-tools/base/capture-tools.d.ts +37 -0
- package/dist/mcp-tools/base/capture-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/capture-tools.js +267 -6
- package/dist/mcp-tools/base/capture-tools.js.map +1 -1
- package/dist/mcp-tools/base/cognitive-tools.d.ts +12 -0
- package/dist/mcp-tools/base/cognitive-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/cognitive-tools.js +63 -15
- package/dist/mcp-tools/base/cognitive-tools.js.map +1 -1
- package/dist/mcp-tools/base/extraction-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/extraction-tools.js +33 -2
- package/dist/mcp-tools/base/extraction-tools.js.map +1 -1
- package/dist/mcp-tools/base/index.d.ts +2 -0
- package/dist/mcp-tools/base/index.d.ts.map +1 -1
- package/dist/mcp-tools/base/index.js +9 -0
- package/dist/mcp-tools/base/index.js.map +1 -1
- package/dist/mcp-tools/base/marketing-tools.js +3 -3
- package/dist/mcp-tools/base/marketing-tools.js.map +1 -1
- package/dist/mcp-tools/base/persona-comparison-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/persona-comparison-tools.js +529 -40
- package/dist/mcp-tools/base/persona-comparison-tools.js.map +1 -1
- package/dist/mcp-tools/base/site-knowledge-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/site-knowledge-tools.js +36 -3
- package/dist/mcp-tools/base/site-knowledge-tools.js.map +1 -1
- package/dist/mcp-tools/base/values-tools.d.ts +113 -0
- package/dist/mcp-tools/base/values-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/values-tools.js +868 -78
- package/dist/mcp-tools/base/values-tools.js.map +1 -1
- package/dist/mcp-tools/base/visual-testing-tools.d.ts.map +1 -1
- package/dist/mcp-tools/base/visual-testing-tools.js +130 -10
- package/dist/mcp-tools/base/visual-testing-tools.js.map +1 -1
- package/dist/mcp-tools/index.d.ts +3 -1
- package/dist/mcp-tools/index.d.ts.map +1 -1
- package/dist/mcp-tools/index.js +3 -28
- package/dist/mcp-tools/index.js.map +1 -1
- package/dist/mcp-tools/persona-creation-tools.d.ts.map +1 -1
- package/dist/mcp-tools/persona-creation-tools.js +363 -20
- package/dist/mcp-tools/persona-creation-tools.js.map +1 -1
- package/dist/mcp-tools/persona-lifecycle-tools.d.ts +3 -0
- package/dist/mcp-tools/persona-lifecycle-tools.d.ts.map +1 -0
- package/dist/mcp-tools/persona-lifecycle-tools.js +406 -0
- package/dist/mcp-tools/persona-lifecycle-tools.js.map +1 -0
- package/dist/mcp-tools/screenshot-utils.d.ts.map +1 -1
- package/dist/mcp-tools/screenshot-utils.js +56 -10
- package/dist/mcp-tools/screenshot-utils.js.map +1 -1
- package/dist/mcp-tools/tier-gate.js +2 -2
- package/dist/mcp-tools/tier-gate.js.map +1 -1
- package/dist/mcp-tools/tool-categories.d.ts.map +1 -1
- package/dist/mcp-tools/tool-categories.js +4 -1
- package/dist/mcp-tools/tool-categories.js.map +1 -1
- package/dist/mcp-tools/ui-resources.d.ts +167 -0
- package/dist/mcp-tools/ui-resources.d.ts.map +1 -0
- package/dist/mcp-tools/ui-resources.js +707 -0
- package/dist/mcp-tools/ui-resources.js.map +1 -0
- package/dist/mcp-tools/widget-kit.d.ts +299 -0
- package/dist/mcp-tools/widget-kit.d.ts.map +1 -0
- package/dist/mcp-tools/widget-kit.js +2034 -0
- package/dist/mcp-tools/widget-kit.js.map +1 -0
- package/dist/mcp-ui-resources.d.ts.map +1 -1
- package/dist/mcp-ui-resources.js +14 -4
- package/dist/mcp-ui-resources.js.map +1 -1
- package/dist/persona-questionnaire.d.ts +69 -1
- package/dist/persona-questionnaire.d.ts.map +1 -1
- package/dist/persona-questionnaire.js +177 -76
- package/dist/persona-questionnaire.js.map +1 -1
- package/dist/persona-scope.d.ts +20 -0
- package/dist/persona-scope.d.ts.map +1 -0
- package/dist/persona-scope.js +59 -0
- package/dist/persona-scope.js.map +1 -0
- package/dist/personas.d.ts +51 -17
- package/dist/personas.d.ts.map +1 -1
- package/dist/personas.js +947 -286
- package/dist/personas.js.map +1 -1
- package/dist/recording/capture-player.d.ts +68 -0
- package/dist/recording/capture-player.d.ts.map +1 -0
- package/dist/recording/capture-player.js +298 -0
- package/dist/recording/capture-player.js.map +1 -0
- package/dist/recording/engine.d.ts +52 -0
- package/dist/recording/engine.d.ts.map +1 -1
- package/dist/recording/engine.js +241 -3
- package/dist/recording/engine.js.map +1 -1
- package/dist/recording/inline-player.d.ts +39 -0
- package/dist/recording/inline-player.d.ts.map +1 -0
- package/dist/recording/inline-player.js +272 -0
- package/dist/recording/inline-player.js.map +1 -0
- package/dist/recording/saliency-overlay.d.ts +133 -0
- package/dist/recording/saliency-overlay.d.ts.map +1 -0
- package/dist/recording/saliency-overlay.js +360 -0
- package/dist/recording/saliency-overlay.js.map +1 -0
- package/dist/recording/types.d.ts +32 -0
- package/dist/recording/types.d.ts.map +1 -1
- package/dist/recording/types.js +46 -0
- package/dist/recording/types.js.map +1 -1
- package/dist/site-model/manager.d.ts +20 -0
- package/dist/site-model/manager.d.ts.map +1 -1
- package/dist/site-model/manager.js +52 -0
- package/dist/site-model/manager.js.map +1 -1
- package/dist/trait-reference.d.ts +26 -2
- package/dist/trait-reference.d.ts.map +1 -1
- package/dist/trait-reference.js +129 -19
- package/dist/trait-reference.js.map +1 -1
- package/dist/types.d.ts +105 -18
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/url-resolution.d.ts +58 -0
- package/dist/url-resolution.d.ts.map +1 -0
- package/dist/url-resolution.js +143 -0
- package/dist/url-resolution.js.map +1 -0
- package/dist/values/big-five-inference.d.ts +121 -0
- package/dist/values/big-five-inference.d.ts.map +1 -0
- package/dist/values/big-five-inference.js +241 -0
- package/dist/values/big-five-inference.js.map +1 -0
- package/dist/values/big-five-values.d.ts +76 -0
- package/dist/values/big-five-values.d.ts.map +1 -0
- package/dist/values/big-five-values.js +152 -0
- package/dist/values/big-five-values.js.map +1 -0
- package/dist/values/index.d.ts +1 -1
- package/dist/values/index.d.ts.map +1 -1
- package/dist/values/index.js +1 -1
- package/dist/values/index.js.map +1 -1
- package/dist/values/persona-completeness.d.ts +91 -0
- package/dist/values/persona-completeness.d.ts.map +1 -0
- package/dist/values/persona-completeness.js +114 -0
- package/dist/values/persona-completeness.js.map +1 -0
- package/dist/values/persona-diff.d.ts +50 -0
- package/dist/values/persona-diff.d.ts.map +1 -0
- package/dist/values/persona-diff.js +126 -0
- package/dist/values/persona-diff.js.map +1 -0
- package/dist/values/persona-values.d.ts +16 -0
- package/dist/values/persona-values.d.ts.map +1 -1
- package/dist/values/persona-values.js +33 -0
- package/dist/values/persona-values.js.map +1 -1
- package/dist/values/schwartz-values.d.ts +9 -1
- package/dist/values/schwartz-values.d.ts.map +1 -1
- package/dist/values/schwartz-values.js +10 -2
- package/dist/values/schwartz-values.js.map +1 -1
- package/dist/values/value-mappings.d.ts +41 -2
- package/dist/values/value-mappings.d.ts.map +1 -1
- package/dist/values/value-mappings.js +65 -8
- package/dist/values/value-mappings.js.map +1 -1
- package/dist/visual/attention-quality.d.ts +27 -0
- package/dist/visual/attention-quality.d.ts.map +1 -1
- package/dist/visual/attention-quality.js +164 -2
- package/dist/visual/attention-quality.js.map +1 -1
- package/dist/visual/attention-transport.d.ts +41 -2
- package/dist/visual/attention-transport.d.ts.map +1 -1
- package/dist/visual/attention-transport.js +98 -26
- package/dist/visual/attention-transport.js.map +1 -1
- package/dist/visual/barrier-overlay-html.d.ts +10 -0
- package/dist/visual/barrier-overlay-html.d.ts.map +1 -1
- package/dist/visual/barrier-overlay-html.js.map +1 -1
- package/dist/visual/cognitive-models.d.ts +109 -13
- package/dist/visual/cognitive-models.d.ts.map +1 -1
- package/dist/visual/cognitive-models.js +166 -3
- package/dist/visual/cognitive-models.js.map +1 -1
- package/dist/visual/cognitive-transport-chain.d.ts +149 -5
- package/dist/visual/cognitive-transport-chain.d.ts.map +1 -1
- package/dist/visual/cognitive-transport-chain.js +558 -30
- package/dist/visual/cognitive-transport-chain.js.map +1 -1
- package/dist/visual/cognitive-transport.d.ts +45 -3
- package/dist/visual/cognitive-transport.d.ts.map +1 -1
- package/dist/visual/cognitive-transport.js +126 -12
- package/dist/visual/cognitive-transport.js.map +1 -1
- package/dist/visual/fixation-corpus.d.ts +52 -0
- package/dist/visual/fixation-corpus.d.ts.map +1 -1
- package/dist/visual/fixation-corpus.js +95 -0
- package/dist/visual/fixation-corpus.js.map +1 -1
- package/dist/visual/llm-relevance.d.ts +193 -0
- package/dist/visual/llm-relevance.d.ts.map +1 -0
- package/dist/visual/llm-relevance.js +480 -0
- package/dist/visual/llm-relevance.js.map +1 -0
- package/dist/visual/perceptual-transport.d.ts +60 -0
- package/dist/visual/perceptual-transport.d.ts.map +1 -1
- package/dist/visual/perceptual-transport.js +228 -19
- package/dist/visual/perceptual-transport.js.map +1 -1
- package/dist/visual/persona-context.d.ts +23 -0
- package/dist/visual/persona-context.d.ts.map +1 -0
- package/dist/visual/persona-context.js +44 -0
- package/dist/visual/persona-context.js.map +1 -0
- 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
|
|
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
|
|
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: ["
|
|
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: ["
|
|
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
|
-
//
|
|
582
|
-
//
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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:
|
|
591
|
-
description:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1282
|
-
//
|
|
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
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
1479
|
-
if (!
|
|
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,
|
|
1524
|
-
return calculateEmpathyScoreWithContext(barriers, frictionPoints,
|
|
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
|
-
//
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
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
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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">
|
|
1675
|
-
<span class="value">${pr.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
2292
|
+
// One deduplicated barrier per type-and-criterion group.
|
|
2005
2293
|
const deduplicated = [];
|
|
2006
|
-
for (const [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|