@pie-players/pie-assessment-toolkit 0.3.63 → 0.3.65

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 (67) hide show
  1. package/README.md +92 -1
  2. package/dist/components/ItemToolBar.custom-element.js +1 -1
  3. package/dist/components/PieAssessmentToolkit.custom-element.js +11 -11
  4. package/dist/components/SectionToolBar.custom-element.js +1 -1
  5. package/dist/components/chunks/ItemToolBar-cckwpz6c.js +51 -0
  6. package/dist/components/chunks/ItemToolBar-pryf0rtz.js +22 -0
  7. package/dist/index.d.ts +10 -6
  8. package/dist/index.js +12 -4
  9. package/dist/policy/core/ToolPolicyEngine.d.ts +21 -0
  10. package/dist/policy/core/ToolPolicyEngine.js +27 -0
  11. package/dist/policy/core/feature-decision.d.ts +57 -0
  12. package/dist/policy/core/feature-decision.js +40 -0
  13. package/dist/policy/engine.d.ts +1 -0
  14. package/dist/policy/sources/PnpPolicySource.d.ts +22 -0
  15. package/dist/policy/sources/PnpPolicySource.js +41 -11
  16. package/dist/runtime/catalog-registration.d.ts +56 -1
  17. package/dist/runtime/catalog-registration.js +64 -31
  18. package/dist/runtime/composition-emit-scheduler.d.ts +78 -0
  19. package/dist/runtime/composition-emit-scheduler.js +154 -0
  20. package/dist/runtime/core/engine-resolver.d.ts +1 -1
  21. package/dist/services/AccessibilityCatalogResolver.d.ts +100 -4
  22. package/dist/services/AccessibilityCatalogResolver.js +183 -58
  23. package/dist/services/SSMLExtractor.js +28 -18
  24. package/dist/services/TTSService.d.ts +25 -0
  25. package/dist/services/TTSService.js +241 -45
  26. package/dist/services/ToolRegistry.d.ts +218 -8
  27. package/dist/services/ToolRegistry.js +124 -8
  28. package/dist/services/ToolkitCoordinator.d.ts +25 -3
  29. package/dist/services/ToolkitCoordinator.js +47 -6
  30. package/dist/services/catalog-media.d.ts +25 -0
  31. package/dist/services/catalog-media.js +101 -0
  32. package/dist/services/createDefaultToolRegistry.d.ts +25 -58
  33. package/dist/services/createDefaultToolRegistry.js +24 -104
  34. package/dist/services/defaultPersonalNeedsProfile.d.ts +20 -2
  35. package/dist/services/defaultPersonalNeedsProfile.js +21 -19
  36. package/dist/services/interfaces.d.ts +29 -2
  37. package/dist/services/pnp-standard-features.d.ts +1 -1
  38. package/dist/services/spoken-audio-cards.d.ts +54 -0
  39. package/dist/services/spoken-audio-cards.js +66 -0
  40. package/dist/services/tool-config-defaults.d.ts +7 -23
  41. package/dist/services/tool-config-defaults.js +7 -46
  42. package/dist/services/tool-config-validation.d.ts +1 -1
  43. package/dist/services/tool-config-validation.js +44 -4
  44. package/dist/services/tts/browser-provider.js +2 -1
  45. package/dist/services/tts/math-aware-text-processing.js +3 -3
  46. package/dist/services/tts/text-processing.d.ts +51 -0
  47. package/dist/services/tts/text-processing.js +117 -1
  48. package/dist/services/tts-runtime-config.js +7 -2
  49. package/dist/tools/internal.d.ts +34 -0
  50. package/dist/tools/internal.js +33 -0
  51. package/dist/tools/tool-tag-map.d.ts +15 -3
  52. package/dist/tools/tool-tag-map.js +21 -18
  53. package/package.json +14 -10
  54. package/dist/components/chunks/ItemToolBar-843902tp.js +0 -51
  55. package/dist/components/chunks/ItemToolBar-84nv78dy.js +0 -22
  56. package/dist/tools/registrations/accessibility-tools.d.ts +0 -34
  57. package/dist/tools/registrations/accessibility-tools.js +0 -217
  58. package/dist/tools/registrations/calculator.d.ts +0 -20
  59. package/dist/tools/registrations/calculator.js +0 -228
  60. package/dist/tools/registrations/interaction-tools.d.ts +0 -27
  61. package/dist/tools/registrations/interaction-tools.js +0 -143
  62. package/dist/tools/registrations/measurement-tools.d.ts +0 -24
  63. package/dist/tools/registrations/measurement-tools.js +0 -130
  64. package/dist/tools/registrations/subject-specific-tools.d.ts +0 -27
  65. package/dist/tools/registrations/subject-specific-tools.js +0 -158
  66. package/dist/tools/registrations/tts.d.ts +0 -21
  67. package/dist/tools/registrations/tts.js +0 -184
@@ -1,4 +1,4 @@
1
- import type { AccessibilityCatalog } from "@pie-players/pie-players-shared/types";
1
+ import type { AccessibilityCatalog, CatalogCard, CatalogCardPayload } from "@pie-players/pie-players-shared/types";
2
2
  export type CatalogOwnerKind = "global" | "passage" | "itemModel";
3
3
  export interface CatalogOwnerContext {
4
4
  ownerKind: CatalogOwnerKind;
@@ -10,10 +10,50 @@ export interface CatalogOwnerContext {
10
10
  modelId?: string;
11
11
  }
12
12
  export type CatalogLookupContext = CatalogOwnerContext;
13
+ /** What changed in the resolver's catalog set. */
14
+ export type CatalogChangeReason = "scoped-registered" | "scoped-removed" | "item-added" | "item-cleared";
15
+ /**
16
+ * Emitted after the resolver's catalog set changes.
17
+ *
18
+ * Carries no resolved cards on purpose — a listener re-queries with its own
19
+ * lookup context and options, the same way `ToolPolicyChangeEvent` leaves the
20
+ * new decision to `decideToolPolicy`. `context` is present for the scoped
21
+ * reasons, so a listener can cheaply ignore owners it does not render.
22
+ */
23
+ export interface CatalogChangeEvent {
24
+ reason: CatalogChangeReason;
25
+ context?: CatalogOwnerContext;
26
+ }
27
+ export type CatalogChangeListener = (event: CatalogChangeEvent) => void;
13
28
  /**
14
29
  * Supported accessibility catalog types from QTI 3.0 / APIP
15
30
  */
16
- export type CatalogType = "spoken" | "sign-language" | "braille" | "tactile" | "simplified-language" | "audio-description" | "extended-description" | string;
31
+ export type CatalogType = "spoken" | "sign-language" | "transcript" | "braille" | "tactile" | "simplified-language" | "audio-description" | "extended-description" | string;
32
+ /**
33
+ * The catalog types PIE names, plus the rule for the ones it does not.
34
+ *
35
+ * The type above stays open on purpose: QTI treats the support vocabulary as
36
+ * extensible, and closing it here would reject content PIE has no reason to
37
+ * reject and could not usefully validate anyway, since catalogs arrive as
38
+ * authored JSON rather than through this type. Keeping it open cost something
39
+ * though — the named literals were documentation only, so a card written
40
+ * `"spokn"` was a perfectly valid `CatalogType` that no reader would ever ask
41
+ * for, and it failed by being invisible rather than by failing. That is what
42
+ * `isKnownCatalogType` and the warnings below are for: the openness stays, the
43
+ * silence does not.
44
+ */
45
+ export declare const KNOWN_CATALOG_TYPES: ReadonlySet<string>;
46
+ export declare function isKnownCatalogType(type: string): boolean;
47
+ /**
48
+ * Which of a card's two content slots it fills.
49
+ *
50
+ * Not a new field on the card and not a second discriminant: the card already
51
+ * says which form it is by carrying `content` or `payload`, and the
52
+ * exactly-one-of invariant is what makes that unambiguous. This names the
53
+ * distinction so a lookup can ask for one.
54
+ */
55
+ export type CatalogCardForm = "content" | "payload";
56
+ export declare const catalogCardForm: (card: CatalogCard) => CatalogCardForm;
17
57
  /**
18
58
  * Lookup options for catalog resolution
19
59
  */
@@ -26,6 +66,23 @@ export interface CatalogLookupOptions {
26
66
  useFallback?: boolean;
27
67
  /** Scope used to resolve local catalog idrefs for rendered content */
28
68
  context?: CatalogLookupContext;
69
+ /**
70
+ * Preferred content form, when one catalog type legitimately has both on the
71
+ * same node.
72
+ *
73
+ * The case this exists for is a `spoken` node carrying both a reading script
74
+ * and a recording of it — which is APIP's authoring pattern and what QTI 3's
75
+ * migration guidance tells you to keep, the script doubling as the source the
76
+ * audio was generated from and as the fallback when it cannot play. Before
77
+ * this, both resolution rungs took the first card matching type and language,
78
+ * so whichever of the two was written second in the array was unreachable and
79
+ * nothing said so.
80
+ *
81
+ * A preference, not a filter: if the requested form is not present, the other
82
+ * one is still returned. Callers that cannot use a form must check what they
83
+ * got, exactly as they already must for a card of a type they did not expect.
84
+ */
85
+ form?: CatalogCardForm;
29
86
  }
30
87
  /**
31
88
  * Resolved catalog result
@@ -37,8 +94,16 @@ export interface ResolvedCatalog {
37
94
  type: CatalogType;
38
95
  /** The language code */
39
96
  language?: string;
40
- /** The content (HTML, URL, or plain text) */
41
- content: string;
97
+ /**
98
+ * The string form (SSML, HTML, or plain text). Absent on cards whose content
99
+ * is structured; those carry `payload` instead.
100
+ */
101
+ content?: string;
102
+ /**
103
+ * The structured form, for catalog types a string cannot express — a signing
104
+ * video's sources, poster, and time range. Interpreted according to `type`.
105
+ */
106
+ payload?: CatalogCardPayload;
42
107
  /** Source of the catalog (assessment or item) */
43
108
  source: "assessment" | "item";
44
109
  }
@@ -89,11 +154,35 @@ export declare class AccessibilityCatalogResolver {
89
154
  private scopedCatalogs;
90
155
  private defaultLanguage;
91
156
  private sanitizedSpokenCache;
157
+ private catalogChangeListeners;
92
158
  constructor(assessmentCatalogs?: AccessibilityCatalog[], defaultLanguage?: string);
93
159
  /**
94
160
  * Set the default language for fallback resolution
95
161
  */
96
162
  setDefaultLanguage(language: string): void;
163
+ /**
164
+ * Subscribe to catalog registrations and removals.
165
+ *
166
+ * Readers that render a catalog — as opposed to TTS, which resolves by DOM
167
+ * lookup at the moment it speaks — have to compute "is there a card for this
168
+ * item" before the catalogs exist: registration is driven by an item shell's
169
+ * mount event, so a card that renders alongside the item legitimately looks
170
+ * too early. Without a signal the only options are polling on a deadline (no
171
+ * budget is right: too short strands the accommodation, too long is a visible
172
+ * delay) or missing the content silently.
173
+ *
174
+ * Same contract as `ToolPolicyEngine.onPolicyChange`, deliberately: a listener
175
+ * plus an unsubscribe, an event that names the `reason` and carries no
176
+ * resolved state, and subscriber errors swallowed so one bad listener cannot
177
+ * break registration. Listeners re-query rather than consuming a payload,
178
+ * which is what keeps the resolver free of assumptions about who is reading.
179
+ *
180
+ * Fires after the mutation, so a listener that re-queries sees the new state.
181
+ *
182
+ * @returns Unsubscribe function
183
+ */
184
+ onCatalogsChange(listener: CatalogChangeListener): () => void;
185
+ private emitCatalogsChange;
97
186
  /**
98
187
  * Get the default language
99
188
  */
@@ -134,6 +223,13 @@ export declare class AccessibilityCatalogResolver {
134
223
  private findMatchingCard;
135
224
  /**
136
225
  * Get all available alternatives for a catalog identifier
226
+ *
227
+ * Every card goes through `resolveCard`, the same projection `getAlternative`
228
+ * uses, so enumeration cannot describe a card differently from the resolution
229
+ * that renders it. It was hand-rolled here once and drifted immediately: the
230
+ * `signLanguage` alias was folded in on the resolution path only, so a card
231
+ * that arrived under the alias rendered correctly and was still reported as
232
+ * carrying no payload by anything asking what alternates exist.
137
233
  */
138
234
  getAllAlternatives(catalogId: string): ResolvedCatalog[];
139
235
  /**
@@ -1,4 +1,57 @@
1
1
  import { sanitizeSsmlString } from "./SSMLExtractor.js";
2
+ /**
3
+ * The catalog types PIE names, plus the rule for the ones it does not.
4
+ *
5
+ * The type above stays open on purpose: QTI treats the support vocabulary as
6
+ * extensible, and closing it here would reject content PIE has no reason to
7
+ * reject and could not usefully validate anyway, since catalogs arrive as
8
+ * authored JSON rather than through this type. Keeping it open cost something
9
+ * though — the named literals were documentation only, so a card written
10
+ * `"spokn"` was a perfectly valid `CatalogType` that no reader would ever ask
11
+ * for, and it failed by being invisible rather than by failing. That is what
12
+ * `isKnownCatalogType` and the warnings below are for: the openness stays, the
13
+ * silence does not.
14
+ */
15
+ export const KNOWN_CATALOG_TYPES = new Set([
16
+ "spoken",
17
+ "sign-language",
18
+ "transcript",
19
+ "braille",
20
+ "tactile",
21
+ "simplified-language",
22
+ "audio-description",
23
+ "extended-description",
24
+ ]);
25
+ /**
26
+ * QTI reserves an `ext:` prefix for vendor extensions, and pairs such a card
27
+ * with a standard one on the same node in its own examples. A prefixed token is
28
+ * therefore a deliberate extension rather than a typo, and passes without
29
+ * comment even though PIE ships no consumer for it.
30
+ */
31
+ const EXTENSION_TYPE_PREFIX = "ext:";
32
+ export function isKnownCatalogType(type) {
33
+ if (KNOWN_CATALOG_TYPES.has(type))
34
+ return true;
35
+ return (type.startsWith(EXTENSION_TYPE_PREFIX) &&
36
+ type.length > EXTENSION_TYPE_PREFIX.length);
37
+ }
38
+ // One report per distinct token per side, because the interesting information is
39
+ // "this token is not a thing", and repeating it per card or per lookup would bury
40
+ // it under itself.
41
+ const reportedUnknownTypes = new Set();
42
+ function reportUnknownCatalogType(type, side, where) {
43
+ const key = `${side}|${type}`;
44
+ if (reportedUnknownTypes.has(key))
45
+ return;
46
+ reportedUnknownTypes.add(key);
47
+ const known = `${Array.from(KNOWN_CATALOG_TYPES).join(", ")}, or an "${EXTENSION_TYPE_PREFIX}" prefixed vendor extension`;
48
+ if (side === "card") {
49
+ console.warn(`[AccessibilityCatalogResolver] catalog "${where}" has a card of unknown type "${type}"; it is stored but no reader asks for that type, so the alternate will never be shown. Expected one of: ${known}.`);
50
+ return;
51
+ }
52
+ console.warn(`[AccessibilityCatalogResolver] lookup for unknown catalog type "${type}" on "${where}" cannot match any card. Expected one of: ${known}.`);
53
+ }
54
+ export const catalogCardForm = (card) => card.payload !== undefined ? "payload" : "content";
2
55
  /**
3
56
  * Accessibility Catalog Resolver Service
4
57
  *
@@ -39,6 +92,7 @@ export class AccessibilityCatalogResolver {
39
92
  // provider regardless of where indexing happened; the cache keeps it to one
40
93
  // pass per unique string (sanitizeSsmlString is idempotent).
41
94
  sanitizedSpokenCache = new Map();
95
+ catalogChangeListeners = new Set();
42
96
  constructor(assessmentCatalogs, defaultLanguage = "en-US") {
43
97
  this.defaultLanguage = defaultLanguage;
44
98
  this.indexCatalogs(assessmentCatalogs ?? [], "assessment");
@@ -49,6 +103,46 @@ export class AccessibilityCatalogResolver {
49
103
  setDefaultLanguage(language) {
50
104
  this.defaultLanguage = language;
51
105
  }
106
+ /**
107
+ * Subscribe to catalog registrations and removals.
108
+ *
109
+ * Readers that render a catalog — as opposed to TTS, which resolves by DOM
110
+ * lookup at the moment it speaks — have to compute "is there a card for this
111
+ * item" before the catalogs exist: registration is driven by an item shell's
112
+ * mount event, so a card that renders alongside the item legitimately looks
113
+ * too early. Without a signal the only options are polling on a deadline (no
114
+ * budget is right: too short strands the accommodation, too long is a visible
115
+ * delay) or missing the content silently.
116
+ *
117
+ * Same contract as `ToolPolicyEngine.onPolicyChange`, deliberately: a listener
118
+ * plus an unsubscribe, an event that names the `reason` and carries no
119
+ * resolved state, and subscriber errors swallowed so one bad listener cannot
120
+ * break registration. Listeners re-query rather than consuming a payload,
121
+ * which is what keeps the resolver free of assumptions about who is reading.
122
+ *
123
+ * Fires after the mutation, so a listener that re-queries sees the new state.
124
+ *
125
+ * @returns Unsubscribe function
126
+ */
127
+ onCatalogsChange(listener) {
128
+ this.catalogChangeListeners.add(listener);
129
+ return () => {
130
+ this.catalogChangeListeners.delete(listener);
131
+ };
132
+ }
133
+ emitCatalogsChange(event) {
134
+ // Iterated over a copy: a listener that unsubscribes itself (or another)
135
+ // while handling the event must not make the loop skip its neighbours.
136
+ for (const listener of Array.from(this.catalogChangeListeners)) {
137
+ try {
138
+ listener(event);
139
+ }
140
+ catch {
141
+ // Subscriber errors must not break registration. Hosts that want
142
+ // error telemetry should wrap their listener.
143
+ }
144
+ }
145
+ }
52
146
  /**
53
147
  * Get the default language
54
148
  */
@@ -89,6 +183,12 @@ export class AccessibilityCatalogResolver {
89
183
  catalogs: scoped,
90
184
  });
91
185
  }
186
+ // Only when something was actually inserted: an all-duplicates call changes
187
+ // nothing, and waking every reader to re-resolve for that would make the
188
+ // signal untrustworthy.
189
+ if (insertedIds.length > 0) {
190
+ this.emitCatalogsChange({ reason: "scoped-registered", context });
191
+ }
92
192
  return () => {
93
193
  const current = this.scopedCatalogs.get(key);
94
194
  if (current?.catalogs !== scoped)
@@ -99,6 +199,9 @@ export class AccessibilityCatalogResolver {
99
199
  if (current.catalogs.size === 0) {
100
200
  this.scopedCatalogs.delete(key);
101
201
  }
202
+ if (insertedIds.length > 0) {
203
+ this.emitCatalogsChange({ reason: "scoped-removed", context });
204
+ }
102
205
  };
103
206
  }
104
207
  /**
@@ -108,12 +211,16 @@ export class AccessibilityCatalogResolver {
108
211
  if (!catalogs || catalogs.length === 0)
109
212
  return;
110
213
  this.indexCatalogs(catalogs, "item");
214
+ this.emitCatalogsChange({ reason: "item-added" });
111
215
  }
112
216
  /**
113
217
  * Clear item-level catalogs (called when leaving an item)
114
218
  */
115
219
  clearItemCatalogs() {
220
+ if (this.itemCatalogs.size === 0)
221
+ return;
116
222
  this.itemCatalogs.clear();
223
+ this.emitCatalogsChange({ reason: "item-cleared" });
117
224
  }
118
225
  /**
119
226
  * Check if a catalog exists (checks both assessment and item levels)
@@ -129,6 +236,11 @@ export class AccessibilityCatalogResolver {
129
236
  * Priority: Item-level catalogs take precedence over assessment-level
130
237
  */
131
238
  getAlternative(catalogId, options) {
239
+ // A typo on this side is as silent as one on a card: the lookup simply finds
240
+ // nothing and the caller reads that as "no alternate authored".
241
+ if (!isKnownCatalogType(options.type)) {
242
+ reportUnknownCatalogType(options.type, "lookup", catalogId);
243
+ }
132
244
  const scopedCatalog = options.context
133
245
  ? this.scopedCatalogs
134
246
  .get(this.getOwnerKey(options.context))
@@ -172,9 +284,10 @@ export class AccessibilityCatalogResolver {
172
284
  catalogId,
173
285
  type: card.catalog,
174
286
  language: card.language,
175
- content: card.catalog === "spoken"
287
+ content: card.catalog === "spoken" && card.content !== undefined
176
288
  ? this.ensureSpokenSanitized(card.content)
177
289
  : card.content,
290
+ payload: card.payload,
178
291
  source,
179
292
  };
180
293
  }
@@ -240,92 +353,104 @@ export class AccessibilityCatalogResolver {
240
353
  }
241
354
  return true;
242
355
  }
356
+ // The single funnel every registration path runs through — the constructor and
357
+ // `addItemCatalogs` by way of `indexCatalogs`, and `registerCatalogs`
358
+ // directly — which is why the unknown-type report lives here rather than at
359
+ // each entry point.
243
360
  sanitizeCatalogs(catalogs) {
244
361
  return catalogs.map((catalog) => ({
245
362
  ...catalog,
246
- cards: catalog.cards.map((card) => ({
247
- ...card,
248
- content: card.catalog === "spoken"
249
- ? sanitizeSsmlString(card.content)
250
- : card.content,
251
- })),
363
+ cards: catalog.cards.map((card) => {
364
+ if (!isKnownCatalogType(card.catalog)) {
365
+ reportUnknownCatalogType(card.catalog, "card", catalog.identifier);
366
+ }
367
+ return {
368
+ ...card,
369
+ content: card.catalog === "spoken" && card.content !== undefined
370
+ ? sanitizeSsmlString(card.content)
371
+ : card.content,
372
+ };
373
+ }),
252
374
  }));
253
375
  }
254
376
  /**
255
377
  * Find a matching catalog card based on lookup options
256
378
  */
257
379
  findMatchingCard(catalog, options) {
258
- const { type, language, useFallback = true } = options;
259
- // Try exact match (type + language)
380
+ const { type, language, useFallback = true, form } = options;
381
+ // Language rungs, most specific first: requested language, then the default
382
+ // language, then any. Unchanged — only what happens *within* a rung is new.
383
+ const languageRungs = [];
260
384
  if (language) {
261
- const exactMatch = catalog.cards.find((card) => card.catalog === type && card.language === language);
262
- if (exactMatch)
263
- return exactMatch;
385
+ languageRungs.push((card) => card.language === language);
264
386
  }
265
- // Try type match with default language (if fallback enabled)
266
387
  if (useFallback) {
267
- const defaultMatch = catalog.cards.find((card) => card.catalog === type && card.language === this.defaultLanguage);
268
- if (defaultMatch)
269
- return defaultMatch;
270
- // Try type match without language constraint
271
- const typeMatch = catalog.cards.find((card) => card.catalog === type);
272
- if (typeMatch)
273
- return typeMatch;
388
+ languageRungs.push((card) => card.language === this.defaultLanguage);
389
+ languageRungs.push(() => true);
390
+ }
391
+ for (const matchesLanguage of languageRungs) {
392
+ const candidates = catalog.cards.filter((card) => card.catalog === type && matchesLanguage(card));
393
+ if (candidates.length === 0)
394
+ continue;
395
+ // Form is preferred inside a language rung and never across them: a
396
+ // recording in the requested language beats a script in that language,
397
+ // but a script in the requested language beats a recording in another
398
+ // one. Getting this backwards would answer a Spanish lookup with English
399
+ // audio, which is worse than answering it with Spanish text.
400
+ if (form) {
401
+ const preferred = candidates.find((card) => catalogCardForm(card) === form);
402
+ if (preferred)
403
+ return preferred;
404
+ }
405
+ // No preference expressed, or the preferred form is absent: first match,
406
+ // which is what every caller got before form preference existed.
407
+ return candidates[0];
274
408
  }
275
409
  return null;
276
410
  }
277
411
  /**
278
412
  * Get all available alternatives for a catalog identifier
413
+ *
414
+ * Every card goes through `resolveCard`, the same projection `getAlternative`
415
+ * uses, so enumeration cannot describe a card differently from the resolution
416
+ * that renders it. It was hand-rolled here once and drifted immediately: the
417
+ * `signLanguage` alias was folded in on the resolution path only, so a card
418
+ * that arrived under the alias rendered correctly and was still reported as
419
+ * carrying no payload by anything asking what alternates exist.
279
420
  */
280
421
  getAllAlternatives(catalogId) {
281
422
  const results = [];
282
- // Add item-level alternatives
423
+ // Type, language *and* form: one catalog identifier legitimately carries
424
+ // several cards of the same type in different languages, and also a script
425
+ // and a recording of the same type in the *same* language. Keying on type
426
+ // and language alone dropped the second of those on the floor, so anything
427
+ // asking what alternates exist under-reported them.
428
+ const claimed = new Set();
429
+ const add = (card, source) => {
430
+ const key = `${card.catalog}|${card.language ?? ""}|${catalogCardForm(card)}`;
431
+ if (claimed.has(key))
432
+ return;
433
+ claimed.add(key);
434
+ results.push(this.resolveCard(catalogId, card, source));
435
+ };
436
+ // Item-level first, which is also the precedence `getAlternative` applies.
283
437
  const itemCatalog = this.itemCatalogs.get(catalogId);
284
438
  if (itemCatalog) {
285
- for (const card of itemCatalog.cards) {
286
- results.push({
287
- catalogId,
288
- type: card.catalog,
289
- language: card.language,
290
- content: card.content,
291
- source: "item",
292
- });
293
- }
439
+ for (const card of itemCatalog.cards)
440
+ add(card, "item");
294
441
  }
295
- // Add assessment-level alternatives (if not already provided by item)
296
442
  const assessmentCatalog = this.assessmentCatalogs.get(catalogId);
297
443
  if (assessmentCatalog) {
298
- for (const card of assessmentCatalog.cards) {
299
- // Only add if not already provided by item catalog
300
- const exists = results.some((r) => r.type === card.catalog && r.language === card.language);
301
- if (!exists) {
302
- results.push({
303
- catalogId,
304
- type: card.catalog,
305
- language: card.language,
306
- content: card.content,
307
- source: "assessment",
308
- });
309
- }
310
- }
444
+ for (const card of assessmentCatalog.cards)
445
+ add(card, "assessment");
311
446
  }
312
- // Add scoped (context-registered) alternatives. These resolve as "item"
313
- // in getAlternative, so report them the same way here.
447
+ // Scoped (context-registered) alternatives resolve as "item" in
448
+ // `getAlternative`, so report them the same way here.
314
449
  for (const [id, catalog] of this.scopedCatalogEntries()) {
315
450
  if (id !== catalogId)
316
451
  continue;
317
- for (const card of catalog.cards) {
318
- const exists = results.some((r) => r.type === card.catalog && r.language === card.language);
319
- if (!exists) {
320
- results.push({
321
- catalogId,
322
- type: card.catalog,
323
- language: card.language,
324
- content: card.content,
325
- source: "item",
326
- });
327
- }
328
- }
452
+ for (const card of catalog.cards)
453
+ add(card, "item");
329
454
  }
330
455
  return results;
331
456
  }
@@ -377,26 +377,36 @@ export class SSMLExtractor {
377
377
  const language = speakEl.getAttribute("xml:lang") ||
378
378
  speakEl.getAttribute("lang") ||
379
379
  "en-US";
380
- // Get plain text for visual display
381
- const plainText = speakEl.textContent || "";
382
- // Find parent element or create wrapper
383
- let wrapper = speakEl.parentElement;
384
- if (!wrapper || wrapper.tagName === "BODY") {
385
- // SPEAK is at root level - create span wrapper
386
- const span = doc.createElement("span");
387
- speakEl.parentNode?.insertBefore(span, speakEl);
388
- span.appendChild(doc.createTextNode(plainText));
389
- wrapper = span;
390
- speakEl.remove();
380
+ // The docking node is the element the author already wrote around
381
+ // the visible content this SSML speaks. Nothing is synthesized to
382
+ // stand in for it: a `<speak>` with no element around it has no
383
+ // content node to be an alternate *for*, and inventing one would
384
+ // mean inventing visible content too.
385
+ const wrapper = speakEl.parentElement && speakEl.parentElement.tagName !== "BODY"
386
+ ? speakEl.parentElement
387
+ : null;
388
+ // Visible content never keeps the SSML, docked or not.
389
+ speakEl.remove();
390
+ // Tag the docking node with the QTI-style catalog reference so the
391
+ // runtime can resolve this region's spoken content. Same attribute
392
+ // authored content uses (`data-catalog-idref`) — one canonical name,
393
+ // which is also why an existing value is never replaced: the
394
+ // reference names a whole card array, so overwriting it to win the
395
+ // spoken type would take that node's braille, simplified-language and
396
+ // sign-language cards down with it.
397
+ //
398
+ // Either way the catalog is still emitted, so a consumer resolving
399
+ // through the item's catalog set finds it. What an undocked catalog
400
+ // loses is DOM lookup, which is how TTS resolves — a content problem
401
+ // only the author can fix, so say so rather than failing silently.
402
+ const existingIdRef = wrapper?.getAttribute("data-catalog-idref");
403
+ if (!wrapper) {
404
+ console.warn(`[SSMLExtractor] "${idPrefix}" has a <speak> with no element around it, so catalog "${catalogId}" has no content node to dock to and TTS will not find it. Wrap the visible content this SSML speaks in an element.`);
391
405
  }
392
- else {
393
- // Just remove <speak> - visual content should be in sibling element
394
- speakEl.remove();
406
+ else if (existingIdRef) {
407
+ console.warn(`[SSMLExtractor] "${idPrefix}" has an inline <speak> inside a node already docked to catalog "${existingIdRef}"; keeping the existing reference, so catalog "${catalogId}" is not reachable from this node. Give the <speak> its own wrapper element, or author the SSML as a "spoken" card on "${existingIdRef}".`);
395
408
  }
396
- // Tag the wrapper with the QTI-style catalog reference so the
397
- // runtime can resolve this region's spoken content. Same attribute
398
- // authored content uses (`data-catalog-idref`) — one canonical name.
399
- if (wrapper) {
409
+ else {
400
410
  wrapper.setAttribute("data-catalog-idref", catalogId);
401
411
  }
402
412
  // Create catalog entry
@@ -64,6 +64,7 @@ export declare class TTSService {
64
64
  private activeWordBoundaryOffset;
65
65
  private seekSegments;
66
66
  private playbackChunks;
67
+ private activeRecordedAudio;
67
68
  private sentenceHighlightSegments;
68
69
  private currentSeekSegmentIndex;
69
70
  private activeSentenceStartOffset;
@@ -108,6 +109,18 @@ export declare class TTSService {
108
109
  * @param resolver AccessibilityCatalogResolver instance
109
110
  */
110
111
  setCatalogResolver(resolver: AccessibilityCatalogResolver): void;
112
+ /**
113
+ * Whether a catalog holds spoken content this service could actually speak.
114
+ *
115
+ * Exists because `data-catalog-idref` is one attribute with several readers:
116
+ * a node can be docked to a catalog that carries only a signing card, and the
117
+ * nearest docked ancestor of a selection is therefore not necessarily the one
118
+ * holding its authored SSML. A caller resolving a selection climbs ancestors
119
+ * and asks this before settling on an id — see the TTS tool's
120
+ * `findSpokenCatalogId`. Without it, a signing card docked on an inner node
121
+ * silently shadows authored speech on an outer one.
122
+ */
123
+ hasSpokenAlternate(catalogId: string, language?: string): boolean;
111
124
  private getHighlightResolverRuntime;
112
125
  private getNodeElement;
113
126
  private isElementWithinScope;
@@ -193,6 +206,18 @@ export declare class TTSService {
193
206
  private highlightRenderableActiveTarget;
194
207
  private renderHighlightDecision;
195
208
  private speakCatalogChunk;
209
+ /**
210
+ * Play a recorded spoken alternate, resolving when it finishes.
211
+ *
212
+ * Rejects if the clip cannot play, which is what routes playback to the
213
+ * chunk's `plainFallback` — the reading script — in `speakCatalogChunk`.
214
+ * Highlighting is the docked node as a block for the clip's duration: a
215
+ * recording emits no word boundaries, and inventing them from its duration
216
+ * would highlight the wrong words confidently rather than the right region
217
+ * vaguely.
218
+ */
219
+ private playRecordedAudio;
220
+ private cancelRecordedAudio;
196
221
  private speakCatalogChunkOnce;
197
222
  private executeSpeakPlayback;
198
223
  private clearHighlightsAndTracking;