@pie-players/pie-players-shared 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 (87) hide show
  1. package/README.md +59 -0
  2. package/dist/formative/index.d.ts +16 -0
  3. package/dist/formative/index.js +5 -0
  4. package/dist/formative/mastery.d.ts +16 -0
  5. package/dist/formative/mastery.js +43 -0
  6. package/dist/formative/outcome.d.ts +19 -0
  7. package/dist/formative/outcome.js +90 -0
  8. package/dist/formative/policy.d.ts +37 -0
  9. package/dist/formative/policy.js +86 -0
  10. package/dist/formative/session.d.ts +19 -0
  11. package/dist/formative/session.js +113 -0
  12. package/dist/formative/state.d.ts +60 -0
  13. package/dist/formative/state.js +140 -0
  14. package/dist/formative/types.d.ts +164 -0
  15. package/dist/formative/types.js +13 -0
  16. package/dist/i18n/catalogs.d.ts +38 -0
  17. package/dist/i18n/catalogs.js +47 -0
  18. package/dist/i18n/index.d.ts +28 -4
  19. package/dist/i18n/index.js +34 -3
  20. package/dist/i18n/language-tags.d.ts +51 -0
  21. package/dist/i18n/language-tags.js +84 -0
  22. package/dist/i18n/messages/en-US.d.ts +601 -0
  23. package/dist/i18n/messages/en-US.js +617 -0
  24. package/dist/i18n/messages/nl-NL.d.ts +516 -0
  25. package/dist/i18n/messages/nl-NL.js +516 -0
  26. package/dist/i18n/provider.d.ts +168 -0
  27. package/dist/i18n/provider.js +472 -0
  28. package/dist/i18n/types.d.ts +177 -28
  29. package/dist/i18n/types.js +6 -2
  30. package/dist/index.d.ts +3 -1
  31. package/dist/index.js +2 -0
  32. package/dist/pie/custom-element-define.d.ts +24 -0
  33. package/dist/pie/custom-element-define.js +36 -6
  34. package/dist/pie/index.d.ts +1 -1
  35. package/dist/pie/index.js +1 -1
  36. package/dist/pie/versioned-tag.js +3 -2
  37. package/dist/security/sanitize-forbidden-lists.d.ts +8 -0
  38. package/dist/security/sanitize-forbidden-lists.js +41 -0
  39. package/dist/security/sanitize-item-markup.js +3 -36
  40. package/dist/security/sanitize-svg-icon.js +3 -25
  41. package/dist/security/wrap-overwide-images.d.ts +5 -10
  42. package/dist/security/wrap-overwide-images.js +13 -62
  43. package/dist/security/wrap-overwide-tables.d.ts +5 -10
  44. package/dist/security/wrap-overwide-tables.js +13 -62
  45. package/dist/security/wrap-overwide.d.ts +51 -0
  46. package/dist/security/wrap-overwide.js +91 -0
  47. package/dist/server/npm-auth-env.d.ts +13 -0
  48. package/dist/server/npm-auth-env.js +64 -0
  49. package/dist/timed-media/index.d.ts +17 -0
  50. package/dist/timed-media/index.js +16 -0
  51. package/dist/timed-media/media-element-source.d.ts +36 -0
  52. package/dist/timed-media/media-element-source.js +121 -0
  53. package/dist/timed-media/policy.d.ts +36 -0
  54. package/dist/timed-media/policy.js +297 -0
  55. package/dist/timed-media/session.d.ts +21 -0
  56. package/dist/timed-media/session.js +85 -0
  57. package/dist/timed-media/state.d.ts +66 -0
  58. package/dist/timed-media/state.js +357 -0
  59. package/dist/timed-media/types.d.ts +343 -0
  60. package/dist/timed-media/types.js +12 -0
  61. package/dist/tools/term-lookup.d.ts +189 -0
  62. package/dist/tools/term-lookup.js +284 -0
  63. package/dist/types/index.d.ts +105 -1
  64. package/dist/ui/attribute-coercion.d.ts +1 -0
  65. package/dist/ui/attribute-coercion.js +19 -0
  66. package/dist/ui/first-focusable.d.ts +22 -0
  67. package/dist/ui/first-focusable.js +47 -0
  68. package/dist/ui/focus-trap.js +7 -2
  69. package/dist/ui/pointer-drag.d.ts +30 -0
  70. package/dist/ui/pointer-drag.js +34 -0
  71. package/package.json +38 -6
  72. package/dist/i18n/loader.d.ts +0 -35
  73. package/dist/i18n/loader.js +0 -132
  74. package/dist/i18n/simple-i18n.d.ts +0 -68
  75. package/dist/i18n/simple-i18n.js +0 -198
  76. package/dist/i18n/translations/ar/common.json +0 -36
  77. package/dist/i18n/translations/ar/toolkit.json +0 -48
  78. package/dist/i18n/translations/ar/tools.json +0 -103
  79. package/dist/i18n/translations/en/common.json +0 -36
  80. package/dist/i18n/translations/en/toolkit.json +0 -48
  81. package/dist/i18n/translations/en/tools.json +0 -103
  82. package/dist/i18n/translations/es/common.json +0 -36
  83. package/dist/i18n/translations/es/toolkit.json +0 -48
  84. package/dist/i18n/translations/es/tools.json +0 -103
  85. package/dist/i18n/translations/zh/common.json +0 -36
  86. package/dist/i18n/translations/zh/toolkit.json +0 -48
  87. package/dist/i18n/translations/zh/tools.json +0 -103
@@ -0,0 +1,357 @@
1
+ /**
2
+ * The cue reduction: media position plus delivery state in, cue state and the
3
+ * effects the port must be driven with out.
4
+ *
5
+ * Pure and Node-safe, which is what makes cue policy testable without a browser
6
+ * and what keeps it out of every layout that hosts timed media. The caller owns
7
+ * the port and applies the effects; nothing here touches media.
8
+ */
9
+ export const TIMED_MEDIA_SLICE_VERSION = 1;
10
+ /**
11
+ * How far past the furthest reached position a seek may land before it is
12
+ * clamped. A native element reports `currentTime` from `timeupdate`, which fires
13
+ * about four times a second, so the furthest recorded position trails real
14
+ * playback by up to a quarter second; clamping at exactly the recorded value
15
+ * would fight a learner nudging the scrubber where they already are.
16
+ */
17
+ const SEEK_AHEAD_TOLERANCE_SECONDS = 0.5;
18
+ /** Media positions are compared in seconds; a millisecond is below resolution. */
19
+ const TIME_EPSILON_SECONDS = 1e-3;
20
+ /**
21
+ * How much new ground playback covers before the furthest position is worth
22
+ * republishing for.
23
+ *
24
+ * Position renders nowhere, so this exists only to bound what a reload hands back:
25
+ * `allowSeekAhead: false` clamps against the persisted `maxPositionSeconds`, and a
26
+ * section whose next cue is minutes away would otherwise persist nothing in
27
+ * between.
28
+ */
29
+ const POSITION_PERSIST_BUCKET_SECONDS = 10;
30
+ export function createTimedMediaState() {
31
+ return {
32
+ version: TIMED_MEDIA_SLICE_VERSION,
33
+ mediaCurrentTime: 0,
34
+ maxPositionSeconds: 0,
35
+ mediaCompleted: false,
36
+ visitedCueIdentifiers: [],
37
+ completedCueIdentifiers: [],
38
+ aggregateComplete: false,
39
+ };
40
+ }
41
+ function toFiniteSeconds(value, fallback) {
42
+ const numeric = Number(value);
43
+ if (!Number.isFinite(numeric) || numeric < 0)
44
+ return fallback;
45
+ return numeric;
46
+ }
47
+ /**
48
+ * A cue is reached once playback has passed its start, and stays reached.
49
+ *
50
+ * The end bound does not un-reach it, and seeking past a cue's whole window still
51
+ * reaches it. That is deliberate: the alternative lets a learner who is allowed
52
+ * to seek ahead jump over a checkpoint the author wrote, and a gate that can be
53
+ * skipped is not a gate.
54
+ */
55
+ function isCueReached(cue, seconds) {
56
+ return seconds + TIME_EPSILON_SECONDS >= cue.range.startSeconds;
57
+ }
58
+ /**
59
+ * Whether one item satisfies a gate condition.
60
+ *
61
+ * `undefined` correctness is "no Try yet" and never satisfies a correctness
62
+ * condition. `"unknown"` is an item no loaded controller can score, and the
63
+ * authored `onUnknownCorrectness` decides it — never collapsed into
64
+ * `"incorrect"`, which would hold a learner behind an item nothing can release.
65
+ */
66
+ function itemSatisfiesGate(cue, itemId, delivery) {
67
+ if (cue.releaseOn === "responded") {
68
+ return delivery.respondedByItemId[itemId] === true;
69
+ }
70
+ const correctness = delivery.correctnessByItemId[itemId];
71
+ if (correctness === undefined)
72
+ return false;
73
+ if (correctness === "unknown")
74
+ return cue.onUnknownCorrectness === "release";
75
+ if (cue.releaseOn === "correct")
76
+ return correctness === "correct";
77
+ return correctness === "correct" || correctness === "partial";
78
+ }
79
+ /** Every item a gate names has to satisfy it; one unanswered item still holds. */
80
+ function isGateReleased(cue, delivery) {
81
+ if (cue.itemRefs.length === 0)
82
+ return true;
83
+ return cue.itemRefs.every((itemId) => itemSatisfiesGate(cue, itemId, delivery));
84
+ }
85
+ function sameStringList(left, right) {
86
+ if (left.length !== right.length)
87
+ return false;
88
+ return left.every((value, index) => value === right[index]);
89
+ }
90
+ /**
91
+ * Advance cue state.
92
+ *
93
+ * Two things are monotonic by design. A cue stays visited and its items stay
94
+ * revealed once reached, because un-revealing an item a learner has already
95
+ * answered would take a response off the screen. And a released gate stays
96
+ * complete, so a retry that lowers correctness cannot re-trap a learner
97
+ * mid-playback — the same reasoning that keeps `firstCorrectTry` from being
98
+ * overwritten by a later incorrect Try.
99
+ */
100
+ export function reduceTimedMediaState(args) {
101
+ const { state, data, delivery, input } = args;
102
+ const effects = { pause: false };
103
+ let nextTime = state.mediaCurrentTime;
104
+ let mediaCompleted = state.mediaCompleted;
105
+ switch (input.kind) {
106
+ case "time":
107
+ nextTime = toFiniteSeconds(input.currentTimeSeconds, state.mediaCurrentTime);
108
+ break;
109
+ case "seek": {
110
+ const requested = toFiniteSeconds(input.currentTimeSeconds, state.mediaCurrentTime);
111
+ if (!data.playbackPolicy.allowSeekAhead &&
112
+ requested >
113
+ state.maxPositionSeconds + SEEK_AHEAD_TOLERANCE_SECONDS) {
114
+ effects.seekToSeconds = state.maxPositionSeconds;
115
+ nextTime = state.maxPositionSeconds;
116
+ }
117
+ else {
118
+ nextTime = requested;
119
+ }
120
+ break;
121
+ }
122
+ case "ended":
123
+ nextTime = toFiniteSeconds(input.currentTimeSeconds, state.mediaCurrentTime);
124
+ mediaCompleted = true;
125
+ break;
126
+ case "delivery-changed":
127
+ break;
128
+ }
129
+ const maxPositionSeconds = Math.max(state.maxPositionSeconds, nextTime);
130
+ const visitedCueIdentifiers = [...state.visitedCueIdentifiers];
131
+ const visitedSet = new Set(visitedCueIdentifiers);
132
+ for (const cue of data.cues) {
133
+ if (visitedSet.has(cue.identifier))
134
+ continue;
135
+ if (!isCueReached(cue, nextTime))
136
+ continue;
137
+ visitedSet.add(cue.identifier);
138
+ visitedCueIdentifiers.push(cue.identifier);
139
+ // Only the last activation of a reduction is reported: a seek that crosses
140
+ // several cues at once has one place for focus to go, and that is the
141
+ // furthest one, which is where the learner now is.
142
+ effects.activatedCueIdentifier = cue.identifier;
143
+ }
144
+ const completedSet = new Set(state.completedCueIdentifiers);
145
+ let holdingGate = null;
146
+ for (const cue of data.cues) {
147
+ if (!visitedSet.has(cue.identifier))
148
+ continue;
149
+ if (completedSet.has(cue.identifier))
150
+ continue;
151
+ if (!cue.holdsPlayback) {
152
+ // A reveal or metadata cue has no condition to satisfy, and a gate under
153
+ // `pauseOnRequiredCue: false` has had its hold switched off wholesale.
154
+ completedSet.add(cue.identifier);
155
+ continue;
156
+ }
157
+ if (isGateReleased(cue, delivery)) {
158
+ completedSet.add(cue.identifier);
159
+ effects.releasedCueIdentifier = cue.identifier;
160
+ continue;
161
+ }
162
+ // Authored order decides which of two open gates holds, and only the first
163
+ // one does — playback is stopped either way, and reporting both would give
164
+ // the layout two places to send focus.
165
+ if (!holdingGate)
166
+ holdingGate = cue;
167
+ }
168
+ const completedCueIdentifiers = data.cues
169
+ .map((cue) => cue.identifier)
170
+ .filter((identifier) => completedSet.has(identifier));
171
+ effects.pause = holdingGate !== null;
172
+ const activeCueIdentifier = holdingGate?.identifier ??
173
+ visitedCueIdentifiers[visitedCueIdentifiers.length - 1];
174
+ const aggregateComplete = completedCueIdentifiers.length === data.cues.length &&
175
+ delivery.itemsComplete &&
176
+ (data.playbackPolicy.requireMediaCompletion ? mediaCompleted : true);
177
+ const next = {
178
+ version: TIMED_MEDIA_SLICE_VERSION,
179
+ mediaCurrentTime: nextTime,
180
+ maxPositionSeconds,
181
+ mediaCompleted,
182
+ visitedCueIdentifiers,
183
+ completedCueIdentifiers,
184
+ activeCueIdentifier,
185
+ aggregateComplete,
186
+ };
187
+ const changed = next.mediaCurrentTime !== state.mediaCurrentTime ||
188
+ next.maxPositionSeconds !== state.maxPositionSeconds ||
189
+ next.mediaCompleted !== state.mediaCompleted ||
190
+ next.activeCueIdentifier !== state.activeCueIdentifier ||
191
+ next.aggregateComplete !== state.aggregateComplete ||
192
+ !sameStringList(next.visitedCueIdentifiers, state.visitedCueIdentifiers) ||
193
+ !sameStringList(next.completedCueIdentifiers, state.completedCueIdentifiers);
194
+ return { state: changed ? next : state, effects, changed };
195
+ }
196
+ /**
197
+ * Which policies the attached port can actually carry out.
198
+ *
199
+ * Absent a port there is nothing to degrade *from* yet, so an unattached section
200
+ * reports its authored intent rather than a capability gap it has not measured.
201
+ */
202
+ export function resolveTimedMediaEnforcement(args) {
203
+ const degradations = [];
204
+ const wantsPause = args.playbackPolicy.pauseOnRequiredCue && args.hasGate;
205
+ const wantsSeekLock = !args.playbackPolicy.allowSeekAhead;
206
+ const canPause = args.capabilities ? args.capabilities.canPause : true;
207
+ const canRestrictSeeking = args.capabilities
208
+ ? args.capabilities.canRestrictSeeking
209
+ : true;
210
+ if (wantsPause && !canPause) {
211
+ degradations.push({
212
+ policy: "pause-on-required-cue",
213
+ capability: "canPause",
214
+ message: "The media time source cannot pause, so gate cues are advisory: they fire and record state, and playback continues.",
215
+ });
216
+ }
217
+ if (wantsSeekLock && !canRestrictSeeking) {
218
+ degradations.push({
219
+ policy: "restrict-seek-ahead",
220
+ capability: "canRestrictSeeking",
221
+ message: "The media time source cannot restrict seeking, so allowSeekAhead: false is advisory: a learner can seek past cues.",
222
+ });
223
+ }
224
+ return {
225
+ pause: wantsPause && !canPause ? "advisory" : "enforced",
226
+ seek: wantsSeekLock && !canRestrictSeeking ? "advisory" : "enforced",
227
+ degradations,
228
+ };
229
+ }
230
+ /** The gate a layout renders, or `null` when nothing is holding. */
231
+ function resolveGateView(args) {
232
+ const visited = new Set(args.state.visitedCueIdentifiers);
233
+ const completed = new Set(args.state.completedCueIdentifiers);
234
+ for (const cue of args.data.cues) {
235
+ if (!cue.holdsPlayback || !cue.releaseOn)
236
+ continue;
237
+ if (!visited.has(cue.identifier))
238
+ continue;
239
+ if (completed.has(cue.identifier))
240
+ continue;
241
+ return {
242
+ cueIdentifier: cue.identifier,
243
+ itemRefs: [...cue.itemRefs],
244
+ releaseOn: cue.releaseOn,
245
+ holding: !isGateReleased(cue, args.delivery),
246
+ enforcement: args.pauseEnforcement,
247
+ };
248
+ }
249
+ return null;
250
+ }
251
+ /**
252
+ * Whether a cue decides when its items are delivered.
253
+ *
254
+ * A `metadata` cue does not: it records state and reveals nothing, so the items it
255
+ * names stay ordinary items. One predicate for both id sets below, because a cue
256
+ * counted as sequencing but never as revealing hides its items for the whole
257
+ * section.
258
+ */
259
+ function cueSequencesDelivery(cue) {
260
+ return cue.activation !== "metadata";
261
+ }
262
+ /** Canonical ids of every item the timeline sequences, in cue order. */
263
+ function resolveSequencedItemIds(data) {
264
+ const sequenced = [];
265
+ for (const cue of data.cues) {
266
+ if (!cueSequencesDelivery(cue))
267
+ continue;
268
+ for (const itemId of cue.itemRefs) {
269
+ if (!sequenced.includes(itemId))
270
+ sequenced.push(itemId);
271
+ }
272
+ }
273
+ return sequenced;
274
+ }
275
+ /** Canonical ids of items a visited cue has revealed, in cue order. */
276
+ function resolveRevealedItemIds(data, state) {
277
+ const visited = new Set(state.visitedCueIdentifiers);
278
+ const revealed = [];
279
+ for (const cue of data.cues) {
280
+ if (!cueSequencesDelivery(cue))
281
+ continue;
282
+ if (!visited.has(cue.identifier))
283
+ continue;
284
+ for (const itemId of cue.itemRefs) {
285
+ if (!revealed.includes(itemId))
286
+ revealed.push(itemId);
287
+ }
288
+ }
289
+ return revealed;
290
+ }
291
+ /**
292
+ * Derive the view layouts read. Recomputed from state on every republish rather
293
+ * than stored, so there is one source of truth and no mirror to go stale.
294
+ */
295
+ export function resolveTimedMediaProjection(args) {
296
+ const hasGate = args.data.cues.some((cue) => cue.activation === "gate");
297
+ const enforcement = resolveTimedMediaEnforcement({
298
+ playbackPolicy: args.data.playbackPolicy,
299
+ capabilities: args.capabilities,
300
+ hasGate,
301
+ });
302
+ return {
303
+ version: 1,
304
+ stimulusRenderableId: args.data.stimulusRenderableId,
305
+ cues: args.data.cues,
306
+ playbackPolicy: args.data.playbackPolicy,
307
+ mediaAttached: args.capabilities !== null,
308
+ enforcement: { pause: enforcement.pause, seek: enforcement.seek },
309
+ degradations: enforcement.degradations,
310
+ revealedItemIds: resolveRevealedItemIds(args.data, args.state),
311
+ sequencedItemIds: resolveSequencedItemIds(args.data),
312
+ activeCueIdentifier: args.state.activeCueIdentifier,
313
+ gate: resolveGateView({
314
+ data: args.data,
315
+ state: args.state,
316
+ delivery: args.delivery,
317
+ pauseEnforcement: enforcement.pause,
318
+ }),
319
+ visitedCueIdentifiers: [...args.state.visitedCueIdentifiers],
320
+ completedCueIdentifiers: [...args.state.completedCueIdentifiers],
321
+ mediaCurrentTime: args.state.mediaCurrentTime,
322
+ maxPositionSeconds: args.state.maxPositionSeconds,
323
+ mediaCompleted: args.state.mediaCompleted,
324
+ aggregateComplete: args.state.aggregateComplete === true,
325
+ };
326
+ }
327
+ /**
328
+ * The projection facts that change what a layout renders, as one string.
329
+ *
330
+ * One implementation for the two callers that have to agree: `SectionController`
331
+ * decides whether to emit on it, and the toolkit folds it into the composition
332
+ * revision key. Two encodings drift silently in either direction — the controller
333
+ * emits a change the toolkit coalesces away, or the toolkit republishes for a
334
+ * change the controller never announced.
335
+ *
336
+ * `mediaCurrentTime` is absent because it moves about four times a second and
337
+ * nothing renders it. `maxPositionSeconds` enters only as a coarse bucket; see
338
+ * `POSITION_PERSIST_BUCKET_SECONDS`.
339
+ */
340
+ export function timedMediaProjectionSignature(projection) {
341
+ if (!projection)
342
+ return "";
343
+ return [
344
+ (projection.visitedCueIdentifiers ?? []).join(","),
345
+ (projection.completedCueIdentifiers ?? []).join(","),
346
+ (projection.revealedItemIds ?? []).join(","),
347
+ projection.activeCueIdentifier ?? "",
348
+ projection.gate?.cueIdentifier ?? "",
349
+ projection.gate?.holding === true ? "1" : "0",
350
+ projection.enforcement?.pause ?? "",
351
+ projection.enforcement?.seek ?? "",
352
+ projection.mediaAttached === true ? "1" : "0",
353
+ projection.mediaCompleted === true ? "1" : "0",
354
+ projection.aggregateComplete === true ? "1" : "0",
355
+ String(Math.floor((projection.maxPositionSeconds ?? 0) / POSITION_PERSIST_BUCKET_SECONDS)),
356
+ ].join(":");
357
+ }
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Timed-media section vocabulary.
3
+ *
4
+ * A **Cue** is a window on a media timeline that activates one or more of the
5
+ * section's item refs. A **Media Time Source** is the port the section reaches
6
+ * media through — never a library API, so a host can supply its own media
7
+ * element without shipping a PIE element.
8
+ *
9
+ * See `docs/prds/timed-media-section-contract.md` for the ratified contract and
10
+ * `docs/architecture/timed-media-section.md` for the layer-ownership record.
11
+ */
12
+ import type { FormativeCorrectness } from "../formative/types.js";
13
+ import type { MediaFragmentRange } from "../types/index.js";
14
+ /**
15
+ * What a cue does to the items it names.
16
+ *
17
+ * - `reveal`: the items become visible when the media reaches the cue.
18
+ * - `gate`: as `reveal`, and playback is held until the release condition holds.
19
+ * - `metadata`: state and events only — an author-visible timeline marker that
20
+ * reveals nothing and gates nothing.
21
+ */
22
+ export type TimedMediaCueActivation = "reveal" | "gate" | "metadata";
23
+ /**
24
+ * The condition over delivery state that releases a gate.
25
+ *
26
+ * Named from the shipped formative vocabulary rather than defined here, which is
27
+ * the whole reason formative delivery sequenced first — see
28
+ * `docs/adr/0001-formative-delivery-before-timed-media.md`. `"responded"` is the
29
+ * response-only case, and it reads item completion rather than Try state so a
30
+ * gate works in a section that does not deliver formatively at all.
31
+ */
32
+ export type TimedMediaGateCondition = "responded" | "correct" | "partial-or-better";
33
+ /**
34
+ * What a gate does when the item cannot be auto-scored — `FormativeCorrectness`
35
+ * of `"unknown"`, which is the honest state of an item holding a rubric element
36
+ * rather than a defect to design around.
37
+ *
38
+ * Required on a gate whose condition is a correctness condition; see
39
+ * `normalizeTimedMediaSectionData`. Not defaulted, because both answers are
40
+ * defensible and the wrong silent one either traps a learner behind an item
41
+ * nothing can score or waves through the checkpoint the author wrote.
42
+ */
43
+ export type TimedMediaUnknownCorrectness = "release" | "hold";
44
+ export interface TimedMediaCuePolicy {
45
+ activation: TimedMediaCueActivation;
46
+ /** Required for `activation: "gate"`; ignored otherwise. */
47
+ releaseOn?: TimedMediaGateCondition;
48
+ /** Required when `releaseOn` names correctness; ignored otherwise. */
49
+ onUnknownCorrectness?: TimedMediaUnknownCorrectness;
50
+ }
51
+ export interface TimedMediaCue {
52
+ identifier: string;
53
+ /**
54
+ * The window in which this cue is active, reusing the ratified range type. A
55
+ * point cue omits `endSeconds`.
56
+ *
57
+ * Carried beside the asset rather than inside `MediaAssetRef` because a range
58
+ * describes a *use* of an asset. Here that use is activation, not a slice to
59
+ * play, so it implies no seeking — the opposite of what the two shipped
60
+ * catalog consumers read the same shape as.
61
+ */
62
+ range: MediaFragmentRange;
63
+ /** Identifiers of this section's `assessmentItemRefs`. */
64
+ itemRefs: string[];
65
+ policy: TimedMediaCuePolicy;
66
+ }
67
+ export interface TimedMediaPlaybackPolicy {
68
+ /** `false` clamps a forward seek to the furthest position already reached. */
69
+ allowSeekAhead: boolean;
70
+ /** `false` turns every `gate` cue into a `reveal`: nothing holds playback. */
71
+ pauseOnRequiredCue: boolean;
72
+ /** Whether `aggregateComplete` requires the media to have ended. */
73
+ requireMediaCompletion: boolean;
74
+ }
75
+ /**
76
+ * Authored scoring intent. Accepted, validated and persisted; PIE computes no
77
+ * aggregate outcome from it yet and supplies no default, so a section that omits
78
+ * it is not silently assigned one.
79
+ *
80
+ * No `weighted-child-outcomes`: no weight is authorable on a cue, an item ref or
81
+ * the section, so the strategy would name a capability PIE does not have. Where
82
+ * weights live is the score contract's question, and a host that already has its
83
+ * own weights says `host-defined`.
84
+ */
85
+ export interface TimedMediaScoringPolicy {
86
+ strategy: "sum-child-outcomes" | "average-child-outcomes" | "host-defined";
87
+ }
88
+ /**
89
+ * The authored `timedMedia` payload, carried on `AssessmentSection`.
90
+ *
91
+ * No media payload: `stimulusRef` names the renderable that supplies the time
92
+ * source, authored as a `class: "stimulus"` rubric block whose passage config
93
+ * mounts the media element. The passage owns the asset and its accessibility
94
+ * catalogs, because a passage is a Catalog Owner and a media blob is not.
95
+ */
96
+ export interface TimedMediaSectionData {
97
+ /**
98
+ * The rubric block identifier or passage id of the stimulus renderable.
99
+ * Required, and validated to resolve within this section: a section carrying
100
+ * cues with no resolvable stimulus is malformed, because the alternative is
101
+ * cues that silently never fire.
102
+ */
103
+ stimulusRef: string;
104
+ cues: TimedMediaCue[];
105
+ playbackPolicy: TimedMediaPlaybackPolicy;
106
+ scoringPolicy?: TimedMediaScoringPolicy;
107
+ }
108
+ /** A cue after validation, with every optional field decided. */
109
+ export interface ResolvedTimedMediaCue {
110
+ identifier: string;
111
+ range: MediaFragmentRange;
112
+ /** Filtered to item refs this section actually holds. */
113
+ itemRefs: string[];
114
+ activation: TimedMediaCueActivation;
115
+ /** Present only for a cue that holds playback. */
116
+ releaseOn?: TimedMediaGateCondition;
117
+ onUnknownCorrectness?: TimedMediaUnknownCorrectness;
118
+ /**
119
+ * Whether this cue holds playback: `activation: "gate"` and a playback policy
120
+ * that has not switched gating off wholesale.
121
+ */
122
+ holdsPlayback: boolean;
123
+ }
124
+ /** The authored data after validation. */
125
+ export interface ResolvedTimedMediaSectionData {
126
+ stimulusRef: string;
127
+ /** The renderable id the stimulus resolved to, which the layout renders. */
128
+ stimulusRenderableId: string;
129
+ cues: ResolvedTimedMediaCue[];
130
+ playbackPolicy: TimedMediaPlaybackPolicy;
131
+ scoringPolicy?: TimedMediaScoringPolicy;
132
+ }
133
+ /**
134
+ * What a gated item's resolved formative policy leaves the learner.
135
+ *
136
+ * Read by validation, not by the reduction: a gate on correctness over an item
137
+ * with a finite Try budget is a dead end, and so is one over an item that does not
138
+ * deliver formatively at all — no Try means no correctness, ever.
139
+ */
140
+ export type TimedMediaItemTryBudget = "unlimited" | "finite" | "not-formative";
141
+ export interface TimedMediaValidationError {
142
+ /** Machine-readable, so a host can branch without parsing prose. */
143
+ code: "missing-stimulus-ref" | "unresolved-stimulus-ref"
144
+ /**
145
+ * Reported at runtime rather than by validation: `stimulusRef` resolved, but
146
+ * the renderable it names exposed no Media Time Source, so no cue can fire.
147
+ * Whether a renderable mounts media is not knowable from authored data.
148
+ */
149
+ | "stimulus-exposes-no-time-source" | "no-cues" | "duplicate-cue-identifier" | "invalid-cue-identifier" | "invalid-cue-range" | "invalid-cue-activation" | "unknown-item-ref" | "missing-item-refs" | "missing-release-condition" | "missing-unknown-correctness" | "gate-requires-unlimited-tries" | "invalid-playback-policy" | "invalid-scoring-policy";
150
+ message: string;
151
+ /** The cue the error is about, where it is about one. */
152
+ cueIdentifier?: string;
153
+ }
154
+ export interface TimedMediaValidationResult {
155
+ data: ResolvedTimedMediaSectionData | null;
156
+ errors: TimedMediaValidationError[];
157
+ }
158
+ /**
159
+ * What the adapter can actually do, as opposed to what the section would like.
160
+ *
161
+ * Declared by the port rather than assumed by the section: a third-party embed
162
+ * commonly exposes time and nothing else. Where a capability is missing the
163
+ * matching policy degrades from enforced to advisory — cues still fire and state
164
+ * is still recorded — and the degradation is reported as a recoverable framework
165
+ * warning, because a seek lock that does not lock reads to an author as one that
166
+ * does.
167
+ */
168
+ export interface MediaTimeSourceCapabilities {
169
+ canPause: boolean;
170
+ canRestrictSeeking: boolean;
171
+ }
172
+ /** Structurally `TimeRanges`, so a native media element satisfies it as-is. */
173
+ export interface MediaTimeRanges {
174
+ readonly length: number;
175
+ start(index: number): number;
176
+ end(index: number): number;
177
+ }
178
+ export type MediaTimeSourceNotification = {
179
+ type: "time";
180
+ currentTime: number;
181
+ } | {
182
+ type: "seek";
183
+ currentTime: number;
184
+ } | {
185
+ type: "play";
186
+ currentTime: number;
187
+ } | {
188
+ type: "pause";
189
+ currentTime: number;
190
+ } | {
191
+ type: "ended";
192
+ currentTime: number;
193
+ };
194
+ /**
195
+ * The only way the section reaches media.
196
+ *
197
+ * Shaped after `HTMLMediaElement` deliberately: the browser's own surface is the
198
+ * one every adapter author already knows, and a native `<video>` satisfies it
199
+ * through the few lines in `createMediaElementTimeSource`. That is what keeps the
200
+ * media-player dependency question reversible instead of load-bearing.
201
+ *
202
+ * Two departures from the element, both to make capability honest. Seeking is
203
+ * `seekTo(seconds)` rather than a writable `currentTime`, because a writable
204
+ * property gives a source that cannot seek no way to say so; and `capabilities`
205
+ * has no element equivalent at all.
206
+ */
207
+ export interface MediaTimeSource {
208
+ readonly currentTime: number;
209
+ /** `NaN` until metadata loads, as on the element. */
210
+ readonly duration: number;
211
+ readonly paused: boolean;
212
+ readonly seekable: MediaTimeRanges | null;
213
+ readonly capabilities: MediaTimeSourceCapabilities;
214
+ play(): Promise<void> | void;
215
+ pause(): void;
216
+ seekTo(seconds: number): void;
217
+ subscribe(listener: (notification: MediaTimeSourceNotification) => void): () => void;
218
+ }
219
+ /**
220
+ * The persisted slice, carried on `SectionControllerSessionState.timedMedia`.
221
+ *
222
+ * One shape live and persisted, unlike formative's split between a per-item
223
+ * reducer state and a slice: timed-media state is section-scoped, so there is
224
+ * nothing to key.
225
+ */
226
+ export interface TimedMediaSectionSessionSlice {
227
+ version: 1;
228
+ mediaCurrentTime: number;
229
+ /**
230
+ * Furthest position reached, which is what `allowSeekAhead: false` clamps
231
+ * against. Persisted rather than derived because a reload would otherwise hand
232
+ * the learner the whole timeline back — an addition to the contract's session
233
+ * sketch, recorded in the PRD.
234
+ */
235
+ maxPositionSeconds: number;
236
+ mediaCompleted: boolean;
237
+ visitedCueIdentifiers: string[];
238
+ completedCueIdentifiers: string[];
239
+ activeCueIdentifier?: string;
240
+ aggregateComplete?: boolean;
241
+ }
242
+ /**
243
+ * Delivery state a gate condition reads, supplied by the caller so this module
244
+ * stays pure and depends on neither the controller nor the formative module's
245
+ * live state.
246
+ */
247
+ export interface TimedMediaDeliveryState {
248
+ /** Item completion by canonical id: the `"responded"` condition's signal. */
249
+ respondedByItemId: Record<string, boolean>;
250
+ /** Last Try correctness by canonical id; absent means no Try yet. */
251
+ correctnessByItemId: Record<string, FormativeCorrectness | undefined>;
252
+ /** Whether every item in the section reports completion. */
253
+ itemsComplete: boolean;
254
+ }
255
+ export type TimedMediaInput = {
256
+ kind: "time";
257
+ currentTimeSeconds: number;
258
+ } | {
259
+ kind: "seek";
260
+ currentTimeSeconds: number;
261
+ } | {
262
+ kind: "ended";
263
+ currentTimeSeconds?: number;
264
+ }
265
+ /** Delivery state changed under a held gate; re-evaluate releases. */
266
+ | {
267
+ kind: "delivery-changed";
268
+ };
269
+ /**
270
+ * What the caller must do to the port after a reduction. Advisory when the port
271
+ * lacks the capability, which the caller reports rather than silently skipping.
272
+ */
273
+ export interface TimedMediaEffects {
274
+ /** A gate is holding and playback should stop. */
275
+ pause: boolean;
276
+ /** A forward seek exceeded policy and should be clamped here. */
277
+ seekToSeconds?: number;
278
+ /** Cue that activated in this reduction, for focus and announcement. */
279
+ activatedCueIdentifier?: string;
280
+ /** Cue whose gate released in this reduction, for announcement. */
281
+ releasedCueIdentifier?: string;
282
+ }
283
+ export interface TimedMediaReduction {
284
+ state: TimedMediaSectionSessionSlice;
285
+ effects: TimedMediaEffects;
286
+ /** `false` when the reduction produced the state it was given. */
287
+ changed: boolean;
288
+ }
289
+ export type TimedMediaEnforcement = "enforced" | "advisory";
290
+ export interface TimedMediaDegradation {
291
+ policy: "pause-on-required-cue" | "restrict-seek-ahead";
292
+ /** The capability the port does not have. */
293
+ capability: keyof MediaTimeSourceCapabilities;
294
+ message: string;
295
+ }
296
+ export interface TimedMediaGateView {
297
+ cueIdentifier: string;
298
+ itemRefs: string[];
299
+ releaseOn: TimedMediaGateCondition;
300
+ /** The gate is active and its condition does not hold. */
301
+ holding: boolean;
302
+ /** Whether holding actually stops playback, or is merely recorded. */
303
+ enforcement: TimedMediaEnforcement;
304
+ }
305
+ /**
306
+ * What layouts read off the composition model. Derived, never persisted — the
307
+ * slice above is the state, this is the view of it.
308
+ */
309
+ export interface TimedMediaSectionProjection {
310
+ version: 1;
311
+ stimulusRenderableId: string;
312
+ cues: ResolvedTimedMediaCue[];
313
+ playbackPolicy: TimedMediaPlaybackPolicy;
314
+ /** Whether a media time source is attached; cues cannot fire without one. */
315
+ mediaAttached: boolean;
316
+ enforcement: {
317
+ pause: TimedMediaEnforcement;
318
+ seek: TimedMediaEnforcement;
319
+ };
320
+ degradations: TimedMediaDegradation[];
321
+ /** Canonical ids of items whose cue has activated. Monotonic. */
322
+ revealedItemIds: string[];
323
+ /**
324
+ * Canonical ids of every item the timeline sequences — named by a `reveal` or a
325
+ * `gate` cue. An item outside this set is delivered normally, including one a
326
+ * `metadata` cue names: metadata records state and reveals nothing, so naming an
327
+ * item on one must not take that item out of delivery.
328
+ */
329
+ sequencedItemIds: string[];
330
+ activeCueIdentifier?: string;
331
+ gate: TimedMediaGateView | null;
332
+ visitedCueIdentifiers: string[];
333
+ completedCueIdentifiers: string[];
334
+ mediaCurrentTime: number;
335
+ maxPositionSeconds: number;
336
+ mediaCompleted: boolean;
337
+ /**
338
+ * Every required cue complete, every item complete, and — where the policy
339
+ * requires it — the media ended. Deliberately three separate facts rather than
340
+ * one: a section can be item-complete with the video half watched.
341
+ */
342
+ aggregateComplete: boolean;
343
+ }