cbrowser 18.78.0 → 18.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
  2. package/dist/analysis/accessibility-empathy.js +395 -66
  3. package/dist/analysis/accessibility-empathy.js.map +1 -1
  4. package/dist/analysis/agent-ready-audit.d.ts.map +1 -1
  5. package/dist/analysis/agent-ready-audit.js +78 -0
  6. package/dist/analysis/agent-ready-audit.js.map +1 -1
  7. package/dist/analysis/page-understanding.d.ts +139 -1
  8. package/dist/analysis/page-understanding.d.ts.map +1 -1
  9. package/dist/analysis/page-understanding.js +179 -12
  10. package/dist/analysis/page-understanding.js.map +1 -1
  11. package/dist/artifact-store.d.ts +19 -5
  12. package/dist/artifact-store.d.ts.map +1 -1
  13. package/dist/artifact-store.js +41 -11
  14. package/dist/artifact-store.js.map +1 -1
  15. package/dist/browser.d.ts +9 -0
  16. package/dist/browser.d.ts.map +1 -1
  17. package/dist/browser.js +81 -5
  18. package/dist/browser.js.map +1 -1
  19. package/dist/cli.js +55 -10
  20. package/dist/cli.js.map +1 -1
  21. package/dist/cognitive/index.d.ts.map +1 -1
  22. package/dist/cognitive/index.js +3 -2
  23. package/dist/cognitive/index.js.map +1 -1
  24. package/dist/config.d.ts +57 -0
  25. package/dist/config.d.ts.map +1 -1
  26. package/dist/config.js +234 -7
  27. package/dist/config.js.map +1 -1
  28. package/dist/index.js +1 -1
  29. package/dist/index.js.map +1 -1
  30. package/dist/mcp-server-remote.d.ts.map +1 -1
  31. package/dist/mcp-server-remote.js +108 -4
  32. package/dist/mcp-server-remote.js.map +1 -1
  33. package/dist/mcp-server.d.ts.map +1 -1
  34. package/dist/mcp-server.js +62 -938
  35. package/dist/mcp-server.js.map +1 -1
  36. package/dist/mcp-tools/account-personas.d.ts +41 -0
  37. package/dist/mcp-tools/account-personas.d.ts.map +1 -0
  38. package/dist/mcp-tools/account-personas.js +98 -0
  39. package/dist/mcp-tools/account-personas.js.map +1 -0
  40. package/dist/mcp-tools/base/analysis-tools.d.ts.map +1 -1
  41. package/dist/mcp-tools/base/analysis-tools.js +12 -2
  42. package/dist/mcp-tools/base/analysis-tools.js.map +1 -1
  43. package/dist/mcp-tools/base/artifact-tools.d.ts +3 -0
  44. package/dist/mcp-tools/base/artifact-tools.d.ts.map +1 -0
  45. package/dist/mcp-tools/base/artifact-tools.js +78 -0
  46. package/dist/mcp-tools/base/artifact-tools.js.map +1 -0
  47. package/dist/mcp-tools/base/audit-tools.d.ts +11 -0
  48. package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
  49. package/dist/mcp-tools/base/audit-tools.js +595 -340
  50. package/dist/mcp-tools/base/audit-tools.js.map +1 -1
  51. package/dist/mcp-tools/base/browser-management-tools.d.ts.map +1 -1
  52. package/dist/mcp-tools/base/browser-management-tools.js +18 -0
  53. package/dist/mcp-tools/base/browser-management-tools.js.map +1 -1
  54. package/dist/mcp-tools/base/bug-analysis-tools.d.ts.map +1 -1
  55. package/dist/mcp-tools/base/bug-analysis-tools.js +59 -3
  56. package/dist/mcp-tools/base/bug-analysis-tools.js.map +1 -1
  57. package/dist/mcp-tools/base/capture-tools.d.ts +37 -0
  58. package/dist/mcp-tools/base/capture-tools.d.ts.map +1 -1
  59. package/dist/mcp-tools/base/capture-tools.js +267 -6
  60. package/dist/mcp-tools/base/capture-tools.js.map +1 -1
  61. package/dist/mcp-tools/base/cognitive-tools.d.ts +12 -0
  62. package/dist/mcp-tools/base/cognitive-tools.d.ts.map +1 -1
  63. package/dist/mcp-tools/base/cognitive-tools.js +63 -15
  64. package/dist/mcp-tools/base/cognitive-tools.js.map +1 -1
  65. package/dist/mcp-tools/base/extraction-tools.d.ts.map +1 -1
  66. package/dist/mcp-tools/base/extraction-tools.js +33 -2
  67. package/dist/mcp-tools/base/extraction-tools.js.map +1 -1
  68. package/dist/mcp-tools/base/index.d.ts +2 -0
  69. package/dist/mcp-tools/base/index.d.ts.map +1 -1
  70. package/dist/mcp-tools/base/index.js +9 -0
  71. package/dist/mcp-tools/base/index.js.map +1 -1
  72. package/dist/mcp-tools/base/marketing-tools.js +3 -3
  73. package/dist/mcp-tools/base/marketing-tools.js.map +1 -1
  74. package/dist/mcp-tools/base/persona-comparison-tools.d.ts.map +1 -1
  75. package/dist/mcp-tools/base/persona-comparison-tools.js +529 -40
  76. package/dist/mcp-tools/base/persona-comparison-tools.js.map +1 -1
  77. package/dist/mcp-tools/base/site-knowledge-tools.d.ts.map +1 -1
  78. package/dist/mcp-tools/base/site-knowledge-tools.js +36 -3
  79. package/dist/mcp-tools/base/site-knowledge-tools.js.map +1 -1
  80. package/dist/mcp-tools/base/values-tools.d.ts +113 -0
  81. package/dist/mcp-tools/base/values-tools.d.ts.map +1 -1
  82. package/dist/mcp-tools/base/values-tools.js +868 -78
  83. package/dist/mcp-tools/base/values-tools.js.map +1 -1
  84. package/dist/mcp-tools/base/visual-testing-tools.d.ts.map +1 -1
  85. package/dist/mcp-tools/base/visual-testing-tools.js +130 -10
  86. package/dist/mcp-tools/base/visual-testing-tools.js.map +1 -1
  87. package/dist/mcp-tools/index.d.ts +3 -1
  88. package/dist/mcp-tools/index.d.ts.map +1 -1
  89. package/dist/mcp-tools/index.js +3 -28
  90. package/dist/mcp-tools/index.js.map +1 -1
  91. package/dist/mcp-tools/persona-creation-tools.d.ts.map +1 -1
  92. package/dist/mcp-tools/persona-creation-tools.js +363 -20
  93. package/dist/mcp-tools/persona-creation-tools.js.map +1 -1
  94. package/dist/mcp-tools/persona-lifecycle-tools.d.ts +3 -0
  95. package/dist/mcp-tools/persona-lifecycle-tools.d.ts.map +1 -0
  96. package/dist/mcp-tools/persona-lifecycle-tools.js +406 -0
  97. package/dist/mcp-tools/persona-lifecycle-tools.js.map +1 -0
  98. package/dist/mcp-tools/screenshot-utils.d.ts.map +1 -1
  99. package/dist/mcp-tools/screenshot-utils.js +56 -10
  100. package/dist/mcp-tools/screenshot-utils.js.map +1 -1
  101. package/dist/mcp-tools/tier-gate.js +2 -2
  102. package/dist/mcp-tools/tier-gate.js.map +1 -1
  103. package/dist/mcp-tools/tool-categories.d.ts.map +1 -1
  104. package/dist/mcp-tools/tool-categories.js +4 -1
  105. package/dist/mcp-tools/tool-categories.js.map +1 -1
  106. package/dist/mcp-tools/ui-resources.d.ts +167 -0
  107. package/dist/mcp-tools/ui-resources.d.ts.map +1 -0
  108. package/dist/mcp-tools/ui-resources.js +707 -0
  109. package/dist/mcp-tools/ui-resources.js.map +1 -0
  110. package/dist/mcp-tools/widget-kit.d.ts +299 -0
  111. package/dist/mcp-tools/widget-kit.d.ts.map +1 -0
  112. package/dist/mcp-tools/widget-kit.js +2034 -0
  113. package/dist/mcp-tools/widget-kit.js.map +1 -0
  114. package/dist/mcp-ui-resources.d.ts.map +1 -1
  115. package/dist/mcp-ui-resources.js +14 -4
  116. package/dist/mcp-ui-resources.js.map +1 -1
  117. package/dist/persona-questionnaire.d.ts +69 -1
  118. package/dist/persona-questionnaire.d.ts.map +1 -1
  119. package/dist/persona-questionnaire.js +177 -76
  120. package/dist/persona-questionnaire.js.map +1 -1
  121. package/dist/persona-scope.d.ts +20 -0
  122. package/dist/persona-scope.d.ts.map +1 -0
  123. package/dist/persona-scope.js +59 -0
  124. package/dist/persona-scope.js.map +1 -0
  125. package/dist/personas.d.ts +51 -17
  126. package/dist/personas.d.ts.map +1 -1
  127. package/dist/personas.js +947 -286
  128. package/dist/personas.js.map +1 -1
  129. package/dist/recording/capture-player.d.ts +68 -0
  130. package/dist/recording/capture-player.d.ts.map +1 -0
  131. package/dist/recording/capture-player.js +298 -0
  132. package/dist/recording/capture-player.js.map +1 -0
  133. package/dist/recording/engine.d.ts +52 -0
  134. package/dist/recording/engine.d.ts.map +1 -1
  135. package/dist/recording/engine.js +241 -3
  136. package/dist/recording/engine.js.map +1 -1
  137. package/dist/recording/inline-player.d.ts +39 -0
  138. package/dist/recording/inline-player.d.ts.map +1 -0
  139. package/dist/recording/inline-player.js +272 -0
  140. package/dist/recording/inline-player.js.map +1 -0
  141. package/dist/recording/saliency-overlay.d.ts +133 -0
  142. package/dist/recording/saliency-overlay.d.ts.map +1 -0
  143. package/dist/recording/saliency-overlay.js +360 -0
  144. package/dist/recording/saliency-overlay.js.map +1 -0
  145. package/dist/recording/types.d.ts +32 -0
  146. package/dist/recording/types.d.ts.map +1 -1
  147. package/dist/recording/types.js +46 -0
  148. package/dist/recording/types.js.map +1 -1
  149. package/dist/site-model/manager.d.ts +20 -0
  150. package/dist/site-model/manager.d.ts.map +1 -1
  151. package/dist/site-model/manager.js +52 -0
  152. package/dist/site-model/manager.js.map +1 -1
  153. package/dist/trait-reference.d.ts +26 -2
  154. package/dist/trait-reference.d.ts.map +1 -1
  155. package/dist/trait-reference.js +129 -19
  156. package/dist/trait-reference.js.map +1 -1
  157. package/dist/types.d.ts +105 -18
  158. package/dist/types.d.ts.map +1 -1
  159. package/dist/types.js.map +1 -1
  160. package/dist/url-resolution.d.ts +58 -0
  161. package/dist/url-resolution.d.ts.map +1 -0
  162. package/dist/url-resolution.js +143 -0
  163. package/dist/url-resolution.js.map +1 -0
  164. package/dist/values/big-five-inference.d.ts +121 -0
  165. package/dist/values/big-five-inference.d.ts.map +1 -0
  166. package/dist/values/big-five-inference.js +241 -0
  167. package/dist/values/big-five-inference.js.map +1 -0
  168. package/dist/values/big-five-values.d.ts +76 -0
  169. package/dist/values/big-five-values.d.ts.map +1 -0
  170. package/dist/values/big-five-values.js +152 -0
  171. package/dist/values/big-five-values.js.map +1 -0
  172. package/dist/values/index.d.ts +1 -1
  173. package/dist/values/index.d.ts.map +1 -1
  174. package/dist/values/index.js +1 -1
  175. package/dist/values/index.js.map +1 -1
  176. package/dist/values/persona-completeness.d.ts +91 -0
  177. package/dist/values/persona-completeness.d.ts.map +1 -0
  178. package/dist/values/persona-completeness.js +114 -0
  179. package/dist/values/persona-completeness.js.map +1 -0
  180. package/dist/values/persona-diff.d.ts +50 -0
  181. package/dist/values/persona-diff.d.ts.map +1 -0
  182. package/dist/values/persona-diff.js +126 -0
  183. package/dist/values/persona-diff.js.map +1 -0
  184. package/dist/values/persona-values.d.ts +16 -0
  185. package/dist/values/persona-values.d.ts.map +1 -1
  186. package/dist/values/persona-values.js +33 -0
  187. package/dist/values/persona-values.js.map +1 -1
  188. package/dist/values/schwartz-values.d.ts +9 -1
  189. package/dist/values/schwartz-values.d.ts.map +1 -1
  190. package/dist/values/schwartz-values.js +10 -2
  191. package/dist/values/schwartz-values.js.map +1 -1
  192. package/dist/values/value-mappings.d.ts +41 -2
  193. package/dist/values/value-mappings.d.ts.map +1 -1
  194. package/dist/values/value-mappings.js +65 -8
  195. package/dist/values/value-mappings.js.map +1 -1
  196. package/dist/visual/attention-quality.d.ts +27 -0
  197. package/dist/visual/attention-quality.d.ts.map +1 -1
  198. package/dist/visual/attention-quality.js +164 -2
  199. package/dist/visual/attention-quality.js.map +1 -1
  200. package/dist/visual/attention-transport.d.ts +41 -2
  201. package/dist/visual/attention-transport.d.ts.map +1 -1
  202. package/dist/visual/attention-transport.js +98 -26
  203. package/dist/visual/attention-transport.js.map +1 -1
  204. package/dist/visual/barrier-overlay-html.d.ts +10 -0
  205. package/dist/visual/barrier-overlay-html.d.ts.map +1 -1
  206. package/dist/visual/barrier-overlay-html.js.map +1 -1
  207. package/dist/visual/cognitive-models.d.ts +109 -13
  208. package/dist/visual/cognitive-models.d.ts.map +1 -1
  209. package/dist/visual/cognitive-models.js +166 -3
  210. package/dist/visual/cognitive-models.js.map +1 -1
  211. package/dist/visual/cognitive-transport-chain.d.ts +149 -5
  212. package/dist/visual/cognitive-transport-chain.d.ts.map +1 -1
  213. package/dist/visual/cognitive-transport-chain.js +558 -30
  214. package/dist/visual/cognitive-transport-chain.js.map +1 -1
  215. package/dist/visual/cognitive-transport.d.ts +45 -3
  216. package/dist/visual/cognitive-transport.d.ts.map +1 -1
  217. package/dist/visual/cognitive-transport.js +126 -12
  218. package/dist/visual/cognitive-transport.js.map +1 -1
  219. package/dist/visual/fixation-corpus.d.ts +52 -0
  220. package/dist/visual/fixation-corpus.d.ts.map +1 -1
  221. package/dist/visual/fixation-corpus.js +95 -0
  222. package/dist/visual/fixation-corpus.js.map +1 -1
  223. package/dist/visual/llm-relevance.d.ts +193 -0
  224. package/dist/visual/llm-relevance.d.ts.map +1 -0
  225. package/dist/visual/llm-relevance.js +480 -0
  226. package/dist/visual/llm-relevance.js.map +1 -0
  227. package/dist/visual/perceptual-transport.d.ts +60 -0
  228. package/dist/visual/perceptual-transport.d.ts.map +1 -1
  229. package/dist/visual/perceptual-transport.js +228 -19
  230. package/dist/visual/perceptual-transport.js.map +1 -1
  231. package/dist/visual/persona-context.d.ts +23 -0
  232. package/dist/visual/persona-context.d.ts.map +1 -0
  233. package/dist/visual/persona-context.js +44 -0
  234. package/dist/visual/persona-context.js.map +1 -0
  235. package/package.json +2 -1
@@ -5,10 +5,11 @@
5
5
  * @license MIT
6
6
  */
7
7
  import { z } from "zod";
8
+ import { resolveValuesForPersona } from "../../values/persona-values.js";
8
9
  import { writeArtifact } from "../../artifact-store.js";
9
10
  import { comparePersonas } from "../../analysis/index.js";
10
11
  import { isApiKeyConfigured } from "../../cognitive/index.js";
11
- import { getAnyPersona, getCognitiveProfile, createCognitivePersona, isAgentPersonaObject, BUILTIN_PERSONAS, ACCESSIBILITY_PERSONAS, EMOTIONAL_PERSONAS, } from "../../personas.js";
12
+ import { getAnyPersona, resolvePersonaOrThrow, getCognitiveProfile, createCognitivePersona, isAgentPersonaObject, BUILTIN_PERSONAS, ACCESSIBILITY_PERSONAS, EMOTIONAL_PERSONAS, } from "../../personas.js";
12
13
  import { buildOTCognitiveProfile as buildOTProfile, cognitiveDistance, cognitiveBarycenter, cognitiveGeodesic, cognitiveDistanceMatrix, selectMaxCoveragePersonas, estimateCognitiveLoad, } from "../../visual/cognitive-transport.js";
13
14
  let distanceReference = null;
14
15
  function getDistanceReference() {
@@ -160,6 +161,9 @@ Example:
160
161
  const existingPersona = getAnyPersona(personaName);
161
162
  let personaObj;
162
163
  if (!existingPersona) {
164
+ // Unknown name: refuse. A fabricated persona here produces a complete
165
+ // per-persona assessment nobody can distinguish from a real one.
166
+ resolvePersonaOrThrow(personaName);
163
167
  personaObj = createCognitivePersona(personaName, personaName, {});
164
168
  }
165
169
  else {
@@ -571,10 +575,16 @@ Begin with the first persona: ${personas[0]}
571
575
  });
572
576
  // ── Cognitive Effort (Full COT) ──
573
577
  server.registerTool("cognitive_effort", {
578
+ _meta: { ui: { resourceUri: "ui://cbrowser/effort" } },
574
579
  title: "Cognitive Effort Analysis",
575
- description: "Compute total cognitive effort for a persona to use a page. Uses the 6-layer Sequential Transport Chain: saliency → cognitive load → decision → motor → frustration → readability. IMPORTANT: High-familiarity personas (power-user, confident-user) require site knowledge. If the user asks for these personas, first check if site knowledge exists by running site_model_status. If no site knowledge exists, either (1) ask the user if they want to build it first by navigating the site, or (2) warn them that results will treat the persona as a first-time visitor. The tool returns a familiarityWarning when site knowledge is missing.",
580
+ description: "Compute total cognitive effort for a persona to use a page. MEASURES THE VIEWPORT BY DEFAULT (above the fold); pass scope='full_page' to measure the whole page. Uses the 8-layer Sequential Transport Chain: saliency → cognitive load → decision → motor (target acquisition) → motor procedure (executing a sequence) → frustration → readability (decoding) → reading attention (holding the line, re-reading). IMPORTANT: High-familiarity personas (power-user, confident-user) require site knowledge. If the user asks for these personas, first check if site knowledge exists by running site_model_status. If no site knowledge exists, either (1) ask the user if they want to build it first by navigating the site, or (2) warn them that results will treat the persona as a first-time visitor. The tool returns a familiarityWarning when site knowledge is missing.",
576
581
  inputSchema: {
577
582
  url: z.string().url().describe("URL to analyze"),
583
+ // Per RUN, not per persona. A persona is familiar with one site and new
584
+ // to another, so this only has an answer once a URL is named. The
585
+ // persona's stored value, where one exists, is the fallback. (2026-08-01)
586
+ siteFamiliarity: z.number().min(0).max(1).optional()
587
+ .describe("How familiar this persona is with THIS url: 0 first visit, 1 daily user. Set it per run — familiarity is a persona-site pair, not a disposition. Supplying it also skips the site-knowledge downgrade, which exists only to stop a stored value asserting experience of a site nobody has crawled."),
578
588
  persona: z.string().describe("Persona name. WORKFLOW: For power-user, confident-user, or any persona the user describes as 'experienced' — check site_model_status first. If site knowledge exists, ask the user: 'Site knowledge exists for this domain. Should I use it to simulate an experienced user, or test as a first-time visitor?' If no site knowledge exists, warn: 'No site knowledge for this domain. power-user will be tested as a first-time visitor. Run page_understand first to build site knowledge.'"),
579
589
  _browserToken: z.string().optional().describe("Browser session token"),
580
590
  userLocation: z.string().optional().describe("User's approximate location (e.g., 'Denver, Colorado, US')"),
@@ -584,6 +594,7 @@ Begin with the first persona: ${personas[0]}
584
594
  useValues: z.boolean().optional().default(false).describe("Enable motivational value modulation (Schwartz values). When true, persona values modulate saliency maps, decision costs, and frustration costs. Default: false (trait-only mode)."),
585
595
  waitAfterLoad: z.number().optional().describe("Extra ms to wait after page loads (e.g., 3000 for sites with client-side translation)"),
586
596
  waitForSelector: z.string().optional().describe("CSS selector to wait for after load (e.g., '[data-translated]')"),
597
+ scope: z.enum(["viewport", "full_page", "sequential"]).optional().default("viewport").describe("What to measure: 'viewport' (first impression, above-the-fold — default), 'full_page' (scroll the whole page, count every element), or 'sequential' (score each screen on its own content, carrying capacity forward, and report where people stop). Viewport and full_page match empathy_audit's parameter. 'sequential' answers a different question from either: not 'what would the whole page cost' but 'how far do they get'. NOTE: every published CTC number predates this parameter and is a viewport measurement."),
587
598
  },
588
599
  annotations: {
589
600
  title: "Cognitive Effort Analysis",
@@ -592,7 +603,7 @@ Begin with the first persona: ${personas[0]}
592
603
  idempotentHint: true,
593
604
  openWorldHint: true,
594
605
  },
595
- }, async ({ url, persona: personaName, _browserToken, userLocation, userTimezone, userLanguage, proxy, useValues, waitAfterLoad, waitForSelector }) => {
606
+ }, async ({ url, persona: personaName, siteFamiliarity, _browserToken, userLocation, userTimezone, userLanguage, proxy, useValues, waitAfterLoad, waitForSelector, scope }) => {
596
607
  try {
597
608
  // Get browser
598
609
  let b;
@@ -674,14 +685,15 @@ Begin with the first persona: ${personas[0]}
674
685
  };
675
686
  }
676
687
  // Get persona
688
+ // Refuses an unknown name rather than inventing one. An agent persona is
689
+ // still synthesised -- it exists, it just has no cognitive profile of its
690
+ // own -- but a name that resolves to nothing stops the run.
677
691
  const existingPersona = getAnyPersona(personaName);
678
- let personaObj;
679
- if (!existingPersona || isAgentPersonaObject(existingPersona)) {
680
- personaObj = createCognitivePersona(personaName, personaName, {});
681
- }
682
- else {
683
- personaObj = existingPersona;
684
- }
692
+ if (!existingPersona)
693
+ resolvePersonaOrThrow(personaName);
694
+ const personaObj = isAgentPersonaObject(existingPersona)
695
+ ? createCognitivePersona(personaName, personaName, {})
696
+ : existingPersona;
685
697
  // v18.61.0: siteFamiliarity is a binary gate based on site knowledge
686
698
  // Has site knowledge → persona keeps its familiarity (maxed to 1.0 for experts)
687
699
  // No site knowledge → forced to 0.0 (first visit)
@@ -698,7 +710,15 @@ Begin with the first persona: ${personas[0]}
698
710
  hasSiteKnowledge = !!(stats && stats.navigationNodes > 0);
699
711
  }
700
712
  catch { }
701
- if (hasSiteKnowledge) {
713
+ // A caller who states familiarity for THIS run has answered the question
714
+ // the downgrade exists to answer. The gate guards against a value STORED
715
+ // on a persona claiming experience of a site nobody has crawled -- it was
716
+ // never meant to overrule someone naming the pair directly.
717
+ const familiarityFromCaller = typeof siteFamiliarity === "number";
718
+ if (familiarityFromCaller) {
719
+ traits.siteFamiliarity = siteFamiliarity;
720
+ }
721
+ else if (hasSiteKnowledge) {
702
722
  // Site knowledge exists — high-familiarity personas get max familiarity
703
723
  if (requestedFamiliarity > 0.5)
704
724
  traits.siteFamiliarity = 1.0;
@@ -707,22 +727,96 @@ Begin with the first persona: ${personas[0]}
707
727
  // No site knowledge — everyone is a first-time visitor
708
728
  traits.siteFamiliarity = 0.0;
709
729
  if (requestedFamiliarity > 0.5) {
710
- familiarityWarning = `"${personaName}" has siteFamiliarity=${requestedFamiliarity.toFixed(1)} but no site knowledge exists. Downgraded to 0.0 (first visit). To build site knowledge: navigate the site (navigate + click builds it automatically), or run page_understand for deeper analysis. Then re-run this tool.`;
730
+ familiarityWarning = `"${personaName}" has siteFamiliarity=${requestedFamiliarity.toFixed(1)} stored on the persona but no site knowledge exists for this domain. Downgraded to 0.0 (first visit). Familiarity is a persona-site pair: pass siteFamiliarity on this call to state it for this run, or build knowledge by navigating the site (page_understand goes deeper).`;
711
731
  }
712
732
  }
733
+ // Bridge the formal models into the trait vector BEFORE the chain runs.
734
+ //
735
+ // getReadingProfile and getPointingProfile already model this persona's
736
+ // decoding ability and Fitts pointing -- visual span, phonological
737
+ // decoding, throughput, endpoint dispersion -- and until now the chain
738
+ // never saw any of it. Its readability layer read `readingTendency`
739
+ // (a disposition: how much text someone reads) and its motor layer read
740
+ // patience and proceduralFluency, so a dyslexic persona scored LOWER
741
+ // readability cost than an ADHD persona on a text-dense page while the
742
+ // same response reported them at 157 WPM against 204.
743
+ //
744
+ // Injected here rather than stored on the persona: these are derived from
745
+ // the persona's profile, so storing them would create a second copy that
746
+ // can drift from the model that produces it. (2026-08-02)
747
+ let pointingProfileForResponse;
748
+ try {
749
+ const { getReadingProfile, getPointingProfile, readingCapacityOf, motorCapacityOf, sustainedAttentionOf } = await import("../../visual/cognitive-models.js");
750
+ // accessibilityTraits passed so an author's STATED reading capacity is
751
+ // honoured; without it the name lookup wins and the fields are inert.
752
+ const accTraits = personaObj.accessibilityTraits;
753
+ traits.readingCapacity = readingCapacityOf(getReadingProfile({ name: personaName, traits, accessibilityTraits: accTraits }));
754
+ const _pp = getPointingProfile({ name: personaName, traits });
755
+ pointingProfileForResponse = _pp;
756
+ traits.motorCapacity = motorCapacityOf(_pp);
757
+ // The attentional half of reading. Unlike the other two this one has no
758
+ // formal model behind it -- attentionSpan is stated directly, and the
759
+ // fallback is interruptRecovery -- so the resolver is the whole bridge.
760
+ traits.sustainedAttention = sustainedAttentionOf({ traits, accessibilityTraits: accTraits });
761
+ }
762
+ catch (e) {
763
+ // Left absent rather than defaulted: a missing capacity is visible as a
764
+ // gap, a defaulted one silently reads as average.
765
+ console.debug(`[cognitive_effort] capacity bridge unavailable: ${e.message}`);
766
+ }
713
767
  // Build OT profile (with potentially adjusted siteFamiliarity)
714
768
  const otProfile = buildOTProfile(personaName, traits);
715
769
  // Extract page metrics
716
770
  const { extractPageMetrics } = await import("../../visual/cognitive-transport.js");
717
- const pageMetrics = await extractPageMetrics(page);
771
+ const measureScope = scope ?? "viewport";
772
+ // Sequential scores each screen on its own content; the aggregate path
773
+ // below still runs so every existing field keeps its meaning, measured at
774
+ // full_page since that is the comparable whole-document number.
775
+ const pageMetrics = await extractPageMetrics(page, measureScope === "sequential" ? "full_page" : measureScope);
718
776
  // Compute full COT
719
777
  const { computeDemandDistribution, computeSequentialCTC } = await import("../../visual/cognitive-transport-chain.js");
720
778
  const demand = computeDemandDistribution(pageMetrics);
779
+ // SEQUENTIAL PASS. Runs before the aggregate so a failure here degrades to
780
+ // the normal answer rather than losing the whole call.
781
+ let sequentialResult;
782
+ if (measureScope === "sequential") {
783
+ try {
784
+ const { extractPerScreenMetrics } = await import("../../visual/cognitive-transport.js");
785
+ const { computeSequentialScrollCTC } = await import("../../visual/cognitive-transport-chain.js");
786
+ const perScreen = await extractPerScreenMetrics(page);
787
+ const seqProfile = buildOTProfile(personaName, traits);
788
+ const seq = computeSequentialScrollCTC(seqProfile, perScreen.map((m) => computeDemandDistribution(m)), { asymmetric: true, interactions: true });
789
+ sequentialResult = {
790
+ screensMeasured: seq.screens.length,
791
+ totalAbandonment: `${Math.round(seq.totalAbandonment * 100)}%`,
792
+ expectedDepthScreens: seq.expectedDepthScreens,
793
+ dropOff: seq.dropOff
794
+ ? { screen: seq.dropOff.screen, layer: seq.dropOff.layer, shareOfSessions: `${Math.round(seq.dropOff.share * 100)}%` }
795
+ : null,
796
+ reachWeightedCTC: seq.reachWeightedCTC,
797
+ curve: seq.screens.map((x) => ({
798
+ screen: x.screen,
799
+ reach: `${Math.round(x.reachProbability * 100)}%`,
800
+ stopHere: `${Math.round(x.stopHazard * 100)}%`,
801
+ cumulativeAbandonment: `${Math.round(x.cumulativeAbandonment * 100)}%`,
802
+ screenCTC: x.screenCTC,
803
+ bottleneck: x.bottleneckLayer,
804
+ })),
805
+ note: "Each screen is measured on ITS OWN content, and capacity carries forward -- screen 7 is scored against what screens 1-6 left, not against a fresh persona. Count-based demands (interactive elements, choices) charge only elements not seen on an earlier screen, so a sticky nav is one decision rather than one per screen; text and visual density are NOT deduplicated, because re-encountering text is re-reading it.",
806
+ differsFrom: "The aggregate `abandonmentRisk` above asks what the whole document would cost if it were all processed. This asks how far they get. They are different questions and will not agree.",
807
+ uncalibrated: "The per-screen hazard uses the same ABANDONMENT_HILL_EXPONENT as the aggregate, which nothing empirical set. At 2 the curve is superlinear, so this model inherits a thumb on the scale.",
808
+ readThisFirst: "TRUST THE SHAPE, NOT THE TOTAL. `totalAbandonment` compounds a per-screen hazard across every screen, so on a long page it saturates toward 100% almost regardless of the page -- measured, elderly-user reached 100% on a 7-screen university homepage. That is the uncalibrated exponent compounding, not a finding about that page. `expectedDepthScreens` and `dropOff` are the numbers to act on: they say how far people get and what stops them, and both depend on the RANKING of per-screen hazards rather than their absolute level, which is the part the exponent least disturbs. `dropOff` is persona-dependent by design and that is not noise: an impatient reader drops on screen one, a patient one survives to whichever screen is genuinely hardest, and the two answers are both correct for their reader.",
809
+ };
810
+ }
811
+ catch (e) {
812
+ sequentialResult = { error: `sequential pass failed: ${e.message}`, note: "The aggregate figures below are unaffected." };
813
+ }
814
+ }
721
815
  // Modulate demand by motivational values (opt-in, default off)
722
816
  if (useValues)
723
817
  try {
724
818
  const { getPersonaValues, registerPersonaValues: regPV2, createPersonaValues: createPV2 } = await import("../../values/index.js");
725
- let pValues = getPersonaValues(personaName);
819
+ let pValues = resolveValuesForPersona(personaName);
726
820
  // Custom persona CMS fallback
727
821
  if (!pValues) {
728
822
  try {
@@ -772,6 +866,14 @@ Begin with the first persona: ${personas[0]}
772
866
  // Also compute motor and readability from formal models
773
867
  let motorResult = null;
774
868
  let readabilityResult = null;
869
+ // Hoisted out of the try below: the readability OVERLAY is drawn later,
870
+ // outside that block, and needs the on-page rectangles the analysis
871
+ // itself does not carry.
872
+ let textBlockGeom = [];
873
+ // Hoisted for the same reason as textBlockGeom: the motor OVERLAY is drawn
874
+ // outside the block that scores these, and it must use the scored list
875
+ // rather than a second query with a different filter.
876
+ let motorGeom = [];
775
877
  try {
776
878
  const { motorAccessibility, readability, getPointingProfile, getReadingProfile } = await import("../../visual/cognitive-models.js");
777
879
  // Get interactive elements for motor analysis — viewport-visible only
@@ -779,23 +881,95 @@ Begin with the first persona: ${personas[0]}
779
881
  const vw = window.innerWidth;
780
882
  const vh = window.innerHeight;
781
883
  const interactive = document.querySelectorAll('a, button, input, select, textarea, [role="button"]');
884
+ // Only elements a POINTING DEVICE could actually hit.
885
+ //
886
+ // The old filter asked for non-zero size and in-viewport, which a
887
+ // visually-hidden skip link passes: the standard implementation is a
888
+ // 1px box with clip: rect(0,0,0,0), so it has non-zero geometry and
889
+ // sits at the top of the page. It was then fed to Fitts, which
890
+ // correctly reported a multi-second movement time at 1% accuracy for
891
+ // a 1px target -- and the tool docked the site's motor accessibility
892
+ // score for implementing WCAG 2.4.1 Bypass Blocks.
893
+ //
894
+ // An accessibility product penalising a site for correct accessibility
895
+ // is the most expensive false positive available here, so the check is
896
+ // now what actually decides a mouse click: elementFromPoint at the
897
+ // element's centre. If the click would land on something else, it is
898
+ // not a pointing target. Cheap style and area checks run first because
899
+ // elementFromPoint forces layout.
900
+ //
901
+ // Skip links ARE interactive for keyboard users. If the motor model is
902
+ // ever extended to keyboard traversal they belong there, with their
903
+ // FOCUSED geometry rather than their hidden geometry. (2026-08-02)
904
+ const MIN_TARGET_PX = 4;
782
905
  return Array.from(interactive).filter((el) => {
783
906
  const rect = el.getBoundingClientRect();
784
- return rect.width > 0 && rect.height > 0 &&
785
- rect.bottom > 0 && rect.top < vh &&
786
- rect.right > 0 && rect.left < vw;
907
+ if (rect.width < MIN_TARGET_PX || rect.height < MIN_TARGET_PX)
908
+ return false;
909
+ if (rect.bottom <= 0 || rect.top >= vh || rect.right <= 0 || rect.left >= vw)
910
+ return false;
911
+ const cs = window.getComputedStyle(el);
912
+ if (cs.visibility === "hidden" || cs.display === "none")
913
+ return false;
914
+ if (parseFloat(cs.opacity || "1") < 0.05)
915
+ return false;
916
+ if (cs.pointerEvents === "none")
917
+ return false;
918
+ // clip / clip-path collapsing the box to nothing is the classic
919
+ // visually-hidden idiom and leaves the bounding rect intact.
920
+ const clip = cs.clip || "";
921
+ if (/rect\(\s*0(px)?[,\s]+0(px)?[,\s]+0(px)?[,\s]+0(px)?\s*\)/.test(clip))
922
+ return false;
923
+ if ((cs.clipPath || "").replace(/\s/g, "") === "inset(50%)")
924
+ return false;
925
+ // The decisive test: would a click at the centre reach this element?
926
+ const x = Math.min(vw - 1, Math.max(0, rect.left + rect.width / 2));
927
+ const y = Math.min(vh - 1, Math.max(0, rect.top + rect.height / 2));
928
+ const hit = document.elementFromPoint(x, y);
929
+ return !!hit && (hit === el || el.contains(hit) || hit.contains(el));
787
930
  }).slice(0, 30).map((el) => {
788
931
  const rect = el.getBoundingClientRect();
789
932
  const cx = vw / 2;
790
933
  const cy = vh / 2;
791
934
  return {
792
- selector: el.tagName.toLowerCase() + (el.id ? `#${el.id}` : ''),
935
+ // Identifying, not just the tag name.
936
+ //
937
+ // This reported barriers as bare "a", so every anchor on the page
938
+ // looked identical and a finding like "an <a> with 1% hit
939
+ // probability" named nothing anyone could go and fix. It also made
940
+ // two runs look like they were measuring the same element when
941
+ // they had surfaced different links -- which is how a slower
942
+ // movement time appeared alongside a HIGHER throughput and read as
943
+ // a model inversion. (2026-08-02)
944
+ selector: (() => {
945
+ const tag = el.tagName.toLowerCase();
946
+ if (el.id)
947
+ return `${tag}#${el.id}`;
948
+ // className via the guarded read: on an SVG element it is an
949
+ // SVGAnimatedString, not a string, and calling .trim() on it
950
+ // throws. There is a class-sweep test for exactly this and it
951
+ // caught this line.
952
+ const rawCls = el.className;
953
+ const clsStr = typeof rawCls === "string"
954
+ ? rawCls
955
+ : String(rawCls?.baseVal ?? "");
956
+ const cls = clsStr.trim() ? `.${clsStr.trim().split(/\s+/)[0]}` : "";
957
+ const label = (el.getAttribute("aria-label") || el.textContent || "").trim().slice(0, 30);
958
+ const sibs = el.parentElement ? Array.from(el.parentElement.children).filter((c) => c.tagName === el.tagName) : [];
959
+ const nth = sibs.length > 1 ? `:nth-of-type(${sibs.indexOf(el) + 1})` : "";
960
+ return `${tag}${cls}${nth}${label ? ` "${label}"` : ""}`;
961
+ })(),
793
962
  width: rect.width,
794
963
  height: rect.height,
964
+ // Carried so the overlay can be drawn from THIS list rather than
965
+ // re-querying: see the note at the overlay site.
966
+ x: rect.x,
967
+ y: rect.y,
795
968
  distance: Math.sqrt((rect.x + rect.width / 2 - cx) ** 2 + (rect.y + rect.height / 2 - cy) ** 2),
796
969
  };
797
970
  });
798
971
  });
972
+ motorGeom = elements.map((e) => ({ selector: e.selector, x: e.x, y: e.y, width: e.width, height: e.height }));
799
973
  motorResult = motorAccessibility(elements, otProfile);
800
974
  // Get text blocks for readability analysis — viewport-visible only
801
975
  const textBlocks = await page.evaluate(() => {
@@ -817,30 +991,85 @@ Begin with the first persona: ${personas[0]}
817
991
  const lineHeight = parseFloat(style.lineHeight) / fontSize || 1.5;
818
992
  const fontFamily = style.fontFamily || 'sans-serif';
819
993
  const isSerif = /serif/i.test(fontFamily) && !/sans-serif/i.test(fontFamily);
820
- blocks.push({ text: text.slice(0, 500), fontSize, lineHeight, fontFamily, isSerif, contrastRatio: 7 });
994
+ // Geometry travels with the block. Without it the readability
995
+ // overlay cannot be drawn at all -- the analysis knew which text was
996
+ // hard and not where any of it was.
997
+ blocks.push({ text: text.slice(0, 500), fontSize, lineHeight, fontFamily, isSerif, contrastRatio: 7,
998
+ x: rect.x, y: rect.y, width: rect.width, height: rect.height });
821
999
  if (blocks.length >= 20)
822
1000
  break;
823
1001
  }
824
1002
  return blocks;
825
1003
  });
1004
+ textBlockGeom = textBlocks.map((b) => ({ x: b.x, y: b.y, width: b.width, height: b.height, text: b.text }));
826
1005
  if (textBlocks.length > 0) {
827
- readabilityResult = readability(textBlocks, otProfile);
1006
+ // otProfile carries name + traits but not accessibilityTraits, so the
1007
+ // stated reading capacity is attached here or the fields stay inert.
1008
+ readabilityResult = readability(textBlocks, {
1009
+ ...otProfile,
1010
+ accessibilityTraits: personaObj.accessibilityTraits,
1011
+ });
828
1012
  }
829
1013
  }
830
1014
  catch { }
831
1015
  // Format response
1016
+ // What was actually measured, taken from the page after navigation rather
1017
+ // than from the argument. The response used to echo the requested URL
1018
+ // while describing a different site: `url: "https://www.ucdenver.edu"` on
1019
+ // an analysis of cudenver.edu, which ucdenver.edu 301s to. `navigate`
1020
+ // already warns on this mismatch; nothing else did.
1021
+ const landedUrl = (() => { try {
1022
+ return page.url();
1023
+ }
1024
+ catch {
1025
+ return url;
1026
+ } })();
1027
+ const hostOf = (u) => { try {
1028
+ return new URL(u).hostname.toLowerCase();
1029
+ }
1030
+ catch {
1031
+ return "";
1032
+ } };
1033
+ const redirected = !!landedUrl && hostOf(landedUrl) !== "" && hostOf(landedUrl) !== hostOf(url);
832
1034
  const response = {
833
1035
  url,
1036
+ ...(redirected ? {
1037
+ resolvedUrl: landedUrl,
1038
+ redirectNote: `Requested ${hostOf(url)} and landed on ${hostOf(landedUrl)}. Every number below describes ${hostOf(landedUrl)}.`,
1039
+ } : { resolvedUrl: landedUrl }),
834
1040
  persona: personaName,
835
1041
  cognitiveTransportCost: {
836
1042
  total: Math.round(result.totalCTC * 1000) / 1000,
837
1043
  additive: Math.round(result.additiveCTC * 1000) / 1000,
838
- sequentialAmplification: Math.round((result.totalCTC / Math.max(0.001, result.additiveCTC)) * 100) / 100,
1044
+ // The un-normalized cost. `total` is a sigmoid of this, so the two
1045
+ // are not interchangeable and any ratio must use `raw`.
1046
+ raw: Math.round(result.rawCTC * 1000) / 1000,
1047
+ // raw / additive — both in layer-cost units, so this is the genuine
1048
+ // contribution of interactions and sequential depletion.
1049
+ chainCoefficient: Math.round((result.rawCTC / Math.max(0.001, result.additiveCTC)) * 1000) / 1000,
1050
+ /**
1051
+ * @deprecated Use chainCoefficient. NOTE ITS VALUE CHANGED: this used
1052
+ * to divide `total` (a sigmoid, 0-1) by `additive` (a raw sum) and
1053
+ * reported ~0.3-0.5, which read as the chain dampening by 60%. That
1054
+ * was a unit error, not a measurement — it was reporting the sigmoid.
1055
+ * Preserving the old number bit-for-bit would preserve a meaningless
1056
+ * one, so this now carries the corrected value.
1057
+ */
1058
+ sequentialAmplification: Math.round((result.rawCTC / Math.max(0.001, result.additiveCTC)) * 1000) / 1000,
1059
+ chainNote: "chainCoefficient = raw / additive, both in layer-cost units. Above 1 means interactions and sequential depletion added cost. Re-measured 1.002-1.036 at EIGHT layers, after `motor` was split into target acquisition and sequence execution (2026-08-03). It was 1.003-1.054 at seven and 1.002-1.019 at six. The range narrowed rather than widening this time: splitting one layer into two halves each layer's per-step cost, so the same total is reached through smaller increments and less of it compounds. Real, and small. Do NOT compute total/additive — total is a sigmoid of raw and the ratio compares different units.",
1060
+ interpretationBasis: "total",
1061
+ interpretationBands: "total < 0.3 comfortable, < 0.6 moderate, < 0.8 struggling, else likely abandon",
839
1062
  },
840
1063
  layers: result.layers.map(l => ({
841
1064
  name: l.name,
842
1065
  cost: Math.round(l.transportCost * 1000) / 1000,
843
1066
  capacityConsumed: Math.round(l.capacityConsumed * 1000) / 1000,
1067
+ // Emitted only where it says something the cost cannot: at zero cost
1068
+ // the layer has stopped discriminating and headroom is the remaining
1069
+ // signal. Above zero the cost carries it, and a second number would
1070
+ // just be noise. (BUG-18)
1071
+ ...(l.transportCost < 0.001 && l.headroom > 0
1072
+ ? { headroom: l.headroom, costIsAtFloor: true } : {}),
844
1073
  })),
845
1074
  bottleneck: result.bottleneckLayer,
846
1075
  deficitVsSurplus: {
@@ -848,10 +1077,61 @@ Begin with the first persona: ${personas[0]}
848
1077
  surplus: Math.round(result.surplusCost * 1000) / 1000,
849
1078
  },
850
1079
  abandonmentRisk: Math.round(result.abandonmentRisk * 100) + "%",
1080
+ // Says out loud that these two are different constructs.
1081
+ //
1082
+ // A run can read "easy" on CTC and 53% on abandonment and both be
1083
+ // correct, which looks like a contradiction and is not one:
1084
+ // abandonmentRisk is a sigmoid of (deficit - 0.3*surplus) against this
1085
+ // persona's blended patience and resilience, and never touches
1086
+ // totalCTC. CTC asks how expensive the page is; abandonment asks
1087
+ // whether THIS persona's tolerance runs out first. A page can be cheap
1088
+ // in absolute terms and still exceed someone with little patience.
1089
+ // WHAT THIS NUMBER IS CONDITIONAL ON. It was reported as though it were
1090
+ // "the fraction of visitors who leave", and it is not that at either
1091
+ // scope -- it is two different conditional quantities depending on
1092
+ // which scope produced it, and the output did not distinguish them.
1093
+ abandonmentBasis: measureScope === "full_page"
1094
+ ? "P(this persona's budget is exhausted | they process the ENTIRE document). A conditional upper bound, not a prediction of observed behaviour: almost nobody reads a whole page. Deficit is summed across the whole document and compared against this persona's blended patience and resilience. Independent of totalCTC, so the two can disagree without either being wrong."
1095
+ : "P(this persona's budget is exhausted ON THE FIRST SCREEN). Real and interpretable, and it ignores everything below the fold. Deficit is summed across the viewport only and compared against this persona's blended patience and resilience. Independent of totalCTC, so the two can disagree without either being wrong.",
1096
+ abandonmentNotModelled: "Neither figure is 'what fraction of visitors leave'. A page is consumed as a SEQUENCE of screens, each with its own chance of stopping, and this model evaluates one aggregate. Two consequences it cannot express: demand below the fold is not weighted by the probability of ever reaching it, and capacity depletes with scroll depth as well as across layers. A persona also has no variance here -- every trait is a scalar, so this is one hypothetical person's trajectory, not a population, and no fraction-of-visitors reading is available from it at all.",
851
1097
  interactions: Object.fromEntries(Object.entries(result.interactions)
852
1098
  .filter(([, v]) => v > 0.001)
853
1099
  .map(([k, v]) => [k, Math.round(v * 1000) / 1000])),
854
1100
  ...(motorResult ? {
1101
+ // WHY the two motor layers can disagree, shown rather than left to be
1102
+ // discovered. `motor` is target acquisition and `motorProcedure` is
1103
+ // sequence execution, and a persona can be poor at one and competent at
1104
+ // the other -- which is exactly what makes them separable and exactly
1105
+ // what read as a contradiction while they shared a scalar.
1106
+ //
1107
+ // Pointing is decomposed further because `motorCapacity` and
1108
+ // `hitProbability` are two different reductions of the same profile:
1109
+ // capacity weights Fitts throughput at 0.75, hit probability uses
1110
+ // endpoint dispersion alone. They therefore rank personas differently,
1111
+ // and a reader comparing them needs to see that rather than infer it.
1112
+ motorDecomposition: (() => {
1113
+ try {
1114
+ const pp = pointingProfileForResponse;
1115
+ if (!pp)
1116
+ return undefined;
1117
+ const L = result.layers;
1118
+ const acquire = L.find((l) => l.name === "motor")?.transportCost ?? 0;
1119
+ const sequence = L.find((l) => l.name === "motorProcedure")?.transportCost ?? 0;
1120
+ return {
1121
+ targetAcquisition: Math.round(acquire * 1000) / 1000,
1122
+ sequenceExecution: Math.round(sequence * 1000) / 1000,
1123
+ pointingInputs: {
1124
+ fittsThroughput: Math.round(pp.throughput * 100) / 100,
1125
+ endpointDispersionPx: Math.round(((pp.sigmaX + pp.sigmaY) / 2) * 10) / 10,
1126
+ note: "throughput drives movementTimeMs; dispersion drives hitProbability. motorCapacity weights throughput at 0.75, so the `motor` layer and `hitProbability` legitimately rank personas differently.",
1127
+ },
1128
+ note: "These two are additive and unweighted against each other. There is no empirical basis for a relative weighting between acquiring a target and executing a sequence, so none is applied - see MOTOR_LAYER_WEIGHTS, both 1.0 and marked uncalibrated.",
1129
+ };
1130
+ }
1131
+ catch {
1132
+ return undefined;
1133
+ }
1134
+ })(),
855
1135
  motorAccessibility: {
856
1136
  score: Math.round(motorResult.score * 100) + "%",
857
1137
  barriers: motorResult.barrierCount,
@@ -864,21 +1144,115 @@ Begin with the first persona: ${personas[0]}
864
1144
  } : {}),
865
1145
  ...(readabilityResult ? {
866
1146
  readability: {
1147
+ // Renamed from `score`. This is a QUALITY percentage where high is
1148
+ // good; the chain's `readability` layer is a COST where high is bad.
1149
+ // Both were called readability, so a run could report readability
1150
+ // 91% and readability as the bottleneck in the same payload and
1151
+ // look self-contradictory when it was reporting two different
1152
+ // measurements under one word. (2026-08-02)
1153
+ legibilityQuality: Math.round(readabilityResult.score * 100) + "%",
1154
+ /** @deprecated ambiguous against the layer cost — use legibilityQuality */
867
1155
  score: Math.round(readabilityResult.score * 100) + "%",
1156
+ // The note here previously said this was "a quality score for the
1157
+ // text itself", contrasted against the persona-relative layer cost.
1158
+ // That was wrong, and I wrote it. It comes from readability(), which
1159
+ // resolves a per-persona reading profile (visual span, phonological
1160
+ // decoding, crowding sensitivity) -- so it is persona-RELATIVE by
1161
+ // construction and reads 91% for one persona and 73% for another on
1162
+ // identical text. A field promising objectivity is the one most
1163
+ // likely to be quoted as an objective page score. (2026-08-02)
1164
+ note: "legibilityQuality is how legible this text is FOR THIS PERSONA (higher is better) — it is computed from their reading profile, so it varies across personas on identical text and is NOT an objective property of the page. The `readability` entry in `layers` is the DECODING transport cost for the same persona (higher is worse), and the two move together: for a given persona, lower legibility means higher readability cost until the cost reaches its floor of 0, which it does whenever decoding capacity exceeds what the page demands — at that point read `headroom` on the readability layer instead, because the cost cannot tell a comfortable reader from a very comfortable one. Across personas the ordering holds for any material difference in legibility, but not to the last decimal — layers spend from a budget the earlier ones depleted, so two readers with near-identical legibility can differ slightly if one arrived more depleted. The ATTENTIONAL cost of reading (holding the line, re-reading, being pulled away) is a separate layer, `readingAttention`, and is deliberately not part of this number.",
1165
+ legibilityQualityIsPersonaRelative: true,
868
1166
  averageWPM: Math.round(readabilityResult.blocks.reduce((s, b) => s + b.wordsPerMinute, 0) / readabilityResult.blocks.length),
869
1167
  hardestBlock: readabilityResult.blocks.length > 0
870
1168
  ? readabilityResult.blocks.reduce((worst, b) => b.difficulty > worst.difficulty ? b : worst).penalties
871
1169
  : [],
1170
+ // Every component of that block, including the zeroes.
1171
+ //
1172
+ // `hardestBlock` is a prose list and each entry is gated behind its
1173
+ // own threshold, so a component evaluating to 0 is simply absent --
1174
+ // and absent is indistinguishable from not modelled. That is how
1175
+ // `crowdingSensitivity` came to look like a trait that does
1176
+ // nothing: it is weighted 0.15 into readingCapacity AND priced per
1177
+ // block, but Pelli's crowding effect only begins below 16px and
1178
+ // every page tested sets body text at 16px or more. At 13px it
1179
+ // discriminates cleanly -- adhd +6ms, dyslexic +17ms, low-vision
1180
+ // +21ms, tracking sensitivities of 0.20 / 0.55 / 0.70. (BUG-12)
1181
+ ...(readabilityResult.blocks.length > 0 ? {
1182
+ hardestBlockPenaltyMs: readabilityResult.blocks
1183
+ .reduce((worst, b) => b.difficulty > worst.difficulty ? b : worst).penaltyBreakdown,
1184
+ penaltyNote: "hardestBlockPenaltyMs carries every component, including those that came to 0. A 0 means the component was evaluated and does not apply to this text -- crowding, for one, only begins below 16px (Pelli et al. 2004) -- not that it is unmodelled. crowdingSensitivity additionally carries a 0.15 weight into readingCapacity, which reaches the readability layer at every font size.",
1185
+ } : {}),
872
1186
  },
873
1187
  } : {}),
874
- interpretation: result.totalCTC < 0.3
875
- ? `${personaName} should handle this page comfortably.`
876
- : result.totalCTC < 0.6
877
- ? `${personaName} will experience moderate cognitive effort. Bottleneck: ${result.bottleneckLayer}.`
878
- : result.totalCTC < 0.8
879
- ? `${personaName} will struggle significantly. ${result.bottleneckLayer} is the primary barrier. Consider simplifying.`
880
- : `${personaName} is likely to abandon this page. Cognitive transport cost is ${Math.round(result.totalCTC * 100)}%. Immediate remediation needed on ${result.bottleneckLayer}.`,
1188
+ // The verdict names its own scope. Reading "should handle this page
1189
+ // comfortably" off a measurement of the top 800px of a 6436px page is
1190
+ // the same class of output defect as the skip-link false positive:
1191
+ // internally consistent and indefensible to a customer.
1192
+ //
1193
+ // `subject` is what was actually measured, so the sentence stays true
1194
+ // at either scope instead of the scope being a footnote elsewhere.
1195
+ interpretation: (() => {
1196
+ const subject = measureScope === "full_page" ? "this page" : "the top of this page";
1197
+ const where = measureScope === "full_page" ? "" : " (viewport only";
1198
+ const screens = pageMetrics.pageScreens > 1.2
1199
+ ? `${measureScope === "full_page" ? " Measured across" : `${where}; the page is`} ${pageMetrics.pageScreens} screens${measureScope === "full_page" ? "." : ", so most of it was not measured — pass scope='full_page' for the whole thing.)"}`
1200
+ : where ? `${where}.)` : "";
1201
+ const verdict = result.totalCTC < 0.3
1202
+ ? `${personaName} should handle ${subject} comfortably.`
1203
+ : result.totalCTC < 0.6
1204
+ ? `${personaName} will experience moderate cognitive effort on ${subject}. Bottleneck: ${result.bottleneckLayer}.`
1205
+ : result.totalCTC < 0.8
1206
+ ? `${personaName} will struggle significantly with ${subject}. ${result.bottleneckLayer} is the primary barrier. Consider simplifying.`
1207
+ : `${personaName} is likely to abandon ${subject}. Cognitive transport cost is ${Math.round(result.totalCTC * 100)}%. Immediate remediation needed on ${result.bottleneckLayer}.`;
1208
+ return verdict + screens;
1209
+ })(),
1210
+ // ONE AUTHORITATIVE FIELD PER READING DIMENSION, stated rather than
1211
+ // left to be inferred from which layer moved. Reading ability was
1212
+ // encoded in two places -- five explicit fields in accessibility_traits
1213
+ // AND `comprehension` plus `readingTendency` in the main vector -- with
1214
+ // nothing saying which the model reads. That is the two-registry
1215
+ // divergence shape, caught before the fields drifted. (BUG-13 / D-11)
1216
+ readingModel: {
1217
+ decoding: {
1218
+ authoritative: "accessibility_traits.{orthographicFluency, phonologicalDecoding, visualSpan, vocabularyBreadth, crowdingSensitivity}",
1219
+ reaches: "readability layer, via the derived readingCapacity dimension",
1220
+ },
1221
+ attention: {
1222
+ authoritative: "accessibility_traits.attentionSpan, falling back to traits.interruptRecovery",
1223
+ reaches: "readingAttention layer, via the derived sustainedAttention dimension",
1224
+ },
1225
+ engagement: {
1226
+ authoritative: "traits.readingTendency",
1227
+ reaches: "modulates readingAttention DEMAND only — a disposition, not an ability, and it does not touch decoding",
1228
+ },
1229
+ notConsumedForReading: {
1230
+ "traits.comprehension": "grasp of UI conventions, not reading. Consumed by cognitiveLoad only. A persona's 0.5 here says nothing about how well they read.",
1231
+ },
1232
+ },
1233
+ ...(sequentialResult ? { sequential: sequentialResult } : {}),
1234
+ scope: measureScope,
1235
+ pageScreens: pageMetrics.pageScreens,
1236
+ scopeNote: measureScope === "full_page"
1237
+ ? "Whole-page measurement: every laid-out element counted, densities normalised against document area, and the page scrolled first so lazy content exists. Not comparable with viewport-scoped numbers, including every CTC this tool published before 2026-08-02."
1238
+ : "VIEWPORT ONLY: demand is computed from elements intersecting the first screenful. Content below the fold contributed nothing. This was the tool's undeclared behaviour until 2026-08-02 — measured on a 229-link, 8-screen page it saw 4 links and 47 words and reported LOWER cognitive load than a shorter homepage. Pass scope='full_page' to measure the whole page.",
881
1239
  ...(familiarityWarning ? { familiarityWarning, siteFamiliarityAdjusted: true, originalFamiliarity: requestedFamiliarity, effectiveFamiliarity: traits.siteFamiliarity } : {}),
1240
+ // Reports what was USED, not what was passed.
1241
+ //
1242
+ // This previously said "supplied for this run" whenever the caller sent
1243
+ // a number, without any check that the number reached the model. It
1244
+ // did not: siteFamiliarity had no demand term and no home layer, so
1245
+ // three runs at unset / 1 / 0 came back byte-identical under a response
1246
+ // asserting the parameter had been applied. An attestation nobody
1247
+ // verifies is worse than no attestation, because it makes an
1248
+ // output-level audit report the knob as working. (2026-08-02)
1249
+ siteFamiliarity: traits.siteFamiliarity,
1250
+ siteFamiliaritySource: familiarityFromCaller
1251
+ ? "supplied for this run"
1252
+ : hasSiteKnowledge
1253
+ ? "derived from this install's crawl of the site"
1254
+ : "defaulted to first visit — no site knowledge and none supplied",
1255
+ siteFamiliarityEffect: `Consumed by the cognitiveLoad layer against a demand set by navigation depth. A page reachable in one click demands no site knowledge, so this changes nothing there.`,
882
1256
  ...(languageWarning ? { languageWarning } : {}),
883
1257
  ...(userLocation ? { userContext: { location: userLocation, timezone: userTimezone, language: userLanguage } } : {}),
884
1258
  ...(token ? { _browserToken: token } : {}),
@@ -894,18 +1268,21 @@ Begin with the first persona: ${personas[0]}
894
1268
  await page.screenshot({ path: ssPath, fullPage: false });
895
1269
  // Get element bounding boxes for motor overlay
896
1270
  const motorElements = await page.evaluate(() => {
897
- const els = document.querySelectorAll('a, button, input, select, textarea, [role="button"]');
898
- return Array.from(els).slice(0, 30).map(el => {
899
- const rect = el.getBoundingClientRect();
900
- return {
901
- selector: el.tagName.toLowerCase(),
902
- x: rect.x, y: rect.y, width: rect.width, height: rect.height,
903
- };
904
- }).filter((e) => e.width > 0 && e.height > 0);
1271
+ return [];
905
1272
  });
906
1273
  const { generateMotorOverlay } = await import("../../visual/visual-overlays.js");
907
- const motorOverlayElements = motorElements.map((el, i) => ({
908
- ...el,
1274
+ // Drawn from the SAME list that was scored.
1275
+ //
1276
+ // This used to run a second querySelectorAll with a different filter
1277
+ // and zip it to motorResult.elements BY INDEX, so the two lists only
1278
+ // lined up while both filters happened to admit the same elements in
1279
+ // the same order. Tightening the scoring filter to exclude
1280
+ // non-hit-testable targets would have guaranteed a mismatch, and the
1281
+ // overlay would have drawn each element's hit probability on some
1282
+ // other element's box. (2026-08-02)
1283
+ const motorOverlayElements = motorGeom.map((el, i) => ({
1284
+ selector: el.selector,
1285
+ x: el.x, y: el.y, width: el.width, height: el.height,
909
1286
  hitProbability: motorResult.elements[i]?.hitProbability ?? 0.9,
910
1287
  isBarrier: motorResult.elements[i]?.isBarrier ?? false,
911
1288
  }));
@@ -915,7 +1292,115 @@ Begin with the first persona: ${personas[0]}
915
1292
  if (written)
916
1293
  response.motorOverlayUrl = written.url;
917
1294
  response.motorOverlayNote = "Green = easy to click. Yellow = moderate difficulty. Red = motor barrier for this persona.";
918
- content.push({ type: "image", data: motorBase64, mimeType: "image/png" });
1295
+ // Inlined ONLY when small. A tool result is not an image transport.
1296
+ //
1297
+ // This pushed the full base64 overlay unconditionally, and on a real
1298
+ // page that was 309,296 of a 312,580-byte result -- 99% image around
1299
+ // 2,828 bytes of data. Hosts cap tool-result size, and past that cap
1300
+ // the widget does not degrade, it fails to load. The MCP Apps guidance
1301
+ // says heavy assets travel via callServerTool, which is exactly what
1302
+ // the chain widget now does: every overlay, motor included, is written
1303
+ // as an artifact and fetched on demand by the bar that shows it.
1304
+ //
1305
+ // A small overlay still rides along so the model can see it without a
1306
+ // round trip. A large one is named, not carried. (2026-08-02)
1307
+ const INLINE_IMAGE_MAX_B64 = 40_000;
1308
+ if (motorBase64.length <= INLINE_IMAGE_MAX_B64) {
1309
+ content.push({ type: "image", data: motorBase64, mimeType: "image/png" });
1310
+ }
1311
+ else {
1312
+ response.motorOverlayInline = false;
1313
+ response.motorOverlayNote = `${response.motorOverlayNote ?? ""} Overlay is ${Math.round(motorBase64.length / 1024)}KB, too large to inline in a tool result; fetch it with artifact_fetch using the filename in layerOverlays, or open it from the chain view.`.trim();
1314
+ }
1315
+ // ssPath is deliberately NOT deleted here any more -- the other
1316
+ // layer overlays are drawn from the same screenshot below.
1317
+ }
1318
+ }
1319
+ catch (e) {
1320
+ console.debug(`[cognitive_effort] Motor overlay failed: ${e.message}`);
1321
+ }
1322
+ // Per-layer visual evidence, so a bar in the chain can be opened into the
1323
+ // thing it measured.
1324
+ //
1325
+ // Only layers with a real generator get one. The other two are listed with
1326
+ // the reason rather than omitted: a chain of six where four are clickable
1327
+ // and two silently are not reads as a broken widget, while "no overlay for
1328
+ // this layer, and here is why" is information. Nothing is fabricated to
1329
+ // fill the gap. (2026-08-02)
1330
+ const layerOverlays = [];
1331
+ try {
1332
+ const { join: joinPath } = await import("path");
1333
+ const { tmpdir: getTmpDir } = await import("os");
1334
+ const { unlinkSync: ul, existsSync: ex } = await import("fs");
1335
+ const ssPath = joinPath(getTmpDir(), `cog-effort-layers-${Date.now()}.png`);
1336
+ await page.screenshot({ path: ssPath, fullPage: false });
1337
+ // saliency -- keyless. Lab-space centre-surround over the screenshot,
1338
+ // so this needs no API key and no model call.
1339
+ try {
1340
+ const { computeLabSaliency } = await import("../../visual/attention-transport.js");
1341
+ const { generateHeatmapOverlay } = await import("../../visual/heatmap-overlay.js");
1342
+ const sal = await computeLabSaliency(ssPath);
1343
+ const b64 = await generateHeatmapOverlay(ssPath, sal.cells, sal.rows, sal.cols, `${personaName} — saliency`);
1344
+ const w = writeArtifact(Buffer.from(b64, "base64"), `saliency-${personaName}-${Date.now()}.png`);
1345
+ if (w)
1346
+ layerOverlays.push({ layer: "saliency", file: w.url.split("/").pop(), available: true,
1347
+ legend: "Hotter = more visually salient before any reading happens." });
1348
+ }
1349
+ catch (e) {
1350
+ layerOverlays.push({ layer: "saliency", available: false, reason: `saliency map failed: ${e.message}` });
1351
+ }
1352
+ // motor -- already drawn above; surfaced here so the chain has one list.
1353
+ if (response.motorOverlayUrl) {
1354
+ layerOverlays.push({ layer: "motor", file: String(response.motorOverlayUrl).split("/").pop(), available: true,
1355
+ legend: String(response.motorOverlayNote || "") });
1356
+ }
1357
+ else {
1358
+ layerOverlays.push({ layer: "motor", available: false, reason: "no interactive elements were found to score" });
1359
+ }
1360
+ // readability
1361
+ if (readabilityResult && readabilityResult.blocks.length > 0) {
1362
+ try {
1363
+ const { generateReadabilityOverlay } = await import("../../visual/visual-overlays.js");
1364
+ const rb = readabilityResult.blocks.map((b, i) => ({
1365
+ x: textBlockGeom[i]?.x ?? 0, y: textBlockGeom[i]?.y ?? 0,
1366
+ width: textBlockGeom[i]?.width ?? 0, height: textBlockGeom[i]?.height ?? 0,
1367
+ difficulty: b.difficulty ?? 0, wpm: b.wordsPerMinute ?? 0,
1368
+ text: textBlockGeom[i]?.text ?? "",
1369
+ })).filter((b) => b.width > 0 && b.height > 0);
1370
+ if (rb.length) {
1371
+ const b64 = await generateReadabilityOverlay(ssPath, rb, personaName);
1372
+ const w = writeArtifact(Buffer.from(b64, "base64"), `readability-${personaName}-${Date.now()}.png`);
1373
+ if (w)
1374
+ layerOverlays.push({ layer: "readability", file: w.url.split("/").pop(), available: true,
1375
+ legend: "Hotter = slower for this persona to read." });
1376
+ }
1377
+ else {
1378
+ layerOverlays.push({ layer: "readability", available: false, reason: "text blocks carried no on-page geometry" });
1379
+ }
1380
+ }
1381
+ catch (e) {
1382
+ layerOverlays.push({ layer: "readability", available: false, reason: `readability overlay failed: ${e.message}` });
1383
+ }
1384
+ }
1385
+ else {
1386
+ layerOverlays.push({ layer: "readability", available: false, reason: "no readable text blocks in the viewport" });
1387
+ }
1388
+ // The remaining two have no generator, and no data here to build one
1389
+ // from. Said plainly rather than left as a dead bar.
1390
+ // camelCase, matching LAYER_DEFINITIONS. Emitted as "cognitive-load" at
1391
+ // first, which matched nothing: the bar got neither a button nor a
1392
+ // no-overlay tag and silently looked different from its five siblings.
1393
+ layerOverlays.push({ layer: "cognitiveLoad", available: false,
1394
+ reason: "no per-region load data is produced by this tool, so there is nothing to draw" });
1395
+ layerOverlays.push({ layer: "decision", available: false,
1396
+ reason: "decision cost is computed over the page as a whole, not per region" });
1397
+ layerOverlays.push({ layer: "frustration", available: false,
1398
+ reason: "frustration is a running state across the chain, not a place on the page" });
1399
+ layerOverlays.push({ layer: "motorProcedure", available: false,
1400
+ reason: "sequence execution is a property of a flow across pages, not a place on this one \u2014 the motor overlay above shows where TARGET ACQUISITION is hard" });
1401
+ layerOverlays.push({ layer: "readingAttention", available: false,
1402
+ reason: "attentional reading cost is a property of the reader against the whole page, not of any one region \u2014 the readability overlay above shows where DECODING is slow" });
1403
+ if (ex(ssPath)) {
919
1404
  try {
920
1405
  ul(ssPath);
921
1406
  }
@@ -923,7 +1408,11 @@ Begin with the first persona: ${personas[0]}
923
1408
  }
924
1409
  }
925
1410
  catch (e) {
926
- console.debug(`[cognitive_effort] Motor overlay failed: ${e.message}`);
1411
+ console.debug(`[cognitive_effort] Layer overlays failed: ${e.message}`);
1412
+ }
1413
+ if (layerOverlays.length) {
1414
+ response.layerOverlays = layerOverlays;
1415
+ response.layerOverlayNote = "Each entry maps to a bar in the transport chain. Layers without an overlay say why.";
927
1416
  }
928
1417
  content.unshift({
929
1418
  type: "text",