@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
@@ -113,6 +113,18 @@ export declare class ToolPolicyEngine {
113
113
  * {@link FeaturePolicyDecision.assessmentBound}.
114
114
  */
115
115
  decideFeature(featureId: string): FeaturePolicyDecision;
116
+ /**
117
+ * The host gates that hold for a feature id, or `null` when none fires.
118
+ *
119
+ * `policy.allowed` / `policy.blocked` name capabilities, not placements, so
120
+ * they are the one part of the host pipeline that is meaningful without a
121
+ * placement level — and the only lever a host has over a capability that
122
+ * renders as its own surface, since a `region` capability is rejected from
123
+ * `tools.placement` by configuration validation. `provider-disabled` and
124
+ * `placement-membership` are deliberately not applied: both are statements
125
+ * about a toolbar the feature was never on.
126
+ */
127
+ private hostFeatureGate;
116
128
  /**
117
129
  * Convenience wrapper for hosts that just want the visible tool
118
130
  * IDs. Equivalent to `decide(...).visibleTools.map(e => e.toolId)`.
@@ -16,8 +16,8 @@
16
16
  * PR 1 ships the engine *without callers*. PR 2 wires it into
17
17
  * `ToolkitCoordinator`. PR 3 switches `<pie-item-toolbar>` over.
18
18
  */
19
- import { normalizeToolsConfig } from "../../services/tools-config-normalizer.js";
20
- import { interpretFeatureResult } from "./feature-decision.js";
19
+ import { normalizeToolList, normalizeToolsConfig, } from "../../services/tools-config-normalizer.js";
20
+ import { hostFeatureDenial, interpretFeatureResult, } from "./feature-decision.js";
21
21
  import { composeDecision } from "./compose-decision.js";
22
22
  import { resolveDefaultPnpEnforcement } from "./pnp-policy-inputs.js";
23
23
  import { PnpPolicySource } from "../sources/PnpPolicySource.js";
@@ -111,6 +111,9 @@ export class ToolPolicyEngine {
111
111
  */
112
112
  decideFeature(featureId) {
113
113
  this.assertNotDisposed();
114
+ const hostDenial = this.hostFeatureGate(featureId);
115
+ if (hostDenial)
116
+ return hostDenial;
114
117
  return interpretFeatureResult(featureId, this.pnpPolicySource.resolveFeature(featureId, {
115
118
  assessment: this.assessment ?? undefined,
116
119
  currentItemRef: this.currentItemRef ?? undefined,
@@ -120,6 +123,29 @@ export class ToolPolicyEngine {
120
123
  // The engine can.
121
124
  { assessmentBound: this.assessment !== null });
122
125
  }
126
+ /**
127
+ * The host gates that hold for a feature id, or `null` when none fires.
128
+ *
129
+ * `policy.allowed` / `policy.blocked` name capabilities, not placements, so
130
+ * they are the one part of the host pipeline that is meaningful without a
131
+ * placement level — and the only lever a host has over a capability that
132
+ * renders as its own surface, since a `region` capability is rejected from
133
+ * `tools.placement` by configuration validation. `provider-disabled` and
134
+ * `placement-membership` are deliberately not applied: both are statements
135
+ * about a toolbar the feature was never on.
136
+ */
137
+ hostFeatureGate(featureId) {
138
+ const context = { assessmentBound: this.assessment !== null };
139
+ const blocked = normalizeToolList(this.tools.policy.blocked);
140
+ if (blocked.includes(featureId)) {
141
+ return hostFeatureDenial(featureId, "host-blocked", blocked, context);
142
+ }
143
+ const allowed = normalizeToolList(this.tools.policy.allowed);
144
+ if (allowed.length > 0 && !allowed.includes(featureId)) {
145
+ return hostFeatureDenial(featureId, "host-allowlist", allowed, context);
146
+ }
147
+ return null;
148
+ }
123
149
  /**
124
150
  * Convenience wrapper for hosts that just want the visible tool
125
151
  * IDs. Equivalent to `decide(...).visibleTools.map(e => e.toolId)`.
@@ -23,6 +23,13 @@
23
23
  import type { PnpPolicyResult } from "../sources/PnpPolicySource.js";
24
24
  import type { PnpPolicySourceRule } from "./policy-source-tag.js";
25
25
  import type { ToolPolicyResolutionDecision, ToolPolicySourceType } from "./provenance.js";
26
+ /**
27
+ * A feature verdict comes from one of the six PNP precedence levels, or from a
28
+ * host gate that never reaches them: `tools.policy.blocked` and a non-empty
29
+ * `tools.policy.allowed` are absolute for the id they name, exactly as they are
30
+ * on the placement-scoped path.
31
+ */
32
+ export type FeaturePolicyRule = PnpPolicySourceRule | "host-allowlist" | "host-blocked";
26
33
  export interface FeaturePolicyDecision {
27
34
  /** The PNP/AfA support id that was evaluated (e.g. `"signLanguage"`). */
28
35
  featureId: string;
@@ -34,8 +41,8 @@ export interface FeaturePolicyDecision {
34
41
  granted: boolean;
35
42
  action: ToolPolicyResolutionDecision["action"];
36
43
  /** Which precedence rule produced the verdict. */
37
- rule: PnpPolicySourceRule;
38
- precedence: 1 | 2 | 3 | 4 | 5 | 6;
44
+ rule: FeaturePolicyRule;
45
+ precedence: 0 | 1 | 2 | 3 | 4 | 5 | 6;
39
46
  sourceType: ToolPolicySourceType;
40
47
  /** Human-readable explanation, suitable for a policy debugger. */
41
48
  reason: string;
@@ -74,6 +81,25 @@ export interface FeatureDecisionContext {
74
81
  /** Whether the engine has an assessment bound. */
75
82
  assessmentBound: boolean;
76
83
  }
84
+ /**
85
+ * A host gate denied the feature outright, so no policy source was consulted.
86
+ *
87
+ * `precedence: 0` and the two `host-*` rules are the same values
88
+ * `composeDecision(...)` records for the placement-scoped path, so a debugger
89
+ * reads one vocabulary for both. `assessmentBound` is still reported: a host
90
+ * blocklist is a verdict whether or not an assessment was bound, and the flag
91
+ * only ever qualifies a *policy* denial.
92
+ */
93
+ export declare function hostFeatureDenial(featureId: string, rule: "host-allowlist" | "host-blocked", hostValue: readonly string[], context: FeatureDecisionContext): FeaturePolicyDecision;
94
+ /**
95
+ * Whether a host gate produced the verdict, rather than a policy source.
96
+ *
97
+ * The distinction is load-bearing for a content-dependent capability that
98
+ * declares `resolvesWithoutGrant`: an absent grant is a case it is allowed to
99
+ * answer from the content alone, while a host denial is the host's off switch
100
+ * and nothing may reopen it.
101
+ */
102
+ export declare function isHostDeniedFeature(decision: Pick<FeaturePolicyDecision, "rule"> | null | undefined): boolean;
77
103
  /**
78
104
  * Interpret a single-feature `PnpPolicySource.resolveFeature(...)` result.
79
105
  *
@@ -29,6 +29,41 @@
29
29
  * a precedence level that does not exist.
30
30
  */
31
31
  const unboundAssessmentReason = (featureId) => `No assessment is bound, so no policy source could grant "${featureId}"`;
32
+ /**
33
+ * A host gate denied the feature outright, so no policy source was consulted.
34
+ *
35
+ * `precedence: 0` and the two `host-*` rules are the same values
36
+ * `composeDecision(...)` records for the placement-scoped path, so a debugger
37
+ * reads one vocabulary for both. `assessmentBound` is still reported: a host
38
+ * blocklist is a verdict whether or not an assessment was bound, and the flag
39
+ * only ever qualifies a *policy* denial.
40
+ */
41
+ export function hostFeatureDenial(featureId, rule, hostValue, context) {
42
+ return {
43
+ featureId,
44
+ granted: false,
45
+ action: "block",
46
+ rule,
47
+ precedence: 0,
48
+ sourceType: "host",
49
+ reason: rule === "host-blocked"
50
+ ? "Listed in tools.policy.blocked"
51
+ : `Not listed in tools.policy.allowed (${hostValue.join(", ")})`,
52
+ required: false,
53
+ assessmentBound: context.assessmentBound,
54
+ };
55
+ }
56
+ /**
57
+ * Whether a host gate produced the verdict, rather than a policy source.
58
+ *
59
+ * The distinction is load-bearing for a content-dependent capability that
60
+ * declares `resolvesWithoutGrant`: an absent grant is a case it is allowed to
61
+ * answer from the content alone, while a host denial is the host's off switch
62
+ * and nothing may reopen it.
63
+ */
64
+ export function isHostDeniedFeature(decision) {
65
+ return (decision?.rule === "host-blocked" || decision?.rule === "host-allowlist");
66
+ }
32
67
  /**
33
68
  * Interpret a single-feature `PnpPolicySource.resolveFeature(...)` result.
34
69
  *
@@ -21,7 +21,7 @@
21
21
  */
22
22
  export { ToolPolicyEngine, type PnpEnforcementMode, type ResolvedEngineInputs, type ToolPolicyChangeEvent, type ToolPolicyChangeListener, type ToolPolicyEngineArgs, type ToolPolicyEngineInputs, } from "./core/ToolPolicyEngine.js";
23
23
  export { TOOL_POLICY_ENGINE_KEY, type ToolPolicyEngineContext, } from "./core/engine-context.js";
24
- export type { FeaturePolicyDecision } from "./core/feature-decision.js";
24
+ export { isHostDeniedFeature, type FeaturePolicyDecision, type FeaturePolicyRule, } from "./core/feature-decision.js";
25
25
  export type { RequiredToolBlockedDetails, ToolPolicyDecision, ToolPolicyDecisionRequest, ToolPolicyDiagnostic, ToolPolicyDiagnosticCode, ToolPolicyEntry, ToolPolicyHostGate, ToolScope, } from "./core/decision-types.js";
26
26
  export type { PolicySource, PolicySourceDecisionContext, PolicySourceProvenanceEntry, PolicySourceResult, } from "./core/PolicySource.js";
27
27
  export type { PolicySourceTag, PnpPolicySourceRule, PnpPolicySourceTag, CustomPolicySourceTag, } from "./core/policy-source-tag.js";
@@ -21,3 +21,4 @@
21
21
  */
22
22
  export { ToolPolicyEngine, } from "./core/ToolPolicyEngine.js";
23
23
  export { TOOL_POLICY_ENGINE_KEY, } from "./core/engine-context.js";
24
+ export { isHostDeniedFeature, } from "./core/feature-decision.js";
@@ -48,6 +48,7 @@ import type { SectionEngineInput } from "./core/engine-input.js";
48
48
  import type { SectionEngineOutput } from "./core/engine-output.js";
49
49
  import { type EffectiveRuntime, type RuntimeInputs } from "./core/engine-resolver.js";
50
50
  import { type SectionEngineState } from "./core/engine-state.js";
51
+ import type { MediaTimeSource } from "@pie-players/pie-players-shared/timed-media";
51
52
  import type { RuntimeRegistrationDetail } from "./registration-events.js";
52
53
  import { RuntimeRegistry } from "./RuntimeRegistry.js";
53
54
  /**
@@ -95,6 +96,27 @@ interface RuntimeController extends SectionControllerHandle {
95
96
  } | null;
96
97
  subscribe?: (listener: (event: SectionControllerEvent) => void) => () => void;
97
98
  navigateToItem?: (index: number) => unknown;
99
+ attachMediaTimeSource?: (source: MediaTimeSource, options?: {
100
+ origin?: "native-adapter" | "host";
101
+ renderableId?: string;
102
+ }) => void;
103
+ detachMediaTimeSource?: (options?: {
104
+ origin?: "native-adapter" | "host";
105
+ }) => void;
106
+ pauseMediaForCompetingAudio?: () => boolean;
107
+ }
108
+ /** What a media-time-source registration reaching the engine has to say. */
109
+ export interface SectionRuntimeMediaTimeSourceAction {
110
+ renderableId: string;
111
+ action: "attach" | "detach";
112
+ source?: MediaTimeSource;
113
+ origin?: "native-adapter" | "host";
114
+ }
115
+ /** What a formative action reaching the engine has to say. */
116
+ export interface SectionRuntimeFormativeAction {
117
+ itemId: string;
118
+ action: "check" | "retry";
119
+ outcomes?: unknown[];
98
120
  }
99
121
  /**
100
122
  * Initialize args used by the toolkit CE to resolve the coordinator-backed
@@ -109,6 +131,14 @@ interface EngineInitArgs {
109
131
  attemptId?: string;
110
132
  createDefaultController: () => Promise<RuntimeController> | RuntimeController;
111
133
  onCompositionChanged?: (composition: unknown) => void;
134
+ /**
135
+ * The controller event that caused a republish, for the few events the toolkit
136
+ * has to act on rather than merely propagate — a timed-media policy that cannot
137
+ * be enforced becomes a framework warning, and malformed cue data becomes a
138
+ * framework error. Every other event reaches hosts through the composition
139
+ * republish and the coordinator's own subscriptions.
140
+ */
141
+ onControllerEvent?: (event: SectionControllerEvent) => void;
112
142
  }
113
143
  /**
114
144
  * Engine-side `attachHost` args. The host element, framework-error bus,
@@ -273,6 +303,32 @@ export declare class SectionRuntimeEngine {
273
303
  }): void;
274
304
  updateItemSession(itemId: string, session: unknown): unknown;
275
305
  navigateToItem(index: number): unknown;
306
+ /**
307
+ * Route a learner's formative action to the controller.
308
+ *
309
+ * Canonicalized here for the same reason `updateItemSession` is: the runtime
310
+ * id a card dispatches with is not necessarily the identifier the controller
311
+ * keys state by.
312
+ */
313
+ handleFormativeAction(action: SectionRuntimeFormativeAction): void;
314
+ /**
315
+ * Bind or release the section's Media Time Source.
316
+ *
317
+ * A pass-through, like `handleFormativeAction`: the engine routes, the
318
+ * controller decides. `renderableId` travels with the attach because whether the
319
+ * registering renderable is the one `stimulusRef` names is the controller's call
320
+ * — it validated `stimulusRef` in the first place.
321
+ */
322
+ handleMediaTimeSource(action: SectionRuntimeMediaTimeSourceAction): void;
323
+ /**
324
+ * Silence media audio because something else is about to speak.
325
+ *
326
+ * A pass-through like `handleMediaTimeSource`, and for the same reason: which
327
+ * source is authoritative and whether it reports `canPause` are the controller's
328
+ * to know. Returns whether media audio is now silent, or `true` where there is
329
+ * no timed-media controller to ask — nothing is playing.
330
+ */
331
+ requestMediaPauseForCompetingAudio(): boolean;
276
332
  persist(): Promise<void>;
277
333
  hydrate(): Promise<void>;
278
334
  getRegistry(): RuntimeRegistry;
@@ -41,8 +41,10 @@
41
41
  import { SectionEngineAdapter, } from "./adapter/SectionEngineAdapter.js";
42
42
  import { resolveSectionEngineRuntimeState, } from "./core/engine-resolver.js";
43
43
  import { createInitialEngineState, } from "./core/engine-state.js";
44
+ import { createPieLogger, isGlobalDebugEnabled, } from "@pie-players/pie-players-shared";
44
45
  import { RuntimeRegistry } from "./RuntimeRegistry.js";
45
46
  import { createRuntimeId } from "./runtime-id.js";
47
+ const logger = createPieLogger("section-runtime-engine", () => isGlobalDebugEnabled());
46
48
  export class SectionRuntimeEngine {
47
49
  registry = new RuntimeRegistry();
48
50
  runtimeId = createRuntimeId("section-engine");
@@ -209,7 +211,17 @@ export class SectionRuntimeEngine {
209
211
  this.replayRegisteredShellsIntoController(resolved);
210
212
  args.onCompositionChanged?.(resolved.getCompositionModel?.());
211
213
  this.unsubscribeController =
212
- resolved.subscribe?.(() => {
214
+ resolved.subscribe?.((event) => {
215
+ // Isolated deliberately: the controller's emit loop catches per
216
+ // listener, so a diagnostic handler that throws would take the
217
+ // composition republish down with it and every cue and Try would stop
218
+ // reaching the cards — a failure with no symptom except a warning.
219
+ try {
220
+ args.onControllerEvent?.(event);
221
+ }
222
+ catch (error) {
223
+ logger.warn("onControllerEvent handler threw", error);
224
+ }
213
225
  args.onCompositionChanged?.(resolved.getCompositionModel?.());
214
226
  }) || null;
215
227
  }
@@ -333,6 +345,59 @@ export class SectionRuntimeEngine {
333
345
  navigateToItem(index) {
334
346
  return this.controller?.navigateToItem?.(index) ?? null;
335
347
  }
348
+ /**
349
+ * Route a learner's formative action to the controller.
350
+ *
351
+ * Canonicalized here for the same reason `updateItemSession` is: the runtime
352
+ * id a card dispatches with is not necessarily the identifier the controller
353
+ * keys state by.
354
+ */
355
+ handleFormativeAction(action) {
356
+ if (!action?.itemId)
357
+ return;
358
+ const canonicalId = this.getCanonicalItemId(action.itemId);
359
+ if (action.action === "retry") {
360
+ this.controller?.retryFormativeItem?.({ itemId: canonicalId });
361
+ return;
362
+ }
363
+ this.controller?.recordFormativeTry?.({
364
+ itemId: canonicalId,
365
+ outcomes: action.outcomes,
366
+ });
367
+ }
368
+ /**
369
+ * Bind or release the section's Media Time Source.
370
+ *
371
+ * A pass-through, like `handleFormativeAction`: the engine routes, the
372
+ * controller decides. `renderableId` travels with the attach because whether the
373
+ * registering renderable is the one `stimulusRef` names is the controller's call
374
+ * — it validated `stimulusRef` in the first place.
375
+ */
376
+ handleMediaTimeSource(action) {
377
+ if (action?.action === "detach") {
378
+ this.controller?.detachMediaTimeSource?.({
379
+ origin: action.origin ?? "host",
380
+ });
381
+ return;
382
+ }
383
+ if (!action?.source)
384
+ return;
385
+ this.controller?.attachMediaTimeSource?.(action.source, {
386
+ origin: action.origin ?? "host",
387
+ renderableId: action.renderableId,
388
+ });
389
+ }
390
+ /**
391
+ * Silence media audio because something else is about to speak.
392
+ *
393
+ * A pass-through like `handleMediaTimeSource`, and for the same reason: which
394
+ * source is authoritative and whether it reports `canPause` are the controller's
395
+ * to know. Returns whether media audio is now silent, or `true` where there is
396
+ * no timed-media controller to ask — nothing is playing.
397
+ */
398
+ requestMediaPauseForCompetingAudio() {
399
+ return this.controller?.pauseMediaForCompetingAudio?.() ?? true;
400
+ }
336
401
  async persist() {
337
402
  await this.controller?.persist?.();
338
403
  }
@@ -68,6 +68,18 @@ export type RuntimeConfig = {
68
68
  * markup (the default). Purely visual — no engine effect.
69
69
  */
70
70
  ndsIcons?: boolean;
71
+ /**
72
+ * Interface locale: a BCP-47 tag naming the language the player renders its own
73
+ * UI in. Purely presentational — no engine effect.
74
+ *
75
+ * Distinct from content language, which describes the authored item and
76
+ * travels on `env`. QTI 3's implementation guide states the independence
77
+ * directly: a candidate may choose an interface language which may or may not
78
+ * also be the language of the content.
79
+ *
80
+ * Unset renders `en-US`. POSIX (`nl_NL`) and bare (`nl`) forms both resolve.
81
+ */
82
+ locale?: string;
71
83
  toolConfigStrictness?: ToolConfigStrictness;
72
84
  onFrameworkError?: FrameworkErrorHandler;
73
85
  onStageChange?: StageChangeHandler;
@@ -120,7 +132,7 @@ export declare function resolveRuntime(args: {
120
132
  createSectionController: unknown;
121
133
  isolation: string;
122
134
  env: Record<string, unknown>;
123
- toolConfigStrictness: "off" | "warn" | "error";
135
+ toolConfigStrictness: "off" | "error" | "warn";
124
136
  onFrameworkError: FrameworkErrorHandler | undefined;
125
137
  onStageChange: StageChangeHandler | undefined;
126
138
  onLoadingComplete: LoadingCompleteHandler | undefined;
@@ -134,6 +146,18 @@ export declare function resolveRuntime(args: {
134
146
  * markup (the default). Purely visual — no engine effect.
135
147
  */
136
148
  ndsIcons?: boolean;
149
+ /**
150
+ * Interface locale: a BCP-47 tag naming the language the player renders its own
151
+ * UI in. Purely presentational — no engine effect.
152
+ *
153
+ * Distinct from content language, which describes the authored item and
154
+ * travels on `env`. QTI 3's implementation guide states the independence
155
+ * directly: a candidate may choose an interface language which may or may not
156
+ * also be the language of the content.
157
+ *
158
+ * Unset renders `en-US`. POSIX (`nl_NL`) and bare (`nl`) forms both resolve.
159
+ */
160
+ locale?: string;
137
161
  };
138
162
  /**
139
163
  * Effective runtime returned by `resolveRuntime`. The shape is exposed
@@ -1,9 +1,12 @@
1
+ import type { MediaTimeSource } from "@pie-players/pie-players-shared/timed-media";
1
2
  export declare const PIE_REGISTER_EVENT = "pie-register";
2
3
  export declare const PIE_UNREGISTER_EVENT = "pie-unregister";
3
4
  export declare const PIE_INTERNAL_ITEM_SESSION_CHANGED_EVENT = "pie-item-session-changed";
4
5
  export declare const PIE_ITEM_SESSION_CHANGED_EVENT = "item-session-changed";
5
6
  export declare const PIE_INTERNAL_CONTENT_LOADED_EVENT = "pie-content-loaded";
6
7
  export declare const PIE_INTERNAL_ITEM_PLAYER_ERROR_EVENT = "pie-item-player-error";
8
+ export declare const PIE_INTERNAL_FORMATIVE_ACTION_EVENT = "pie-formative-action";
9
+ export declare const PIE_INTERNAL_MEDIA_TIME_SOURCE_EVENT = "pie-media-time-source";
7
10
  export type RuntimeRegistrationKind = "item" | "passage";
8
11
  export interface RuntimeRegistrationDetail {
9
12
  kind: RuntimeRegistrationKind;
@@ -35,3 +38,52 @@ export interface InternalItemPlayerErrorDetail {
35
38
  contentKind?: string;
36
39
  error: unknown;
37
40
  }
41
+ /**
42
+ * A learner's formative action, dispatched by the component that owns the
43
+ * control and the item player node — the only place that can call
44
+ * `provideScore()`. It reports outcomes rather than interpreting them; the
45
+ * section controller derives correctness and owns the state.
46
+ *
47
+ * `outcomes` is the array `pie-item-player.provideScore()` returned, verbatim,
48
+ * including the `undefined` slots it leaves for models with no element or
49
+ * controller.
50
+ */
51
+ export interface InternalFormativeActionDetail {
52
+ itemId: string;
53
+ canonicalItemId?: string;
54
+ action: "check" | "retry";
55
+ outcomes?: unknown[];
56
+ }
57
+ /**
58
+ * A Media Time Source becoming available or going away.
59
+ *
60
+ * The one seam through which a timed-media section reaches media, and
61
+ * deliberately the *only* one: the stimulus card dispatches this with a native
62
+ * `<video>` adapter, and a host wrapping a third-party player dispatches the same
63
+ * event with its own port. One code path, two producers — which is what keeps
64
+ * "a host can supply its own media element without shipping a PIE element" true
65
+ * rather than aspirational.
66
+ *
67
+ * `source` carries a live object, not serializable data. That is fine and
68
+ * intended: this event never crosses a realm, exactly like the element reference
69
+ * on `pie-register`.
70
+ */
71
+ export interface InternalMediaTimeSourceDetail {
72
+ /** The renderable that owns the media, for matching against `stimulusRef`. */
73
+ renderableId: string;
74
+ action: "attach" | "detach";
75
+ source?: MediaTimeSource;
76
+ /**
77
+ * Which producer this came from. `"native-adapter"` is the stimulus card
78
+ * wrapping a media element it found in its own subtree; anything else is a host
79
+ * wiring its own player, and omitting the field reads as `"host"` because a
80
+ * caller constructing this event by hand is one.
81
+ *
82
+ * Load-bearing for precedence: the card re-runs its discovery whenever its
83
+ * content changes, so without this a host that supplied a third-party port would
84
+ * have it silently replaced by the native element mid-session — and the
85
+ * capabilities would flip back with it, which is exactly the "appears to enforce"
86
+ * failure this contract exists to prevent.
87
+ */
88
+ origin?: "native-adapter" | "host";
89
+ }
@@ -4,3 +4,5 @@ export const PIE_INTERNAL_ITEM_SESSION_CHANGED_EVENT = "pie-item-session-changed
4
4
  export const PIE_ITEM_SESSION_CHANGED_EVENT = "item-session-changed";
5
5
  export const PIE_INTERNAL_CONTENT_LOADED_EVENT = "pie-content-loaded";
6
6
  export const PIE_INTERNAL_ITEM_PLAYER_ERROR_EVENT = "pie-item-player-error";
7
+ export const PIE_INTERNAL_FORMATIVE_ACTION_EVENT = "pie-formative-action";
8
+ export const PIE_INTERNAL_MEDIA_TIME_SOURCE_EVENT = "pie-media-time-source";
@@ -1,3 +1,4 @@
1
+ import { languageTagLookupSequence, normalizeLanguageTag, } from "@pie-players/pie-players-shared/i18n/language-tags";
1
2
  import { catalogOwnerContextFor, collectOwnerCatalogRegistrations, } from "./catalog-owner.js";
2
3
  import { sanitizeSsmlString } from "./SSMLExtractor.js";
3
4
  /**
@@ -537,14 +538,25 @@ export class AccessibilityCatalogResolver {
537
538
  */
538
539
  findMatchingCard(catalog, options) {
539
540
  const { type, language, useFallback = true, form } = options;
540
- // Language rungs, most specific first: requested language, then the default
541
- // language, then any. Unchanged — only what happens *within* a rung is new.
541
+ // Language rungs, most specific first: the requested language, then the
542
+ // default language, then any.
543
+ //
544
+ // Each requested tag expands into its RFC 4647 lookup sequence, so `es-MX`
545
+ // tries `es-mx` and then `es` before falling through to the default. Matching
546
+ // was `===`, which made a POSIX `es_ES` card — what the Learnosity transform
547
+ // emits — unreachable for an `es-ES` request except through the final
548
+ // no-constraint rung, i.e. by accident.
542
549
  const languageRungs = [];
550
+ const pushLookupRungs = (tag) => {
551
+ for (const step of languageTagLookupSequence(tag)) {
552
+ languageRungs.push((card) => normalizeLanguageTag(card.language) === step);
553
+ }
554
+ };
543
555
  if (language) {
544
- languageRungs.push((card) => card.language === language);
556
+ pushLookupRungs(language);
545
557
  }
546
558
  if (useFallback) {
547
- languageRungs.push((card) => card.language === this.defaultLanguage);
559
+ pushLookupRungs(this.defaultLanguage);
548
560
  languageRungs.push(() => true);
549
561
  }
550
562
  for (const matchesLanguage of languageRungs) {
@@ -586,7 +598,11 @@ export class AccessibilityCatalogResolver {
586
598
  // asking what alternates exist under-reported them.
587
599
  const claimed = new Set();
588
600
  const add = (card, source) => {
589
- const key = `${card.catalog}|${card.language ?? ""}|${catalogCardForm(card)}`;
601
+ // The language part is normalized, so a POSIX `es_ES` card and a BCP-47
602
+ // `es-ES` card collapse to one entry here exactly as they now collapse on
603
+ // the resolution path. Keying on the raw string would report two
604
+ // alternates where resolution can only ever return one.
605
+ const key = `${card.catalog}|${normalizeLanguageTag(card.language)}|${catalogCardForm(card)}`;
590
606
  if (claimed.has(key))
591
607
  return;
592
608
  claimed.add(key);