@pie-players/pie-assessment-toolkit 0.3.73 → 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 (274) hide show
  1. package/README.md +469 -318
  2. package/dist/attempt/AssessmentSession.d.ts +1 -5
  3. package/dist/attempt/AssessmentSession.js +0 -29
  4. package/dist/attempt/TestSession.d.ts +1 -20
  5. package/dist/attempt/TestSession.js +8 -99
  6. package/dist/components/ItemScope.custom-element.d.ts +1 -0
  7. package/dist/components/ItemScope.custom-element.js +1 -0
  8. package/dist/components/ItemToolBar.custom-element.d.ts +1 -0
  9. package/dist/components/ItemToolBar.custom-element.js +1 -1
  10. package/dist/components/PieAssessmentToolkit.custom-element.d.ts +1 -0
  11. package/dist/components/PieAssessmentToolkit.custom-element.js +13 -13
  12. package/dist/components/SectionToolBar.custom-element.d.ts +1 -0
  13. package/dist/components/SectionToolBar.custom-element.js +1 -1
  14. package/dist/components/chunks/ItemToolBar-7tt7v2xp.js +4 -0
  15. package/dist/components/chunks/ItemToolBar-97hx60z4.js +2 -0
  16. package/dist/components/chunks/ItemToolBar-bxwyhr5p.js +2 -0
  17. package/dist/components/chunks/ItemToolBar-jx38w99s.js +2 -0
  18. package/dist/components/chunks/ItemToolBar-rd2bb4p6.js +40 -0
  19. package/dist/components/chunks/ItemToolBar-vdkyn9f3.js +2 -0
  20. package/dist/components/chunks/sre-engine-jd8cc08x.js +1 -0
  21. package/dist/components/item-scope-element.d.ts +2 -0
  22. package/dist/components/item-scope-element.js +2 -0
  23. package/dist/context/assessment-toolkit-context.d.ts +32 -6
  24. package/dist/index.d.ts +23 -48
  25. package/dist/index.js +12 -37
  26. package/dist/policy/core/PolicySource.d.ts +1 -1
  27. package/dist/policy/core/ToolPolicyEngine.d.ts +85 -34
  28. package/dist/policy/core/ToolPolicyEngine.js +148 -46
  29. package/dist/policy/core/compose-decision.d.ts +28 -7
  30. package/dist/policy/core/compose-decision.js +75 -54
  31. package/dist/policy/core/decision-types.d.ts +65 -34
  32. package/dist/policy/core/feature-decision.d.ts +16 -9
  33. package/dist/policy/core/feature-decision.js +14 -8
  34. package/dist/policy/core/pnp-policy-inputs.d.ts +24 -33
  35. package/dist/policy/core/pnp-policy-inputs.js +28 -56
  36. package/dist/policy/core/provenance.d.ts +9 -4
  37. package/dist/policy/core/provenance.js +1 -1
  38. package/dist/policy/engine.d.ts +7 -23
  39. package/dist/policy/engine.js +5 -20
  40. package/dist/policy/sources/PnpPolicySource.d.ts +44 -61
  41. package/dist/policy/sources/PnpPolicySource.js +117 -118
  42. package/dist/runtime/SectionControllerBinding.d.ts +279 -0
  43. package/dist/runtime/SectionControllerBinding.js +450 -0
  44. package/dist/runtime/SectionRuntimeEngine.d.ts +26 -289
  45. package/dist/runtime/SectionRuntimeEngine.js +30 -372
  46. package/dist/runtime/adapter/SectionEngineAdapter.d.ts +19 -80
  47. package/dist/runtime/adapter/SectionEngineAdapter.js +19 -92
  48. package/dist/runtime/adapter/dom-event-bridge.d.ts +15 -30
  49. package/dist/runtime/adapter/dom-event-bridge.js +17 -44
  50. package/dist/runtime/adapter/subscriber-fanout.d.ts +1 -2
  51. package/dist/runtime/adapter/subscriber-fanout.js +1 -2
  52. package/dist/runtime/catalog-context.d.ts +19 -0
  53. package/dist/runtime/catalog-context.js +28 -0
  54. package/dist/runtime/composition-emit-scheduler.d.ts +9 -15
  55. package/dist/runtime/composition-emit-scheduler.js +9 -15
  56. package/dist/runtime/content-language.d.ts +32 -0
  57. package/dist/runtime/content-language.js +46 -0
  58. package/dist/runtime/content-region.d.ts +17 -0
  59. package/dist/runtime/content-region.js +29 -0
  60. package/dist/runtime/content-registration.d.ts +13 -0
  61. package/dist/runtime/content-registration.js +25 -0
  62. package/dist/runtime/core/SectionEngineCore.d.ts +2 -2
  63. package/dist/runtime/core/SectionEngineCore.js +2 -2
  64. package/dist/runtime/core/cohort.d.ts +1 -1
  65. package/dist/runtime/core/cohort.js +1 -1
  66. package/dist/runtime/core/engine-input.d.ts +18 -30
  67. package/dist/runtime/core/engine-input.js +11 -12
  68. package/dist/runtime/core/engine-output.d.ts +16 -40
  69. package/dist/runtime/core/engine-output.js +9 -21
  70. package/dist/runtime/core/engine-readiness.d.ts +5 -37
  71. package/dist/runtime/core/engine-readiness.js +8 -41
  72. package/dist/runtime/core/engine-resolver.d.ts +46 -74
  73. package/dist/runtime/core/engine-resolver.js +18 -43
  74. package/dist/runtime/core/engine-state.d.ts +28 -36
  75. package/dist/runtime/core/engine-state.js +18 -21
  76. package/dist/runtime/core/engine-transition.d.ts +14 -19
  77. package/dist/runtime/core/engine-transition.js +71 -51
  78. package/dist/runtime/engine.d.ts +16 -19
  79. package/dist/runtime/engine.js +12 -19
  80. package/dist/runtime/page-warnings.d.ts +13 -0
  81. package/dist/runtime/page-warnings.js +22 -0
  82. package/dist/runtime/policy-input-forwarding.d.ts +25 -0
  83. package/dist/runtime/policy-input-forwarding.js +25 -0
  84. package/dist/runtime/registration-events.d.ts +14 -0
  85. package/dist/runtime/runtime-event-claim.d.ts +8 -0
  86. package/dist/runtime/runtime-event-claim.js +15 -0
  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 +188 -0
  93. package/dist/runtime/tool-host-contract.d.ts +28 -10
  94. package/dist/runtime/tool-host-contract.js +31 -10
  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/ElementToolStateStore.d.ts +14 -10
  98. package/dist/services/ElementToolStateStore.js +37 -13
  99. package/dist/services/HighlightCoordinator.d.ts +22 -28
  100. package/dist/services/HighlightCoordinator.js +431 -327
  101. package/dist/services/RangeSerializer.d.ts +10 -1
  102. package/dist/services/RangeSerializer.js +87 -14
  103. package/dist/services/TTSService.d.ts +130 -69
  104. package/dist/services/TTSService.js +713 -570
  105. package/dist/services/ToolCoordinator.d.ts +43 -66
  106. package/dist/services/ToolCoordinator.js +164 -208
  107. package/dist/services/ToolRegistry.d.ts +78 -54
  108. package/dist/services/ToolRegistry.js +107 -105
  109. package/dist/services/ToolkitCoordinator.d.ts +261 -175
  110. package/dist/services/ToolkitCoordinator.js +667 -433
  111. package/dist/services/audio-handoff.d.ts +6 -7
  112. package/dist/services/audio-handoff.js +20 -14
  113. package/dist/services/catalog-owner.d.ts +11 -1
  114. package/dist/services/catalog-owner.js +25 -0
  115. package/dist/services/create-tools-config.d.ts +2 -2
  116. package/dist/services/framework-error-bus.d.ts +3 -6
  117. package/dist/services/framework-error-bus.js +3 -6
  118. package/dist/services/framework-error.d.ts +30 -8
  119. package/dist/services/framework-error.js +9 -7
  120. package/dist/services/interfaces.d.ts +158 -129
  121. package/dist/services/section-controller-types.d.ts +15 -0
  122. package/dist/services/section-session-assignment.d.ts +21 -0
  123. package/dist/services/section-session-assignment.js +82 -0
  124. package/dist/services/spoken-audio-cards.d.ts +0 -2
  125. package/dist/services/spoken-audio-cards.js +1 -3
  126. package/dist/services/tool-config-defaults.d.ts +3 -3
  127. package/dist/services/tool-config-defaults.js +3 -3
  128. package/dist/services/tool-config-validation.d.ts +14 -4
  129. package/dist/services/tool-config-validation.js +63 -71
  130. package/dist/services/tool-context.d.ts +0 -8
  131. package/dist/services/tool-context.js +29 -25
  132. package/dist/services/tool-icons.d.ts +4 -3
  133. package/dist/services/tool-icons.js +4 -3
  134. package/dist/services/tool-instance-id.d.ts +0 -1
  135. package/dist/services/tool-instance-id.js +0 -6
  136. package/dist/services/tool-providers/TTSToolProvider.d.ts +41 -80
  137. package/dist/services/tool-providers/TTSToolProvider.js +81 -40
  138. package/dist/services/tool-providers/ToolProviderApi.d.ts +10 -4
  139. package/dist/services/tool-providers/ToolProviderRegistry.d.ts +32 -28
  140. package/dist/services/tool-providers/ToolProviderRegistry.js +121 -107
  141. package/dist/services/tool-providers/index.d.ts +0 -9
  142. package/dist/services/tool-providers/index.js +2 -4
  143. package/dist/services/tool-request.d.ts +27 -12
  144. package/dist/services/tool-request.js +24 -14
  145. package/dist/services/toolbar-decided-tools.d.ts +15 -0
  146. package/dist/services/toolbar-decided-tools.js +28 -0
  147. package/dist/services/tools-config-normalizer.d.ts +37 -23
  148. package/dist/services/tools-config-normalizer.js +2 -10
  149. package/dist/services/tts/browser-provider.d.ts +38 -2
  150. package/dist/services/tts/browser-provider.js +276 -140
  151. package/dist/services/tts/catalog-span-alignment.d.ts +2 -17
  152. package/dist/services/tts/catalog-span-alignment.js +17 -142
  153. package/dist/services/tts/flat-tree.d.ts +71 -0
  154. package/dist/services/tts/flat-tree.js +269 -0
  155. package/dist/services/tts/generated-speech/assemble-plan.d.ts +3 -1
  156. package/dist/services/tts/generated-speech/assemble-plan.js +3 -1
  157. package/dist/services/tts/generated-speech/dom/build-dom-plan.d.ts +2 -0
  158. package/dist/services/tts/generated-speech/dom/build-dom-plan.js +1 -1
  159. package/dist/services/tts/generated-speech/math-speech-cache.d.ts +3 -1
  160. package/dist/services/tts/generated-speech/math-speech-cache.js +5 -1
  161. package/dist/services/tts/generated-speech/types.d.ts +0 -12
  162. package/dist/services/tts/highlight-pipeline/boundary-normalizer.js +12 -62
  163. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +38 -5
  164. package/dist/services/tts/highlight-pipeline/normalize-speech-chunks.js +3 -6
  165. package/dist/services/tts/highlight-pipeline/prose-boundary-resolver.js +6 -4
  166. package/dist/services/tts/highlight-pipeline/readable-region.js +3 -2
  167. package/dist/services/tts/highlight-pipeline/types.d.ts +5 -0
  168. package/dist/services/tts/highlight-pipeline/visible-map-range.d.ts +12 -0
  169. package/dist/services/tts/highlight-pipeline/visible-map-range.js +73 -7
  170. package/dist/services/tts/math-alignment/index.d.ts +0 -5
  171. package/dist/services/tts/math-alignment/index.js +5 -25
  172. package/dist/services/tts/math-alignment/speech-tokenizer.d.ts +9 -1
  173. package/dist/services/tts/math-alignment/speech-tokenizer.js +38 -36
  174. package/dist/services/tts/math-alignment/types.d.ts +4 -2
  175. package/dist/services/tts/math-aware-text-processing.d.ts +5 -1
  176. package/dist/services/tts/math-aware-text-processing.js +74 -18
  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 +16 -1
  180. package/dist/services/tts/math-speech.js +55 -19
  181. package/dist/services/tts/provider-options.d.ts +41 -0
  182. package/dist/services/tts/provider-options.js +1 -0
  183. package/dist/services/tts/sre-engine.d.ts +7 -0
  184. package/dist/services/tts/sre-engine.js +11 -0
  185. package/dist/services/tts/sre-locales.d.ts +14 -0
  186. package/dist/services/tts/sre-locales.js +57 -0
  187. package/dist/services/tts/ssml/spoken-text.d.ts +2 -26
  188. package/dist/services/tts/ssml/spoken-text.js +29 -0
  189. package/dist/services/tts/start-failure.d.ts +11 -0
  190. package/dist/services/tts/start-failure.js +19 -0
  191. package/dist/services/tts/text-processing.d.ts +9 -1
  192. package/dist/services/tts/text-processing.js +46 -34
  193. package/dist/services/tts/text-segmentation.d.ts +7 -0
  194. package/dist/services/tts/text-segmentation.js +47 -0
  195. package/dist/services/tts-runtime-config.d.ts +44 -18
  196. package/dist/services/tts-runtime-config.js +22 -93
  197. package/dist/tools/content-capability-resolution.d.ts +7 -9
  198. package/dist/tools/content-capability-resolution.js +8 -45
  199. package/dist/tools/registration.d.ts +58 -0
  200. package/dist/tools/registration.js +54 -0
  201. package/dist/tools/registrations/toolbar-registration-helpers.js +2 -2
  202. package/dist/tools/tool-surface-host.d.ts +6 -10
  203. package/dist/tools/tool-surface-host.js +44 -42
  204. package/dist/tools/tool-tag-map.d.ts +5 -15
  205. package/dist/tools/tool-tag-map.js +6 -20
  206. package/dist/utils/structural-equality.d.ts +11 -0
  207. package/dist/utils/structural-equality.js +47 -0
  208. package/package.json +24 -59
  209. package/dist/attempt/adapters/activity-to-test-attempt-session.d.ts +0 -49
  210. package/dist/attempt/adapters/activity-to-test-attempt-session.js +0 -138
  211. package/dist/components/chunks/ItemToolBar-byq80z42.js +0 -40
  212. package/dist/components/chunks/ItemToolBar-d36g3aw4.js +0 -49
  213. package/dist/context/runtime-context-consumer.d.ts +0 -13
  214. package/dist/context/runtime-context-consumer.js +0 -51
  215. package/dist/item-loader.d.ts +0 -44
  216. package/dist/item-loader.js +0 -61
  217. package/dist/policy/core/engine-context.d.ts +0 -29
  218. package/dist/policy/core/engine-context.js +0 -27
  219. package/dist/policy/core/policy-source-tag.d.ts +0 -31
  220. package/dist/policy/core/policy-source-tag.js +0 -28
  221. package/dist/policy/internal.d.ts +0 -25
  222. package/dist/policy/internal.js +0 -25
  223. package/dist/runtime/adapter/coordinator-bridge.d.ts +0 -92
  224. package/dist/runtime/adapter/coordinator-bridge.js +0 -126
  225. package/dist/runtime/adapter/framework-error-bridge.d.ts +0 -36
  226. package/dist/runtime/adapter/framework-error-bridge.js +0 -46
  227. package/dist/runtime/adapter/instrumentation-bridge.d.ts +0 -31
  228. package/dist/runtime/adapter/instrumentation-bridge.js +0 -38
  229. package/dist/runtime/core/engine-stage-derivation.d.ts +0 -18
  230. package/dist/runtime/core/engine-stage-derivation.js +0 -29
  231. package/dist/runtime/engine-context.d.ts +0 -39
  232. package/dist/runtime/engine-context.js +0 -31
  233. package/dist/runtime/internal.d.ts +0 -43
  234. package/dist/runtime/internal.js +0 -45
  235. package/dist/runtime/section-runtime-engine-host-context.d.ts +0 -80
  236. package/dist/runtime/section-runtime-engine-host-context.js +0 -95
  237. package/dist/runtime/stage-emit-gate.d.ts +0 -92
  238. package/dist/runtime/stage-emit-gate.js +0 -82
  239. package/dist/services/I18nService.d.ts +0 -47
  240. package/dist/services/I18nService.js +0 -84
  241. package/dist/services/ThemeProvider.d.ts +0 -95
  242. package/dist/services/ThemeProvider.js +0 -334
  243. package/dist/services/ToolConfigResolver.d.ts +0 -161
  244. package/dist/services/ToolConfigResolver.js +0 -229
  245. package/dist/services/catalog-media.d.ts +0 -56
  246. package/dist/services/catalog-media.js +0 -190
  247. package/dist/services/createDefaultToolRegistry.d.ts +0 -53
  248. package/dist/services/createDefaultToolRegistry.js +0 -45
  249. package/dist/services/defaultPersonalNeedsProfile.d.ts +0 -21
  250. package/dist/services/defaultPersonalNeedsProfile.js +0 -26
  251. package/dist/services/pnp-standard-features.d.ts +0 -238
  252. package/dist/services/pnp-standard-features.js +0 -338
  253. package/dist/services/tool-providers/CortexToolProvider.d.ts +0 -13
  254. package/dist/services/tool-providers/CortexToolProvider.js +0 -32
  255. package/dist/services/tool-providers/DesmosToolProvider.d.ts +0 -14
  256. package/dist/services/tool-providers/DesmosToolProvider.js +0 -32
  257. package/dist/services/tool-providers/GeoGebraToolProvider.d.ts +0 -16
  258. package/dist/services/tool-providers/GeoGebraToolProvider.js +0 -32
  259. package/dist/services/tool-providers/LazyCalculatorToolProvider.d.ts +0 -35
  260. package/dist/services/tool-providers/LazyCalculatorToolProvider.js +0 -95
  261. package/dist/services/tts/visual-line-ranges.d.ts +0 -18
  262. package/dist/services/tts/visual-line-ranges.js +0 -226
  263. package/dist/tools/client.d.ts +0 -12
  264. package/dist/tools/client.js +0 -14
  265. package/dist/tools/index.d.ts +0 -8
  266. package/dist/tools/index.js +0 -10
  267. package/dist/tools/internal.d.ts +0 -43
  268. package/dist/tools/internal.js +0 -41
  269. package/dist/tools/response-discovery.d.ts +0 -61
  270. package/dist/tools/response-discovery.js +0 -113
  271. package/dist/tools/types.d.ts +0 -337
  272. package/dist/tools/types.js +0 -12
  273. package/dist/tools/variant-resolver.d.ts +0 -41
  274. package/dist/tools/variant-resolver.js +0 -207
@@ -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,237 +43,13 @@ export var HighlightColor;
42
43
  HighlightColor["ORANGE"] = "orange";
43
44
  HighlightColor["UNDERLINE"] = "underline";
44
45
  })(HighlightColor || (HighlightColor = {}));
45
- export class HighlightCoordinator {
46
- config;
47
- ttsWordHighlight = null;
48
- ttsSentenceHighlight = null;
49
- ttsWordElementHighlights = new Set();
50
- ttsSentenceElementHighlights = new Set();
51
- annotations = new Map();
52
- // Shared highlights per color (one Highlight object per color, contains all ranges)
53
- colorHighlights = new Map();
54
- nextAnnotationId = 1;
55
- supported = false;
56
- rangeSerializer;
57
- themeObserver = null;
58
- explicitTTSColorOverride = null;
59
- constructor(config = {}) {
60
- this.config = config;
61
- this.rangeSerializer = new RangeSerializer();
62
- // SSR guard
63
- if (typeof CSS === "undefined" || !("highlights" in CSS)) {
64
- console.warn("CSS Custom Highlight API not supported (SSR or unsupported browser)");
65
- return;
66
- }
67
- this.supported = true;
68
- this.initializeHighlights();
69
- this.registerStyles();
70
- this.applyAdaptiveTTSStyle();
71
- this.setupThemeObservation();
72
- }
73
- setupThemeObservation() {
74
- if (typeof document === "undefined")
75
- return;
76
- if (typeof MutationObserver === "undefined")
77
- return;
78
- const refresh = () => this.applyAdaptiveTTSStyle();
79
- this.themeObserver = new MutationObserver(refresh);
80
- this.themeObserver.observe(document.documentElement, {
81
- attributes: true,
82
- attributeFilter: ["style", "data-theme", "data-color-scheme", "class"],
83
- });
84
- for (const host of document.querySelectorAll("pie-theme")) {
85
- this.themeObserver.observe(host, {
86
- attributes: true,
87
- attributeFilter: [
88
- "theme",
89
- "scheme",
90
- "provider",
91
- "variables",
92
- "style",
93
- "data-theme",
94
- "data-color-scheme",
95
- ],
96
- });
97
- }
98
- }
99
- parseColor(input) {
100
- if (!input)
101
- return null;
102
- const value = input.trim();
103
- if (!value)
104
- return null;
105
- const hexMatch = /^#([a-f\d]{3}|[a-f\d]{6})$/i.exec(value);
106
- if (hexMatch) {
107
- const hex = hexMatch[1];
108
- if (hex.length === 3) {
109
- return [
110
- parseInt(hex[0] + hex[0], 16),
111
- parseInt(hex[1] + hex[1], 16),
112
- parseInt(hex[2] + hex[2], 16),
113
- ];
114
- }
115
- return [
116
- parseInt(hex.slice(0, 2), 16),
117
- parseInt(hex.slice(2, 4), 16),
118
- parseInt(hex.slice(4, 6), 16),
119
- ];
120
- }
121
- const rgbMatch = /^rgba?\((.+)\)$/i.exec(value);
122
- if (rgbMatch) {
123
- const normalized = rgbMatch[1].replace(/\//g, ",");
124
- const parts = normalized
125
- .split(/[,\s]+/)
126
- .map((part) => part.trim())
127
- .filter(Boolean);
128
- const r = Number(parts[0]);
129
- const g = Number(parts[1]);
130
- const b = Number(parts[2]);
131
- if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) {
132
- return [r, g, b];
133
- }
134
- }
135
- // Browser parser fallback (covers formats like oklch()).
136
- if (typeof document !== "undefined") {
137
- const parserEl = document.createElement("span");
138
- parserEl.style.color = value;
139
- if (typeof parserEl.style.color === "string" && parserEl.style.color) {
140
- document.body?.appendChild(parserEl);
141
- const resolved = typeof getComputedStyle === "function"
142
- ? getComputedStyle(parserEl).color
143
- : "";
144
- parserEl.remove();
145
- const normalizedResolved = resolved.trim();
146
- // Guard against recursive loops when computed style returns the same
147
- // unresolved function syntax (observed with certain color formats).
148
- if (normalizedResolved && normalizedResolved !== value) {
149
- return this.parseColor(resolved);
150
- }
151
- }
152
- }
153
- return null;
154
- }
155
- relativeLuminance([r, g, b]) {
156
- const toLinear = (channel) => {
157
- const n = channel / 255;
158
- return n <= 0.03928 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4;
159
- };
160
- const lr = toLinear(r);
161
- const lg = toLinear(g);
162
- const lb = toLinear(b);
163
- return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
164
- }
165
- contrastRatio(a, b) {
166
- const la = this.relativeLuminance(a);
167
- const lb = this.relativeLuminance(b);
168
- const lighter = Math.max(la, lb);
169
- const darker = Math.min(la, lb);
170
- return (lighter + 0.05) / (darker + 0.05);
171
- }
172
- blend(fg, bg, alpha) {
173
- return [
174
- Math.round(fg[0] * alpha + bg[0] * (1 - alpha)),
175
- Math.round(fg[1] * alpha + bg[1] * (1 - alpha)),
176
- Math.round(fg[2] * alpha + bg[2] * (1 - alpha)),
177
- ];
178
- }
179
- resolveAdaptiveTTSStyle(sourceEl) {
180
- const fallbackMissing = [255, 235, 59];
181
- const fallbackText = [17, 24, 39];
182
- const fallbackBackground = [255, 255, 255];
183
- const target = sourceEl ||
184
- (typeof document !== "undefined" ? document.documentElement : null);
185
- const computed = target && typeof getComputedStyle === "function"
186
- ? getComputedStyle(target)
187
- : null;
188
- const background = this.parseColor(computed?.getPropertyValue("--pie-background") ||
189
- computed?.backgroundColor) || fallbackBackground;
190
- const text = this.parseColor(computed?.getPropertyValue("--pie-text")) || fallbackText;
191
- const accent = this.explicitTTSColorOverride
192
- ? this.parseColor(this.explicitTTSColorOverride.color) || fallbackMissing
193
- : this.parseColor(computed?.getPropertyValue("--pie-missing")) ||
194
- fallbackMissing;
195
- const opacityCandidates = this.explicitTTSColorOverride
196
- ? [Math.max(0.3, Math.min(0.95, this.explicitTTSColorOverride.opacity))]
197
- : [0.8, 0.72, 0.68, 0.62, 0.56, 0.5];
198
- let selectedOpacity = opacityCandidates[opacityCandidates.length - 1];
199
- let bestScore = -Infinity;
200
- for (const opacity of opacityCandidates) {
201
- const blended = this.blend(accent, background, opacity);
202
- const backgroundDelta = this.contrastRatio(blended, background);
203
- const textContrast = this.contrastRatio(blended, text);
204
- const score = backgroundDelta * 1.2 + textContrast * 0.8;
205
- if (backgroundDelta >= 1.25 && textContrast >= 2.4) {
206
- selectedOpacity = opacity;
207
- break;
208
- }
209
- if (score > bestScore) {
210
- bestScore = score;
211
- selectedOpacity = opacity;
212
- }
213
- }
214
- const sentenceOpacity = Math.max(0.24, Math.min(0.85, selectedOpacity * 0.55));
215
- const underlineOpacity = Math.max(0.55, Math.min(0.95, selectedOpacity + 0.2));
216
- const shadowOpacity = Math.max(0.22, Math.min(0.6, selectedOpacity * 0.45));
217
- const underlineColor = text;
218
- const underlineBlend = this.blend(underlineColor, background, underlineOpacity);
219
- const underlineDelta = this.contrastRatio(underlineBlend, background);
220
- const fallbackUnderline = this.relativeLuminance(background) > 0.45 ? [0, 0, 0] : [255, 255, 255];
221
- const finalUnderline = underlineDelta >= 1.35 ? underlineColor : fallbackUnderline;
222
- const wordHighlight = `rgba(${accent[0]}, ${accent[1]}, ${accent[2]}, ${selectedOpacity})`;
223
- const sentenceHighlight = `rgba(${accent[0]}, ${accent[1]}, ${accent[2]}, ${sentenceOpacity})`;
224
- const wordUnderline = `rgba(${finalUnderline[0]}, ${finalUnderline[1]}, ${finalUnderline[2]}, ${underlineOpacity})`;
225
- const wordShadow = `rgba(${finalUnderline[0]}, ${finalUnderline[1]}, ${finalUnderline[2]}, ${shadowOpacity})`;
226
- return {
227
- wordHighlight,
228
- sentenceHighlight,
229
- wordUnderline,
230
- wordShadow,
231
- };
232
- }
233
- applyAdaptiveTTSStyle(sourceEl) {
234
- if (typeof document === "undefined")
235
- return;
236
- const vars = this.resolveAdaptiveTTSStyle(sourceEl);
237
- document.documentElement.style.setProperty("--pie-tts-word-highlight", vars.wordHighlight);
238
- document.documentElement.style.setProperty("--pie-tts-sentence-highlight", vars.sentenceHighlight);
239
- document.documentElement.style.setProperty("--pie-tts-line-highlight", vars.sentenceHighlight);
240
- document.documentElement.style.setProperty("--pie-tts-word-underline", vars.wordUnderline);
241
- document.documentElement.style.setProperty("--pie-tts-word-shadow", vars.wordShadow);
242
- }
243
- /**
244
- * Initialize CSS Custom Highlights
245
- */
246
- initializeHighlights() {
247
- if (!this.supported)
248
- return;
249
- // Create highlight registries for TTS
250
- this.ttsWordHighlight = new Highlight();
251
- this.ttsSentenceHighlight = new Highlight();
252
- // Register TTS highlights with CSS
253
- CSS.highlights.set("tts-word", this.ttsWordHighlight);
254
- CSS.highlights.set("tts-sentence", this.ttsSentenceHighlight);
255
- // Create shared highlights for each annotation color
256
- for (const color of Object.values(HighlightColor)) {
257
- const highlight = new Highlight();
258
- this.colorHighlights.set(color, highlight);
259
- CSS.highlights.set(`annotation-${color}`, highlight);
260
- }
261
- }
262
- /**
263
- * Register CSS styles for highlights
264
- */
265
- registerStyles() {
266
- if (!this.supported)
267
- return;
268
- if (typeof document === "undefined")
269
- return; // SSR guard
270
- // Check if styles already exist
271
- if (document.getElementById("pie-highlight-styles"))
272
- return;
273
- const style = document.createElement("style");
274
- style.id = "pie-highlight-styles";
275
- 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 = `
276
53
  /* TTS highlights - temporary */
277
54
  ::highlight(tts-word) {
278
55
  background-color: var(--pie-tts-word-highlight, color-mix(in srgb, var(--pie-missing, #ffeb3b) 68%, transparent));
@@ -483,33 +260,374 @@ export class HighlightCoordinator {
483
260
  }
484
261
  }
485
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;
486
602
  document.head.appendChild(style);
487
603
  }
488
604
  /**
489
- * 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.
490
609
  *
491
- * @param textNode Text node containing the word
492
- * @param startOffset Start position in text node
493
- * @param endOffset End position in text node
610
+ * @param ranges The word's ranges: one per tree it spans
494
611
  */
495
- highlightTTSWord(textNode, startOffset, endOffset) {
496
- if (!this.ttsWordHighlight)
612
+ highlightTTSWord(ranges) {
613
+ if (!this.supported)
497
614
  return;
498
- this.applyAdaptiveTTSStyle(textNode.parentElement);
499
- // Clear previous word highlight
615
+ this.applyAdaptiveTTSStyle(startElementOf(ranges));
500
616
  this.clearTTSWord();
501
- // Create range for word
502
- const range = document.createRange();
503
- range.setStart(textNode, startOffset);
504
- range.setEnd(textNode, endOffset);
505
- // Add to highlight
506
- 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));
507
625
  }
508
626
  /**
509
627
  * Highlight a single element as the active TTS word (temporary).
510
628
  *
511
- * Unlike {@link highlightTTSWord} (which paints a CSS range over a text node)
512
- * 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
513
631
  * for atomic targets that have no direct text node to range over — most
514
632
  * notably MathJax CHTML tokens (e.g. `<mjx-mi><mjx-c/></mjx-mi>`, whose glyph
515
633
  * lives in a font-driven pseudo-element a CSS range cannot paint) and
@@ -523,9 +641,10 @@ export class HighlightCoordinator {
523
641
  * should paint the whole expression.
524
642
  */
525
643
  highlightTTSWordElement(element) {
526
- if (!this.ttsWordHighlight)
644
+ if (!this.supported)
527
645
  return;
528
646
  this.applyAdaptiveTTSStyle(element);
647
+ adoptHighlightStylesFor(element);
529
648
  // Clear previous word highlight (CSS range and any element attributes)
530
649
  this.clearTTSWord();
531
650
  element.setAttribute("data-pie-tts-word-element", "true");
@@ -538,18 +657,16 @@ export class HighlightCoordinator {
538
657
  * @param ranges Line (or sentence) ranges to paint with `--pie-tts-line-highlight`
539
658
  */
540
659
  highlightTTSSentence(ranges) {
541
- if (!this.ttsSentenceHighlight)
660
+ if (!this.supported)
542
661
  return;
543
- const source = ranges[0]?.startContainer;
544
- const sourceElement = source?.nodeType === Node.ELEMENT_NODE
545
- ? source
546
- : source?.parentElement;
547
- this.applyAdaptiveTTSStyle(sourceElement);
662
+ this.applyAdaptiveTTSStyle(startElementOf(ranges));
548
663
  // Clear previous sentence highlight
549
664
  this.clearTTSSentence();
550
- // Add all ranges
665
+ const highlight = sharedHighlight(TTS_SENTENCE_HIGHLIGHT);
551
666
  for (const range of ranges) {
552
- this.ttsSentenceHighlight.add(range);
667
+ adoptHighlightStylesFor(range.startContainer);
668
+ highlight.add(range);
669
+ this.ttsSentenceRanges.add(range);
553
670
  }
554
671
  this.highlightTTSElementFallbacks(ranges);
555
672
  }
@@ -559,11 +676,12 @@ export class HighlightCoordinator {
559
676
  * @param elements Visible elements to paint with `--pie-tts-line-highlight`
560
677
  */
561
678
  highlightTTSSentenceElements(elements) {
562
- if (!this.ttsSentenceHighlight)
679
+ if (!this.supported)
563
680
  return;
564
681
  this.applyAdaptiveTTSStyle(elements[0] || null);
565
682
  this.clearTTSSentence();
566
683
  for (const element of elements) {
684
+ adoptHighlightStylesFor(element);
567
685
  element.setAttribute("data-pie-tts-sentence-element", "true");
568
686
  this.ttsSentenceElementHighlights.add(element);
569
687
  }
@@ -589,36 +707,38 @@ export class HighlightCoordinator {
589
707
  this.highlightElementFallbacks(ranges, "data-pie-tts-word-element", this.ttsWordElementHighlights, HighlightCoordinator.WORD_FALLBACK_SELECTOR);
590
708
  }
591
709
  highlightElementFallbacks(ranges, attributeName, trackedElements, selector) {
710
+ const elementAt = (node) => node.nodeType === Node.ELEMENT_NODE
711
+ ? node
712
+ : composedParentElement(node);
592
713
  for (const range of ranges) {
593
- const root = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
594
- ? range.commonAncestorContainer
595
- : 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);
596
720
  if (!root)
597
721
  continue;
598
722
  const candidates = [];
599
- const rangeStartElement = range.startContainer.nodeType === Node.ELEMENT_NODE
600
- ? range.startContainer
601
- : range.startContainer.parentElement;
602
- const rangeEndElement = range.endContainer.nodeType === Node.ELEMENT_NODE
603
- ? range.endContainer
604
- : range.endContainer.parentElement;
723
+ const rangeStartElement = elementAt(range.startContainer);
724
+ const rangeEndElement = elementAt(range.endContainer);
605
725
  for (const element of [rangeStartElement, rangeEndElement]) {
606
726
  let current = element;
607
727
  while (current) {
608
728
  if (current.matches?.(selector))
609
729
  candidates.push(current);
610
- current = current.parentElement;
730
+ current = composedParentElement(current);
611
731
  }
612
732
  }
613
- if (root.matches?.(selector)) {
733
+ if (!isShadowRootNode(root) && root.matches?.(selector)) {
614
734
  candidates.push(root);
615
735
  }
616
736
  candidates.push(...Array.from(root.querySelectorAll(selector)));
617
737
  for (const element of new Set(candidates)) {
618
738
  try {
619
739
  if (!range.intersectsNode(element) &&
620
- !element.contains(rangeStartElement) &&
621
- !element.contains(rangeEndElement)) {
740
+ !composedContains(element, rangeStartElement) &&
741
+ !composedContains(element, rangeEndElement)) {
622
742
  continue;
623
743
  }
624
744
  }
@@ -634,9 +754,10 @@ export class HighlightCoordinator {
634
754
  * Clear TTS word highlight
635
755
  */
636
756
  clearTTSWord() {
637
- if (!this.ttsWordHighlight)
757
+ if (!this.supported)
638
758
  return;
639
- this.ttsWordHighlight.clear();
759
+ removeRanges(TTS_WORD_HIGHLIGHT, this.ttsWordRanges);
760
+ this.ttsWordRanges.clear();
640
761
  for (const element of this.ttsWordElementHighlights) {
641
762
  element.removeAttribute("data-pie-tts-word-element");
642
763
  }
@@ -646,9 +767,10 @@ export class HighlightCoordinator {
646
767
  * Clear TTS sentence highlight
647
768
  */
648
769
  clearTTSSentence() {
649
- if (!this.ttsSentenceHighlight)
770
+ if (!this.supported)
650
771
  return;
651
- this.ttsSentenceHighlight.clear();
772
+ removeRanges(TTS_SENTENCE_HIGHLIGHT, this.ttsSentenceRanges);
773
+ this.ttsSentenceRanges.clear();
652
774
  for (const element of this.ttsSentenceElementHighlights) {
653
775
  element.removeAttribute("data-pie-tts-sentence-element");
654
776
  }
@@ -660,6 +782,10 @@ export class HighlightCoordinator {
660
782
  clearTTS() {
661
783
  this.clearTTSWord();
662
784
  this.clearTTSSentence();
785
+ if (ttsStyleInputs.source === this.ttsStyleSource) {
786
+ ttsStyleInputs.source = null;
787
+ }
788
+ this.ttsStyleSource = null;
663
789
  }
664
790
  /**
665
791
  * Add an annotation highlight (persistent)
@@ -672,6 +798,7 @@ export class HighlightCoordinator {
672
798
  const id = `annotation-${this.nextAnnotationId++}`;
673
799
  // Clone the range to store
674
800
  const clonedRange = range.cloneRange();
801
+ adoptHighlightStylesFor(clonedRange.startContainer);
675
802
  // Store annotation data
676
803
  const annotation = {
677
804
  id,
@@ -681,11 +808,9 @@ export class HighlightCoordinator {
681
808
  timestamp: Date.now(),
682
809
  };
683
810
  this.annotations.set(id, annotation);
684
- // Add the SAME range object to the shared color highlight
685
- // This ensures we can later delete it by reference
686
- const colorHighlight = this.colorHighlights.get(color);
687
- if (colorHighlight) {
688
- 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);
689
814
  }
690
815
  return id;
691
816
  }
@@ -700,26 +825,24 @@ export class HighlightCoordinator {
700
825
  console.warn(`[HighlightCoordinator] Annotation ${id} not found`);
701
826
  return;
702
827
  }
703
- console.log(`[HighlightCoordinator] Removing annotation ${id} (color: ${annotation.color})`);
704
- // Remove range from the shared color highlight
705
- const colorHighlight = this.colorHighlights.get(annotation.color);
706
- if (colorHighlight) {
707
- const deleted = colorHighlight.delete(annotation.range);
708
- console.log(`[HighlightCoordinator] Highlight.delete() returned:`, deleted);
828
+ if (this.supported) {
829
+ removeRanges(annotationHighlightName(annotation.color), [
830
+ annotation.range,
831
+ ]);
709
832
  }
710
- // Clean up
711
833
  this.annotations.delete(id);
712
- console.log(`[HighlightCoordinator] Annotation ${id} removed from map. Remaining:`, this.annotations.size);
713
834
  }
714
835
  /**
715
836
  * Remove all annotations
716
837
  */
717
838
  clearAnnotations() {
718
- // Clear all color highlights
719
- for (const colorHighlight of this.colorHighlights.values()) {
720
- colorHighlight.clear();
839
+ if (this.supported) {
840
+ for (const annotation of this.annotations.values()) {
841
+ removeRanges(annotationHighlightName(annotation.color), [
842
+ annotation.range,
843
+ ]);
844
+ }
721
845
  }
722
- // Clear annotation data
723
846
  this.annotations.clear();
724
847
  }
725
848
  /**
@@ -747,17 +870,10 @@ export class HighlightCoordinator {
747
870
  const oldColor = annotation.color;
748
871
  if (oldColor === newColor)
749
872
  return;
750
- // Remove from old color highlight
751
- const oldColorHighlight = this.colorHighlights.get(oldColor);
752
- if (oldColorHighlight) {
753
- oldColorHighlight.delete(annotation.range);
754
- }
755
- // Update color
756
873
  annotation.color = newColor;
757
- // Add to new color highlight
758
- const newColorHighlight = this.colorHighlights.get(newColor);
759
- if (newColorHighlight) {
760
- newColorHighlight.add(annotation.range);
874
+ if (this.supported) {
875
+ removeRanges(annotationHighlightName(oldColor), [annotation.range]);
876
+ sharedHighlight(annotationHighlightName(newColor)).add(annotation.range);
761
877
  }
762
878
  }
763
879
  /**
@@ -798,34 +914,19 @@ export class HighlightCoordinator {
798
914
  // HighlightCoordinatorApi interface implementation
799
915
  // ============================================================================
800
916
  /**
801
- * Highlight a text range (interface method)
802
- *
803
- * Generic method that adapts to the specific highlight type.
804
- * 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.
805
919
  */
806
920
  highlightRange(range, type, color = HighlightColor.YELLOW) {
807
921
  if (!this.supported)
808
922
  return;
809
923
  switch (type) {
810
924
  case HighlightType.TTS_WORD:
811
- case HighlightType.TTS_SENTENCE: {
812
- // For TTS, add to appropriate highlight
813
- const highlight = type === HighlightType.TTS_WORD
814
- ? this.ttsWordHighlight
815
- : this.ttsSentenceHighlight;
816
- if (highlight) {
817
- highlight.clear();
818
- highlight.add(range);
819
- if (type === HighlightType.TTS_WORD) {
820
- for (const element of this.ttsWordElementHighlights) {
821
- element.removeAttribute("data-pie-tts-word-element");
822
- }
823
- this.ttsWordElementHighlights.clear();
824
- this.highlightTTSWordElementFallbacks([range]);
825
- }
826
- }
925
+ this.highlightTTSWord([range]);
926
+ break;
927
+ case HighlightType.TTS_SENTENCE:
928
+ this.highlightTTSSentence([range]);
827
929
  break;
828
- }
829
930
  case HighlightType.ANNOTATION:
830
931
  // For annotations, use the existing method
831
932
  this.addAnnotation(range, color);
@@ -893,24 +994,27 @@ export class HighlightCoordinator {
893
994
  const rgb = hexToRgb(color);
894
995
  if (!rgb)
895
996
  return;
896
- this.explicitTTSColorOverride = {
997
+ ttsStyleInputs.override = {
897
998
  color: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
898
999
  opacity: Math.max(0.2, Math.min(0.95, opacity)),
1000
+ owner: this,
899
1001
  };
900
1002
  this.applyAdaptiveTTSStyle();
901
1003
  }
902
1004
  /**
903
- * Cleanup this coordinator's highlights and observer.
1005
+ * Remove this coordinator's ranges and stop its observer.
904
1006
  *
905
- * The stylesheet is process-global and shared by every live coordinator, so
906
- * removing it here would break a replacement or sibling coordinator. Keep the
907
- * 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.
908
1009
  */
909
1010
  destroy() {
910
1011
  if (!this.supported)
911
1012
  return;
912
1013
  this.clearTTS();
913
1014
  this.clearAnnotations();
1015
+ if (ttsStyleInputs.override?.owner === this) {
1016
+ ttsStyleInputs.override = null;
1017
+ }
914
1018
  this.themeObserver?.disconnect();
915
1019
  this.themeObserver = null;
916
1020
  }