@pie-players/pie-assessment-toolkit 0.3.66 → 0.3.68

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 (65) hide show
  1. package/README.md +1 -1
  2. package/dist/attempt/AssessmentSession.d.ts +7 -27
  3. package/dist/components/ItemToolBar.custom-element.js +1 -1
  4. package/dist/components/PieAssessmentToolkit.custom-element.js +13 -13
  5. package/dist/components/SectionToolBar.custom-element.js +1 -1
  6. package/dist/components/chunks/ItemToolBar-38mhtjsq.js +51 -0
  7. package/dist/components/chunks/ItemToolBar-9ymm7pd1.js +46 -0
  8. package/dist/components/item-toolbar-element.js +1 -0
  9. package/dist/components/pie-assessment-toolkit-element.js +1 -0
  10. package/dist/components/section-toolbar-element.js +1 -0
  11. package/dist/context/assessment-toolkit-context.d.ts +28 -0
  12. package/dist/index.d.ts +8 -3
  13. package/dist/index.js +5 -2
  14. package/dist/policy/core/ToolPolicyEngine.d.ts +12 -0
  15. package/dist/policy/core/ToolPolicyEngine.js +28 -2
  16. package/dist/policy/core/feature-decision.d.ts +28 -2
  17. package/dist/policy/core/feature-decision.js +35 -0
  18. package/dist/policy/engine.d.ts +1 -1
  19. package/dist/policy/engine.js +1 -0
  20. package/dist/runtime/SectionRuntimeEngine.d.ts +56 -0
  21. package/dist/runtime/SectionRuntimeEngine.js +66 -1
  22. package/dist/runtime/core/engine-resolver.d.ts +25 -1
  23. package/dist/runtime/registration-events.d.ts +52 -0
  24. package/dist/runtime/registration-events.js +2 -0
  25. package/dist/services/AccessibilityCatalogResolver.js +21 -5
  26. package/dist/services/I18nService.d.ts +28 -100
  27. package/dist/services/I18nService.js +43 -233
  28. package/dist/services/TTSService.d.ts +12 -0
  29. package/dist/services/TTSService.js +18 -17
  30. package/dist/services/ToolRegistry.d.ts +86 -2
  31. package/dist/services/ToolRegistry.js +30 -0
  32. package/dist/services/ToolkitCoordinator.d.ts +39 -37
  33. package/dist/services/ToolkitCoordinator.js +40 -0
  34. package/dist/services/audio-handoff.d.ts +39 -0
  35. package/dist/services/audio-handoff.js +58 -0
  36. package/dist/services/catalog-media.d.ts +35 -4
  37. package/dist/services/catalog-media.js +92 -3
  38. package/dist/services/framework-error.d.ts +15 -1
  39. package/dist/services/interfaces.d.ts +28 -0
  40. package/dist/services/pnp-standard-features.d.ts +1 -1
  41. package/dist/services/section-controller-types.d.ts +218 -7
  42. package/dist/services/selection-action.d.ts +49 -0
  43. package/dist/services/selection-action.js +10 -0
  44. package/dist/services/spoken-audio-cards.js +5 -1
  45. package/dist/services/tool-context.d.ts +6 -5
  46. package/dist/services/tool-context.js +197 -152
  47. package/dist/services/tool-icons.d.ts +18 -0
  48. package/dist/services/tool-icons.js +31 -0
  49. package/dist/services/tool-providers/DesmosToolProvider.d.ts +6 -5
  50. package/dist/services/tool-request.d.ts +106 -0
  51. package/dist/services/tool-request.js +127 -0
  52. package/dist/services/toolbar-items.d.ts +6 -0
  53. package/dist/tools/client.d.ts +0 -1
  54. package/dist/tools/client.js +0 -2
  55. package/dist/tools/content-capability-resolution.d.ts +106 -0
  56. package/dist/tools/content-capability-resolution.js +136 -0
  57. package/dist/tools/internal.d.ts +8 -0
  58. package/dist/tools/internal.js +8 -0
  59. package/dist/tools/tool-surface-host.d.ts +57 -0
  60. package/dist/tools/tool-surface-host.js +610 -0
  61. package/package.json +14 -10
  62. package/dist/components/chunks/ItemToolBar-8jgdz50p.js +0 -51
  63. package/dist/components/chunks/ItemToolBar-cvs646j3.js +0 -36
  64. package/dist/tools/calculators/desmos-provider.d.ts +0 -46
  65. package/dist/tools/calculators/desmos-provider.js +0 -393
@@ -1,119 +1,47 @@
1
1
  /**
2
2
  * I18nService
3
3
  *
4
- * Internationalization service with hybrid loading strategy.
5
- * Manages translations, locale switching, and RTL/LTR direction.
4
+ * The toolkit's view of the shared i18n provider: a delegating wrapper over
5
+ * `SimpleI18n` from `players-shared` that adds toolkit-scoped logging and wires
6
+ * the lazy catalog loaders.
6
7
  *
7
- * Features:
8
- * - Subscriber/listener pattern for reactive updates
9
- * - Hybrid loading: Bundle English, lazy-load other locales
10
- * - Interpolation: {variable} syntax
11
- * - Pluralization: ICU-style plural rules
12
- * - RTL detection: Automatic based on locale
13
- * - Fallback to English for missing keys
8
+ * It was once a near-verbatim second copy of that class — same fields, same
9
+ * lookup, same plural selection. Duplicate implementations of one contract drift
10
+ * silently, because nothing fails when only one of them is fixed. Keep this a
11
+ * wrapper.
14
12
  *
15
13
  * Part of PIE Assessment Toolkit.
16
14
  */
17
- import type { I18nConfig, I18nServiceApi, PluralTranslation, TranslationBundle } from "@pie-players/pie-players-shared/i18n";
18
- export type { I18nConfig, I18nServiceApi, PluralTranslation, TranslationBundle, };
15
+ import type { I18nConfig, I18nProvider, I18nServiceApi, InterpolationValues, LocaleCode, MessageCatalog, MessageKeyInput, PluralOptions, TextDirection } from "@pie-players/pie-players-shared/i18n";
16
+ export type { I18nConfig, I18nProvider, I18nServiceApi, InterpolationValues, LocaleCode, MessageCatalog, MessageKeyInput, PluralOptions, TextDirection, };
19
17
  /**
20
- * I18nService
18
+ * Toolkit-scoped i18n service.
21
19
  *
22
- * Manages internationalization with reactive state updates.
20
+ * Serves every locale this repository ships, English resident and the rest
21
+ * lazily loaded.
23
22
  */
24
23
  export declare class I18nService implements I18nServiceApi {
25
- private locale;
26
- private fallbackLocale;
27
- private direction;
28
- private translations;
29
- private listeners;
30
- private loadingPromises;
31
- private config;
24
+ private readonly i18n;
32
25
  constructor(config?: I18nConfig);
33
26
  /**
34
- * Initialize i18n with locale and loading strategy
35
- */
36
- initialize(config: I18nConfig): Promise<void>;
37
- /**
38
- * Translate a key with optional interpolation
39
- *
40
- * @param key Translation key (e.g., 'common.save')
41
- * @param params Optional parameters for interpolation
42
- * @returns Translated string
43
- */
44
- t(key: string, params?: Record<string, any>): string;
45
- /**
46
- * Translate with pluralization
27
+ * Set the locale, loading its catalog first.
47
28
  *
48
- * @param key Translation key
49
- * @param count Count for pluralization
50
- * @param params Optional parameters for interpolation
51
- * @returns Translated string with plural form
52
- */
53
- tn(key: string, count: number, params?: Record<string, any>): string;
54
- /**
55
- * Get current locale
29
+ * Never falls back to the browser locale: a rendered-string change reaches a
30
+ * host's live delivery on their next install with no build signal on their
31
+ * side, so the locale has to be something the host asked for.
56
32
  */
33
+ initialize(config: I18nConfig): Promise<void>;
34
+ t(key: MessageKeyInput, values?: InterpolationValues): string;
35
+ plural(key: MessageKeyInput, options: PluralOptions): string;
57
36
  getLocale(): string;
58
- /**
59
- * Change locale (triggers async loading if needed)
60
- *
61
- * @param locale Locale code (e.g., 'en', 'es', 'zh', 'ar')
62
- */
63
- setLocale(locale: string): Promise<void>;
64
- /**
65
- * Get current text direction
66
- */
67
- getDirection(): "ltr" | "rtl";
68
- /**
69
- * Get available locales
70
- */
37
+ setLocale(locale: LocaleCode): Promise<void>;
38
+ getDirection(): TextDirection;
71
39
  getAvailableLocales(): string[];
72
- /**
73
- * Check if locale is loaded
74
- */
75
- isLocaleLoaded(locale: string): boolean;
76
- /**
77
- * Subscribe to locale/translation changes
78
- * Returns unsubscribe function
79
- */
40
+ isLocaleLoaded(locale: LocaleCode): boolean;
80
41
  subscribe(listener: () => void): () => void;
81
- /**
82
- * Check if a translation key exists
83
- */
84
- hasKey(key: string): boolean;
85
- /**
86
- * Notify all listeners of state change
87
- */
88
- private notifyListeners;
89
- /**
90
- * Apply locale and notify listeners
91
- */
92
- private applyLocale;
93
- /**
94
- * Apply direction to DOM
95
- */
96
- private applyDOMDirection;
97
- /**
98
- * Load translations for a locale
99
- */
100
- private loadTranslationsForLocale;
101
- /**
102
- * Get translation for a key
103
- */
104
- private getTranslation;
105
- /**
106
- * Interpolate variables in translation string
107
- * Replaces {variable} with params.variable
108
- */
109
- private interpolate;
110
- /**
111
- * Select plural form based on count and locale
112
- * Simplified implementation - real implementation would use Intl.PluralRules
113
- */
114
- private selectPluralForm;
115
- /**
116
- * Detect browser locale
117
- */
118
- private detectBrowserLocale;
42
+ hasKey(key: MessageKeyInput): boolean;
43
+ addCustomMessages(locale: string, messages: MessageCatalog): void;
44
+ withLocale(locale: LocaleCode): I18nProvider;
45
+ formatNumber(value: number, options?: Intl.NumberFormatOptions): string;
46
+ formatDate(date: Date, options?: Intl.DateTimeFormatOptions): string;
119
47
  }
@@ -1,274 +1,84 @@
1
1
  /**
2
2
  * I18nService
3
3
  *
4
- * Internationalization service with hybrid loading strategy.
5
- * Manages translations, locale switching, and RTL/LTR direction.
4
+ * The toolkit's view of the shared i18n provider: a delegating wrapper over
5
+ * `SimpleI18n` from `players-shared` that adds toolkit-scoped logging and wires
6
+ * the lazy catalog loaders.
6
7
  *
7
- * Features:
8
- * - Subscriber/listener pattern for reactive updates
9
- * - Hybrid loading: Bundle English, lazy-load other locales
10
- * - Interpolation: {variable} syntax
11
- * - Pluralization: ICU-style plural rules
12
- * - RTL detection: Automatic based on locale
13
- * - Fallback to English for missing keys
8
+ * It was once a near-verbatim second copy of that class — same fields, same
9
+ * lookup, same plural selection. Duplicate implementations of one contract drift
10
+ * silently, because nothing fails when only one of them is fixed. Keep this a
11
+ * wrapper.
14
12
  *
15
13
  * Part of PIE Assessment Toolkit.
16
14
  */
15
+ import { createPieI18n } from "@pie-players/pie-players-shared/i18n";
17
16
  import { createLogger } from "../utils/logger.js";
18
17
  const log = createLogger("I18nService");
19
18
  /**
20
- * I18nService
19
+ * Toolkit-scoped i18n service.
21
20
  *
22
- * Manages internationalization with reactive state updates.
21
+ * Serves every locale this repository ships, English resident and the rest
22
+ * lazily loaded.
23
23
  */
24
24
  export class I18nService {
25
- locale = "en";
26
- fallbackLocale = "en";
27
- direction = "ltr";
28
- translations = new Map();
29
- listeners = new Set();
30
- loadingPromises = new Map();
31
- config;
25
+ i18n;
32
26
  constructor(config = {}) {
33
- this.config = config;
34
- this.fallbackLocale = config.fallbackLocale || "en";
35
- log("I18nService initialized", { fallbackLocale: this.fallbackLocale });
36
- // Load bundled translations (English by default)
37
- if (config.bundledTranslations) {
38
- for (const [locale, bundle] of Object.entries(config.bundledTranslations)) {
39
- this.translations.set(locale, bundle);
40
- log(`Loaded bundled translations for: ${locale}`);
41
- }
42
- }
27
+ this.i18n = createPieI18n(config);
28
+ log("I18nService created", {
29
+ locale: this.i18n.getLocale(),
30
+ fallbackLocale: config.fallbackLocale ?? "en-US",
31
+ });
43
32
  }
44
33
  /**
45
- * Initialize i18n with locale and loading strategy
34
+ * Set the locale, loading its catalog first.
35
+ *
36
+ * Never falls back to the browser locale: a rendered-string change reaches a
37
+ * host's live delivery on their next install with no build signal on their
38
+ * side, so the locale has to be something the host asked for.
46
39
  */
47
40
  async initialize(config) {
48
- Object.assign(this.config, config);
49
- const locale = config.locale || this.detectBrowserLocale();
50
- log(`Initializing with locale: ${locale}`);
51
- await this.setLocale(locale);
41
+ log(`Initializing with locale: ${config.locale ?? this.i18n.getLocale()}`);
42
+ await this.i18n.initialize(config);
52
43
  }
53
- /**
54
- * Translate a key with optional interpolation
55
- *
56
- * @param key Translation key (e.g., 'common.save')
57
- * @param params Optional parameters for interpolation
58
- * @returns Translated string
59
- */
60
- t(key, params) {
61
- const translation = this.getTranslation(key);
62
- return this.interpolate(translation, params);
44
+ t(key, values) {
45
+ return this.i18n.t(key, values);
63
46
  }
64
- /**
65
- * Translate with pluralization
66
- *
67
- * @param key Translation key
68
- * @param count Count for pluralization
69
- * @param params Optional parameters for interpolation
70
- * @returns Translated string with plural form
71
- */
72
- tn(key, count, params) {
73
- const translation = this.getTranslation(key);
74
- if (typeof translation === "object") {
75
- const pluralForm = this.selectPluralForm(count, this.locale);
76
- const text = translation[pluralForm] || translation.other;
77
- return this.interpolate(text, { ...params, count });
78
- }
79
- return this.interpolate(translation, { ...params, count });
47
+ plural(key, options) {
48
+ return this.i18n.plural(key, options);
80
49
  }
81
- /**
82
- * Get current locale
83
- */
84
50
  getLocale() {
85
- return this.locale;
51
+ return this.i18n.getLocale();
86
52
  }
87
- /**
88
- * Change locale (triggers async loading if needed)
89
- *
90
- * @param locale Locale code (e.g., 'en', 'es', 'zh', 'ar')
91
- */
92
53
  async setLocale(locale) {
93
54
  log(`Setting locale to: ${locale}`);
94
- // Check if already loading
95
- if (this.loadingPromises.has(locale)) {
96
- log(`Locale ${locale} is already loading, waiting...`);
97
- await this.loadingPromises.get(locale);
98
- return;
99
- }
100
- // Check if already loaded
101
- if (this.translations.has(locale)) {
102
- log(`Locale ${locale} already loaded`);
103
- this.applyLocale(locale);
104
- return;
105
- }
106
- // Load translations
107
- const loadingPromise = this.loadTranslationsForLocale(locale);
108
- this.loadingPromises.set(locale, loadingPromise);
109
- try {
110
- await loadingPromise;
111
- this.applyLocale(locale);
112
- }
113
- finally {
114
- this.loadingPromises.delete(locale);
115
- }
55
+ await this.i18n.setLocale(locale);
116
56
  }
117
- /**
118
- * Get current text direction
119
- */
120
57
  getDirection() {
121
- return this.direction;
58
+ return this.i18n.getDirection();
122
59
  }
123
- /**
124
- * Get available locales
125
- */
126
60
  getAvailableLocales() {
127
- return Array.from(this.translations.keys());
61
+ return this.i18n.getAvailableLocales();
128
62
  }
129
- /**
130
- * Check if locale is loaded
131
- */
132
63
  isLocaleLoaded(locale) {
133
- return this.translations.has(locale);
64
+ return this.i18n.isLocaleLoaded(locale);
134
65
  }
135
- /**
136
- * Subscribe to locale/translation changes
137
- * Returns unsubscribe function
138
- */
139
66
  subscribe(listener) {
140
- this.listeners.add(listener);
141
- return () => {
142
- this.listeners.delete(listener);
143
- };
67
+ return this.i18n.subscribe(listener);
144
68
  }
145
- /**
146
- * Check if a translation key exists
147
- */
148
69
  hasKey(key) {
149
- const bundle = this.translations.get(this.locale);
150
- return !!bundle?.translations[key];
151
- }
152
- /**
153
- * Notify all listeners of state change
154
- */
155
- notifyListeners() {
156
- this.listeners.forEach((listener) => listener());
157
- }
158
- /**
159
- * Apply locale and notify listeners
160
- */
161
- applyLocale(locale) {
162
- const bundle = this.translations.get(locale);
163
- if (!bundle) {
164
- log(`No bundle found for locale: ${locale}`, "warn");
165
- return;
166
- }
167
- const previousLocale = this.locale;
168
- this.locale = locale;
169
- this.direction = bundle.direction;
170
- log(`Applied locale: ${locale} (direction: ${this.direction})`);
171
- this.applyDOMDirection();
172
- this.notifyListeners();
173
- }
174
- /**
175
- * Apply direction to DOM
176
- */
177
- applyDOMDirection() {
178
- if (typeof document === "undefined")
179
- return;
180
- document.documentElement.setAttribute("dir", this.direction);
181
- document.documentElement.setAttribute("lang", this.locale);
182
- log(`Applied DOM attributes: dir="${this.direction}" lang="${this.locale}"`);
183
- }
184
- /**
185
- * Load translations for a locale
186
- */
187
- async loadTranslationsForLocale(locale) {
188
- if (!this.config.loadTranslations) {
189
- throw new Error(`No translation loader configured for locale: ${locale}`);
190
- }
191
- log(`Loading translations for: ${locale}`);
192
- try {
193
- const bundle = await this.config.loadTranslations(locale);
194
- this.translations.set(locale, bundle);
195
- log(`Loaded translations for: ${locale} (${Object.keys(bundle.translations).length} keys)`);
196
- }
197
- catch (error) {
198
- log(`Failed to load translations for: ${locale}`, "error");
199
- throw error;
200
- }
70
+ return this.i18n.hasKey(key);
201
71
  }
202
- /**
203
- * Get translation for a key
204
- */
205
- getTranslation(key) {
206
- // Try current locale
207
- const currentBundle = this.translations.get(this.locale);
208
- if (currentBundle?.translations[key]) {
209
- return currentBundle.translations[key];
210
- }
211
- // Try fallback locale
212
- const fallbackBundle = this.translations.get(this.fallbackLocale);
213
- if (fallbackBundle?.translations[key]) {
214
- if (this.config.debug) {
215
- log(`Using fallback translation for key: ${key}`);
216
- }
217
- return fallbackBundle.translations[key];
218
- }
219
- // Missing key handling
220
- if (this.config.onMissingKey) {
221
- this.config.onMissingKey(key, this.locale);
222
- }
223
- if (this.config.debug) {
224
- log(`Missing translation key: ${key} (locale: ${this.locale})`, "warn");
225
- }
226
- return key;
72
+ addCustomMessages(locale, messages) {
73
+ this.i18n.addCustomMessages(locale, messages);
227
74
  }
228
- /**
229
- * Interpolate variables in translation string
230
- * Replaces {variable} with params.variable
231
- */
232
- interpolate(text, params) {
233
- if (!params)
234
- return text;
235
- return text.replace(/\{(\w+)\}/g, (match, key) => {
236
- return params[key]?.toString() || match;
237
- });
75
+ withLocale(locale) {
76
+ return this.i18n.withLocale(locale);
238
77
  }
239
- /**
240
- * Select plural form based on count and locale
241
- * Simplified implementation - real implementation would use Intl.PluralRules
242
- */
243
- selectPluralForm(count, locale) {
244
- // Use Intl.PluralRules if available
245
- if (typeof Intl !== "undefined" && Intl.PluralRules) {
246
- try {
247
- const rules = new Intl.PluralRules(locale);
248
- const category = rules.select(count);
249
- return category;
250
- }
251
- catch (error) {
252
- log(`Failed to use Intl.PluralRules for locale: ${locale}`, "warn");
253
- }
254
- }
255
- // Fallback to simple rules
256
- if (count === 0)
257
- return "zero";
258
- if (count === 1)
259
- return "one";
260
- return "other";
78
+ formatNumber(value, options) {
79
+ return this.i18n.formatNumber?.(value, options) ?? String(value);
261
80
  }
262
- /**
263
- * Detect browser locale
264
- */
265
- detectBrowserLocale() {
266
- if (typeof navigator === "undefined")
267
- return "en";
268
- const browserLang = navigator.language ||
269
- (navigator.languages && navigator.languages[0]) ||
270
- "en";
271
- // Extract language code (e.g., 'en-US' -> 'en')
272
- return browserLang.split("-")[0];
81
+ formatDate(date, options) {
82
+ return this.i18n.formatDate?.(date, options) ?? date.toISOString();
273
83
  }
274
84
  }
@@ -109,6 +109,18 @@ export declare class TTSService {
109
109
  setHighlightCoordinator(coordinator: HighlightCoordinatorApi): void;
110
110
  /**
111
111
  * Set a late-bound provider for optional host TTS highlight target remapping.
112
+ *
113
+ * The returned disposer is the only thing that clears the registration: the
114
+ * caller owns the lifetime, and a provider installed once keeps remapping
115
+ * across stops, seek-restarts and later playbacks. Playback termination used to
116
+ * clear it in `stop()` and on both exits of `restartFromSeekIndex`, which left a
117
+ * host that installs before mount — the case a late-bound provider exists for —
118
+ * remapping exactly one playback and then silently falling back to identity.
119
+ *
120
+ * Installing again replaces the previous provider, so a caller that reinstalls
121
+ * per playback needs no disposal between them. A stale provider cannot paint
122
+ * outside its scope in any case: targets are validated by containment in
123
+ * `context.scopeElement` and a failing one falls back to its native range.
112
124
  */
113
125
  setHighlightTargetResolverProvider(provider: TTSHighlightTargetResolverProvider | null): () => void;
114
126
  /**
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * Part of PIE Assessment Toolkit.
17
17
  */
18
- import { applyMediaFragment } from "./catalog-media.js";
18
+ import { applyMediaFragment, enforceMediaFragment } from "./catalog-media.js";
19
19
  import { HighlightColor, HighlightType } from "./HighlightCoordinator.js";
20
20
  import { resolveSpokenAudioMedia, } from "./spoken-audio-cards.js";
21
21
  import { BrowserTTSProvider } from "./tts/browser-provider.js";
@@ -282,6 +282,18 @@ export class TTSService {
282
282
  }
283
283
  /**
284
284
  * Set a late-bound provider for optional host TTS highlight target remapping.
285
+ *
286
+ * The returned disposer is the only thing that clears the registration: the
287
+ * caller owns the lifetime, and a provider installed once keeps remapping
288
+ * across stops, seek-restarts and later playbacks. Playback termination used to
289
+ * clear it in `stop()` and on both exits of `restartFromSeekIndex`, which left a
290
+ * host that installs before mount — the case a late-bound provider exists for —
291
+ * remapping exactly one playback and then silently falling back to identity.
292
+ *
293
+ * Installing again replaces the previous provider, so a caller that reinstalls
294
+ * per playback needs no disposal between them. A stale provider cannot paint
295
+ * outside its scope in any case: targets are validated by containment in
296
+ * `context.scopeElement` and a failing one falls back to its native range.
285
297
  */
286
298
  setHighlightTargetResolverProvider(provider) {
287
299
  this.highlightTargetResolverProvider = provider;
@@ -1731,7 +1743,7 @@ export class TTSService {
1731
1743
  element.src = applyMediaFragment(source.src, media.fragment);
1732
1744
  element.playbackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate || 1));
1733
1745
  await new Promise((resolve, reject) => {
1734
- let endGuard;
1746
+ let disposeFragment;
1735
1747
  let didStart = false;
1736
1748
  let settled = false;
1737
1749
  const markStarted = () => {
@@ -1744,8 +1756,7 @@ export class TTSService {
1744
1756
  element.removeEventListener("ended", onEnded);
1745
1757
  element.removeEventListener("error", onError);
1746
1758
  element.removeEventListener("playing", markStarted);
1747
- if (endGuard !== undefined)
1748
- clearInterval(endGuard);
1759
+ disposeFragment?.();
1749
1760
  if (this.activeRecordedAudio?.element === element) {
1750
1761
  this.activeRecordedAudio = null;
1751
1762
  }
@@ -1781,16 +1792,9 @@ export class TTSService {
1781
1792
  element.addEventListener("ended", onEnded);
1782
1793
  element.addEventListener("error", onError);
1783
1794
  element.addEventListener("playing", markStarted);
1784
- // Browsers honour a Media Fragments start offset but are inconsistent
1785
- // about the end bound, so the end is enforced here — the same reason the
1786
- // signing region enforces its own.
1787
- const endSeconds = media.fragment?.endSeconds;
1788
- if (endSeconds !== undefined) {
1789
- endGuard = setInterval(() => {
1790
- if (element.currentTime >= endSeconds)
1791
- onEnded();
1792
- }, 100);
1793
- }
1795
+ // Reaching the slice's end is this clip finishing, so the chunk sequence
1796
+ // advances rather than the element merely pausing.
1797
+ disposeFragment = enforceMediaFragment(element, media.fragment, onEnded);
1794
1798
  Promise.resolve(element.play()).then(markStarted).catch(onError);
1795
1799
  }).finally(() => {
1796
1800
  if (this.activeRecordedAudio?.element === element) {
@@ -2092,7 +2096,6 @@ export class TTSService {
2092
2096
  return;
2093
2097
  this.setState(PlaybackState.IDLE);
2094
2098
  this.clearHighlightsAndTracking();
2095
- this.highlightTargetResolverProvider = null;
2096
2099
  }
2097
2100
  catch (error) {
2098
2101
  if (runId !== this.speakRunId)
@@ -2100,7 +2103,6 @@ export class TTSService {
2100
2103
  this.lastError = error instanceof Error ? error.message : String(error);
2101
2104
  this.setState(PlaybackState.ERROR);
2102
2105
  this.clearHighlightsAndTracking();
2103
- this.highlightTargetResolverProvider = null;
2104
2106
  throw error;
2105
2107
  }
2106
2108
  finally {
@@ -2205,7 +2207,6 @@ export class TTSService {
2205
2207
  this.cancelRecordedAudio();
2206
2208
  this.provider.onWordBoundary = undefined;
2207
2209
  this.provider.stop();
2208
- this.highlightTargetResolverProvider = null;
2209
2210
  this.setState(PlaybackState.IDLE);
2210
2211
  this.currentText = null;
2211
2212
  // Clear highlights