cbrowser 20.2.2 → 21.0.1

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 (311) hide show
  1. package/README.md +2 -2
  2. package/dist/analysis/accessibility-empathy.d.ts +29 -1
  3. package/dist/analysis/accessibility-empathy.d.ts.map +1 -1
  4. package/dist/analysis/accessibility-empathy.js +69 -35
  5. package/dist/analysis/accessibility-empathy.js.map +1 -1
  6. package/dist/browser.d.ts.map +1 -1
  7. package/dist/browser.js +11 -7
  8. package/dist/browser.js.map +1 -1
  9. package/dist/cif-score.d.ts +56 -5
  10. package/dist/cif-score.d.ts.map +1 -1
  11. package/dist/cif-score.js +595 -346
  12. package/dist/cif-score.js.map +1 -1
  13. package/dist/cli.js +10 -8
  14. package/dist/cli.js.map +1 -1
  15. package/dist/cognitive/default-traits.d.ts +14 -0
  16. package/dist/cognitive/default-traits.d.ts.map +1 -0
  17. package/dist/cognitive/default-traits.js +36 -0
  18. package/dist/cognitive/default-traits.js.map +1 -0
  19. package/dist/cognitive/emotions.d.ts.map +1 -1
  20. package/dist/cognitive/emotions.js +104 -174
  21. package/dist/cognitive/emotions.js.map +1 -1
  22. package/dist/cognitive/index.d.ts +28 -0
  23. package/dist/cognitive/index.d.ts.map +1 -1
  24. package/dist/cognitive/index.js +353 -287
  25. package/dist/cognitive/index.js.map +1 -1
  26. package/dist/cognitive/temporal.d.ts +0 -24
  27. package/dist/cognitive/temporal.d.ts.map +1 -1
  28. package/dist/cognitive/temporal.js +45 -35
  29. package/dist/cognitive/temporal.js.map +1 -1
  30. package/dist/data/ctc-reference-v2-2026-10-24.d.ts +16 -0
  31. package/dist/data/ctc-reference-v2-2026-10-24.d.ts.map +1 -0
  32. package/dist/data/ctc-reference-v2-2026-10-24.js +1004 -0
  33. package/dist/data/ctc-reference-v2-2026-10-24.js.map +1 -0
  34. package/dist/ideal-profiles-gap.d.ts +1 -0
  35. package/dist/ideal-profiles-gap.d.ts.map +1 -1
  36. package/dist/ideal-profiles-gap.js +9 -1
  37. package/dist/ideal-profiles-gap.js.map +1 -1
  38. package/dist/ideal-profiles.d.ts +8 -0
  39. package/dist/ideal-profiles.d.ts.map +1 -1
  40. package/dist/ideal-profiles.js +28 -24
  41. package/dist/ideal-profiles.js.map +1 -1
  42. package/dist/mcp-server-remote.d.ts.map +1 -1
  43. package/dist/mcp-server-remote.js +7 -8
  44. package/dist/mcp-server-remote.js.map +1 -1
  45. package/dist/mcp-tools/account-personas.d.ts.map +1 -1
  46. package/dist/mcp-tools/account-personas.js +3 -1
  47. package/dist/mcp-tools/account-personas.js.map +1 -1
  48. package/dist/mcp-tools/base/answer-provenance.d.ts +28 -0
  49. package/dist/mcp-tools/base/answer-provenance.d.ts.map +1 -0
  50. package/dist/mcp-tools/base/answer-provenance.js +32 -0
  51. package/dist/mcp-tools/base/answer-provenance.js.map +1 -0
  52. package/dist/mcp-tools/base/audit-tools.d.ts +0 -3
  53. package/dist/mcp-tools/base/audit-tools.d.ts.map +1 -1
  54. package/dist/mcp-tools/base/audit-tools.js +152 -84
  55. package/dist/mcp-tools/base/audit-tools.js.map +1 -1
  56. package/dist/mcp-tools/base/capture-tools.js +1 -1
  57. package/dist/mcp-tools/base/capture-tools.js.map +1 -1
  58. package/dist/mcp-tools/base/chain-values.d.ts +38 -0
  59. package/dist/mcp-tools/base/chain-values.d.ts.map +1 -0
  60. package/dist/mcp-tools/base/chain-values.js +85 -0
  61. package/dist/mcp-tools/base/chain-values.js.map +1 -0
  62. package/dist/mcp-tools/base/cognitive-tools.d.ts.map +1 -1
  63. package/dist/mcp-tools/base/cognitive-tools.js +126 -120
  64. package/dist/mcp-tools/base/cognitive-tools.js.map +1 -1
  65. package/dist/mcp-tools/base/gif-tools.d.ts.map +1 -1
  66. package/dist/mcp-tools/base/gif-tools.js +23 -5
  67. package/dist/mcp-tools/base/gif-tools.js.map +1 -1
  68. package/dist/mcp-tools/base/persona-comparison-tools.d.ts.map +1 -1
  69. package/dist/mcp-tools/base/persona-comparison-tools.js +229 -172
  70. package/dist/mcp-tools/base/persona-comparison-tools.js.map +1 -1
  71. package/dist/mcp-tools/base/site-familiarity.d.ts +31 -0
  72. package/dist/mcp-tools/base/site-familiarity.d.ts.map +1 -0
  73. package/dist/mcp-tools/base/site-familiarity.js +30 -0
  74. package/dist/mcp-tools/base/site-familiarity.js.map +1 -0
  75. package/dist/mcp-tools/base/site-knowledge-tools.js +1 -1
  76. package/dist/mcp-tools/base/site-knowledge-tools.js.map +1 -1
  77. package/dist/mcp-tools/base/story-narrative.d.ts +50 -0
  78. package/dist/mcp-tools/base/story-narrative.d.ts.map +1 -0
  79. package/dist/mcp-tools/base/story-narrative.js +88 -0
  80. package/dist/mcp-tools/base/story-narrative.js.map +1 -0
  81. package/dist/mcp-tools/base/values-tools.d.ts.map +1 -1
  82. package/dist/mcp-tools/base/values-tools.js +19 -5
  83. package/dist/mcp-tools/base/values-tools.js.map +1 -1
  84. package/dist/mcp-tools/base/visual-testing-tools.d.ts +5 -5
  85. package/dist/mcp-tools/base/visual-testing-tools.d.ts.map +1 -1
  86. package/dist/mcp-tools/base/visual-testing-tools.js +38 -11
  87. package/dist/mcp-tools/base/visual-testing-tools.js.map +1 -1
  88. package/dist/mcp-tools/persona-creation-tools.js +6 -6
  89. package/dist/mcp-tools/persona-creation-tools.js.map +1 -1
  90. package/dist/mcp-tools/persona-lifecycle-tools.d.ts.map +1 -1
  91. package/dist/mcp-tools/persona-lifecycle-tools.js +5 -1
  92. package/dist/mcp-tools/persona-lifecycle-tools.js.map +1 -1
  93. package/dist/mcp-tools/tier-gate.js +1 -1
  94. package/dist/mcp-tools/tool-result-saver.d.ts +7 -0
  95. package/dist/mcp-tools/tool-result-saver.d.ts.map +1 -1
  96. package/dist/mcp-tools/tool-result-saver.js +11 -0
  97. package/dist/mcp-tools/tool-result-saver.js.map +1 -1
  98. package/dist/mcp-tools/trait-provenance.d.ts +109 -0
  99. package/dist/mcp-tools/trait-provenance.d.ts.map +1 -0
  100. package/dist/mcp-tools/trait-provenance.js +187 -0
  101. package/dist/mcp-tools/trait-provenance.js.map +1 -0
  102. package/dist/mcp-tools/ui-resources.d.ts +6 -7
  103. package/dist/mcp-tools/ui-resources.d.ts.map +1 -1
  104. package/dist/mcp-tools/ui-resources.js +162 -12
  105. package/dist/mcp-tools/ui-resources.js.map +1 -1
  106. package/dist/mcp-tools/widget-kit.d.ts +129 -3
  107. package/dist/mcp-tools/widget-kit.d.ts.map +1 -1
  108. package/dist/mcp-tools/widget-kit.js +507 -28
  109. package/dist/mcp-tools/widget-kit.js.map +1 -1
  110. package/dist/performance/metrics.d.ts.map +1 -1
  111. package/dist/performance/metrics.js +4 -1
  112. package/dist/performance/metrics.js.map +1 -1
  113. package/dist/persona-age.d.ts +59 -0
  114. package/dist/persona-age.d.ts.map +1 -0
  115. package/dist/persona-age.js +84 -0
  116. package/dist/persona-age.js.map +1 -0
  117. package/dist/persona-questionnaire.js +1 -1
  118. package/dist/persona-questionnaire.js.map +1 -1
  119. package/dist/personas.d.ts +3 -1
  120. package/dist/personas.d.ts.map +1 -1
  121. package/dist/personas.js +154 -119
  122. package/dist/personas.js.map +1 -1
  123. package/dist/recording/engine.d.ts.map +1 -1
  124. package/dist/recording/engine.js +5 -2
  125. package/dist/recording/engine.js.map +1 -1
  126. package/dist/recording/saliency-overlay.d.ts +8 -3
  127. package/dist/recording/saliency-overlay.d.ts.map +1 -1
  128. package/dist/recording/saliency-overlay.js +14 -5
  129. package/dist/recording/saliency-overlay.js.map +1 -1
  130. package/dist/recording/types.js +1 -1
  131. package/dist/testing/nl-test-suite.d.ts.map +1 -1
  132. package/dist/testing/nl-test-suite.js +2 -1
  133. package/dist/testing/nl-test-suite.js.map +1 -1
  134. package/dist/trait-reference.d.ts +19 -2
  135. package/dist/trait-reference.d.ts.map +1 -1
  136. package/dist/trait-reference.js +19 -2
  137. package/dist/trait-reference.js.map +1 -1
  138. package/dist/types.d.ts +32 -21
  139. package/dist/types.d.ts.map +1 -1
  140. package/dist/types.js +62 -52
  141. package/dist/types.js.map +1 -1
  142. package/dist/values/persona-completeness.js +1 -1
  143. package/dist/values/persona-completeness.js.map +1 -1
  144. package/dist/visual/ab-comparison.d.ts.map +1 -1
  145. package/dist/visual/ab-comparison.js +4 -1
  146. package/dist/visual/ab-comparison.js.map +1 -1
  147. package/dist/visual/attention-quality.d.ts +1 -1
  148. package/dist/visual/attention-quality.js +1 -1
  149. package/dist/visual/attention-transport.d.ts +74 -10
  150. package/dist/visual/attention-transport.d.ts.map +1 -1
  151. package/dist/visual/attention-transport.js +353 -193
  152. package/dist/visual/attention-transport.js.map +1 -1
  153. package/dist/visual/cognitive-models.d.ts +77 -55
  154. package/dist/visual/cognitive-models.d.ts.map +1 -1
  155. package/dist/visual/cognitive-models.js +231 -104
  156. package/dist/visual/cognitive-models.js.map +1 -1
  157. package/dist/visual/cognitive-transport-chain.d.ts +328 -30
  158. package/dist/visual/cognitive-transport-chain.d.ts.map +1 -1
  159. package/dist/visual/cognitive-transport-chain.js +576 -353
  160. package/dist/visual/cognitive-transport-chain.js.map +1 -1
  161. package/dist/visual/cognitive-transport.d.ts +446 -13
  162. package/dist/visual/cognitive-transport.d.ts.map +1 -1
  163. package/dist/visual/cognitive-transport.js +1085 -245
  164. package/dist/visual/cognitive-transport.js.map +1 -1
  165. package/dist/visual/cross-browser.d.ts.map +1 -1
  166. package/dist/visual/cross-browser.js +8 -6
  167. package/dist/visual/cross-browser.js.map +1 -1
  168. package/dist/visual/ctc-bands.d.ts +91 -0
  169. package/dist/visual/ctc-bands.d.ts.map +1 -0
  170. package/dist/visual/ctc-bands.js +117 -0
  171. package/dist/visual/ctc-bands.js.map +1 -0
  172. package/dist/visual/ctc-reference.d.ts +287 -0
  173. package/dist/visual/ctc-reference.d.ts.map +1 -0
  174. package/dist/visual/ctc-reference.js +376 -0
  175. package/dist/visual/ctc-reference.js.map +1 -0
  176. package/dist/visual/demand-laws.d.ts +145 -0
  177. package/dist/visual/demand-laws.d.ts.map +1 -0
  178. package/dist/visual/demand-laws.js +220 -0
  179. package/dist/visual/demand-laws.js.map +1 -0
  180. package/dist/visual/distance-metrics.d.ts +23 -1
  181. package/dist/visual/distance-metrics.d.ts.map +1 -1
  182. package/dist/visual/distance-metrics.js +49 -18
  183. package/dist/visual/distance-metrics.js.map +1 -1
  184. package/dist/visual/dro-bound.d.ts +220 -0
  185. package/dist/visual/dro-bound.d.ts.map +1 -0
  186. package/dist/visual/dro-bound.js +341 -0
  187. package/dist/visual/dro-bound.js.map +1 -0
  188. package/dist/visual/exact-w1.d.ts +60 -0
  189. package/dist/visual/exact-w1.d.ts.map +1 -0
  190. package/dist/visual/exact-w1.js +247 -0
  191. package/dist/visual/exact-w1.js.map +1 -0
  192. package/dist/visual/human-input.d.ts.map +1 -1
  193. package/dist/visual/human-input.js +45 -41
  194. package/dist/visual/human-input.js.map +1 -1
  195. package/dist/visual/index.d.ts +2 -0
  196. package/dist/visual/index.d.ts.map +1 -1
  197. package/dist/visual/index.js +2 -0
  198. package/dist/visual/index.js.map +1 -1
  199. package/dist/visual/law-times-report.d.ts +49 -0
  200. package/dist/visual/law-times-report.d.ts.map +1 -0
  201. package/dist/visual/law-times-report.js +38 -0
  202. package/dist/visual/law-times-report.js.map +1 -0
  203. package/dist/visual/model-provenance.d.ts +151 -0
  204. package/dist/visual/model-provenance.d.ts.map +1 -0
  205. package/dist/visual/model-provenance.js +335 -0
  206. package/dist/visual/model-provenance.js.map +1 -0
  207. package/dist/visual/normal-cdf.d.ts +14 -0
  208. package/dist/visual/normal-cdf.d.ts.map +1 -0
  209. package/dist/visual/normal-cdf.js +26 -0
  210. package/dist/visual/normal-cdf.js.map +1 -0
  211. package/dist/visual/perceptual-transport.d.ts +28 -4
  212. package/dist/visual/perceptual-transport.d.ts.map +1 -1
  213. package/dist/visual/perceptual-transport.js +66 -32
  214. package/dist/visual/perceptual-transport.js.map +1 -1
  215. package/dist/visual/provenance/age.d.ts +30 -0
  216. package/dist/visual/provenance/age.d.ts.map +1 -0
  217. package/dist/visual/provenance/age.js +28 -0
  218. package/dist/visual/provenance/age.js.map +1 -0
  219. package/dist/visual/provenance/attention.d.ts +55 -0
  220. package/dist/visual/provenance/attention.d.ts.map +1 -0
  221. package/dist/visual/provenance/attention.js +188 -0
  222. package/dist/visual/provenance/attention.js.map +1 -0
  223. package/dist/visual/provenance/chain.d.ts +41 -0
  224. package/dist/visual/provenance/chain.d.ts.map +1 -0
  225. package/dist/visual/provenance/chain.js +66 -0
  226. package/dist/visual/provenance/chain.js.map +1 -0
  227. package/dist/visual/provenance/cif.d.ts +42 -0
  228. package/dist/visual/provenance/cif.d.ts.map +1 -0
  229. package/dist/visual/provenance/cif.js +116 -0
  230. package/dist/visual/provenance/cif.js.map +1 -0
  231. package/dist/visual/provenance/ctc-reference.d.ts +36 -0
  232. package/dist/visual/provenance/ctc-reference.d.ts.map +1 -0
  233. package/dist/visual/provenance/ctc-reference.js +96 -0
  234. package/dist/visual/provenance/ctc-reference.js.map +1 -0
  235. package/dist/visual/provenance/distance.d.ts +10 -0
  236. package/dist/visual/provenance/distance.d.ts.map +1 -0
  237. package/dist/visual/provenance/distance.js +72 -0
  238. package/dist/visual/provenance/distance.js.map +1 -0
  239. package/dist/visual/provenance/dro.d.ts +10 -0
  240. package/dist/visual/provenance/dro.d.ts.map +1 -0
  241. package/dist/visual/provenance/dro.js +2 -0
  242. package/dist/visual/provenance/dro.js.map +1 -0
  243. package/dist/visual/provenance/emotion.d.ts +26 -0
  244. package/dist/visual/provenance/emotion.d.ts.map +1 -0
  245. package/dist/visual/provenance/emotion.js +99 -0
  246. package/dist/visual/provenance/emotion.js.map +1 -0
  247. package/dist/visual/provenance/human-input.d.ts +19 -0
  248. package/dist/visual/provenance/human-input.d.ts.map +1 -0
  249. package/dist/visual/provenance/human-input.js +84 -0
  250. package/dist/visual/provenance/human-input.js.map +1 -0
  251. package/dist/visual/provenance/journey-group.d.ts +15 -0
  252. package/dist/visual/provenance/journey-group.d.ts.map +1 -0
  253. package/dist/visual/provenance/journey-group.js +15 -0
  254. package/dist/visual/provenance/journey-group.js.map +1 -0
  255. package/dist/visual/provenance/journey-mcp.d.ts +24 -0
  256. package/dist/visual/provenance/journey-mcp.d.ts.map +1 -0
  257. package/dist/visual/provenance/journey-mcp.js +54 -0
  258. package/dist/visual/provenance/journey-mcp.js.map +1 -0
  259. package/dist/visual/provenance/journey.d.ts +34 -0
  260. package/dist/visual/provenance/journey.d.ts.map +1 -0
  261. package/dist/visual/provenance/journey.js +184 -0
  262. package/dist/visual/provenance/journey.js.map +1 -0
  263. package/dist/visual/provenance/laws.d.ts +38 -0
  264. package/dist/visual/provenance/laws.d.ts.map +1 -0
  265. package/dist/visual/provenance/laws.js +40 -0
  266. package/dist/visual/provenance/laws.js.map +1 -0
  267. package/dist/visual/provenance/page-metrics.d.ts +42 -0
  268. package/dist/visual/provenance/page-metrics.d.ts.map +1 -0
  269. package/dist/visual/provenance/page-metrics.js +79 -0
  270. package/dist/visual/provenance/page-metrics.js.map +1 -0
  271. package/dist/visual/provenance/perceptual.d.ts +63 -0
  272. package/dist/visual/provenance/perceptual.d.ts.map +1 -0
  273. package/dist/visual/provenance/perceptual.js +115 -0
  274. package/dist/visual/provenance/perceptual.js.map +1 -0
  275. package/dist/visual/provenance/persona-rates.d.ts +84 -0
  276. package/dist/visual/provenance/persona-rates.d.ts.map +1 -0
  277. package/dist/visual/provenance/persona-rates.js +108 -0
  278. package/dist/visual/provenance/persona-rates.js.map +1 -0
  279. package/dist/visual/provenance/personas.d.ts +122 -0
  280. package/dist/visual/provenance/personas.d.ts.map +1 -0
  281. package/dist/visual/provenance/personas.js +298 -0
  282. package/dist/visual/provenance/personas.js.map +1 -0
  283. package/dist/visual/provenance/profiles.d.ts +39 -0
  284. package/dist/visual/provenance/profiles.d.ts.map +1 -0
  285. package/dist/visual/provenance/profiles.js +37 -0
  286. package/dist/visual/provenance/profiles.js.map +1 -0
  287. package/dist/visual/provenance/temporal.d.ts +21 -0
  288. package/dist/visual/provenance/temporal.d.ts.map +1 -0
  289. package/dist/visual/provenance/temporal.js +56 -0
  290. package/dist/visual/provenance/temporal.js.map +1 -0
  291. package/dist/visual/provenance/visual-distance.d.ts +22 -0
  292. package/dist/visual/provenance/visual-distance.d.ts.map +1 -0
  293. package/dist/visual/provenance/visual-distance.js +30 -0
  294. package/dist/visual/provenance/visual-distance.js.map +1 -0
  295. package/dist/visual/regression.d.ts.map +1 -1
  296. package/dist/visual/regression.js +14 -9
  297. package/dist/visual/regression.js.map +1 -1
  298. package/dist/visual/responsive.d.ts.map +1 -1
  299. package/dist/visual/responsive.js +4 -1
  300. package/dist/visual/responsive.js.map +1 -1
  301. package/dist/visual/scope-prose.js +2 -2
  302. package/dist/visual/scope-prose.js.map +1 -1
  303. package/dist/visual/seeded-random.d.ts +16 -0
  304. package/dist/visual/seeded-random.d.ts.map +1 -0
  305. package/dist/visual/seeded-random.js +25 -0
  306. package/dist/visual/seeded-random.js.map +1 -0
  307. package/dist/visual/stored-accessibility-traits.d.ts +27 -0
  308. package/dist/visual/stored-accessibility-traits.d.ts.map +1 -0
  309. package/dist/visual/stored-accessibility-traits.js +35 -0
  310. package/dist/visual/stored-accessibility-traits.js.map +1 -0
  311. package/package.json +1 -1
@@ -19,7 +19,9 @@
19
19
  * @since v18.28.0
20
20
  * @see https://github.com/alexandriashai/cbrowser/issues/159
21
21
  */
22
- import { getPerceptualProfile } from './perceptual-transport.js';
22
+ import { resolvePerceptualProfile } from './perceptual-transport.js';
23
+ import { param } from './model-provenance.js';
24
+ import { tableChecksum, ATTENTION_STATIC_MODIFIER_TYPES, ATTENTION_VALUE_PATTERN_VALUES } from './provenance/attention.js';
23
25
  /**
24
26
  * Locate where two attention maps differ, as regions rather than cells.
25
27
  *
@@ -52,6 +54,8 @@ import { getPerceptualProfile } from './perceptual-transport.js';
52
54
  * Pure: no image, no persona. `cellSize` is the cell's size in image pixels.
53
55
  */
54
56
  export function rankDivergentRegions(salA, salB, rows, cols, cellSize) {
57
+ const R2 = param("attention.rounding.hundredths");
58
+ const R3 = param("attention.rounding.thousandths");
55
59
  const n = Math.min(salA.length, salB.length, rows * cols);
56
60
  let totalA = 0;
57
61
  let totalB = 0;
@@ -136,11 +140,11 @@ export function rankDivergentRegions(salA, salB, rows, cols, cellSize) {
136
140
  },
137
141
  cellCount: count,
138
142
  direction: positive ? "personaA" : "personaB",
139
- attentionA: Math.round(shareA * 1000) / 1000,
140
- attentionB: Math.round(shareB * 1000) / 1000,
141
- saliencyA: Math.round((sumA / count) * 100) / 100,
142
- saliencyB: Math.round((sumB / count) * 100) / 100,
143
- divergence: Math.round((sum / divergence) * 1000) / 1000,
143
+ attentionA: Math.round(shareA * R3) / R3,
144
+ attentionB: Math.round(shareB * R3) / R3,
145
+ saliencyA: Math.round((sumA / count) * R2) / R2,
146
+ saliencyB: Math.round((sumB / count) * R2) / R2,
147
+ divergence: Math.round((sum / divergence) * R3) / R3,
144
148
  _sum: sum,
145
149
  _peak: peak,
146
150
  _peakIdx: peakIdx,
@@ -154,25 +158,36 @@ export function rankDivergentRegions(salA, salB, rows, cols, cellSize) {
154
158
  regions: regions.map(({ _sum: _s, _peak: _p, _peakIdx: _i, ...r }) => r),
155
159
  };
156
160
  }
157
- // ── CIE-Lab Color Space Conversion ──
158
- function srgbToLab(r, g, b) {
161
+ function labConstants() {
162
+ const P = (id) => param(`attention.${id}`);
163
+ return {
164
+ channelMax: P("srgb.channelMax"), linearKnee: P("srgb.linearKnee"), linearSlope: P("srgb.linearSlope"),
165
+ gammaOffset: P("srgb.gammaOffset"), gammaScale: P("srgb.gammaScale"), gamma: P("srgb.gamma"),
166
+ rX: P("xyz.rX"), gX: P("xyz.gX"), bX: P("xyz.bX"),
167
+ rY: P("xyz.rY"), gY: P("xyz.gY"), bY: P("xyz.bY"),
168
+ rZ: P("xyz.rZ"), gZ: P("xyz.gZ"), bZ: P("xyz.bZ"),
169
+ xn: P("white.x"), yn: P("white.y"), zn: P("white.z"),
170
+ epsilon: P("lab.epsilon"), kappaSlope: P("lab.kappaSlope"),
171
+ lScale: P("lab.lScale"), lOffset: P("lab.lOffset"), aScale: P("lab.aScale"), bScale: P("lab.bScale"),
172
+ };
173
+ }
174
+ function srgbToLab(r, g, b, K) {
159
175
  // sRGB to linear
160
176
  const toLinear = (c) => {
161
- c /= 255;
162
- return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
177
+ c /= K.channelMax;
178
+ return c <= K.linearKnee ? c / K.linearSlope : Math.pow((c + K.gammaOffset) / K.gammaScale, K.gamma);
163
179
  };
164
180
  const rl = toLinear(r), gl = toLinear(g), bl = toLinear(b);
165
181
  // Linear RGB to XYZ (D65)
166
- const x = 0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl;
167
- const y = 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl;
168
- const z = 0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl;
182
+ const x = K.rX * rl + K.gX * gl + K.bX * bl;
183
+ const y = K.rY * rl + K.gY * gl + K.bY * bl;
184
+ const z = K.rZ * rl + K.gZ * gl + K.bZ * bl;
169
185
  // XYZ to Lab (D65 reference)
170
- const xn = 0.95047, yn = 1.0, zn = 1.08883;
171
- const f = (t) => t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
172
- const fx = f(x / xn), fy = f(y / yn), fz = f(z / zn);
173
- const L = 116 * fy - 16;
174
- const a = 500 * (fx - fy);
175
- const bLab = 200 * (fy - fz);
186
+ const f = (t) => t > K.epsilon ? Math.cbrt(t) : K.kappaSlope * t + K.lOffset / K.lScale;
187
+ const fx = f(x / K.xn), fy = f(y / K.yn), fz = f(z / K.zn);
188
+ const L = K.lScale * fy - K.lOffset;
189
+ const a = K.aScale * (fx - fy);
190
+ const bLab = K.bScale * (fy - fz);
176
191
  return [L, a, bLab];
177
192
  }
178
193
  // ── Core: Center-Surround Saliency via W₂ ──
@@ -193,15 +208,27 @@ function srgbToLab(r, g, b) {
193
208
  * impractical, and re-deriving it in a separate script is how the Study 1
194
209
  * Python port drifted from shipped code. (2026-07-29)
195
210
  */
196
- export async function computeLabSaliency(imagePath, cellSize = 16, surroundRadius = 3) {
211
+ export async function computeLabSaliency(imagePath, cellSize = param("attention.gaze.cellSize"), surroundRadius = param("attention.lab.surroundRadius")) {
212
+ const K = labConstants();
213
+ const channels = param("attention.lab.channels");
214
+ const alpha = channels - 1; // RGBA: alpha is the last byte
215
+ const alphaCutoff = param("attention.lab.alphaCutoff");
216
+ const varFloor = param("attention.lab.varianceFloor");
217
+ // Chrominance boost: a/b channels weighted 3x relative to L (see the loop below).
218
+ const wL = param("attention.lab.weightL");
219
+ const wA = param("attention.lab.weightA");
220
+ const wB = param("attention.lab.weightB");
221
+ const normFloor = param("attention.normFloor");
222
+ const scale = param("attention.lab.downscale");
223
+ const fallbackWidth = param("attention.lab.fallbackWidth");
224
+ const fallbackHeight = param("attention.lab.fallbackHeight");
197
225
  const sharpModule = await import('sharp');
198
226
  const sharp = sharpModule.default ?? sharpModule;
199
227
  // Load and downscale
200
- const scale = 0.5;
201
228
  const img = sharp(imagePath);
202
229
  const metadata = await img.metadata();
203
- const width = Math.round((metadata.width || 800) * scale);
204
- const height = Math.round((metadata.height || 600) * scale);
230
+ const width = Math.round((metadata.width || fallbackWidth) * scale);
231
+ const height = Math.round((metadata.height || fallbackHeight) * scale);
205
232
  const { data } = await img
206
233
  .resize(width, height)
207
234
  .ensureAlpha()
@@ -224,10 +251,10 @@ export async function computeLabSaliency(imagePath, cellSize = 16, surroundRadiu
224
251
  const xEnd = Math.min(xStart + cellSize, width);
225
252
  for (let y = yStart; y < yEnd; y++) {
226
253
  for (let x = xStart; x < xEnd; x++) {
227
- const idx = (y * width + x) * 4;
228
- if (pixels[idx + 3] < 25)
254
+ const idx = (y * width + x) * channels;
255
+ if (pixels[idx + alpha] < alphaCutoff)
229
256
  continue; // Skip transparent
230
- const [L, a, b] = srgbToLab(pixels[idx], pixels[idx + 1], pixels[idx + 2]);
257
+ const [L, a, b] = srgbToLab(pixels[idx], pixels[idx + 1], pixels[idx + 2], K);
231
258
  sumL += L;
232
259
  sumA += a;
233
260
  sumB += b;
@@ -241,14 +268,14 @@ export async function computeLabSaliency(imagePath, cellSize = 16, surroundRadiu
241
268
  const mL = sumL / count, mA = sumA / count, mB = sumB / count;
242
269
  cellStats.push({
243
270
  meanL: mL, meanA: mA, meanB: mB,
244
- varL: Math.max(0.01, sumL2 / count - mL * mL),
245
- varA: Math.max(0.01, sumA2 / count - mA * mA),
246
- varB: Math.max(0.01, sumB2 / count - mB * mB),
271
+ varL: Math.max(varFloor, sumL2 / count - mL * mL),
272
+ varA: Math.max(varFloor, sumA2 / count - mA * mA),
273
+ varB: Math.max(varFloor, sumB2 / count - mB * mB),
247
274
  count,
248
275
  });
249
276
  }
250
277
  else {
251
- cellStats.push({ meanL: 0, meanA: 0, meanB: 0, varL: 0.01, varA: 0.01, varB: 0.01, count: 0 });
278
+ cellStats.push({ meanL: 0, meanA: 0, meanB: 0, varL: varFloor, varA: varFloor, varB: varFloor, count: 0 });
252
279
  }
253
280
  }
254
281
  }
@@ -295,14 +322,12 @@ export async function computeLabSaliency(imagePath, cellSize = 16, surroundRadiu
295
322
  // Bures-Wasserstein distance for 3D Gaussians (diagonal covariance)
296
323
  // BW² = w_L||ΔL||² + w_a||Δa||² + w_b||Δb||² + variance terms
297
324
  //
298
- // Chrominance boost: a/b channels weighted 3x relative to L.
325
+ // Chrominance boost: a/b channels weighted 3x relative to L (wL, wA,
326
+ // wB, read at entry; hand-set, see attention.lab.weightA).
299
327
  // This captures the preattentive "pop-out effect" where a uniquely-colored
300
328
  // item captures attention even when its luminance is similar to surroundings.
301
329
  // Without this, teal text on white scores LOWER than black text on white
302
330
  // because teal has less luminance contrast — the opposite of perceptual reality.
303
- const wL = 1.0; // luminance weight
304
- const wA = 3.0; // chrominance a* weight (red-green axis)
305
- const wB = 3.0; // chrominance b* weight (blue-yellow axis)
306
331
  const meanDist = wL * (center.meanL - sMeanL) ** 2 +
307
332
  wA * (center.meanA - sMeanA) ** 2 +
308
333
  wB * (center.meanB - sMeanB) ** 2;
@@ -313,12 +338,37 @@ export async function computeLabSaliency(imagePath, cellSize = 16, surroundRadiu
313
338
  }
314
339
  }
315
340
  // Normalize to 0-1
316
- const maxSal = Math.max(...Array.from(saliency), 0.001);
341
+ const maxSal = Math.max(...Array.from(saliency), normFloor);
317
342
  for (let i = 0; i < numCells; i++)
318
343
  saliency[i] /= maxSal;
319
344
  return { cells: saliency, rows, cols, width: width / scale, height: height / scale };
320
345
  }
321
346
  function computeValueAttentionParams(filter, schwartzValues) {
347
+ const P = (id) => param(`attention.${id}`);
348
+ const mode = {
349
+ centerHeavyCenterBias: P("mode.centerHeavy.centerBias"),
350
+ motionAttractedExponent: P("mode.motionAttracted.exponent"),
351
+ textFocusedThreshold: P("mode.textFocused.threshold"),
352
+ textFocusedBias: P("mode.textFocused.bias"),
353
+ largeElementsThreshold: P("mode.largeElements.threshold"),
354
+ };
355
+ const neutral = P("value.neutral");
356
+ // Signed coefficients on (value - neutral): positive raises the parameter.
357
+ const mod = {
358
+ stimulationExponent: P("valueMod.stimulation.exponent"),
359
+ securityBias: P("valueMod.security.bias"),
360
+ securityThreshold: P("valueMod.security.threshold"),
361
+ achievementExponent: P("valueMod.achievement.exponent"),
362
+ conformityCenterBias: P("valueMod.conformity.centerBias"),
363
+ selfDirectionCenterBias: P("valueMod.selfDirection.centerBias"),
364
+ traditionThreshold: P("valueMod.tradition.threshold"),
365
+ };
366
+ const clamp = {
367
+ exponentMin: P("filter.exponentMin"),
368
+ exponentMax: P("filter.exponentMax"),
369
+ biasLimit: P("filter.biasLimit"),
370
+ thresholdMax: P("filter.thresholdMax"),
371
+ };
322
372
  // Start with the base attention mode
323
373
  let exponent = 1.0;
324
374
  let bias = 0.0;
@@ -327,45 +377,45 @@ function computeValueAttentionParams(filter, schwartzValues) {
327
377
  // Apply categorical attention mode (legacy — still used for accessibility personas)
328
378
  switch (filter.attentionMode) {
329
379
  case 'center-heavy':
330
- centerBias = 0.7;
380
+ centerBias = mode.centerHeavyCenterBias;
331
381
  break;
332
382
  case 'motion-attracted':
333
- exponent = 0.5; // square root = more things register
383
+ exponent = mode.motionAttractedExponent; // square root = more things register
334
384
  break;
335
385
  case 'text-focused':
336
- threshold = 0.2; // suppress low-saliency distractors
337
- bias = -0.1; // slightly suppress overall
386
+ threshold = mode.textFocusedThreshold; // suppress low-saliency distractors
387
+ bias = mode.textFocusedBias; // slightly suppress overall
338
388
  break;
339
389
  case 'large-elements':
340
- threshold = 0.3;
390
+ threshold = mode.largeElementsThreshold;
341
391
  break;
342
392
  }
343
393
  // Apply value-driven modulation (v18.54.0)
344
394
  if (schwartzValues) {
345
- const v = (key) => schwartzValues[key] ?? 0.5;
395
+ const v = (key) => schwartzValues[key] ?? neutral;
346
396
  // Stimulation: high → dispersed attention (notices novelty everywhere)
347
397
  // Anderson (2013): reward-seeking broadens attentional spotlight
348
- exponent -= (v('stimulation') - 0.5) * 0.4; // 0.9 stim → exponent -0.16
398
+ exponent += (v('stimulation') - neutral) * mod.stimulationExponent; // 0.9 stim → exponent -0.16
349
399
  // Security: high → vigilant scanning (boost everything slightly, lower threshold)
350
400
  // Pessoa (2009): threat-sensitive individuals show broadened attention
351
- bias += (v('security') - 0.5) * 0.15;
352
- threshold -= (v('security') - 0.5) * 0.1;
401
+ bias += (v('security') - neutral) * mod.securityBias;
402
+ threshold += (v('security') - neutral) * mod.securityThreshold;
353
403
  // Achievement: high → focused on high-value targets (increase exponent)
354
404
  // Concentrate attention on the most salient elements, ignore distractors
355
- exponent += (v('achievement') - 0.5) * 0.3;
405
+ exponent += (v('achievement') - neutral) * mod.achievementExponent;
356
406
  // Conformity: high → center-biased (follows expected layout patterns)
357
407
  // F-pattern and Z-pattern readers look where convention says to look
358
- centerBias += (v('conformity') - 0.5) * 0.3;
408
+ centerBias += (v('conformity') - neutral) * mod.conformityCenterBias;
359
409
  // Self-direction: high → less center-biased (explores periphery)
360
- centerBias -= (v('selfDirection') - 0.5) * 0.2;
410
+ centerBias += (v('selfDirection') - neutral) * mod.selfDirectionCenterBias;
361
411
  // Tradition: high → suppresses novel elements (sticks to familiar patterns)
362
- threshold += (v('tradition') - 0.5) * 0.1;
412
+ threshold += (v('tradition') - neutral) * mod.traditionThreshold;
363
413
  }
364
414
  // Clamp values to sane ranges
365
- exponent = Math.max(0.3, Math.min(2.0, exponent));
366
- bias = Math.max(-0.3, Math.min(0.3, bias));
415
+ exponent = Math.max(clamp.exponentMin, Math.min(clamp.exponentMax, exponent));
416
+ bias = Math.max(-clamp.biasLimit, Math.min(clamp.biasLimit, bias));
367
417
  centerBias = Math.max(0, Math.min(1.0, centerBias));
368
- threshold = Math.max(0, Math.min(0.5, threshold));
418
+ threshold = Math.max(0, Math.min(clamp.thresholdMax, threshold));
369
419
  return { exponent, bias, centerBias, threshold };
370
420
  }
371
421
  /**
@@ -377,6 +427,11 @@ function computeValueAttentionParams(filter, schwartzValues) {
377
427
  */
378
428
  export function applyPersonaFilter(saliency, rows, cols, filter, schwartzValues) {
379
429
  const params = computeValueAttentionParams(filter, schwartzValues);
430
+ const belowThresholdGain = param("attention.filter.belowThresholdGain");
431
+ const slowProcessingBelow = param("attention.filter.slowProcessingBelow");
432
+ const speedThresholdBase = param("attention.filter.speedThresholdBase");
433
+ const speedThresholdSlope = param("attention.filter.speedThresholdSlope");
434
+ const normFloor = param("attention.normFloor");
380
435
  const filtered = new Float64Array(saliency.length);
381
436
  const centerRow = rows / 2;
382
437
  const centerCol = cols / 2;
@@ -387,7 +442,7 @@ export function applyPersonaFilter(saliency, rows, cols, filter, schwartzValues)
387
442
  let s = saliency[idx];
388
443
  // Apply threshold floor
389
444
  if (s < params.threshold) {
390
- s *= 0.2;
445
+ s *= belowThresholdGain;
391
446
  }
392
447
  // Apply power curve (exponent controls attention concentration)
393
448
  s = Math.pow(Math.max(0, s), params.exponent);
@@ -402,15 +457,15 @@ export function applyPersonaFilter(saliency, rows, cols, filter, schwartzValues)
402
457
  }
403
458
  }
404
459
  // Apply processing speed
405
- if (filter.processingSpeed < 0.8) {
406
- const speedThreshold = 0.3 + (1 - filter.processingSpeed) * 0.3;
460
+ if (filter.processingSpeed < slowProcessingBelow) {
461
+ const speedThreshold = speedThresholdBase + (1 - filter.processingSpeed) * speedThresholdSlope;
407
462
  for (let i = 0; i < filtered.length; i++) {
408
463
  if (filtered[i] < speedThreshold)
409
464
  filtered[i] *= filter.processingSpeed;
410
465
  }
411
466
  }
412
467
  // Renormalize
413
- const max = Math.max(...Array.from(filtered), 0.001);
468
+ const max = Math.max(...Array.from(filtered), normFloor);
414
469
  for (let i = 0; i < filtered.length; i++)
415
470
  filtered[i] /= max;
416
471
  return filtered;
@@ -421,60 +476,33 @@ export function applyPersonaFilter(saliency, rows, cols, filter, schwartzValues)
421
476
  * These represent how inherently attention-grabbing each element type is,
422
477
  * independent of visual appearance. A search bar is visually subtle but
423
478
  * a power user looks there first.
479
+ *
480
+ * The weights (cta 1.0 down to decorative 0.1, price and error 0.9) are
481
+ * registered as attention.type.*; with the spatial prior in place their
482
+ * spread contributes nothing measurable on UEyes (see that entry).
424
483
  */
425
- const ELEMENT_TYPE_WEIGHTS = {
426
- cta: 1.0, // Calls to action — highest inherent draw
427
- heading: 0.8, // Headings — structural anchors
428
- form: 0.7, // Form fields — interactive, task-relevant
429
- search: 0.7, // Search bars — navigation entry point
430
- image: 0.5, // Images — visual anchors
431
- navigation: 0.4, // Nav elements — wayfinding
432
- link: 0.3, // Regular links
433
- content: 0.2, // Body text
434
- decorative: 0.1, // Decorative images, spacers
435
- price: 0.9, // Prices — high commercial relevance
436
- error: 0.9, // Error messages — demand immediate attention
437
- };
484
+ const ELEMENT_TYPES = [
485
+ "cta", "heading", "form", "search", "image", "navigation", "link", "content", "decorative", "price", "error",
486
+ ];
487
+ function elementTypeWeights() {
488
+ const w = {};
489
+ for (const t of ELEMENT_TYPES)
490
+ w[t] = param(`attention.type.${t}`);
491
+ return w;
492
+ }
438
493
  /**
439
- * Persona trait → element type weight modifiers.
440
- * Each persona amplifies or suppresses attention to certain element types
441
- * based on their cognitive traits and goals.
494
+ * Persona name → element type weight modifiers, the fallback when no cognitive
495
+ * traits resolve for the persona (computeTraitModifiers is the normal path).
496
+ * The table lives in the registry as attention.personaMod.<persona>.<type>.
442
497
  */
443
- const PERSONA_ELEMENT_MODIFIERS = {
444
- "first-timer": {
445
- cta: 1.2, heading: 1.3, navigation: 1.2, search: 1.0, content: 0.6, decorative: 0.4,
446
- },
447
- "power-user": {
448
- cta: 0.6, heading: 0.5, navigation: 1.4, search: 1.5, content: 0.3, form: 1.2, decorative: 0.1,
449
- },
450
- "impatient-user": {
451
- cta: 1.5, heading: 1.0, navigation: 0.8, search: 1.3, content: 0.2, decorative: 0.1, price: 1.3,
452
- },
453
- "elderly-user": {
454
- cta: 1.0, heading: 1.4, navigation: 1.3, search: 0.8, content: 0.8, form: 0.9, decorative: 0.3,
455
- },
456
- "cognitive-adhd": {
457
- cta: 0.8, heading: 0.7, navigation: 0.5, image: 1.5, decorative: 1.2, content: 0.3, search: 0.6,
458
- },
459
- "anxious-user": {
460
- cta: 0.7, heading: 1.0, price: 1.5, error: 1.5, form: 0.8, decorative: 0.3,
461
- },
462
- "motor-impairment-tremor": {
463
- cta: 1.0, heading: 0.8, navigation: 0.6, form: 0.7, search: 0.9, content: 0.5,
464
- },
465
- "dyslexic-user": {
466
- cta: 1.1, heading: 1.3, image: 1.3, content: 0.4, navigation: 0.9, decorative: 0.5,
467
- },
468
- "low-vision-magnified": {
469
- cta: 1.2, heading: 1.5, navigation: 1.0, content: 0.5, image: 0.8, decorative: 0.2,
470
- },
471
- "mobile-user": {
472
- cta: 1.3, heading: 1.0, search: 1.2, navigation: 0.7, content: 0.4, decorative: 0.2,
473
- },
474
- "confident-user": {
475
- cta: 0.7, heading: 0.5, navigation: 1.3, search: 1.4, form: 1.2, content: 0.3, decorative: 0.1,
476
- },
477
- };
498
+ function staticPersonaModifiers(personaName) {
499
+ const out = {};
500
+ if (!Object.hasOwn(ATTENTION_STATIC_MODIFIER_TYPES, personaName))
501
+ return out;
502
+ for (const t of ATTENTION_STATIC_MODIFIER_TYPES[personaName])
503
+ out[t] = param(`attention.personaMod.${personaName}.${t}`);
504
+ return out;
505
+ }
478
506
  /**
479
507
  * How many ranked cells to keep as the hotspot pool.
480
508
  *
@@ -497,7 +525,7 @@ const PERSONA_ELEMENT_MODIFIERS = {
497
525
  * `attentionCompetitors`, a different field, so response size is unchanged.
498
526
  */
499
527
  function hotspotPoolSize(totalCells) {
500
- return Math.min(4000, Math.max(200, Math.round(totalCells * 0.05)));
528
+ return Math.min(param("attention.hotspotPool.max"), Math.max(param("attention.hotspotPool.min"), Math.round(totalCells * param("attention.hotspotPool.share"))));
501
529
  }
502
530
  /**
503
531
  * Compute trait-driven element modifiers from cognitive traits.
@@ -505,15 +533,18 @@ function hotspotPoolSize(totalCells) {
505
533
  * ANY persona's trait values, not just the 11 hardcoded ones.
506
534
  */
507
535
  function computeTraitModifiers(traits) {
508
- const t = (key) => traits[key] ?? 0.5;
536
+ const P = (id) => param(`attention.traitMod.${id}`);
537
+ const missing = P("missingTrait");
538
+ const reading = { threshold: P("reading.threshold"), base: P("reading.base"), slope: P("reading.slope") };
539
+ const t = (key) => traits[key] ?? missing;
509
540
  return {
510
541
  // Low patience → fixate on CTAs and search, skip content
511
- cta: 1.0 + (1.0 - t("patience")) * 0.5,
512
- search: 0.7 + (1.0 - t("patience")) * 0.6,
513
- content: 0.2 + t("patience") * 0.4,
542
+ cta: P("cta.base") + (1.0 - t("patience")) * P("cta.slope"),
543
+ search: P("search.base") + (1.0 - t("patience")) * P("search.slope"),
544
+ content: P("content.base") + t("patience") * P("content.slope"),
514
545
  // High curiosity → explore headings, images, more elements
515
- heading: 0.8 + t("curiosity") * 0.4,
516
- image: 0.5 + t("curiosity") * 0.5,
546
+ heading: P("heading.base") + t("curiosity") * P("heading.slope"),
547
+ image: P("image.base") + t("curiosity") * P("image.slope"),
517
548
  // Navigation weight is deliberately FAMILIARITY-INDEPENDENT.
518
549
  //
519
550
  // This read `0.4 + (1.0 - t("siteFamiliarity")) * 0.6`, which made the
@@ -533,32 +564,36 @@ function computeTraitModifiers(traits) {
533
564
  //
534
565
  // 0.7 is the previous formula evaluated at familiarity 0.5, so a persona
535
566
  // with a neutral (or absent) value sees no change at all.
536
- navigation: 0.7,
567
+ navigation: P("navigation"),
537
568
  // High readingTendency → content draws attention
538
569
  // (overrides patience for reading-heavy personas)
539
- ...(t("readingTendency") > 0.6 ? { content: 0.4 + t("readingTendency") * 0.4 } : {}),
570
+ ...(t("readingTendency") > reading.threshold ? { content: reading.base + t("readingTendency") * reading.slope } : {}),
540
571
  // Low selfEfficacy → error messages, help text draw attention
541
- error: 0.5 + (1.0 - t("selfEfficacy")) * 0.5,
572
+ error: P("error.base") + (1.0 - t("selfEfficacy")) * P("error.slope"),
542
573
  // High riskTolerance → prices don't scare, CTAs are natural
543
- price: 0.5 + (1.0 - t("riskTolerance")) * 0.5,
574
+ price: P("price.base") + (1.0 - t("riskTolerance")) * P("price.slope"),
544
575
  // Decorative: ADHD-like attention (low metacognitivePlanning = distracted by visuals)
545
- decorative: 0.1 + (1.0 - t("metacognitivePlanning")) * 0.4,
546
- form: 0.5 + t("proceduralFluency") * 0.3,
576
+ decorative: P("decorative.base") + (1.0 - t("metacognitivePlanning")) * P("decorative.slope"),
577
+ form: P("form.base") + t("proceduralFluency") * P("form.slope"),
547
578
  };
548
579
  }
580
+ /** Goal words that never count as keywords. */
581
+ const GOAL_STOP_WORDS = [
582
+ "the", "a", "an", "to", "for", "of", "in", "on", "at", "is", "it", "my", "i", "me", "and", "or", "how", "do", "can",
583
+ ];
549
584
  /**
550
585
  * Compute goal relevance boost for an element.
551
586
  * If the element's text matches the user's goal, massively boost its weight.
552
587
  * This is the "task-driven attention" component (Yarbus 1967).
553
588
  */
554
- function computeGoalRelevance(elementText, goal) {
589
+ function computeGoalRelevance(elementText, goal, minKeywordLength, gain) {
555
590
  if (!goal || !elementText)
556
591
  return 0;
557
592
  const goalLower = goal.toLowerCase();
558
593
  const textLower = elementText.toLowerCase();
559
594
  // Extract goal keywords (remove stop words)
560
- const stopWords = new Set(["the", "a", "an", "to", "for", "of", "in", "on", "at", "is", "it", "my", "i", "me", "and", "or", "how", "do", "can"]);
561
- const goalWords = goalLower.split(/\s+/).filter(w => w.length > 2 && !stopWords.has(w));
595
+ const stopWords = new Set(GOAL_STOP_WORDS);
596
+ const goalWords = goalLower.split(/\s+/).filter(w => w.length >= minKeywordLength && !stopWords.has(w));
562
597
  if (goalWords.length === 0)
563
598
  return 0;
564
599
  // Score based on keyword matches
@@ -571,9 +606,41 @@ function computeGoalRelevance(elementText, goal) {
571
606
  return 0;
572
607
  // Partial match: some keywords found
573
608
  const matchRatio = matches / goalWords.length;
574
- // 2.0x boost for full match, proportional for partial
575
- return matchRatio * 2.0;
609
+ // 2.0x boost for full match (attention.relevance.gain), proportional for partial
610
+ return matchRatio * gain;
576
611
  }
612
+ /**
613
+ * The element weights the semantic layer paints with for this persona: base
614
+ * weight per type, the persona's modifier per type (from its cognitive traits,
615
+ * else the static table, as buildSemanticMap chooses), the weight of an unknown
616
+ * type, and the goal-keyword constants.
617
+ *
618
+ * Exported for the in-page journey heatmap (ATTENTION_OVERLAY_SCRIPT in
619
+ * cognitive/index.ts), which draws inside the browser and cannot call this
620
+ * module. It received a hand-written copy of these tables that had drifted:
621
+ * its navigation weight still read siteFamiliarity, dropped here on
622
+ * 2026-08-05. It now receives this. (ISC-48.1, 2026-10-22;
623
+ * tests/attention-overlay-config.test.ts checks it against buildSemanticMap.)
624
+ */
625
+ export function semanticWeightTable(personaName, cognitiveTraits) {
626
+ return {
627
+ typeWeights: elementTypeWeights(),
628
+ personaMods: cognitiveTraits ? computeTraitModifiers(cognitiveTraits) : staticPersonaModifiers(personaName),
629
+ typeFallback: param("attention.type.fallback"),
630
+ relevanceGain: param("attention.relevance.gain"),
631
+ minKeywordLength: param("attention.goal.minKeywordLength"),
632
+ stopWords: [...GOAL_STOP_WORDS],
633
+ };
634
+ }
635
+ /** Text cues for Schwartz-value relevance, in the order their boosts apply. */
636
+ const VALUE_PATTERN_REGEX = {
637
+ trust: /secur|trust|verif|guarant|privacy|encrypt|safe|protect/i,
638
+ social: /review|rating|star|trusted.?by|\d+[kK]?\+?\s*users|testimonial/i,
639
+ novelty: /\bnew\b|beta|launch|updat|just.?added|latest|early.?access/i,
640
+ metrics: /\d+%|\d+x\s|roi|faster|improv|performance|\$\d/i,
641
+ urgency: /limited|only\s*\d|hurry|expire|last.?chance|act.?now/i,
642
+ community: /community|join|forum|together|open.?source|contrib/i,
643
+ };
577
644
  /**
578
645
  * Build a semantic attention map from DOM elements.
579
646
  *
@@ -607,20 +674,24 @@ relevanceScores,
607
674
  */
608
675
  typeWeights) {
609
676
  const map = new Float64Array(rows * cols);
610
- const TYPE_W = typeWeights ?? ELEMENT_TYPE_WEIGHTS;
677
+ const TYPE_W = typeWeights ?? elementTypeWeights();
678
+ const typeFallback = param("attention.type.fallback");
679
+ const relevanceGain = param("attention.relevance.gain");
680
+ const minKeywordLength = param("attention.goal.minKeywordLength");
681
+ const neutral = param("attention.value.neutral");
682
+ const edgeEpsilon = param("attention.grid.edgeEpsilon");
683
+ const normPercentile = param("attention.normPercentile");
684
+ const normFloor = param("attention.normFloor");
611
685
  // Use trait-driven modifiers if traits available, else fall back to static table
612
686
  const modifiers = cognitiveTraits
613
687
  ? computeTraitModifiers(cognitiveTraits)
614
- : (PERSONA_ELEMENT_MODIFIERS[personaName] || {});
615
- // Value-driven semantic patterns (imported from attention-quality)
616
- const VALUE_PATTERNS = {
617
- trust: { pattern: /secur|trust|verif|guarant|privacy|encrypt|safe|protect/i, values: { security: 1.0, conformity: 0.5 } },
618
- social: { pattern: /review|rating|star|trusted.?by|\d+[kK]?\+?\s*users|testimonial/i, values: { conformity: 1.0, security: 0.6 } },
619
- novelty: { pattern: /\bnew\b|beta|launch|updat|just.?added|latest|early.?access/i, values: { stimulation: 1.0, selfDirection: 0.5 } },
620
- metrics: { pattern: /\d+%|\d+x\s|roi|faster|improv|performance|\$\d/i, values: { achievement: 1.0, power: 0.4 } },
621
- urgency: { pattern: /limited|only\s*\d|hurry|expire|last.?chance|act.?now/i, values: { stimulation: 0.5, achievement: 0.4 } },
622
- community: { pattern: /community|join|forum|together|open.?source|contrib/i, values: { benevolence: 0.8, universalism: 0.6 } },
623
- };
688
+ : staticPersonaModifiers(personaName);
689
+ // Value-driven semantic patterns (imported from attention-quality). The
690
+ // weights are attention.valuePattern.<pattern>.<value>, summed in this order.
691
+ const VALUE_PATTERNS = Object.entries(VALUE_PATTERN_REGEX).map(([name, pattern]) => ({
692
+ pattern,
693
+ values: ATTENTION_VALUE_PATTERN_VALUES[name].map((v) => [v, param(`attention.valuePattern.${name}.${v}`)]),
694
+ }));
624
695
  for (const [elementIndex, el] of elements.entries()) {
625
696
  // Determine element type
626
697
  let elType = el.type || "content";
@@ -633,7 +704,7 @@ typeWeights) {
633
704
  else if (el.isDecorative)
634
705
  elType = "decorative";
635
706
  // Layer 1: Base weight by element type
636
- let weight = TYPE_W[elType] ?? 0.2;
707
+ let weight = TYPE_W[elType] ?? typeFallback;
637
708
  // Layer 2: Persona modifier (trait-driven or static lookup)
638
709
  const modifier = modifiers[elType] ?? 1.0;
639
710
  weight *= modifier;
@@ -646,22 +717,22 @@ typeWeights) {
646
717
  // is a floor to degrade to, never the intended path.
647
718
  const judged = relevanceScores?.[elementIndex];
648
719
  if (judged !== undefined && judged > 0) {
649
- weight *= (1.0 + judged * 2.0); // same 3x ceiling as the keyword path
720
+ weight *= (1.0 + judged * relevanceGain); // same 3x ceiling as the keyword path
650
721
  }
651
722
  else if (goal && el.text) {
652
- const goalBoost = computeGoalRelevance(el.text, goal);
723
+ const goalBoost = computeGoalRelevance(el.text, goal, minKeywordLength, relevanceGain);
653
724
  if (goalBoost > 0) {
654
725
  weight *= (1.0 + goalBoost);
655
726
  }
656
727
  }
657
728
  // Layer 4: Schwartz value relevance — elements matching persona values get boost
658
729
  if (schwartzValues && el.text) {
659
- for (const [, pattern] of Object.entries(VALUE_PATTERNS)) {
730
+ for (const pattern of VALUE_PATTERNS) {
660
731
  if (pattern.pattern.test(el.text)) {
661
732
  let valueBoost = 0;
662
- for (const [valueName, valueWeight] of Object.entries(pattern.values)) {
663
- const personaValue = schwartzValues[valueName] ?? 0.5;
664
- valueBoost += (personaValue - 0.5) * valueWeight;
733
+ for (const [valueName, valueWeight] of pattern.values) {
734
+ const personaValue = schwartzValues[valueName] ?? neutral;
735
+ valueBoost += (personaValue - neutral) * valueWeight;
665
736
  }
666
737
  weight *= (1.0 + Math.max(0, valueBoost));
667
738
  }
@@ -691,9 +762,9 @@ typeWeights) {
691
762
  const x0 = box.x, x1 = box.x + box.width;
692
763
  const y0 = box.y, y1 = box.y + box.height;
693
764
  const startCol = Math.max(0, Math.floor(x0 / pxPerCol));
694
- const endCol = Math.min(cols - 1, Math.floor((x1 - 1e-6) / pxPerCol));
765
+ const endCol = Math.min(cols - 1, Math.floor((x1 - edgeEpsilon) / pxPerCol));
695
766
  const startRow = Math.max(0, Math.floor(y0 / pxPerRow));
696
- const endRow = Math.min(rows - 1, Math.floor((y1 - 1e-6) / pxPerRow));
767
+ const endRow = Math.min(rows - 1, Math.floor((y1 - edgeEpsilon) / pxPerRow));
697
768
  for (let r = startRow; r <= endRow; r++) {
698
769
  const cellTop = r * pxPerRow;
699
770
  const overlapY = Math.max(0, Math.min(y1, cellTop + pxPerRow) - Math.max(y0, cellTop));
@@ -726,9 +797,9 @@ typeWeights) {
726
797
  // exceptional cells clip, which is standard practice for rendering saliency.
727
798
  const sorted = Array.from(map).filter((v) => v > 0).sort((a, b) => a - b);
728
799
  const p95 = sorted.length > 0
729
- ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))]
730
- : 0.001;
731
- const denom = Math.max(p95, 0.001);
800
+ ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * normPercentile))]
801
+ : normFloor;
802
+ const denom = Math.max(p95, normFloor);
732
803
  for (let i = 0; i < map.length; i++)
733
804
  map[i] = Math.min(1, map[i] / denom);
734
805
  return map;
@@ -769,13 +840,14 @@ typeWeights) {
769
840
  * attention for a specific persona. 0.05 is the best available estimate, not a
770
841
  * validated constant for the goal-directed case.
771
842
  */
772
- export function blendSaliencyMaps(visual, semantic, visualWeight = 0.05, semanticWeight = 0.95) {
843
+ export function blendSaliencyMaps(visual, semantic, visualWeight = param("attention.blend.legacyVisualWeight"), semanticWeight = param("attention.blend.legacySemanticWeight")) {
844
+ const normFloor = param("attention.normFloor");
773
845
  const blended = new Float64Array(visual.length);
774
846
  for (let i = 0; i < visual.length; i++) {
775
847
  blended[i] = visual[i] * visualWeight + semantic[i] * semanticWeight;
776
848
  }
777
849
  // Renormalize
778
- const max = Math.max(...Array.from(blended), 0.001);
850
+ const max = Math.max(...Array.from(blended), normFloor);
779
851
  for (let i = 0; i < blended.length; i++)
780
852
  blended[i] /= max;
781
853
  return blended;
@@ -810,8 +882,6 @@ export function blendSaliencyMaps(visual, semantic, visualWeight = 0.05, semanti
810
882
  *
811
883
  * Pass `spatialPrior: 0` to disable it for any of those cases.
812
884
  */
813
- const WEB_SPATIAL_PRIOR_ROWS = 12;
814
- const WEB_SPATIAL_PRIOR_COLS = 16;
815
885
  const WEB_SPATIAL_PRIOR = [
816
886
  0.3438, 0.4761, 0.6428, 0.8103, 0.9375, 1.0000, 0.9920, 0.9220, 0.8109, 0.6831, 0.5571, 0.4409, 0.3344, 0.2406, 0.1686, 0.1216,
817
887
  0.3225, 0.4491, 0.6086, 0.7704, 0.8973, 0.9657, 0.9669, 0.9055, 0.8004, 0.6767, 0.5533, 0.4380, 0.3308, 0.2359, 0.1636, 0.1172,
@@ -826,13 +896,63 @@ const WEB_SPATIAL_PRIOR = [
826
896
  0.1355, 0.1682, 0.2067, 0.2426, 0.2686, 0.2818, 0.2811, 0.2677, 0.2465, 0.2236, 0.2021, 0.1813, 0.1578, 0.1310, 0.1061, 0.0877,
827
897
  0.1150, 0.1385, 0.1658, 0.1908, 0.2086, 0.2173, 0.2160, 0.2056, 0.1899, 0.1736, 0.1587, 0.1442, 0.1278, 0.1094, 0.0923, 0.0795,
828
898
  ];
829
- /** Fitted operating point, all five CV folds unanimous. See `applyGazeCalibration`. */
830
- export const GAZE_CALIBRATION = { visualWeight: 0.2, sigmaCells: 2.5, spatialPrior: 1.0 };
899
+ /**
900
+ * The fitted table, checked against its registered checksum on every use.
901
+ *
902
+ * The 192 cells are registered as ONE fitted entry (attention.spatialPrior.checksum,
903
+ * with its shape as .rows and .cols) rather than one per cell; provenance/attention.ts
904
+ * says why. Reading the checksum through param() and refusing a table that no
905
+ * longer hashes to it is what ties this literal to the registry: a cell edited
906
+ * here without re-registering stops the model instead of shipping unrecorded.
907
+ */
908
+ function spatialPrior() {
909
+ const rows = param("attention.spatialPrior.rows");
910
+ const cols = param("attention.spatialPrior.cols");
911
+ const checksum = param("attention.spatialPrior.checksum");
912
+ if (WEB_SPATIAL_PRIOR.length !== rows * cols || WEB_SPATIAL_PRIOR_CHECKSUM !== checksum) {
913
+ throw new Error(`WEB_SPATIAL_PRIOR does not match its registration (${WEB_SPATIAL_PRIOR.length} cells, checksum ${WEB_SPATIAL_PRIOR_CHECKSUM}; ` +
914
+ `registered ${rows}x${cols}, checksum ${checksum}). Re-fit or re-register it in provenance/attention.ts.`);
915
+ }
916
+ return { table: WEB_SPATIAL_PRIOR, rows, cols };
917
+ }
918
+ const WEB_SPATIAL_PRIOR_CHECKSUM = tableChecksum(WEB_SPATIAL_PRIOR);
919
+ /**
920
+ * Fitted operating point, all five CV folds unanimous. See `applyGazeCalibration`.
921
+ *
922
+ * A live view of the registry (attention.gaze.*): each field reads its entry
923
+ * through param() when read, so this object cannot disagree with the values the
924
+ * model runs on.
925
+ */
926
+ export const GAZE_CALIBRATION = Object.freeze({
927
+ get visualWeight() { return param("attention.gaze.visualWeight"); },
928
+ get sigmaCells() { return param("attention.gaze.sigmaCells"); },
929
+ get spatialPrior() { return param("attention.gaze.spatialPrior"); },
930
+ });
931
+ /**
932
+ * The cell size the calibration was fitted at: research/study1 runs every
933
+ * stimulus at CELL = 16 (run-optimize.ts, run-semantic.ts). computeLabSaliency
934
+ * halves the image before celling, so one fitted cell spans 32 capture px and
935
+ * `sigmaCells` 2.5 is 80 capture px. Registered as attention.gaze.cellSize; this
936
+ * export is that entry's value, read once at load.
937
+ */
938
+ export const GAZE_CALIBRATION_CELL_SIZE = param("attention.gaze.cellSize");
939
+ /**
940
+ * `GAZE_CALIBRATION.sigmaCells` rescaled to a grid of `cellSize`, so the
941
+ * smoothing keeps its fitted width in pixels.
942
+ *
943
+ * The blur runs in cells, and until 2026-10-21 every caller passed 2.5 cells
944
+ * whatever its grid: attention_analysis (cellSize 4) smoothed at a quarter of the
945
+ * fitted width, journey_heatmap_gif (8) at half. Exactly 2.5 at 16, so the
946
+ * Study 1 numbers do not move.
947
+ */
948
+ export function gazeSigmaCells(cellSize) {
949
+ return param("attention.gaze.sigmaCells") * (param("attention.gaze.cellSize") / cellSize);
950
+ }
831
951
  /** Separable Gaussian blur over the cell grid. */
832
952
  export function smoothSaliency(map, rows, cols, sigma) {
833
953
  if (sigma <= 0)
834
954
  return map;
835
- const rad = Math.ceil(sigma * 3), inv = 1 / (2 * sigma * sigma), k = [];
955
+ const rad = Math.ceil(sigma * param("attention.smooth.radiusSigmas")), inv = 1 / (2 * sigma * sigma), k = [];
836
956
  for (let d = -rad; d <= rad; d++)
837
957
  k.push(Math.exp(-d * d * inv));
838
958
  const tmp = new Float64Array(map.length), out = new Float64Array(map.length);
@@ -877,16 +997,21 @@ export function smoothSaliency(map, rows, cols, sigma) {
877
997
  * 0.0005. Human inter-observer ceiling on the same data is 0.8112, so this
878
998
  * closes about 52% of the reachable gap. All five folds selected the same
879
999
  * values.
1000
+ *
1001
+ * `sigma` is in cells and its default is right only on the fitted grid
1002
+ * (GAZE_CALIBRATION_CELL_SIZE); on any other grid pass `gazeSigmaCells(cellSize)`.
1003
+ * The spatial prior needs no rescaling: it is looked up by normalised position.
880
1004
  */
881
- export function applyGazeCalibration(map, rows, cols, sigma = GAZE_CALIBRATION.sigmaCells, priorStrength = GAZE_CALIBRATION.spatialPrior) {
1005
+ export function applyGazeCalibration(map, rows, cols, sigma = param("attention.gaze.sigmaCells"), priorStrength = param("attention.gaze.spatialPrior")) {
882
1006
  let m = smoothSaliency(map, rows, cols, sigma);
883
1007
  if (priorStrength > 0) {
1008
+ const prior = spatialPrior();
884
1009
  const out = new Float64Array(m.length);
885
1010
  for (let r = 0; r < rows; r++) {
886
- const pr = Math.min(WEB_SPATIAL_PRIOR_ROWS - 1, Math.floor((r / rows) * WEB_SPATIAL_PRIOR_ROWS));
1011
+ const pr = Math.min(prior.rows - 1, Math.floor((r / rows) * prior.rows));
887
1012
  for (let c = 0; c < cols; c++) {
888
- const pc = Math.min(WEB_SPATIAL_PRIOR_COLS - 1, Math.floor((c / cols) * WEB_SPATIAL_PRIOR_COLS));
889
- const p = WEB_SPATIAL_PRIOR[pr * WEB_SPATIAL_PRIOR_COLS + pc];
1013
+ const pc = Math.min(prior.cols - 1, Math.floor((c / cols) * prior.cols));
1014
+ const p = prior.table[pr * prior.cols + pc];
890
1015
  out[r * cols + c] = m[r * cols + c] * (1 - priorStrength + priorStrength * p);
891
1016
  }
892
1017
  }
@@ -895,6 +1020,17 @@ export function applyGazeCalibration(map, rows, cols, sigma = GAZE_CALIBRATION.s
895
1020
  return m;
896
1021
  }
897
1022
  // ── Public API ──
1023
+ /** Metric definitions and display rounding shared by both analysis paths. */
1024
+ function readMetricParams() {
1025
+ return {
1026
+ entropyEpsilon: param("attention.metric.entropyEpsilon"),
1027
+ concentrationShare: param("attention.metric.concentrationShare"),
1028
+ competitors: param("attention.report.competitors"),
1029
+ r2: param("attention.rounding.hundredths"),
1030
+ r3: param("attention.rounding.thousandths"),
1031
+ r4: param("attention.rounding.tenThousandths"),
1032
+ };
1033
+ }
898
1034
  /**
899
1035
  * Compute persona-specific attention analysis for a screenshot.
900
1036
  *
@@ -902,7 +1038,8 @@ export function applyGazeCalibration(map, rows, cols, sigma = GAZE_CALIBRATION.s
902
1038
  * 1. Visual saliency — W₂ on CIE-Lab distributions (what POPS visually)
903
1039
  * 2. Semantic attention — DOM element classification × persona priorities (what MATTERS)
904
1040
  *
905
- * Blended at 5% visual / 95% semantic. The direction is the one the literature
1041
+ * Blended at 20% visual / 80% semantic (GAZE_CALIBRATION, refitted with the spatial
1042
+ * prior on 2026-08-04; 5% was the optimum before the prior existed). The direction is the one the literature
906
1043
  * predicts — task-driven attention dominates bottom-up saliency for goal-oriented
907
1044
  * users (Yarbus 1967, Henderson 2003, Tatler et al. 2011) — but the number is no
908
1045
  * longer read off those papers. It was swept against 495 UEyes web stimuli and
@@ -921,9 +1058,11 @@ export function applyGazeCalibration(map, rows, cols, sigma = GAZE_CALIBRATION.s
921
1058
  * are interchangeable. Typical runtime: 5-30ms vs 1500-3000ms for the full
922
1059
  * version.
923
1060
  */
924
- export async function analyzeAttentionFromDOM(viewportWidth, viewportHeight, domElements, personaName, cellSize = 16, schwartzValues, goal, cognitiveTraits, relevanceScores) {
1061
+ export async function analyzeAttentionFromDOM(viewportWidth, viewportHeight, domElements, personaName, cellSize = param("attention.gaze.cellSize"), schwartzValues, goal, cognitiveTraits, relevanceScores, accessibilityTraits) {
925
1062
  const startTime = performance.now();
926
- const profile = getPerceptualProfile(personaName);
1063
+ const M = readMetricParams();
1064
+ const resolvedProfile = resolvePerceptualProfile(personaName, accessibilityTraits);
1065
+ const profile = resolvedProfile.profile;
927
1066
  // Look up Schwartz values from built-in profiles if caller didn't pass any
928
1067
  let values = schwartzValues;
929
1068
  if (!values) {
@@ -966,9 +1105,17 @@ export async function analyzeAttentionFromDOM(viewportWidth, viewportHeight, dom
966
1105
  //
967
1106
  // This path exists so callers are INTERCHANGEABLE with analyzeAttention (its
968
1107
  // own docstring says so). Calibrating one and not the other would silently
969
- // make the fast path and the full path disagree about where a persona looks --
970
- // the live cognitive-journey overlay drawing a different map from the audit.
971
- const filtered = applyGazeCalibration(applyPersonaFilter(semanticMap, rows, cols, profile.visualFilter, values), rows, cols);
1108
+ // make the fast path and the full path disagree about where a persona looks.
1109
+ //
1110
+ // Sigma keeps the fitted width in PIXELS (80, at device pixel ratio 1). This
1111
+ // grid is not halved, so a cell here spans cellSize px, which is the cell
1112
+ // analyzeAttention gets from cellSize x downscale. gazeSigmaCells(cellSize)
1113
+ // alone smoothed this path at 40 px, half the fitted width, at every
1114
+ // cellSize (ISC-47, fixed 2026-10-22; tests/attention-dom-gaze-width.test.ts).
1115
+ // The live cognitive-journey overlay does NOT run this function: it draws
1116
+ // its own in-page copy (ATTENTION_OVERLAY_SCRIPT in cognitive/index.ts), so
1117
+ // no shipped overlay moved with this fix.
1118
+ const filtered = applyGazeCalibration(applyPersonaFilter(semanticMap, rows, cols, profile.visualFilter, values), rows, cols, gazeSigmaCells(cellSize * param("attention.lab.downscale")));
972
1119
  // Hotspots, entropy, concentration — same shape as analyzeAttention
973
1120
  const indexed = Array.from(filtered).map((s, i) => ({ s, i }));
974
1121
  indexed.sort((a, b) => b.s - a.s);
@@ -984,15 +1131,16 @@ export async function analyzeAttentionFromDOM(viewportWidth, viewportHeight, dom
984
1131
  for (let i = 0; i < filtered.length; i++) {
985
1132
  const p = filtered[i] / totalSaliency;
986
1133
  if (p > 0)
987
- entropy -= p * Math.log2(p + 1e-10);
1134
+ entropy -= p * Math.log2(p + M.entropyEpsilon);
988
1135
  }
989
1136
  entropy = entropy / Math.log2(filtered.length || 1);
990
1137
  const sorted = Array.from(filtered).sort((a, b) => b - a);
991
- const top20Count = Math.ceil(filtered.length * 0.2);
1138
+ const top20Count = Math.ceil(filtered.length * M.concentrationShare);
992
1139
  const top20Sum = sorted.slice(0, top20Count).reduce((a, b) => a + b, 0);
993
1140
  const concentration = top20Sum / totalSaliency;
994
1141
  return {
995
1142
  persona: personaName,
1143
+ perceptualProfile: { resolution: resolvedProfile.resolution, name: resolvedProfile.resolvedProfile },
996
1144
  saliencyMap: {
997
1145
  cells: filtered,
998
1146
  rows, cols,
@@ -1001,19 +1149,23 @@ export async function analyzeAttentionFromDOM(viewportWidth, viewportHeight, dom
1001
1149
  hotspots,
1002
1150
  },
1003
1151
  alignmentScore: 1, // No base to compare against
1004
- entropy: Math.round(entropy * 1000) / 1000,
1005
- concentration: Math.round(concentration * 1000) / 1000,
1006
- attentionCompetitors: hotspots.slice(0, 5).map((h) => ({
1152
+ entropy: Math.round(entropy * M.r3) / M.r3,
1153
+ concentration: Math.round(concentration * M.r3) / M.r3,
1154
+ attentionCompetitors: hotspots.slice(0, M.competitors).map((h) => ({
1007
1155
  region: `(${h.x}, ${h.y})`,
1008
- saliency: Math.round(h.saliency * 100) / 100,
1156
+ saliency: Math.round(h.saliency * M.r2) / M.r2,
1009
1157
  })),
1010
1158
  transportCost: 0,
1011
1159
  computeTimeMs: Math.round(performance.now() - startTime),
1012
1160
  };
1013
1161
  }
1014
- export async function analyzeAttention(screenshotPath, personaName, cellSize = 16, schwartzValues, domElements, goal, cognitiveTraits, relevanceScores) {
1162
+ export async function analyzeAttention(screenshotPath, personaName, cellSize = param("attention.gaze.cellSize"), schwartzValues, domElements, goal, cognitiveTraits, relevanceScores, accessibilityTraits) {
1015
1163
  const startTime = performance.now();
1016
- const profile = getPerceptualProfile(personaName);
1164
+ const M = readMetricParams();
1165
+ const downscale = param("attention.lab.downscale");
1166
+ const visualWeight = param("attention.gaze.visualWeight");
1167
+ const resolvedProfile = resolvePerceptualProfile(personaName, accessibilityTraits);
1168
+ const profile = resolvedProfile.profile;
1017
1169
  // v18.54.0: If no values passed, try to look them up from the built-in value profiles
1018
1170
  let values = schwartzValues;
1019
1171
  if (!values) {
@@ -1056,13 +1208,13 @@ export async function analyzeAttention(screenshotPath, personaName, cellSize = 1
1056
1208
  // blend to take more from the visual channel. Re-fitted WITH the prior and
1057
1209
  // smoothing in place -- all five CV folds selected 0.2. Carrying 0.05 over
1058
1210
  // would have been importing half of an optimum.
1059
- combinedSaliency = blendSaliencyMaps(baseSaliency.cells, semanticMap, GAZE_CALIBRATION.visualWeight, 1 - GAZE_CALIBRATION.visualWeight);
1211
+ combinedSaliency = blendSaliencyMaps(baseSaliency.cells, semanticMap, visualWeight, 1 - visualWeight);
1060
1212
  }
1061
1213
  else {
1062
1214
  // No DOM data — fall back to visual-only.
1063
1215
  //
1064
1216
  // This fallback changes what the heatmap MEANS, and said so nowhere. With
1065
- // DOM elements the map is 35% bottom-up saliency + 65% semantic (element
1217
+ // DOM elements the map is 20% bottom-up saliency + 80% semantic (element
1066
1218
  // type x persona priority) — a model of attention. Without them it is the
1067
1219
  // bottom-up layer alone: centre-surround colour contrast in CIE-Lab. Those
1068
1220
  // are different quantities under one name, and two of the three production
@@ -1077,16 +1229,17 @@ export async function analyzeAttention(screenshotPath, personaName, cellSize = 1
1077
1229
  }
1078
1230
  const attentionModel = domElements && domElements.length > 0 ? "visual+semantic" : "visual-only";
1079
1231
  // Apply persona perceptual filter + value-driven attention modulation, then
1080
- // foveal smoothing and the fitted spatial prior (see applyGazeCalibration).
1081
- const filtered = applyGazeCalibration(applyPersonaFilter(combinedSaliency, baseSaliency.rows, baseSaliency.cols, profile.visualFilter, values), baseSaliency.rows, baseSaliency.cols);
1232
+ // foveal smoothing and the fitted spatial prior (see applyGazeCalibration),
1233
+ // the smoothing scaled to this grid so its width in pixels is the fitted one.
1234
+ const filtered = applyGazeCalibration(applyPersonaFilter(combinedSaliency, baseSaliency.rows, baseSaliency.cols, profile.visualFilter, values), baseSaliency.rows, baseSaliency.cols, gazeSigmaCells(cellSize));
1082
1235
  // Build saliency map with hotspots
1083
1236
  const indexed = Array.from(filtered).map((s, i) => ({ s, i }));
1084
1237
  indexed.sort((a, b) => b.s - a.s);
1085
1238
  const hotspots = indexed.slice(0, hotspotPoolSize(indexed.length)).map(({ s, i }) => ({
1086
1239
  row: Math.floor(i / baseSaliency.cols),
1087
1240
  col: i % baseSaliency.cols,
1088
- x: (i % baseSaliency.cols) * cellSize * 2, // Scale back to original
1089
- y: Math.floor(i / baseSaliency.cols) * cellSize * 2,
1241
+ x: (i % baseSaliency.cols) * cellSize / downscale, // Scale back to original
1242
+ y: Math.floor(i / baseSaliency.cols) * cellSize / downscale,
1090
1243
  saliency: s,
1091
1244
  }));
1092
1245
  const saliencyMap = {
@@ -1094,7 +1247,7 @@ export async function analyzeAttention(screenshotPath, personaName, cellSize = 1
1094
1247
  /**
1095
1248
  * Which model produced `cells`.
1096
1249
  *
1097
- * "visual+semantic" — 35% centre-surround CIE-Lab saliency blended with 65%
1250
+ * "visual+semantic" — 20% centre-surround CIE-Lab saliency blended with 80%
1098
1251
  * DOM-semantic attention (element type x persona priority).
1099
1252
  * "visual-only" — the bottom-up layer alone, i.e. local colour-contrast
1100
1253
  * pop-out. Useful, but NOT a model of where a person looks.
@@ -1104,7 +1257,7 @@ export async function analyzeAttention(screenshotPath, personaName, cellSize = 1
1104
1257
  cols: baseSaliency.cols,
1105
1258
  width: baseSaliency.width,
1106
1259
  height: baseSaliency.height,
1107
- cellSize: cellSize * 2, // Scaled
1260
+ cellSize: cellSize / downscale, // Scaled
1108
1261
  hotspots,
1109
1262
  };
1110
1263
  // Compute attention metrics
@@ -1114,13 +1267,13 @@ export async function analyzeAttention(screenshotPath, personaName, cellSize = 1
1114
1267
  for (let i = 0; i < filtered.length; i++) {
1115
1268
  const p = filtered[i] / totalSaliency;
1116
1269
  if (p > 0)
1117
- entropy -= p * Math.log2(p + 1e-10);
1270
+ entropy -= p * Math.log2(p + M.entropyEpsilon);
1118
1271
  }
1119
1272
  const maxEntropy = Math.log2(filtered.length);
1120
1273
  entropy = entropy / maxEntropy; // Normalize to 0-1
1121
1274
  // Concentration: fraction of total saliency in top 20% of cells
1122
1275
  const sorted = Array.from(filtered).sort((a, b) => b - a);
1123
- const top20Count = Math.ceil(filtered.length * 0.2);
1276
+ const top20Count = Math.ceil(filtered.length * M.concentrationShare);
1124
1277
  const top20Sum = sorted.slice(0, top20Count).reduce((a, b) => a + b, 0);
1125
1278
  const concentration = top20Sum / totalSaliency;
1126
1279
  // Alignment: how well does the persona's saliency match the base (unfiltered) saliency?
@@ -1142,18 +1295,19 @@ export async function analyzeAttention(screenshotPath, personaName, cellSize = 1
1142
1295
  transportCost += Math.abs(baseSaliency.cells[i] / (totalSaliency || 1) - filtered[i] / (totalSaliency || 1));
1143
1296
  }
1144
1297
  // Attention competitors: group hotspots into regions
1145
- const attentionCompetitors = hotspots.slice(0, 5).map((h, i) => ({
1298
+ const attentionCompetitors = hotspots.slice(0, M.competitors).map((h, i) => ({
1146
1299
  region: `(${h.x}, ${h.y})`,
1147
- saliency: Math.round(h.saliency * 100) / 100,
1300
+ saliency: Math.round(h.saliency * M.r2) / M.r2,
1148
1301
  }));
1149
1302
  return {
1150
1303
  persona: personaName,
1304
+ perceptualProfile: { resolution: resolvedProfile.resolution, name: resolvedProfile.resolvedProfile },
1151
1305
  saliencyMap,
1152
- alignmentScore: Math.round(alignmentScore * 1000) / 1000,
1153
- entropy: Math.round(entropy * 1000) / 1000,
1154
- concentration: Math.round(concentration * 1000) / 1000,
1306
+ alignmentScore: Math.round(alignmentScore * M.r3) / M.r3,
1307
+ entropy: Math.round(entropy * M.r3) / M.r3,
1308
+ concentration: Math.round(concentration * M.r3) / M.r3,
1155
1309
  attentionCompetitors,
1156
- transportCost: Math.round(transportCost * 10000) / 10000,
1310
+ transportCost: Math.round(transportCost * M.r4) / M.r4,
1157
1311
  computeTimeMs: Math.round(performance.now() - startTime),
1158
1312
  };
1159
1313
  }
@@ -1162,11 +1316,17 @@ export async function analyzeAttention(screenshotPath, personaName, cellSize = 1
1162
1316
  * Returns the L1 divergence between their normalized saliency maps (described
1163
1317
  * elsewhere as Wasserstein; it is not, see attentionDivergence) and the
1164
1318
  * connected regions where attention differs most.
1319
+ *
1320
+ * `accessibilityTraitsA` / `B` are each persona's own accessibility traits, so
1321
+ * a persona without a built-in perceptual profile is filtered through one
1322
+ * synthesized from them, as analyzeAttention does when given them (ISC-48).
1165
1323
  */
1166
- export async function compareAttention(screenshotPath, personaA, personaB, cellSize = 16, domElements, goal) {
1324
+ export async function compareAttention(screenshotPath, personaA, personaB, cellSize = param("attention.gaze.cellSize"), domElements, goal, accessibilityTraitsA, accessibilityTraitsB) {
1325
+ const r4 = param("attention.rounding.tenThousandths");
1326
+ const regionCount = param("attention.report.divergentRegions");
1167
1327
  const [analysisA, analysisB] = await Promise.all([
1168
- analyzeAttention(screenshotPath, personaA, cellSize, undefined, domElements, goal),
1169
- analyzeAttention(screenshotPath, personaB, cellSize, undefined, domElements, goal),
1328
+ analyzeAttention(screenshotPath, personaA, cellSize, undefined, domElements, goal, undefined, undefined, accessibilityTraitsA),
1329
+ analyzeAttention(screenshotPath, personaB, cellSize, undefined, domElements, goal, undefined, undefined, accessibilityTraitsB),
1170
1330
  ]);
1171
1331
  // One pass computes the aggregate AND the regions from the same normalized
1172
1332
  // maps, so the regions are by construction the contributors to the number
@@ -1179,8 +1339,8 @@ export async function compareAttention(screenshotPath, personaA, personaB, cellS
1179
1339
  return {
1180
1340
  personaA: analysisA,
1181
1341
  personaB: analysisB,
1182
- attentionDivergence: Math.round(divergence * 10000) / 10000,
1183
- divergentRegions: regions.slice(0, 10),
1342
+ attentionDivergence: Math.round(divergence * r4) / r4,
1343
+ divergentRegions: regions.slice(0, regionCount),
1184
1344
  };
1185
1345
  }
1186
1346
  //# sourceMappingURL=attention-transport.js.map