@pie-players/pie-assessment-toolkit 0.3.74 → 1.0.0-beta.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 (257) hide show
  1. package/README.md +658 -714
  2. package/custom-elements.json +1315 -0
  3. package/dist/attempt/TestSession.d.ts +1 -21
  4. package/dist/attempt/TestSession.js +16 -102
  5. package/dist/components/ItemScope.custom-element.d.ts +1 -0
  6. package/dist/components/ItemScope.custom-element.js +1 -0
  7. package/dist/components/ItemToolBar.custom-element.js +1 -1
  8. package/dist/components/PieAssessmentToolkit.custom-element.js +14 -14
  9. package/dist/components/SectionToolBar.custom-element.js +1 -1
  10. package/dist/components/chunks/ItemToolBar-3zrc4p3d.js +4 -0
  11. package/dist/components/chunks/ItemToolBar-66hcwmfw.js +2 -0
  12. package/dist/components/chunks/ItemToolBar-9gqhfxtn.js +40 -0
  13. package/dist/components/chunks/ItemToolBar-hzc5wyjp.js +2 -0
  14. package/dist/components/chunks/{ItemToolBar-gmrryv2v.js → ItemToolBar-kkj69ptr.js} +1 -1
  15. package/dist/components/chunks/ItemToolBar-vdkyn9f3.js +2 -0
  16. package/dist/components/chunks/sre-engine-bj997w4b.js +1 -0
  17. package/dist/components/item-scope-element.d.ts +2 -0
  18. package/dist/components/item-scope-element.js +2 -0
  19. package/dist/context/assessment-toolkit-context.d.ts +33 -6
  20. package/dist/index.d.ts +25 -50
  21. package/dist/index.js +15 -45
  22. package/dist/policy/core/PolicySource.d.ts +2 -2
  23. package/dist/policy/core/ToolPolicyEngine.d.ts +118 -34
  24. package/dist/policy/core/ToolPolicyEngine.js +203 -56
  25. package/dist/policy/core/compose-decision.d.ts +28 -7
  26. package/dist/policy/core/compose-decision.js +95 -74
  27. package/dist/policy/core/decision-types.d.ts +98 -55
  28. package/dist/policy/core/feature-decision.d.ts +28 -19
  29. package/dist/policy/core/feature-decision.js +15 -11
  30. package/dist/policy/core/pnp-policy-inputs.d.ts +29 -35
  31. package/dist/policy/core/pnp-policy-inputs.js +40 -88
  32. package/dist/policy/core/provenance.d.ts +10 -5
  33. package/dist/policy/core/provenance.js +1 -1
  34. package/dist/policy/core/tool-parameters.d.ts +11 -0
  35. package/dist/policy/core/tool-parameters.js +15 -0
  36. package/dist/policy/engine.d.ts +7 -23
  37. package/dist/policy/engine.js +5 -20
  38. package/dist/policy/sources/PnpPolicySource.d.ts +43 -68
  39. package/dist/policy/sources/PnpPolicySource.js +133 -132
  40. package/dist/runtime/SectionControllerBinding.d.ts +283 -0
  41. package/dist/runtime/SectionControllerBinding.js +456 -0
  42. package/dist/runtime/SectionRuntimeEngine.d.ts +26 -289
  43. package/dist/runtime/SectionRuntimeEngine.js +30 -372
  44. package/dist/runtime/adapter/SectionEngineAdapter.d.ts +19 -80
  45. package/dist/runtime/adapter/SectionEngineAdapter.js +19 -92
  46. package/dist/runtime/adapter/dom-event-bridge.d.ts +15 -30
  47. package/dist/runtime/adapter/dom-event-bridge.js +17 -44
  48. package/dist/runtime/adapter/subscriber-fanout.d.ts +1 -3
  49. package/dist/runtime/adapter/subscriber-fanout.js +2 -6
  50. package/dist/runtime/catalog-context.d.ts +19 -0
  51. package/dist/runtime/catalog-context.js +28 -0
  52. package/dist/runtime/composition-emit-scheduler.d.ts +12 -18
  53. package/dist/runtime/composition-emit-scheduler.js +10 -16
  54. package/dist/runtime/content-language.d.ts +32 -0
  55. package/dist/runtime/content-language.js +46 -0
  56. package/dist/runtime/content-region.d.ts +13 -0
  57. package/dist/runtime/content-region.js +29 -0
  58. package/dist/runtime/content-registration.d.ts +13 -0
  59. package/dist/runtime/content-registration.js +25 -0
  60. package/dist/runtime/core/SectionEngineCore.d.ts +2 -4
  61. package/dist/runtime/core/SectionEngineCore.js +2 -6
  62. package/dist/runtime/core/cohort.d.ts +1 -6
  63. package/dist/runtime/core/cohort.js +1 -10
  64. package/dist/runtime/core/engine-input.d.ts +18 -30
  65. package/dist/runtime/core/engine-input.js +11 -12
  66. package/dist/runtime/core/engine-output.d.ts +17 -40
  67. package/dist/runtime/core/engine-output.js +9 -21
  68. package/dist/runtime/core/engine-readiness.d.ts +5 -37
  69. package/dist/runtime/core/engine-readiness.js +8 -41
  70. package/dist/runtime/core/engine-resolver.d.ts +46 -74
  71. package/dist/runtime/core/engine-resolver.js +18 -43
  72. package/dist/runtime/core/engine-state.d.ts +28 -36
  73. package/dist/runtime/core/engine-state.js +18 -21
  74. package/dist/runtime/core/engine-transition.d.ts +14 -19
  75. package/dist/runtime/core/engine-transition.js +74 -53
  76. package/dist/runtime/engine.d.ts +16 -19
  77. package/dist/runtime/engine.js +12 -19
  78. package/dist/runtime/page-warnings.d.ts +13 -0
  79. package/dist/runtime/page-warnings.js +22 -0
  80. package/dist/runtime/policy-input-forwarding.d.ts +25 -0
  81. package/dist/runtime/policy-input-forwarding.js +25 -0
  82. package/dist/runtime/registration-events.d.ts +22 -14
  83. package/dist/runtime/runtime-event-claim.d.ts +8 -0
  84. package/dist/runtime/runtime-event-claim.js +15 -0
  85. package/dist/runtime/runtime-id.d.ts +1 -1
  86. package/dist/runtime/runtime-id.js +1 -1
  87. package/dist/runtime/shell-event-bridge.d.ts +45 -0
  88. package/dist/runtime/shell-event-bridge.js +127 -0
  89. package/dist/runtime/shell-registration.d.ts +73 -0
  90. package/dist/runtime/shell-registration.js +95 -0
  91. package/dist/runtime/shell-scope.d.ts +68 -0
  92. package/dist/runtime/shell-scope.js +194 -0
  93. package/dist/runtime/tool-host-contract.d.ts +8 -14
  94. package/dist/runtime/tool-host-contract.js +12 -12
  95. package/dist/runtime/unclaimed-registration-watch.d.ts +7 -0
  96. package/dist/runtime/unclaimed-registration-watch.js +23 -0
  97. package/dist/services/AccessibilityCatalogResolver.d.ts +21 -31
  98. package/dist/services/AccessibilityCatalogResolver.js +29 -42
  99. package/dist/services/ElementToolStateStore.d.ts +42 -67
  100. package/dist/services/ElementToolStateStore.js +65 -71
  101. package/dist/services/HighlightCoordinator.d.ts +28 -40
  102. package/dist/services/HighlightCoordinator.js +459 -381
  103. package/dist/services/RangeSerializer.d.ts +11 -33
  104. package/dist/services/RangeSerializer.js +88 -59
  105. package/dist/services/SSMLExtractor.d.ts +8 -31
  106. package/dist/services/SSMLExtractor.js +18 -40
  107. package/dist/services/TTSService.d.ts +159 -119
  108. package/dist/services/TTSService.js +937 -822
  109. package/dist/services/ToolCoordinator.d.ts +42 -102
  110. package/dist/services/ToolCoordinator.js +178 -256
  111. package/dist/services/ToolRegistry.d.ts +152 -133
  112. package/dist/services/ToolRegistry.js +192 -178
  113. package/dist/services/ToolkitCoordinator.d.ts +233 -237
  114. package/dist/services/ToolkitCoordinator.js +615 -557
  115. package/dist/services/audio-handoff.d.ts +6 -7
  116. package/dist/services/audio-handoff.js +20 -14
  117. package/dist/services/catalog-owner.d.ts +0 -1
  118. package/dist/services/framework-error-bus.d.ts +3 -6
  119. package/dist/services/framework-error-bus.js +3 -6
  120. package/dist/services/framework-error.d.ts +51 -13
  121. package/dist/services/framework-error.js +19 -20
  122. package/dist/services/interfaces.d.ts +189 -184
  123. package/dist/services/section-controller-types.d.ts +7 -7
  124. package/dist/services/section-session-assignment.d.ts +21 -0
  125. package/dist/services/section-session-assignment.js +80 -0
  126. package/dist/services/spoken-audio-cards.d.ts +2 -6
  127. package/dist/services/spoken-audio-cards.js +2 -6
  128. package/dist/services/tool-config-defaults.d.ts +3 -3
  129. package/dist/services/tool-config-defaults.js +3 -3
  130. package/dist/services/tool-config-validation.d.ts +9 -12
  131. package/dist/services/tool-config-validation.js +47 -87
  132. package/dist/services/tool-context.d.ts +7 -31
  133. package/dist/services/tool-context.js +99 -65
  134. package/dist/services/tool-failure.d.ts +30 -0
  135. package/dist/services/tool-failure.js +11 -0
  136. package/dist/services/tool-icons.d.ts +5 -5
  137. package/dist/services/tool-icons.js +6 -5
  138. package/dist/services/tool-instance-id.d.ts +8 -7
  139. package/dist/services/tool-instance-id.js +12 -24
  140. package/dist/services/tool-providers/TTSToolProvider.d.ts +20 -58
  141. package/dist/services/tool-providers/TTSToolProvider.js +32 -85
  142. package/dist/services/tool-providers/ToolProviderApi.d.ts +10 -11
  143. package/dist/services/tool-providers/ToolProviderRegistry.d.ts +29 -60
  144. package/dist/services/tool-providers/ToolProviderRegistry.js +128 -144
  145. package/dist/services/tool-request.d.ts +31 -12
  146. package/dist/services/tool-request.js +31 -16
  147. package/dist/services/toolbar-decided-tools.d.ts +16 -0
  148. package/dist/services/toolbar-decided-tools.js +29 -0
  149. package/dist/services/toolbar-items.js +2 -5
  150. package/dist/services/tools-config-normalizer.d.ts +33 -29
  151. package/dist/services/tools-config-normalizer.js +26 -28
  152. package/dist/services/tts/browser-provider.d.ts +39 -6
  153. package/dist/services/tts/browser-provider.js +280 -160
  154. package/dist/services/tts/catalog-span-alignment.js +14 -7
  155. package/dist/services/tts/flat-tree.d.ts +64 -0
  156. package/dist/services/tts/flat-tree.js +269 -0
  157. package/dist/services/tts/generated-speech/assemble-plan.d.ts +3 -1
  158. package/dist/services/tts/generated-speech/assemble-plan.js +3 -1
  159. package/dist/services/tts/generated-speech/dom/build-dom-plan.d.ts +2 -0
  160. package/dist/services/tts/generated-speech/dom/build-dom-plan.js +1 -1
  161. package/dist/services/tts/generated-speech/dom/to-chunk-inputs.d.ts +2 -2
  162. package/dist/services/tts/generated-speech/index.d.ts +1 -1
  163. package/dist/services/tts/generated-speech/index.js +1 -1
  164. package/dist/services/tts/generated-speech/math-speech-cache.d.ts +3 -1
  165. package/dist/services/tts/generated-speech/math-speech-cache.js +5 -1
  166. package/dist/services/tts/generated-speech/types.d.ts +1 -1
  167. package/dist/services/tts/generated-speech/types.js +1 -1
  168. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +22 -16
  169. package/dist/services/tts/highlight-pipeline/prose-boundary-resolver.js +6 -4
  170. package/dist/services/tts/highlight-pipeline/readable-region.js +3 -2
  171. package/dist/services/tts/highlight-pipeline/types.d.ts +5 -0
  172. package/dist/services/tts/highlight-pipeline/visible-map-range.d.ts +12 -0
  173. package/dist/services/tts/highlight-pipeline/visible-map-range.js +73 -7
  174. package/dist/services/tts/highlight-target-resolver.d.ts +12 -0
  175. package/dist/services/tts/math-aware-text-processing.d.ts +5 -1
  176. package/dist/services/tts/math-aware-text-processing.js +42 -47
  177. package/dist/services/tts/math-control-names.d.ts +21 -0
  178. package/dist/services/tts/math-control-names.js +142 -0
  179. package/dist/services/tts/math-speech.d.ts +12 -1
  180. package/dist/services/tts/math-speech.js +54 -18
  181. package/dist/services/tts/mathml-sanitization.d.ts +7 -0
  182. package/dist/services/tts/mathml-sanitization.js +9 -1
  183. package/dist/services/tts/provider-options.d.ts +52 -0
  184. package/dist/services/tts/provider-options.js +1 -0
  185. package/dist/services/tts/ssml/spoken-text.d.ts +2 -26
  186. package/dist/services/tts/ssml/spoken-text.js +29 -0
  187. package/dist/services/tts/start-failure.d.ts +4 -0
  188. package/dist/services/tts/start-failure.js +19 -0
  189. package/dist/services/tts/text-processing.d.ts +9 -0
  190. package/dist/services/tts/text-processing.js +46 -33
  191. package/dist/services/tts/text-segmentation.d.ts +12 -0
  192. package/dist/services/tts/text-segmentation.js +60 -10
  193. package/dist/services/tts-runtime-config.d.ts +54 -26
  194. package/dist/services/tts-runtime-config.js +27 -112
  195. package/dist/tools/content-capability-resolution.d.ts +9 -11
  196. package/dist/tools/content-capability-resolution.js +8 -45
  197. package/dist/tools/registration.d.ts +59 -0
  198. package/dist/tools/registration.js +55 -0
  199. package/dist/tools/registrations/toolbar-registration-helpers.d.ts +6 -0
  200. package/dist/tools/registrations/toolbar-registration-helpers.js +8 -2
  201. package/dist/tools/tool-surface-host.d.ts +6 -10
  202. package/dist/tools/tool-surface-host.js +45 -50
  203. package/dist/tools/tool-tag-map.d.ts +5 -15
  204. package/dist/tools/tool-tag-map.js +6 -20
  205. package/dist/utils/structural-equality.d.ts +11 -0
  206. package/dist/utils/structural-equality.js +47 -0
  207. package/package.json +15 -29
  208. package/dist/attempt/adapters/activity-to-test-attempt-session.d.ts +0 -49
  209. package/dist/attempt/adapters/activity-to-test-attempt-session.js +0 -138
  210. package/dist/components/chunks/ItemToolBar-b9dz5jvd.js +0 -40
  211. package/dist/components/chunks/ItemToolBar-h4xap0t0.js +0 -4
  212. package/dist/components/chunks/ItemToolBar-wr2myaah.js +0 -2
  213. package/dist/components/chunks/sre-engine-wf9s43gg.js +0 -1
  214. package/dist/context/runtime-context-consumer.d.ts +0 -13
  215. package/dist/context/runtime-context-consumer.js +0 -18
  216. package/dist/item-loader.d.ts +0 -44
  217. package/dist/item-loader.js +0 -61
  218. package/dist/policy/core/engine-context.d.ts +0 -29
  219. package/dist/policy/core/engine-context.js +0 -27
  220. package/dist/policy/core/policy-source-tag.d.ts +0 -31
  221. package/dist/policy/core/policy-source-tag.js +0 -28
  222. package/dist/policy/internal.d.ts +0 -25
  223. package/dist/policy/internal.js +0 -25
  224. package/dist/runtime/adapter/coordinator-bridge.d.ts +0 -92
  225. package/dist/runtime/adapter/coordinator-bridge.js +0 -126
  226. package/dist/runtime/adapter/framework-error-bridge.d.ts +0 -36
  227. package/dist/runtime/adapter/framework-error-bridge.js +0 -46
  228. package/dist/runtime/adapter/instrumentation-bridge.d.ts +0 -31
  229. package/dist/runtime/adapter/instrumentation-bridge.js +0 -38
  230. package/dist/runtime/core/engine-stage-derivation.d.ts +0 -18
  231. package/dist/runtime/core/engine-stage-derivation.js +0 -29
  232. package/dist/runtime/engine-context.d.ts +0 -39
  233. package/dist/runtime/engine-context.js +0 -31
  234. package/dist/runtime/internal.d.ts +0 -43
  235. package/dist/runtime/internal.js +0 -45
  236. package/dist/runtime/section-runtime-engine-host-context.d.ts +0 -74
  237. package/dist/runtime/section-runtime-engine-host-context.js +0 -57
  238. package/dist/runtime/stage-emit-gate.d.ts +0 -92
  239. package/dist/runtime/stage-emit-gate.js +0 -82
  240. package/dist/services/ContextVariableStore.d.ts +0 -113
  241. package/dist/services/ContextVariableStore.js +0 -206
  242. package/dist/services/I18nService.d.ts +0 -47
  243. package/dist/services/I18nService.js +0 -84
  244. package/dist/services/ThemeProvider.d.ts +0 -95
  245. package/dist/services/ThemeProvider.js +0 -334
  246. package/dist/services/defaultPersonalNeedsProfile.d.ts +0 -21
  247. package/dist/services/defaultPersonalNeedsProfile.js +0 -26
  248. package/dist/services/pnp-standard-features.d.ts +0 -238
  249. package/dist/services/pnp-standard-features.js +0 -338
  250. package/dist/services/tts-control-events.d.ts +0 -13
  251. package/dist/services/tts-control-events.js +0 -7
  252. package/dist/tools/client.d.ts +0 -10
  253. package/dist/tools/client.js +0 -10
  254. package/dist/tools/internal.d.ts +0 -44
  255. package/dist/tools/internal.js +0 -39
  256. package/dist/utils/logger.d.ts +0 -6
  257. package/dist/utils/logger.js +0 -10
@@ -1,5 +1,9 @@
1
1
  import { tokenizeAlignmentText, tokenizeSpeechSource, } from "./math-alignment/speech-tokenizer.js";
2
2
  import { normalizeTextForSpeech } from "./text-processing.js";
3
+ // A spoken token equal to a visible token; phrase targets score below it.
4
+ const EXACT_TOKEN_SCORE = 10;
5
+ // Below this, an anchored chunk highlights its region instead of spans.
6
+ const MIN_ANCHOR_SPAN_CONFIDENCE = 0.12;
3
7
  // LIMITATION (i18n): the phrase table is English, so multi-word operator
4
8
  // phrases ("plus or minus", "divided by") only align for English speech; other
5
9
  // locales degrade to coarse region highlighting.
@@ -29,7 +33,7 @@ const createCandidates = (spokenTokens, visibleTokens) => {
29
33
  spokenStartToken: spokenIndex,
30
34
  spokenEndToken: spokenIndex + 1,
31
35
  visibleToken: visibleIndex,
32
- score: 10,
36
+ score: EXACT_TOKEN_SCORE,
33
37
  });
34
38
  }
35
39
  }
@@ -57,6 +61,9 @@ const createCandidates = (spokenTokens, visibleTokens) => {
57
61
  }
58
62
  return candidates;
59
63
  };
64
+ // The highest-scoring chain of candidates monotonic in both spoken and visible
65
+ // order (O(n²) DP). A gap whose spoken and visible token counts differ by more
66
+ // than 2 pays the excess.
60
67
  const computeAnchors = (spokenTokens, visibleTokens) => {
61
68
  const candidates = createCandidates(spokenTokens, visibleTokens).sort((left, right) => spokenTokens[left.spokenStartToken].start -
62
69
  spokenTokens[right.spokenStartToken].start ||
@@ -116,7 +123,7 @@ const scoreConfidence = (anchors, spokenTokens, visibleTokens) => {
116
123
  Math.max(1, Math.min(spokenTokens.length, visibleTokens.length));
117
124
  const averageScore = anchors.reduce((total, anchor) => total + anchor.score, 0) /
118
125
  anchors.length /
119
- 10;
126
+ EXACT_TOKEN_SCORE;
120
127
  return Math.min(1, tokenCoverage * averageScore);
121
128
  };
122
129
  export const createCatalogSpanAlignment = (args) => {
@@ -130,17 +137,17 @@ export const createCatalogSpanAlignment = (args) => {
130
137
  const confidence = scoreConfidence(anchors, spokenTokens, visibleTokens);
131
138
  const isExact = speech.spokenText === visibleText && speech.spokenText.length > 0;
132
139
  const hasShortSingleExactAnchor = anchors.length === 1 &&
133
- anchors[0].score === 10 &&
140
+ anchors[0].score === EXACT_TOKEN_SCORE &&
134
141
  spokenTokens.length <= 2 &&
135
142
  visibleTokens.length <= 2;
143
+ const anchorsAreTrusted = (anchors.length >= 2 || hasShortSingleExactAnchor) &&
144
+ confidence >= MIN_ANCHOR_SPAN_CONFIDENCE;
136
145
  const playbackMode = speech.unsupportedSemantic
137
146
  ? "region-fallback"
138
147
  : isExact
139
148
  ? "exact-word"
140
- : anchors.length >= 2 || hasShortSingleExactAnchor
141
- ? confidence >= 0.12
142
- ? "anchor-span"
143
- : "region-fallback"
149
+ : anchorsAreTrusted
150
+ ? "anchor-span"
144
151
  : "region-fallback";
145
152
  return {
146
153
  speech,
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The tree read-aloud walks: the flat tree, which is the tree the page renders.
3
+ *
4
+ * An element with an open shadow root contributes that root's children in place
5
+ * of its own light children, and a `<slot>` inside a shadow root contributes the
6
+ * nodes assigned to it, flattened through nested slots, so a host's light
7
+ * children are reached once, at the slot that renders them. Closed shadow roots
8
+ * are invisible to script, so their hosts are walked as light DOM.
9
+ *
10
+ * Toolkit chrome is skipped wherever it appears: toolbars and tool elements
11
+ * render their controls into open shadow roots, and descending into them would
12
+ * read button labels as content. So are the empty `aria-hidden` anchors the
13
+ * shells and the toolkit keep beside their slots, and elements whose text never
14
+ * renders (`style`, `script`, `noscript`, `template`).
15
+ *
16
+ * Node types are compared as the spec's numbers, so this module reads no DOM
17
+ * global, at import or at call time.
18
+ */
19
+ export declare const isShadowRootNode: (node: Node | null | undefined) => node is ShadowRoot;
20
+ /** `node`'s children in the flat tree, with skipped elements left out. */
21
+ export declare const flatTreeChildNodes: (node: Node) => Node[];
22
+ /**
23
+ * What a visitor returns: `"skip"` leaves the node's subtree out, `"stop"` ends
24
+ * the walk, anything else descends.
25
+ */
26
+ export type FlatTreeVisitResult = "skip" | "stop" | undefined;
27
+ /** Visits `root`'s flat-tree descendants in rendering order; `root` itself is not visited. */
28
+ export declare const walkFlatTree: (root: Node, visit: (node: Node) => FlatTreeVisitResult) => void;
29
+ /** `root`'s text nodes in rendering order. */
30
+ export declare const flatTreeTextNodes: (root: Node) => Text[];
31
+ /** `textContent` over the flat tree: shadow content in, skipped elements out. */
32
+ export declare const flatTextContent: (root: Node) => string;
33
+ /** Elements under `root` matching `selector`, in rendering order. */
34
+ export declare const flatQuerySelectorAll: (root: Node, selector: string) => Element[];
35
+ export declare const flatQuerySelector: (root: Node, selector: string) => Element | null;
36
+ export declare const flatTreeParentElement: (node: Node) => Element | null;
37
+ /** The nearest element at or above `node` in the flat tree that matches `selector`. */
38
+ export declare const flatTreeClosest: (node: Node, selector: string) => Element | null;
39
+ /**
40
+ * `node`'s parent element, continuing from a shadow root to its host. This is the
41
+ * chain inheritance follows (`lang`, regions an author marks up), and for slotted
42
+ * content it stays in the light tree the author wrote.
43
+ */
44
+ export declare const composedParentElement: (node: Node) => Element | null;
45
+ /**
46
+ * The nearest element at or above `node` on the composed chain that matches
47
+ * `selector`, or null. With `boundary`, the climb ends after testing it.
48
+ */
49
+ export declare const composedClosest: (node: Node, selector: string, boundary?: Element | null) => Element | null;
50
+ /** `contains` across shadow boundaries: whether `node` is `container` or below it. */
51
+ export declare const composedContains: (container: Node | null | undefined, node: Node | null | undefined) => boolean;
52
+ /**
53
+ * `node`, or the shadow host above it, that lies in the tree rooted at
54
+ * `treeRoot`; null when `node` is not inside that tree at any depth.
55
+ */
56
+ export declare const retargetToTree: (node: Node, treeRoot: Node) => Node | null;
57
+ /**
58
+ * Whether the character at `offset` in `node` lies inside `range`. A live range
59
+ * holds nodes of one tree only, so a node in a shadow tree below it counts as
60
+ * inside when the range holds that tree's host whole.
61
+ */
62
+ export declare const rangeHoldsTextPosition: (range: Range, node: Text, offset: number) => boolean;
63
+ /** Whether `range` intersects `node`, counting a shadow tree below the range as {@link rangeHoldsTextPosition} does. */
64
+ export declare const rangeIntersectsComposedNode: (range: Range, node: Node) => boolean;
@@ -0,0 +1,269 @@
1
+ /**
2
+ * The tree read-aloud walks: the flat tree, which is the tree the page renders.
3
+ *
4
+ * An element with an open shadow root contributes that root's children in place
5
+ * of its own light children, and a `<slot>` inside a shadow root contributes the
6
+ * nodes assigned to it, flattened through nested slots, so a host's light
7
+ * children are reached once, at the slot that renders them. Closed shadow roots
8
+ * are invisible to script, so their hosts are walked as light DOM.
9
+ *
10
+ * Toolkit chrome is skipped wherever it appears: toolbars and tool elements
11
+ * render their controls into open shadow roots, and descending into them would
12
+ * read button labels as content. So are the empty `aria-hidden` anchors the
13
+ * shells and the toolkit keep beside their slots, and elements whose text never
14
+ * renders (`style`, `script`, `noscript`, `template`).
15
+ *
16
+ * Node types are compared as the spec's numbers, so this module reads no DOM
17
+ * global, at import or at call time.
18
+ */
19
+ const ELEMENT_NODE = 1;
20
+ const TEXT_NODE = 3;
21
+ const DOCUMENT_FRAGMENT_NODE = 11;
22
+ const CHROME_TAGS = new Set([
23
+ "pie-item-toolbar",
24
+ "pie-section-toolbar",
25
+ "pie-item-player-session-debugger",
26
+ ]);
27
+ const CHROME_TAG_PREFIXES = ["pie-tool-", "pie-section-player-tools-"];
28
+ const UNRENDERED_TEXT_TAGS = new Set([
29
+ "style",
30
+ "script",
31
+ "noscript",
32
+ "template",
33
+ ]);
34
+ export const isShadowRootNode = (node) => !!node &&
35
+ node.nodeType === DOCUMENT_FRAGMENT_NODE &&
36
+ node.host?.nodeType === ELEMENT_NODE;
37
+ /** Toolbars, tool elements and debug panels: chrome around content, never content. */
38
+ const isToolkitChromeElement = (element) => {
39
+ const tag = element.localName?.toLowerCase() || "";
40
+ if (CHROME_TAGS.has(tag))
41
+ return true;
42
+ return CHROME_TAG_PREFIXES.some((prefix) => tag.startsWith(prefix));
43
+ };
44
+ const isSkippedElement = (element) => {
45
+ const tag = element.localName?.toLowerCase() || "";
46
+ if (UNRENDERED_TEXT_TAGS.has(tag))
47
+ return true;
48
+ if (isToolkitChromeElement(element))
49
+ return true;
50
+ // A shell's anchor: an `aria-hidden` element at the top of a shadow root,
51
+ // there to reach the host's context and holding nothing to read.
52
+ return (isShadowRootNode(element.parentNode) &&
53
+ element.getAttribute("aria-hidden") === "true");
54
+ };
55
+ const slotFlattenedNodes = (slot) => {
56
+ if (!isShadowRootNode(slot.getRootNode?.()))
57
+ return null;
58
+ const assignedNodes = slot.assignedNodes;
59
+ if (typeof assignedNodes !== "function")
60
+ return null;
61
+ try {
62
+ return assignedNodes.call(slot, { flatten: true });
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ };
68
+ /** `node`'s children in the flat tree, with skipped elements left out. */
69
+ export const flatTreeChildNodes = (node) => {
70
+ let children = node.childNodes;
71
+ if (node.nodeType === ELEMENT_NODE) {
72
+ const element = node;
73
+ const shadowRoot = element.shadowRoot;
74
+ if (shadowRoot) {
75
+ children = shadowRoot.childNodes;
76
+ }
77
+ else if (element.localName?.toLowerCase() === "slot") {
78
+ children = slotFlattenedNodes(element) ?? element.childNodes;
79
+ }
80
+ }
81
+ const result = [];
82
+ for (const child of Array.from(children || [])) {
83
+ if (child.nodeType === ELEMENT_NODE && isSkippedElement(child)) {
84
+ continue;
85
+ }
86
+ result.push(child);
87
+ }
88
+ return result;
89
+ };
90
+ /** Visits `root`'s flat-tree descendants in rendering order; `root` itself is not visited. */
91
+ export const walkFlatTree = (root, visit) => {
92
+ const descend = (node) => {
93
+ for (const child of flatTreeChildNodes(node)) {
94
+ const result = visit(child);
95
+ if (result === "stop")
96
+ return false;
97
+ if (result === "skip")
98
+ continue;
99
+ if (!descend(child))
100
+ return false;
101
+ }
102
+ return true;
103
+ };
104
+ descend(root);
105
+ };
106
+ /** `root`'s text nodes in rendering order. */
107
+ export const flatTreeTextNodes = (root) => {
108
+ const nodes = [];
109
+ walkFlatTree(root, (node) => {
110
+ if (node.nodeType === TEXT_NODE)
111
+ nodes.push(node);
112
+ return undefined;
113
+ });
114
+ return nodes;
115
+ };
116
+ /** `textContent` over the flat tree: shadow content in, skipped elements out. */
117
+ export const flatTextContent = (root) => flatTreeTextNodes(root)
118
+ .map((node) => node.data)
119
+ .join("");
120
+ /** Elements under `root` matching `selector`, in rendering order. */
121
+ export const flatQuerySelectorAll = (root, selector) => {
122
+ const matches = [];
123
+ walkFlatTree(root, (node) => {
124
+ if (node.nodeType === ELEMENT_NODE && node.matches(selector)) {
125
+ matches.push(node);
126
+ }
127
+ return undefined;
128
+ });
129
+ return matches;
130
+ };
131
+ export const flatQuerySelector = (root, selector) => {
132
+ let match = null;
133
+ walkFlatTree(root, (node) => {
134
+ if (node.nodeType === ELEMENT_NODE && node.matches(selector)) {
135
+ match = node;
136
+ return "stop";
137
+ }
138
+ return undefined;
139
+ });
140
+ return match;
141
+ };
142
+ /**
143
+ * `node`'s parent in the flat tree: the slot rendering it, else its parent, with
144
+ * a shadow root standing for its host.
145
+ */
146
+ const flatTreeParentNode = (node) => {
147
+ const slot = node.assignedSlot;
148
+ if (slot)
149
+ return slot;
150
+ const parent = node.parentNode;
151
+ if (isShadowRootNode(parent))
152
+ return parent.host;
153
+ return parent;
154
+ };
155
+ export const flatTreeParentElement = (node) => {
156
+ let parent = flatTreeParentNode(node);
157
+ while (parent && parent.nodeType !== ELEMENT_NODE) {
158
+ parent = flatTreeParentNode(parent);
159
+ }
160
+ return parent;
161
+ };
162
+ /** The nearest element at or above `node` in the flat tree that matches `selector`. */
163
+ export const flatTreeClosest = (node, selector) => {
164
+ let current = node.nodeType === ELEMENT_NODE
165
+ ? node
166
+ : flatTreeParentElement(node);
167
+ while (current) {
168
+ if (current.matches(selector))
169
+ return current;
170
+ current = flatTreeParentElement(current);
171
+ }
172
+ return null;
173
+ };
174
+ /**
175
+ * `node`'s parent element, continuing from a shadow root to its host. This is the
176
+ * chain inheritance follows (`lang`, regions an author marks up), and for slotted
177
+ * content it stays in the light tree the author wrote.
178
+ */
179
+ export const composedParentElement = (node) => {
180
+ if (node.parentElement)
181
+ return node.parentElement;
182
+ const parent = node.parentNode;
183
+ return isShadowRootNode(parent) ? parent.host : null;
184
+ };
185
+ /**
186
+ * The nearest element at or above `node` on the composed chain that matches
187
+ * `selector`, or null. With `boundary`, the climb ends after testing it.
188
+ */
189
+ export const composedClosest = (node, selector, boundary) => {
190
+ let current = node.nodeType === ELEMENT_NODE
191
+ ? node
192
+ : composedParentElement(node);
193
+ while (current) {
194
+ if (current.matches(selector))
195
+ return current;
196
+ if (boundary && current === boundary)
197
+ return null;
198
+ current = composedParentElement(current);
199
+ }
200
+ return null;
201
+ };
202
+ /** `contains` across shadow boundaries: whether `node` is `container` or below it. */
203
+ export const composedContains = (container, node) => {
204
+ if (!container || !node)
205
+ return false;
206
+ let current = node;
207
+ while (current) {
208
+ if (current === container || container.contains(current))
209
+ return true;
210
+ const root = current.getRootNode?.();
211
+ current = isShadowRootNode(root) ? root.host : null;
212
+ }
213
+ return false;
214
+ };
215
+ /**
216
+ * `node`, or the shadow host above it, that lies in the tree rooted at
217
+ * `treeRoot`; null when `node` is not inside that tree at any depth.
218
+ */
219
+ export const retargetToTree = (node, treeRoot) => {
220
+ let current = node;
221
+ while (current) {
222
+ const root = current.getRootNode?.();
223
+ if (root === treeRoot)
224
+ return current;
225
+ current = isShadowRootNode(root) ? root.host : null;
226
+ }
227
+ return null;
228
+ };
229
+ /**
230
+ * Whether the character at `offset` in `node` lies inside `range`. A live range
231
+ * holds nodes of one tree only, so a node in a shadow tree below it counts as
232
+ * inside when the range holds that tree's host whole.
233
+ */
234
+ export const rangeHoldsTextPosition = (range, node, offset) => {
235
+ try {
236
+ const treeRoot = range.startContainer.getRootNode?.();
237
+ if (!treeRoot || node.getRootNode?.() === treeRoot) {
238
+ return (range.comparePoint(node, offset) === 0 &&
239
+ range.comparePoint(node, Math.min(offset + 1, node.length)) === 0);
240
+ }
241
+ return rangeHoldsNodeWhole(range, retargetToTree(node, treeRoot));
242
+ }
243
+ catch {
244
+ return false;
245
+ }
246
+ };
247
+ /** Whether `range` intersects `node`, counting a shadow tree below the range as {@link rangeHoldsTextPosition} does. */
248
+ export const rangeIntersectsComposedNode = (range, node) => {
249
+ try {
250
+ const treeRoot = range.startContainer.getRootNode?.();
251
+ if (!treeRoot || node.getRootNode?.() === treeRoot) {
252
+ return range.intersectsNode(node);
253
+ }
254
+ return rangeHoldsNodeWhole(range, retargetToTree(node, treeRoot));
255
+ }
256
+ catch {
257
+ return false;
258
+ }
259
+ };
260
+ const rangeHoldsNodeWhole = (range, node) => {
261
+ const parent = node?.parentNode;
262
+ if (!node || !parent)
263
+ return false;
264
+ const index = Array.prototype.indexOf.call(parent.childNodes, node);
265
+ if (index < 0)
266
+ return false;
267
+ return (range.comparePoint(parent, index) === 0 &&
268
+ range.comparePoint(parent, index + 1) === 0);
269
+ };
@@ -1,5 +1,5 @@
1
1
  import type { MathAwareSpeechChunk } from "../math-aware-text-processing.js";
2
- import { type ResolvedMathSpeech, type SREMathSpeechOptions } from "../math-speech.js";
2
+ import { type ResolveMathSpeechOptions, type ResolvedMathSpeech, type SREMathSpeechOptions } from "../math-speech.js";
3
3
  import type { AssembledSpeech } from "./types.js";
4
4
  type MathChunk = Extract<MathAwareSpeechChunk, {
5
5
  type: "math";
@@ -13,6 +13,8 @@ export type MathSpeechResolver = (chunk: MathChunk, options: {
13
13
  language?: string;
14
14
  produceSsml?: boolean;
15
15
  mathSpeech?: SREMathSpeechOptions;
16
+ /** The read's one engine load, shared by its equations. */
17
+ loadSre?: ResolveMathSpeechOptions["loadSre"];
16
18
  }) => Promise<ResolvedMathSpeech>;
17
19
  /**
18
20
  * Assemble an anchor-free speech plan from already-collected math-aware chunks.
@@ -1,4 +1,4 @@
1
- import { resolveMathSpeechFromChunks, } from "../math-speech.js";
1
+ import { createReadSreLoad, resolveMathSpeechFromChunks, } from "../math-speech.js";
2
2
  import { normalizeTextForSpeech } from "../text-processing.js";
3
3
  import { segmentSentences as segmentTextToSentences } from "../text-segmentation.js";
4
4
  const defaultMathSpeechResolver = (chunk, options) => resolveMathSpeechFromChunks([chunk], options);
@@ -37,6 +37,7 @@ const shouldPreserveProseChunk = (chunk) => {
37
37
  */
38
38
  export const assembleGeneratedSpeech = async (args) => {
39
39
  const resolveMathSpeech = args.resolveMathSpeech ?? defaultMathSpeechResolver;
40
+ const loadSre = createReadSreLoad(args.mathSpeech);
40
41
  const { visibleText } = args;
41
42
  const segments = [];
42
43
  // Cursor-based span tracking:
@@ -84,6 +85,7 @@ export const assembleGeneratedSpeech = async (args) => {
84
85
  language: args.language,
85
86
  produceSsml: args.produceSsml,
86
87
  mathSpeech: args.mathSpeech,
88
+ loadSre,
87
89
  });
88
90
  const usedFallback = !generated.speechText;
89
91
  const spokenText = generated.speechText || chunk.fallbackText;
@@ -16,6 +16,8 @@ export interface BuildGeneratedSpeechResult {
16
16
  */
17
17
  export declare const buildGeneratedSpeechFromRoot: (args: {
18
18
  contentRoot: Element;
19
+ /** Limit the plan to the part of `contentRoot` this range selects. */
20
+ range?: Range;
19
21
  language?: string;
20
22
  mathSpeech?: SREMathSpeechOptions;
21
23
  textProcessingOptions?: TextProcessingOptions;
@@ -17,7 +17,7 @@ const sliceVisibleMap = (map, start, end) => {
17
17
  * entry point that touches the DOM; assembly and serialization stay pure.
18
18
  */
19
19
  export const buildGeneratedSpeechFromRoot = async (args) => {
20
- const extracted = collectMathAwareTextAndMap(args.contentRoot, args.textProcessingOptions);
20
+ const extracted = collectMathAwareTextAndMap(args.contentRoot, args.textProcessingOptions, args.range);
21
21
  const assembled = await assembleGeneratedSpeech({
22
22
  chunks: extracted.chunks,
23
23
  visibleText: extracted.visibleText,
@@ -3,8 +3,8 @@ import type { DomAnchor, GeneratedSpeechChunk } from "./types.js";
3
3
  export interface PlanToChunksOptions {
4
4
  /**
5
5
  * Playback format:
6
- * - "plain" (default): plain spoken text for every chunk (Phase A, the
7
- * stable generated-speech behavior).
6
+ * - "plain" (default): plain spoken text for every chunk (the stable
7
+ * generated-speech behavior).
8
8
  * - "ssml": math chunks with SRE SSML send that SSML to the provider (with
9
9
  * a plain fallback attached); prose chunks always stay plain.
10
10
  */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Generated-speech module (PIE-623): runtime speech composition for math items
2
+ * Generated-speech module: runtime speech composition for math items
3
3
  * that ship without authored SSML / `accessibilityCatalogs`.
4
4
  *
5
5
  * Layering:
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Generated-speech module (PIE-623): runtime speech composition for math items
2
+ * Generated-speech module: runtime speech composition for math items
3
3
  * that ship without authored SSML / `accessibilityCatalogs`.
4
4
  *
5
5
  * Layering:
@@ -7,7 +7,9 @@ export interface MathSpeechCacheOptions {
7
7
  /**
8
8
  * Build a memoized {@link MathSpeechResolver}.
9
9
  *
10
- * Caches only DOM-free spoken-text results keyed by
10
+ * Caches only DOM-free spoken-text results that SRE produced, so an equation
11
+ * read as its visible text after a failed engine load is resolved again on
12
+ * the next read. Keyed by
11
13
  * `(SRE version, locale, output mode, SRE mathSpeech options, MathML source)`.
12
14
  * Never caches DOM nodes or maps. Bounded by a simple LRU so long sessions do
13
15
  * not grow unbounded.
@@ -35,7 +35,9 @@ const cacheKeyFor = (language, produceSsml, mathSpeech, mathml) => `${SRE_CACHE_
35
35
  /**
36
36
  * Build a memoized {@link MathSpeechResolver}.
37
37
  *
38
- * Caches only DOM-free spoken-text results keyed by
38
+ * Caches only DOM-free spoken-text results that SRE produced, so an equation
39
+ * read as its visible text after a failed engine load is resolved again on
40
+ * the next read. Keyed by
39
41
  * `(SRE version, locale, output mode, SRE mathSpeech options, MathML source)`.
40
42
  * Never caches DOM nodes or maps. Bounded by a simple LRU so long sessions do
41
43
  * not grow unbounded.
@@ -66,6 +68,8 @@ export const createMemoizedMathSpeechResolver = (options = {}) => {
66
68
  return cached;
67
69
  }
68
70
  const resolved = await resolve(chunk, { ...opts, mathSpeech });
71
+ if (resolved.usedFallback)
72
+ return resolved;
69
73
  if (produceSsml && resolved.ssml && maxEntries > 1) {
70
74
  setCached(cacheKeyFor(opts.language, false, mathSpeech, chunk.mathml), {
71
75
  speechText: resolved.speechText,
@@ -2,7 +2,7 @@
2
2
  * Generated-speech plan model (PURE core).
3
3
  *
4
4
  * This module is the speech-composition core for items that contain math but
5
- * ship without authored SSML / `accessibilityCatalogs` (PIE-623). It is
5
+ * ship without authored SSML / `accessibilityCatalogs`. It is
6
6
  * deliberately free of any imports from `TTSService`, the highlight renderer,
7
7
  * `HighlightCoordinator`, Svelte, or custom-element entrypoints
8
8
  * (`scripts/check-speech-composition-purity.mjs` enforces this). DOM primitive
@@ -2,7 +2,7 @@
2
2
  * Generated-speech plan model (PURE core).
3
3
  *
4
4
  * This module is the speech-composition core for items that contain math but
5
- * ship without authored SSML / `accessibilityCatalogs` (PIE-623). It is
5
+ * ship without authored SSML / `accessibilityCatalogs`. It is
6
6
  * deliberately free of any imports from `TTSService`, the highlight renderer,
7
7
  * `HighlightCoordinator`, Svelte, or custom-element entrypoints
8
8
  * (`scripts/check-speech-composition-purity.mjs` enforces this). DOM primitive
@@ -3,12 +3,19 @@ import { createHighlightDecision } from "./highlight-policy.js";
3
3
  import { resolveMathBoundaryTarget } from "./math-semantic-resolver.js";
4
4
  import { resolveProseBoundaryTarget } from "./prose-boundary-resolver.js";
5
5
  import { classifyMathHighlightCapability, createRenderedMathTargetResolver, } from "./rendered-math-target-resolver.js";
6
+ import { composedContains, composedParentElement, rangeIntersectsComposedNode, } from "../flat-tree.js";
6
7
  import { isNodeHiddenForTTS } from "../text-processing.js";
8
+ import { createRangesFromVisibleMap } from "./visible-map-range.js";
9
+ const regionRangesFor = (chunk) => {
10
+ const ranges = createRangesFromVisibleMap(chunk.visibleMap, 0, chunk.visibleText.length);
11
+ return ranges.length > 1 ? ranges : undefined;
12
+ };
7
13
  const regionTargetFor = (chunk) => chunk.regionRange
8
14
  ? {
9
15
  type: "range",
10
16
  quality: "region",
11
17
  range: chunk.regionRange,
18
+ ranges: regionRangesFor(chunk),
12
19
  }
13
20
  : chunk.regionElement
14
21
  ? {
@@ -29,12 +36,13 @@ const mathCandidatesForTarget = (target, chunk) => {
29
36
  return chunk.mathAlignments;
30
37
  return chunk.mathAlignments.filter(({ element }) => {
31
38
  try {
32
- if (target.type === "range")
33
- return target.range.intersectsNode(element);
39
+ if (target.type === "range") {
40
+ return (target.ranges ?? [target.range]).some((range) => rangeIntersectsComposedNode(range, element));
41
+ }
34
42
  if (target.type === "element") {
35
- return target.element === element || target.element.contains(element);
43
+ return composedContains(target.element, element);
36
44
  }
37
- return target.node.parentElement?.contains(element) || false;
45
+ return composedContains(composedParentElement(target.node), element);
38
46
  }
39
47
  catch {
40
48
  return false;
@@ -59,11 +67,10 @@ const TEXT_NODE = 3;
59
67
  // the last token — painting it would be a likely false positive, and "false
60
68
  // positives are worse than coarse highlighting." A single-text-node prose range
61
69
  // is kept: it underlines one token cleanly and usefully fills in any token the
62
- // math resolver missed. (The highlight coordinator no longer escalates word
63
- // ranges to the whole <math> / <mjx-container>, so this is now a precision
64
- // policy rather than flash-suppression — see HighlightCoordinator
65
- // WORD_FALLBACK_SELECTOR.)
66
- const wouldEscalateInsideTokenMath = (planned, target, candidates) => {
70
+ // math resolver missed. The word layer never escalates a range to the enclosing
71
+ // <math>/<mjx-container> (HighlightCoordinator WORD_FALLBACK_SELECTOR), so this
72
+ // is a precision policy.
73
+ const isMultiNodeRangeInsideTokenMath = (planned, target, candidates) => {
67
74
  if (!target || target.type !== "range")
68
75
  return false;
69
76
  const { startContainer, endContainer } = target.range;
@@ -120,8 +127,7 @@ export const createTTSHighlightPlan = (args) => {
120
127
  const planned = plannedChunksById.get(chunkId);
121
128
  if (!planned)
122
129
  return missingChunkDecision();
123
- const alignments = planned.chunk.mathAlignments;
124
- const soleElement = alignments.length === 1 ? alignments[0].element : null;
130
+ const soleElement = soleEquationElement(planned.chunk.mathAlignments);
125
131
  const capability = soleElement
126
132
  ? planned.mathCapabilityByElement.get(soleElement)
127
133
  : null;
@@ -156,9 +162,9 @@ export const createTTSHighlightPlan = (args) => {
156
162
  const normalized = normalizeBoundaryEvent(chunk, event);
157
163
  const proseTarget = resolveProseBoundaryTarget(chunk, normalized);
158
164
  const mathCandidates = mathCandidatesForTarget(proseTarget, chunk);
159
- const resolvedMathTarget = (!proseTarget || mathCandidates.length > 0
165
+ const resolvedMathTarget = !proseTarget || mathCandidates.length > 0
160
166
  ? resolveMathBoundaryTarget(chunk, normalized, event, mathCandidates, planned.renderedMathTargetResolver)
161
- : null) || null;
167
+ : null;
162
168
  const mathTarget = resolvedMathTarget?.quality === "semantic-token"
163
169
  ? resolvedMathTarget
164
170
  : (renderHiddenProseTarget(planned, proseTarget) ??
@@ -187,15 +193,15 @@ export const createTTSHighlightPlan = (args) => {
187
193
  }
188
194
  // (2) A spoken word that maps to visible prose. The prose anchor is
189
195
  // precise; a coarse math expression fallback must never override it.
190
- // Exception: a multi-node prose range inside a token-mode equation
191
- // would escalate to the whole expression, so it is rejected here and
196
+ // Exception: a multi-node prose range inside a token-mode equation is
197
+ // an untrustworthy glyph-level match, so it is rejected here and
192
198
  // falls through to the token hold below. When per-token math
193
199
  // highlighting is disabled, any prose range that lands inside a
194
200
  // formula is likewise rejected so the formula stays a single block.
195
201
  if (proseTarget &&
196
202
  !isHiddenRange(proseTarget) &&
197
203
  (mathTokenHighlighting
198
- ? !wouldEscalateInsideTokenMath(planned, proseTarget, mathCandidates)
204
+ ? !isMultiNodeRangeInsideTokenMath(planned, proseTarget, mathCandidates)
199
205
  : mathCandidates.length === 0)) {
200
206
  return createHighlightDecision({
201
207
  semanticTarget: proseTarget,
@@ -1,19 +1,21 @@
1
1
  import { resolveVisibleSpanForBoundary } from "../catalog-span-alignment.js";
2
- import { createRangeFromVisibleMap } from "./visible-map-range.js";
2
+ import { createRangesFromVisibleMap } from "./visible-map-range.js";
3
3
  export const resolveProseBoundaryTarget = (chunk, boundary) => {
4
4
  if (!chunk.catalogAlignment || boundary.chunkSpokenStart === null)
5
5
  return null;
6
6
  const visibleSpan = resolveVisibleSpanForBoundary(chunk.catalogAlignment, boundary.chunkSpokenStart);
7
7
  if (!visibleSpan)
8
8
  return null;
9
- const range = createRangeFromVisibleMap(chunk.visibleMap, visibleSpan.start, visibleSpan.end);
10
- if (!range)
9
+ // One range per tree, as the uncataloged word path builds them.
10
+ const ranges = createRangesFromVisibleMap(chunk.visibleMap, visibleSpan.start, visibleSpan.end);
11
+ if (ranges.length === 0)
11
12
  return null;
12
13
  return {
13
14
  type: "range",
14
15
  quality: chunk.catalogAlignment.playbackMode === "exact-word"
15
16
  ? "exact-word"
16
17
  : "semantic-token",
17
- range,
18
+ range: ranges[0],
19
+ ranges: ranges.length > 1 ? ranges : undefined,
18
20
  };
19
21
  };