@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
@@ -11,8 +11,6 @@
11
11
  * - Configurable colors and styles
12
12
  * - Annotation persistence via RangeSerializer
13
13
  *
14
- * Part of PIE Assessment Toolkit.
15
- *
16
14
  * Browser Support:
17
15
  * - Chrome/Edge 105+
18
16
  * - Safari 17.2+
@@ -21,6 +19,7 @@
21
19
  * @see https://developer.mozilla.org/en-US/docs/Web/API/CSS_Custom_Highlight_API
22
20
  */
23
21
  import { RangeSerializer } from "./RangeSerializer.js";
22
+ import { composedContains, composedParentElement, isShadowRootNode, } from "./tts/flat-tree.js";
24
23
  /**
25
24
  * Highlight types
26
25
  */
@@ -42,235 +41,13 @@ export var HighlightColor;
42
41
  HighlightColor["ORANGE"] = "orange";
43
42
  HighlightColor["UNDERLINE"] = "underline";
44
43
  })(HighlightColor || (HighlightColor = {}));
45
- export class HighlightCoordinator {
46
- ttsWordHighlight = null;
47
- ttsSentenceHighlight = null;
48
- ttsWordElementHighlights = new Set();
49
- ttsSentenceElementHighlights = new Set();
50
- annotations = new Map();
51
- // Shared highlights per color (one Highlight object per color, contains all ranges)
52
- colorHighlights = new Map();
53
- nextAnnotationId = 1;
54
- supported = false;
55
- rangeSerializer;
56
- themeObserver = null;
57
- explicitTTSColorOverride = null;
58
- constructor() {
59
- this.rangeSerializer = new RangeSerializer();
60
- // SSR guard
61
- if (typeof CSS === "undefined" || !("highlights" in CSS)) {
62
- console.warn("CSS Custom Highlight API not supported (SSR or unsupported browser)");
63
- return;
64
- }
65
- this.supported = true;
66
- this.initializeHighlights();
67
- this.registerStyles();
68
- this.applyAdaptiveTTSStyle();
69
- this.setupThemeObservation();
70
- }
71
- setupThemeObservation() {
72
- if (typeof document === "undefined")
73
- return;
74
- if (typeof MutationObserver === "undefined")
75
- return;
76
- const refresh = () => this.applyAdaptiveTTSStyle();
77
- this.themeObserver = new MutationObserver(refresh);
78
- this.themeObserver.observe(document.documentElement, {
79
- attributes: true,
80
- attributeFilter: ["style", "data-theme", "data-color-scheme", "class"],
81
- });
82
- for (const host of document.querySelectorAll("pie-theme")) {
83
- this.themeObserver.observe(host, {
84
- attributes: true,
85
- attributeFilter: [
86
- "theme",
87
- "scheme",
88
- "provider",
89
- "variables",
90
- "style",
91
- "data-theme",
92
- "data-color-scheme",
93
- ],
94
- });
95
- }
96
- }
97
- parseColor(input) {
98
- if (!input)
99
- return null;
100
- const value = input.trim();
101
- if (!value)
102
- return null;
103
- const hexMatch = /^#([a-f\d]{3}|[a-f\d]{6})$/i.exec(value);
104
- if (hexMatch) {
105
- const hex = hexMatch[1];
106
- if (hex.length === 3) {
107
- return [
108
- parseInt(hex[0] + hex[0], 16),
109
- parseInt(hex[1] + hex[1], 16),
110
- parseInt(hex[2] + hex[2], 16),
111
- ];
112
- }
113
- return [
114
- parseInt(hex.slice(0, 2), 16),
115
- parseInt(hex.slice(2, 4), 16),
116
- parseInt(hex.slice(4, 6), 16),
117
- ];
118
- }
119
- const rgbMatch = /^rgba?\((.+)\)$/i.exec(value);
120
- if (rgbMatch) {
121
- const normalized = rgbMatch[1].replace(/\//g, ",");
122
- const parts = normalized
123
- .split(/[,\s]+/)
124
- .map((part) => part.trim())
125
- .filter(Boolean);
126
- const r = Number(parts[0]);
127
- const g = Number(parts[1]);
128
- const b = Number(parts[2]);
129
- if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) {
130
- return [r, g, b];
131
- }
132
- }
133
- // Browser parser fallback (covers formats like oklch()).
134
- if (typeof document !== "undefined") {
135
- const parserEl = document.createElement("span");
136
- parserEl.style.color = value;
137
- if (typeof parserEl.style.color === "string" && parserEl.style.color) {
138
- document.body?.appendChild(parserEl);
139
- const resolved = typeof getComputedStyle === "function"
140
- ? getComputedStyle(parserEl).color
141
- : "";
142
- parserEl.remove();
143
- const normalizedResolved = resolved.trim();
144
- // Guard against recursive loops when computed style returns the same
145
- // unresolved function syntax (observed with certain color formats).
146
- if (normalizedResolved && normalizedResolved !== value) {
147
- return this.parseColor(resolved);
148
- }
149
- }
150
- }
151
- return null;
152
- }
153
- relativeLuminance([r, g, b]) {
154
- const toLinear = (channel) => {
155
- const n = channel / 255;
156
- return n <= 0.03928 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4;
157
- };
158
- const lr = toLinear(r);
159
- const lg = toLinear(g);
160
- const lb = toLinear(b);
161
- return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
162
- }
163
- contrastRatio(a, b) {
164
- const la = this.relativeLuminance(a);
165
- const lb = this.relativeLuminance(b);
166
- const lighter = Math.max(la, lb);
167
- const darker = Math.min(la, lb);
168
- return (lighter + 0.05) / (darker + 0.05);
169
- }
170
- blend(fg, bg, alpha) {
171
- return [
172
- Math.round(fg[0] * alpha + bg[0] * (1 - alpha)),
173
- Math.round(fg[1] * alpha + bg[1] * (1 - alpha)),
174
- Math.round(fg[2] * alpha + bg[2] * (1 - alpha)),
175
- ];
176
- }
177
- resolveAdaptiveTTSStyle(sourceEl) {
178
- const fallbackMissing = [255, 235, 59];
179
- const fallbackText = [17, 24, 39];
180
- const fallbackBackground = [255, 255, 255];
181
- const target = sourceEl ||
182
- (typeof document !== "undefined" ? document.documentElement : null);
183
- const computed = target && typeof getComputedStyle === "function"
184
- ? getComputedStyle(target)
185
- : null;
186
- const background = this.parseColor(computed?.getPropertyValue("--pie-background") ||
187
- computed?.backgroundColor) || fallbackBackground;
188
- const text = this.parseColor(computed?.getPropertyValue("--pie-text")) || fallbackText;
189
- const accent = this.explicitTTSColorOverride
190
- ? this.parseColor(this.explicitTTSColorOverride.color) || fallbackMissing
191
- : this.parseColor(computed?.getPropertyValue("--pie-missing")) ||
192
- fallbackMissing;
193
- const opacityCandidates = this.explicitTTSColorOverride
194
- ? [Math.max(0.3, Math.min(0.95, this.explicitTTSColorOverride.opacity))]
195
- : [0.8, 0.72, 0.68, 0.62, 0.56, 0.5];
196
- let selectedOpacity = opacityCandidates[opacityCandidates.length - 1];
197
- let bestScore = -Infinity;
198
- for (const opacity of opacityCandidates) {
199
- const blended = this.blend(accent, background, opacity);
200
- const backgroundDelta = this.contrastRatio(blended, background);
201
- const textContrast = this.contrastRatio(blended, text);
202
- const score = backgroundDelta * 1.2 + textContrast * 0.8;
203
- if (backgroundDelta >= 1.25 && textContrast >= 2.4) {
204
- selectedOpacity = opacity;
205
- break;
206
- }
207
- if (score > bestScore) {
208
- bestScore = score;
209
- selectedOpacity = opacity;
210
- }
211
- }
212
- const sentenceOpacity = Math.max(0.24, Math.min(0.85, selectedOpacity * 0.55));
213
- const underlineOpacity = Math.max(0.55, Math.min(0.95, selectedOpacity + 0.2));
214
- const shadowOpacity = Math.max(0.22, Math.min(0.6, selectedOpacity * 0.45));
215
- const underlineColor = text;
216
- const underlineBlend = this.blend(underlineColor, background, underlineOpacity);
217
- const underlineDelta = this.contrastRatio(underlineBlend, background);
218
- const fallbackUnderline = this.relativeLuminance(background) > 0.45 ? [0, 0, 0] : [255, 255, 255];
219
- const finalUnderline = underlineDelta >= 1.35 ? underlineColor : fallbackUnderline;
220
- const wordHighlight = `rgba(${accent[0]}, ${accent[1]}, ${accent[2]}, ${selectedOpacity})`;
221
- const sentenceHighlight = `rgba(${accent[0]}, ${accent[1]}, ${accent[2]}, ${sentenceOpacity})`;
222
- const wordUnderline = `rgba(${finalUnderline[0]}, ${finalUnderline[1]}, ${finalUnderline[2]}, ${underlineOpacity})`;
223
- const wordShadow = `rgba(${finalUnderline[0]}, ${finalUnderline[1]}, ${finalUnderline[2]}, ${shadowOpacity})`;
224
- return {
225
- wordHighlight,
226
- sentenceHighlight,
227
- wordUnderline,
228
- wordShadow,
229
- };
230
- }
231
- applyAdaptiveTTSStyle(sourceEl) {
232
- if (typeof document === "undefined")
233
- return;
234
- const vars = this.resolveAdaptiveTTSStyle(sourceEl);
235
- document.documentElement.style.setProperty("--pie-tts-word-highlight", vars.wordHighlight);
236
- document.documentElement.style.setProperty("--pie-tts-sentence-highlight", vars.sentenceHighlight);
237
- document.documentElement.style.setProperty("--pie-tts-line-highlight", vars.sentenceHighlight);
238
- document.documentElement.style.setProperty("--pie-tts-word-underline", vars.wordUnderline);
239
- document.documentElement.style.setProperty("--pie-tts-word-shadow", vars.wordShadow);
240
- }
241
- /**
242
- * Initialize CSS Custom Highlights
243
- */
244
- initializeHighlights() {
245
- if (!this.supported)
246
- return;
247
- // Create highlight registries for TTS
248
- this.ttsWordHighlight = new Highlight();
249
- this.ttsSentenceHighlight = new Highlight();
250
- // Register TTS highlights with CSS
251
- CSS.highlights.set("tts-word", this.ttsWordHighlight);
252
- CSS.highlights.set("tts-sentence", this.ttsSentenceHighlight);
253
- // Create shared highlights for each annotation color
254
- for (const color of Object.values(HighlightColor)) {
255
- const highlight = new Highlight();
256
- this.colorHighlights.set(color, highlight);
257
- CSS.highlights.set(`annotation-${color}`, highlight);
258
- }
259
- }
260
- /**
261
- * Register CSS styles for highlights
262
- */
263
- registerStyles() {
264
- if (!this.supported)
265
- return;
266
- if (typeof document === "undefined")
267
- return; // SSR guard
268
- // Check if styles already exist
269
- if (document.getElementById("pie-highlight-styles"))
270
- return;
271
- const style = document.createElement("style");
272
- style.id = "pie-highlight-styles";
273
- style.textContent = `
44
+ /**
45
+ * The highlight stylesheet: the document's copy in `<style id="pie-highlight-styles">`,
46
+ * and each shadow root's that holds a highlighted range. `::highlight()` rules
47
+ * paint text in the tree whose styles hold them, so text in a shadow root needs
48
+ * its own copy; Firefox paints none of it from the document's.
49
+ */
50
+ const HIGHLIGHT_STYLES = `
274
51
  /* TTS highlights - temporary */
275
52
  ::highlight(tts-word) {
276
53
  background-color: var(--pie-tts-word-highlight, color-mix(in srgb, var(--pie-missing, #ffeb3b) 68%, transparent));
@@ -481,49 +258,414 @@ export class HighlightCoordinator {
481
258
  }
482
259
  }
483
260
  `;
261
+ /**
262
+ * Page-wide, so every copy of the toolkit on a page shares one constructed sheet
263
+ * and adopts it into a shadow root once. The first copy to build it supplies its
264
+ * rules, as the first to append the document's `<style>` does.
265
+ */
266
+ const SHARED_SHEET_SLOT = Symbol.for("@pie-players/pie-assessment-toolkit/highlight-stylesheet");
267
+ const ADOPTED_SHEET_MARKER = Symbol.for("@pie-players/pie-assessment-toolkit/highlight-stylesheet-adopted");
268
+ const isSheetLike = (value) => !!value && typeof value.replaceSync === "function";
269
+ const sharedHighlightSheet = () => {
270
+ const slot = globalThis;
271
+ const existing = slot[SHARED_SHEET_SLOT];
272
+ if (isSheetLike(existing))
273
+ return existing;
274
+ const Sheet = globalThis
275
+ .CSSStyleSheet;
276
+ if (typeof Sheet !== "function")
277
+ return null;
278
+ try {
279
+ const sheet = new Sheet();
280
+ if (!isSheetLike(sheet))
281
+ return null;
282
+ sheet.replaceSync(HIGHLIGHT_STYLES);
283
+ slot[SHARED_SHEET_SLOT] = sheet;
284
+ return sheet;
285
+ }
286
+ catch {
287
+ return null;
288
+ }
289
+ };
290
+ /**
291
+ * Adopts the highlight stylesheet into the shadow root holding `node`, once per
292
+ * root. Appended, so the root's own sheets stay in place; re-adopted if the
293
+ * root's owner replaced its sheets since.
294
+ */
295
+ const adoptHighlightStylesFor = (node) => {
296
+ const found = node?.getRootNode?.();
297
+ if (!isShadowRootNode(found))
298
+ return;
299
+ const root = found;
300
+ const sheets = root.adoptedStyleSheets;
301
+ if (!sheets || typeof sheets !== "object")
302
+ return;
303
+ const current = Array.from(sheets);
304
+ const adopted = root[ADOPTED_SHEET_MARKER];
305
+ if (adopted && current.includes(adopted))
306
+ return;
307
+ const sheet = sharedHighlightSheet();
308
+ if (!sheet)
309
+ return;
310
+ try {
311
+ root.adoptedStyleSheets = [...current, sheet];
312
+ root[ADOPTED_SHEET_MARKER] = sheet;
313
+ }
314
+ catch {
315
+ // A root that refuses the sheet paints from the document's rules where the
316
+ // browser allows it.
317
+ }
318
+ };
319
+ const TTS_WORD_HIGHLIGHT = "tts-word";
320
+ const TTS_SENTENCE_HIGHLIGHT = "tts-sentence";
321
+ const annotationHighlightName = (color) => `annotation-${color}`;
322
+ /**
323
+ * The page's highlight registered under `name`. Every coordinator on a page
324
+ * paints into the same one, because the page-wide `::highlight()` rules select
325
+ * it by that name, and removes only the ranges it added.
326
+ */
327
+ const sharedHighlight = (name) => {
328
+ const registered = CSS.highlights.get(name);
329
+ if (registered)
330
+ return registered;
331
+ const highlight = new Highlight();
332
+ CSS.highlights.set(name, highlight);
333
+ return highlight;
334
+ };
335
+ const removeRanges = (name, ranges) => {
336
+ const highlight = CSS.highlights.get(name);
337
+ if (!highlight)
338
+ return;
339
+ for (const range of ranges)
340
+ highlight.delete(range);
341
+ };
342
+ /** The element the first range starts in, which the TTS colors adapt to. */
343
+ const startElementOf = (ranges) => {
344
+ const source = ranges[0]?.startContainer;
345
+ if (!source)
346
+ return null;
347
+ return source.nodeType === Node.ELEMENT_NODE
348
+ ? source
349
+ : composedParentElement(source);
350
+ };
351
+ /**
352
+ * What the TTS colors derive from. The colors are custom properties on the root
353
+ * element, which every coordinator on the page writes, so the coordinators share
354
+ * these inputs: any one's refresh writes the same values, and two theme
355
+ * observers cannot keep undoing each other's writes.
356
+ */
357
+ const ttsStyleInputs = { source: null, override: null };
358
+ export class HighlightCoordinator {
359
+ // The ranges this coordinator painted into the shared TTS highlights.
360
+ ttsWordRanges = new Set();
361
+ ttsSentenceRanges = new Set();
362
+ ttsWordElementHighlights = new Set();
363
+ ttsSentenceElementHighlights = new Set();
364
+ annotations = new Map();
365
+ // The element this coordinator last adapted the TTS colors to.
366
+ ttsStyleSource = null;
367
+ nextAnnotationId = 1;
368
+ supported = false;
369
+ rangeSerializer;
370
+ themeObserver = null;
371
+ constructor() {
372
+ this.rangeSerializer = new RangeSerializer();
373
+ // SSR guard
374
+ if (typeof CSS === "undefined" || !("highlights" in CSS)) {
375
+ console.warn("CSS Custom Highlight API not supported (SSR or unsupported browser)");
376
+ return;
377
+ }
378
+ this.supported = true;
379
+ this.initializeHighlights();
380
+ this.registerStyles();
381
+ this.applyAdaptiveTTSStyle();
382
+ this.setupThemeObservation();
383
+ }
384
+ /**
385
+ * Re-adapt the TTS colors when the page's theme moves: on the document
386
+ * element, on a `<pie-theme>` host, and when a `<pie-theme>` mounts, which
387
+ * themes its target as it connects.
388
+ */
389
+ setupThemeObservation() {
390
+ if (typeof document === "undefined")
391
+ return;
392
+ if (typeof MutationObserver === "undefined")
393
+ return;
394
+ const themeHosts = document.getElementsByTagName("pie-theme");
395
+ const observedHosts = new WeakSet();
396
+ const observeNewHosts = () => {
397
+ let found = false;
398
+ for (const host of themeHosts) {
399
+ if (observedHosts.has(host))
400
+ continue;
401
+ observedHosts.add(host);
402
+ observer.observe(host, {
403
+ attributes: true,
404
+ attributeFilter: [
405
+ "theme",
406
+ "scheme",
407
+ "provider",
408
+ "variables",
409
+ "style",
410
+ "data-theme",
411
+ "data-color-scheme",
412
+ ],
413
+ });
414
+ found = true;
415
+ }
416
+ return found;
417
+ };
418
+ const observer = new MutationObserver((records) => {
419
+ const mounted = observeNewHosts();
420
+ if (mounted || records.some((record) => record.type === "attributes")) {
421
+ this.applyAdaptiveTTSStyle();
422
+ }
423
+ });
424
+ this.themeObserver = observer;
425
+ observer.observe(document.documentElement, {
426
+ attributes: true,
427
+ attributeFilter: ["style", "data-theme", "data-color-scheme", "class"],
428
+ });
429
+ // Insertions, so a `<pie-theme>` mounted later is found. An insertion
430
+ // costs a walk of the live host list, which holds a handful at most.
431
+ observer.observe(document, { childList: true, subtree: true });
432
+ observeNewHosts();
433
+ }
434
+ parseColor(input) {
435
+ if (!input)
436
+ return null;
437
+ const value = input.trim();
438
+ if (!value)
439
+ return null;
440
+ const hexMatch = /^#([a-f\d]{3}|[a-f\d]{6})$/i.exec(value);
441
+ if (hexMatch) {
442
+ const hex = hexMatch[1];
443
+ if (hex.length === 3) {
444
+ return [
445
+ parseInt(hex[0] + hex[0], 16),
446
+ parseInt(hex[1] + hex[1], 16),
447
+ parseInt(hex[2] + hex[2], 16),
448
+ ];
449
+ }
450
+ return [
451
+ parseInt(hex.slice(0, 2), 16),
452
+ parseInt(hex.slice(2, 4), 16),
453
+ parseInt(hex.slice(4, 6), 16),
454
+ ];
455
+ }
456
+ const rgbMatch = /^rgba?\((.+)\)$/i.exec(value);
457
+ if (rgbMatch) {
458
+ const normalized = rgbMatch[1].replace(/\//g, ",");
459
+ const parts = normalized
460
+ .split(/[,\s]+/)
461
+ .map((part) => part.trim())
462
+ .filter(Boolean);
463
+ const r = Number(parts[0]);
464
+ const g = Number(parts[1]);
465
+ const b = Number(parts[2]);
466
+ if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) {
467
+ return [r, g, b];
468
+ }
469
+ }
470
+ // Browser parser fallback (covers formats like oklch()).
471
+ if (typeof document !== "undefined") {
472
+ const parserEl = document.createElement("span");
473
+ parserEl.style.color = value;
474
+ if (typeof parserEl.style.color === "string" && parserEl.style.color) {
475
+ document.body?.appendChild(parserEl);
476
+ const resolved = typeof getComputedStyle === "function"
477
+ ? getComputedStyle(parserEl).color
478
+ : "";
479
+ parserEl.remove();
480
+ const normalizedResolved = resolved.trim();
481
+ // Guard against recursive loops when computed style returns the same
482
+ // unresolved function syntax (observed with certain color formats).
483
+ if (normalizedResolved && normalizedResolved !== value) {
484
+ return this.parseColor(resolved);
485
+ }
486
+ }
487
+ }
488
+ return null;
489
+ }
490
+ relativeLuminance([r, g, b]) {
491
+ const toLinear = (channel) => {
492
+ const n = channel / 255;
493
+ return n <= 0.03928 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4;
494
+ };
495
+ const lr = toLinear(r);
496
+ const lg = toLinear(g);
497
+ const lb = toLinear(b);
498
+ return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
499
+ }
500
+ contrastRatio(a, b) {
501
+ const la = this.relativeLuminance(a);
502
+ const lb = this.relativeLuminance(b);
503
+ const lighter = Math.max(la, lb);
504
+ const darker = Math.min(la, lb);
505
+ return (lighter + 0.05) / (darker + 0.05);
506
+ }
507
+ blend(fg, bg, alpha) {
508
+ return [
509
+ Math.round(fg[0] * alpha + bg[0] * (1 - alpha)),
510
+ Math.round(fg[1] * alpha + bg[1] * (1 - alpha)),
511
+ Math.round(fg[2] * alpha + bg[2] * (1 - alpha)),
512
+ ];
513
+ }
514
+ resolveAdaptiveTTSStyle(sourceEl) {
515
+ const fallbackMissing = [255, 235, 59];
516
+ const fallbackText = [17, 24, 39];
517
+ const fallbackBackground = [255, 255, 255];
518
+ const target = sourceEl ||
519
+ (typeof document !== "undefined" ? document.documentElement : null);
520
+ const computed = target && typeof getComputedStyle === "function"
521
+ ? getComputedStyle(target)
522
+ : null;
523
+ const background = this.parseColor(computed?.getPropertyValue("--pie-background") ||
524
+ computed?.backgroundColor) || fallbackBackground;
525
+ const text = this.parseColor(computed?.getPropertyValue("--pie-text")) || fallbackText;
526
+ const override = ttsStyleInputs.override;
527
+ const accent = override
528
+ ? this.parseColor(override.color) || fallbackMissing
529
+ : this.parseColor(computed?.getPropertyValue("--pie-missing")) ||
530
+ fallbackMissing;
531
+ const opacityCandidates = override
532
+ ? [Math.max(0.3, Math.min(0.95, override.opacity))]
533
+ : [0.8, 0.72, 0.68, 0.62, 0.56, 0.5];
534
+ let selectedOpacity = opacityCandidates[opacityCandidates.length - 1];
535
+ let bestScore = -Infinity;
536
+ // The first opacity whose blend clears 1.25:1 against the background and
537
+ // 2.4:1 against the text wins; otherwise the best weighted score.
538
+ for (const opacity of opacityCandidates) {
539
+ const blended = this.blend(accent, background, opacity);
540
+ const backgroundDelta = this.contrastRatio(blended, background);
541
+ const textContrast = this.contrastRatio(blended, text);
542
+ const score = backgroundDelta * 1.2 + textContrast * 0.8;
543
+ if (backgroundDelta >= 1.25 && textContrast >= 2.4) {
544
+ selectedOpacity = opacity;
545
+ break;
546
+ }
547
+ if (score > bestScore) {
548
+ bestScore = score;
549
+ selectedOpacity = opacity;
550
+ }
551
+ }
552
+ const sentenceOpacity = Math.max(0.24, Math.min(0.85, selectedOpacity * 0.55));
553
+ const underlineOpacity = Math.max(0.55, Math.min(0.95, selectedOpacity + 0.2));
554
+ const shadowOpacity = Math.max(0.22, Math.min(0.6, selectedOpacity * 0.45));
555
+ const underlineColor = text;
556
+ const underlineBlend = this.blend(underlineColor, background, underlineOpacity);
557
+ const underlineDelta = this.contrastRatio(underlineBlend, background);
558
+ const fallbackUnderline = this.relativeLuminance(background) > 0.45 ? [0, 0, 0] : [255, 255, 255];
559
+ const finalUnderline = underlineDelta >= 1.35 ? underlineColor : fallbackUnderline;
560
+ const wordHighlight = `rgba(${accent[0]}, ${accent[1]}, ${accent[2]}, ${selectedOpacity})`;
561
+ const sentenceHighlight = `rgba(${accent[0]}, ${accent[1]}, ${accent[2]}, ${sentenceOpacity})`;
562
+ const wordUnderline = `rgba(${finalUnderline[0]}, ${finalUnderline[1]}, ${finalUnderline[2]}, ${underlineOpacity})`;
563
+ const wordShadow = `rgba(${finalUnderline[0]}, ${finalUnderline[1]}, ${finalUnderline[2]}, ${shadowOpacity})`;
564
+ return {
565
+ wordHighlight,
566
+ sentenceHighlight,
567
+ wordUnderline,
568
+ wordShadow,
569
+ };
570
+ }
571
+ /**
572
+ * Adapt the TTS colors to `sourceEl`, the element being highlighted. Without
573
+ * one, to the element highlighted last on the page while it stays connected,
574
+ * else to the document.
575
+ */
576
+ applyAdaptiveTTSStyle(sourceEl) {
577
+ if (typeof document === "undefined")
578
+ return;
579
+ if (sourceEl) {
580
+ this.ttsStyleSource = sourceEl;
581
+ ttsStyleInputs.source = sourceEl;
582
+ }
583
+ const source = sourceEl ??
584
+ (ttsStyleInputs.source?.isConnected ? ttsStyleInputs.source : null);
585
+ const vars = this.resolveAdaptiveTTSStyle(source);
586
+ const style = document.documentElement.style;
587
+ const properties = [
588
+ ["--pie-tts-word-highlight", vars.wordHighlight],
589
+ ["--pie-tts-sentence-highlight", vars.sentenceHighlight],
590
+ ["--pie-tts-line-highlight", vars.sentenceHighlight],
591
+ ["--pie-tts-word-underline", vars.wordUnderline],
592
+ ["--pie-tts-word-shadow", vars.wordShadow],
593
+ ];
594
+ for (const [name, value] of properties) {
595
+ // An unchanged value is not rewritten: the theme observer watches this
596
+ // element's style, and a write would notify it again.
597
+ if (style.getPropertyValue?.(name) === value)
598
+ continue;
599
+ style.setProperty(name, value);
600
+ }
601
+ }
602
+ /** Register the shared highlights, unless another coordinator already did. */
603
+ initializeHighlights() {
604
+ if (!this.supported)
605
+ return;
606
+ sharedHighlight(TTS_WORD_HIGHLIGHT);
607
+ sharedHighlight(TTS_SENTENCE_HIGHLIGHT);
608
+ for (const color of Object.values(HighlightColor)) {
609
+ sharedHighlight(annotationHighlightName(color));
610
+ }
611
+ }
612
+ /**
613
+ * Register CSS styles for highlights
614
+ */
615
+ registerStyles() {
616
+ if (!this.supported)
617
+ return;
618
+ if (typeof document === "undefined")
619
+ return; // SSR guard
620
+ if (document.getElementById("pie-highlight-styles"))
621
+ return;
622
+ const style = document.createElement("style");
623
+ style.id = "pie-highlight-styles";
624
+ style.textContent = HIGHLIGHT_STYLES;
484
625
  document.head.appendChild(style);
485
626
  }
486
627
  /**
487
- * Highlight a word for TTS (temporary)
628
+ * Highlight the word being read (temporary), replacing the previous one.
629
+ * Every range paints, so a word split across inline elements highlights
630
+ * whole. A range that covers elements also marks the replaced elements in it
631
+ * (svg, img, canvas), which a CSS range cannot paint.
488
632
  *
489
- * @param textNode Text node containing the word
490
- * @param startOffset Start position in text node
491
- * @param endOffset End position in text node
633
+ * @param ranges The word's ranges: one per tree it spans
492
634
  */
493
- highlightTTSWord(textNode, startOffset, endOffset) {
494
- if (!this.ttsWordHighlight)
635
+ highlightTTSWord(ranges) {
636
+ if (!this.supported)
495
637
  return;
496
- this.applyAdaptiveTTSStyle(textNode.parentElement);
497
- // Clear previous word highlight
638
+ this.applyAdaptiveTTSStyle(startElementOf(ranges));
498
639
  this.clearTTSWord();
499
- // Create range for word
500
- const range = document.createRange();
501
- range.setStart(textNode, startOffset);
502
- range.setEnd(textNode, endOffset);
503
- // Add to highlight
504
- this.ttsWordHighlight.add(range);
640
+ const highlight = sharedHighlight(TTS_WORD_HIGHLIGHT);
641
+ for (const range of ranges) {
642
+ adoptHighlightStylesFor(range.startContainer);
643
+ highlight.add(range);
644
+ this.ttsWordRanges.add(range);
645
+ }
646
+ this.highlightTTSWordElementFallbacks(ranges.filter((range) => range.startContainer !== range.endContainer ||
647
+ range.startContainer.nodeType !== Node.TEXT_NODE));
505
648
  }
506
649
  /**
507
650
  * Highlight a single element as the active TTS word (temporary).
508
651
  *
509
- * Unlike {@link highlightTTSWord} (which paints a CSS range over a text node)
510
- * this marks the element itself via `data-pie-tts-word-element`. It is used
652
+ * Unlike {@link highlightTTSWord} (which paints CSS ranges over text) this
653
+ * marks the element itself via `data-pie-tts-word-element`. It is used
511
654
  * for atomic targets that have no direct text node to range over — most
512
655
  * notably MathJax CHTML tokens (e.g. `<mjx-mi><mjx-c/></mjx-mi>`, whose glyph
513
656
  * lives in a font-driven pseudo-element a CSS range cannot paint) and
514
657
  * whole-expression fallbacks.
515
658
  *
516
- * The supplied element is painted exactly; we deliberately do NOT walk up to
517
- * a containing `<math>` / `<mjx-container>`. That escalation is what made
518
- * MathJax-rendered math highlight as a full block while native MathML tracked
519
- * per-token: a resolved single token must paint as a token, and only a
520
- * genuine expression fallback (where the element already is the container)
521
- * should paint the whole expression.
659
+ * The supplied element is painted exactly, with no walk up to a containing
660
+ * `<math>` / `<mjx-container>`: a resolved token paints as a token, and only
661
+ * an expression fallback, where the element already is the container, paints
662
+ * the whole expression.
522
663
  */
523
664
  highlightTTSWordElement(element) {
524
- if (!this.ttsWordHighlight)
665
+ if (!this.supported)
525
666
  return;
526
667
  this.applyAdaptiveTTSStyle(element);
668
+ adoptHighlightStylesFor(element);
527
669
  // Clear previous word highlight (CSS range and any element attributes)
528
670
  this.clearTTSWord();
529
671
  element.setAttribute("data-pie-tts-word-element", "true");
@@ -536,18 +678,15 @@ export class HighlightCoordinator {
536
678
  * @param ranges Line (or sentence) ranges to paint with `--pie-tts-line-highlight`
537
679
  */
538
680
  highlightTTSSentence(ranges) {
539
- if (!this.ttsSentenceHighlight)
681
+ if (!this.supported)
540
682
  return;
541
- const source = ranges[0]?.startContainer;
542
- const sourceElement = source?.nodeType === Node.ELEMENT_NODE
543
- ? source
544
- : source?.parentElement;
545
- this.applyAdaptiveTTSStyle(sourceElement);
546
- // Clear previous sentence highlight
683
+ this.applyAdaptiveTTSStyle(startElementOf(ranges));
547
684
  this.clearTTSSentence();
548
- // Add all ranges
685
+ const highlight = sharedHighlight(TTS_SENTENCE_HIGHLIGHT);
549
686
  for (const range of ranges) {
550
- this.ttsSentenceHighlight.add(range);
687
+ adoptHighlightStylesFor(range.startContainer);
688
+ highlight.add(range);
689
+ this.ttsSentenceRanges.add(range);
551
690
  }
552
691
  this.highlightTTSElementFallbacks(ranges);
553
692
  }
@@ -557,11 +696,12 @@ export class HighlightCoordinator {
557
696
  * @param elements Visible elements to paint with `--pie-tts-line-highlight`
558
697
  */
559
698
  highlightTTSSentenceElements(elements) {
560
- if (!this.ttsSentenceHighlight)
699
+ if (!this.supported)
561
700
  return;
562
701
  this.applyAdaptiveTTSStyle(elements[0] || null);
563
702
  this.clearTTSSentence();
564
703
  for (const element of elements) {
704
+ adoptHighlightStylesFor(element);
565
705
  element.setAttribute("data-pie-tts-sentence-element", "true");
566
706
  this.ttsSentenceElementHighlights.add(element);
567
707
  }
@@ -569,12 +709,11 @@ export class HighlightCoordinator {
569
709
  // The read-along (sentence) band legitimately covers whole equations and
570
710
  // replaced elements, so it escalates to them.
571
711
  static SENTENCE_FALLBACK_SELECTOR = "math, mjx-container, svg, img, canvas, [role='img']";
572
- // The word layer must NOT escalate a multi-node range to the enclosing
573
- // `<math>` / `<mjx-container>`: that is what made an equation flash as a
574
- // whole block whenever a spoken word's visible range crossed several math
575
- // glyph nodes. Math is tracked per token (or held / region) by the highlight
576
- // pipeline instead. Replaced elements (svg/img/canvas) still need the element
577
- // fallback because a CSS range cannot paint them.
712
+ // The word layer falls back to the element only for replaced elements
713
+ // (svg/img/canvas), which a CSS range cannot paint. Escalating to the
714
+ // enclosing `<math>` / `<mjx-container>` flashed the whole equation whenever
715
+ // a word crossed several glyph nodes, so math is tracked per token, held or
716
+ // by region in the highlight pipeline.
578
717
  static WORD_FALLBACK_SELECTOR = "svg, img, canvas, [role='img']";
579
718
  highlightTTSElementFallbacks(ranges) {
580
719
  if (typeof Element === "undefined")
@@ -587,36 +726,38 @@ export class HighlightCoordinator {
587
726
  this.highlightElementFallbacks(ranges, "data-pie-tts-word-element", this.ttsWordElementHighlights, HighlightCoordinator.WORD_FALLBACK_SELECTOR);
588
727
  }
589
728
  highlightElementFallbacks(ranges, attributeName, trackedElements, selector) {
729
+ const elementAt = (node) => node.nodeType === Node.ELEMENT_NODE
730
+ ? node
731
+ : composedParentElement(node);
590
732
  for (const range of ranges) {
591
- const root = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
592
- ? range.commonAncestorContainer
593
- : range.commonAncestorContainer.parentElement;
733
+ // A range over a shadow root's top-level children has the root as its
734
+ // common ancestor, and the root is what to search.
735
+ const common = range.commonAncestorContainer;
736
+ const root = isShadowRootNode(common)
737
+ ? common
738
+ : elementAt(common);
594
739
  if (!root)
595
740
  continue;
596
741
  const candidates = [];
597
- const rangeStartElement = range.startContainer.nodeType === Node.ELEMENT_NODE
598
- ? range.startContainer
599
- : range.startContainer.parentElement;
600
- const rangeEndElement = range.endContainer.nodeType === Node.ELEMENT_NODE
601
- ? range.endContainer
602
- : range.endContainer.parentElement;
742
+ const rangeStartElement = elementAt(range.startContainer);
743
+ const rangeEndElement = elementAt(range.endContainer);
603
744
  for (const element of [rangeStartElement, rangeEndElement]) {
604
745
  let current = element;
605
746
  while (current) {
606
747
  if (current.matches?.(selector))
607
748
  candidates.push(current);
608
- current = current.parentElement;
749
+ current = composedParentElement(current);
609
750
  }
610
751
  }
611
- if (root.matches?.(selector)) {
752
+ if (!isShadowRootNode(root) && root.matches?.(selector)) {
612
753
  candidates.push(root);
613
754
  }
614
755
  candidates.push(...Array.from(root.querySelectorAll(selector)));
615
756
  for (const element of new Set(candidates)) {
616
757
  try {
617
758
  if (!range.intersectsNode(element) &&
618
- !element.contains(rangeStartElement) &&
619
- !element.contains(rangeEndElement)) {
759
+ !composedContains(element, rangeStartElement) &&
760
+ !composedContains(element, rangeEndElement)) {
620
761
  continue;
621
762
  }
622
763
  }
@@ -632,9 +773,10 @@ export class HighlightCoordinator {
632
773
  * Clear TTS word highlight
633
774
  */
634
775
  clearTTSWord() {
635
- if (!this.ttsWordHighlight)
776
+ if (!this.supported)
636
777
  return;
637
- this.ttsWordHighlight.clear();
778
+ removeRanges(TTS_WORD_HIGHLIGHT, this.ttsWordRanges);
779
+ this.ttsWordRanges.clear();
638
780
  for (const element of this.ttsWordElementHighlights) {
639
781
  element.removeAttribute("data-pie-tts-word-element");
640
782
  }
@@ -644,9 +786,10 @@ export class HighlightCoordinator {
644
786
  * Clear TTS sentence highlight
645
787
  */
646
788
  clearTTSSentence() {
647
- if (!this.ttsSentenceHighlight)
789
+ if (!this.supported)
648
790
  return;
649
- this.ttsSentenceHighlight.clear();
791
+ removeRanges(TTS_SENTENCE_HIGHLIGHT, this.ttsSentenceRanges);
792
+ this.ttsSentenceRanges.clear();
650
793
  for (const element of this.ttsSentenceElementHighlights) {
651
794
  element.removeAttribute("data-pie-tts-sentence-element");
652
795
  }
@@ -658,6 +801,10 @@ export class HighlightCoordinator {
658
801
  clearTTS() {
659
802
  this.clearTTSWord();
660
803
  this.clearTTSSentence();
804
+ if (ttsStyleInputs.source === this.ttsStyleSource) {
805
+ ttsStyleInputs.source = null;
806
+ }
807
+ this.ttsStyleSource = null;
661
808
  }
662
809
  /**
663
810
  * Add an annotation highlight (persistent)
@@ -668,9 +815,8 @@ export class HighlightCoordinator {
668
815
  */
669
816
  addAnnotation(range, color = HighlightColor.YELLOW) {
670
817
  const id = `annotation-${this.nextAnnotationId++}`;
671
- // Clone the range to store
672
818
  const clonedRange = range.cloneRange();
673
- // Store annotation data
819
+ adoptHighlightStylesFor(clonedRange.startContainer);
674
820
  const annotation = {
675
821
  id,
676
822
  range: clonedRange,
@@ -679,11 +825,9 @@ export class HighlightCoordinator {
679
825
  timestamp: Date.now(),
680
826
  };
681
827
  this.annotations.set(id, annotation);
682
- // Add the SAME range object to the shared color highlight
683
- // This ensures we can later delete it by reference
684
- const colorHighlight = this.colorHighlights.get(color);
685
- if (colorHighlight) {
686
- colorHighlight.add(clonedRange);
828
+ // The same range object, so it can later be deleted by reference.
829
+ if (this.supported) {
830
+ sharedHighlight(annotationHighlightName(color)).add(clonedRange);
687
831
  }
688
832
  return id;
689
833
  }
@@ -698,26 +842,24 @@ export class HighlightCoordinator {
698
842
  console.warn(`[HighlightCoordinator] Annotation ${id} not found`);
699
843
  return;
700
844
  }
701
- console.log(`[HighlightCoordinator] Removing annotation ${id} (color: ${annotation.color})`);
702
- // Remove range from the shared color highlight
703
- const colorHighlight = this.colorHighlights.get(annotation.color);
704
- if (colorHighlight) {
705
- const deleted = colorHighlight.delete(annotation.range);
706
- console.log(`[HighlightCoordinator] Highlight.delete() returned:`, deleted);
845
+ if (this.supported) {
846
+ removeRanges(annotationHighlightName(annotation.color), [
847
+ annotation.range,
848
+ ]);
707
849
  }
708
- // Clean up
709
850
  this.annotations.delete(id);
710
- console.log(`[HighlightCoordinator] Annotation ${id} removed from map. Remaining:`, this.annotations.size);
711
851
  }
712
852
  /**
713
853
  * Remove all annotations
714
854
  */
715
855
  clearAnnotations() {
716
- // Clear all color highlights
717
- for (const colorHighlight of this.colorHighlights.values()) {
718
- colorHighlight.clear();
856
+ if (this.supported) {
857
+ for (const annotation of this.annotations.values()) {
858
+ removeRanges(annotationHighlightName(annotation.color), [
859
+ annotation.range,
860
+ ]);
861
+ }
719
862
  }
720
- // Clear annotation data
721
863
  this.annotations.clear();
722
864
  }
723
865
  /**
@@ -745,17 +887,10 @@ export class HighlightCoordinator {
745
887
  const oldColor = annotation.color;
746
888
  if (oldColor === newColor)
747
889
  return;
748
- // Remove from old color highlight
749
- const oldColorHighlight = this.colorHighlights.get(oldColor);
750
- if (oldColorHighlight) {
751
- oldColorHighlight.delete(annotation.range);
752
- }
753
- // Update color
754
890
  annotation.color = newColor;
755
- // Add to new color highlight
756
- const newColorHighlight = this.colorHighlights.get(newColor);
757
- if (newColorHighlight) {
758
- newColorHighlight.add(annotation.range);
891
+ if (this.supported) {
892
+ removeRanges(annotationHighlightName(oldColor), [annotation.range]);
893
+ sharedHighlight(annotationHighlightName(newColor)).add(annotation.range);
759
894
  }
760
895
  }
761
896
  /**
@@ -795,59 +930,6 @@ export class HighlightCoordinator {
795
930
  // ============================================================================
796
931
  // HighlightCoordinatorApi interface implementation
797
932
  // ============================================================================
798
- /**
799
- * Highlight a text range (interface method)
800
- *
801
- * Generic method that adapts to the specific highlight type.
802
- * For more control, use the specific methods like highlightTTSWord().
803
- */
804
- highlightRange(range, type, color = HighlightColor.YELLOW) {
805
- if (!this.supported)
806
- return;
807
- switch (type) {
808
- case HighlightType.TTS_WORD:
809
- case HighlightType.TTS_SENTENCE: {
810
- // For TTS, add to appropriate highlight
811
- const highlight = type === HighlightType.TTS_WORD
812
- ? this.ttsWordHighlight
813
- : this.ttsSentenceHighlight;
814
- if (highlight) {
815
- highlight.clear();
816
- highlight.add(range);
817
- if (type === HighlightType.TTS_WORD) {
818
- for (const element of this.ttsWordElementHighlights) {
819
- element.removeAttribute("data-pie-tts-word-element");
820
- }
821
- this.ttsWordElementHighlights.clear();
822
- this.highlightTTSWordElementFallbacks([range]);
823
- }
824
- }
825
- break;
826
- }
827
- case HighlightType.ANNOTATION:
828
- // For annotations, use the existing method
829
- this.addAnnotation(range, color);
830
- break;
831
- }
832
- }
833
- /**
834
- * Clear highlights of a specific type (interface method)
835
- */
836
- clearHighlights(type) {
837
- if (!this.supported)
838
- return;
839
- switch (type) {
840
- case HighlightType.TTS_WORD:
841
- this.clearTTSWord();
842
- break;
843
- case HighlightType.TTS_SENTENCE:
844
- this.clearTTSSentence();
845
- break;
846
- case HighlightType.ANNOTATION:
847
- this.clearAnnotations();
848
- break;
849
- }
850
- }
851
933
  /**
852
934
  * Clear all highlights (interface method)
853
935
  */
@@ -857,12 +939,6 @@ export class HighlightCoordinator {
857
939
  this.clearTTS();
858
940
  this.clearAnnotations();
859
941
  }
860
- /**
861
- * Check if CSS Highlight API is supported (interface method)
862
- */
863
- isSupported() {
864
- return this.supported;
865
- }
866
942
  /**
867
943
  * Update TTS highlight style dynamically
868
944
  *
@@ -877,7 +953,6 @@ export class HighlightCoordinator {
877
953
  const styleEl = document.getElementById("pie-highlight-styles");
878
954
  if (!styleEl)
879
955
  return;
880
- // Convert hex to rgba
881
956
  const hexToRgb = (hex) => {
882
957
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
883
958
  return result
@@ -891,24 +966,27 @@ export class HighlightCoordinator {
891
966
  const rgb = hexToRgb(color);
892
967
  if (!rgb)
893
968
  return;
894
- this.explicitTTSColorOverride = {
969
+ ttsStyleInputs.override = {
895
970
  color: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
896
971
  opacity: Math.max(0.2, Math.min(0.95, opacity)),
972
+ owner: this,
897
973
  };
898
974
  this.applyAdaptiveTTSStyle();
899
975
  }
900
976
  /**
901
- * Cleanup this coordinator's highlights and observer.
977
+ * Remove this coordinator's ranges and stop its observer.
902
978
  *
903
- * The stylesheet is process-global and shared by every live coordinator, so
904
- * removing it here would break a replacement or sibling coordinator. Keep the
905
- * single bounded style element installed for the lifetime of the document.
979
+ * The stylesheet and the registered highlights are page-wide and shared by
980
+ * every live coordinator, so they stay installed for the document's lifetime.
906
981
  */
907
982
  destroy() {
908
983
  if (!this.supported)
909
984
  return;
910
985
  this.clearTTS();
911
986
  this.clearAnnotations();
987
+ if (ttsStyleInputs.override?.owner === this) {
988
+ ttsStyleInputs.override = null;
989
+ }
912
990
  this.themeObserver?.disconnect();
913
991
  this.themeObserver = null;
914
992
  }