@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
@@ -4,6 +4,7 @@
4
4
  * Central registry for all assessment tools. Manages tool metadata, visibility logic,
5
5
  * and button/instance creation. Supports dynamic registration and override by integrators.
6
6
  */
7
+ import type { I18nProvider } from "@pie-players/pie-players-shared/i18n/types";
7
8
  import type { CatalogOwnerSnapshot } from "./AccessibilityCatalogResolver.js";
8
9
  import type { ToolContext, ToolLevel } from "./tool-context.js";
9
10
  import type { ToolComponentOverrides } from "../tools/tool-tag-map.js";
@@ -30,6 +31,17 @@ export interface ToolToolbarButtonDefinition {
30
31
  * `assertToolRegistrationShape` enforces that.
31
32
  */
32
33
  icon?: string;
34
+ /**
35
+ * FontAwesome icon name, opting this button into `<nds-icon-button>` rendering
36
+ * where the host enables NDS icons (`ndsIcons`). Absent means the toolbar keeps
37
+ * its own button rendering, which is what every capability got before the NDS
38
+ * button existed.
39
+ *
40
+ * A declaration rather than a toolId lookup in the toolbar: which capabilities
41
+ * a deployment renders through the host's design system is a composition-layer
42
+ * decision, and the generic core names no capability.
43
+ */
44
+ faIconName?: string;
33
45
  ariaLabel: string;
34
46
  tooltip?: string;
35
47
  onClick: () => void;
@@ -49,7 +61,24 @@ export interface ToolbarContext {
49
61
  };
50
62
  itemId: string;
51
63
  catalogId: string;
64
+ /**
65
+ * Content-alternate language: which authored alternate the catalog resolver
66
+ * should select. Not the interface locale — see {@link ToolbarContext.i18n}. The
67
+ * two are independent by QTI 3's own statement, and conflating them is how a
68
+ * Spanish passage ends up forcing Spanish widget chrome.
69
+ */
52
70
  language: string;
71
+ /**
72
+ * Interface-locale provider for this capability's own UI strings.
73
+ *
74
+ * Required, and always the facade `resolveInterfaceI18n` returns: the toolbar
75
+ * resolves it once from the toolkit runtime context, so a registration reads
76
+ * `toolbarContext.i18n` and neither repeats the no-publisher fallback nor
77
+ * misses the republish that a locale change produces. With no publisher the
78
+ * facade wraps the English-only default, which is why this can be required
79
+ * rather than optional.
80
+ */
81
+ i18n: I18nProvider;
53
82
  ui?: {
54
83
  size?: string;
55
84
  };
@@ -137,6 +166,23 @@ export interface ToolWindowShellConfig {
137
166
  initialMargin?: number;
138
167
  content?: ToolWindowShellContentConfig;
139
168
  actions?: ToolWindowShellAction[];
169
+ /**
170
+ * Render the shell's own header controls as `<nds-icon-button>`s where the host
171
+ * enables NDS icons. Shells without this keep the plain `<button>` controls, and
172
+ * an opted-out host gets those either way.
173
+ */
174
+ ndsHeaderControls?: boolean;
175
+ /**
176
+ * Let Tab and Shift+Tab cross between the page and this shell rather than
177
+ * cycling inside it.
178
+ *
179
+ * The shell is appended to `<body>`, so it sits outside the document order its
180
+ * opener lives in. A shell a learner works *alongside* — reading the question
181
+ * while using the tool — needs Tab from the opener to enter it and Shift+Tab
182
+ * from the first question control to reach its end. A shell a learner works
183
+ * *inside* wants the default trap, which keeps focus captured until dismissed.
184
+ */
185
+ pageTabOrder?: boolean;
140
186
  }
141
187
  export interface HostedToolContext {
142
188
  toolId: string;
@@ -163,6 +209,19 @@ export interface ToolToolbarRenderResult {
163
209
  sync?: () => void;
164
210
  subscribeActive?: (callback: (active: boolean) => void) => () => void;
165
211
  }
212
+ /**
213
+ * A registration's display name in the interface locale.
214
+ *
215
+ * Precedence: the resolved `nameKey`, then `name`. A key that does not resolve
216
+ * falls back to `name` rather than rendering the key, so a catalog gap degrades
217
+ * to English instead of to `tools.something.name` on a toolbar button.
218
+ *
219
+ * Both display resolvers live here so the toolbars, the settings panels and the
220
+ * PNP debugger cannot each invent their own precedence.
221
+ */
222
+ export declare function resolveToolRegistrationName(registration: Pick<ToolRegistration, "name" | "nameKey">, i18n?: I18nProvider): string;
223
+ /** A registration's description in the interface locale. See the name resolver. */
224
+ export declare function resolveToolRegistrationDescription(registration: Pick<ToolRegistration, "description" | "descriptionKey">, i18n?: I18nProvider): string;
166
225
  export type ToolActivation = "toolbar-toggle" | "selection-gateway" | "region";
167
226
  export type ToolSingletonScope = "section";
168
227
  /**
@@ -177,6 +236,13 @@ export interface ToolSurfaceServices {
177
236
  toolkitCoordinator: ToolkitCoordinatorApi | null;
178
237
  ttsService: TtsServiceApi | null;
179
238
  catalogResolver: AccessibilityCatalogResolverApi | null;
239
+ /**
240
+ * Interface-locale provider for the surface's own labels — a region's
241
+ * `aria-label`, a heading it emits. Required, and always the facade
242
+ * `resolveInterfaceI18n` returns, which wraps the English-only default where
243
+ * the host published nothing.
244
+ */
245
+ i18n: I18nProvider;
180
246
  }
181
247
  /**
182
248
  * What a host tells a capability when asking it to fill a surface.
@@ -310,10 +376,28 @@ export interface ToolSurfaceRenderResult {
310
376
  export interface ToolRegistration {
311
377
  /** Unique tool identifier (e.g., 'calculator', 'textToSpeech') */
312
378
  toolId: string;
313
- /** Human-readable name */
379
+ /**
380
+ * Human-readable name.
381
+ *
382
+ * Required, and stays required: it is part of the contract a host implements
383
+ * when it contributes its own capability, and a host cannot be made to ship a
384
+ * message catalog. Treat it as the English source.
385
+ */
314
386
  name: string;
315
- /** Description of what the tool does */
387
+ /** Description of what the tool does. English source, like {@link name}. */
316
388
  description: string;
389
+ /**
390
+ * Message key resolving to {@link name} in the interface locale.
391
+ *
392
+ * Optional by design. A capability that supplies one is localizable; one that
393
+ * does not renders `name` verbatim, which is what keeps a host-authored
394
+ * registration working with no catalog. Resolve with
395
+ * {@link resolveToolRegistrationName} rather than reading either field
396
+ * directly, so precedence stays in one place.
397
+ */
398
+ nameKey?: string;
399
+ /** Message key resolving to {@link description} in the interface locale. */
400
+ descriptionKey?: string;
317
401
  /**
318
402
  * Icon identifier or SVG string. Required for the activations that render a
319
403
  * toolbar button; a region capability has no button, so it has no icon.
@@ -4,7 +4,37 @@
4
4
  * Central registry for all assessment tools. Manages tool metadata, visibility logic,
5
5
  * and button/instance creation. Supports dynamic registration and override by integrators.
6
6
  */
7
+ import { dynamicMessageKey } from "@pie-players/pie-players-shared/i18n/provider";
7
8
  import { normalizeToolAlias } from "./tools-config-normalizer.js";
9
+ /**
10
+ * A registration's display name in the interface locale.
11
+ *
12
+ * Precedence: the resolved `nameKey`, then `name`. A key that does not resolve
13
+ * falls back to `name` rather than rendering the key, so a catalog gap degrades
14
+ * to English instead of to `tools.something.name` on a toolbar button.
15
+ *
16
+ * Both display resolvers live here so the toolbars, the settings panels and the
17
+ * PNP debugger cannot each invent their own precedence.
18
+ */
19
+ export function resolveToolRegistrationName(registration, i18n) {
20
+ return resolveKeyedString(registration.name, registration.nameKey, i18n);
21
+ }
22
+ /** A registration's description in the interface locale. See the name resolver. */
23
+ export function resolveToolRegistrationDescription(registration, i18n) {
24
+ return resolveKeyedString(registration.description, registration.descriptionKey, i18n);
25
+ }
26
+ function resolveKeyedString(source, key, i18n) {
27
+ if (!key || !i18n)
28
+ return source;
29
+ // A registration may be host-authored against a host catalog, so the key is
30
+ // not drawn from PIE's `MessageKey` union and has to be asserted.
31
+ const messageKey = dynamicMessageKey(key);
32
+ if (i18n.hasKey)
33
+ return i18n.hasKey(messageKey) ? i18n.t(messageKey) : source;
34
+ // A provider without `hasKey` still signals a miss by returning the key.
35
+ const resolved = i18n.t(messageKey);
36
+ return resolved === key ? source : resolved;
37
+ }
8
38
  const VALID_TOOL_LEVELS = [
9
39
  "assessment",
10
40
  "section",
@@ -24,13 +24,14 @@ import { FrameworkErrorBus, type FrameworkErrorListener } from "./framework-erro
24
24
  import { HighlightCoordinator } from "./HighlightCoordinator.js";
25
25
  import { ToolCoordinator } from "./ToolCoordinator.js";
26
26
  import { TTSService } from "./TTSService.js";
27
- import { type TTSSpeedOption, type TTSLayoutMode } from "./tts-runtime-config.js";
27
+ import { type TTSRuntimeSettings } from "./tts-runtime-config.js";
28
28
  import type { SREMathSpeechOptions } from "./tts/math-speech.js";
29
29
  import { ToolProviderRegistry } from "./tool-providers/index.js";
30
30
  import type { ToolProviderApi } from "./tool-providers/ToolProviderApi.js";
31
31
  import type { TTSToolProviderConfig } from "./tool-providers/index.js";
32
32
  import { ToolRegistry } from "./ToolRegistry.js";
33
33
  import type { ResolvedToolContext, ToolContextResolver, ToolContextResolverContext, ToolContextResolverMap } from "./ToolRegistry.js";
34
+ import type { ToolOpenRequest, ToolRequestTarget } from "./tool-request.js";
34
35
  import { type FeaturePolicyDecision, type PnpEnforcementMode, type PolicySource, type ResolvedEngineInputs, type ToolPolicyChangeListener, type ToolPolicyDecision, type ToolPolicyDecisionRequest } from "../policy/engine.js";
35
36
  import type { SectionControllerContext, SectionControllerEvent, SectionControllerEventType, SectionControllerFactoryDefaults, SectionControllerHandle, SectionControllerKey, SectionSessionPersistenceStrategy, SectionPersistenceFactoryDefaults } from "./section-controller-types.js";
36
37
  export type { SectionControllerContext, SectionControllerEvent, SectionControllerEventType, SectionControllerFactoryDefaults, SectionControllerHandle, SectionControllerKey, SectionControllerLoadedRenderable, SectionSessionPersistenceConfig, SectionSessionPersistenceStrategy, SectionControllerRuntimeState, SectionControllerSessionState, SectionPersistenceFactoryDefaults, } from "./section-controller-types.js";
@@ -45,45 +46,25 @@ export interface ToolConfig {
45
46
  [key: string]: unknown;
46
47
  }
47
48
  /**
48
- * TTS tool configuration
49
+ * TTS configuration as a host writes it.
50
+ *
51
+ * The field set is `TTSRuntimeSettings`, which the runtime resolver owns: the two
52
+ * were declared separately and had already drifted in both directions, so a field
53
+ * the runtime honoured could not be named here. What this adds is the two things
54
+ * only a host-facing config has — a place to stash unrecognised keys, and a
55
+ * callback for fetching provider credentials, neither of which the resolved
56
+ * runtime settings carry.
57
+ *
58
+ * An intersection rather than an interface: `ToolConfig.provider` is `unknown`
59
+ * where the runtime settings narrow it to the three provider ids, and an interface
60
+ * cannot inherit a member from two parents that type it differently.
49
61
  */
50
- export interface TTSToolConfig extends ToolConfig {
51
- backend?: "browser" | "polly" | "google" | "server";
52
- provider?: "polly" | "google" | "custom";
53
- serverProvider?: "polly" | "google" | "custom";
54
- engine?: "standard" | "neural";
55
- sampleRate?: number;
56
- format?: "mp3" | "ogg" | "pcm";
57
- speechMarksMode?: "word" | "word+sentence";
58
- defaultVoice?: string;
59
- rate?: number;
60
- pitch?: number;
61
- apiEndpoint?: string;
62
- language?: string;
63
- transportMode?: "pie" | "custom";
64
- endpointMode?: "synthesizePath" | "rootPost";
65
- endpointValidationMode?: "voices" | "endpoint" | "none";
66
- includeAuthOnAssetFetch?: boolean;
67
- validateEndpoint?: boolean;
68
- cache?: boolean;
69
- speedRate?: "slow" | "medium" | "fast";
70
- lang_id?: string;
71
- /**
72
- * Optional inline TTS speed buttons.
73
- * - Omitted/non-array: default speed buttons are shown.
74
- * - Empty array: hide speed buttons.
75
- * - Arrays that sanitize to no valid values: default speed buttons are shown.
76
- * - Object entries can customize button text while preserving numeric rates.
77
- */
78
- speedOptions?: TTSSpeedOption[];
79
- layoutMode?: TTSLayoutMode;
80
- /** Speech Rule Engine options for generated MathML speech. */
81
- mathSpeech?: SREMathSpeechOptions;
62
+ export type TTSToolConfig = ToolConfig & TTSRuntimeSettings & {
82
63
  settings?: Record<string, unknown> & {
83
64
  mathSpeech?: SREMathSpeechOptions;
84
65
  };
85
66
  authFetcher?: () => Promise<Partial<TTSToolProviderConfig>>;
86
- }
67
+ };
87
68
  /**
88
69
  * Answer eliminator tool configuration
89
70
  */
@@ -205,8 +186,8 @@ export interface SectionEventSubscriptionArgs {
205
186
  eventTypes?: readonly SectionControllerEventType[];
206
187
  itemIds?: readonly string[];
207
188
  }
208
- export type SectionItemEventType = Exclude<SectionControllerEventType, "section-navigation-change" | "section-session-applied" | "section-loading-complete" | "section-items-complete-changed" | "section-error">;
209
- export type SectionScopedEventType = Extract<SectionControllerEventType, "section-navigation-change" | "section-session-applied" | "section-loading-complete" | "section-items-complete-changed" | "section-error">;
189
+ export type SectionItemEventType = Exclude<SectionControllerEventType, "section-navigation-change" | "section-session-applied" | "section-loading-complete" | "section-items-complete-changed" | "section-error" | "timed-media-cue-changed" | "timed-media-policy-degraded" | "timed-media-invalid">;
190
+ export type SectionScopedEventType = Extract<SectionControllerEventType, "section-navigation-change" | "section-session-applied" | "section-loading-complete" | "section-items-complete-changed" | "section-error" | "timed-media-cue-changed" | "timed-media-policy-degraded" | "timed-media-invalid">;
210
191
  export type SectionItemEvent = Extract<SectionControllerEvent, {
211
192
  type: SectionItemEventType;
212
193
  }>;
@@ -339,6 +320,7 @@ export declare class ToolkitCoordinator {
339
320
  private readonly toolRegistry;
340
321
  private readonly toolContextResolvers;
341
322
  private readonly toolContextResolverChangeListeners;
323
+ private readonly toolRequests;
342
324
  private readonly sectionControllers;
343
325
  private readonly sectionControllerKeys;
344
326
  private readonly sectionControllerInitPromises;
@@ -778,6 +760,26 @@ export declare class ToolkitCoordinator {
778
760
  hasToolContextResolver(toolId: string): boolean;
779
761
  resolveToolContext(context: ToolContextResolverContext): ResolvedToolContext | null;
780
762
  onToolContextResolverChange(listener: () => void): () => void;
763
+ /**
764
+ * Claim requests for one placement level. Called by a toolbar on mount.
765
+ */
766
+ registerToolRequestTarget(target: ToolRequestTarget): () => void;
767
+ /**
768
+ * Ask the toolbar hosting a tool to open it, handing it `params`.
769
+ *
770
+ * Returns whether a toolbar claimed the request. A surface offering this as an
771
+ * affordance should gate on {@link canRequestTool} first rather than acting on
772
+ * the return value, so the learner never sees a control that does nothing.
773
+ */
774
+ requestTool(request: ToolOpenRequest): boolean;
775
+ /**
776
+ * Answers `false` for a tool this deployment does not carry rather than
777
+ * throwing as {@link requestTool} does. A composer asks this while rendering a
778
+ * surface, and a host that swapped the registry for one without the tool would
779
+ * otherwise lose the whole surface to an exception over an absent action.
780
+ */
781
+ canRequestTool(toolId: string, level?: ToolOpenRequest["level"]): boolean;
782
+ onToolRequestTargetsChange(listener: () => void): () => void;
781
783
  /**
782
784
  * Compute the effective PNP/profile enforcement mode given the explicit
783
785
  * host override and the auto-mode helper.
@@ -26,6 +26,7 @@ import { BrowserTTSProvider } from "./tts/browser-provider.js";
26
26
  import { buildRuntimeTTSConfig, resolveTTSBackend, resolveTTSRuntimeSettings, } from "./tts-runtime-config.js";
27
27
  import { ToolProviderRegistry } from "./tool-providers/index.js";
28
28
  import { ToolRegistry } from "./ToolRegistry.js";
29
+ import { ToolRequestRegistry } from "./tool-request.js";
29
30
  import { ToolPolicyEngine, } from "../policy/engine.js";
30
31
  import { resolveDefaultPnpEnforcement } from "../policy/internal.js";
31
32
  const isPlainRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
@@ -59,6 +60,9 @@ const SECTION_SCOPED_EVENT_TYPES = [
59
60
  "section-loading-complete",
60
61
  "section-items-complete-changed",
61
62
  "section-error",
63
+ "timed-media-cue-changed",
64
+ "timed-media-policy-degraded",
65
+ "timed-media-invalid",
62
66
  ];
63
67
  /**
64
68
  * ToolkitCoordinator - Orchestrates all assessment toolkit services
@@ -113,6 +117,7 @@ export class ToolkitCoordinator {
113
117
  toolRegistry;
114
118
  toolContextResolvers = new Map();
115
119
  toolContextResolverChangeListeners = new Set();
120
+ toolRequests = new ToolRequestRegistry();
116
121
  sectionControllers = new Map();
117
122
  sectionControllerKeys = new Map();
118
123
  sectionControllerInitPromises = new Map();
@@ -1741,6 +1746,41 @@ export class ToolkitCoordinator {
1741
1746
  this.toolContextResolverChangeListeners.delete(listener);
1742
1747
  };
1743
1748
  }
1749
+ /**
1750
+ * Claim requests for one placement level. Called by a toolbar on mount.
1751
+ */
1752
+ registerToolRequestTarget(target) {
1753
+ return this.toolRequests.registerTarget(target);
1754
+ }
1755
+ /**
1756
+ * Ask the toolbar hosting a tool to open it, handing it `params`.
1757
+ *
1758
+ * Returns whether a toolbar claimed the request. A surface offering this as an
1759
+ * affordance should gate on {@link canRequestTool} first rather than acting on
1760
+ * the return value, so the learner never sees a control that does nothing.
1761
+ */
1762
+ requestTool(request) {
1763
+ this.assertCanonicalToolId(request.toolId);
1764
+ return this.toolRequests.request(request);
1765
+ }
1766
+ /**
1767
+ * Answers `false` for a tool this deployment does not carry rather than
1768
+ * throwing as {@link requestTool} does. A composer asks this while rendering a
1769
+ * surface, and a host that swapped the registry for one without the tool would
1770
+ * otherwise lose the whole surface to an exception over an absent action.
1771
+ */
1772
+ canRequestTool(toolId, level) {
1773
+ try {
1774
+ this.assertCanonicalToolId(toolId);
1775
+ }
1776
+ catch {
1777
+ return false;
1778
+ }
1779
+ return this.toolRequests.canRequest(toolId, level);
1780
+ }
1781
+ onToolRequestTargetsChange(listener) {
1782
+ return this.toolRequests.onTargetsChange(listener);
1783
+ }
1744
1784
  /**
1745
1785
  * Compute the effective PNP/profile enforcement mode given the explicit
1746
1786
  * host override and the auto-mode helper.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Audio handoff between read-aloud and a media surface.
3
+ *
4
+ * One learner, one pair of ears: read-aloud and media audio must never run at
5
+ * once, and the action the learner just took wins. Every media surface in the
6
+ * toolkit's graph needs both halves of that rule — pause yourself when speech
7
+ * starts, pause speech when you start — and there are already two such surfaces
8
+ * (the signing region, a timed-media stimulus reached through its port) with
9
+ * nothing in common but this. Shared so the rule has one statement: which states
10
+ * count as speaking is the part that would drift.
11
+ *
12
+ * Neither half resumes what it silenced. The learner presses play.
13
+ */
14
+ import type { TtsServiceApi } from "./interfaces.js";
15
+ /** Only what a handoff needs, so a host passing a partial service still works. */
16
+ type TtsHandoffSubscriber = Pick<TtsServiceApi, "onStateChange" | "offStateChange">;
17
+ type TtsHandoffPlayback = Pick<TtsServiceApi, "isPlaying" | "pause">;
18
+ /**
19
+ * Silence a media surface whenever read-aloud starts speaking.
20
+ *
21
+ * `LOADING` as well as `PLAYING`, so the pause lands before the first word rather
22
+ * than a provider round-trip after it. A `LOADING` that then fails leaves media
23
+ * paused, which costs the learner one press of play.
24
+ *
25
+ * Returns the teardown, including where there was nothing to bind.
26
+ */
27
+ export declare function bindTtsAudioHandoff(args: {
28
+ ttsService: Partial<TtsHandoffSubscriber> | null | undefined;
29
+ /** Unique per surface: the service keys its listener sets by this. */
30
+ listenerId: string;
31
+ silence: () => void;
32
+ }): () => void;
33
+ /**
34
+ * The other half: media audio has started, so read-aloud yields.
35
+ *
36
+ * Paused rather than stopped, so the learner keeps their place in the passage.
37
+ */
38
+ export declare function pauseTtsForMediaAudio(ttsService: Partial<TtsHandoffPlayback> | null | undefined): void;
39
+ export {};
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Audio handoff between read-aloud and a media surface.
3
+ *
4
+ * One learner, one pair of ears: read-aloud and media audio must never run at
5
+ * once, and the action the learner just took wins. Every media surface in the
6
+ * toolkit's graph needs both halves of that rule — pause yourself when speech
7
+ * starts, pause speech when you start — and there are already two such surfaces
8
+ * (the signing region, a timed-media stimulus reached through its port) with
9
+ * nothing in common but this. Shared so the rule has one statement: which states
10
+ * count as speaking is the part that would drift.
11
+ *
12
+ * Neither half resumes what it silenced. The learner presses play.
13
+ */
14
+ import { PlaybackState } from "./TTSService.js";
15
+ const NOOP = () => { };
16
+ /**
17
+ * Silence a media surface whenever read-aloud starts speaking.
18
+ *
19
+ * `LOADING` as well as `PLAYING`, so the pause lands before the first word rather
20
+ * than a provider round-trip after it. A `LOADING` that then fails leaves media
21
+ * paused, which costs the learner one press of play.
22
+ *
23
+ * Returns the teardown, including where there was nothing to bind.
24
+ */
25
+ export function bindTtsAudioHandoff(args) {
26
+ const { ttsService, listenerId, silence } = args;
27
+ if (typeof ttsService?.onStateChange !== "function")
28
+ return NOOP;
29
+ const onTtsState = (state) => {
30
+ if (state !== PlaybackState.PLAYING && state !== PlaybackState.LOADING) {
31
+ return;
32
+ }
33
+ silence();
34
+ };
35
+ ttsService.onStateChange(listenerId, onTtsState);
36
+ return () => {
37
+ try {
38
+ ttsService.offStateChange?.(listenerId, onTtsState);
39
+ }
40
+ catch {
41
+ // A torn-down service is not a failure to detach from.
42
+ }
43
+ };
44
+ }
45
+ /**
46
+ * The other half: media audio has started, so read-aloud yields.
47
+ *
48
+ * Paused rather than stopped, so the learner keeps their place in the passage.
49
+ */
50
+ export function pauseTtsForMediaAudio(ttsService) {
51
+ try {
52
+ if (ttsService?.isPlaying?.())
53
+ ttsService.pause?.();
54
+ }
55
+ catch {
56
+ // A torn-down or uninitialized TTS service must not break playback.
57
+ }
58
+ }
@@ -12,14 +12,45 @@
12
12
  * Part of PIE Assessment Toolkit.
13
13
  */
14
14
  import type { MediaFragmentRange, MediaSource } from "@pie-players/pie-players-shared/types";
15
+ /**
16
+ * The only `MediaAssetRef.version` this build renders.
17
+ *
18
+ * `media-asset-contract.md` requires unknown-version rejection for runtime
19
+ * rendering: bumping to `2` obliges every consumer to accept both for as long as
20
+ * any producer emits `1`, so a card claiming a version this build does not
21
+ * implement must not be rendered on a guess at which fields still mean what they
22
+ * did.
23
+ */
24
+ export declare const SUPPORTED_MEDIA_ASSET_VERSION = 1;
25
+ /**
26
+ * Whether a media reference claims a version this build cannot render.
27
+ *
28
+ * An absent `version` is accepted rather than rejected — the same posture the
29
+ * rest of this module takes toward absent fields, and the one `media.kind` and
30
+ * `matchesRequestedSignLanguage` already take: only a positive claim of
31
+ * something else is refused. Producers predate the field, and treating its
32
+ * absence as a rejection would drop cards that are otherwise wholly valid.
33
+ */
34
+ export declare function isUnsupportedMediaAssetVersion(version: unknown): boolean;
15
35
  export declare function isSafeMediaSrc(raw: unknown): raw is string;
16
36
  export declare function normalizeMediaSources(raw: unknown): MediaSource[];
17
37
  export declare function normalizeMediaFragment(raw: unknown): MediaFragmentRange | undefined;
18
38
  export declare function trimmedOrUndefined(value: unknown): string | undefined;
19
39
  /**
20
- * Apply a fragment range to a source URL as a Media Fragments URI, so one
21
- * recording can serve several content nodes. Browsers honour the start offset;
22
- * the end offset is enforced by the caller, because support for the end bound is
23
- * inconsistent.
40
+ * Hold a media element to one fragment's range, and return the disposer.
41
+ *
42
+ * The `#t=` URI `applyMediaFragment` writes is a hint browsers honour at neither
43
+ * bound reliably, so every consumer that means "play only this slice" enforces it
44
+ * here instead of reimplementing the pair. Both shipped consumers do —
45
+ * `SignLanguageMediaRegion` pausing at the end, `TTSService.playRecordedAudio`
46
+ * ending the clip so the chunk sequence advances — which is why the end action is
47
+ * the caller's and only the arithmetic is shared.
48
+ *
49
+ * The start seek is forward only: a browser that did honour the URI has already
50
+ * positioned past the start, and seeking back would replay audio the learner has
51
+ * heard. `onReachedEnd` may fire more than once and must tolerate it; polling
52
+ * stops while the element is paused, so an element left sitting past its end bound
53
+ * goes quiet rather than being told repeatedly.
24
54
  */
55
+ export declare function enforceMediaFragment(element: HTMLMediaElement, fragment: MediaFragmentRange | undefined, onReachedEnd: () => void): () => void;
25
56
  export declare function applyMediaFragment(src: string, fragment?: MediaFragmentRange): string;
@@ -20,6 +20,28 @@
20
20
  */
21
21
  const DISALLOWED_SRC_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
22
22
  const ALLOWED_SRC_SCHEMES = new Set(["http:", "https:", "data:", "blob:"]);
23
+ /**
24
+ * The only `MediaAssetRef.version` this build renders.
25
+ *
26
+ * `media-asset-contract.md` requires unknown-version rejection for runtime
27
+ * rendering: bumping to `2` obliges every consumer to accept both for as long as
28
+ * any producer emits `1`, so a card claiming a version this build does not
29
+ * implement must not be rendered on a guess at which fields still mean what they
30
+ * did.
31
+ */
32
+ export const SUPPORTED_MEDIA_ASSET_VERSION = 1;
33
+ /**
34
+ * Whether a media reference claims a version this build cannot render.
35
+ *
36
+ * An absent `version` is accepted rather than rejected — the same posture the
37
+ * rest of this module takes toward absent fields, and the one `media.kind` and
38
+ * `matchesRequestedSignLanguage` already take: only a positive claim of
39
+ * something else is refused. Producers predate the field, and treating its
40
+ * absence as a rejection would drop cards that are otherwise wholly valid.
41
+ */
42
+ export function isUnsupportedMediaAssetVersion(version) {
43
+ return version !== undefined && version !== SUPPORTED_MEDIA_ASSET_VERSION;
44
+ }
23
45
  export function isSafeMediaSrc(raw) {
24
46
  if (typeof raw !== "string")
25
47
  return false;
@@ -85,10 +107,77 @@ export function trimmedOrUndefined(value) {
85
107
  }
86
108
  /**
87
109
  * Apply a fragment range to a source URL as a Media Fragments URI, so one
88
- * recording can serve several content nodes. Browsers honour the start offset;
89
- * the end offset is enforced by the caller, because support for the end bound is
90
- * inconsistent.
110
+ * recording can serve several content nodes.
111
+ *
112
+ * The URI is a hint only: browsers honour both bounds inconsistently, so the
113
+ * caller enforces the range itself — seek forward to the start once metadata is
114
+ * available, and stop at the end. `SignLanguageMediaRegion` and
115
+ * `TTSService.playRecordedAudio` are the two shipped consumers that do so.
116
+ */
117
+ /**
118
+ * How often the end bound is re-checked while a slice is playing. `timeupdate`
119
+ * alone fires about four times a second, which is loose enough to leak a sliver
120
+ * of the next node's recording.
121
+ */
122
+ const END_CHECK_INTERVAL_MS = 100;
123
+ /** `HTMLMediaElement.HAVE_METADATA`, which not every DOM implementation exposes. */
124
+ const HAVE_METADATA = 1;
125
+ /**
126
+ * Hold a media element to one fragment's range, and return the disposer.
127
+ *
128
+ * The `#t=` URI `applyMediaFragment` writes is a hint browsers honour at neither
129
+ * bound reliably, so every consumer that means "play only this slice" enforces it
130
+ * here instead of reimplementing the pair. Both shipped consumers do —
131
+ * `SignLanguageMediaRegion` pausing at the end, `TTSService.playRecordedAudio`
132
+ * ending the clip so the chunk sequence advances — which is why the end action is
133
+ * the caller's and only the arithmetic is shared.
134
+ *
135
+ * The start seek is forward only: a browser that did honour the URI has already
136
+ * positioned past the start, and seeking back would replay audio the learner has
137
+ * heard. `onReachedEnd` may fire more than once and must tolerate it; polling
138
+ * stops while the element is paused, so an element left sitting past its end bound
139
+ * goes quiet rather than being told repeatedly.
91
140
  */
141
+ export function enforceMediaFragment(element, fragment, onReachedEnd) {
142
+ if (!fragment)
143
+ return () => { };
144
+ const { startSeconds, endSeconds } = fragment;
145
+ let seekToStart;
146
+ if (startSeconds > 0) {
147
+ seekToStart = () => {
148
+ if (element.currentTime < startSeconds) {
149
+ element.currentTime = startSeconds;
150
+ }
151
+ };
152
+ // Seeking before metadata is available only sets the default start
153
+ // position, so wait for it unless it already landed.
154
+ if (element.readyState >= HAVE_METADATA)
155
+ seekToStart();
156
+ else
157
+ element.addEventListener("loadedmetadata", seekToStart);
158
+ }
159
+ let checkEnd;
160
+ let endPoll;
161
+ if (endSeconds !== undefined) {
162
+ checkEnd = () => {
163
+ if (element.currentTime >= endSeconds)
164
+ onReachedEnd();
165
+ };
166
+ element.addEventListener("timeupdate", checkEnd);
167
+ endPoll = setInterval(() => {
168
+ if (!element.paused)
169
+ checkEnd?.();
170
+ }, END_CHECK_INTERVAL_MS);
171
+ }
172
+ return () => {
173
+ if (seekToStart)
174
+ element.removeEventListener("loadedmetadata", seekToStart);
175
+ if (checkEnd)
176
+ element.removeEventListener("timeupdate", checkEnd);
177
+ if (endPoll !== undefined)
178
+ clearInterval(endPoll);
179
+ };
180
+ }
92
181
  export function applyMediaFragment(src, fragment) {
93
182
  if (!fragment)
94
183
  return src;
@@ -1,5 +1,19 @@
1
1
  import type { ToolConfigDiagnostic } from "./tool-config-validation.js";
2
- export type FrameworkErrorKind = "tool-config" | "runtime-init" | "runtime-dispose" | "coordinator-init" | "provider-init" | "provider-register" | "tts-init" | "tool-state-load" | "tool-state-save" | "section-controller-init" | "section-controller-dispose" | "tool-surface" | "unknown";
2
+ export type FrameworkErrorKind = "tool-config" | "runtime-init" | "runtime-dispose" | "coordinator-init" | "provider-init" | "provider-register" | "tts-init" | "tool-state-load" | "tool-state-save" | "section-controller-init" | "section-controller-dispose" | "tool-surface"
3
+ /**
4
+ * A timed-media section could not deliver a policy as authored: a media time
5
+ * source missing `canPause` / `canRestrictSeeking` (recoverable — cues still
6
+ * fire and state is still recorded, only enforcement is lost), or authored
7
+ * `timedMedia` that failed validation (not recoverable — the section delivers
8
+ * as an ordinary section instead).
9
+ */
10
+ | "timed-media"
11
+ /**
12
+ * An interface-locale catalog failed to load. Recoverable by construction: every
13
+ * key still resolves through the English fallback chain, so the player renders
14
+ * — in the wrong language, which a host wants to know about.
15
+ */
16
+ | "i18n-locale-load" | "unknown";
3
17
  export type FrameworkErrorSeverity = "warning" | "error";
4
18
  export interface FrameworkErrorModel {
5
19
  kind: FrameworkErrorKind;