@pie-players/pie-assessment-toolkit 0.3.74 → 0.3.75

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 (228) hide show
  1. package/README.md +349 -313
  2. package/dist/attempt/TestSession.d.ts +1 -20
  3. package/dist/attempt/TestSession.js +8 -99
  4. package/dist/components/ItemScope.custom-element.d.ts +1 -0
  5. package/dist/components/ItemScope.custom-element.js +1 -0
  6. package/dist/components/ItemToolBar.custom-element.js +1 -1
  7. package/dist/components/PieAssessmentToolkit.custom-element.js +14 -14
  8. package/dist/components/SectionToolBar.custom-element.js +1 -1
  9. package/dist/components/chunks/ItemToolBar-7tt7v2xp.js +4 -0
  10. package/dist/components/chunks/{ItemToolBar-gmrryv2v.js → ItemToolBar-97hx60z4.js} +1 -1
  11. package/dist/components/chunks/ItemToolBar-bxwyhr5p.js +2 -0
  12. package/dist/components/chunks/ItemToolBar-jx38w99s.js +2 -0
  13. package/dist/components/chunks/ItemToolBar-rd2bb4p6.js +40 -0
  14. package/dist/components/chunks/ItemToolBar-vdkyn9f3.js +2 -0
  15. package/dist/components/chunks/sre-engine-jd8cc08x.js +1 -0
  16. package/dist/components/item-scope-element.d.ts +2 -0
  17. package/dist/components/item-scope-element.js +2 -0
  18. package/dist/context/assessment-toolkit-context.d.ts +27 -6
  19. package/dist/index.d.ts +22 -47
  20. package/dist/index.js +12 -36
  21. package/dist/policy/core/PolicySource.d.ts +1 -1
  22. package/dist/policy/core/ToolPolicyEngine.d.ts +77 -32
  23. package/dist/policy/core/ToolPolicyEngine.js +136 -46
  24. package/dist/policy/core/compose-decision.d.ts +28 -7
  25. package/dist/policy/core/compose-decision.js +75 -54
  26. package/dist/policy/core/decision-types.d.ts +65 -34
  27. package/dist/policy/core/feature-decision.d.ts +16 -9
  28. package/dist/policy/core/feature-decision.js +14 -8
  29. package/dist/policy/core/pnp-policy-inputs.d.ts +24 -33
  30. package/dist/policy/core/pnp-policy-inputs.js +28 -56
  31. package/dist/policy/core/provenance.d.ts +9 -4
  32. package/dist/policy/core/provenance.js +1 -1
  33. package/dist/policy/engine.d.ts +7 -23
  34. package/dist/policy/engine.js +5 -20
  35. package/dist/policy/sources/PnpPolicySource.d.ts +44 -61
  36. package/dist/policy/sources/PnpPolicySource.js +117 -118
  37. package/dist/runtime/SectionControllerBinding.d.ts +279 -0
  38. package/dist/runtime/SectionControllerBinding.js +450 -0
  39. package/dist/runtime/SectionRuntimeEngine.d.ts +26 -289
  40. package/dist/runtime/SectionRuntimeEngine.js +30 -372
  41. package/dist/runtime/adapter/SectionEngineAdapter.d.ts +19 -80
  42. package/dist/runtime/adapter/SectionEngineAdapter.js +19 -92
  43. package/dist/runtime/adapter/dom-event-bridge.d.ts +15 -30
  44. package/dist/runtime/adapter/dom-event-bridge.js +17 -44
  45. package/dist/runtime/adapter/subscriber-fanout.d.ts +1 -2
  46. package/dist/runtime/adapter/subscriber-fanout.js +1 -2
  47. package/dist/runtime/catalog-context.d.ts +19 -0
  48. package/dist/runtime/catalog-context.js +28 -0
  49. package/dist/runtime/composition-emit-scheduler.d.ts +9 -15
  50. package/dist/runtime/composition-emit-scheduler.js +9 -15
  51. package/dist/runtime/content-language.d.ts +32 -0
  52. package/dist/runtime/content-language.js +46 -0
  53. package/dist/runtime/content-region.d.ts +17 -0
  54. package/dist/runtime/content-region.js +29 -0
  55. package/dist/runtime/content-registration.d.ts +13 -0
  56. package/dist/runtime/content-registration.js +25 -0
  57. package/dist/runtime/core/SectionEngineCore.d.ts +2 -2
  58. package/dist/runtime/core/SectionEngineCore.js +2 -2
  59. package/dist/runtime/core/cohort.d.ts +1 -1
  60. package/dist/runtime/core/cohort.js +1 -1
  61. package/dist/runtime/core/engine-input.d.ts +18 -30
  62. package/dist/runtime/core/engine-input.js +11 -12
  63. package/dist/runtime/core/engine-output.d.ts +16 -40
  64. package/dist/runtime/core/engine-output.js +9 -21
  65. package/dist/runtime/core/engine-readiness.d.ts +5 -37
  66. package/dist/runtime/core/engine-readiness.js +8 -41
  67. package/dist/runtime/core/engine-resolver.d.ts +46 -74
  68. package/dist/runtime/core/engine-resolver.js +18 -43
  69. package/dist/runtime/core/engine-state.d.ts +28 -36
  70. package/dist/runtime/core/engine-state.js +18 -21
  71. package/dist/runtime/core/engine-transition.d.ts +14 -19
  72. package/dist/runtime/core/engine-transition.js +71 -51
  73. package/dist/runtime/engine.d.ts +16 -19
  74. package/dist/runtime/engine.js +12 -19
  75. package/dist/runtime/page-warnings.d.ts +13 -0
  76. package/dist/runtime/page-warnings.js +22 -0
  77. package/dist/runtime/policy-input-forwarding.d.ts +25 -0
  78. package/dist/runtime/policy-input-forwarding.js +25 -0
  79. package/dist/runtime/registration-events.d.ts +11 -0
  80. package/dist/runtime/runtime-event-claim.d.ts +8 -0
  81. package/dist/runtime/runtime-event-claim.js +15 -0
  82. package/dist/runtime/shell-event-bridge.d.ts +45 -0
  83. package/dist/runtime/shell-event-bridge.js +127 -0
  84. package/dist/runtime/shell-registration.d.ts +73 -0
  85. package/dist/runtime/shell-registration.js +95 -0
  86. package/dist/runtime/shell-scope.d.ts +68 -0
  87. package/dist/runtime/shell-scope.js +188 -0
  88. package/dist/runtime/tool-host-contract.d.ts +8 -10
  89. package/dist/runtime/tool-host-contract.js +11 -11
  90. package/dist/runtime/unclaimed-registration-watch.d.ts +7 -0
  91. package/dist/runtime/unclaimed-registration-watch.js +23 -0
  92. package/dist/services/ElementToolStateStore.d.ts +14 -10
  93. package/dist/services/ElementToolStateStore.js +37 -13
  94. package/dist/services/HighlightCoordinator.d.ts +21 -21
  95. package/dist/services/HighlightCoordinator.js +431 -325
  96. package/dist/services/RangeSerializer.d.ts +10 -1
  97. package/dist/services/RangeSerializer.js +87 -14
  98. package/dist/services/TTSService.d.ts +117 -70
  99. package/dist/services/TTSService.js +668 -578
  100. package/dist/services/ToolCoordinator.d.ts +43 -60
  101. package/dist/services/ToolCoordinator.js +164 -206
  102. package/dist/services/ToolRegistry.d.ts +74 -57
  103. package/dist/services/ToolRegistry.js +104 -114
  104. package/dist/services/ToolkitCoordinator.d.ts +183 -189
  105. package/dist/services/ToolkitCoordinator.js +477 -431
  106. package/dist/services/audio-handoff.d.ts +6 -7
  107. package/dist/services/audio-handoff.js +20 -14
  108. package/dist/services/catalog-owner.d.ts +0 -1
  109. package/dist/services/framework-error-bus.d.ts +3 -6
  110. package/dist/services/framework-error-bus.js +3 -6
  111. package/dist/services/framework-error.d.ts +22 -7
  112. package/dist/services/framework-error.js +9 -7
  113. package/dist/services/interfaces.d.ts +158 -129
  114. package/dist/services/section-session-assignment.d.ts +21 -0
  115. package/dist/services/section-session-assignment.js +82 -0
  116. package/dist/services/spoken-audio-cards.d.ts +0 -2
  117. package/dist/services/spoken-audio-cards.js +0 -2
  118. package/dist/services/tool-config-defaults.d.ts +3 -3
  119. package/dist/services/tool-config-defaults.js +3 -3
  120. package/dist/services/tool-config-validation.d.ts +4 -5
  121. package/dist/services/tool-config-validation.js +36 -61
  122. package/dist/services/tool-context.d.ts +0 -8
  123. package/dist/services/tool-context.js +0 -12
  124. package/dist/services/tool-icons.d.ts +4 -3
  125. package/dist/services/tool-icons.js +4 -3
  126. package/dist/services/tool-instance-id.d.ts +0 -1
  127. package/dist/services/tool-instance-id.js +0 -6
  128. package/dist/services/tool-providers/TTSToolProvider.d.ts +18 -19
  129. package/dist/services/tool-providers/TTSToolProvider.js +37 -34
  130. package/dist/services/tool-providers/ToolProviderApi.d.ts +10 -4
  131. package/dist/services/tool-providers/ToolProviderRegistry.d.ts +30 -26
  132. package/dist/services/tool-providers/ToolProviderRegistry.js +119 -93
  133. package/dist/services/tool-request.d.ts +27 -12
  134. package/dist/services/tool-request.js +24 -14
  135. package/dist/services/toolbar-decided-tools.d.ts +15 -0
  136. package/dist/services/toolbar-decided-tools.js +28 -0
  137. package/dist/services/tools-config-normalizer.d.ts +17 -22
  138. package/dist/services/tools-config-normalizer.js +2 -10
  139. package/dist/services/tts/browser-provider.d.ts +38 -2
  140. package/dist/services/tts/browser-provider.js +276 -140
  141. package/dist/services/tts/flat-tree.d.ts +71 -0
  142. package/dist/services/tts/flat-tree.js +269 -0
  143. package/dist/services/tts/generated-speech/assemble-plan.d.ts +3 -1
  144. package/dist/services/tts/generated-speech/assemble-plan.js +3 -1
  145. package/dist/services/tts/generated-speech/dom/build-dom-plan.d.ts +2 -0
  146. package/dist/services/tts/generated-speech/dom/build-dom-plan.js +1 -1
  147. package/dist/services/tts/generated-speech/math-speech-cache.d.ts +3 -1
  148. package/dist/services/tts/generated-speech/math-speech-cache.js +5 -1
  149. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +12 -4
  150. package/dist/services/tts/highlight-pipeline/prose-boundary-resolver.js +6 -4
  151. package/dist/services/tts/highlight-pipeline/readable-region.js +3 -2
  152. package/dist/services/tts/highlight-pipeline/types.d.ts +5 -0
  153. package/dist/services/tts/highlight-pipeline/visible-map-range.d.ts +12 -0
  154. package/dist/services/tts/highlight-pipeline/visible-map-range.js +73 -7
  155. package/dist/services/tts/math-aware-text-processing.d.ts +5 -1
  156. package/dist/services/tts/math-aware-text-processing.js +32 -18
  157. package/dist/services/tts/math-control-names.d.ts +21 -0
  158. package/dist/services/tts/math-control-names.js +142 -0
  159. package/dist/services/tts/math-speech.d.ts +11 -0
  160. package/dist/services/tts/math-speech.js +51 -18
  161. package/dist/services/tts/provider-options.d.ts +41 -0
  162. package/dist/services/tts/provider-options.js +1 -0
  163. package/dist/services/tts/ssml/spoken-text.d.ts +2 -26
  164. package/dist/services/tts/ssml/spoken-text.js +29 -0
  165. package/dist/services/tts/start-failure.d.ts +11 -0
  166. package/dist/services/tts/start-failure.js +19 -0
  167. package/dist/services/tts/text-processing.d.ts +9 -0
  168. package/dist/services/tts/text-processing.js +46 -33
  169. package/dist/services/tts/text-segmentation.d.ts +7 -0
  170. package/dist/services/tts/text-segmentation.js +47 -0
  171. package/dist/services/tts-runtime-config.d.ts +25 -17
  172. package/dist/services/tts-runtime-config.js +16 -95
  173. package/dist/tools/content-capability-resolution.d.ts +7 -9
  174. package/dist/tools/content-capability-resolution.js +8 -45
  175. package/dist/tools/registration.d.ts +58 -0
  176. package/dist/tools/registration.js +54 -0
  177. package/dist/tools/registrations/toolbar-registration-helpers.js +2 -2
  178. package/dist/tools/tool-surface-host.d.ts +6 -10
  179. package/dist/tools/tool-surface-host.js +44 -42
  180. package/dist/tools/tool-tag-map.d.ts +5 -15
  181. package/dist/tools/tool-tag-map.js +6 -20
  182. package/dist/utils/structural-equality.d.ts +11 -0
  183. package/dist/utils/structural-equality.js +47 -0
  184. package/package.json +13 -29
  185. package/dist/attempt/adapters/activity-to-test-attempt-session.d.ts +0 -49
  186. package/dist/attempt/adapters/activity-to-test-attempt-session.js +0 -138
  187. package/dist/components/chunks/ItemToolBar-b9dz5jvd.js +0 -40
  188. package/dist/components/chunks/ItemToolBar-h4xap0t0.js +0 -4
  189. package/dist/components/chunks/ItemToolBar-wr2myaah.js +0 -2
  190. package/dist/components/chunks/sre-engine-wf9s43gg.js +0 -1
  191. package/dist/context/runtime-context-consumer.d.ts +0 -13
  192. package/dist/context/runtime-context-consumer.js +0 -18
  193. package/dist/item-loader.d.ts +0 -44
  194. package/dist/item-loader.js +0 -61
  195. package/dist/policy/core/engine-context.d.ts +0 -29
  196. package/dist/policy/core/engine-context.js +0 -27
  197. package/dist/policy/core/policy-source-tag.d.ts +0 -31
  198. package/dist/policy/core/policy-source-tag.js +0 -28
  199. package/dist/policy/internal.d.ts +0 -25
  200. package/dist/policy/internal.js +0 -25
  201. package/dist/runtime/adapter/coordinator-bridge.d.ts +0 -92
  202. package/dist/runtime/adapter/coordinator-bridge.js +0 -126
  203. package/dist/runtime/adapter/framework-error-bridge.d.ts +0 -36
  204. package/dist/runtime/adapter/framework-error-bridge.js +0 -46
  205. package/dist/runtime/adapter/instrumentation-bridge.d.ts +0 -31
  206. package/dist/runtime/adapter/instrumentation-bridge.js +0 -38
  207. package/dist/runtime/core/engine-stage-derivation.d.ts +0 -18
  208. package/dist/runtime/core/engine-stage-derivation.js +0 -29
  209. package/dist/runtime/engine-context.d.ts +0 -39
  210. package/dist/runtime/engine-context.js +0 -31
  211. package/dist/runtime/internal.d.ts +0 -43
  212. package/dist/runtime/internal.js +0 -45
  213. package/dist/runtime/section-runtime-engine-host-context.d.ts +0 -74
  214. package/dist/runtime/section-runtime-engine-host-context.js +0 -57
  215. package/dist/runtime/stage-emit-gate.d.ts +0 -92
  216. package/dist/runtime/stage-emit-gate.js +0 -82
  217. package/dist/services/I18nService.d.ts +0 -47
  218. package/dist/services/I18nService.js +0 -84
  219. package/dist/services/ThemeProvider.d.ts +0 -95
  220. package/dist/services/ThemeProvider.js +0 -334
  221. package/dist/services/defaultPersonalNeedsProfile.d.ts +0 -21
  222. package/dist/services/defaultPersonalNeedsProfile.js +0 -26
  223. package/dist/services/pnp-standard-features.d.ts +0 -238
  224. package/dist/services/pnp-standard-features.js +0 -338
  225. package/dist/tools/client.d.ts +0 -10
  226. package/dist/tools/client.js +0 -10
  227. package/dist/tools/internal.d.ts +0 -44
  228. package/dist/tools/internal.js +0 -39
@@ -21,6 +21,7 @@
21
21
  * @see https://developer.mozilla.org/en-US/docs/Web/API/CSS_Custom_Highlight_API
22
22
  */
23
23
  import { RangeSerializer } from "./RangeSerializer.js";
24
+ import { composedContains, composedParentElement, isShadowRootNode, } from "./tts/flat-tree.js";
24
25
  /**
25
26
  * Highlight types
26
27
  */
@@ -42,235 +43,13 @@ export var HighlightColor;
42
43
  HighlightColor["ORANGE"] = "orange";
43
44
  HighlightColor["UNDERLINE"] = "underline";
44
45
  })(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 = `
46
+ /**
47
+ * The highlight stylesheet: the document's copy in `<style id="pie-highlight-styles">`,
48
+ * and each shadow root's that holds a highlighted range. `::highlight()` rules
49
+ * paint text in the tree whose styles hold them, so text in a shadow root needs
50
+ * its own copy; Firefox paints none of it from the document's.
51
+ */
52
+ const HIGHLIGHT_STYLES = `
274
53
  /* TTS highlights - temporary */
275
54
  ::highlight(tts-word) {
276
55
  background-color: var(--pie-tts-word-highlight, color-mix(in srgb, var(--pie-missing, #ffeb3b) 68%, transparent));
@@ -481,33 +260,374 @@ export class HighlightCoordinator {
481
260
  }
482
261
  }
483
262
  `;
263
+ /**
264
+ * Page-wide, so every copy of the toolkit on a page shares one constructed sheet
265
+ * and adopts it into a shadow root once. The first copy to build it supplies its
266
+ * rules, as the first to append the document's `<style>` does.
267
+ */
268
+ const SHARED_SHEET_SLOT = Symbol.for("@pie-players/pie-assessment-toolkit/highlight-stylesheet");
269
+ const ADOPTED_SHEET_MARKER = Symbol.for("@pie-players/pie-assessment-toolkit/highlight-stylesheet-adopted");
270
+ const isSheetLike = (value) => !!value && typeof value.replaceSync === "function";
271
+ const sharedHighlightSheet = () => {
272
+ const slot = globalThis;
273
+ const existing = slot[SHARED_SHEET_SLOT];
274
+ if (isSheetLike(existing))
275
+ return existing;
276
+ const Sheet = globalThis
277
+ .CSSStyleSheet;
278
+ if (typeof Sheet !== "function")
279
+ return null;
280
+ try {
281
+ const sheet = new Sheet();
282
+ if (!isSheetLike(sheet))
283
+ return null;
284
+ sheet.replaceSync(HIGHLIGHT_STYLES);
285
+ slot[SHARED_SHEET_SLOT] = sheet;
286
+ return sheet;
287
+ }
288
+ catch {
289
+ return null;
290
+ }
291
+ };
292
+ /**
293
+ * Adopts the highlight stylesheet into the shadow root holding `node`, once per
294
+ * root. Appended, so the root's own sheets stay in place; re-adopted if the
295
+ * root's owner replaced its sheets since.
296
+ */
297
+ const adoptHighlightStylesFor = (node) => {
298
+ const found = node?.getRootNode?.();
299
+ if (!isShadowRootNode(found))
300
+ return;
301
+ const root = found;
302
+ const sheets = root.adoptedStyleSheets;
303
+ if (!sheets || typeof sheets !== "object")
304
+ return;
305
+ const current = Array.from(sheets);
306
+ const adopted = root[ADOPTED_SHEET_MARKER];
307
+ if (adopted && current.includes(adopted))
308
+ return;
309
+ const sheet = sharedHighlightSheet();
310
+ if (!sheet)
311
+ return;
312
+ try {
313
+ root.adoptedStyleSheets = [...current, sheet];
314
+ root[ADOPTED_SHEET_MARKER] = sheet;
315
+ }
316
+ catch {
317
+ // A root that refuses the sheet paints from the document's rules where the
318
+ // browser allows it.
319
+ }
320
+ };
321
+ const TTS_WORD_HIGHLIGHT = "tts-word";
322
+ const TTS_SENTENCE_HIGHLIGHT = "tts-sentence";
323
+ const annotationHighlightName = (color) => `annotation-${color}`;
324
+ /**
325
+ * The page's highlight registered under `name`. Every coordinator on a page
326
+ * paints into the same one, because the page-wide `::highlight()` rules select
327
+ * it by that name, and removes only the ranges it added.
328
+ */
329
+ const sharedHighlight = (name) => {
330
+ const registered = CSS.highlights.get(name);
331
+ if (registered)
332
+ return registered;
333
+ const highlight = new Highlight();
334
+ CSS.highlights.set(name, highlight);
335
+ return highlight;
336
+ };
337
+ const removeRanges = (name, ranges) => {
338
+ const highlight = CSS.highlights.get(name);
339
+ if (!highlight)
340
+ return;
341
+ for (const range of ranges)
342
+ highlight.delete(range);
343
+ };
344
+ /** The element the first range starts in, which the TTS colors adapt to. */
345
+ const startElementOf = (ranges) => {
346
+ const source = ranges[0]?.startContainer;
347
+ if (!source)
348
+ return null;
349
+ return source.nodeType === Node.ELEMENT_NODE
350
+ ? source
351
+ : composedParentElement(source);
352
+ };
353
+ /**
354
+ * What the TTS colors derive from. The colors are custom properties on the root
355
+ * element, which every coordinator on the page writes, so the coordinators share
356
+ * these inputs: any one's refresh writes the same values, and two theme
357
+ * observers cannot keep undoing each other's writes.
358
+ */
359
+ const ttsStyleInputs = { source: null, override: null };
360
+ export class HighlightCoordinator {
361
+ // The ranges this coordinator painted into the shared TTS highlights.
362
+ ttsWordRanges = new Set();
363
+ ttsSentenceRanges = new Set();
364
+ ttsWordElementHighlights = new Set();
365
+ ttsSentenceElementHighlights = new Set();
366
+ annotations = new Map();
367
+ // The element this coordinator last adapted the TTS colors to.
368
+ ttsStyleSource = null;
369
+ nextAnnotationId = 1;
370
+ supported = false;
371
+ rangeSerializer;
372
+ themeObserver = null;
373
+ constructor() {
374
+ this.rangeSerializer = new RangeSerializer();
375
+ // SSR guard
376
+ if (typeof CSS === "undefined" || !("highlights" in CSS)) {
377
+ console.warn("CSS Custom Highlight API not supported (SSR or unsupported browser)");
378
+ return;
379
+ }
380
+ this.supported = true;
381
+ this.initializeHighlights();
382
+ this.registerStyles();
383
+ this.applyAdaptiveTTSStyle();
384
+ this.setupThemeObservation();
385
+ }
386
+ setupThemeObservation() {
387
+ if (typeof document === "undefined")
388
+ return;
389
+ if (typeof MutationObserver === "undefined")
390
+ return;
391
+ const refresh = () => this.applyAdaptiveTTSStyle();
392
+ this.themeObserver = new MutationObserver(refresh);
393
+ this.themeObserver.observe(document.documentElement, {
394
+ attributes: true,
395
+ attributeFilter: ["style", "data-theme", "data-color-scheme", "class"],
396
+ });
397
+ for (const host of document.querySelectorAll("pie-theme")) {
398
+ this.themeObserver.observe(host, {
399
+ attributes: true,
400
+ attributeFilter: [
401
+ "theme",
402
+ "scheme",
403
+ "provider",
404
+ "variables",
405
+ "style",
406
+ "data-theme",
407
+ "data-color-scheme",
408
+ ],
409
+ });
410
+ }
411
+ }
412
+ parseColor(input) {
413
+ if (!input)
414
+ return null;
415
+ const value = input.trim();
416
+ if (!value)
417
+ return null;
418
+ const hexMatch = /^#([a-f\d]{3}|[a-f\d]{6})$/i.exec(value);
419
+ if (hexMatch) {
420
+ const hex = hexMatch[1];
421
+ if (hex.length === 3) {
422
+ return [
423
+ parseInt(hex[0] + hex[0], 16),
424
+ parseInt(hex[1] + hex[1], 16),
425
+ parseInt(hex[2] + hex[2], 16),
426
+ ];
427
+ }
428
+ return [
429
+ parseInt(hex.slice(0, 2), 16),
430
+ parseInt(hex.slice(2, 4), 16),
431
+ parseInt(hex.slice(4, 6), 16),
432
+ ];
433
+ }
434
+ const rgbMatch = /^rgba?\((.+)\)$/i.exec(value);
435
+ if (rgbMatch) {
436
+ const normalized = rgbMatch[1].replace(/\//g, ",");
437
+ const parts = normalized
438
+ .split(/[,\s]+/)
439
+ .map((part) => part.trim())
440
+ .filter(Boolean);
441
+ const r = Number(parts[0]);
442
+ const g = Number(parts[1]);
443
+ const b = Number(parts[2]);
444
+ if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) {
445
+ return [r, g, b];
446
+ }
447
+ }
448
+ // Browser parser fallback (covers formats like oklch()).
449
+ if (typeof document !== "undefined") {
450
+ const parserEl = document.createElement("span");
451
+ parserEl.style.color = value;
452
+ if (typeof parserEl.style.color === "string" && parserEl.style.color) {
453
+ document.body?.appendChild(parserEl);
454
+ const resolved = typeof getComputedStyle === "function"
455
+ ? getComputedStyle(parserEl).color
456
+ : "";
457
+ parserEl.remove();
458
+ const normalizedResolved = resolved.trim();
459
+ // Guard against recursive loops when computed style returns the same
460
+ // unresolved function syntax (observed with certain color formats).
461
+ if (normalizedResolved && normalizedResolved !== value) {
462
+ return this.parseColor(resolved);
463
+ }
464
+ }
465
+ }
466
+ return null;
467
+ }
468
+ relativeLuminance([r, g, b]) {
469
+ const toLinear = (channel) => {
470
+ const n = channel / 255;
471
+ return n <= 0.03928 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4;
472
+ };
473
+ const lr = toLinear(r);
474
+ const lg = toLinear(g);
475
+ const lb = toLinear(b);
476
+ return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
477
+ }
478
+ contrastRatio(a, b) {
479
+ const la = this.relativeLuminance(a);
480
+ const lb = this.relativeLuminance(b);
481
+ const lighter = Math.max(la, lb);
482
+ const darker = Math.min(la, lb);
483
+ return (lighter + 0.05) / (darker + 0.05);
484
+ }
485
+ blend(fg, bg, alpha) {
486
+ return [
487
+ Math.round(fg[0] * alpha + bg[0] * (1 - alpha)),
488
+ Math.round(fg[1] * alpha + bg[1] * (1 - alpha)),
489
+ Math.round(fg[2] * alpha + bg[2] * (1 - alpha)),
490
+ ];
491
+ }
492
+ resolveAdaptiveTTSStyle(sourceEl) {
493
+ const fallbackMissing = [255, 235, 59];
494
+ const fallbackText = [17, 24, 39];
495
+ const fallbackBackground = [255, 255, 255];
496
+ const target = sourceEl ||
497
+ (typeof document !== "undefined" ? document.documentElement : null);
498
+ const computed = target && typeof getComputedStyle === "function"
499
+ ? getComputedStyle(target)
500
+ : null;
501
+ const background = this.parseColor(computed?.getPropertyValue("--pie-background") ||
502
+ computed?.backgroundColor) || fallbackBackground;
503
+ const text = this.parseColor(computed?.getPropertyValue("--pie-text")) || fallbackText;
504
+ const override = ttsStyleInputs.override;
505
+ const accent = override
506
+ ? this.parseColor(override.color) || fallbackMissing
507
+ : this.parseColor(computed?.getPropertyValue("--pie-missing")) ||
508
+ fallbackMissing;
509
+ const opacityCandidates = override
510
+ ? [Math.max(0.3, Math.min(0.95, override.opacity))]
511
+ : [0.8, 0.72, 0.68, 0.62, 0.56, 0.5];
512
+ let selectedOpacity = opacityCandidates[opacityCandidates.length - 1];
513
+ let bestScore = -Infinity;
514
+ for (const opacity of opacityCandidates) {
515
+ const blended = this.blend(accent, background, opacity);
516
+ const backgroundDelta = this.contrastRatio(blended, background);
517
+ const textContrast = this.contrastRatio(blended, text);
518
+ const score = backgroundDelta * 1.2 + textContrast * 0.8;
519
+ if (backgroundDelta >= 1.25 && textContrast >= 2.4) {
520
+ selectedOpacity = opacity;
521
+ break;
522
+ }
523
+ if (score > bestScore) {
524
+ bestScore = score;
525
+ selectedOpacity = opacity;
526
+ }
527
+ }
528
+ const sentenceOpacity = Math.max(0.24, Math.min(0.85, selectedOpacity * 0.55));
529
+ const underlineOpacity = Math.max(0.55, Math.min(0.95, selectedOpacity + 0.2));
530
+ const shadowOpacity = Math.max(0.22, Math.min(0.6, selectedOpacity * 0.45));
531
+ const underlineColor = text;
532
+ const underlineBlend = this.blend(underlineColor, background, underlineOpacity);
533
+ const underlineDelta = this.contrastRatio(underlineBlend, background);
534
+ const fallbackUnderline = this.relativeLuminance(background) > 0.45 ? [0, 0, 0] : [255, 255, 255];
535
+ const finalUnderline = underlineDelta >= 1.35 ? underlineColor : fallbackUnderline;
536
+ const wordHighlight = `rgba(${accent[0]}, ${accent[1]}, ${accent[2]}, ${selectedOpacity})`;
537
+ const sentenceHighlight = `rgba(${accent[0]}, ${accent[1]}, ${accent[2]}, ${sentenceOpacity})`;
538
+ const wordUnderline = `rgba(${finalUnderline[0]}, ${finalUnderline[1]}, ${finalUnderline[2]}, ${underlineOpacity})`;
539
+ const wordShadow = `rgba(${finalUnderline[0]}, ${finalUnderline[1]}, ${finalUnderline[2]}, ${shadowOpacity})`;
540
+ return {
541
+ wordHighlight,
542
+ sentenceHighlight,
543
+ wordUnderline,
544
+ wordShadow,
545
+ };
546
+ }
547
+ /**
548
+ * Adapt the TTS colors to `sourceEl`, the element being highlighted. Without
549
+ * one, to the element highlighted last on the page while it stays connected,
550
+ * else to the document.
551
+ */
552
+ applyAdaptiveTTSStyle(sourceEl) {
553
+ if (typeof document === "undefined")
554
+ return;
555
+ if (sourceEl) {
556
+ this.ttsStyleSource = sourceEl;
557
+ ttsStyleInputs.source = sourceEl;
558
+ }
559
+ const source = sourceEl ??
560
+ (ttsStyleInputs.source?.isConnected ? ttsStyleInputs.source : null);
561
+ const vars = this.resolveAdaptiveTTSStyle(source);
562
+ const style = document.documentElement.style;
563
+ const properties = [
564
+ ["--pie-tts-word-highlight", vars.wordHighlight],
565
+ ["--pie-tts-sentence-highlight", vars.sentenceHighlight],
566
+ ["--pie-tts-line-highlight", vars.sentenceHighlight],
567
+ ["--pie-tts-word-underline", vars.wordUnderline],
568
+ ["--pie-tts-word-shadow", vars.wordShadow],
569
+ ];
570
+ for (const [name, value] of properties) {
571
+ // An unchanged value is not rewritten: the theme observer watches this
572
+ // element's style, and a write would notify it again.
573
+ if (style.getPropertyValue?.(name) === value)
574
+ continue;
575
+ style.setProperty(name, value);
576
+ }
577
+ }
578
+ /** Register the shared highlights, unless another coordinator already did. */
579
+ initializeHighlights() {
580
+ if (!this.supported)
581
+ return;
582
+ sharedHighlight(TTS_WORD_HIGHLIGHT);
583
+ sharedHighlight(TTS_SENTENCE_HIGHLIGHT);
584
+ for (const color of Object.values(HighlightColor)) {
585
+ sharedHighlight(annotationHighlightName(color));
586
+ }
587
+ }
588
+ /**
589
+ * Register CSS styles for highlights
590
+ */
591
+ registerStyles() {
592
+ if (!this.supported)
593
+ return;
594
+ if (typeof document === "undefined")
595
+ return; // SSR guard
596
+ // Check if styles already exist
597
+ if (document.getElementById("pie-highlight-styles"))
598
+ return;
599
+ const style = document.createElement("style");
600
+ style.id = "pie-highlight-styles";
601
+ style.textContent = HIGHLIGHT_STYLES;
484
602
  document.head.appendChild(style);
485
603
  }
486
604
  /**
487
- * Highlight a word for TTS (temporary)
605
+ * Highlight the word being read (temporary), replacing the previous one.
606
+ * Every range paints, so a word split across inline elements highlights
607
+ * whole. A range that covers elements also marks the replaced elements in it
608
+ * (svg, img, canvas), which a CSS range cannot paint.
488
609
  *
489
- * @param textNode Text node containing the word
490
- * @param startOffset Start position in text node
491
- * @param endOffset End position in text node
610
+ * @param ranges The word's ranges: one per tree it spans
492
611
  */
493
- highlightTTSWord(textNode, startOffset, endOffset) {
494
- if (!this.ttsWordHighlight)
612
+ highlightTTSWord(ranges) {
613
+ if (!this.supported)
495
614
  return;
496
- this.applyAdaptiveTTSStyle(textNode.parentElement);
497
- // Clear previous word highlight
615
+ this.applyAdaptiveTTSStyle(startElementOf(ranges));
498
616
  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);
617
+ const highlight = sharedHighlight(TTS_WORD_HIGHLIGHT);
618
+ for (const range of ranges) {
619
+ adoptHighlightStylesFor(range.startContainer);
620
+ highlight.add(range);
621
+ this.ttsWordRanges.add(range);
622
+ }
623
+ this.highlightTTSWordElementFallbacks(ranges.filter((range) => range.startContainer !== range.endContainer ||
624
+ range.startContainer.nodeType !== Node.TEXT_NODE));
505
625
  }
506
626
  /**
507
627
  * Highlight a single element as the active TTS word (temporary).
508
628
  *
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
629
+ * Unlike {@link highlightTTSWord} (which paints CSS ranges over text) this
630
+ * marks the element itself via `data-pie-tts-word-element`. It is used
511
631
  * for atomic targets that have no direct text node to range over — most
512
632
  * notably MathJax CHTML tokens (e.g. `<mjx-mi><mjx-c/></mjx-mi>`, whose glyph
513
633
  * lives in a font-driven pseudo-element a CSS range cannot paint) and
@@ -521,9 +641,10 @@ export class HighlightCoordinator {
521
641
  * should paint the whole expression.
522
642
  */
523
643
  highlightTTSWordElement(element) {
524
- if (!this.ttsWordHighlight)
644
+ if (!this.supported)
525
645
  return;
526
646
  this.applyAdaptiveTTSStyle(element);
647
+ adoptHighlightStylesFor(element);
527
648
  // Clear previous word highlight (CSS range and any element attributes)
528
649
  this.clearTTSWord();
529
650
  element.setAttribute("data-pie-tts-word-element", "true");
@@ -536,18 +657,16 @@ export class HighlightCoordinator {
536
657
  * @param ranges Line (or sentence) ranges to paint with `--pie-tts-line-highlight`
537
658
  */
538
659
  highlightTTSSentence(ranges) {
539
- if (!this.ttsSentenceHighlight)
660
+ if (!this.supported)
540
661
  return;
541
- const source = ranges[0]?.startContainer;
542
- const sourceElement = source?.nodeType === Node.ELEMENT_NODE
543
- ? source
544
- : source?.parentElement;
545
- this.applyAdaptiveTTSStyle(sourceElement);
662
+ this.applyAdaptiveTTSStyle(startElementOf(ranges));
546
663
  // Clear previous sentence highlight
547
664
  this.clearTTSSentence();
548
- // Add all ranges
665
+ const highlight = sharedHighlight(TTS_SENTENCE_HIGHLIGHT);
549
666
  for (const range of ranges) {
550
- this.ttsSentenceHighlight.add(range);
667
+ adoptHighlightStylesFor(range.startContainer);
668
+ highlight.add(range);
669
+ this.ttsSentenceRanges.add(range);
551
670
  }
552
671
  this.highlightTTSElementFallbacks(ranges);
553
672
  }
@@ -557,11 +676,12 @@ export class HighlightCoordinator {
557
676
  * @param elements Visible elements to paint with `--pie-tts-line-highlight`
558
677
  */
559
678
  highlightTTSSentenceElements(elements) {
560
- if (!this.ttsSentenceHighlight)
679
+ if (!this.supported)
561
680
  return;
562
681
  this.applyAdaptiveTTSStyle(elements[0] || null);
563
682
  this.clearTTSSentence();
564
683
  for (const element of elements) {
684
+ adoptHighlightStylesFor(element);
565
685
  element.setAttribute("data-pie-tts-sentence-element", "true");
566
686
  this.ttsSentenceElementHighlights.add(element);
567
687
  }
@@ -587,36 +707,38 @@ export class HighlightCoordinator {
587
707
  this.highlightElementFallbacks(ranges, "data-pie-tts-word-element", this.ttsWordElementHighlights, HighlightCoordinator.WORD_FALLBACK_SELECTOR);
588
708
  }
589
709
  highlightElementFallbacks(ranges, attributeName, trackedElements, selector) {
710
+ const elementAt = (node) => node.nodeType === Node.ELEMENT_NODE
711
+ ? node
712
+ : composedParentElement(node);
590
713
  for (const range of ranges) {
591
- const root = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
592
- ? range.commonAncestorContainer
593
- : range.commonAncestorContainer.parentElement;
714
+ // A range over a shadow root's top-level children has the root as its
715
+ // common ancestor, and the root is what to search.
716
+ const common = range.commonAncestorContainer;
717
+ const root = isShadowRootNode(common)
718
+ ? common
719
+ : elementAt(common);
594
720
  if (!root)
595
721
  continue;
596
722
  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;
723
+ const rangeStartElement = elementAt(range.startContainer);
724
+ const rangeEndElement = elementAt(range.endContainer);
603
725
  for (const element of [rangeStartElement, rangeEndElement]) {
604
726
  let current = element;
605
727
  while (current) {
606
728
  if (current.matches?.(selector))
607
729
  candidates.push(current);
608
- current = current.parentElement;
730
+ current = composedParentElement(current);
609
731
  }
610
732
  }
611
- if (root.matches?.(selector)) {
733
+ if (!isShadowRootNode(root) && root.matches?.(selector)) {
612
734
  candidates.push(root);
613
735
  }
614
736
  candidates.push(...Array.from(root.querySelectorAll(selector)));
615
737
  for (const element of new Set(candidates)) {
616
738
  try {
617
739
  if (!range.intersectsNode(element) &&
618
- !element.contains(rangeStartElement) &&
619
- !element.contains(rangeEndElement)) {
740
+ !composedContains(element, rangeStartElement) &&
741
+ !composedContains(element, rangeEndElement)) {
620
742
  continue;
621
743
  }
622
744
  }
@@ -632,9 +754,10 @@ export class HighlightCoordinator {
632
754
  * Clear TTS word highlight
633
755
  */
634
756
  clearTTSWord() {
635
- if (!this.ttsWordHighlight)
757
+ if (!this.supported)
636
758
  return;
637
- this.ttsWordHighlight.clear();
759
+ removeRanges(TTS_WORD_HIGHLIGHT, this.ttsWordRanges);
760
+ this.ttsWordRanges.clear();
638
761
  for (const element of this.ttsWordElementHighlights) {
639
762
  element.removeAttribute("data-pie-tts-word-element");
640
763
  }
@@ -644,9 +767,10 @@ export class HighlightCoordinator {
644
767
  * Clear TTS sentence highlight
645
768
  */
646
769
  clearTTSSentence() {
647
- if (!this.ttsSentenceHighlight)
770
+ if (!this.supported)
648
771
  return;
649
- this.ttsSentenceHighlight.clear();
772
+ removeRanges(TTS_SENTENCE_HIGHLIGHT, this.ttsSentenceRanges);
773
+ this.ttsSentenceRanges.clear();
650
774
  for (const element of this.ttsSentenceElementHighlights) {
651
775
  element.removeAttribute("data-pie-tts-sentence-element");
652
776
  }
@@ -658,6 +782,10 @@ export class HighlightCoordinator {
658
782
  clearTTS() {
659
783
  this.clearTTSWord();
660
784
  this.clearTTSSentence();
785
+ if (ttsStyleInputs.source === this.ttsStyleSource) {
786
+ ttsStyleInputs.source = null;
787
+ }
788
+ this.ttsStyleSource = null;
661
789
  }
662
790
  /**
663
791
  * Add an annotation highlight (persistent)
@@ -670,6 +798,7 @@ export class HighlightCoordinator {
670
798
  const id = `annotation-${this.nextAnnotationId++}`;
671
799
  // Clone the range to store
672
800
  const clonedRange = range.cloneRange();
801
+ adoptHighlightStylesFor(clonedRange.startContainer);
673
802
  // Store annotation data
674
803
  const annotation = {
675
804
  id,
@@ -679,11 +808,9 @@ export class HighlightCoordinator {
679
808
  timestamp: Date.now(),
680
809
  };
681
810
  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);
811
+ // The same range object, so it can later be deleted by reference.
812
+ if (this.supported) {
813
+ sharedHighlight(annotationHighlightName(color)).add(clonedRange);
687
814
  }
688
815
  return id;
689
816
  }
@@ -698,26 +825,24 @@ export class HighlightCoordinator {
698
825
  console.warn(`[HighlightCoordinator] Annotation ${id} not found`);
699
826
  return;
700
827
  }
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);
828
+ if (this.supported) {
829
+ removeRanges(annotationHighlightName(annotation.color), [
830
+ annotation.range,
831
+ ]);
707
832
  }
708
- // Clean up
709
833
  this.annotations.delete(id);
710
- console.log(`[HighlightCoordinator] Annotation ${id} removed from map. Remaining:`, this.annotations.size);
711
834
  }
712
835
  /**
713
836
  * Remove all annotations
714
837
  */
715
838
  clearAnnotations() {
716
- // Clear all color highlights
717
- for (const colorHighlight of this.colorHighlights.values()) {
718
- colorHighlight.clear();
839
+ if (this.supported) {
840
+ for (const annotation of this.annotations.values()) {
841
+ removeRanges(annotationHighlightName(annotation.color), [
842
+ annotation.range,
843
+ ]);
844
+ }
719
845
  }
720
- // Clear annotation data
721
846
  this.annotations.clear();
722
847
  }
723
848
  /**
@@ -745,17 +870,10 @@ export class HighlightCoordinator {
745
870
  const oldColor = annotation.color;
746
871
  if (oldColor === newColor)
747
872
  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
873
  annotation.color = newColor;
755
- // Add to new color highlight
756
- const newColorHighlight = this.colorHighlights.get(newColor);
757
- if (newColorHighlight) {
758
- newColorHighlight.add(annotation.range);
874
+ if (this.supported) {
875
+ removeRanges(annotationHighlightName(oldColor), [annotation.range]);
876
+ sharedHighlight(annotationHighlightName(newColor)).add(annotation.range);
759
877
  }
760
878
  }
761
879
  /**
@@ -796,34 +914,19 @@ export class HighlightCoordinator {
796
914
  // HighlightCoordinatorApi interface implementation
797
915
  // ============================================================================
798
916
  /**
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().
917
+ * Highlight a range as the given type: the TTS types paint as
918
+ * {@link highlightTTSWord} and {@link highlightTTSSentence} do.
803
919
  */
804
920
  highlightRange(range, type, color = HighlightColor.YELLOW) {
805
921
  if (!this.supported)
806
922
  return;
807
923
  switch (type) {
808
924
  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
- }
925
+ this.highlightTTSWord([range]);
926
+ break;
927
+ case HighlightType.TTS_SENTENCE:
928
+ this.highlightTTSSentence([range]);
825
929
  break;
826
- }
827
930
  case HighlightType.ANNOTATION:
828
931
  // For annotations, use the existing method
829
932
  this.addAnnotation(range, color);
@@ -891,24 +994,27 @@ export class HighlightCoordinator {
891
994
  const rgb = hexToRgb(color);
892
995
  if (!rgb)
893
996
  return;
894
- this.explicitTTSColorOverride = {
997
+ ttsStyleInputs.override = {
895
998
  color: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
896
999
  opacity: Math.max(0.2, Math.min(0.95, opacity)),
1000
+ owner: this,
897
1001
  };
898
1002
  this.applyAdaptiveTTSStyle();
899
1003
  }
900
1004
  /**
901
- * Cleanup this coordinator's highlights and observer.
1005
+ * Remove this coordinator's ranges and stop its observer.
902
1006
  *
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.
1007
+ * The stylesheet and the registered highlights are page-wide and shared by
1008
+ * every live coordinator, so they stay installed for the document's lifetime.
906
1009
  */
907
1010
  destroy() {
908
1011
  if (!this.supported)
909
1012
  return;
910
1013
  this.clearTTS();
911
1014
  this.clearAnnotations();
1015
+ if (ttsStyleInputs.override?.owner === this) {
1016
+ ttsStyleInputs.override = null;
1017
+ }
912
1018
  this.themeObserver?.disconnect();
913
1019
  this.themeObserver = null;
914
1020
  }