cbrowser 20.2.2 → 21.0.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 (302) 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-questionnaire.js +1 -1
  114. package/dist/persona-questionnaire.js.map +1 -1
  115. package/dist/personas.d.ts.map +1 -1
  116. package/dist/personas.js +133 -82
  117. package/dist/personas.js.map +1 -1
  118. package/dist/recording/engine.d.ts.map +1 -1
  119. package/dist/recording/engine.js +5 -2
  120. package/dist/recording/engine.js.map +1 -1
  121. package/dist/recording/saliency-overlay.d.ts +8 -3
  122. package/dist/recording/saliency-overlay.d.ts.map +1 -1
  123. package/dist/recording/saliency-overlay.js +14 -5
  124. package/dist/recording/saliency-overlay.js.map +1 -1
  125. package/dist/recording/types.js +1 -1
  126. package/dist/testing/nl-test-suite.d.ts.map +1 -1
  127. package/dist/testing/nl-test-suite.js +2 -1
  128. package/dist/testing/nl-test-suite.js.map +1 -1
  129. package/dist/trait-reference.d.ts +19 -2
  130. package/dist/trait-reference.d.ts.map +1 -1
  131. package/dist/trait-reference.js +19 -2
  132. package/dist/trait-reference.js.map +1 -1
  133. package/dist/types.d.ts +32 -21
  134. package/dist/types.d.ts.map +1 -1
  135. package/dist/types.js +62 -52
  136. package/dist/types.js.map +1 -1
  137. package/dist/values/persona-completeness.js +1 -1
  138. package/dist/values/persona-completeness.js.map +1 -1
  139. package/dist/visual/ab-comparison.d.ts.map +1 -1
  140. package/dist/visual/ab-comparison.js +4 -1
  141. package/dist/visual/ab-comparison.js.map +1 -1
  142. package/dist/visual/attention-quality.d.ts +1 -1
  143. package/dist/visual/attention-quality.js +1 -1
  144. package/dist/visual/attention-transport.d.ts +74 -10
  145. package/dist/visual/attention-transport.d.ts.map +1 -1
  146. package/dist/visual/attention-transport.js +353 -193
  147. package/dist/visual/attention-transport.js.map +1 -1
  148. package/dist/visual/cognitive-models.d.ts +77 -55
  149. package/dist/visual/cognitive-models.d.ts.map +1 -1
  150. package/dist/visual/cognitive-models.js +231 -104
  151. package/dist/visual/cognitive-models.js.map +1 -1
  152. package/dist/visual/cognitive-transport-chain.d.ts +328 -30
  153. package/dist/visual/cognitive-transport-chain.d.ts.map +1 -1
  154. package/dist/visual/cognitive-transport-chain.js +576 -353
  155. package/dist/visual/cognitive-transport-chain.js.map +1 -1
  156. package/dist/visual/cognitive-transport.d.ts +446 -13
  157. package/dist/visual/cognitive-transport.d.ts.map +1 -1
  158. package/dist/visual/cognitive-transport.js +1085 -245
  159. package/dist/visual/cognitive-transport.js.map +1 -1
  160. package/dist/visual/cross-browser.d.ts.map +1 -1
  161. package/dist/visual/cross-browser.js +8 -6
  162. package/dist/visual/cross-browser.js.map +1 -1
  163. package/dist/visual/ctc-bands.d.ts +91 -0
  164. package/dist/visual/ctc-bands.d.ts.map +1 -0
  165. package/dist/visual/ctc-bands.js +117 -0
  166. package/dist/visual/ctc-bands.js.map +1 -0
  167. package/dist/visual/ctc-reference.d.ts +287 -0
  168. package/dist/visual/ctc-reference.d.ts.map +1 -0
  169. package/dist/visual/ctc-reference.js +376 -0
  170. package/dist/visual/ctc-reference.js.map +1 -0
  171. package/dist/visual/demand-laws.d.ts +145 -0
  172. package/dist/visual/demand-laws.d.ts.map +1 -0
  173. package/dist/visual/demand-laws.js +220 -0
  174. package/dist/visual/demand-laws.js.map +1 -0
  175. package/dist/visual/distance-metrics.d.ts +23 -1
  176. package/dist/visual/distance-metrics.d.ts.map +1 -1
  177. package/dist/visual/distance-metrics.js +49 -18
  178. package/dist/visual/distance-metrics.js.map +1 -1
  179. package/dist/visual/dro-bound.d.ts +220 -0
  180. package/dist/visual/dro-bound.d.ts.map +1 -0
  181. package/dist/visual/dro-bound.js +341 -0
  182. package/dist/visual/dro-bound.js.map +1 -0
  183. package/dist/visual/exact-w1.d.ts +60 -0
  184. package/dist/visual/exact-w1.d.ts.map +1 -0
  185. package/dist/visual/exact-w1.js +247 -0
  186. package/dist/visual/exact-w1.js.map +1 -0
  187. package/dist/visual/human-input.d.ts.map +1 -1
  188. package/dist/visual/human-input.js +45 -41
  189. package/dist/visual/human-input.js.map +1 -1
  190. package/dist/visual/index.d.ts +2 -0
  191. package/dist/visual/index.d.ts.map +1 -1
  192. package/dist/visual/index.js +2 -0
  193. package/dist/visual/index.js.map +1 -1
  194. package/dist/visual/law-times-report.d.ts +49 -0
  195. package/dist/visual/law-times-report.d.ts.map +1 -0
  196. package/dist/visual/law-times-report.js +38 -0
  197. package/dist/visual/law-times-report.js.map +1 -0
  198. package/dist/visual/model-provenance.d.ts +151 -0
  199. package/dist/visual/model-provenance.d.ts.map +1 -0
  200. package/dist/visual/model-provenance.js +334 -0
  201. package/dist/visual/model-provenance.js.map +1 -0
  202. package/dist/visual/normal-cdf.d.ts +14 -0
  203. package/dist/visual/normal-cdf.d.ts.map +1 -0
  204. package/dist/visual/normal-cdf.js +26 -0
  205. package/dist/visual/normal-cdf.js.map +1 -0
  206. package/dist/visual/perceptual-transport.d.ts +28 -4
  207. package/dist/visual/perceptual-transport.d.ts.map +1 -1
  208. package/dist/visual/perceptual-transport.js +66 -32
  209. package/dist/visual/perceptual-transport.js.map +1 -1
  210. package/dist/visual/provenance/attention.d.ts +55 -0
  211. package/dist/visual/provenance/attention.d.ts.map +1 -0
  212. package/dist/visual/provenance/attention.js +188 -0
  213. package/dist/visual/provenance/attention.js.map +1 -0
  214. package/dist/visual/provenance/chain.d.ts +41 -0
  215. package/dist/visual/provenance/chain.d.ts.map +1 -0
  216. package/dist/visual/provenance/chain.js +66 -0
  217. package/dist/visual/provenance/chain.js.map +1 -0
  218. package/dist/visual/provenance/cif.d.ts +42 -0
  219. package/dist/visual/provenance/cif.d.ts.map +1 -0
  220. package/dist/visual/provenance/cif.js +116 -0
  221. package/dist/visual/provenance/cif.js.map +1 -0
  222. package/dist/visual/provenance/ctc-reference.d.ts +36 -0
  223. package/dist/visual/provenance/ctc-reference.d.ts.map +1 -0
  224. package/dist/visual/provenance/ctc-reference.js +96 -0
  225. package/dist/visual/provenance/ctc-reference.js.map +1 -0
  226. package/dist/visual/provenance/distance.d.ts +10 -0
  227. package/dist/visual/provenance/distance.d.ts.map +1 -0
  228. package/dist/visual/provenance/distance.js +72 -0
  229. package/dist/visual/provenance/distance.js.map +1 -0
  230. package/dist/visual/provenance/dro.d.ts +10 -0
  231. package/dist/visual/provenance/dro.d.ts.map +1 -0
  232. package/dist/visual/provenance/dro.js +2 -0
  233. package/dist/visual/provenance/dro.js.map +1 -0
  234. package/dist/visual/provenance/emotion.d.ts +26 -0
  235. package/dist/visual/provenance/emotion.d.ts.map +1 -0
  236. package/dist/visual/provenance/emotion.js +99 -0
  237. package/dist/visual/provenance/emotion.js.map +1 -0
  238. package/dist/visual/provenance/human-input.d.ts +19 -0
  239. package/dist/visual/provenance/human-input.d.ts.map +1 -0
  240. package/dist/visual/provenance/human-input.js +84 -0
  241. package/dist/visual/provenance/human-input.js.map +1 -0
  242. package/dist/visual/provenance/journey-group.d.ts +15 -0
  243. package/dist/visual/provenance/journey-group.d.ts.map +1 -0
  244. package/dist/visual/provenance/journey-group.js +15 -0
  245. package/dist/visual/provenance/journey-group.js.map +1 -0
  246. package/dist/visual/provenance/journey-mcp.d.ts +24 -0
  247. package/dist/visual/provenance/journey-mcp.d.ts.map +1 -0
  248. package/dist/visual/provenance/journey-mcp.js +54 -0
  249. package/dist/visual/provenance/journey-mcp.js.map +1 -0
  250. package/dist/visual/provenance/journey.d.ts +34 -0
  251. package/dist/visual/provenance/journey.d.ts.map +1 -0
  252. package/dist/visual/provenance/journey.js +184 -0
  253. package/dist/visual/provenance/journey.js.map +1 -0
  254. package/dist/visual/provenance/laws.d.ts +38 -0
  255. package/dist/visual/provenance/laws.d.ts.map +1 -0
  256. package/dist/visual/provenance/laws.js +40 -0
  257. package/dist/visual/provenance/laws.js.map +1 -0
  258. package/dist/visual/provenance/page-metrics.d.ts +42 -0
  259. package/dist/visual/provenance/page-metrics.d.ts.map +1 -0
  260. package/dist/visual/provenance/page-metrics.js +79 -0
  261. package/dist/visual/provenance/page-metrics.js.map +1 -0
  262. package/dist/visual/provenance/perceptual.d.ts +63 -0
  263. package/dist/visual/provenance/perceptual.d.ts.map +1 -0
  264. package/dist/visual/provenance/perceptual.js +115 -0
  265. package/dist/visual/provenance/perceptual.js.map +1 -0
  266. package/dist/visual/provenance/persona-rates.d.ts +84 -0
  267. package/dist/visual/provenance/persona-rates.d.ts.map +1 -0
  268. package/dist/visual/provenance/persona-rates.js +112 -0
  269. package/dist/visual/provenance/persona-rates.js.map +1 -0
  270. package/dist/visual/provenance/personas.d.ts +122 -0
  271. package/dist/visual/provenance/personas.d.ts.map +1 -0
  272. package/dist/visual/provenance/personas.js +298 -0
  273. package/dist/visual/provenance/personas.js.map +1 -0
  274. package/dist/visual/provenance/profiles.d.ts +39 -0
  275. package/dist/visual/provenance/profiles.d.ts.map +1 -0
  276. package/dist/visual/provenance/profiles.js +37 -0
  277. package/dist/visual/provenance/profiles.js.map +1 -0
  278. package/dist/visual/provenance/temporal.d.ts +21 -0
  279. package/dist/visual/provenance/temporal.d.ts.map +1 -0
  280. package/dist/visual/provenance/temporal.js +56 -0
  281. package/dist/visual/provenance/temporal.js.map +1 -0
  282. package/dist/visual/provenance/visual-distance.d.ts +22 -0
  283. package/dist/visual/provenance/visual-distance.d.ts.map +1 -0
  284. package/dist/visual/provenance/visual-distance.js +30 -0
  285. package/dist/visual/provenance/visual-distance.js.map +1 -0
  286. package/dist/visual/regression.d.ts.map +1 -1
  287. package/dist/visual/regression.js +14 -9
  288. package/dist/visual/regression.js.map +1 -1
  289. package/dist/visual/responsive.d.ts.map +1 -1
  290. package/dist/visual/responsive.js +4 -1
  291. package/dist/visual/responsive.js.map +1 -1
  292. package/dist/visual/scope-prose.js +2 -2
  293. package/dist/visual/scope-prose.js.map +1 -1
  294. package/dist/visual/seeded-random.d.ts +16 -0
  295. package/dist/visual/seeded-random.d.ts.map +1 -0
  296. package/dist/visual/seeded-random.js +25 -0
  297. package/dist/visual/seeded-random.js.map +1 -0
  298. package/dist/visual/stored-accessibility-traits.d.ts +27 -0
  299. package/dist/visual/stored-accessibility-traits.d.ts.map +1 -0
  300. package/dist/visual/stored-accessibility-traits.js +35 -0
  301. package/dist/visual/stored-accessibility-traits.js.map +1 -0
  302. package/package.json +1 -1
package/dist/cif-score.js CHANGED
@@ -11,16 +11,46 @@
11
11
  * 4. Sensory Fitness (15%) -- can they process without overload?
12
12
  * 5. Navigation Fitness (20%) -- can they get around?
13
13
  *
14
+ * Every number the score computes with is read through param() from
15
+ * visual/provenance/cif.ts, which says where it comes from: five are WCAG's,
16
+ * four are units or display choices, and the rest (the weights above among
17
+ * them) were set by hand. tests/cif-no-literals.test.ts keeps it that way.
18
+ *
19
+ * MISSING INPUTS follow one rule, stated with its rationale at cif.noData.score
20
+ * in visual/provenance/cif.ts: a component scores only from inputs the caller
21
+ * supplied, no assumed value ever stands in for a missing one, and a component
22
+ * with none of its inputs supplied is marked `measured: false`, scores
23
+ * cif.noData.score, says "Not measured" in its details, is listed in
24
+ * `notMeasured` and is never a top issue. `barriers` counts as supplied when it
25
+ * is an array, even an empty one (an audit ran and found nothing); undefined
26
+ * means no audit ran.
27
+ *
28
+ * CERTIFICATION needs every component measured. The grade is read from the
29
+ * composite as always, but platinum, gold, silver and bronze are issued only
30
+ * when all 25 components were measured; otherwise the certification is "none"
31
+ * and `certificationNote` says how many were not. An unmeasured component scores
32
+ * cif.noData.score, so without this a page with nothing measured certified
33
+ * bronze. (Alexa, 2026-10-24.)
34
+ *
14
35
  * @copyright 2026 Alexandria Eden alexandria.shai.eden@gmail.com https://cbrowser.ai
15
36
  * @license MIT
16
37
  * @since v19.0.0
17
38
  */
39
+ import { param } from "./visual/model-provenance.js";
40
+ import { bandSentence, isPercentile, rankStatement } from "./visual/ctc-bands.js";
18
41
  // ============================================================================
19
42
  // Utility functions
20
43
  // ============================================================================
21
- /** Clamp value between 0 and 100 */
44
+ /** Read every id in `ids` through param(); one object per call so the reads are recorded. */
45
+ function readParams(ids) {
46
+ const out = {};
47
+ for (const k in ids)
48
+ out[k] = param(ids[k]);
49
+ return out;
50
+ }
51
+ /** Clamp value between 0 and the top of the scale */
22
52
  function clamp(val) {
23
- return Math.max(0, Math.min(100, val));
53
+ return Math.max(0, Math.min(param("cif.scale.max"), val));
24
54
  }
25
55
  /** Sigmoid function centered at 0, scaled to [0, 1] */
26
56
  function sigmoid(x) {
@@ -28,19 +58,36 @@ function sigmoid(x) {
28
58
  }
29
59
  /** Weighted average of component scores */
30
60
  function weightedAvg(components) {
61
+ const empty = param("cif.emptyAverage");
31
62
  let totalWeight = 0;
32
63
  let totalScore = 0;
33
64
  for (const c of components) {
34
65
  totalScore += c.score * c.weight;
35
66
  totalWeight += c.weight;
36
67
  }
37
- return totalWeight > 0 ? totalScore / totalWeight : 50;
68
+ return totalWeight > 0 ? totalScore / totalWeight : empty;
38
69
  }
39
- /** Safe number that defaults if input is null/undefined/NaN */
40
- function safe(val, fallback) {
41
- if (val == null || isNaN(val))
42
- return fallback;
43
- return val;
70
+ /** True when the caller supplied a usable number (not null, undefined or NaN). */
71
+ function supplied(val) {
72
+ return val != null && !Number.isNaN(val);
73
+ }
74
+ /** A measured component score. */
75
+ function measured(score, details) {
76
+ return { score, details, measured: true };
77
+ }
78
+ /** A component none of whose inputs was supplied; `missing` names them. See cif.noData.score. */
79
+ function notMeasured(missing) {
80
+ return { score: param("cif.noData.score"), details: `Not measured: no ${missing} supplied`, measured: false };
81
+ }
82
+ /**
83
+ * Score at the knee of a two-slope piecewise component: where the slope from
84
+ * the free point reaches it. Derived rather than registered, so the branch
85
+ * above the knee always starts where the branch below ends. overloadThreshold
86
+ * stepped from 61 to 60 at its knee because its knee score was a separate
87
+ * constant (21.0).
88
+ */
89
+ function kneeScore(top, free, knee, slopeBelow) {
90
+ return top - (knee - free) * slopeBelow;
44
91
  }
45
92
  /** Count barriers of a given type */
46
93
  function countBarriersByType(barriers, type) {
@@ -48,97 +95,137 @@ function countBarriersByType(barriers, type) {
48
95
  return 0;
49
96
  return barriers.filter((b) => b.type === type).length;
50
97
  }
51
- /** Count barriers matching a substring in type or description */
52
- function countBarriersMatching(barriers, pattern) {
98
+ /**
99
+ * Does a barrier's type or description name `word`? As a whole word or its
100
+ * plural, with "_" and "-" as separators: "form_unlabeled", "long-form",
101
+ * "Form input" and "captions" match; "Information", "platform", "format",
102
+ * "performance", "conform" and "skipped" do not. A substring match counted
103
+ * empathy_audit's colour-only barrier ("Information conveyed by color alone")
104
+ * as a form barrier too (21.0).
105
+ */
106
+ function matchesBarrier(b, word) {
107
+ const named = new RegExp(`(^|[^\\p{L}\\p{N}])${word.toLowerCase()}s?([^\\p{L}\\p{N}]|$)`, "u");
108
+ return named.test(b.type.toLowerCase()) || named.test((b.description || "").toLowerCase());
109
+ }
110
+ /** Count barriers whose type or description names `word` (matchesBarrier). */
111
+ function countBarriersMatching(barriers, word) {
53
112
  if (!barriers)
54
113
  return 0;
55
- const p = pattern.toLowerCase();
56
- return barriers.filter((b) => b.type.toLowerCase().includes(p) ||
57
- (b.description || "").toLowerCase().includes(p)).length;
114
+ return barriers.filter((b) => matchesBarrier(b, word)).length;
115
+ }
116
+ /** empathy_audit's close-spacing barrier, which it weights as target_spacing. */
117
+ function isCloseSpacing(b) {
118
+ return b.weightKey === "target_spacing";
119
+ }
120
+ function countCloseSpacingBarriers(barriers) {
121
+ return barriers ? barriers.filter(isCloseSpacing).length : 0;
58
122
  }
59
123
  // ============================================================================
60
124
  // Category Scorers
61
125
  // ============================================================================
126
+ const MOTOR_IDS = {
127
+ scale: "cif.scale.max",
128
+ targetPx: "cif.wcag.targetSizeEnhancedPx",
129
+ touchBarrierPenalty: "cif.motor.touchTarget.barrierPenalty",
130
+ touchBarrierFloor: "cif.motor.touchTarget.barrierFloor",
131
+ hitMidpointPx: "cif.motor.hit.midpointPx",
132
+ hitScalePx: "cif.motor.hit.scalePx",
133
+ interactiveFree: "cif.motor.interactive.freeCount",
134
+ interactiveKnee: "cif.motor.interactive.kneeCount",
135
+ interactiveSlopeBelow: "cif.motor.interactive.slopeBelowKnee",
136
+ interactiveSlopeAbove: "cif.motor.interactive.slopeAboveKnee",
137
+ spacingBarrierPenalty: "cif.motor.spacing.barrierPenalty",
138
+ spacingGapPx: "cif.motor.spacing.minGapPx",
139
+ dragBarrierPenalty: "cif.motor.drag.barrierPenalty",
140
+ wTouchTarget: "cif.motor.weight.touchTargetCompliance",
141
+ wHit: "cif.motor.weight.hitProbability",
142
+ wInteraction: "cif.motor.weight.interactionCount",
143
+ wSpacing: "cif.motor.weight.spacingAdequacy",
144
+ wDrag: "cif.motor.weight.dragFreeAlternatives",
145
+ };
62
146
  function computeMotorFitness(data) {
147
+ const P = readParams(MOTOR_IDS);
148
+ const targets = data.touchTargets && data.touchTargets.length > 0 ? data.touchTargets : null;
63
149
  // --- touchTargetCompliance (30%) ---
64
150
  let touchTargetCompliance;
65
- if (data.touchTargets && data.touchTargets.length > 0) {
66
- const nonExempt = data.touchTargets.filter((t) => !t.exempt);
151
+ if (targets) {
152
+ const nonExempt = targets.filter((t) => !t.exempt);
67
153
  if (nonExempt.length === 0) {
68
- touchTargetCompliance = { score: 100, details: "All interactive elements are exempt from size requirements" };
154
+ touchTargetCompliance = measured(P.scale, "All interactive elements are exempt from size requirements");
69
155
  }
70
156
  else {
71
- const compliant = nonExempt.filter((t) => Math.min(t.width, t.height) >= 44).length;
72
- const pct = (compliant / nonExempt.length) * 100;
73
- touchTargetCompliance = {
74
- score: clamp(Math.round(pct)),
75
- details: `${compliant}/${nonExempt.length} interactive elements meet 44px minimum (${Math.round(pct)}%)`,
76
- };
157
+ const compliant = nonExempt.filter((t) => Math.min(t.width, t.height) >= P.targetPx).length;
158
+ const pct = (compliant / nonExempt.length) * P.scale;
159
+ touchTargetCompliance = measured(clamp(Math.round(pct)), `${compliant}/${nonExempt.length} interactive elements meet ${P.targetPx}px minimum (${Math.round(pct)}%)`);
77
160
  }
78
161
  }
79
- else {
80
- // Fall back to barrier count as proxy
162
+ else if (data.barriers) {
163
+ // No sizes: fall back to the audit's touch_target barriers
81
164
  const touchBarriers = countBarriersByType(data.barriers, "touch_target");
82
- touchTargetCompliance = {
83
- score: clamp(Math.max(30, 100 - touchBarriers * 15)),
84
- details: touchBarriers > 0
85
- ? `${touchBarriers} touch target barriers detected (no size data available)`
86
- : "No touch target data available -- estimated from barriers",
87
- };
165
+ touchTargetCompliance = measured(clamp(Math.max(P.touchBarrierFloor, P.scale - touchBarriers * P.touchBarrierPenalty)), touchBarriers > 0
166
+ ? `${touchBarriers} touch target barriers detected (no size data; scored from barriers)`
167
+ : "No touch target barriers detected (no size data; scored from barriers)");
168
+ }
169
+ else {
170
+ touchTargetCompliance = notMeasured("touch target sizes or audit barriers");
88
171
  }
89
172
  // --- hitProbability (25%) ---
90
173
  let hitProbability;
91
- if (data.touchTargets && data.touchTargets.length > 0) {
92
- const nonExempt = data.touchTargets.filter((t) => !t.exempt);
174
+ if (targets) {
175
+ const nonExempt = targets.filter((t) => !t.exempt);
93
176
  if (nonExempt.length === 0) {
94
- hitProbability = { score: 100, details: "No non-exempt interactive elements" };
177
+ hitProbability = measured(P.scale, "No non-exempt interactive elements");
95
178
  }
96
179
  else {
97
180
  const avgMinDim = nonExempt.reduce((sum, t) => sum + Math.min(t.width, t.height), 0) /
98
181
  nonExempt.length;
99
- const score = sigmoid((avgMinDim - 20) / 10) * 100;
100
- hitProbability = {
101
- score: clamp(Math.round(score)),
102
- details: `Average minimum dimension: ${Math.round(avgMinDim)}px (sigmoid-scored)`,
103
- };
182
+ const score = sigmoid((avgMinDim - P.hitMidpointPx) / P.hitScalePx) * P.scale;
183
+ hitProbability = measured(clamp(Math.round(score)), `Average minimum dimension: ${Math.round(avgMinDim)}px (sigmoid-scored)`);
104
184
  }
105
185
  }
106
186
  else {
107
- hitProbability = {
108
- score: 100,
109
- details: "No touch target barriers detected",
110
- };
187
+ hitProbability = notMeasured("touch target sizes");
111
188
  }
112
189
  // --- interactionCount (20%) ---
113
- // Under 20 interactive elements is fine; penalize progressively above that
114
- const interactiveCount = safe(data.interactiveCount, 50);
115
- const interactionCountScore = interactiveCount <= 20 ? 100
116
- : interactiveCount <= 40 ? clamp(Math.round(100 - (interactiveCount - 20) * 2.5))
117
- : clamp(Math.round(50 - (interactiveCount - 40) * 1));
118
- const interactionCount = {
119
- score: interactionCountScore,
120
- details: interactiveCount <= 20
190
+ // Under the free count interactive elements are fine; penalize progressively above that
191
+ let interactionCount;
192
+ if (supplied(data.interactiveCount)) {
193
+ const interactiveCount = data.interactiveCount;
194
+ const interactionCountScore = interactiveCount <= P.interactiveFree ? P.scale
195
+ : interactiveCount <= P.interactiveKnee ? clamp(Math.round(P.scale - (interactiveCount - P.interactiveFree) * P.interactiveSlopeBelow))
196
+ : clamp(Math.round(kneeScore(P.scale, P.interactiveFree, P.interactiveKnee, P.interactiveSlopeBelow) - (interactiveCount - P.interactiveKnee) * P.interactiveSlopeAbove));
197
+ interactionCount = measured(interactionCountScore, interactiveCount <= P.interactiveFree
121
198
  ? `${interactiveCount} interactive elements — manageable for motor impairments`
122
- : `${interactiveCount} interactive elements on page (fewer is easier for motor impairments)`,
123
- };
199
+ : `${interactiveCount} interactive elements on page (fewer is easier for motor impairments)`);
200
+ }
201
+ else {
202
+ interactionCount = notMeasured("interactive element count");
203
+ }
124
204
  // --- spacingAdequacy (15%) ---
125
- const touchTargetBarriers = countBarriersByType(data.barriers, "touch_target");
126
- const spacingScore = clamp(Math.round(100 - touchTargetBarriers * 15));
127
- const spacingAdequacy = {
128
- score: spacingScore,
129
- details: touchTargetBarriers > 0
130
- ? `${touchTargetBarriers} touch target/spacing barriers detected`
131
- : "No spacing barriers detected",
132
- };
205
+ // Reads only the audit's close-spacing barrier. touch_target barriers are SIZE
206
+ // findings and belong to touchTargetCompliance; counting them here too
207
+ // penalised a page twice for the same barrier.
208
+ let spacingAdequacy;
209
+ if (data.barriers) {
210
+ const closeSpacing = countCloseSpacingBarriers(data.barriers);
211
+ spacingAdequacy = measured(clamp(Math.round(P.scale - closeSpacing * P.spacingBarrierPenalty)), closeSpacing > 0
212
+ ? `${closeSpacing} close-spacing barriers detected (interactive elements under ${P.spacingGapPx}px apart)`
213
+ : "No close-spacing barriers detected");
214
+ }
215
+ else {
216
+ spacingAdequacy = notMeasured("audit barriers");
217
+ }
133
218
  // --- dragFreeAlternatives (10%) ---
134
- const dragBarriers = countBarriersMatching(data.barriers, "drag");
135
- const dragScore = clamp(100 - dragBarriers * 25);
136
- const dragFreeAlternatives = {
137
- score: dragScore,
138
- details: dragBarriers > 0
219
+ let dragFreeAlternatives;
220
+ if (data.barriers) {
221
+ const dragBarriers = countBarriersMatching(data.barriers, "drag");
222
+ dragFreeAlternatives = measured(clamp(P.scale - dragBarriers * P.dragBarrierPenalty), dragBarriers > 0
139
223
  ? `${dragBarriers} drag-dependent interactions found without alternatives`
140
- : "No drag-dependent interactions detected",
141
- };
224
+ : "No drag-dependent interactions detected");
225
+ }
226
+ else {
227
+ dragFreeAlternatives = notMeasured("audit barriers");
228
+ }
142
229
  const components = {
143
230
  touchTargetCompliance,
144
231
  hitProbability,
@@ -147,79 +234,101 @@ function computeMotorFitness(data) {
147
234
  dragFreeAlternatives,
148
235
  };
149
236
  const score = clamp(Math.round(weightedAvg([
150
- { score: touchTargetCompliance.score, weight: 0.30 },
151
- { score: hitProbability.score, weight: 0.25 },
152
- { score: interactionCount.score, weight: 0.20 },
153
- { score: spacingAdequacy.score, weight: 0.15 },
154
- { score: dragFreeAlternatives.score, weight: 0.10 },
237
+ { score: touchTargetCompliance.score, weight: P.wTouchTarget },
238
+ { score: hitProbability.score, weight: P.wHit },
239
+ { score: interactionCount.score, weight: P.wInteraction },
240
+ { score: spacingAdequacy.score, weight: P.wSpacing },
241
+ { score: dragFreeAlternatives.score, weight: P.wDrag },
155
242
  ])));
156
243
  return { score, components };
157
244
  }
245
+ const VISUAL_IDS = {
246
+ scale: "cif.scale.max",
247
+ normalText: "cif.wcag.contrastNormalText",
248
+ largeText: "cif.wcag.contrastLargeText",
249
+ contrastBarrierPenalty: "cif.visual.contrast.barrierPenalty",
250
+ proxySlope: "cif.visual.contrast.proxySlope",
251
+ proxyIntercept: "cif.visual.contrast.proxyIntercept",
252
+ densityKnee: "cif.visual.textScalability.densityKnee",
253
+ scalabilitySlope: "cif.visual.textScalability.slope",
254
+ resizePct: "cif.wcag.resizeTextPercent",
255
+ colorBarrierPenalty: "cif.visual.color.barrierPenalty",
256
+ wContrast: "cif.visual.weight.contrastCompliance",
257
+ wScalability: "cif.visual.weight.textScalability",
258
+ wColor: "cif.visual.weight.colorIndependence",
259
+ wFocus: "cif.visual.weight.focusVisibility",
260
+ wSpacing: "cif.visual.weight.spacingDensity",
261
+ };
158
262
  function computeVisualFitness(data) {
263
+ const P = readParams(VISUAL_IDS);
159
264
  // --- contrastCompliance (30%) ---
160
265
  let contrastCompliance;
266
+ const contrastBarriers = countBarriersByType(data.barriers, "contrast");
161
267
  if (data.contrastIssues && data.contrastIssues.length > 0) {
162
- // Use element count as proxy for total text elements
163
- const totalTextEstimate = Math.max(safe(data.elementCount, 50), data.contrastIssues.length);
164
- const failRatio = data.contrastIssues.length / totalTextEstimate;
165
- const score = clamp(Math.round(100 - failRatio * 100));
166
- const failingBelow45 = data.contrastIssues.filter((c) => !c.isLargeText && c.ratio < 4.5).length;
167
- const failingBelow3 = data.contrastIssues.filter((c) => c.isLargeText && c.ratio < 3).length;
168
- contrastCompliance = {
169
- score,
170
- details: `${data.contrastIssues.length} contrast issues (${failingBelow45} normal text below 4.5:1, ${failingBelow3} large text below 3:1)`,
171
- };
172
- }
173
- else {
174
- // Fall back to contrast barriers
175
- const contrastBarriers = countBarriersByType(data.barriers, "contrast");
176
- if (contrastBarriers > 0) {
177
- contrastCompliance = {
178
- score: clamp(100 - contrastBarriers * 10),
179
- details: `${contrastBarriers} contrast barriers detected`,
180
- };
268
+ const issues = data.contrastIssues.length;
269
+ const failingNormalText = data.contrastIssues.filter((c) => !c.isLargeText && c.ratio < P.normalText).length;
270
+ const failingLargeText = data.contrastIssues.filter((c) => c.isLargeText && c.ratio < P.largeText).length;
271
+ const breakdown = `${failingNormalText} normal text below ${P.normalText}:1, ${failingLargeText} large text below ${P.largeText}:1`;
272
+ if (supplied(data.elementCount)) {
273
+ // Share of elements with an issue, the element count standing in for the text elements checked
274
+ const failRatio = issues / Math.max(data.elementCount, issues);
275
+ contrastCompliance = measured(clamp(Math.round(P.scale - failRatio * P.scale)), `${issues} contrast issues among ${Math.max(data.elementCount, issues)} elements (${breakdown})`);
181
276
  }
182
277
  else {
183
- // Use agent_ready accessibility subscore as proxy
184
- const accessibilityProxy = safe(data.agentReadyScore, 60);
185
- contrastCompliance = {
186
- score: clamp(Math.round(accessibilityProxy * 0.8 + 20)),
187
- details: "No contrast data -- estimated from agent-ready accessibility score",
188
- };
278
+ // No element count, so no share: scored per issue, like contrast barriers
279
+ contrastCompliance = measured(clamp(P.scale - issues * P.contrastBarrierPenalty), `${issues} contrast issues (${breakdown}); no element count, so scored per issue`);
189
280
  }
190
281
  }
282
+ else if (contrastBarriers > 0) {
283
+ contrastCompliance = measured(clamp(P.scale - contrastBarriers * P.contrastBarrierPenalty), `${contrastBarriers} contrast barriers detected`);
284
+ }
285
+ else if (supplied(data.agentReadyScore)) {
286
+ // Use agent_ready accessibility subscore as proxy
287
+ contrastCompliance = measured(clamp(Math.round(data.agentReadyScore * P.proxySlope + P.proxyIntercept)), "No contrast data -- estimated from agent-ready accessibility score");
288
+ }
289
+ else {
290
+ contrastCompliance = notMeasured("contrast issues, contrast barriers or agent-ready score");
291
+ }
191
292
  // --- textScalability (20%) ---
192
- // Assume text scales correctly unless density is very high (which breaks at zoom)
193
- const density = safe(data.informationDensity, 0.5);
194
- const scalabilityDeduction = density > 0.6 ? (density - 0.6) * 80 : 0;
195
- const textScalability = {
196
- score: clamp(Math.round(100 - scalabilityDeduction)),
197
- details: scalabilityDeduction > 0
198
- ? `High density (${(density * 100).toFixed(0)}%) may cause overlap at 200% zoom (-${Math.round(scalabilityDeduction)})`
199
- : "Text content can scale to 200% without loss of functionality",
200
- };
293
+ // A proxy: nothing is zoomed. Penalised only when density is high enough that
294
+ // zooming would plausibly break the layout; the details say it was not checked.
295
+ let textScalability;
296
+ if (supplied(data.informationDensity)) {
297
+ const density = data.informationDensity;
298
+ const scalabilityDeduction = density > P.densityKnee ? (density - P.densityKnee) * P.scalabilitySlope : 0;
299
+ textScalability = measured(clamp(Math.round(P.scale - scalabilityDeduction)), scalabilityDeduction > 0
300
+ ? `High density (${(density * P.scale).toFixed(0)}%) may cause overlap at ${P.resizePct}% zoom (-${Math.round(scalabilityDeduction)}); estimated from density, the page was not zoomed`
301
+ : `Information density ${(density * P.scale).toFixed(0)}% is at or below ${(P.densityKnee * P.scale).toFixed(0)}%, so no deduction; resizing text to ${P.resizePct}% (WCAG 1.4.4) was not checked`);
302
+ }
303
+ else {
304
+ textScalability = notMeasured("information density");
305
+ }
201
306
  // --- colorIndependence (20%) ---
202
- const colorOnlyBarriers = countBarriersMatching(data.barriers, "color");
203
- const colorScore = clamp(100 - colorOnlyBarriers * 20);
204
- const colorIndependence = {
205
- score: colorScore,
206
- details: colorOnlyBarriers > 0
307
+ let colorIndependence;
308
+ if (data.barriers) {
309
+ const colorOnlyBarriers = countBarriersMatching(data.barriers, "color");
310
+ colorIndependence = measured(clamp(P.scale - colorOnlyBarriers * P.colorBarrierPenalty), colorOnlyBarriers > 0
207
311
  ? `${colorOnlyBarriers} color-only information barriers detected`
208
- : "No color-only information barriers detected",
209
- };
312
+ : "No color-only information barriers detected");
313
+ }
314
+ else {
315
+ colorIndependence = notMeasured("audit barriers");
316
+ }
210
317
  // --- focusVisibility (15%) ---
211
- const semanticProxy = safe(data.semanticScore, safe(data.agentReadyScore, 50));
212
- const focusScore = clamp(Math.round((semanticProxy / 100) * 100));
213
- const focusVisibility = {
214
- score: focusScore,
215
- details: `Estimated from semantic score (${Math.round(semanticProxy)}/100)`,
216
- };
318
+ let focusVisibility;
319
+ if (supplied(data.semanticScore)) {
320
+ focusVisibility = measured(clamp(Math.round(data.semanticScore)), `Estimated from semantic score (${Math.round(data.semanticScore)}/${P.scale})`);
321
+ }
322
+ else if (supplied(data.agentReadyScore)) {
323
+ focusVisibility = measured(clamp(Math.round(data.agentReadyScore)), `Estimated from agent-ready score (${Math.round(data.agentReadyScore)}/${P.scale})`);
324
+ }
325
+ else {
326
+ focusVisibility = notMeasured("semantic or agent-ready score");
327
+ }
217
328
  // --- spacingDensity (15%) ---
218
- const spacingDensityScore = clamp(Math.round(100 - density * 100));
219
- const spacingDensity = {
220
- score: spacingDensityScore,
221
- details: `Information density: ${(density * 100).toFixed(0)}% (lower is better for visual processing)`,
222
- };
329
+ const spacingDensity = supplied(data.informationDensity)
330
+ ? measured(clamp(Math.round(P.scale - data.informationDensity * P.scale)), `Information density: ${(data.informationDensity * P.scale).toFixed(0)}% (lower is better for visual processing)`)
331
+ : notMeasured("information density");
223
332
  const components = {
224
333
  contrastCompliance,
225
334
  textScalability,
@@ -228,82 +337,77 @@ function computeVisualFitness(data) {
228
337
  spacingDensity,
229
338
  };
230
339
  const score = clamp(Math.round(weightedAvg([
231
- { score: contrastCompliance.score, weight: 0.30 },
232
- { score: textScalability.score, weight: 0.20 },
233
- { score: colorIndependence.score, weight: 0.20 },
234
- { score: focusVisibility.score, weight: 0.15 },
235
- { score: spacingDensity.score, weight: 0.15 },
340
+ { score: contrastCompliance.score, weight: P.wContrast },
341
+ { score: textScalability.score, weight: P.wScalability },
342
+ { score: colorIndependence.score, weight: P.wColor },
343
+ { score: focusVisibility.score, weight: P.wFocus },
344
+ { score: spacingDensity.score, weight: P.wSpacing },
236
345
  ])));
237
346
  return { score, components };
238
347
  }
348
+ const COGNITIVE_IDS = {
349
+ scale: "cif.scale.max",
350
+ costDecimals: "cif.display.costDecimals",
351
+ decisionCostSlope: "cif.cognitive.decision.costSlope",
352
+ fleschGain: "cif.cognitive.readability.fleschGain",
353
+ densityFree: "cif.cognitive.density.freeCount",
354
+ densityKnee: "cif.cognitive.density.kneeCount",
355
+ densityDivisorBelow: "cif.cognitive.density.divisorBelowKnee",
356
+ densityDivisorAbove: "cif.cognitive.density.divisorAboveKnee",
357
+ captureGain: "cif.cognitive.attention.captureGain",
358
+ navFree: "cif.cognitive.predictability.freeCount",
359
+ navSlope: "cif.cognitive.predictability.slope",
360
+ wDecision: "cif.cognitive.weight.decisionComplexity",
361
+ wReadability: "cif.cognitive.weight.readability",
362
+ wDensity: "cif.cognitive.weight.informationDensity",
363
+ wAttention: "cif.cognitive.weight.attentionClarity",
364
+ wPredictability: "cif.cognitive.weight.predictability",
365
+ };
239
366
  function computeCognitiveFitness(data) {
367
+ const P = readParams(COGNITIVE_IDS);
240
368
  // --- decisionComplexity (25%) ---
241
- let decisionCost = 0;
242
- if (data.layers && data.layers.length > 0) {
243
- const decisionLayer = data.layers.find((l) => l.name.toLowerCase().includes("decision") || l.name.toLowerCase().includes("choice"));
244
- decisionCost = decisionLayer ? decisionLayer.cost : 0;
245
- }
246
- const decisionScore = clamp(Math.round(100 - decisionCost * 200));
247
- const decisionComplexity = {
248
- score: decisionScore,
249
- details: decisionCost > 0
250
- ? `Decision layer cost: ${decisionCost.toFixed(3)} (lower is better)`
251
- : "No CTC decision layer data available",
252
- };
369
+ const decisionLayer = data.layers?.find((l) => l.name.toLowerCase().includes("decision") || l.name.toLowerCase().includes("choice"));
370
+ const decisionComplexity = decisionLayer
371
+ ? measured(clamp(Math.round(P.scale - decisionLayer.cost * P.decisionCostSlope)), `Decision layer cost: ${decisionLayer.cost.toFixed(P.costDecimals)} (lower is better)`)
372
+ : notMeasured(data.layers && data.layers.length > 0 ? "decision or choice layer among the CTC layers" : "CTC layers");
253
373
  // --- readability (20%) ---
254
- const flesch = data.fleschScore;
255
- const readabilityScore = flesch != null
256
- ? clamp(Math.round(Math.min(100, flesch * 1.2)))
257
- : 60; // No data = assume mediocre readability (not optimistic)
258
- const readability = {
259
- score: readabilityScore,
260
- details: flesch != null
261
- ? `Flesch readability score: ${Math.round(flesch)} (higher = more readable)`
262
- : "No readability data available — estimated 60 (conservative)",
263
- };
374
+ const readability = supplied(data.fleschScore)
375
+ ? measured(clamp(Math.round(Math.min(P.scale, data.fleschScore * P.fleschGain))), `Flesch readability score: ${Math.round(data.fleschScore)} (higher = more readable)`)
376
+ : notMeasured("Flesch reading-ease score");
264
377
  // --- informationDensity (20%) ---
265
378
  // A well-structured page can have many elements without being overwhelming.
266
379
  // Only penalize when element count is extremely high relative to viewport.
267
- // Threshold: 200 elements is fine, 500+ starts getting dense, 2000+ is overwhelming.
268
- const elemCount = safe(data.elementCount, 100);
269
- const infoScore = elemCount <= 200 ? 100
270
- : elemCount <= 500 ? clamp(Math.round(100 - (elemCount - 200) / 6))
271
- : clamp(Math.round(50 - (elemCount - 500) / 30));
272
- const informationDensity = {
273
- score: infoScore,
274
- details: elemCount <= 200
380
+ // Threshold: the free count is fine, the knee starts getting dense, 2000+ is overwhelming.
381
+ let informationDensity;
382
+ if (supplied(data.elementCount)) {
383
+ const elemCount = data.elementCount;
384
+ const infoScore = elemCount <= P.densityFree ? P.scale
385
+ : elemCount <= P.densityKnee ? clamp(Math.round(P.scale - (elemCount - P.densityFree) / P.densityDivisorBelow))
386
+ : clamp(Math.round(kneeScore(P.scale, P.densityFree, P.densityKnee, 1 / P.densityDivisorBelow) - (elemCount - P.densityKnee) / P.densityDivisorAbove));
387
+ informationDensity = measured(infoScore, elemCount <= P.densityFree
275
388
  ? `${elemCount} elements — well within cognitive limits`
276
- : `${elemCount} elements on page (progressive penalty above 200)`,
277
- };
389
+ : `${elemCount} elements on page (progressive penalty above ${P.densityFree})`);
390
+ }
391
+ else {
392
+ informationDensity = notMeasured("element count");
393
+ }
278
394
  // --- attentionClarity (20%) ---
279
- const captureRate = data.ctaCaptureRate;
280
- const attentionScore = captureRate != null
281
- ? clamp(Math.round(Math.min(100, captureRate * 200)))
282
- : 60; // No data = assume mediocre CTA clarity (not optimistic)
283
- const attentionClarity = {
284
- score: attentionScore,
285
- details: captureRate != null
286
- ? `CTA capture rate: ${(captureRate * 100).toFixed(1)}% (higher = clearer attention guidance)`
287
- : "No attention data available — estimated 60 (conservative)",
288
- };
395
+ const attentionClarity = supplied(data.ctaCaptureRate)
396
+ ? measured(clamp(Math.round(Math.min(P.scale, data.ctaCaptureRate * P.captureGain))), `CTA capture rate: ${(data.ctaCaptureRate * P.scale).toFixed(1)}% (higher = clearer attention guidance)`)
397
+ : notMeasured("CTA capture rate");
289
398
  // --- predictability (15%) ---
290
- const navItems = safe(data.navItemCount, 5);
291
- let predictScore;
292
- if (navItems <= 7) {
293
- predictScore = 100;
294
- }
295
- else if (navItems <= 12) {
296
- predictScore = clamp(Math.round(100 - (navItems - 7) * 5));
399
+ let predictability;
400
+ if (supplied(data.navItemCount)) {
401
+ const navItems = data.navItemCount;
402
+ // One slope above the free count. The knee at 12 it replaced joined two equal
403
+ // slopes, so it changed no score (21.0).
404
+ predictability = measured(navItems <= P.navFree ? P.scale : clamp(Math.round(P.scale - (navItems - P.navFree) * P.navSlope)), navItems <= P.navFree
405
+ ? `${navItems} navigation items — clear, predictable structure`
406
+ : `${navItems} navigation items (${P.navFree} or fewer is optimal for cognitive predictability)`);
297
407
  }
298
408
  else {
299
- predictScore = clamp(Math.round(75 - (navItems - 12) * 5));
409
+ predictability = notMeasured("navigation item count");
300
410
  }
301
- const predictability = {
302
- score: predictScore,
303
- details: navItems <= 7
304
- ? `${navItems} navigation items — clear, predictable structure`
305
- : `${navItems} navigation items (7 or fewer is optimal for cognitive predictability)`,
306
- };
307
411
  const components = {
308
412
  decisionComplexity,
309
413
  readability,
@@ -312,71 +416,123 @@ function computeCognitiveFitness(data) {
312
416
  predictability,
313
417
  };
314
418
  const score = clamp(Math.round(weightedAvg([
315
- { score: decisionComplexity.score, weight: 0.25 },
316
- { score: readability.score, weight: 0.20 },
317
- { score: informationDensity.score, weight: 0.20 },
318
- { score: attentionClarity.score, weight: 0.20 },
319
- { score: predictability.score, weight: 0.15 },
419
+ { score: decisionComplexity.score, weight: P.wDecision },
420
+ { score: readability.score, weight: P.wReadability },
421
+ { score: informationDensity.score, weight: P.wDensity },
422
+ { score: attentionClarity.score, weight: P.wAttention },
423
+ { score: predictability.score, weight: P.wPredictability },
320
424
  ])));
321
425
  return { score, components };
322
426
  }
427
+ const SENSORY_IDS = {
428
+ scale: "cif.scale.max",
429
+ costDecimals: "cif.display.costDecimals",
430
+ controlledFloor: "cif.sensory.animation.controlledFloor",
431
+ controlledPenalty: "cif.sensory.animation.controlledPenalty",
432
+ uncontrolledPenalty: "cif.sensory.animation.uncontrolledPenalty",
433
+ audioBarrierPenalty: "cif.sensory.audio.barrierPenalty",
434
+ calmDensityWeight: "cif.sensory.calm.densityWeight",
435
+ calmAnimationPenalty: "cif.sensory.calm.animationPenalty",
436
+ // The verdict bands' cut points (provenance/ctc-reference.ts), not CIF's own:
437
+ // totalCTC is a percentile of real pages since 21.x, and CIF's 0.3 and 0.6
438
+ // were cuts on the logistic it used to be.
439
+ easyCTC: "ctcBands.upperHalf",
440
+ kneeCTC: "ctcBands.high",
441
+ overloadSlopeBelow: "cif.sensory.overload.slopeBelowKnee",
442
+ overloadSlopeAbove: "cif.sensory.overload.slopeAboveKnee",
443
+ wAnimation: "cif.sensory.weight.animationControl",
444
+ wIndependence: "cif.sensory.weight.sensoryIndependence",
445
+ wCalm: "cif.sensory.weight.visualCalm",
446
+ wLayout: "cif.sensory.weight.layoutConsistency",
447
+ wOverload: "cif.sensory.weight.overloadThreshold",
448
+ };
323
449
  function computeSensoryFitness(data) {
450
+ const P = readParams(SENSORY_IDS);
324
451
  // --- animationControl (30%) ---
325
- const animCount = safe(data.animationCount, 0);
326
- const hasMotionSupport = !!data.hasReducedMotionSupport;
327
- // If prefers-reduced-motion is supported, animations are user-controllable → score 95 (not 100, small tax for having them at all)
328
- // Without support, each animation is a hard penalty
329
- const animScore = hasMotionSupport
330
- ? clamp(Math.max(90, 100 - animCount)) // 90-100 range — minor tax for quantity
331
- : animCount === 0 ? 100 : clamp(Math.round(100 - animCount * 20));
332
- const animationControl = {
333
- score: animScore,
334
- details: animCount === 0
335
- ? "No animations detected"
336
- : hasMotionSupport
337
- ? `${animCount} animations detected — prefers-reduced-motion supported (user can disable)`
338
- : `${animCount} animations detected without motion preference support (each deducts 20 points)`,
339
- };
452
+ // With prefers-reduced-motion support, animations are user-controllable → a small tax per animation, down to the floor.
453
+ // Without support, each animation is a hard penalty. With animations and no support flag, the score is not measured.
454
+ const motionSupport = data.hasReducedMotionSupport;
455
+ let animationControl;
456
+ if (!supplied(data.animationCount)) {
457
+ animationControl = notMeasured("animation count");
458
+ }
459
+ else if (data.animationCount === 0) {
460
+ animationControl = measured(P.scale, "No animations detected");
461
+ }
462
+ else if (motionSupport === true) {
463
+ animationControl = measured(clamp(Math.max(P.controlledFloor, P.scale - data.animationCount * P.controlledPenalty)), // floor-to-top range — minor tax for quantity
464
+ `${data.animationCount} animations detected — prefers-reduced-motion supported (user can disable)`);
465
+ }
466
+ else if (motionSupport === false) {
467
+ animationControl = measured(clamp(Math.round(P.scale - data.animationCount * P.uncontrolledPenalty)), `${data.animationCount} animations detected without motion preference support (each deducts ${P.uncontrolledPenalty} points)`);
468
+ }
469
+ else {
470
+ animationControl = notMeasured(`prefers-reduced-motion support flag for the ${data.animationCount} animations detected`);
471
+ }
340
472
  // --- sensoryIndependence (25%) ---
341
- const captionBarriers = countBarriersMatching(data.barriers, "caption");
342
- const audioBarriers = countBarriersMatching(data.barriers, "audio");
343
- const sensoryDeductions = (captionBarriers + audioBarriers) * 15;
344
- const sensoryScore = clamp(100 - sensoryDeductions);
345
- const sensoryIndependence = {
346
- score: sensoryScore,
347
- details: sensoryDeductions > 0
348
- ? `${captionBarriers + audioBarriers} audio/caption barriers (each deducts 15)`
349
- : "No audio/caption accessibility barriers detected",
350
- };
473
+ let sensoryIndependence;
474
+ if (data.barriers) {
475
+ // Each barrier once, even when it mentions both captions and audio
476
+ const sensoryBarriers = data.barriers.filter((b) => matchesBarrier(b, "caption") || matchesBarrier(b, "audio")).length;
477
+ sensoryIndependence = measured(clamp(P.scale - sensoryBarriers * P.audioBarrierPenalty), sensoryBarriers > 0
478
+ ? `${sensoryBarriers} audio/caption barriers (each deducts ${P.audioBarrierPenalty})`
479
+ : "No audio/caption accessibility barriers detected");
480
+ }
481
+ else {
482
+ sensoryIndependence = notMeasured("audit barriers");
483
+ }
351
484
  // --- visualCalm (20%) ---
352
- const density = safe(data.informationDensity, 0.5);
353
- // If animations are user-controllable, they don't contribute to visual noise
354
- const animCalmPenalty = hasMotionSupport ? 0 : animCount * 15;
355
- const calmScore = clamp(Math.round(100 - density * 50 - animCalmPenalty));
356
- const visualCalm = {
357
- score: calmScore,
358
- details: hasMotionSupport
359
- ? `Information density: ${(density * 100).toFixed(0)}% (animations controllable via prefers-reduced-motion)`
360
- : `Information density: ${(density * 100).toFixed(0)}%, ${animCount} uncontrolled animations`,
361
- };
485
+ // Density is the input; animations adjust it only when their count and, if any, their motion support are known.
486
+ // If animations are user-controllable, they don't contribute to visual noise.
487
+ let visualCalm;
488
+ if (supplied(data.informationDensity)) {
489
+ const density = data.informationDensity;
490
+ const densityText = `Information density: ${(density * P.scale).toFixed(0)}%`;
491
+ let animCalmPenalty = 0;
492
+ let animText;
493
+ if (!supplied(data.animationCount)) {
494
+ animText = ", animations not measured";
495
+ }
496
+ else if (motionSupport === true) {
497
+ animText = " (animations controllable via prefers-reduced-motion)";
498
+ }
499
+ else if (motionSupport === false || data.animationCount === 0) {
500
+ animCalmPenalty = data.animationCount * P.calmAnimationPenalty;
501
+ animText = `, ${data.animationCount} uncontrolled animations`;
502
+ }
503
+ else {
504
+ animText = `, ${data.animationCount} animations (prefers-reduced-motion support not measured)`;
505
+ }
506
+ visualCalm = measured(clamp(Math.round(P.scale - density * P.calmDensityWeight - animCalmPenalty)), `${densityText}${animText}`);
507
+ }
508
+ else {
509
+ visualCalm = notMeasured("information density");
510
+ }
362
511
  // --- layoutConsistency (15%) ---
363
- const agentReady = safe(data.agentReadyScore, 70);
364
- const consistencyScore = clamp(Math.round(agentReady));
365
- const layoutConsistency = {
366
- score: consistencyScore,
367
- details: `Layout consistency derived from structural score (${Math.round(agentReady)}/100)`,
368
- };
512
+ const layoutConsistency = supplied(data.agentReadyScore)
513
+ ? measured(clamp(Math.round(data.agentReadyScore)), `Layout consistency derived from structural score (${Math.round(data.agentReadyScore)}/${P.scale})`)
514
+ : notMeasured("agent-ready score");
369
515
  // --- overloadThreshold (10%) ---
370
- // CTC ≤ 0.3 is "easy" per our scale, 0.3-0.6 moderate, 0.6-1.0 hard, >1.0 very hard
371
- const totalCTC = safe(data.totalCTC, 0.7);
372
- const overloadScore = totalCTC <= 0.3 ? 100
373
- : totalCTC <= 0.6 ? clamp(Math.round(100 - (totalCTC - 0.3) * 130))
374
- : clamp(Math.round(60 - (totalCTC - 0.6) * 100));
375
- const overloadThreshold = {
376
- score: overloadScore,
377
- details: totalCTC <= 0.3 ? `Total CTC: ${totalCTC.toFixed(3)} — within easy range`
378
- : `Total CTC: ${totalCTC.toFixed(3)} (lower = less cognitive overload risk)`,
379
- };
516
+ // totalCTC is a percentile of real pages. Full marks through the lower half
517
+ // (ctcBands.upperHalf), sloping to the knee at the high band (ctcBands.high),
518
+ // then steeper. The details state the rank; the CTC is untested against
519
+ // human data, so they say nothing about how easy the page is.
520
+ let overloadThreshold;
521
+ if (supplied(data.totalCTC) && !isPercentile(data.totalCTC)) {
522
+ // A value outside zero to one is from another scale (a raw cost, or a
523
+ // number from before 21.x): it has no rank to score.
524
+ overloadThreshold = notMeasured(`percentile total CTC (${data.totalCTC} is not a percentile, so it is from another scale and has no rank among the real pages we measured)`);
525
+ }
526
+ else if (supplied(data.totalCTC)) {
527
+ const totalCTC = data.totalCTC;
528
+ const overloadScore = totalCTC <= P.easyCTC ? P.scale
529
+ : totalCTC <= P.kneeCTC ? clamp(Math.round(P.scale - (totalCTC - P.easyCTC) * P.overloadSlopeBelow))
530
+ : clamp(Math.round(kneeScore(P.scale, P.easyCTC, P.kneeCTC, P.overloadSlopeBelow) - (totalCTC - P.kneeCTC) * P.overloadSlopeAbove));
531
+ overloadThreshold = measured(overloadScore, `Total CTC ${totalCTC.toFixed(P.costDecimals)}: ${rankStatement(totalCTC)}. ${bandSentence(totalCTC)}`);
532
+ }
533
+ else {
534
+ overloadThreshold = notMeasured("total CTC");
535
+ }
380
536
  const components = {
381
537
  animationControl,
382
538
  sensoryIndependence,
@@ -385,15 +541,27 @@ function computeSensoryFitness(data) {
385
541
  overloadThreshold,
386
542
  };
387
543
  const score = clamp(Math.round(weightedAvg([
388
- { score: animationControl.score, weight: 0.30 },
389
- { score: sensoryIndependence.score, weight: 0.25 },
390
- { score: visualCalm.score, weight: 0.20 },
391
- { score: layoutConsistency.score, weight: 0.15 },
392
- { score: overloadThreshold.score, weight: 0.10 },
544
+ { score: animationControl.score, weight: P.wAnimation },
545
+ { score: sensoryIndependence.score, weight: P.wIndependence },
546
+ { score: visualCalm.score, weight: P.wCalm },
547
+ { score: layoutConsistency.score, weight: P.wLayout },
548
+ { score: overloadThreshold.score, weight: P.wOverload },
393
549
  ])));
394
550
  return { score, components };
395
551
  }
552
+ const NAVIGATION_IDS = {
553
+ scale: "cif.scale.max",
554
+ keyboardBarrierPenalty: "cif.navigation.keyboard.barrierPenalty",
555
+ skipBarrierPenalty: "cif.navigation.skip.barrierPenalty",
556
+ formBarrierPenalty: "cif.navigation.form.barrierPenalty",
557
+ wSemantic: "cif.navigation.weight.semanticStructure",
558
+ wKeyboard: "cif.navigation.weight.keyboardOperability",
559
+ wSkip: "cif.navigation.weight.skipMechanisms",
560
+ wLinkButton: "cif.navigation.weight.linkButtonClarity",
561
+ wForm: "cif.navigation.weight.formAccessibility",
562
+ };
396
563
  function computeNavigationFitness(data) {
564
+ const P = readParams(NAVIGATION_IDS);
397
565
  // --- semanticStructure (25%) ---
398
566
  const subScores = [];
399
567
  if (data.semanticScore != null)
@@ -402,51 +570,71 @@ function computeNavigationFitness(data) {
402
570
  subScores.push(data.headingScore);
403
571
  if (data.landmarkScore != null)
404
572
  subScores.push(data.landmarkScore);
405
- const semanticAvg = subScores.length > 0
406
- ? subScores.reduce((a, b) => a + b, 0) / subScores.length
407
- : safe(data.agentReadyScore, 50);
408
- const semanticStructure = {
409
- score: clamp(Math.round(semanticAvg)),
410
- details: subScores.length > 0
411
- ? `Average of ${subScores.length} semantic sub-scores: ${Math.round(semanticAvg)}/100`
412
- : `Estimated from agent-ready score: ${Math.round(semanticAvg)}/100`,
413
- };
573
+ let semanticStructure;
574
+ if (subScores.length > 0) {
575
+ const semanticAvg = subScores.reduce((a, b) => a + b, 0) / subScores.length;
576
+ semanticStructure = measured(clamp(Math.round(semanticAvg)), `Average of ${subScores.length} semantic sub-scores: ${Math.round(semanticAvg)}/${P.scale}`);
577
+ }
578
+ else if (supplied(data.agentReadyScore)) {
579
+ semanticStructure = measured(clamp(Math.round(data.agentReadyScore)), `Estimated from agent-ready score: ${Math.round(data.agentReadyScore)}/${P.scale}`);
580
+ }
581
+ else {
582
+ semanticStructure = notMeasured("semantic, heading or landmark score, or agent-ready score");
583
+ }
414
584
  // --- keyboardOperability (25%) ---
415
- const keyboardBarriers = countBarriersMatching(data.barriers, "keyboard");
416
- const motorPrecision = countBarriersByType(data.barriers, "motor_precision");
417
- const kbDeductions = (keyboardBarriers + motorPrecision) * 15;
418
- const kbScore = clamp(100 - kbDeductions);
419
- const keyboardOperability = {
420
- score: kbScore,
421
- details: kbDeductions > 0
422
- ? `${keyboardBarriers + motorPrecision} keyboard/motor barriers detected`
423
- : "No keyboard accessibility barriers detected",
424
- };
585
+ let keyboardOperability;
586
+ if (data.barriers) {
587
+ // Each barrier once: a keyboard finding, or a motor_precision one other than
588
+ // the close-spacing barrier (spacingAdequacy counts that)
589
+ const kbBarriers = data.barriers.filter((b) => matchesBarrier(b, "keyboard") || (b.type === "motor_precision" && !isCloseSpacing(b))).length;
590
+ // empathy_audit's keyboard findings are hover-only and drag-without-keyboard
591
+ // interactions; the details say what it looks for and what it does not. The
592
+ // scope is the audit's, stated as such: a caller's list may hold other
593
+ // keyboard findings (a trap, an unreachable control), counted as found.
594
+ const auditScope = "keyboard reachability, traps or focus order";
595
+ keyboardOperability = measured(clamp(P.scale - kbBarriers * P.keyboardBarrierPenalty), kbBarriers > 0
596
+ ? `${kbBarriers} keyboard barriers detected; empathy_audit looks for hover-only and drag-without-keyboard interactions, not ${auditScope}`
597
+ : `No hover-only or drag-without-keyboard barriers detected; empathy_audit does not check ${auditScope}`);
598
+ }
599
+ else {
600
+ keyboardOperability = notMeasured("audit barriers");
601
+ }
425
602
  // --- skipMechanisms (15%) ---
426
- const skipBarriers = countBarriersMatching(data.barriers, "skip");
427
- const skipScore = clamp(100 - skipBarriers * 25);
428
- const skipMechanisms = {
429
- score: skipScore,
430
- details: skipBarriers > 0
603
+ // Measured only when a skip-link check is known to have run: the caller says
604
+ // so (skipLinksChecked), or the list holds a skip finding. empathy_audit has
605
+ // no skip-link detector, so its empty list said nothing about skip links, and
606
+ // this read a clean 100 on every page (21.0).
607
+ let skipMechanisms;
608
+ const skipBarriers = data.barriers ? countBarriersMatching(data.barriers, "skip") : 0;
609
+ if (!data.barriers) {
610
+ skipMechanisms = notMeasured("audit barriers");
611
+ }
612
+ else if (data.skipLinksChecked !== true && skipBarriers === 0) {
613
+ skipMechanisms = notMeasured("skip-link check");
614
+ }
615
+ else {
616
+ skipMechanisms = measured(clamp(P.scale - skipBarriers * P.skipBarrierPenalty), skipBarriers > 0
431
617
  ? `${skipBarriers} skip mechanism issues detected`
432
- : "No skip navigation issues detected",
433
- };
618
+ : "No skip navigation issues detected");
619
+ }
434
620
  // --- linkButtonClarity (15%) ---
435
- const ariaProxy = safe(data.ariaScore, 50);
436
- const linkButtonClarity = {
437
- score: clamp(Math.round(ariaProxy)),
438
- details: `ARIA label score: ${Math.round(ariaProxy)}/100`,
439
- };
621
+ const linkButtonClarity = supplied(data.ariaScore)
622
+ ? measured(clamp(Math.round(data.ariaScore)), `ARIA label score: ${Math.round(data.ariaScore)}/${P.scale}`)
623
+ : notMeasured("ARIA score");
440
624
  // --- formAccessibility (20%) ---
441
- let formScore = safe(data.formLabelScore, 60);
442
- const formBarriers = countBarriersMatching(data.barriers, "form");
443
- formScore = clamp(formScore - formBarriers * 10);
444
- const formAccessibility = {
445
- score: clamp(Math.round(formScore)),
446
- details: formBarriers > 0
447
- ? `Form label score: ${Math.round(safe(data.formLabelScore, 60))}/100, ${formBarriers} form barriers`
448
- : `Form label score: ${Math.round(safe(data.formLabelScore, 60))}/100`,
449
- };
625
+ // The label score is the base; the audit's form barriers deduct from it. Either alone is a measurement.
626
+ let formAccessibility;
627
+ if (supplied(data.formLabelScore) || data.barriers) {
628
+ const labelScore = supplied(data.formLabelScore) ? data.formLabelScore : null;
629
+ const formBarriers = data.barriers ? countBarriersMatching(data.barriers, "form") : 0;
630
+ const labelText = labelScore !== null ? `Form label score: ${Math.round(labelScore)}/${P.scale}` : "Form label score not measured";
631
+ const barrierText = !data.barriers ? ", form barriers not measured"
632
+ : formBarriers > 0 ? `, ${formBarriers} form barriers` : "";
633
+ formAccessibility = measured(clamp(Math.round((labelScore ?? P.scale) - formBarriers * P.formBarrierPenalty)), `${labelText}${barrierText}`);
634
+ }
635
+ else {
636
+ formAccessibility = notMeasured("form label score or audit barriers");
637
+ }
450
638
  const components = {
451
639
  semanticStructure,
452
640
  keyboardOperability,
@@ -455,92 +643,134 @@ function computeNavigationFitness(data) {
455
643
  formAccessibility,
456
644
  };
457
645
  const score = clamp(Math.round(weightedAvg([
458
- { score: semanticStructure.score, weight: 0.25 },
459
- { score: keyboardOperability.score, weight: 0.25 },
460
- { score: skipMechanisms.score, weight: 0.15 },
461
- { score: linkButtonClarity.score, weight: 0.15 },
462
- { score: formAccessibility.score, weight: 0.20 },
646
+ { score: semanticStructure.score, weight: P.wSemantic },
647
+ { score: keyboardOperability.score, weight: P.wKeyboard },
648
+ { score: skipMechanisms.score, weight: P.wSkip },
649
+ { score: linkButtonClarity.score, weight: P.wLinkButton },
650
+ { score: formAccessibility.score, weight: P.wForm },
463
651
  ])));
464
652
  return { score, components };
465
653
  }
466
654
  // ============================================================================
467
655
  // Grading & Certification
468
656
  // ============================================================================
469
- function computeGrade(composite) {
470
- if (composite >= 95)
657
+ const GRADE_IDS = {
658
+ aPlus: "cif.grade.aPlus",
659
+ a: "cif.grade.a",
660
+ b: "cif.grade.b",
661
+ c: "cif.grade.c",
662
+ d: "cif.grade.d",
663
+ };
664
+ /**
665
+ * The grade for a composite, from the registered cif.grade.* bands. Exported so
666
+ * a caller that grades an average of composites (the dashboard) uses these bands
667
+ * rather than a copy of their numbers.
668
+ */
669
+ export function computeGrade(composite) {
670
+ const G = readParams(GRADE_IDS);
671
+ if (composite >= G.aPlus)
471
672
  return "A+";
472
- if (composite >= 85)
673
+ if (composite >= G.a)
473
674
  return "A";
474
- if (composite >= 70)
675
+ if (composite >= G.b)
475
676
  return "B";
476
- if (composite >= 50)
677
+ if (composite >= G.c)
477
678
  return "C";
478
- if (composite >= 30)
679
+ if (composite >= G.d)
479
680
  return "D";
480
681
  return "F";
481
682
  }
683
+ const CERTIFICATION_IDS = {
684
+ platinum: "cif.certification.platinum",
685
+ gold: "cif.certification.gold",
686
+ silver: "cif.certification.silver",
687
+ bronze: "cif.certification.bronze",
688
+ };
482
689
  function computeCertification(motor, visual, cognitive, sensory, navigation) {
690
+ const C = readParams(CERTIFICATION_IDS);
483
691
  const all = [motor, visual, cognitive, sensory, navigation];
484
692
  const minScore = Math.min(...all);
485
- if (minScore >= 95)
693
+ if (minScore >= C.platinum)
486
694
  return "platinum";
487
- if (minScore >= 85)
695
+ if (minScore >= C.gold)
488
696
  return "gold";
489
- if (minScore >= 70)
697
+ if (minScore >= C.silver)
490
698
  return "silver";
491
- if (minScore >= 50)
699
+ if (minScore >= C.bronze)
492
700
  return "bronze";
493
701
  return "none";
494
702
  }
495
703
  // ============================================================================
496
704
  // Fix Recommendations
497
705
  // ============================================================================
498
- function generateFixRecommendation(category, component, score, data) {
706
+ // Every number a fix quotes is one of these, so the advice cannot drift from
707
+ // the scorer (tests/cif-fix-text.test.ts parses this file to hold it there).
708
+ const FIX_IDS = {
709
+ scale: "cif.scale.max",
710
+ targetPx: "cif.wcag.targetSizeEnhancedPx",
711
+ normalText: "cif.wcag.contrastNormalText",
712
+ largeText: "cif.wcag.contrastLargeText",
713
+ nonTextContrast: "cif.wcag.nonTextContrast",
714
+ resizePct: "cif.wcag.resizeTextPercent",
715
+ interactiveFree: "cif.motor.interactive.freeCount",
716
+ spacingGapPx: "cif.motor.spacing.minGapPx",
717
+ densityKnee: "cif.visual.textScalability.densityKnee",
718
+ choiceThreshold: "cif.fix.decision.choiceThreshold",
719
+ fleschGain: "cif.cognitive.readability.fleschGain",
720
+ elementsFree: "cif.cognitive.density.freeCount",
721
+ navFree: "cif.cognitive.predictability.freeCount",
722
+ };
723
+ /** Fix text for a measured component. Exported for tests; topIssues is the only caller. */
724
+ export function generateFixRecommendation(category, component, score, data) {
725
+ const F = readParams(FIX_IDS);
499
726
  const key = `${category}.${component}`;
500
727
  switch (key) {
501
728
  case "motor.touchTargetCompliance": {
502
729
  const count = data.touchTargets
503
- ? data.touchTargets.filter((t) => !t.exempt && Math.min(t.width, t.height) < 44).length
730
+ ? data.touchTargets.filter((t) => !t.exempt && Math.min(t.width, t.height) < F.targetPx).length
504
731
  : countBarriersByType(data.barriers, "touch_target");
505
- return `Increase ${count || "undersized"} interactive element sizes to at least 44x44px (WCAG 2.5.5 AAA)`;
732
+ return `Increase ${count || "undersized"} interactive element sizes to at least ${F.targetPx}x${F.targetPx}px (WCAG 2.5.5 AAA)`;
733
+ }
734
+ case "motor.hitProbability": {
735
+ const sizes = (data.touchTargets ?? []).filter((t) => !t.exempt).map((t) => Math.min(t.width, t.height));
736
+ const avg = sizes.length > 0 ? Math.round(sizes.reduce((a, b) => a + b, 0) / sizes.length) : null;
737
+ return `Increase interactive element sizes${avg !== null ? ` (smaller side ${avg}px on average)` : ""} toward ${F.targetPx}x${F.targetPx}px (WCAG 2.5.5 AAA) for reliable motor access`;
506
738
  }
507
- case "motor.hitProbability":
508
- return "Increase average interactive element dimensions -- target 48px minimum for reliable motor access";
509
739
  case "motor.interactionCount":
510
- return `Reduce interactive elements from ${safe(data.interactiveCount, 0)} to under 30 on a single view`;
740
+ return `Reduce interactive elements on a single view from ${data.interactiveCount} to ${F.interactiveFree} or fewer`;
511
741
  case "motor.spacingAdequacy":
512
- return "Add at least 8px spacing between adjacent interactive elements to prevent mis-taps";
742
+ return `Space adjacent interactive elements at least ${F.spacingGapPx}px apart (the gap below which the close-spacing barrier is reported) to prevent mis-taps`;
513
743
  case "motor.dragFreeAlternatives":
514
744
  return "Provide keyboard/click alternatives for all drag-and-drop interactions";
515
745
  case "visual.contrastCompliance": {
516
746
  const count = data.contrastIssues?.length || countBarriersByType(data.barriers, "contrast");
517
- return `Fix ${count} elements with contrast ratios below 4.5:1 for normal text or 3:1 for large text`;
747
+ return `Fix ${count} elements with contrast ratios below ${F.normalText}:1 for normal text or ${F.largeText}:1 for large text`;
518
748
  }
519
749
  case "visual.textScalability":
520
- return "Ensure text scales to 200% without loss of content or functionality (WCAG 1.4.4)";
750
+ return `Ensure text resizes to ${F.resizePct}% without loss of content or functionality (WCAG 1.4.4); this score is estimated from information density and the page was not zoomed`;
521
751
  case "visual.colorIndependence":
522
752
  return "Add non-color indicators (icons, patterns, text labels) wherever color conveys meaning";
523
753
  case "visual.focusVisibility":
524
- return "Ensure all interactive elements have visible focus indicators with at least 3:1 contrast";
754
+ return `Ensure every interactive element has a visible focus indicator (WCAG 2.4.7) with at least ${F.nonTextContrast}:1 contrast against adjacent colors (WCAG 1.4.11)`;
525
755
  case "visual.spacingDensity":
526
- return "Increase whitespace between content blocks -- reduce information density below 60%";
756
+ return `Increase whitespace between content blocks to lower information density${supplied(data.informationDensity) ? ` (now ${(data.informationDensity * F.scale).toFixed(0)}%)` : ""}; above ${(F.densityKnee * F.scale).toFixed(0)}% textScalability is penalised too`;
527
757
  case "cognitive.decisionComplexity": {
528
- const choices = safe(data.interactiveCount, 0);
529
- if (choices > 10) {
530
- return `Reduce choices from ${choices} to 5-7 options per decision point (Hick's Law)`;
758
+ const choices = data.interactiveCount;
759
+ if (supplied(choices) && choices > F.choiceThreshold) {
760
+ return `Reduce choices from ${choices} to ${F.choiceThreshold} or fewer per decision point (decision time grows with the number of options: Hick-Hyman law)`;
531
761
  }
532
762
  return "Simplify decision points -- group related options, provide defaults, reduce choices per step";
533
763
  }
534
764
  case "cognitive.readability":
535
- return `Simplify text to 8th-grade reading level (current Flesch: ${Math.round(safe(data.fleschScore, 0))})`;
765
+ return `Simplify text to raise Flesch reading ease from ${Math.round(data.fleschScore)} to ${Math.ceil(F.scale / F.fleschGain)} or more, where readability scores full marks`;
536
766
  case "cognitive.informationDensity":
537
- return `Reduce visible elements from ${safe(data.elementCount, 0)} -- use progressive disclosure to reveal content incrementally`;
767
+ return `Reduce visible elements from ${data.elementCount} toward ${F.elementsFree} -- use progressive disclosure to reveal content incrementally`;
538
768
  case "cognitive.attentionClarity":
539
769
  return "Make primary CTA visually dominant -- increase size, contrast, and whitespace around conversion elements";
540
770
  case "cognitive.predictability":
541
- return `Reduce navigation items from ${safe(data.navItemCount, 0)} to 7 or fewer -- group secondary items in submenus`;
771
+ return `Reduce navigation items from ${data.navItemCount} to ${F.navFree} or fewer -- group secondary items in submenus`;
542
772
  case "sensory.animationControl":
543
- return `Add prefers-reduced-motion support for ${safe(data.animationCount, 0)} animations and provide pause controls`;
773
+ return `Add prefers-reduced-motion support for ${data.animationCount} animations and provide pause controls`;
544
774
  case "sensory.sensoryIndependence":
545
775
  return "Add captions/transcripts for all audio and video content";
546
776
  case "sensory.visualCalm":
@@ -560,33 +790,27 @@ function generateFixRecommendation(category, component, score, data) {
560
790
  case "navigation.formAccessibility":
561
791
  return "Associate every form field with a visible label using <label for> or aria-labelledby";
562
792
  default:
563
- return `Improve ${component} in ${category} category (current score: ${score}/100)`;
793
+ return `Improve ${component} in ${category} category (current score: ${score}/${F.scale})`;
564
794
  }
565
795
  }
566
796
  // ============================================================================
567
797
  // Top Issues
568
798
  // ============================================================================
799
+ /** Every component of the five categories, in category order. */
800
+ function everyComponent(...categories) {
801
+ const names = ["motor", "visual", "cognitive", "sensory", "navigation"];
802
+ return categories.flatMap((cat, i) => Object.entries(cat.components).map(([component, val]) => ({
803
+ category: names[i], component, score: val.score, measured: val.measured,
804
+ })));
805
+ }
569
806
  function buildTopIssues(motor, visual, cognitive, sensory, navigation, data) {
570
- const allComponents = [];
571
- for (const [name, val] of Object.entries(motor.components)) {
572
- allComponents.push({ category: "motor", component: name, score: val.score });
573
- }
574
- for (const [name, val] of Object.entries(visual.components)) {
575
- allComponents.push({ category: "visual", component: name, score: val.score });
576
- }
577
- for (const [name, val] of Object.entries(cognitive.components)) {
578
- allComponents.push({ category: "cognitive", component: name, score: val.score });
579
- }
580
- for (const [name, val] of Object.entries(sensory.components)) {
581
- allComponents.push({ category: "sensory", component: name, score: val.score });
582
- }
583
- for (const [name, val] of Object.entries(navigation.components)) {
584
- allComponents.push({ category: "navigation", component: name, score: val.score });
585
- }
807
+ // A component that was not measured is not an issue found, so it is never listed
808
+ const allComponents = everyComponent(motor, visual, cognitive, sensory, navigation)
809
+ .filter((c) => c.measured);
586
810
  // Sort by score ascending (worst first)
587
811
  allComponents.sort((a, b) => a.score - b.score);
588
- // Return top 5
589
- return allComponents.slice(0, 5).map((c) => ({
812
+ // Return the top few
813
+ return allComponents.slice(0, param("cif.topIssues.count")).map((c) => ({
590
814
  category: c.category,
591
815
  component: c.component,
592
816
  score: c.score,
@@ -600,33 +824,58 @@ function buildTopIssues(motor, visual, cognitive, sensory, navigation, data) {
600
824
  * Compute the CIF (Cognitive Interface Fitness) score from raw tool data.
601
825
  *
602
826
  * Accepts data from empathy_audit, agent_ready_audit, cognitive_effort CTC,
603
- * page_understand, and attention_analysis. All fields are optional --
604
- * missing data produces conservative estimates with lower confidence.
827
+ * page_understand, and attention_analysis. All fields are optional; a
828
+ * component whose inputs are all missing is reported as not measured (see
829
+ * MISSING INPUTS at the top of this file), never estimated.
605
830
  */
831
+ const COMPOSITE_IDS = {
832
+ motor: "cif.weight.motor",
833
+ visual: "cif.weight.visual",
834
+ cognitive: "cif.weight.cognitive",
835
+ sensory: "cif.weight.sensory",
836
+ navigation: "cif.weight.navigation",
837
+ };
606
838
  export function computeCIFScore(data) {
839
+ const W = readParams(COMPOSITE_IDS);
607
840
  const motor = computeMotorFitness(data);
608
841
  const visual = computeVisualFitness(data);
609
842
  const cognitive = computeCognitiveFitness(data);
610
843
  const sensory = computeSensoryFitness(data);
611
844
  const navigation = computeNavigationFitness(data);
612
- const composite = clamp(Math.round(0.20 * motor.score +
613
- 0.20 * visual.score +
614
- 0.25 * cognitive.score +
615
- 0.15 * sensory.score +
616
- 0.20 * navigation.score));
845
+ const composite = clamp(Math.round(W.motor * motor.score +
846
+ W.visual * visual.score +
847
+ W.cognitive * cognitive.score +
848
+ W.sensory * sensory.score +
849
+ W.navigation * navigation.score));
617
850
  const grade = computeGrade(composite);
618
- const certification = computeCertification(motor.score, visual.score, cognitive.score, sensory.score, navigation.score);
619
851
  const topIssues = buildTopIssues(motor, visual, cognitive, sensory, navigation, data);
852
+ const components = everyComponent(motor, visual, cognitive, sensory, navigation);
853
+ const notMeasuredComponents = components
854
+ .filter((c) => !c.measured)
855
+ .map((c) => `${c.category}.${c.component}`);
856
+ // A certification speaks for the whole page, so it needs every component
857
+ // measured. The band is still computed from the category scores; it is only
858
+ // issued when nothing was left unmeasured.
859
+ const allMeasured = notMeasuredComponents.length === 0;
860
+ const certification = allMeasured
861
+ ? computeCertification(motor.score, visual.score, cognitive.score, sensory.score, navigation.score)
862
+ : "none";
863
+ const certificationNote = allMeasured
864
+ ? null
865
+ : `Not certified: ${notMeasuredComponents.length} of ${components.length} components not measured.`;
620
866
  return {
621
867
  composite,
622
868
  grade,
623
869
  certification,
870
+ notMeasuredCount: notMeasuredComponents.length,
871
+ certificationNote,
624
872
  motor,
625
873
  visual,
626
874
  cognitive,
627
875
  sensory,
628
876
  navigation,
629
877
  topIssues,
878
+ notMeasured: notMeasuredComponents,
630
879
  url: data.url,
631
880
  timestamp: new Date().toISOString(),
632
881
  };