@pie-players/pie-assessment-toolkit 0.3.67 → 0.3.69

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 (73) hide show
  1. package/README.md +81 -9
  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 +14 -14
  5. package/dist/components/SectionToolBar.custom-element.js +1 -1
  6. package/dist/components/chunks/ItemToolBar-pe5szfyx.js +46 -0
  7. package/dist/components/chunks/ItemToolBar-rd7te9r0.js +51 -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 +7 -3
  13. package/dist/index.js +4 -2
  14. package/dist/policy/core/compose-decision.js +14 -0
  15. package/dist/policy/core/decision-types.d.ts +1 -1
  16. package/dist/policy/sources/PnpPolicySource.d.ts +8 -0
  17. package/dist/policy/sources/PnpPolicySource.js +19 -10
  18. package/dist/runtime/SectionRuntimeEngine.d.ts +56 -0
  19. package/dist/runtime/SectionRuntimeEngine.js +66 -1
  20. package/dist/runtime/core/engine-resolver.d.ts +25 -1
  21. package/dist/runtime/registration-events.d.ts +52 -0
  22. package/dist/runtime/registration-events.js +2 -0
  23. package/dist/services/AccessibilityCatalogResolver.js +21 -5
  24. package/dist/services/I18nService.d.ts +28 -100
  25. package/dist/services/I18nService.js +43 -233
  26. package/dist/services/TTSService.d.ts +12 -0
  27. package/dist/services/TTSService.js +18 -17
  28. package/dist/services/ToolRegistry.d.ts +120 -2
  29. package/dist/services/ToolRegistry.js +64 -0
  30. package/dist/services/ToolkitCoordinator.d.ts +39 -37
  31. package/dist/services/ToolkitCoordinator.js +40 -0
  32. package/dist/services/audio-handoff.d.ts +39 -0
  33. package/dist/services/audio-handoff.js +58 -0
  34. package/dist/services/catalog-media.d.ts +35 -4
  35. package/dist/services/catalog-media.js +92 -3
  36. package/dist/services/framework-error.d.ts +15 -1
  37. package/dist/services/interfaces.d.ts +28 -0
  38. package/dist/services/pnp-standard-features.d.ts +1 -1
  39. package/dist/services/section-controller-types.d.ts +218 -7
  40. package/dist/services/selection-action.d.ts +49 -0
  41. package/dist/services/selection-action.js +10 -0
  42. package/dist/services/spoken-audio-cards.js +5 -1
  43. package/dist/services/tool-context.d.ts +6 -5
  44. package/dist/services/tool-context.js +205 -155
  45. package/dist/services/tool-icons.d.ts +18 -0
  46. package/dist/services/tool-icons.js +31 -0
  47. package/dist/services/tool-providers/CortexToolProvider.d.ts +18 -0
  48. package/dist/services/tool-providers/CortexToolProvider.js +32 -0
  49. package/dist/services/tool-providers/DesmosToolProvider.d.ts +13 -101
  50. package/dist/services/tool-providers/DesmosToolProvider.js +14 -145
  51. package/dist/services/tool-providers/GeoGebraToolProvider.d.ts +21 -0
  52. package/dist/services/tool-providers/GeoGebraToolProvider.js +32 -0
  53. package/dist/services/tool-providers/LazyCalculatorToolProvider.d.ts +35 -0
  54. package/dist/services/tool-providers/LazyCalculatorToolProvider.js +95 -0
  55. package/dist/services/tool-providers/index.d.ts +4 -0
  56. package/dist/services/tool-providers/index.js +2 -0
  57. package/dist/services/tool-request.d.ts +106 -0
  58. package/dist/services/tool-request.js +127 -0
  59. package/dist/services/toolbar-items.d.ts +6 -0
  60. package/dist/tools/client.d.ts +0 -2
  61. package/dist/tools/client.js +0 -4
  62. package/dist/tools/internal.d.ts +7 -1
  63. package/dist/tools/internal.js +8 -1
  64. package/dist/tools/tool-surface-host.d.ts +57 -0
  65. package/dist/tools/tool-surface-host.js +610 -0
  66. package/dist/tools/types.d.ts +1 -66
  67. package/package.json +22 -12
  68. package/dist/components/chunks/ItemToolBar-8jgdz50p.js +0 -51
  69. package/dist/components/chunks/ItemToolBar-cvs646j3.js +0 -36
  70. package/dist/tools/calculators/desmos-provider.d.ts +0 -46
  71. package/dist/tools/calculators/desmos-provider.js +0 -393
  72. package/dist/tools/library-loader.d.ts +0 -62
  73. package/dist/tools/library-loader.js +0 -261
@@ -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",
@@ -134,6 +164,10 @@ function assertToolRegistrationShape(registration) {
134
164
  typeof registration.isVisibleInContext !== "function") {
135
165
  throw new Error(`Invalid tool registration "${registration.toolId}": "isVisibleInContext" must be a function when present.`);
136
166
  }
167
+ if (registration.isApplicableToContent !== undefined &&
168
+ typeof registration.isApplicableToContent !== "function") {
169
+ throw new Error(`Invalid tool registration "${registration.toolId}": "isApplicableToContent" must be a function when present.`);
170
+ }
137
171
  if (registration.requiresAuthoredContent !== undefined) {
138
172
  if (typeof registration.requiresAuthoredContent !== "object" ||
139
173
  registration.requiresAuthoredContent === null ||
@@ -412,6 +446,36 @@ export class ToolRegistry {
412
446
  }
413
447
  return visible;
414
448
  }
449
+ /**
450
+ * Whether a tool can act on any of the contexts it would be placed against.
451
+ * Unlike the relevance pass this is a veto: a `false` here removes the tool
452
+ * from a toolbar even when a grant protects it, so a tool answers `false`
453
+ * only where its controls provably do nothing.
454
+ *
455
+ * A tool that declares no applicability gate is applicable. So is one
456
+ * evaluated against no contexts — content that has not resolved yet cannot
457
+ * establish that a tool is useless.
458
+ *
459
+ * @param toolId - Tool to ask
460
+ * @param contexts - Every context the tool could act on at this placement
461
+ */
462
+ isApplicableToAnyContext(toolId, contexts) {
463
+ const tool = this.get(toolId);
464
+ if (!tool?.isApplicableToContent)
465
+ return true;
466
+ if (contexts.length === 0)
467
+ return true;
468
+ return contexts.some((context) => {
469
+ try {
470
+ return tool.isApplicableToContent?.(context) ?? true;
471
+ }
472
+ catch (error) {
473
+ console.error(`Error evaluating applicability for tool '${toolId}':`, error);
474
+ // A gate that throws has not established that the tool is useless.
475
+ return true;
476
+ }
477
+ });
478
+ }
415
479
  /**
416
480
  * Get tool metadata for building UIs
417
481
  * Useful for building PNP configuration interfaces
@@ -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;
@@ -25,6 +25,7 @@ import type { FeaturePolicyDecision, PolicySource, PnpEnforcementMode, ResolvedE
25
25
  import type { AssessmentEntity, AssessmentItemRef } from "@pie-players/pie-players-shared/types";
26
26
  import type { ITTSProvider, TTSProviderCapabilities } from "@pie-players/pie-tts";
27
27
  import type { ResolvedToolContext, ToolContextResolver, ToolContextResolverContext, ToolContextResolverMap } from "./ToolRegistry.js";
28
+ import type { ToolOpenRequest, ToolRequestTarget } from "./tool-request.js";
28
29
  export type { I18nServiceApi };
29
30
  /**
30
31
  * Theme provider interface
@@ -702,4 +703,31 @@ export interface ToolkitCoordinatorApi {
702
703
  * Subscribe to resolver registration/removal changes.
703
704
  */
704
705
  onToolContextResolverChange(listener: () => void): () => void;
706
+ /**
707
+ * The tool-open request seam, optional as a group.
708
+ *
709
+ * `ToolkitCoordinator` implements all four. They are optional here because a host
710
+ * may supply a coordinator of its own that predates the seam, and a surface that
711
+ * pairs a selection action to a tool already has to check before offering a button
712
+ * it cannot service. Declaring them required would make such a coordinator
713
+ * structurally non-conformant while every call site guards anyway.
714
+ */
715
+ /** Claim tool-open requests for one placement level. Toolbars call this. */
716
+ registerToolRequestTarget?(target: ToolRequestTarget): () => void;
717
+ /**
718
+ * Ask the toolbar hosting a tool to open it with `params`, and report whether
719
+ * one claimed the request. Lets a selection gateway hand the learner's
720
+ * selection to a tool it neither mounts nor can name a scoped instance of.
721
+ */
722
+ requestTool?(request: ToolOpenRequest): boolean;
723
+ /**
724
+ * Whether a request for this tool would reach a toolbar. A surface asks before
725
+ * offering the affordance.
726
+ */
727
+ canRequestTool?(toolId: string, level?: ToolOpenRequest["level"]): boolean;
728
+ /**
729
+ * Subscribe to toolbar registration/removal, so a surface can re-evaluate the
730
+ * actions it offers.
731
+ */
732
+ onToolRequestTargetsChange?(listener: () => void): () => void;
705
733
  }
@@ -167,7 +167,7 @@ export declare const QTI_STANDARD_ACCESS_FEATURES: {
167
167
  /**
168
168
  * Flat list of all standard access features for validation
169
169
  */
170
- export declare const ALL_STANDARD_ACCESS_FEATURES: ("transcript" | "braille" | "textToSpeech" | "calculator" | "strikethrough" | "answerEliminator" | "magnification" | "screenMagnifier" | "zoomable" | "highContrastDisplay" | "highContrastAudio" | "colorContrast" | "invertColors" | "displayTransformability" | "largePrint" | "fontEnlargement" | "resizeText" | "alternativeText" | "longDescription" | "describedMath" | "tactileGraphic" | "tactileObject" | "audioDescription" | "readAloud" | "humanVoice" | "syntheticVoice" | "speechRate" | "speechVolume" | "voicePitch" | "captions" | "closedCaptions" | "openCaptions" | "signLanguage" | "subtitles" | "audioControl" | "noBackgroundAudio" | "keyboardControl" | "mouseControl" | "touchControl" | "voiceControl" | "switchControl" | "eyeGazeControl" | "singleSwitchAccess" | "stickyKeys" | "keyboardShortcuts" | "timingControl" | "unlimitedTime" | "extendedTime" | "pauseControl" | "simplifiedLanguage" | "reducedComplexity" | "structuralNavigation" | "tableOfContents" | "reducedDistraction" | "noFlashing" | "pauseAnimation" | "annotations" | "bookmarking" | "highlighting" | "guidedNavigation" | "thesaurus" | "spellingAssistance" | "grammarAssistance" | "lineSpacing" | "wordSpacing" | "letterSpacing" | "fontFamily" | "readingMask" | "readingGuide" | "readingRuler" | "wordHighlighting" | "lineHighlighting" | "focusIndicator" | "printableResource" | "nemeth" | "refreshableBraille" | "index" | "pageNavigation" | "skipContent" | "breadcrumbs" | "searchable" | "fullTextSearch" | "multilingualText" | "translatedText" | "glossary" | "signLanguageInterpretation" | "visualLanguage" | "protractor" | "ruler" | "graph" | "graphingCalculator" | "periodicTable" | "formulaSheet" | "answerMasking" | "itemGlossary" | "tutorialAvailable")[];
170
+ export declare const ALL_STANDARD_ACCESS_FEATURES: ("transcript" | "braille" | "textToSpeech" | "calculator" | "graph" | "periodicTable" | "ruler" | "protractor" | "answerEliminator" | "signLanguage" | "readAloud" | "strikethrough" | "magnification" | "screenMagnifier" | "zoomable" | "highContrastDisplay" | "highContrastAudio" | "colorContrast" | "invertColors" | "displayTransformability" | "largePrint" | "fontEnlargement" | "resizeText" | "alternativeText" | "longDescription" | "describedMath" | "tactileGraphic" | "tactileObject" | "audioDescription" | "humanVoice" | "syntheticVoice" | "speechRate" | "speechVolume" | "voicePitch" | "captions" | "closedCaptions" | "openCaptions" | "subtitles" | "audioControl" | "noBackgroundAudio" | "keyboardControl" | "mouseControl" | "touchControl" | "voiceControl" | "switchControl" | "eyeGazeControl" | "singleSwitchAccess" | "stickyKeys" | "keyboardShortcuts" | "timingControl" | "unlimitedTime" | "extendedTime" | "pauseControl" | "simplifiedLanguage" | "reducedComplexity" | "structuralNavigation" | "tableOfContents" | "reducedDistraction" | "noFlashing" | "pauseAnimation" | "annotations" | "bookmarking" | "highlighting" | "guidedNavigation" | "thesaurus" | "spellingAssistance" | "grammarAssistance" | "lineSpacing" | "wordSpacing" | "letterSpacing" | "fontFamily" | "readingMask" | "readingGuide" | "readingRuler" | "wordHighlighting" | "lineHighlighting" | "focusIndicator" | "printableResource" | "nemeth" | "refreshableBraille" | "index" | "pageNavigation" | "skipContent" | "breadcrumbs" | "searchable" | "fullTextSearch" | "multilingualText" | "translatedText" | "glossary" | "signLanguageInterpretation" | "visualLanguage" | "graphingCalculator" | "formulaSheet" | "answerMasking" | "itemGlossary" | "tutorialAvailable")[];
171
171
  /**
172
172
  * Example PNP configurations for common accessibility needs
173
173
  * These are NOT official profiles but illustrative examples showing