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
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* @license MIT
|
|
6
6
|
*/
|
|
7
7
|
import { z } from "zod";
|
|
8
|
+
import { barrierWeightFor, weightedSeverity, weightKeyFor } from "../../visual/perceptual-transport.js";
|
|
8
9
|
import { randomBytes } from "crypto";
|
|
9
|
-
import { htmlUiResource, attachUiResource, uiResourcesEnabled } from "../../mcp-ui-resources.js";
|
|
10
10
|
import { writeArtifact } from "../../artifact-store.js";
|
|
11
11
|
import { runAgentReadyAudit, runCompetitiveBenchmark, runEmpathyAudit, runWebMCPReadyAudit, } from "../../analysis/index.js";
|
|
12
12
|
import { listAccessibilityPersonas } from "../../personas.js";
|
|
13
|
+
import { getDefaultConfig } from "../../config.js";
|
|
13
14
|
import { writeFileSync, mkdirSync, existsSync } from "fs";
|
|
14
15
|
import { join } from "path";
|
|
15
16
|
import { homedir } from "os";
|
|
@@ -45,8 +46,12 @@ function writeAuditScreenshot(base64, persona) {
|
|
|
45
46
|
try {
|
|
46
47
|
const nonce = randomBytes(6).toString("hex");
|
|
47
48
|
const written = writeArtifact(Buffer.from(base64, "base64"), `empathy-${safe}-${nonce}.png`);
|
|
48
|
-
|
|
49
|
+
// The filename too, not just the URL: the widget sandbox cannot fetch that
|
|
50
|
+
// URL and asks artifact_fetch for the bytes by name instead.
|
|
51
|
+
if (written) {
|
|
49
52
|
out.url = written.url;
|
|
53
|
+
out.file = written.filename;
|
|
54
|
+
}
|
|
50
55
|
}
|
|
51
56
|
catch { /* overlay is optional; the audit is not */ }
|
|
52
57
|
return out;
|
|
@@ -77,7 +82,35 @@ export function registerAuditTools(server, context) {
|
|
|
77
82
|
score: result.score,
|
|
78
83
|
grade: result.grade,
|
|
79
84
|
summary: result.summary,
|
|
80
|
-
|
|
85
|
+
// Worst-first, and the cut is reported.
|
|
86
|
+
//
|
|
87
|
+
// This was a plain slice(0,5) over the issues in detection order, so
|
|
88
|
+
// severity had no bearing on what survived: a critical finding placed
|
|
89
|
+
// sixth by the order detectors happen to run was dropped in favour of
|
|
90
|
+
// five low ones, with nothing saying anything had been dropped. Found
|
|
91
|
+
// when a new critical detector fired in the audit and was invisible in
|
|
92
|
+
// the tool that wraps it. (2026-08-01)
|
|
93
|
+
...(() => {
|
|
94
|
+
const rank = (sev) => ({ critical: 0, high: 1, medium: 2, low: 3, info: 4 }[String(sev).toLowerCase()] ?? 5);
|
|
95
|
+
const sorted = [...result.issues].sort((a, b) => rank(a.severity) - rank(b.severity));
|
|
96
|
+
const shown = sorted.slice(0, 5);
|
|
97
|
+
const omitted = sorted.length - shown.length;
|
|
98
|
+
return {
|
|
99
|
+
topIssues: shown,
|
|
100
|
+
issuesFound: result.issues.length,
|
|
101
|
+
...(omitted > 0
|
|
102
|
+
? {
|
|
103
|
+
issuesOmitted: omitted,
|
|
104
|
+
issuesNote: `Showing the 5 most severe of ${result.issues.length}. Severity counts across ALL findings: ` +
|
|
105
|
+
Object.entries(result.issues.reduce((m, i) => {
|
|
106
|
+
const k = String(i.severity);
|
|
107
|
+
m[k] = (m[k] ?? 0) + 1;
|
|
108
|
+
return m;
|
|
109
|
+
}, {})).map(([k, v]) => `${k} ${v}`).join(", ") + ".",
|
|
110
|
+
}
|
|
111
|
+
: {}),
|
|
112
|
+
};
|
|
113
|
+
})(),
|
|
81
114
|
topRecommendations: result.recommendations.slice(0, 5),
|
|
82
115
|
duration: result.duration,
|
|
83
116
|
};
|
|
@@ -126,333 +159,48 @@ export function registerAuditTools(server, context) {
|
|
|
126
159
|
],
|
|
127
160
|
};
|
|
128
161
|
});
|
|
129
|
-
server
|
|
130
|
-
|
|
131
|
-
|
|
162
|
+
registerEmpathyAuditTool(server);
|
|
163
|
+
server.registerTool("webmcp_ready_audit", {
|
|
164
|
+
title: "WebMCP Readiness Audit",
|
|
165
|
+
description: "Audit an MCP server for Claude in Chrome / WebMCP compatibility. Uses 6-tier evaluation: Server Implementation (25%), Tool Discoverability (20%), Instrumentation (15%), Consistency (15%), Agent Optimizations (15%), Documentation (10%). Returns score (0-100), grade (A-F), tier breakdown, issues, and recommendations.",
|
|
132
166
|
inputSchema: {
|
|
133
|
-
url: z.string().url().describe("URL to audit"),
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
maxSteps: z.number().optional().default(5).describe("Max cognitive journey steps (keep low for MCP)"),
|
|
138
|
-
maxTime: z.number().optional().default(20).describe("Max time per persona in seconds"),
|
|
139
|
-
// Base64 page screenshots are large enough to blow an MCP client's context
|
|
140
|
-
// on a single call — every empathy_audit in the 18.73.3 test pass had to be
|
|
141
|
-
// read off disk instead. Off by default; images go to disk and their paths
|
|
142
|
-
// are returned. (2026-07-29)
|
|
143
|
-
includeScreenshots: z.boolean().optional().default(false).describe("Inline base64 page screenshots in the response. Off by default because they are large enough to overflow an MCP client's context; when off, screenshots are written to disk and screenshotPath is returned instead."),
|
|
144
|
-
scope: z.enum(["viewport", "full_page"]).optional().default("viewport").describe("What to score: 'viewport' (first impression, above-the-fold only — default) or 'full_page' (scroll through entire page, all barriers). Use 'viewport' for landing page optimization; 'full_page' for WCAG compliance audits."),
|
|
145
|
-
device: z.string().optional().describe("Device emulation: 'mobile', 'tablet', 'desktop', or specific device like 'iPhone 15', 'Pixel 7'. Essential for mobile WCAG audits — touch targets, viewport sizing, and responsive barriers differ significantly on mobile."),
|
|
146
|
-
uiResource: z.boolean().optional().default(true).describe("Return an interactive HTML report as an MCP UI resource alongside the JSON. Hosts that support MCP Apps render it inline; others ignore it. Set false for scripted callers that diff whole responses."),
|
|
167
|
+
url: z.string().url().describe("MCP server URL to audit (e.g., https://demo.cbrowser.ai/mcp)"),
|
|
168
|
+
apiKey: z.string().optional().describe("API key if server requires authentication"),
|
|
169
|
+
oauthToken: z.string().optional().describe("OAuth token if server uses OAuth"),
|
|
170
|
+
timeout: z.number().optional().default(30000).describe("Timeout in ms (default: 30000)"),
|
|
147
171
|
},
|
|
148
172
|
annotations: {
|
|
149
|
-
title: "
|
|
173
|
+
title: "WebMCP Readiness Audit",
|
|
150
174
|
readOnlyHint: true,
|
|
151
175
|
destructiveHint: false,
|
|
152
176
|
idempotentHint: false,
|
|
153
177
|
openWorldHint: true,
|
|
154
178
|
},
|
|
155
|
-
}, async ({ url,
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
scope: scope || "viewport",
|
|
181
|
-
device: device || "desktop",
|
|
182
|
-
scopeNote: scope === "full_page"
|
|
183
|
-
? "Full-page audit: scrolled through entire page before barrier detection. Scores reflect all content including below-the-fold."
|
|
184
|
-
: "Viewport-only audit: scored first impression (above-the-fold). Use scope='full_page' for complete barrier inventory, or cognitive_journey for path-dependent experience.",
|
|
185
|
-
resultsSummary: result.results.map((r) => {
|
|
186
|
-
const uniqueTypes = new Set(r.barriers.map(b => b.type));
|
|
187
|
-
return {
|
|
188
|
-
persona: r.persona,
|
|
189
|
-
disabilityType: r.disabilityType,
|
|
190
|
-
goalAchieved: r.goalAchieved,
|
|
191
|
-
empathyScore: r.empathyScore,
|
|
192
|
-
// v18.22.0: Added score context for transparency
|
|
193
|
-
// Whitelist projection: anything added to scoreContext upstream is
|
|
194
|
-
// invisible to the caller unless named here. The blend breakdown
|
|
195
|
-
// below is what lets the deductions reconcile to a score the
|
|
196
|
-
// caller can actually see. (2026-07-28)
|
|
197
|
-
scoreContext: r.scoreContext ? {
|
|
198
|
-
explanation: r.scoreContext.explanation,
|
|
199
|
-
deductionsByType: r.scoreContext.deductionsByType,
|
|
200
|
-
totalBarrierDeduction: r.scoreContext.totalBarrierDeduction,
|
|
201
|
-
frictionDeduction: r.scoreContext.frictionDeduction,
|
|
202
|
-
goalDeduction: r.scoreContext.goalDeduction,
|
|
203
|
-
cognitiveOverloadPenalty: r.scoreContext.cognitiveOverloadPenalty,
|
|
204
|
-
appliedBarrierWeights: r.scoreContext.appliedBarrierWeights,
|
|
205
|
-
explainsScore: r.scoreContext.explainsScore,
|
|
206
|
-
barrierOnlyScore: r.scoreContext.barrierOnlyScore,
|
|
207
|
-
perceptualScore: r.scoreContext.perceptualScore,
|
|
208
|
-
blendWeights: r.scoreContext.blendWeights,
|
|
209
|
-
finalScore: r.scoreContext.finalScore,
|
|
210
|
-
// A partial score must be distinguishable from a measured one.
|
|
211
|
-
// Set when the navigation simulation threw: the goal traversal
|
|
212
|
-
// did not run, so no goal deduction was charged and the score
|
|
213
|
-
// reflects barriers only. Without these two names the whitelist
|
|
214
|
-
// above would drop the flag and the score would read as clean.
|
|
215
|
-
degraded: r.scoreContext.degraded,
|
|
216
|
-
degradedReason: r.scoreContext.degradedReason,
|
|
217
|
-
} : undefined,
|
|
218
|
-
barrierTypeCount: uniqueTypes.size,
|
|
219
|
-
barrierTypes: Array.from(uniqueTypes),
|
|
220
|
-
affectedElements: r.barriers.length,
|
|
221
|
-
wcagViolationCount: r.wcagViolations.length,
|
|
222
|
-
// v18.26.0: Perceptual transport metrics (Wasserstein-based)
|
|
223
|
-
perceptualTransport: r.perceptualTransport || undefined,
|
|
224
|
-
empathyScoreBarrierOnly: r.empathyScoreBarrierOnly || undefined,
|
|
225
|
-
// v18.27.0: Cognitive load estimation (optimal transport)
|
|
226
|
-
cognitiveLoad: r.cognitiveLoad || undefined,
|
|
227
|
-
// Names the three different "cognitive load" numbers in this
|
|
228
|
-
// payload and what each measures. Same whitelist caveat as
|
|
229
|
-
// scoreContext above: set upstream, invisible unless named here.
|
|
230
|
-
cognitiveLoadReadings: r.cognitiveLoadReadings || undefined,
|
|
231
|
-
// v18.28.0: Attention transport analysis (W₂ saliency)
|
|
232
|
-
attentionAnalysis: r.attentionAnalysis || undefined,
|
|
233
|
-
// v18.29.0: Journey validation — evidence, path, forensics
|
|
234
|
-
journeyValidation: r.journeyValidation || undefined,
|
|
235
|
-
// v18.35.0: Non-disability persona flag
|
|
236
|
-
isDisabilityPersona: r.isDisabilityPersona !== false,
|
|
237
|
-
personaNote: r.personaNote || undefined,
|
|
238
|
-
};
|
|
239
|
-
}),
|
|
240
|
-
allWcagViolations: result.allWcagViolations,
|
|
241
|
-
topBarriers: result.topBarriers.slice(0, 5),
|
|
242
|
-
// A barrier's affectedPersonas names the personas it hits HARDEST. It
|
|
243
|
-
// is not an exclusion list, which is why a persona absent from it can
|
|
244
|
-
// still take a deduction — susceptibility is applied as a weight, not
|
|
245
|
-
// a gate (see scoreContext.appliedBarrierWeights). Stating it here
|
|
246
|
-
// because the pairing otherwise reads as a contradiction: a
|
|
247
|
-
// touch_target barrier listing motor and elderly personas, scored
|
|
248
|
-
// against cognitive-adhd. (2026-07-29)
|
|
249
|
-
barrierFieldNotes: {
|
|
250
|
-
affectedPersonas: "Exemplars — the personas this barrier affects most. NOT an exclusion list; every tested persona is scored against every barrier, weighted by susceptibility.",
|
|
251
|
-
severity: "On topBarriers entries this is the group maximum across all elements of that type (severityIsGroupMax), not one element's severity.",
|
|
252
|
-
},
|
|
253
|
-
topRemediation: result.combinedRemediation.slice(0, 5),
|
|
254
|
-
duration: result.duration,
|
|
255
|
-
// Errors caught during the run, promoted out of the HTML report. The
|
|
256
|
-
// SVG className crash changed goalAchieved and the goal deduction while
|
|
257
|
-
// being visible ONLY in the HTML, so the JSON reported a confident
|
|
258
|
-
// wrong number. Anything that moves a scored field is surfaced here.
|
|
259
|
-
errors: result.results.flatMap((r) => (r.frictionPoints ?? [])
|
|
260
|
-
.filter((fp) => fp.type === "error")
|
|
261
|
-
.map((fp) => ({ persona: r.persona, message: fp.description }))),
|
|
262
|
-
// v18.60.0: Include per-result screenshots and element rects for WCAG overlay
|
|
263
|
-
pageScreenshots: result.results?.map((r) => ({
|
|
264
|
-
persona: r.persona,
|
|
265
|
-
...(includeScreenshots
|
|
266
|
-
? { screenshot: r.pageScreenshot }
|
|
267
|
-
: (() => {
|
|
268
|
-
const shot = writeAuditScreenshot(r.pageScreenshot, r.persona);
|
|
269
|
-
return {
|
|
270
|
-
screenshotPath: shot.path,
|
|
271
|
-
screenshotUrl: shot.url,
|
|
272
|
-
screenshotNote: "Pass includeScreenshots:true to inline the base64 instead.",
|
|
273
|
-
};
|
|
274
|
-
})()),
|
|
275
|
-
viewportSize: r.viewportSize,
|
|
276
|
-
// Rects come from getBoundingClientRect with no scroll compensation,
|
|
277
|
-
// so they are VIEWPORT coordinates — which is correct here, because
|
|
278
|
-
// the screenshot above is captured with fullPage: false and these are
|
|
279
|
-
// meant to overlay it. Converting them to document coordinates would
|
|
280
|
-
// break that alignment.
|
|
281
|
-
//
|
|
282
|
-
// The real hazard is scope: "full_page", where the audit scrolls and
|
|
283
|
-
// rects captured at different offsets land in one list against a
|
|
284
|
-
// single viewport image; anything above the captured viewport then
|
|
285
|
-
// carries a negative y and would render off-canvas. Say which space
|
|
286
|
-
// these are in and flag the ones that fall outside the image rather
|
|
287
|
-
// than emitting silent negatives. (2026-07-28)
|
|
288
|
-
// Rects are now DOCUMENT coordinates (scroll offset added at capture),
|
|
289
|
-
// so they are stable across the scrolling that scope:"full_page" does.
|
|
290
|
-
// They were previously labelled "viewport" while holding values from
|
|
291
|
-
// several scroll positions at once — y:-274 and y:3951 in one list.
|
|
292
|
-
coordinateSpace: "document",
|
|
293
|
-
// Height of the captured image in document space. A viewport capture
|
|
294
|
-
// covers only the scroll position it was taken at; a full_page capture
|
|
295
|
-
// covers the document. Without this the bounds test compared document
|
|
296
|
-
// coordinates against a viewport height and flagged EVERY rect,
|
|
297
|
-
// including ones plainly inside the image. (2026-07-29)
|
|
298
|
-
captureHeight: (scope === "full_page" ? (r.documentHeight ?? undefined) : r.viewportSize?.height),
|
|
299
|
-
barrierRects: r.barriers?.filter((b) => b.rect).map((b) => {
|
|
300
|
-
const vp = r.viewportSize;
|
|
301
|
-
const bound = scope === "full_page" ? (r.documentHeight ?? Infinity) : (vp?.height ?? Infinity);
|
|
302
|
-
const outside = !!b.rect && (b.rect.y + b.rect.height < 0 || b.rect.x + b.rect.width < 0 ||
|
|
303
|
-
b.rect.y > bound || (!!vp && b.rect.x > vp.width));
|
|
304
|
-
return {
|
|
305
|
-
type: b.type, severity: b.severity, element: b.element,
|
|
306
|
-
description: b.description, rect: b.rect,
|
|
307
|
-
wcag: b.wcagCriteria,
|
|
308
|
-
...(outside ? { outsideScreenshot: true } : {}),
|
|
309
|
-
};
|
|
310
|
-
}),
|
|
311
|
-
// Keep entries that carry EITHER the inline image or a path to it.
|
|
312
|
-
// Filtering on `screenshot` alone silently dropped every entry once
|
|
313
|
-
// the default stopped inlining base64, taking the barrierRects with
|
|
314
|
-
// them. (2026-07-29)
|
|
315
|
-
})).filter((s) => s.screenshot || s.screenshotPath),
|
|
316
|
-
};
|
|
317
|
-
// Add guidance if we limited the request
|
|
318
|
-
if (wasLimited) {
|
|
319
|
-
response.note = `Limited to 1 persona to avoid timeout. For full coverage, call again with: ${remainingPersonas.slice(0, 3).join(", ")}${remainingPersonas.length > 3 ? `, and ${remainingPersonas.length - 3} more` : ""}`;
|
|
320
|
-
response.remainingPersonas = remainingPersonas;
|
|
321
|
-
}
|
|
322
|
-
// Auto-save handled by tier-gate wrapper.
|
|
323
|
-
//
|
|
324
|
-
// The UI resource is APPENDED to the text block, never substituted for
|
|
325
|
-
// it: content[0] stays the same JSON every CLI and CI caller already
|
|
326
|
-
// parses. `generateEmpathyAuditHtmlReport` is the report the --html flag
|
|
327
|
-
// has always produced — self-contained, no remote refs — so this is a
|
|
328
|
-
// transport seam rather than new UI. If it cannot be produced safely,
|
|
329
|
-
// htmlUiResource returns null and the response is exactly as before.
|
|
330
|
-
// (2026-07-29)
|
|
331
|
-
const content = [
|
|
332
|
-
{ type: "text", text: JSON.stringify(response, null, 2) },
|
|
333
|
-
];
|
|
334
|
-
if (uiResourcesEnabled(uiResource)) {
|
|
335
|
-
// The overlay goes FIRST. It is the thing that makes a score
|
|
336
|
-
// falsifiable by eye — barriers drawn on the page at their real
|
|
337
|
-
// positions — and the text report is the detail behind it.
|
|
338
|
-
try {
|
|
339
|
-
const { buildBarrierOverlayHtml } = await import("../../visual/barrier-overlay-html.js");
|
|
340
|
-
const shots = (response.pageScreenshots ?? []);
|
|
341
|
-
for (const shot of shots) {
|
|
342
|
-
const overlayHtml = buildBarrierOverlayHtml({
|
|
343
|
-
imageUrl: shot.screenshotUrl,
|
|
344
|
-
imageWidth: shot.viewportSize?.width,
|
|
345
|
-
captureHeight: shot.captureHeight ?? shot.viewportSize?.height,
|
|
346
|
-
barrierRects: shot.barrierRects ?? [],
|
|
347
|
-
persona: String(shot.persona ?? testedPersona),
|
|
348
|
-
pageUrl: String(result.url),
|
|
349
|
-
score: result.overallScore,
|
|
350
|
-
});
|
|
351
|
-
attachUiResource(content, htmlUiResource(`ui://cbrowser/barrier-overlay/${encodeURIComponent(String(shot.persona ?? testedPersona))}`, overlayHtml ?? undefined, { frameSize: ["100%", "820px"] }));
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
catch { /* overlay is additive; never fail the audit for it */ }
|
|
355
|
-
let reportHtml;
|
|
356
|
-
try {
|
|
357
|
-
const { generateEmpathyAuditHtmlReport } = await import("../../analysis/accessibility-empathy.js");
|
|
358
|
-
reportHtml = generateEmpathyAuditHtmlReport(result);
|
|
359
|
-
}
|
|
360
|
-
catch {
|
|
361
|
-
reportHtml = undefined;
|
|
362
|
-
}
|
|
363
|
-
attachUiResource(content, htmlUiResource(`ui://cbrowser/empathy-audit/${encodeURIComponent(testedPersona)}`, reportHtml, { frameSize: ["100%", "760px"] }));
|
|
364
|
-
}
|
|
365
|
-
return { content };
|
|
366
|
-
}
|
|
367
|
-
catch (error) {
|
|
368
|
-
// Categorize the error for better user feedback
|
|
369
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
370
|
-
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
371
|
-
// Determine error type for actionable feedback
|
|
372
|
-
let errorType = "unknown";
|
|
373
|
-
let suggestion = "Please try again or contact support if the issue persists.";
|
|
374
|
-
if (errorMessage.includes("timeout") || errorMessage.includes("Timeout")) {
|
|
375
|
-
errorType = "timeout";
|
|
376
|
-
suggestion = "The page took too long to load. Try increasing maxTime or testing a faster page.";
|
|
377
|
-
}
|
|
378
|
-
else if (errorMessage.includes("net::") || errorMessage.includes("DNS") || errorMessage.includes("ERR_")) {
|
|
379
|
-
errorType = "network";
|
|
380
|
-
suggestion = "Unable to reach the URL. Check the URL is valid and accessible.";
|
|
381
|
-
}
|
|
382
|
-
else if (errorMessage.includes("blocked") || errorMessage.includes("403") || errorMessage.includes("captcha")) {
|
|
383
|
-
errorType = "bot-detection";
|
|
384
|
-
suggestion = "The site may be blocking automation. Try with a different URL.";
|
|
385
|
-
}
|
|
386
|
-
else if (errorMessage.includes("chromium") || errorMessage.includes("browser")) {
|
|
387
|
-
errorType = "browser";
|
|
388
|
-
suggestion = "Browser automation error. The server may need to restart.";
|
|
389
|
-
}
|
|
390
|
-
// Log the full error for debugging
|
|
391
|
-
console.error(`[empathy_audit] Error: ${errorMessage}`);
|
|
392
|
-
if (errorStack) {
|
|
393
|
-
console.error(`[empathy_audit] Stack: ${errorStack}`);
|
|
394
|
-
}
|
|
395
|
-
// Return a structured error response
|
|
396
|
-
return {
|
|
397
|
-
content: [
|
|
398
|
-
{
|
|
399
|
-
type: "text",
|
|
400
|
-
text: JSON.stringify({
|
|
401
|
-
error: true,
|
|
402
|
-
errorType,
|
|
403
|
-
message: errorMessage,
|
|
404
|
-
suggestion,
|
|
405
|
-
url,
|
|
406
|
-
goal,
|
|
407
|
-
disabilities: disabilities || "all",
|
|
408
|
-
}, null, 2),
|
|
409
|
-
},
|
|
410
|
-
],
|
|
411
|
-
isError: true,
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
});
|
|
415
|
-
server.registerTool("webmcp_ready_audit", {
|
|
416
|
-
title: "WebMCP Readiness Audit",
|
|
417
|
-
description: "Audit an MCP server for Claude in Chrome / WebMCP compatibility. Uses 6-tier evaluation: Server Implementation (25%), Tool Discoverability (20%), Instrumentation (15%), Consistency (15%), Agent Optimizations (15%), Documentation (10%). Returns score (0-100), grade (A-F), tier breakdown, issues, and recommendations.",
|
|
418
|
-
inputSchema: {
|
|
419
|
-
url: z.string().url().describe("MCP server URL to audit (e.g., https://demo.cbrowser.ai/mcp)"),
|
|
420
|
-
apiKey: z.string().optional().describe("API key if server requires authentication"),
|
|
421
|
-
oauthToken: z.string().optional().describe("OAuth token if server uses OAuth"),
|
|
422
|
-
timeout: z.number().optional().default(30000).describe("Timeout in ms (default: 30000)"),
|
|
423
|
-
},
|
|
424
|
-
annotations: {
|
|
425
|
-
title: "WebMCP Readiness Audit",
|
|
426
|
-
readOnlyHint: true,
|
|
427
|
-
destructiveHint: false,
|
|
428
|
-
idempotentHint: false,
|
|
429
|
-
openWorldHint: true,
|
|
430
|
-
},
|
|
431
|
-
}, async ({ url, apiKey, oauthToken, timeout }) => {
|
|
432
|
-
const result = await runWebMCPReadyAudit(url, {
|
|
433
|
-
apiKey,
|
|
434
|
-
oauthToken,
|
|
435
|
-
timeout,
|
|
436
|
-
});
|
|
437
|
-
return {
|
|
438
|
-
content: [
|
|
439
|
-
{
|
|
440
|
-
type: "text",
|
|
441
|
-
text: JSON.stringify({
|
|
442
|
-
url: result.url,
|
|
443
|
-
score: result.score,
|
|
444
|
-
grade: result.grade,
|
|
445
|
-
summary: result.summary,
|
|
446
|
-
tierScores: result.tiers.map((t) => ({
|
|
447
|
-
tier: t.tier,
|
|
448
|
-
name: t.name,
|
|
449
|
-
score: t.score,
|
|
450
|
-
weight: `${Math.round(t.weight * 100)}%`,
|
|
451
|
-
})),
|
|
452
|
-
topIssues: result.issues.slice(0, 5),
|
|
453
|
-
topRecommendations: result.recommendations.slice(0, 5),
|
|
454
|
-
duration: result.duration,
|
|
455
|
-
}, null, 2),
|
|
179
|
+
}, async ({ url, apiKey, oauthToken, timeout }) => {
|
|
180
|
+
const result = await runWebMCPReadyAudit(url, {
|
|
181
|
+
apiKey,
|
|
182
|
+
oauthToken,
|
|
183
|
+
timeout,
|
|
184
|
+
});
|
|
185
|
+
return {
|
|
186
|
+
content: [
|
|
187
|
+
{
|
|
188
|
+
type: "text",
|
|
189
|
+
text: JSON.stringify({
|
|
190
|
+
url: result.url,
|
|
191
|
+
score: result.score,
|
|
192
|
+
grade: result.grade,
|
|
193
|
+
summary: result.summary,
|
|
194
|
+
tierScores: result.tiers.map((t) => ({
|
|
195
|
+
tier: t.tier,
|
|
196
|
+
name: t.name,
|
|
197
|
+
score: t.score,
|
|
198
|
+
weight: `${Math.round(t.weight * 100)}%`,
|
|
199
|
+
})),
|
|
200
|
+
topIssues: result.issues.slice(0, 5),
|
|
201
|
+
topRecommendations: result.recommendations.slice(0, 5),
|
|
202
|
+
duration: result.duration,
|
|
203
|
+
}, null, 2),
|
|
456
204
|
},
|
|
457
205
|
],
|
|
458
206
|
};
|
|
@@ -465,6 +213,12 @@ export function registerAuditTools(server, context) {
|
|
|
465
213
|
url: z.string().url().describe("URL to assess"),
|
|
466
214
|
personas: z.string().optional().default("first-timer,cognitive-adhd").describe("Comma-separated persona names (default: first-timer,cognitive-adhd)"),
|
|
467
215
|
threshold: z.number().optional().default(60).describe("Minimum agent-ready score to proceed to persona analysis (default: 60)"),
|
|
216
|
+
// Per RUN, not per persona. The same person is familiar with one site and new
|
|
217
|
+
// to another, so this has no value until a site is named -- a stored value only
|
|
218
|
+
// ever answered "familiar with which site?" by accident. The persona's value is
|
|
219
|
+
// the fallback when a caller does not pass one. (2026-08-01)
|
|
220
|
+
siteFamiliarity: z.number().min(0).max(1).optional()
|
|
221
|
+
.describe("How familiar these personas are with THIS site: 0 first visit, 1 regular user. Overrides any value stored on the persona. Familiarity is a persona-site pair, not a disposition, so set it per run."),
|
|
468
222
|
userLocation: z.string().optional().describe("User's approximate location (e.g., 'Denver, Colorado, US') — for geo-aware content expectations"),
|
|
469
223
|
userTimezone: z.string().optional().describe("User's timezone (e.g., 'America/Denver') — for time-sensitive content evaluation"),
|
|
470
224
|
userLanguage: z.string().optional().describe("User's expected language (e.g., 'en-US') — for readability calibration"),
|
|
@@ -483,7 +237,7 @@ export function registerAuditTools(server, context) {
|
|
|
483
237
|
idempotentHint: false,
|
|
484
238
|
openWorldHint: true,
|
|
485
239
|
},
|
|
486
|
-
}, async ({ url, personas, threshold, userLocation, userTimezone, userLanguage, proxy, geoRegion, device, waitAfterLoad, waitForSelector, scope, _browserToken }) => {
|
|
240
|
+
}, async ({ url, personas, threshold, siteFamiliarity, userLocation, userTimezone, userLanguage, proxy, geoRegion, device, waitAfterLoad, waitForSelector, scope, _browserToken }) => {
|
|
487
241
|
const startTime = Date.now();
|
|
488
242
|
const personaList = (personas || "first-timer,cognitive-adhd").split(",").map(s => s.trim()).filter(Boolean);
|
|
489
243
|
// Derive locale from userLanguage (e.g., "en-US" → locale "en-US", language "en")
|
|
@@ -737,7 +491,7 @@ export function registerAuditTools(server, context) {
|
|
|
737
491
|
const { buildOTCognitiveProfile } = await import("../../visual/cognitive-transport.js");
|
|
738
492
|
const { computeDemandDistribution, computeSequentialCTC } = await import("../../visual/cognitive-transport-chain.js");
|
|
739
493
|
const { extractPageMetrics } = await import("../../visual/cognitive-transport.js");
|
|
740
|
-
const { getAnyPersona, createCognitivePersona } = await import("../../personas.js");
|
|
494
|
+
const { getAnyPersona, createCognitivePersona, resolvePersonaOrThrow } = await import("../../personas.js");
|
|
741
495
|
// Navigate in our browser for page metrics
|
|
742
496
|
await browser.navigate(url);
|
|
743
497
|
const metricsPage = await browser.getPage();
|
|
@@ -771,20 +525,29 @@ export function registerAuditTools(server, context) {
|
|
|
771
525
|
for (const personaName of personaList) {
|
|
772
526
|
try {
|
|
773
527
|
const existingPersona = getAnyPersona(personaName);
|
|
528
|
+
if (!existingPersona)
|
|
529
|
+
resolvePersonaOrThrow(personaName);
|
|
774
530
|
const personaObj = existingPersona || createCognitivePersona(personaName, personaName, {});
|
|
775
531
|
const traits = { ...(personaObj.cognitiveTraits || {}) };
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
532
|
+
// Familiarity is a persona-SITE pair, so the caller's value for THIS
|
|
533
|
+
// run wins over anything stored on the persona. A stored value only
|
|
534
|
+
// ever answered "familiar with which site?" by accident.
|
|
535
|
+
// (2026-08-01)
|
|
779
536
|
const reqFam = traits.siteFamiliarity ?? 0.5;
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
traits.siteFamiliarity = 1.0;
|
|
537
|
+
const familiarityFromCaller = typeof siteFamiliarity === "number";
|
|
538
|
+
if (familiarityFromCaller) {
|
|
539
|
+
traits.siteFamiliarity = siteFamiliarity;
|
|
784
540
|
}
|
|
785
541
|
else {
|
|
786
|
-
//
|
|
787
|
-
|
|
542
|
+
// v18.61.0: without an explicit value it stays a binary gate on
|
|
543
|
+
// whether this install has crawled the site at all.
|
|
544
|
+
if (hasSiteKnowledge) {
|
|
545
|
+
if (reqFam > 0.5)
|
|
546
|
+
traits.siteFamiliarity = 1.0;
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
traits.siteFamiliarity = 0.0;
|
|
550
|
+
}
|
|
788
551
|
}
|
|
789
552
|
const otProfile = buildOTCognitiveProfile(personaName, traits);
|
|
790
553
|
const result = computeSequentialCTC(otProfile, demand, { asymmetric: true, interactions: true });
|
|
@@ -798,9 +561,13 @@ export function registerAuditTools(server, context) {
|
|
|
798
561
|
name: l.name,
|
|
799
562
|
cost: Math.round(l.transportCost * 1000) / 1000,
|
|
800
563
|
})),
|
|
801
|
-
|
|
802
|
-
|
|
564
|
+
// Only warn about the automatic downgrade. A caller who SET the
|
|
565
|
+
// value for this run has already answered the question the
|
|
566
|
+
// downgrade exists to answer.
|
|
567
|
+
...(!familiarityFromCaller && reqFam > 0.5 && !hasSiteKnowledge ? {
|
|
568
|
+
familiarityWarning: `siteFamiliarity downgraded from ${reqFam} to 0.0 — no site knowledge exists for this domain. Pass siteFamiliarity on this call to state it directly, or run page_understand first.`,
|
|
803
569
|
} : {}),
|
|
570
|
+
...(familiarityFromCaller ? { siteFamiliaritySource: "supplied for this run" } : {}),
|
|
804
571
|
};
|
|
805
572
|
}
|
|
806
573
|
catch (personaErr) {
|
|
@@ -921,7 +688,20 @@ export function registerAuditTools(server, context) {
|
|
|
921
688
|
else {
|
|
922
689
|
browser = new BrowserClass({
|
|
923
690
|
headless: true,
|
|
924
|
-
|
|
691
|
+
// The CONFIGURED viewport, not a hardcoded 1920x1080.
|
|
692
|
+
//
|
|
693
|
+
// cognitive_effort and every other tool render at the configured
|
|
694
|
+
// default (1280x800), and this one alone rendered at 1920x1080. The
|
|
695
|
+
// two tools were therefore measuring different rendered pages while
|
|
696
|
+
// reporting coordinates as if they shared a space -- attention regions
|
|
697
|
+
// came back at x=1312 against a config that says the viewport is 1280
|
|
698
|
+
// wide, which is impossible and was the tell. Any comparison between
|
|
699
|
+
// an attention run and a CTC run was comparing two different layouts.
|
|
700
|
+
// (2026-08-02)
|
|
701
|
+
...(device ? { device: device.toLowerCase() } : {
|
|
702
|
+
viewportWidth: getDefaultConfig().viewportWidth,
|
|
703
|
+
viewportHeight: getDefaultConfig().viewportHeight,
|
|
704
|
+
}),
|
|
925
705
|
});
|
|
926
706
|
await browser.launch();
|
|
927
707
|
}
|
|
@@ -981,8 +761,9 @@ export function registerAuditTools(server, context) {
|
|
|
981
761
|
try {
|
|
982
762
|
const { motorAccessibility } = await import("../../visual/cognitive-models.js");
|
|
983
763
|
const { buildOTCognitiveProfile } = await import("../../visual/cognitive-transport.js");
|
|
984
|
-
const { getAnyPersona, createCognitivePersona } = await import("../../personas.js");
|
|
985
|
-
|
|
764
|
+
const { getAnyPersona, createCognitivePersona, resolvePersonaOrThrow } = await import("../../personas.js");
|
|
765
|
+
// A miss throws instead of yielding an all-midpoint stand-in.
|
|
766
|
+
const personaObj = getAnyPersona(persona) || resolvePersonaOrThrow(persona);
|
|
986
767
|
const traits = (personaObj.cognitiveTraits || {});
|
|
987
768
|
const otProfile = buildOTCognitiveProfile(persona, traits);
|
|
988
769
|
const elements = await page.evaluate(() => {
|
|
@@ -1110,8 +891,9 @@ export function registerAuditTools(server, context) {
|
|
|
1110
891
|
const { buildOTCognitiveProfile } = await import("../../visual/cognitive-transport.js");
|
|
1111
892
|
const { computeDemandDistribution, computeSequentialCTC } = await import("../../visual/cognitive-transport-chain.js");
|
|
1112
893
|
const { extractPageMetrics } = await import("../../visual/cognitive-transport.js");
|
|
1113
|
-
const { getAnyPersona, createCognitivePersona } = await import("../../personas.js");
|
|
1114
|
-
|
|
894
|
+
const { getAnyPersona, createCognitivePersona, resolvePersonaOrThrow } = await import("../../personas.js");
|
|
895
|
+
// A miss throws instead of yielding an all-midpoint stand-in.
|
|
896
|
+
const personaObj = getAnyPersona(persona) || resolvePersonaOrThrow(persona);
|
|
1115
897
|
const traits = (personaObj.cognitiveTraits || {});
|
|
1116
898
|
const otProfile = buildOTCognitiveProfile(persona, traits);
|
|
1117
899
|
const pageMetrics = await extractPageMetrics(page);
|
|
@@ -1316,4 +1098,477 @@ export function registerAuditTools(server, context) {
|
|
|
1316
1098
|
}
|
|
1317
1099
|
});
|
|
1318
1100
|
}
|
|
1101
|
+
/**
|
|
1102
|
+
* The canonical empathy_audit. Exported on its own so the stdio server can
|
|
1103
|
+
* register it too: mcp-server.ts carried a second, older implementation of
|
|
1104
|
+
* the same tool name that returned a thin summary with no barrier rects, no
|
|
1105
|
+
* persona weighting and no UI resource. Two implementations of one tool is a
|
|
1106
|
+
* bug generator -- fixes landed on the remote one for weeks while the local
|
|
1107
|
+
* one silently served stale shapes -- and registerAuditTools cannot simply be
|
|
1108
|
+
* called from there because three of its other tools collide with the stdio
|
|
1109
|
+
* server's own. (2026-07-31)
|
|
1110
|
+
*/
|
|
1111
|
+
export function registerEmpathyAuditTool(server) {
|
|
1112
|
+
server.registerTool("empathy_audit", {
|
|
1113
|
+
// Barriers ranked by severity is the artifact a product team acts on, and
|
|
1114
|
+
// nested JSON buries the ordering that makes it actionable.
|
|
1115
|
+
_meta: { ui: { resourceUri: "ui://cbrowser/empathy" } },
|
|
1116
|
+
title: "Empathy Accessibility Audit",
|
|
1117
|
+
description: "Simulate how real users experience a site. Accepts ANY persona — disability personas get specialized barrier detection, non-disability personas get general UX analysis with a flag. Tests ONE persona per call. Disability: motor-impairment-tremor, low-vision-magnified, cognitive-adhd, dyslexic-user, deaf-user, elderly-low-vision, color-blind-deuteranopia. General: first-timer, power-user, mobile-user, elderly-user, impatient-user, or any custom persona.",
|
|
1118
|
+
inputSchema: {
|
|
1119
|
+
url: z.string().url().describe("URL to audit"),
|
|
1120
|
+
// Optional as of 2026-08-02. It was required, which implied every audit
|
|
1121
|
+
// traversed toward it — most do not. A cognitive journey runs only when an
|
|
1122
|
+
// API key is configured; without one, nothing reads this. Barrier
|
|
1123
|
+
// detection, the score, and the noBlockingBarriers verdict never do.
|
|
1124
|
+
goal: z.string().optional().describe("Optional. What the persona is trying to do (e.g. 'complete checkout'). Used ONLY when an API key is set, to drive a cognitive journey and record a goal path. Barrier detection and the empathy score do not use it, so omitting it changes nothing about the accessibility findings."),
|
|
1125
|
+
disabilities: z.array(z.string()).optional().describe("Persona to test. Accepts disability names OR any cognitive persona (first-timer, power-user, etc). Non-disability personas are flagged. Pass ONE for reliable results."),
|
|
1126
|
+
wcagLevel: z.enum(["A", "AA", "AAA"]).optional().default("AA").describe("WCAG conformance level"),
|
|
1127
|
+
maxSteps: z.number().optional().default(5).describe("Max cognitive journey steps (keep low for MCP)"),
|
|
1128
|
+
maxTime: z.number().optional().default(20).describe("Max time per persona in seconds"),
|
|
1129
|
+
// Base64 page screenshots are large enough to blow an MCP client's context
|
|
1130
|
+
// on a single call — every empathy_audit in the 18.73.3 test pass had to be
|
|
1131
|
+
// read off disk instead. Off by default; images go to disk and their paths
|
|
1132
|
+
// are returned. (2026-07-29)
|
|
1133
|
+
includeScreenshots: z.boolean().optional().default(false).describe("Inline base64 page screenshots in the response. Off by default because they are large enough to overflow an MCP client's context; when off, screenshots are written to disk and screenshotPath is returned instead."),
|
|
1134
|
+
scope: z.enum(["viewport", "full_page"]).optional().default("viewport").describe("What to score: 'viewport' (first impression, above-the-fold only — default) or 'full_page' (scroll through entire page, all barriers). Use 'viewport' for landing page optimization; 'full_page' for WCAG compliance audits."),
|
|
1135
|
+
device: z.string().optional().describe("Device emulation: 'mobile', 'tablet', 'desktop', or specific device like 'iPhone 15', 'Pixel 7'. Essential for mobile WCAG audits — touch targets, viewport sizing, and responsive barriers differ significantly on mobile."),
|
|
1136
|
+
uiResource: z.boolean().optional().default(true).describe("Return an interactive HTML report as an MCP UI resource alongside the JSON. Hosts that support MCP Apps render it inline; others ignore it. Set false for scripted callers that diff whole responses."),
|
|
1137
|
+
},
|
|
1138
|
+
annotations: {
|
|
1139
|
+
title: "Empathy Accessibility Audit",
|
|
1140
|
+
readOnlyHint: true,
|
|
1141
|
+
destructiveHint: false,
|
|
1142
|
+
idempotentHint: false,
|
|
1143
|
+
openWorldHint: true,
|
|
1144
|
+
},
|
|
1145
|
+
}, async ({ url, goal, disabilities, wcagLevel, maxSteps, maxTime, scope, device, includeScreenshots, uiResource }) => {
|
|
1146
|
+
try {
|
|
1147
|
+
// Auto-limit to 1 persona to avoid MCP client timeout on Claude.ai (~60s limit)
|
|
1148
|
+
const allPersonas = listAccessibilityPersonas();
|
|
1149
|
+
const requestedList = disabilities || allPersonas;
|
|
1150
|
+
const wasLimited = requestedList.length > 1;
|
|
1151
|
+
const singlePersona = [requestedList[0]];
|
|
1152
|
+
const result = await runEmpathyAudit(url, {
|
|
1153
|
+
goal,
|
|
1154
|
+
disabilities: singlePersona,
|
|
1155
|
+
wcagLevel,
|
|
1156
|
+
maxSteps,
|
|
1157
|
+
maxTime,
|
|
1158
|
+
headless: true,
|
|
1159
|
+
scope: scope || "viewport",
|
|
1160
|
+
device: device || undefined,
|
|
1161
|
+
});
|
|
1162
|
+
// Build response with guidance for additional personas
|
|
1163
|
+
const testedPersona = singlePersona[0];
|
|
1164
|
+
const remainingPersonas = allPersonas.filter(p => p !== testedPersona);
|
|
1165
|
+
// The findings shown, selected once so the overlay and the list agree.
|
|
1166
|
+
// Both the numbered boxes on the map and the numbered entries in the
|
|
1167
|
+
// list index into this array, so box 3 is finding 3 by construction
|
|
1168
|
+
// rather than by two pieces of code happening to sort the same way.
|
|
1169
|
+
const chosenBarriers = (() => {
|
|
1170
|
+
const top = result.topBarriers.slice(0, 5);
|
|
1171
|
+
const covered = new Set(top.flatMap((b) => b.wcagCriteria ?? []));
|
|
1172
|
+
const rescued = [];
|
|
1173
|
+
for (const b of result.topBarriers.slice(5)) {
|
|
1174
|
+
const missing = (b.wcagCriteria ?? []).filter((c) => !covered.has(c));
|
|
1175
|
+
if (missing.length === 0)
|
|
1176
|
+
continue;
|
|
1177
|
+
missing.forEach((c) => covered.add(c));
|
|
1178
|
+
rescued.push(b);
|
|
1179
|
+
}
|
|
1180
|
+
// Numbered in the order they are DISPLAYED (worst-first by the
|
|
1181
|
+
// persona-weighted grade, which is how the widget sorts them), so
|
|
1182
|
+
// the numbers on the map read 1,2,3 down the list instead of
|
|
1183
|
+
// whatever order the analyzer happened to emit.
|
|
1184
|
+
const rank = (b) => {
|
|
1185
|
+
const { weight } = barrierWeightFor(testedPersona, String(b.type ?? ""), b.wcagCriteria);
|
|
1186
|
+
const { severity } = weightedSeverity(String(b.severity ?? "minor"), weight);
|
|
1187
|
+
const order = {
|
|
1188
|
+
critical: 0, blocker: 0, high: 1, major: 1, serious: 1,
|
|
1189
|
+
medium: 2, moderate: 2, low: 3, minor: 3, info: 4, notice: 4
|
|
1190
|
+
};
|
|
1191
|
+
return order[String(severity).toLowerCase()] ?? 9;
|
|
1192
|
+
};
|
|
1193
|
+
return [...top, ...rescued].sort((a, b) => rank(a) - rank(b));
|
|
1194
|
+
})();
|
|
1195
|
+
// Group keys in list order; a rect's key looked up here gives its
|
|
1196
|
+
// finding number.
|
|
1197
|
+
const findingKeys = chosenBarriers.map((b) => `${b.type}|${weightKeyFor(String(b.type ?? ""), b.wcagCriteria) ?? ""}`);
|
|
1198
|
+
const response = {
|
|
1199
|
+
url: result.url,
|
|
1200
|
+
goal: result.goal,
|
|
1201
|
+
testedPersona,
|
|
1202
|
+
overallScore: result.overallScore,
|
|
1203
|
+
scope: scope || "viewport",
|
|
1204
|
+
device: device || "desktop",
|
|
1205
|
+
scopeNote: scope === "full_page"
|
|
1206
|
+
? "Full-page audit: scrolled through entire page before barrier detection. Scores reflect all content including below-the-fold."
|
|
1207
|
+
: "Viewport-only audit: scored first impression (above-the-fold). Use scope='full_page' for complete barrier inventory, or cognitive_journey for path-dependent experience.",
|
|
1208
|
+
resultsSummary: result.results.map((r) => {
|
|
1209
|
+
const uniqueTypes = new Set(r.barriers.map(b => b.type));
|
|
1210
|
+
return {
|
|
1211
|
+
persona: r.persona,
|
|
1212
|
+
disabilityType: r.disabilityType,
|
|
1213
|
+
// What the audit actually computed: no barrier found that would
|
|
1214
|
+
// block this persona. Renamed from goalAchieved, which asserted a
|
|
1215
|
+
// completed task nobody attempted.
|
|
1216
|
+
noBlockingBarriers: r.noBlockingBarriers,
|
|
1217
|
+
/** @deprecated one release only — use noBlockingBarriers */
|
|
1218
|
+
goalAchieved: r.noBlockingBarriers,
|
|
1219
|
+
empathyScore: r.empathyScore,
|
|
1220
|
+
// v18.22.0: Added score context for transparency
|
|
1221
|
+
// Whitelist projection: anything added to scoreContext upstream is
|
|
1222
|
+
// invisible to the caller unless named here. The blend breakdown
|
|
1223
|
+
// below is what lets the deductions reconcile to a score the
|
|
1224
|
+
// caller can actually see. (2026-07-28)
|
|
1225
|
+
scoreContext: r.scoreContext ? {
|
|
1226
|
+
explanation: r.scoreContext.explanation,
|
|
1227
|
+
deductionsByType: r.scoreContext.deductionsByType,
|
|
1228
|
+
totalBarrierDeduction: r.scoreContext.totalBarrierDeduction,
|
|
1229
|
+
frictionDeduction: r.scoreContext.frictionDeduction,
|
|
1230
|
+
goalDeduction: r.scoreContext.goalDeduction,
|
|
1231
|
+
cognitiveOverloadPenalty: r.scoreContext.cognitiveOverloadPenalty,
|
|
1232
|
+
appliedBarrierWeights: r.scoreContext.appliedBarrierWeights,
|
|
1233
|
+
explainsScore: r.scoreContext.explainsScore,
|
|
1234
|
+
barrierOnlyScore: r.scoreContext.barrierOnlyScore,
|
|
1235
|
+
perceptualScore: r.scoreContext.perceptualScore,
|
|
1236
|
+
blendWeights: r.scoreContext.blendWeights,
|
|
1237
|
+
finalScore: r.scoreContext.finalScore,
|
|
1238
|
+
// A partial score must be distinguishable from a measured one.
|
|
1239
|
+
// Set when the navigation simulation threw: the goal traversal
|
|
1240
|
+
// did not run, so no goal deduction was charged and the score
|
|
1241
|
+
// reflects barriers only. Without these two names the whitelist
|
|
1242
|
+
// above would drop the flag and the score would read as clean.
|
|
1243
|
+
degraded: r.scoreContext.degraded,
|
|
1244
|
+
degradedReason: r.scoreContext.degradedReason,
|
|
1245
|
+
} : undefined,
|
|
1246
|
+
barrierTypeCount: uniqueTypes.size,
|
|
1247
|
+
barrierTypes: Array.from(uniqueTypes),
|
|
1248
|
+
affectedElements: r.barriers.length,
|
|
1249
|
+
wcagViolationCount: r.wcagViolations.length,
|
|
1250
|
+
// v18.26.0: Perceptual transport metrics (Wasserstein-based)
|
|
1251
|
+
perceptualTransport: r.perceptualTransport || undefined,
|
|
1252
|
+
empathyScoreBarrierOnly: r.empathyScoreBarrierOnly || undefined,
|
|
1253
|
+
// v18.27.0: Cognitive load estimation (optimal transport)
|
|
1254
|
+
cognitiveLoad: r.cognitiveLoad || undefined,
|
|
1255
|
+
// Names the three different "cognitive load" numbers in this
|
|
1256
|
+
// payload and what each measures. Same whitelist caveat as
|
|
1257
|
+
// scoreContext above: set upstream, invisible unless named here.
|
|
1258
|
+
cognitiveLoadReadings: r.cognitiveLoadReadings || undefined,
|
|
1259
|
+
// v18.28.0: Attention transport analysis (W₂ saliency)
|
|
1260
|
+
attentionAnalysis: r.attentionAnalysis || undefined,
|
|
1261
|
+
// Same metric names as the attention_analysis tool, different
|
|
1262
|
+
// implementation, and the distributions do not overlap --
|
|
1263
|
+
// measured on one page: alignment 0.884 here against 0.389-0.616
|
|
1264
|
+
// there, concentration 0.292 against 0.65-0.90, transportCost
|
|
1265
|
+
// 0.460 against 0.756-1.010. Comparing a number from one against
|
|
1266
|
+
// a number from the other is meaningless, and nothing said so.
|
|
1267
|
+
// Corrected: this is the SAME analyzeAttention implementation the
|
|
1268
|
+
// attention_analysis tool calls, not a second one. The earlier
|
|
1269
|
+
// note claimed otherwise, which was wrong -- the divergence comes
|
|
1270
|
+
// from parameters, a 16px grid here against the tool's finer
|
|
1271
|
+
// default and a viewport rather than full-page capture.
|
|
1272
|
+
attentionAnalysisNote: r.attentionAnalysis
|
|
1273
|
+
? "Same analyzeAttention implementation the attention_analysis tool uses, run here on a 16px grid over the viewport rather than the tool's finer default — so the values are not comparable across the two and only entropy, concentration and transportCost are published, because those are the three that feed this audit's perceptual score. Call attention_analysis directly for attention metrics."
|
|
1274
|
+
: undefined,
|
|
1275
|
+
// v18.29.0: Journey validation — evidence, path, forensics
|
|
1276
|
+
journeyValidation: r.journeyValidation || undefined,
|
|
1277
|
+
// v18.35.0: Non-disability persona flag
|
|
1278
|
+
isDisabilityPersona: r.isDisabilityPersona !== false,
|
|
1279
|
+
personaNote: r.personaNote || undefined,
|
|
1280
|
+
// Media this page cannot decide about. Not barriers and not
|
|
1281
|
+
// scored: a third-party embed keeps its caption and description
|
|
1282
|
+
// tracks inside a cross-origin iframe, so the only honest report
|
|
1283
|
+
// is that the question is open and where to go to close it.
|
|
1284
|
+
// Previously these were asserted as missing captions.
|
|
1285
|
+
unverifiableMedia: r.unverifiableMedia || undefined,
|
|
1286
|
+
};
|
|
1287
|
+
}),
|
|
1288
|
+
allWcagViolations: result.allWcagViolations,
|
|
1289
|
+
// Top five, PLUS one barrier for every WCAG criterion the top five
|
|
1290
|
+
// would otherwise drop.
|
|
1291
|
+
//
|
|
1292
|
+
// A flat slice made the audit unusable for compliance: 1.1.1 and
|
|
1293
|
+
// 1.2.2 appeared in allWcagViolations while no barrier, remediation
|
|
1294
|
+
// or rect mentioned them, so fixing everything listed still left two
|
|
1295
|
+
// violations with nothing saying what they were. The criteria are
|
|
1296
|
+
// reported either way, so the slice was hiding the explanation while
|
|
1297
|
+
// keeping the accusation.
|
|
1298
|
+
topBarriers: (() => {
|
|
1299
|
+
const chosen = chosenBarriers;
|
|
1300
|
+
// Severity as this persona experiences it, alongside the base.
|
|
1301
|
+
// The base is what maps to WCAG conformance -- a criterion is not
|
|
1302
|
+
// more or less violated depending on who reads it -- so both ship
|
|
1303
|
+
// and the weight that separates them ships with them.
|
|
1304
|
+
return chosen.map((b, i) => {
|
|
1305
|
+
const { weight, key, defaulted } = barrierWeightFor(testedPersona, String(b.type ?? ""), b.wcagCriteria);
|
|
1306
|
+
const { severity, shifted } = weightedSeverity(String(b.severity ?? "minor"), weight);
|
|
1307
|
+
return {
|
|
1308
|
+
...b,
|
|
1309
|
+
// Same number the overlay draws on the matching boxes.
|
|
1310
|
+
finding: i + 1,
|
|
1311
|
+
severityForPersona: severity,
|
|
1312
|
+
personaWeight: Math.round(weight * 100) / 100,
|
|
1313
|
+
personaWeightKey: key,
|
|
1314
|
+
// An unmeasured 1.0 and a measured 1.0 mean different things.
|
|
1315
|
+
// Saying which prevents a default from reading as a finding.
|
|
1316
|
+
personaWeightIsDefault: defaulted,
|
|
1317
|
+
...(shifted !== 0
|
|
1318
|
+
? { severityShiftedBy: shifted > 0 ? `+${shifted}` : String(shifted) }
|
|
1319
|
+
: {}),
|
|
1320
|
+
};
|
|
1321
|
+
});
|
|
1322
|
+
})(),
|
|
1323
|
+
topBarriersNote: "Ordered worst-first by severityForPersona — the persona-weighted grade — so an entry can sit above a higher WCAG severity when this persona is more susceptible to it. Any entries after the fifth are included because they carry a WCAG criterion the top five do not.",
|
|
1324
|
+
// Asserting full coverage was itself a defect: the claim held only
|
|
1325
|
+
// when a barrier existed for every criterion, and said nothing when
|
|
1326
|
+
// one did not. Now it reports the gap instead of asserting there
|
|
1327
|
+
// isn't one.
|
|
1328
|
+
wcagCriteriaWithoutBarrier: (() => {
|
|
1329
|
+
const covered = new Set(result.topBarriers.flatMap((b) => b.wcagCriteria ?? []));
|
|
1330
|
+
const missing = Array.from(result.allWcagViolations ?? []).filter((c) => !covered.has(c));
|
|
1331
|
+
return missing.length ? missing : undefined;
|
|
1332
|
+
})(),
|
|
1333
|
+
// A barrier's affectedPersonas names the personas it hits HARDEST. It
|
|
1334
|
+
// is not an exclusion list, which is why a persona absent from it can
|
|
1335
|
+
// still take a deduction — susceptibility is applied as a weight, not
|
|
1336
|
+
// a gate (see scoreContext.appliedBarrierWeights). Stating it here
|
|
1337
|
+
// because the pairing otherwise reads as a contradiction: a
|
|
1338
|
+
// touch_target barrier listing motor and elderly personas, scored
|
|
1339
|
+
// against cognitive-adhd. (2026-07-29)
|
|
1340
|
+
// Same treatment cognitiveLoadReadings got: state why two numbers
|
|
1341
|
+
// that look contradictory are both right, rather than leaving a
|
|
1342
|
+
// reader to conclude one is broken.
|
|
1343
|
+
scoreFieldNotes: {
|
|
1344
|
+
cognitiveOverloadPenalty: "Persona susceptibility to visual noise, derived from the persona's own noiseTolerance and charged on every page — NOT a finding that this page overloaded anyone. It is non-zero for a sensitive persona even where cognitiveLoad.overloaded is false, because that flag is a separate page measurement against a threshold. The two disagreeing is expected.",
|
|
1345
|
+
},
|
|
1346
|
+
barrierFieldNotes: {
|
|
1347
|
+
affectedPersonas: "Exemplars — the personas this barrier affects most. NOT an exclusion list; every tested persona is scored against every barrier, weighted by susceptibility.",
|
|
1348
|
+
severity: "On topBarriers entries this is the group maximum across all elements of that type (severityIsGroupMax), not one element's severity.",
|
|
1349
|
+
severityForPersona: "The same barrier re-graded by this persona's susceptibility weight, which is what should drive triage order. `severity` stays the WCAG-facing grade: a criterion is not more or less violated depending on who reads it. A 169-item navigation is genuinely minor to most personas and critical to cognitive-adhd, whose cognitive_load weight is 3.0 — and only the weighted grade says so.",
|
|
1350
|
+
},
|
|
1351
|
+
topRemediation: result.combinedRemediation.slice(0, 5),
|
|
1352
|
+
duration: result.duration,
|
|
1353
|
+
// Errors caught during the run, promoted out of the HTML report. The
|
|
1354
|
+
// SVG className crash changed goalAchieved and the goal deduction while
|
|
1355
|
+
// being visible ONLY in the HTML, so the JSON reported a confident
|
|
1356
|
+
// wrong number. Anything that moves a scored field is surfaced here.
|
|
1357
|
+
errors: result.results.flatMap((r) => (r.frictionPoints ?? [])
|
|
1358
|
+
.filter((fp) => fp.type === "error")
|
|
1359
|
+
.map((fp) => ({ persona: r.persona, message: fp.description }))),
|
|
1360
|
+
// v18.60.0: Include per-result screenshots and element rects for WCAG overlay
|
|
1361
|
+
pageScreenshots: result.results?.map((r) => ({
|
|
1362
|
+
persona: r.persona,
|
|
1363
|
+
...(includeScreenshots
|
|
1364
|
+
? { screenshot: r.pageScreenshot }
|
|
1365
|
+
: (() => {
|
|
1366
|
+
const shot = writeAuditScreenshot(r.pageScreenshot, r.persona);
|
|
1367
|
+
return {
|
|
1368
|
+
screenshotPath: shot.path,
|
|
1369
|
+
screenshotUrl: shot.url,
|
|
1370
|
+
screenshotFile: shot.file,
|
|
1371
|
+
screenshotNote: "Pass includeScreenshots:true to inline the base64 instead.",
|
|
1372
|
+
};
|
|
1373
|
+
})()),
|
|
1374
|
+
viewportSize: r.viewportSize,
|
|
1375
|
+
// How much of the barrier set the overlay can actually draw.
|
|
1376
|
+
//
|
|
1377
|
+
// A rect needs an element that resolved to a bounding box at
|
|
1378
|
+
// capture time, so anything below the fold, hidden, or behind an
|
|
1379
|
+
// unresolvable selector produces a barrier with no box. The
|
|
1380
|
+
// overlay then silently understates coverage -- 21 affected
|
|
1381
|
+
// elements against 10 rects reads as "10 problems" unless the gap
|
|
1382
|
+
// is stated. Reported rather than papered over, the same way
|
|
1383
|
+
// cognitiveLoadReadings reports its disagreement.
|
|
1384
|
+
barrierRectCoverage: (() => {
|
|
1385
|
+
// Counted the same way barrierRects is built below, from
|
|
1386
|
+
// barriers that resolved to a rect. Reading r.barrierRects gave
|
|
1387
|
+
// 0 every time: that field is this payload's OUTPUT name, not an
|
|
1388
|
+
// input, so the coverage line reported drawn:0 beside ten
|
|
1389
|
+
// populated rects.
|
|
1390
|
+
// Zero-area rects draw nothing. They were counted as drawn, so
|
|
1391
|
+
// coverage read 19 while only 16 boxes were visible -- an
|
|
1392
|
+
// element that resolved to {0,0,0,0} (display:none, a collapsed
|
|
1393
|
+
// menu item) is undrawn in every sense that matters.
|
|
1394
|
+
const rects = (r.barriers ?? []).filter((b) => b.rect && b.rect.width > 0 && b.rect.height > 0).length;
|
|
1395
|
+
const affected = (r.barriers ?? []).reduce((n, b) => n + (b.affectedElementCount ?? 1), 0);
|
|
1396
|
+
return {
|
|
1397
|
+
drawn: rects,
|
|
1398
|
+
affectedElements: affected,
|
|
1399
|
+
...(affected > rects
|
|
1400
|
+
? { undrawn: affected - rects,
|
|
1401
|
+
undrawnReason: "Barriers whose element did not resolve to a bounding box at capture time — typically below the fold, hidden, or an unresolvable selector. They are counted in the score and listed in barriers, but the overlay cannot outline them." }
|
|
1402
|
+
: {}),
|
|
1403
|
+
};
|
|
1404
|
+
})(),
|
|
1405
|
+
// Rects come from getBoundingClientRect with no scroll compensation,
|
|
1406
|
+
// so they are VIEWPORT coordinates — which is correct here, because
|
|
1407
|
+
// the screenshot above is captured with fullPage: false and these are
|
|
1408
|
+
// meant to overlay it. Converting them to document coordinates would
|
|
1409
|
+
// break that alignment.
|
|
1410
|
+
//
|
|
1411
|
+
// The real hazard is scope: "full_page", where the audit scrolls and
|
|
1412
|
+
// rects captured at different offsets land in one list against a
|
|
1413
|
+
// single viewport image; anything above the captured viewport then
|
|
1414
|
+
// carries a negative y and would render off-canvas. Say which space
|
|
1415
|
+
// these are in and flag the ones that fall outside the image rather
|
|
1416
|
+
// than emitting silent negatives. (2026-07-28)
|
|
1417
|
+
// Rects are now DOCUMENT coordinates (scroll offset added at capture),
|
|
1418
|
+
// so they are stable across the scrolling that scope:"full_page" does.
|
|
1419
|
+
// They were previously labelled "viewport" while holding values from
|
|
1420
|
+
// several scroll positions at once — y:-274 and y:3951 in one list.
|
|
1421
|
+
coordinateSpace: "document",
|
|
1422
|
+
// The document-space region the screenshot actually covers, so a
|
|
1423
|
+
// consumer can map a rect onto the image without guessing. A
|
|
1424
|
+
// full_page capture starts at the document origin; a viewport
|
|
1425
|
+
// capture starts wherever the page was scrolled to.
|
|
1426
|
+
//
|
|
1427
|
+
// These two fields exist because captureHeight alone was not
|
|
1428
|
+
// enough: it reported documentHeight while the image was the
|
|
1429
|
+
// viewport, and the overlay scaled 5363px of coordinates onto an
|
|
1430
|
+
// 800px picture. Origin plus size is the whole answer, and it is
|
|
1431
|
+
// read from the PNG header rather than measured separately, so it
|
|
1432
|
+
// cannot disagree with the picture it describes. (2026-07-31)
|
|
1433
|
+
captureOrigin: scope === "full_page"
|
|
1434
|
+
? { x: 0, y: 0 }
|
|
1435
|
+
: (r.captureScroll ?? { x: 0, y: 0 }),
|
|
1436
|
+
captureSize: r.screenshotSize,
|
|
1437
|
+
// Height of the captured image in document space. A viewport capture
|
|
1438
|
+
// covers only the scroll position it was taken at; a full_page capture
|
|
1439
|
+
// covers the document. Without this the bounds test compared document
|
|
1440
|
+
// coordinates against a viewport height and flagged EVERY rect,
|
|
1441
|
+
// including ones plainly inside the image. (2026-07-29)
|
|
1442
|
+
// The height of the image that exists, preferred over the
|
|
1443
|
+
// separately-measured documentHeight. A page that lazy-loads
|
|
1444
|
+
// during capture makes the two disagree, and the picture is the
|
|
1445
|
+
// one the boxes are drawn on.
|
|
1446
|
+
captureHeight: r.screenshotSize?.height
|
|
1447
|
+
?? (scope === "full_page" ? (r.documentHeight ?? undefined) : r.viewportSize?.height),
|
|
1448
|
+
barrierRects: r.barriers?.filter((b) => b.rect && b.rect.width > 0 && b.rect.height > 0).map((b) => {
|
|
1449
|
+
// Bounds tested in IMAGE space: document rect minus capture
|
|
1450
|
+
// origin, against the real image size. Testing document
|
|
1451
|
+
// coordinates against documentHeight passed every rect as
|
|
1452
|
+
// "inside" even when the image showed only the top 800px, so
|
|
1453
|
+
// nothing was ever listed as off-capture and the overlay
|
|
1454
|
+
// claimed coverage it did not have.
|
|
1455
|
+
const origin = scope === "full_page"
|
|
1456
|
+
? { x: 0, y: 0 }
|
|
1457
|
+
: (r.captureScroll ?? { x: 0, y: 0 });
|
|
1458
|
+
const size = r.screenshotSize
|
|
1459
|
+
?? { width: r.viewportSize?.width ?? Infinity,
|
|
1460
|
+
height: scope === "full_page" ? (r.documentHeight ?? Infinity) : (r.viewportSize?.height ?? Infinity) };
|
|
1461
|
+
const ix = b.rect ? b.rect.x - origin.x : 0;
|
|
1462
|
+
const iy = b.rect ? b.rect.y - origin.y : 0;
|
|
1463
|
+
const outside = !!b.rect && (iy + b.rect.height < 0 || ix + b.rect.width < 0 ||
|
|
1464
|
+
iy > size.height || ix > size.width);
|
|
1465
|
+
// The map is coloured by the SAME grade the findings list is
|
|
1466
|
+
// ranked by. It previously carried only the raw WCAG severity
|
|
1467
|
+
// while the list below it used severityForPersona, so on a
|
|
1468
|
+
// weighted audit one barrier was orange on the map and red in
|
|
1469
|
+
// the list -- two scales, no label saying which was which.
|
|
1470
|
+
// (2026-07-31)
|
|
1471
|
+
const { weight } = barrierWeightFor(r.persona, String(b.type ?? ""), b.wcagCriteria);
|
|
1472
|
+
const { severity: weighted } = weightedSeverity(String(b.severity ?? "minor"), weight);
|
|
1473
|
+
// Which findings-list entry this box belongs to. Boxes are
|
|
1474
|
+
// per-element and findings are grouped by type+criterion, so
|
|
1475
|
+
// ten boxes can map to five findings; without the link there is
|
|
1476
|
+
// no way to tell which box the list is talking about.
|
|
1477
|
+
const gk = `${b.type}|${weightKeyFor(String(b.type ?? ""), b.wcagCriteria) ?? ""}`;
|
|
1478
|
+
const findingIndex = findingKeys.indexOf(gk);
|
|
1479
|
+
return {
|
|
1480
|
+
type: b.type, severity: b.severity, element: b.element,
|
|
1481
|
+
severityForPersona: weighted,
|
|
1482
|
+
...(findingIndex >= 0 ? { finding: findingIndex + 1 } : {}),
|
|
1483
|
+
description: b.description, rect: b.rect,
|
|
1484
|
+
wcag: b.wcagCriteria,
|
|
1485
|
+
...(outside ? { outsideScreenshot: true } : {}),
|
|
1486
|
+
};
|
|
1487
|
+
}),
|
|
1488
|
+
// Keep entries that carry EITHER the inline image or a path to it.
|
|
1489
|
+
// Filtering on `screenshot` alone silently dropped every entry once
|
|
1490
|
+
// the default stopped inlining base64, taking the barrierRects with
|
|
1491
|
+
// them. (2026-07-29)
|
|
1492
|
+
})).filter((s) => s.screenshot || s.screenshotPath),
|
|
1493
|
+
};
|
|
1494
|
+
// Add guidance if we limited the request
|
|
1495
|
+
if (wasLimited) {
|
|
1496
|
+
response.note = `Limited to 1 persona to avoid timeout. For full coverage, call again with: ${remainingPersonas.slice(0, 3).join(", ")}${remainingPersonas.length > 3 ? `, and ${remainingPersonas.length - 3} more` : ""}`;
|
|
1497
|
+
response.remainingPersonas = remainingPersonas;
|
|
1498
|
+
}
|
|
1499
|
+
// Auto-save handled by tier-gate wrapper.
|
|
1500
|
+
//
|
|
1501
|
+
// The UI resource is APPENDED to the text block, never substituted for
|
|
1502
|
+
// it: content[0] stays the same JSON every CLI and CI caller already
|
|
1503
|
+
// parses. `generateEmpathyAuditHtmlReport` is the report the --html flag
|
|
1504
|
+
// has always produced — self-contained, no remote refs — so this is a
|
|
1505
|
+
// transport seam rather than new UI. If it cannot be produced safely,
|
|
1506
|
+
// htmlUiResource returns null and the response is exactly as before.
|
|
1507
|
+
// (2026-07-29)
|
|
1508
|
+
const content = [
|
|
1509
|
+
{ type: "text", text: JSON.stringify(response, null, 2) },
|
|
1510
|
+
];
|
|
1511
|
+
// No UI resource is attached here any more.
|
|
1512
|
+
//
|
|
1513
|
+
// Both mcp-ui attachments are retired: the whole-report rawHtml and the
|
|
1514
|
+
// barrier overlay. Both embedded rendered HTML in the result, which
|
|
1515
|
+
// costs payload against the host's ~150k cap, and the overlay's boxes
|
|
1516
|
+
// were drawn over an img on cbrowser.ai that the widget sandbox will
|
|
1517
|
+
// not load -- outlines over empty space. Both are now served by
|
|
1518
|
+
// ui://cbrowser/empathy, declared on the tool, where the host fetches
|
|
1519
|
+
// the view and the overlay pulls its screenshot through artifact_fetch.
|
|
1520
|
+
//
|
|
1521
|
+
// uiResource is still accepted as an argument so existing callers do
|
|
1522
|
+
// not break; it simply no longer changes the response.
|
|
1523
|
+
return { content };
|
|
1524
|
+
}
|
|
1525
|
+
catch (error) {
|
|
1526
|
+
// Categorize the error for better user feedback
|
|
1527
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1528
|
+
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
1529
|
+
// Determine error type for actionable feedback
|
|
1530
|
+
let errorType = "unknown";
|
|
1531
|
+
let suggestion = "Please try again or contact support if the issue persists.";
|
|
1532
|
+
if (errorMessage.includes("timeout") || errorMessage.includes("Timeout")) {
|
|
1533
|
+
errorType = "timeout";
|
|
1534
|
+
suggestion = "The page took too long to load. Try increasing maxTime or testing a faster page.";
|
|
1535
|
+
}
|
|
1536
|
+
else if (errorMessage.includes("net::") || errorMessage.includes("DNS") || errorMessage.includes("ERR_")) {
|
|
1537
|
+
errorType = "network";
|
|
1538
|
+
suggestion = "Unable to reach the URL. Check the URL is valid and accessible.";
|
|
1539
|
+
}
|
|
1540
|
+
else if (errorMessage.includes("blocked") || errorMessage.includes("403") || errorMessage.includes("captcha")) {
|
|
1541
|
+
errorType = "bot-detection";
|
|
1542
|
+
suggestion = "The site may be blocking automation. Try with a different URL.";
|
|
1543
|
+
}
|
|
1544
|
+
else if (errorMessage.includes("chromium") || errorMessage.includes("browser")) {
|
|
1545
|
+
errorType = "browser";
|
|
1546
|
+
suggestion = "Browser automation error. The server may need to restart.";
|
|
1547
|
+
}
|
|
1548
|
+
// Log the full error for debugging
|
|
1549
|
+
console.error(`[empathy_audit] Error: ${errorMessage}`);
|
|
1550
|
+
if (errorStack) {
|
|
1551
|
+
console.error(`[empathy_audit] Stack: ${errorStack}`);
|
|
1552
|
+
}
|
|
1553
|
+
// Return a structured error response
|
|
1554
|
+
return {
|
|
1555
|
+
content: [
|
|
1556
|
+
{
|
|
1557
|
+
type: "text",
|
|
1558
|
+
text: JSON.stringify({
|
|
1559
|
+
error: true,
|
|
1560
|
+
errorType,
|
|
1561
|
+
message: errorMessage,
|
|
1562
|
+
suggestion,
|
|
1563
|
+
url,
|
|
1564
|
+
goal,
|
|
1565
|
+
disabilities: disabilities || "all",
|
|
1566
|
+
}, null, 2),
|
|
1567
|
+
},
|
|
1568
|
+
],
|
|
1569
|
+
isError: true,
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1319
1574
|
//# sourceMappingURL=audit-tools.js.map
|