cbrowser 18.79.0 → 18.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
  2. package/dist/analysis/accessibility-empathy.js +395 -66
  3. package/dist/analysis/accessibility-empathy.js.map +1 -1
  4. package/dist/analysis/agent-ready-audit.d.ts.map +1 -1
  5. package/dist/analysis/agent-ready-audit.js +78 -0
  6. package/dist/analysis/agent-ready-audit.js.map +1 -1
  7. package/dist/analysis/page-understanding.d.ts +139 -1
  8. package/dist/analysis/page-understanding.d.ts.map +1 -1
  9. package/dist/analysis/page-understanding.js +179 -12
  10. package/dist/analysis/page-understanding.js.map +1 -1
  11. package/dist/artifact-store.d.ts +19 -5
  12. package/dist/artifact-store.d.ts.map +1 -1
  13. package/dist/artifact-store.js +41 -11
  14. package/dist/artifact-store.js.map +1 -1
  15. package/dist/browser.d.ts +9 -0
  16. package/dist/browser.d.ts.map +1 -1
  17. package/dist/browser.js +81 -5
  18. package/dist/browser.js.map +1 -1
  19. package/dist/cli.js +55 -10
  20. package/dist/cli.js.map +1 -1
  21. package/dist/cognitive/index.d.ts.map +1 -1
  22. package/dist/cognitive/index.js +3 -2
  23. package/dist/cognitive/index.js.map +1 -1
  24. package/dist/config.d.ts +57 -0
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +234 -7
  27. package/dist/config.js.map +1 -1
  28. package/dist/content-integrity.d.ts +65 -0
  29. package/dist/content-integrity.d.ts.map +1 -0
  30. package/dist/content-integrity.js +127 -0
  31. package/dist/content-integrity.js.map +1 -0
  32. package/dist/index.js +1 -1
  33. package/dist/index.js.map +1 -1
  34. package/dist/mcp-server-remote.d.ts.map +1 -1
  35. package/dist/mcp-server-remote.js +148 -6
  36. package/dist/mcp-server-remote.js.map +1 -1
  37. package/dist/mcp-server.d.ts.map +1 -1
  38. package/dist/mcp-server.js +62 -938
  39. package/dist/mcp-server.js.map +1 -1
  40. package/dist/mcp-tools/account-personas.d.ts +41 -0
  41. package/dist/mcp-tools/account-personas.d.ts.map +1 -0
  42. package/dist/mcp-tools/account-personas.js +98 -0
  43. package/dist/mcp-tools/account-personas.js.map +1 -0
  44. package/dist/mcp-tools/base/analysis-tools.d.ts.map +1 -1
  45. package/dist/mcp-tools/base/analysis-tools.js +12 -2
  46. package/dist/mcp-tools/base/analysis-tools.js.map +1 -1
  47. package/dist/mcp-tools/base/artifact-tools.d.ts +3 -0
  48. package/dist/mcp-tools/base/artifact-tools.d.ts.map +1 -0
  49. package/dist/mcp-tools/base/artifact-tools.js +78 -0
  50. package/dist/mcp-tools/base/artifact-tools.js.map +1 -0
  51. package/dist/mcp-tools/base/audit-tools.d.ts +11 -0
  52. package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
  53. package/dist/mcp-tools/base/audit-tools.js +624 -342
  54. package/dist/mcp-tools/base/audit-tools.js.map +1 -1
  55. package/dist/mcp-tools/base/browser-management-tools.d.ts.map +1 -1
  56. package/dist/mcp-tools/base/browser-management-tools.js +18 -0
  57. package/dist/mcp-tools/base/browser-management-tools.js.map +1 -1
  58. package/dist/mcp-tools/base/bug-analysis-tools.d.ts.map +1 -1
  59. package/dist/mcp-tools/base/bug-analysis-tools.js +59 -3
  60. package/dist/mcp-tools/base/bug-analysis-tools.js.map +1 -1
  61. package/dist/mcp-tools/base/capture-tools.d.ts +37 -0
  62. package/dist/mcp-tools/base/capture-tools.d.ts.map +1 -1
  63. package/dist/mcp-tools/base/capture-tools.js +267 -6
  64. package/dist/mcp-tools/base/capture-tools.js.map +1 -1
  65. package/dist/mcp-tools/base/cognitive-tools.d.ts +12 -0
  66. package/dist/mcp-tools/base/cognitive-tools.d.ts.map +1 -1
  67. package/dist/mcp-tools/base/cognitive-tools.js +63 -15
  68. package/dist/mcp-tools/base/cognitive-tools.js.map +1 -1
  69. package/dist/mcp-tools/base/extraction-tools.d.ts.map +1 -1
  70. package/dist/mcp-tools/base/extraction-tools.js +33 -2
  71. package/dist/mcp-tools/base/extraction-tools.js.map +1 -1
  72. package/dist/mcp-tools/base/index.d.ts +2 -0
  73. package/dist/mcp-tools/base/index.d.ts.map +1 -1
  74. package/dist/mcp-tools/base/index.js +9 -0
  75. package/dist/mcp-tools/base/index.js.map +1 -1
  76. package/dist/mcp-tools/base/marketing-tools.js +3 -3
  77. package/dist/mcp-tools/base/marketing-tools.js.map +1 -1
  78. package/dist/mcp-tools/base/navigation-tools.d.ts.map +1 -1
  79. package/dist/mcp-tools/base/navigation-tools.js +21 -1
  80. package/dist/mcp-tools/base/navigation-tools.js.map +1 -1
  81. package/dist/mcp-tools/base/persona-comparison-tools.d.ts.map +1 -1
  82. package/dist/mcp-tools/base/persona-comparison-tools.js +529 -40
  83. package/dist/mcp-tools/base/persona-comparison-tools.js.map +1 -1
  84. package/dist/mcp-tools/base/site-knowledge-tools.d.ts.map +1 -1
  85. package/dist/mcp-tools/base/site-knowledge-tools.js +36 -3
  86. package/dist/mcp-tools/base/site-knowledge-tools.js.map +1 -1
  87. package/dist/mcp-tools/base/values-tools.d.ts +113 -0
  88. package/dist/mcp-tools/base/values-tools.d.ts.map +1 -1
  89. package/dist/mcp-tools/base/values-tools.js +868 -78
  90. package/dist/mcp-tools/base/values-tools.js.map +1 -1
  91. package/dist/mcp-tools/base/visual-testing-tools.d.ts.map +1 -1
  92. package/dist/mcp-tools/base/visual-testing-tools.js +130 -10
  93. package/dist/mcp-tools/base/visual-testing-tools.js.map +1 -1
  94. package/dist/mcp-tools/index.d.ts +3 -1
  95. package/dist/mcp-tools/index.d.ts.map +1 -1
  96. package/dist/mcp-tools/index.js +3 -28
  97. package/dist/mcp-tools/index.js.map +1 -1
  98. package/dist/mcp-tools/persona-creation-tools.d.ts.map +1 -1
  99. package/dist/mcp-tools/persona-creation-tools.js +363 -20
  100. package/dist/mcp-tools/persona-creation-tools.js.map +1 -1
  101. package/dist/mcp-tools/persona-lifecycle-tools.d.ts +3 -0
  102. package/dist/mcp-tools/persona-lifecycle-tools.d.ts.map +1 -0
  103. package/dist/mcp-tools/persona-lifecycle-tools.js +406 -0
  104. package/dist/mcp-tools/persona-lifecycle-tools.js.map +1 -0
  105. package/dist/mcp-tools/screenshot-utils.d.ts.map +1 -1
  106. package/dist/mcp-tools/screenshot-utils.js +56 -10
  107. package/dist/mcp-tools/screenshot-utils.js.map +1 -1
  108. package/dist/mcp-tools/tier-gate.js +2 -2
  109. package/dist/mcp-tools/tier-gate.js.map +1 -1
  110. package/dist/mcp-tools/tool-categories.d.ts.map +1 -1
  111. package/dist/mcp-tools/tool-categories.js +4 -1
  112. package/dist/mcp-tools/tool-categories.js.map +1 -1
  113. package/dist/mcp-tools/ui-resources.d.ts +167 -0
  114. package/dist/mcp-tools/ui-resources.d.ts.map +1 -0
  115. package/dist/mcp-tools/ui-resources.js +707 -0
  116. package/dist/mcp-tools/ui-resources.js.map +1 -0
  117. package/dist/mcp-tools/widget-kit.d.ts +299 -0
  118. package/dist/mcp-tools/widget-kit.d.ts.map +1 -0
  119. package/dist/mcp-tools/widget-kit.js +2034 -0
  120. package/dist/mcp-tools/widget-kit.js.map +1 -0
  121. package/dist/mcp-ui-resources.d.ts.map +1 -1
  122. package/dist/mcp-ui-resources.js +14 -4
  123. package/dist/mcp-ui-resources.js.map +1 -1
  124. package/dist/persona-questionnaire.d.ts +69 -1
  125. package/dist/persona-questionnaire.d.ts.map +1 -1
  126. package/dist/persona-questionnaire.js +177 -76
  127. package/dist/persona-questionnaire.js.map +1 -1
  128. package/dist/persona-scope.d.ts +20 -0
  129. package/dist/persona-scope.d.ts.map +1 -0
  130. package/dist/persona-scope.js +59 -0
  131. package/dist/persona-scope.js.map +1 -0
  132. package/dist/personas.d.ts +51 -17
  133. package/dist/personas.d.ts.map +1 -1
  134. package/dist/personas.js +947 -286
  135. package/dist/personas.js.map +1 -1
  136. package/dist/recording/capture-player.d.ts +68 -0
  137. package/dist/recording/capture-player.d.ts.map +1 -0
  138. package/dist/recording/capture-player.js +298 -0
  139. package/dist/recording/capture-player.js.map +1 -0
  140. package/dist/recording/engine.d.ts +52 -0
  141. package/dist/recording/engine.d.ts.map +1 -1
  142. package/dist/recording/engine.js +241 -3
  143. package/dist/recording/engine.js.map +1 -1
  144. package/dist/recording/inline-player.d.ts +39 -0
  145. package/dist/recording/inline-player.d.ts.map +1 -0
  146. package/dist/recording/inline-player.js +272 -0
  147. package/dist/recording/inline-player.js.map +1 -0
  148. package/dist/recording/saliency-overlay.d.ts +133 -0
  149. package/dist/recording/saliency-overlay.d.ts.map +1 -0
  150. package/dist/recording/saliency-overlay.js +360 -0
  151. package/dist/recording/saliency-overlay.js.map +1 -0
  152. package/dist/recording/types.d.ts +32 -0
  153. package/dist/recording/types.d.ts.map +1 -1
  154. package/dist/recording/types.js +46 -0
  155. package/dist/recording/types.js.map +1 -1
  156. package/dist/site-model/manager.d.ts +20 -0
  157. package/dist/site-model/manager.d.ts.map +1 -1
  158. package/dist/site-model/manager.js +52 -0
  159. package/dist/site-model/manager.js.map +1 -1
  160. package/dist/trait-reference.d.ts +26 -2
  161. package/dist/trait-reference.d.ts.map +1 -1
  162. package/dist/trait-reference.js +129 -19
  163. package/dist/trait-reference.js.map +1 -1
  164. package/dist/types.d.ts +105 -18
  165. package/dist/types.d.ts.map +1 -1
  166. package/dist/types.js.map +1 -1
  167. package/dist/url-resolution.d.ts +58 -0
  168. package/dist/url-resolution.d.ts.map +1 -0
  169. package/dist/url-resolution.js +143 -0
  170. package/dist/url-resolution.js.map +1 -0
  171. package/dist/values/big-five-inference.d.ts +121 -0
  172. package/dist/values/big-five-inference.d.ts.map +1 -0
  173. package/dist/values/big-five-inference.js +241 -0
  174. package/dist/values/big-five-inference.js.map +1 -0
  175. package/dist/values/big-five-values.d.ts +76 -0
  176. package/dist/values/big-five-values.d.ts.map +1 -0
  177. package/dist/values/big-five-values.js +152 -0
  178. package/dist/values/big-five-values.js.map +1 -0
  179. package/dist/values/index.d.ts +1 -1
  180. package/dist/values/index.d.ts.map +1 -1
  181. package/dist/values/index.js +1 -1
  182. package/dist/values/index.js.map +1 -1
  183. package/dist/values/persona-completeness.d.ts +91 -0
  184. package/dist/values/persona-completeness.d.ts.map +1 -0
  185. package/dist/values/persona-completeness.js +114 -0
  186. package/dist/values/persona-completeness.js.map +1 -0
  187. package/dist/values/persona-diff.d.ts +50 -0
  188. package/dist/values/persona-diff.d.ts.map +1 -0
  189. package/dist/values/persona-diff.js +126 -0
  190. package/dist/values/persona-diff.js.map +1 -0
  191. package/dist/values/persona-values.d.ts +16 -0
  192. package/dist/values/persona-values.d.ts.map +1 -1
  193. package/dist/values/persona-values.js +33 -0
  194. package/dist/values/persona-values.js.map +1 -1
  195. package/dist/values/schwartz-values.d.ts +9 -1
  196. package/dist/values/schwartz-values.d.ts.map +1 -1
  197. package/dist/values/schwartz-values.js +10 -2
  198. package/dist/values/schwartz-values.js.map +1 -1
  199. package/dist/values/value-mappings.d.ts +41 -2
  200. package/dist/values/value-mappings.d.ts.map +1 -1
  201. package/dist/values/value-mappings.js +65 -8
  202. package/dist/values/value-mappings.js.map +1 -1
  203. package/dist/visual/attention-quality.d.ts +27 -0
  204. package/dist/visual/attention-quality.d.ts.map +1 -1
  205. package/dist/visual/attention-quality.js +164 -2
  206. package/dist/visual/attention-quality.js.map +1 -1
  207. package/dist/visual/attention-transport.d.ts +41 -2
  208. package/dist/visual/attention-transport.d.ts.map +1 -1
  209. package/dist/visual/attention-transport.js +98 -26
  210. package/dist/visual/attention-transport.js.map +1 -1
  211. package/dist/visual/barrier-overlay-html.d.ts +10 -0
  212. package/dist/visual/barrier-overlay-html.d.ts.map +1 -1
  213. package/dist/visual/barrier-overlay-html.js.map +1 -1
  214. package/dist/visual/cognitive-models.d.ts +109 -13
  215. package/dist/visual/cognitive-models.d.ts.map +1 -1
  216. package/dist/visual/cognitive-models.js +166 -3
  217. package/dist/visual/cognitive-models.js.map +1 -1
  218. package/dist/visual/cognitive-transport-chain.d.ts +165 -5
  219. package/dist/visual/cognitive-transport-chain.d.ts.map +1 -1
  220. package/dist/visual/cognitive-transport-chain.js +567 -31
  221. package/dist/visual/cognitive-transport-chain.js.map +1 -1
  222. package/dist/visual/cognitive-transport.d.ts +45 -3
  223. package/dist/visual/cognitive-transport.d.ts.map +1 -1
  224. package/dist/visual/cognitive-transport.js +126 -12
  225. package/dist/visual/cognitive-transport.js.map +1 -1
  226. package/dist/visual/llm-relevance.d.ts +193 -0
  227. package/dist/visual/llm-relevance.d.ts.map +1 -0
  228. package/dist/visual/llm-relevance.js +480 -0
  229. package/dist/visual/llm-relevance.js.map +1 -0
  230. package/dist/visual/perceptual-transport.d.ts +60 -0
  231. package/dist/visual/perceptual-transport.d.ts.map +1 -1
  232. package/dist/visual/perceptual-transport.js +228 -19
  233. package/dist/visual/perceptual-transport.js.map +1 -1
  234. package/dist/visual/persona-context.d.ts +23 -0
  235. package/dist/visual/persona-context.d.ts.map +1 -0
  236. package/dist/visual/persona-context.js +44 -0
  237. package/dist/visual/persona-context.js.map +1 -0
  238. 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
- if (written)
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
- topIssues: result.issues.slice(0, 5),
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.registerTool("empathy_audit", {
130
- title: "Empathy Accessibility Audit",
131
- 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.",
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
- goal: z.string().describe("Task goal (e.g., 'complete checkout')"),
135
- 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."),
136
- wcagLevel: z.enum(["A", "AA", "AAA"]).optional().default("AA").describe("WCAG conformance level"),
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: "Empathy Accessibility Audit",
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, goal, disabilities, wcagLevel, maxSteps, maxTime, scope, device, includeScreenshots, uiResource }) => {
156
- try {
157
- // Auto-limit to 1 persona to avoid MCP client timeout on Claude.ai (~60s limit)
158
- const allPersonas = listAccessibilityPersonas();
159
- const requestedList = disabilities || allPersonas;
160
- const wasLimited = requestedList.length > 1;
161
- const singlePersona = [requestedList[0]];
162
- const result = await runEmpathyAudit(url, {
163
- goal,
164
- disabilities: singlePersona,
165
- wcagLevel,
166
- maxSteps,
167
- maxTime,
168
- headless: true,
169
- scope: scope || "viewport",
170
- device: device || undefined,
171
- });
172
- // Build response with guidance for additional personas
173
- const testedPersona = singlePersona[0];
174
- const remainingPersonas = allPersonas.filter(p => p !== testedPersona);
175
- const response = {
176
- url: result.url,
177
- goal: result.goal,
178
- testedPersona,
179
- overallScore: result.overallScore,
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,18 +491,39 @@ 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();
744
- const pageMetrics = await extractPageMetrics(metricsPage);
498
+ // `auditScope`, not the default. This argument was omitted, so every layer
499
+ // cost this tool produced was computed from viewport-only metrics while the
500
+ // payload said `"scope": "full_page"` (set at :369). The response asserted a
501
+ // scope the measurement never used.
502
+ //
503
+ // The report that found it compared this tool at full_page against
504
+ // cognitive_effort at viewport and got six of eight layers identical to three
505
+ // decimals. That is the signature: cognitive_effort DOES thread scope
506
+ // (persona-comparison-tools.ts), this did not, so both measured the same first
507
+ // screenful.
508
+ //
509
+ // This is worse than the undeclared-viewport defect it replaced. That one was
510
+ // a silent default; this one was an explicit false claim, and the criterion
511
+ // asserting "every response states its scope" passed throughout, because the
512
+ // response did state one. It just was not true.
513
+ //
514
+ // extractPageMetrics runs its own lazy-load scroll walk at full_page, so
515
+ // forwarding the scope is the entire fix.
516
+ const pageMetrics = await extractPageMetrics(metricsPage, auditScope);
745
517
  const demand = computeDemandDistribution(pageMetrics);
746
518
  // Page understanding
747
519
  let pageUnderstanding = null;
748
520
  try {
749
521
  const { PageUnderstandingEngine } = await import("../../analysis/page-understanding.js");
750
522
  const engine = new PageUnderstandingEngine();
751
- const pu = await engine.analyze(metricsPage);
523
+ // Display-only — it feeds no layer. Scoped anyway: a payload reporting
524
+ // affordanceCount beside a full_page verdict should be counting the full
525
+ // page's affordances, or it is the same false claim one field over.
526
+ const pu = await engine.analyze(metricsPage, auditScope);
752
527
  pageUnderstanding = {
753
528
  type: pu.type,
754
529
  affordanceCount: pu.affordances.length,
@@ -771,20 +546,29 @@ export function registerAuditTools(server, context) {
771
546
  for (const personaName of personaList) {
772
547
  try {
773
548
  const existingPersona = getAnyPersona(personaName);
549
+ if (!existingPersona)
550
+ resolvePersonaOrThrow(personaName);
774
551
  const personaObj = existingPersona || createCognitivePersona(personaName, personaName, {});
775
552
  const traits = { ...(personaObj.cognitiveTraits || {}) };
776
- // v18.61.0: siteFamiliarity is a binary gate, not a gradient
777
- // Has site knowledge → persona keeps its configured familiarity (maxed to 1.0 for high-familiarity personas)
778
- // No site knowledge → forced to 0.0 regardless of persona definition
553
+ // Familiarity is a persona-SITE pair, so the caller's value for THIS
554
+ // run wins over anything stored on the persona. A stored value only
555
+ // ever answered "familiar with which site?" by accident.
556
+ // (2026-08-01)
779
557
  const reqFam = traits.siteFamiliarity ?? 0.5;
780
- if (hasSiteKnowledge) {
781
- // Site knowledge exists — high-familiarity personas get max familiarity
782
- if (reqFam > 0.5)
783
- traits.siteFamiliarity = 1.0;
558
+ const familiarityFromCaller = typeof siteFamiliarity === "number";
559
+ if (familiarityFromCaller) {
560
+ traits.siteFamiliarity = siteFamiliarity;
784
561
  }
785
562
  else {
786
- // No site knowledge — everyone is a first-time visitor
787
- traits.siteFamiliarity = 0.0;
563
+ // v18.61.0: without an explicit value it stays a binary gate on
564
+ // whether this install has crawled the site at all.
565
+ if (hasSiteKnowledge) {
566
+ if (reqFam > 0.5)
567
+ traits.siteFamiliarity = 1.0;
568
+ }
569
+ else {
570
+ traits.siteFamiliarity = 0.0;
571
+ }
788
572
  }
789
573
  const otProfile = buildOTCognitiveProfile(personaName, traits);
790
574
  const result = computeSequentialCTC(otProfile, demand, { asymmetric: true, interactions: true });
@@ -798,9 +582,13 @@ export function registerAuditTools(server, context) {
798
582
  name: l.name,
799
583
  cost: Math.round(l.transportCost * 1000) / 1000,
800
584
  })),
801
- ...(reqFam > 0.5 && !hasSiteKnowledge ? {
802
- familiarityWarning: `siteFamiliarity downgraded from ${reqFam} to 0.0 — no site knowledge exists. Run page_understand first for accurate ${personaName} results.`,
585
+ // Only warn about the automatic downgrade. A caller who SET the
586
+ // value for this run has already answered the question the
587
+ // downgrade exists to answer.
588
+ ...(!familiarityFromCaller && reqFam > 0.5 && !hasSiteKnowledge ? {
589
+ 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
590
  } : {}),
591
+ ...(familiarityFromCaller ? { siteFamiliaritySource: "supplied for this run" } : {}),
804
592
  };
805
593
  }
806
594
  catch (personaErr) {
@@ -921,7 +709,20 @@ export function registerAuditTools(server, context) {
921
709
  else {
922
710
  browser = new BrowserClass({
923
711
  headless: true,
924
- ...(device ? { device: device.toLowerCase() } : { viewportWidth: 1920, viewportHeight: 1080 }),
712
+ // The CONFIGURED viewport, not a hardcoded 1920x1080.
713
+ //
714
+ // cognitive_effort and every other tool render at the configured
715
+ // default (1280x800), and this one alone rendered at 1920x1080. The
716
+ // two tools were therefore measuring different rendered pages while
717
+ // reporting coordinates as if they shared a space -- attention regions
718
+ // came back at x=1312 against a config that says the viewport is 1280
719
+ // wide, which is impossible and was the tell. Any comparison between
720
+ // an attention run and a CTC run was comparing two different layouts.
721
+ // (2026-08-02)
722
+ ...(device ? { device: device.toLowerCase() } : {
723
+ viewportWidth: getDefaultConfig().viewportWidth,
724
+ viewportHeight: getDefaultConfig().viewportHeight,
725
+ }),
925
726
  });
926
727
  await browser.launch();
927
728
  }
@@ -981,8 +782,9 @@ export function registerAuditTools(server, context) {
981
782
  try {
982
783
  const { motorAccessibility } = await import("../../visual/cognitive-models.js");
983
784
  const { buildOTCognitiveProfile } = await import("../../visual/cognitive-transport.js");
984
- const { getAnyPersona, createCognitivePersona } = await import("../../personas.js");
985
- const personaObj = getAnyPersona(persona) || createCognitivePersona(persona, persona, {});
785
+ const { getAnyPersona, createCognitivePersona, resolvePersonaOrThrow } = await import("../../personas.js");
786
+ // A miss throws instead of yielding an all-midpoint stand-in.
787
+ const personaObj = getAnyPersona(persona) || resolvePersonaOrThrow(persona);
986
788
  const traits = (personaObj.cognitiveTraits || {});
987
789
  const otProfile = buildOTCognitiveProfile(persona, traits);
988
790
  const elements = await page.evaluate(() => {
@@ -1110,10 +912,17 @@ export function registerAuditTools(server, context) {
1110
912
  const { buildOTCognitiveProfile } = await import("../../visual/cognitive-transport.js");
1111
913
  const { computeDemandDistribution, computeSequentialCTC } = await import("../../visual/cognitive-transport-chain.js");
1112
914
  const { extractPageMetrics } = await import("../../visual/cognitive-transport.js");
1113
- const { getAnyPersona, createCognitivePersona } = await import("../../personas.js");
1114
- const personaObj = getAnyPersona(persona) || createCognitivePersona(persona, persona, {});
915
+ const { getAnyPersona, createCognitivePersona, resolvePersonaOrThrow } = await import("../../personas.js");
916
+ // A miss throws instead of yielding an all-midpoint stand-in.
917
+ const personaObj = getAnyPersona(persona) || resolvePersonaOrThrow(persona);
1115
918
  const traits = (personaObj.cognitiveTraits || {});
1116
919
  const otProfile = buildOTCognitiveProfile(persona, traits);
920
+ // viewport-by-design: visual_cognitive_story declares no `scope` parameter,
921
+ // so it promises the caller nothing about how much of the page it read, and
922
+ // measuring the viewport is an honest default rather than a dropped
923
+ // argument. Distinct from the site_cognitive_assessment defect above, where
924
+ // a scope WAS offered, echoed, and then not applied. If this tool ever gains
925
+ // a scope, this marker must go and the scope must be threaded.
1117
926
  const pageMetrics = await extractPageMetrics(page);
1118
927
  const demand = computeDemandDistribution(pageMetrics);
1119
928
  const ctcResult = computeSequentialCTC(otProfile, demand, { asymmetric: true, interactions: true });
@@ -1316,4 +1125,477 @@ export function registerAuditTools(server, context) {
1316
1125
  }
1317
1126
  });
1318
1127
  }
1128
+ /**
1129
+ * The canonical empathy_audit. Exported on its own so the stdio server can
1130
+ * register it too: mcp-server.ts carried a second, older implementation of
1131
+ * the same tool name that returned a thin summary with no barrier rects, no
1132
+ * persona weighting and no UI resource. Two implementations of one tool is a
1133
+ * bug generator -- fixes landed on the remote one for weeks while the local
1134
+ * one silently served stale shapes -- and registerAuditTools cannot simply be
1135
+ * called from there because three of its other tools collide with the stdio
1136
+ * server's own. (2026-07-31)
1137
+ */
1138
+ export function registerEmpathyAuditTool(server) {
1139
+ server.registerTool("empathy_audit", {
1140
+ // Barriers ranked by severity is the artifact a product team acts on, and
1141
+ // nested JSON buries the ordering that makes it actionable.
1142
+ _meta: { ui: { resourceUri: "ui://cbrowser/empathy" } },
1143
+ title: "Empathy Accessibility Audit",
1144
+ 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.",
1145
+ inputSchema: {
1146
+ url: z.string().url().describe("URL to audit"),
1147
+ // Optional as of 2026-08-02. It was required, which implied every audit
1148
+ // traversed toward it — most do not. A cognitive journey runs only when an
1149
+ // API key is configured; without one, nothing reads this. Barrier
1150
+ // detection, the score, and the noBlockingBarriers verdict never do.
1151
+ 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."),
1152
+ 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."),
1153
+ wcagLevel: z.enum(["A", "AA", "AAA"]).optional().default("AA").describe("WCAG conformance level"),
1154
+ maxSteps: z.number().optional().default(5).describe("Max cognitive journey steps (keep low for MCP)"),
1155
+ maxTime: z.number().optional().default(20).describe("Max time per persona in seconds"),
1156
+ // Base64 page screenshots are large enough to blow an MCP client's context
1157
+ // on a single call — every empathy_audit in the 18.73.3 test pass had to be
1158
+ // read off disk instead. Off by default; images go to disk and their paths
1159
+ // are returned. (2026-07-29)
1160
+ 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."),
1161
+ 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."),
1162
+ 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."),
1163
+ 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."),
1164
+ },
1165
+ annotations: {
1166
+ title: "Empathy Accessibility Audit",
1167
+ readOnlyHint: true,
1168
+ destructiveHint: false,
1169
+ idempotentHint: false,
1170
+ openWorldHint: true,
1171
+ },
1172
+ }, async ({ url, goal, disabilities, wcagLevel, maxSteps, maxTime, scope, device, includeScreenshots, uiResource }) => {
1173
+ try {
1174
+ // Auto-limit to 1 persona to avoid MCP client timeout on Claude.ai (~60s limit)
1175
+ const allPersonas = listAccessibilityPersonas();
1176
+ const requestedList = disabilities || allPersonas;
1177
+ const wasLimited = requestedList.length > 1;
1178
+ const singlePersona = [requestedList[0]];
1179
+ const result = await runEmpathyAudit(url, {
1180
+ goal,
1181
+ disabilities: singlePersona,
1182
+ wcagLevel,
1183
+ maxSteps,
1184
+ maxTime,
1185
+ headless: true,
1186
+ scope: scope || "viewport",
1187
+ device: device || undefined,
1188
+ });
1189
+ // Build response with guidance for additional personas
1190
+ const testedPersona = singlePersona[0];
1191
+ const remainingPersonas = allPersonas.filter(p => p !== testedPersona);
1192
+ // The findings shown, selected once so the overlay and the list agree.
1193
+ // Both the numbered boxes on the map and the numbered entries in the
1194
+ // list index into this array, so box 3 is finding 3 by construction
1195
+ // rather than by two pieces of code happening to sort the same way.
1196
+ const chosenBarriers = (() => {
1197
+ const top = result.topBarriers.slice(0, 5);
1198
+ const covered = new Set(top.flatMap((b) => b.wcagCriteria ?? []));
1199
+ const rescued = [];
1200
+ for (const b of result.topBarriers.slice(5)) {
1201
+ const missing = (b.wcagCriteria ?? []).filter((c) => !covered.has(c));
1202
+ if (missing.length === 0)
1203
+ continue;
1204
+ missing.forEach((c) => covered.add(c));
1205
+ rescued.push(b);
1206
+ }
1207
+ // Numbered in the order they are DISPLAYED (worst-first by the
1208
+ // persona-weighted grade, which is how the widget sorts them), so
1209
+ // the numbers on the map read 1,2,3 down the list instead of
1210
+ // whatever order the analyzer happened to emit.
1211
+ const rank = (b) => {
1212
+ const { weight } = barrierWeightFor(testedPersona, String(b.type ?? ""), b.wcagCriteria);
1213
+ const { severity } = weightedSeverity(String(b.severity ?? "minor"), weight);
1214
+ const order = {
1215
+ critical: 0, blocker: 0, high: 1, major: 1, serious: 1,
1216
+ medium: 2, moderate: 2, low: 3, minor: 3, info: 4, notice: 4
1217
+ };
1218
+ return order[String(severity).toLowerCase()] ?? 9;
1219
+ };
1220
+ return [...top, ...rescued].sort((a, b) => rank(a) - rank(b));
1221
+ })();
1222
+ // Group keys in list order; a rect's key looked up here gives its
1223
+ // finding number.
1224
+ const findingKeys = chosenBarriers.map((b) => `${b.type}|${weightKeyFor(String(b.type ?? ""), b.wcagCriteria) ?? ""}`);
1225
+ const response = {
1226
+ url: result.url,
1227
+ goal: result.goal,
1228
+ testedPersona,
1229
+ overallScore: result.overallScore,
1230
+ scope: scope || "viewport",
1231
+ device: device || "desktop",
1232
+ scopeNote: scope === "full_page"
1233
+ ? "Full-page audit: scrolled through entire page before barrier detection. Scores reflect all content including below-the-fold."
1234
+ : "Viewport-only audit: scored first impression (above-the-fold). Use scope='full_page' for complete barrier inventory, or cognitive_journey for path-dependent experience.",
1235
+ resultsSummary: result.results.map((r) => {
1236
+ const uniqueTypes = new Set(r.barriers.map(b => b.type));
1237
+ return {
1238
+ persona: r.persona,
1239
+ disabilityType: r.disabilityType,
1240
+ // What the audit actually computed: no barrier found that would
1241
+ // block this persona. Renamed from goalAchieved, which asserted a
1242
+ // completed task nobody attempted.
1243
+ noBlockingBarriers: r.noBlockingBarriers,
1244
+ /** @deprecated one release only — use noBlockingBarriers */
1245
+ goalAchieved: r.noBlockingBarriers,
1246
+ empathyScore: r.empathyScore,
1247
+ // v18.22.0: Added score context for transparency
1248
+ // Whitelist projection: anything added to scoreContext upstream is
1249
+ // invisible to the caller unless named here. The blend breakdown
1250
+ // below is what lets the deductions reconcile to a score the
1251
+ // caller can actually see. (2026-07-28)
1252
+ scoreContext: r.scoreContext ? {
1253
+ explanation: r.scoreContext.explanation,
1254
+ deductionsByType: r.scoreContext.deductionsByType,
1255
+ totalBarrierDeduction: r.scoreContext.totalBarrierDeduction,
1256
+ frictionDeduction: r.scoreContext.frictionDeduction,
1257
+ goalDeduction: r.scoreContext.goalDeduction,
1258
+ cognitiveOverloadPenalty: r.scoreContext.cognitiveOverloadPenalty,
1259
+ appliedBarrierWeights: r.scoreContext.appliedBarrierWeights,
1260
+ explainsScore: r.scoreContext.explainsScore,
1261
+ barrierOnlyScore: r.scoreContext.barrierOnlyScore,
1262
+ perceptualScore: r.scoreContext.perceptualScore,
1263
+ blendWeights: r.scoreContext.blendWeights,
1264
+ finalScore: r.scoreContext.finalScore,
1265
+ // A partial score must be distinguishable from a measured one.
1266
+ // Set when the navigation simulation threw: the goal traversal
1267
+ // did not run, so no goal deduction was charged and the score
1268
+ // reflects barriers only. Without these two names the whitelist
1269
+ // above would drop the flag and the score would read as clean.
1270
+ degraded: r.scoreContext.degraded,
1271
+ degradedReason: r.scoreContext.degradedReason,
1272
+ } : undefined,
1273
+ barrierTypeCount: uniqueTypes.size,
1274
+ barrierTypes: Array.from(uniqueTypes),
1275
+ affectedElements: r.barriers.length,
1276
+ wcagViolationCount: r.wcagViolations.length,
1277
+ // v18.26.0: Perceptual transport metrics (Wasserstein-based)
1278
+ perceptualTransport: r.perceptualTransport || undefined,
1279
+ empathyScoreBarrierOnly: r.empathyScoreBarrierOnly || undefined,
1280
+ // v18.27.0: Cognitive load estimation (optimal transport)
1281
+ cognitiveLoad: r.cognitiveLoad || undefined,
1282
+ // Names the three different "cognitive load" numbers in this
1283
+ // payload and what each measures. Same whitelist caveat as
1284
+ // scoreContext above: set upstream, invisible unless named here.
1285
+ cognitiveLoadReadings: r.cognitiveLoadReadings || undefined,
1286
+ // v18.28.0: Attention transport analysis (W₂ saliency)
1287
+ attentionAnalysis: r.attentionAnalysis || undefined,
1288
+ // Same metric names as the attention_analysis tool, different
1289
+ // implementation, and the distributions do not overlap --
1290
+ // measured on one page: alignment 0.884 here against 0.389-0.616
1291
+ // there, concentration 0.292 against 0.65-0.90, transportCost
1292
+ // 0.460 against 0.756-1.010. Comparing a number from one against
1293
+ // a number from the other is meaningless, and nothing said so.
1294
+ // Corrected: this is the SAME analyzeAttention implementation the
1295
+ // attention_analysis tool calls, not a second one. The earlier
1296
+ // note claimed otherwise, which was wrong -- the divergence comes
1297
+ // from parameters, a 16px grid here against the tool's finer
1298
+ // default and a viewport rather than full-page capture.
1299
+ attentionAnalysisNote: r.attentionAnalysis
1300
+ ? "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."
1301
+ : undefined,
1302
+ // v18.29.0: Journey validation — evidence, path, forensics
1303
+ journeyValidation: r.journeyValidation || undefined,
1304
+ // v18.35.0: Non-disability persona flag
1305
+ isDisabilityPersona: r.isDisabilityPersona !== false,
1306
+ personaNote: r.personaNote || undefined,
1307
+ // Media this page cannot decide about. Not barriers and not
1308
+ // scored: a third-party embed keeps its caption and description
1309
+ // tracks inside a cross-origin iframe, so the only honest report
1310
+ // is that the question is open and where to go to close it.
1311
+ // Previously these were asserted as missing captions.
1312
+ unverifiableMedia: r.unverifiableMedia || undefined,
1313
+ };
1314
+ }),
1315
+ allWcagViolations: result.allWcagViolations,
1316
+ // Top five, PLUS one barrier for every WCAG criterion the top five
1317
+ // would otherwise drop.
1318
+ //
1319
+ // A flat slice made the audit unusable for compliance: 1.1.1 and
1320
+ // 1.2.2 appeared in allWcagViolations while no barrier, remediation
1321
+ // or rect mentioned them, so fixing everything listed still left two
1322
+ // violations with nothing saying what they were. The criteria are
1323
+ // reported either way, so the slice was hiding the explanation while
1324
+ // keeping the accusation.
1325
+ topBarriers: (() => {
1326
+ const chosen = chosenBarriers;
1327
+ // Severity as this persona experiences it, alongside the base.
1328
+ // The base is what maps to WCAG conformance -- a criterion is not
1329
+ // more or less violated depending on who reads it -- so both ship
1330
+ // and the weight that separates them ships with them.
1331
+ return chosen.map((b, i) => {
1332
+ const { weight, key, defaulted } = barrierWeightFor(testedPersona, String(b.type ?? ""), b.wcagCriteria);
1333
+ const { severity, shifted } = weightedSeverity(String(b.severity ?? "minor"), weight);
1334
+ return {
1335
+ ...b,
1336
+ // Same number the overlay draws on the matching boxes.
1337
+ finding: i + 1,
1338
+ severityForPersona: severity,
1339
+ personaWeight: Math.round(weight * 100) / 100,
1340
+ personaWeightKey: key,
1341
+ // An unmeasured 1.0 and a measured 1.0 mean different things.
1342
+ // Saying which prevents a default from reading as a finding.
1343
+ personaWeightIsDefault: defaulted,
1344
+ ...(shifted !== 0
1345
+ ? { severityShiftedBy: shifted > 0 ? `+${shifted}` : String(shifted) }
1346
+ : {}),
1347
+ };
1348
+ });
1349
+ })(),
1350
+ 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.",
1351
+ // Asserting full coverage was itself a defect: the claim held only
1352
+ // when a barrier existed for every criterion, and said nothing when
1353
+ // one did not. Now it reports the gap instead of asserting there
1354
+ // isn't one.
1355
+ wcagCriteriaWithoutBarrier: (() => {
1356
+ const covered = new Set(result.topBarriers.flatMap((b) => b.wcagCriteria ?? []));
1357
+ const missing = Array.from(result.allWcagViolations ?? []).filter((c) => !covered.has(c));
1358
+ return missing.length ? missing : undefined;
1359
+ })(),
1360
+ // A barrier's affectedPersonas names the personas it hits HARDEST. It
1361
+ // is not an exclusion list, which is why a persona absent from it can
1362
+ // still take a deduction — susceptibility is applied as a weight, not
1363
+ // a gate (see scoreContext.appliedBarrierWeights). Stating it here
1364
+ // because the pairing otherwise reads as a contradiction: a
1365
+ // touch_target barrier listing motor and elderly personas, scored
1366
+ // against cognitive-adhd. (2026-07-29)
1367
+ // Same treatment cognitiveLoadReadings got: state why two numbers
1368
+ // that look contradictory are both right, rather than leaving a
1369
+ // reader to conclude one is broken.
1370
+ scoreFieldNotes: {
1371
+ 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.",
1372
+ },
1373
+ barrierFieldNotes: {
1374
+ affectedPersonas: "Exemplars — the personas this barrier affects most. NOT an exclusion list; every tested persona is scored against every barrier, weighted by susceptibility.",
1375
+ severity: "On topBarriers entries this is the group maximum across all elements of that type (severityIsGroupMax), not one element's severity.",
1376
+ 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.",
1377
+ },
1378
+ topRemediation: result.combinedRemediation.slice(0, 5),
1379
+ duration: result.duration,
1380
+ // Errors caught during the run, promoted out of the HTML report. The
1381
+ // SVG className crash changed goalAchieved and the goal deduction while
1382
+ // being visible ONLY in the HTML, so the JSON reported a confident
1383
+ // wrong number. Anything that moves a scored field is surfaced here.
1384
+ errors: result.results.flatMap((r) => (r.frictionPoints ?? [])
1385
+ .filter((fp) => fp.type === "error")
1386
+ .map((fp) => ({ persona: r.persona, message: fp.description }))),
1387
+ // v18.60.0: Include per-result screenshots and element rects for WCAG overlay
1388
+ pageScreenshots: result.results?.map((r) => ({
1389
+ persona: r.persona,
1390
+ ...(includeScreenshots
1391
+ ? { screenshot: r.pageScreenshot }
1392
+ : (() => {
1393
+ const shot = writeAuditScreenshot(r.pageScreenshot, r.persona);
1394
+ return {
1395
+ screenshotPath: shot.path,
1396
+ screenshotUrl: shot.url,
1397
+ screenshotFile: shot.file,
1398
+ screenshotNote: "Pass includeScreenshots:true to inline the base64 instead.",
1399
+ };
1400
+ })()),
1401
+ viewportSize: r.viewportSize,
1402
+ // How much of the barrier set the overlay can actually draw.
1403
+ //
1404
+ // A rect needs an element that resolved to a bounding box at
1405
+ // capture time, so anything below the fold, hidden, or behind an
1406
+ // unresolvable selector produces a barrier with no box. The
1407
+ // overlay then silently understates coverage -- 21 affected
1408
+ // elements against 10 rects reads as "10 problems" unless the gap
1409
+ // is stated. Reported rather than papered over, the same way
1410
+ // cognitiveLoadReadings reports its disagreement.
1411
+ barrierRectCoverage: (() => {
1412
+ // Counted the same way barrierRects is built below, from
1413
+ // barriers that resolved to a rect. Reading r.barrierRects gave
1414
+ // 0 every time: that field is this payload's OUTPUT name, not an
1415
+ // input, so the coverage line reported drawn:0 beside ten
1416
+ // populated rects.
1417
+ // Zero-area rects draw nothing. They were counted as drawn, so
1418
+ // coverage read 19 while only 16 boxes were visible -- an
1419
+ // element that resolved to {0,0,0,0} (display:none, a collapsed
1420
+ // menu item) is undrawn in every sense that matters.
1421
+ const rects = (r.barriers ?? []).filter((b) => b.rect && b.rect.width > 0 && b.rect.height > 0).length;
1422
+ const affected = (r.barriers ?? []).reduce((n, b) => n + (b.affectedElementCount ?? 1), 0);
1423
+ return {
1424
+ drawn: rects,
1425
+ affectedElements: affected,
1426
+ ...(affected > rects
1427
+ ? { undrawn: affected - rects,
1428
+ 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." }
1429
+ : {}),
1430
+ };
1431
+ })(),
1432
+ // Rects come from getBoundingClientRect with no scroll compensation,
1433
+ // so they are VIEWPORT coordinates — which is correct here, because
1434
+ // the screenshot above is captured with fullPage: false and these are
1435
+ // meant to overlay it. Converting them to document coordinates would
1436
+ // break that alignment.
1437
+ //
1438
+ // The real hazard is scope: "full_page", where the audit scrolls and
1439
+ // rects captured at different offsets land in one list against a
1440
+ // single viewport image; anything above the captured viewport then
1441
+ // carries a negative y and would render off-canvas. Say which space
1442
+ // these are in and flag the ones that fall outside the image rather
1443
+ // than emitting silent negatives. (2026-07-28)
1444
+ // Rects are now DOCUMENT coordinates (scroll offset added at capture),
1445
+ // so they are stable across the scrolling that scope:"full_page" does.
1446
+ // They were previously labelled "viewport" while holding values from
1447
+ // several scroll positions at once — y:-274 and y:3951 in one list.
1448
+ coordinateSpace: "document",
1449
+ // The document-space region the screenshot actually covers, so a
1450
+ // consumer can map a rect onto the image without guessing. A
1451
+ // full_page capture starts at the document origin; a viewport
1452
+ // capture starts wherever the page was scrolled to.
1453
+ //
1454
+ // These two fields exist because captureHeight alone was not
1455
+ // enough: it reported documentHeight while the image was the
1456
+ // viewport, and the overlay scaled 5363px of coordinates onto an
1457
+ // 800px picture. Origin plus size is the whole answer, and it is
1458
+ // read from the PNG header rather than measured separately, so it
1459
+ // cannot disagree with the picture it describes. (2026-07-31)
1460
+ captureOrigin: scope === "full_page"
1461
+ ? { x: 0, y: 0 }
1462
+ : (r.captureScroll ?? { x: 0, y: 0 }),
1463
+ captureSize: r.screenshotSize,
1464
+ // Height of the captured image in document space. A viewport capture
1465
+ // covers only the scroll position it was taken at; a full_page capture
1466
+ // covers the document. Without this the bounds test compared document
1467
+ // coordinates against a viewport height and flagged EVERY rect,
1468
+ // including ones plainly inside the image. (2026-07-29)
1469
+ // The height of the image that exists, preferred over the
1470
+ // separately-measured documentHeight. A page that lazy-loads
1471
+ // during capture makes the two disagree, and the picture is the
1472
+ // one the boxes are drawn on.
1473
+ captureHeight: r.screenshotSize?.height
1474
+ ?? (scope === "full_page" ? (r.documentHeight ?? undefined) : r.viewportSize?.height),
1475
+ barrierRects: r.barriers?.filter((b) => b.rect && b.rect.width > 0 && b.rect.height > 0).map((b) => {
1476
+ // Bounds tested in IMAGE space: document rect minus capture
1477
+ // origin, against the real image size. Testing document
1478
+ // coordinates against documentHeight passed every rect as
1479
+ // "inside" even when the image showed only the top 800px, so
1480
+ // nothing was ever listed as off-capture and the overlay
1481
+ // claimed coverage it did not have.
1482
+ const origin = scope === "full_page"
1483
+ ? { x: 0, y: 0 }
1484
+ : (r.captureScroll ?? { x: 0, y: 0 });
1485
+ const size = r.screenshotSize
1486
+ ?? { width: r.viewportSize?.width ?? Infinity,
1487
+ height: scope === "full_page" ? (r.documentHeight ?? Infinity) : (r.viewportSize?.height ?? Infinity) };
1488
+ const ix = b.rect ? b.rect.x - origin.x : 0;
1489
+ const iy = b.rect ? b.rect.y - origin.y : 0;
1490
+ const outside = !!b.rect && (iy + b.rect.height < 0 || ix + b.rect.width < 0 ||
1491
+ iy > size.height || ix > size.width);
1492
+ // The map is coloured by the SAME grade the findings list is
1493
+ // ranked by. It previously carried only the raw WCAG severity
1494
+ // while the list below it used severityForPersona, so on a
1495
+ // weighted audit one barrier was orange on the map and red in
1496
+ // the list -- two scales, no label saying which was which.
1497
+ // (2026-07-31)
1498
+ const { weight } = barrierWeightFor(r.persona, String(b.type ?? ""), b.wcagCriteria);
1499
+ const { severity: weighted } = weightedSeverity(String(b.severity ?? "minor"), weight);
1500
+ // Which findings-list entry this box belongs to. Boxes are
1501
+ // per-element and findings are grouped by type+criterion, so
1502
+ // ten boxes can map to five findings; without the link there is
1503
+ // no way to tell which box the list is talking about.
1504
+ const gk = `${b.type}|${weightKeyFor(String(b.type ?? ""), b.wcagCriteria) ?? ""}`;
1505
+ const findingIndex = findingKeys.indexOf(gk);
1506
+ return {
1507
+ type: b.type, severity: b.severity, element: b.element,
1508
+ severityForPersona: weighted,
1509
+ ...(findingIndex >= 0 ? { finding: findingIndex + 1 } : {}),
1510
+ description: b.description, rect: b.rect,
1511
+ wcag: b.wcagCriteria,
1512
+ ...(outside ? { outsideScreenshot: true } : {}),
1513
+ };
1514
+ }),
1515
+ // Keep entries that carry EITHER the inline image or a path to it.
1516
+ // Filtering on `screenshot` alone silently dropped every entry once
1517
+ // the default stopped inlining base64, taking the barrierRects with
1518
+ // them. (2026-07-29)
1519
+ })).filter((s) => s.screenshot || s.screenshotPath),
1520
+ };
1521
+ // Add guidance if we limited the request
1522
+ if (wasLimited) {
1523
+ 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` : ""}`;
1524
+ response.remainingPersonas = remainingPersonas;
1525
+ }
1526
+ // Auto-save handled by tier-gate wrapper.
1527
+ //
1528
+ // The UI resource is APPENDED to the text block, never substituted for
1529
+ // it: content[0] stays the same JSON every CLI and CI caller already
1530
+ // parses. `generateEmpathyAuditHtmlReport` is the report the --html flag
1531
+ // has always produced — self-contained, no remote refs — so this is a
1532
+ // transport seam rather than new UI. If it cannot be produced safely,
1533
+ // htmlUiResource returns null and the response is exactly as before.
1534
+ // (2026-07-29)
1535
+ const content = [
1536
+ { type: "text", text: JSON.stringify(response, null, 2) },
1537
+ ];
1538
+ // No UI resource is attached here any more.
1539
+ //
1540
+ // Both mcp-ui attachments are retired: the whole-report rawHtml and the
1541
+ // barrier overlay. Both embedded rendered HTML in the result, which
1542
+ // costs payload against the host's ~150k cap, and the overlay's boxes
1543
+ // were drawn over an img on cbrowser.ai that the widget sandbox will
1544
+ // not load -- outlines over empty space. Both are now served by
1545
+ // ui://cbrowser/empathy, declared on the tool, where the host fetches
1546
+ // the view and the overlay pulls its screenshot through artifact_fetch.
1547
+ //
1548
+ // uiResource is still accepted as an argument so existing callers do
1549
+ // not break; it simply no longer changes the response.
1550
+ return { content };
1551
+ }
1552
+ catch (error) {
1553
+ // Categorize the error for better user feedback
1554
+ const errorMessage = error instanceof Error ? error.message : String(error);
1555
+ const errorStack = error instanceof Error ? error.stack : undefined;
1556
+ // Determine error type for actionable feedback
1557
+ let errorType = "unknown";
1558
+ let suggestion = "Please try again or contact support if the issue persists.";
1559
+ if (errorMessage.includes("timeout") || errorMessage.includes("Timeout")) {
1560
+ errorType = "timeout";
1561
+ suggestion = "The page took too long to load. Try increasing maxTime or testing a faster page.";
1562
+ }
1563
+ else if (errorMessage.includes("net::") || errorMessage.includes("DNS") || errorMessage.includes("ERR_")) {
1564
+ errorType = "network";
1565
+ suggestion = "Unable to reach the URL. Check the URL is valid and accessible.";
1566
+ }
1567
+ else if (errorMessage.includes("blocked") || errorMessage.includes("403") || errorMessage.includes("captcha")) {
1568
+ errorType = "bot-detection";
1569
+ suggestion = "The site may be blocking automation. Try with a different URL.";
1570
+ }
1571
+ else if (errorMessage.includes("chromium") || errorMessage.includes("browser")) {
1572
+ errorType = "browser";
1573
+ suggestion = "Browser automation error. The server may need to restart.";
1574
+ }
1575
+ // Log the full error for debugging
1576
+ console.error(`[empathy_audit] Error: ${errorMessage}`);
1577
+ if (errorStack) {
1578
+ console.error(`[empathy_audit] Stack: ${errorStack}`);
1579
+ }
1580
+ // Return a structured error response
1581
+ return {
1582
+ content: [
1583
+ {
1584
+ type: "text",
1585
+ text: JSON.stringify({
1586
+ error: true,
1587
+ errorType,
1588
+ message: errorMessage,
1589
+ suggestion,
1590
+ url,
1591
+ goal,
1592
+ disabilities: disabilities || "all",
1593
+ }, null, 2),
1594
+ },
1595
+ ],
1596
+ isError: true,
1597
+ };
1598
+ }
1599
+ });
1600
+ }
1319
1601
  //# sourceMappingURL=audit-tools.js.map