@intellectif/lk-react 6.1.0 → 7.0.0

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 (72) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +2 -0
  3. package/dist/{WrittenResponse-BXHidIaK.d.cts → WrittenResponse-BJL4dGsR.d.ts} +5 -1
  4. package/dist/{WrittenResponse-DJYGuMKk.d.ts → WrittenResponse-zWT-Go1g.d.cts} +5 -1
  5. package/dist/chunk-6ZKVJAO6.js +540 -0
  6. package/dist/chunk-6ZKVJAO6.js.map +1 -0
  7. package/dist/chunk-A572N5G7.cjs +542 -0
  8. package/dist/chunk-A572N5G7.cjs.map +1 -0
  9. package/dist/{chunk-UQ3BBEIT.js → chunk-BG456EM3.js} +21 -5
  10. package/dist/chunk-BG456EM3.js.map +1 -0
  11. package/dist/{chunk-BE7R2X3S.cjs → chunk-BNESCXJ5.cjs} +16 -4
  12. package/dist/chunk-BNESCXJ5.cjs.map +1 -0
  13. package/dist/{chunk-MDZMKC27.js → chunk-CMZGT26S.js} +21 -4
  14. package/dist/chunk-CMZGT26S.js.map +1 -0
  15. package/dist/{chunk-57E3E2H4.js → chunk-J23XVR2B.js} +16 -4
  16. package/dist/chunk-J23XVR2B.js.map +1 -0
  17. package/dist/{chunk-DUIWK272.cjs → chunk-J4LP4CLY.cjs} +109 -29
  18. package/dist/chunk-J4LP4CLY.cjs.map +1 -0
  19. package/dist/{chunk-NEWGUDA5.cjs → chunk-KK4WGW2V.cjs} +21 -5
  20. package/dist/chunk-KK4WGW2V.cjs.map +1 -0
  21. package/dist/{chunk-MDYKYDRN.cjs → chunk-KPI335JJ.cjs} +21 -4
  22. package/dist/chunk-KPI335JJ.cjs.map +1 -0
  23. package/dist/{chunk-ZLUMUJUQ.js → chunk-MB532SXM.js} +16 -4
  24. package/dist/chunk-MB532SXM.js.map +1 -0
  25. package/dist/{chunk-NPY2F7F6.cjs → chunk-VXOOGQ6W.cjs} +16 -4
  26. package/dist/chunk-VXOOGQ6W.cjs.map +1 -0
  27. package/dist/{chunk-IUODGQXE.js → chunk-ZPVMB3LH.js} +107 -27
  28. package/dist/chunk-ZPVMB3LH.js.map +1 -0
  29. package/dist/components/ActivitySequence.cjs +7 -7
  30. package/dist/components/ActivitySequence.d.cts +13 -15
  31. package/dist/components/ActivitySequence.d.ts +13 -15
  32. package/dist/components/ActivitySequence.js +6 -6
  33. package/dist/components/FillInTheBlanks.cjs +3 -3
  34. package/dist/components/FillInTheBlanks.d.cts +1 -1
  35. package/dist/components/FillInTheBlanks.d.ts +1 -1
  36. package/dist/components/FillInTheBlanks.js +2 -2
  37. package/dist/components/MultipleChoice.cjs +3 -3
  38. package/dist/components/MultipleChoice.d.cts +1 -1
  39. package/dist/components/MultipleChoice.d.ts +1 -1
  40. package/dist/components/MultipleChoice.js +2 -2
  41. package/dist/components/StimulusPanel.cjs +3 -3
  42. package/dist/components/StimulusPanel.d.cts +19 -3
  43. package/dist/components/StimulusPanel.d.ts +19 -3
  44. package/dist/components/StimulusPanel.js +2 -2
  45. package/dist/components/WrittenResponse.cjs +3 -3
  46. package/dist/components/WrittenResponse.d.cts +3 -3
  47. package/dist/components/WrittenResponse.d.ts +3 -3
  48. package/dist/components/WrittenResponse.js +2 -2
  49. package/dist/index.cjs +11 -11
  50. package/dist/index.cjs.map +1 -1
  51. package/dist/index.d.cts +2 -2
  52. package/dist/index.d.ts +2 -2
  53. package/dist/index.js +6 -6
  54. package/dist/index.js.map +1 -1
  55. package/dist/theme/skin.css +204 -1
  56. package/dist/{types-9iS1Vs2I.d.ts → types-eXX4hzSj.d.cts} +128 -2
  57. package/dist/{types-9iS1Vs2I.d.cts → types-eXX4hzSj.d.ts} +128 -2
  58. package/package.json +3 -3
  59. package/dist/chunk-4HZ7LQIH.cjs +0 -41
  60. package/dist/chunk-4HZ7LQIH.cjs.map +0 -1
  61. package/dist/chunk-57E3E2H4.js.map +0 -1
  62. package/dist/chunk-BE7R2X3S.cjs.map +0 -1
  63. package/dist/chunk-DUIWK272.cjs.map +0 -1
  64. package/dist/chunk-GB5URWM4.js +0 -39
  65. package/dist/chunk-GB5URWM4.js.map +0 -1
  66. package/dist/chunk-IUODGQXE.js.map +0 -1
  67. package/dist/chunk-MDYKYDRN.cjs.map +0 -1
  68. package/dist/chunk-MDZMKC27.js.map +0 -1
  69. package/dist/chunk-NEWGUDA5.cjs.map +0 -1
  70. package/dist/chunk-NPY2F7F6.cjs.map +0 -1
  71. package/dist/chunk-UQ3BBEIT.js.map +0 -1
  72. package/dist/chunk-ZLUMUJUQ.js.map +0 -1
@@ -174,7 +174,7 @@
174
174
  transition: var(--lk-transition-fast);
175
175
  }
176
176
 
177
- .lk-mc-option:hover {
177
+ .lk-mc-option:not([data-correct]):hover {
178
178
  border-color: var(--lk-color-primary);
179
179
  background: var(--lk-color-surface-raised);
180
180
  }
@@ -188,14 +188,68 @@
188
188
  cursor: default;
189
189
  }
190
190
 
191
+ .lk-mc-option > span:first-of-type {
192
+ flex: 1 1 0%;
193
+ min-inline-size: 12ch;
194
+ overflow-wrap: anywhere;
195
+ }
196
+
197
+ /* -- Post-submit marking ---------------------------------------------
198
+ Hue says correctness; weight says whether the LEARNER chose it. Both
199
+ correct states are green, because it is the same fact, but only the
200
+ chosen one is filled, so "you got it" and "the key was here" cannot be
201
+ confused. The glyphs exist for WCAG 1.4.1: colour must not be the only
202
+ carrier, and the component emits no correctness text of its own. They
203
+ are declared decorative (the `/ ""` alt text) because the translatable
204
+ announcement belongs to the component, not to a stylesheet that would
205
+ bake English into every locale. */
206
+
207
+ /* The answer was here and the learner did not pick it: outlined, not filled. */
191
208
  .lk-mc-option[data-correct="true"] {
192
209
  border-color: var(--lk-color-success);
210
+ border-style: dashed;
211
+ box-shadow: none;
212
+ }
213
+
214
+ .lk-mc-option[data-correct="true"]::after {
215
+ content: "○" / "";
216
+ margin-inline-start: auto;
217
+ font-weight: var(--lk-font-weight-bold);
218
+ color: var(--lk-color-success);
219
+ }
220
+
221
+ /* Chosen AND right: the strongest mark. At (0,3,1) this outranks
222
+ `:has(input:checked)`, which is the whole reason it has to exist. */
223
+ .lk-mc-option[data-correct="true"]:has(input:checked) {
224
+ border-color: var(--lk-color-success);
225
+ border-style: solid;
193
226
  box-shadow: inset 0 0 0 1px var(--lk-color-success);
227
+ background: color-mix(in srgb, var(--lk-color-success) 10%, var(--lk-color-surface));
194
228
  }
195
229
 
230
+ .lk-mc-option[data-correct="true"]:has(input:checked)::after {
231
+ content: "✓" / "";
232
+ }
233
+
234
+ /* Chosen and wrong. */
196
235
  .lk-mc-option[data-correct="false"]:has(input:checked) {
197
236
  border-color: var(--lk-color-error);
198
237
  box-shadow: inset 0 0 0 1px var(--lk-color-error);
238
+ background: color-mix(in srgb, var(--lk-color-error) 8%, var(--lk-color-surface));
239
+ }
240
+
241
+ .lk-mc-option[data-correct="false"]:has(input:checked)::after {
242
+ content: "✗" / "";
243
+ margin-inline-start: auto;
244
+ font-weight: var(--lk-font-weight-bold);
245
+ color: var(--lk-color-error);
246
+ }
247
+
248
+ /* A distractor nobody picked recedes so the three marks carry the eye.
249
+ 0.7 keeps the default text at 6.6:1 on the default surface (AA);
250
+ 0.55 would drop it to 3.9:1 and fail. */
251
+ .lk-mc-option[data-correct="false"]:not(:has(input:checked)) {
252
+ opacity: 0.7;
199
253
  }
200
254
 
201
255
  .lk-mc-option input {
@@ -559,3 +613,152 @@
559
613
  color: var(--lk-color-warning);
560
614
  }
561
615
  }
616
+
617
+ /*
618
+ * The SDK's own audio transport, rendered whenever a playback policy has
619
+ * something to enforce. It replaces the browser's control bar, so it has to be
620
+ * at least as operable: 44px targets (WCAG 2.5.8), visible focus, and controls
621
+ * that never disappear just because a budget is spent.
622
+ */
623
+ @layer lk-skin {
624
+ .lk-media[data-controls="minimal"] {
625
+ margin: 0 0 var(--lk-spacing-md);
626
+ padding: var(--lk-spacing-sm) var(--lk-spacing-md);
627
+ background: var(--lk-color-surface-raised);
628
+ border: 1px solid var(--lk-color-border);
629
+ border-radius: var(--lk-radius-base);
630
+ }
631
+
632
+ .lk-media-transport {
633
+ display: flex;
634
+ flex-wrap: wrap;
635
+ align-items: center;
636
+ gap: var(--lk-spacing-sm);
637
+ }
638
+
639
+ .lk-media-play,
640
+ .lk-media-mute {
641
+ min-block-size: 44px;
642
+ min-inline-size: 44px;
643
+ padding: var(--lk-spacing-xs) var(--lk-spacing-md);
644
+ font: inherit;
645
+ font-weight: var(--lk-font-weight-bold);
646
+ color: var(--lk-color-surface);
647
+ background: var(--lk-color-primary);
648
+ border: 0;
649
+ border-radius: var(--lk-radius-base);
650
+ cursor: pointer;
651
+ transition: var(--lk-transition-fast);
652
+ }
653
+
654
+ .lk-media-mute {
655
+ color: var(--lk-color-text);
656
+ background: transparent;
657
+ border: 1px solid var(--lk-color-border);
658
+ }
659
+
660
+ .lk-media-play:hover {
661
+ background: var(--lk-color-primary-hover);
662
+ }
663
+
664
+ /*
665
+ * `aria-disabled`, never `disabled`: the button stays focusable so a learner
666
+ * who tabs to it is told why it will not start, and pressing it re-announces
667
+ * the refusal. Styled as unavailable without leaving the focus order.
668
+ */
669
+ .lk-media-play[aria-disabled="true"] {
670
+ background: var(--lk-color-text-muted);
671
+ cursor: not-allowed;
672
+ }
673
+
674
+ .lk-media-play:focus-visible,
675
+ .lk-media-mute:focus-visible,
676
+ .lk-media-scrub:focus-visible,
677
+ .lk-media-volume:focus-visible,
678
+ .lk-media-rate:focus-visible {
679
+ outline: 2px solid var(--lk-color-focus-ring);
680
+ outline-offset: 2px;
681
+ }
682
+
683
+ .lk-media-time {
684
+ font-size: var(--lk-font-size-sm);
685
+ font-variant-numeric: tabular-nums;
686
+ color: var(--lk-color-text-muted);
687
+ }
688
+
689
+ .lk-media-scrub {
690
+ flex: 1 1 8rem;
691
+ }
692
+
693
+ .lk-media-volume {
694
+ inline-size: 6rem;
695
+ }
696
+
697
+ .lk-media-rate {
698
+ min-block-size: 44px;
699
+ padding: var(--lk-spacing-xs);
700
+ font: inherit;
701
+ color: var(--lk-color-text);
702
+ background: var(--lk-color-surface);
703
+ border: 1px solid var(--lk-color-border);
704
+ border-radius: var(--lk-radius-sm);
705
+ }
706
+
707
+ .lk-media-plays {
708
+ margin: var(--lk-spacing-xs) 0 0;
709
+ font-size: var(--lk-font-size-sm);
710
+ color: var(--lk-color-text-muted);
711
+ }
712
+
713
+ /* A refusal is the highest-stakes text on a listening paper. */
714
+ .lk-media-notice:not(:empty) {
715
+ margin: var(--lk-spacing-xs) 0 0;
716
+ font-size: var(--lk-font-size-sm);
717
+ color: var(--lk-color-warning);
718
+ }
719
+
720
+ .lk-media-confirm {
721
+ display: flex;
722
+ flex-wrap: wrap;
723
+ align-items: center;
724
+ gap: var(--lk-spacing-sm);
725
+ margin: var(--lk-spacing-sm) 0 0;
726
+ padding: var(--lk-spacing-sm);
727
+ border: 1px solid var(--lk-color-border);
728
+ border-radius: var(--lk-radius-base);
729
+ }
730
+
731
+ .lk-media-confirm legend {
732
+ padding-inline: var(--lk-spacing-xs);
733
+ font-size: var(--lk-font-size-sm);
734
+ color: var(--lk-color-text);
735
+ }
736
+
737
+ .lk-media-confirm-start,
738
+ .lk-media-confirm-cancel {
739
+ min-block-size: 44px;
740
+ padding: var(--lk-spacing-xs) var(--lk-spacing-md);
741
+ font: inherit;
742
+ border-radius: var(--lk-radius-base);
743
+ cursor: pointer;
744
+ }
745
+
746
+ .lk-media-confirm-start {
747
+ color: var(--lk-color-surface);
748
+ background: var(--lk-color-primary);
749
+ border: 0;
750
+ }
751
+
752
+ .lk-media-confirm-cancel {
753
+ color: var(--lk-color-text);
754
+ background: transparent;
755
+ border: 1px solid var(--lk-color-border);
756
+ }
757
+
758
+ @media (prefers-reduced-motion: reduce) {
759
+ .lk-media-play,
760
+ .lk-media-mute {
761
+ transition: none;
762
+ }
763
+ }
764
+ }
@@ -1,4 +1,4 @@
1
- import { ActivityData, ActivityResult, LearnerResponse, ItemOutcome, InteractionEvent, ThemeTokens, RedactedActivityData, ItemGroup, SequenceEntry } from '@intellectif/lk-core';
1
+ import { ActivityData, ActivityResult, LearnerResponse, ItemOutcome, MediaPlayLedgerEntry, MediaPlayClaim, MediaPlayGrant, InteractionEvent, ThemeTokens, RedactedActivityData, ItemGroup, SequenceEntry } from '@intellectif/lk-core';
2
2
 
3
3
  /**
4
4
  * How an activity is being presented. This is the single switch that decides
@@ -108,6 +108,14 @@ interface ActivityProps<TData extends ActivityData = ActivityData> {
108
108
  outcome?: ItemOutcome;
109
109
  /** Renders author-supplied rich text when provided. See {@link HtmlSanitizer}. */
110
110
  sanitizeHtml?: HtmlSanitizer;
111
+ /**
112
+ * Binds this activity's own `data.media` to a play budget the consumer
113
+ * persists. Usually supplied by `<ActivitySequence mediaBudget={…}>`; pass it
114
+ * yourself when rendering an activity standalone.
115
+ */
116
+ mediaBudget?: MediaBudgetBinding;
117
+ /** Translations for the audio transport chrome. See {@link MediaTransportStrings}. */
118
+ mediaStrings?: Partial<MediaTransportStrings>;
111
119
  onInteraction?: (event: InteractionEvent) => void;
112
120
  /** Per-instance token overrides, applied as inline CSS vars on the root. */
113
121
  theme?: Partial<ThemeTokens>;
@@ -177,5 +185,123 @@ declare function asRenderableSequence(entries: readonly (RedactedActivityData |
177
185
  type RedactedItemGroupData = ItemGroup<RedactedActivityData> & {
178
186
  redacted: true;
179
187
  };
188
+ /**
189
+ * Every English string the SDK's audio transport renders.
190
+ *
191
+ * Supply them to translate it. These are the highest-stakes strings on a
192
+ * listening paper — "No plays remaining" decides whether a learner believes
193
+ * they may try again — so shipping them as untranslatable English inside a
194
+ * Spanish panel was not acceptable. Supplying any of them also sets `lang` on
195
+ * the transport chrome, so a screen reader does not read the SDK's own words
196
+ * with the passage's phonetics.
197
+ */
198
+ interface MediaTransportStrings {
199
+ play: string;
200
+ pause: string;
201
+ preparing: string;
202
+ mute: string;
203
+ unmute: string;
204
+ volume: string;
205
+ speed: string;
206
+ seek: string;
207
+ /** e.g. `(1, 2) => '1 of 2 plays remaining'`. */
208
+ playsRemaining: (remaining: number, max: number) => string;
209
+ noPlaysRemaining: string;
210
+ /** Shown before the LAST play is spent, so a stray press cannot cost it. */
211
+ lastPlayConfirm: string;
212
+ lastPlayStart: string;
213
+ lastPlayCancel: string;
214
+ seekBlocked: string;
215
+ rateBlocked: string;
216
+ playFailed: string;
217
+ }
218
+ /**
219
+ * Binds ONE media block to a play budget the consumer persists.
220
+ *
221
+ * The SDK refuses a play; it does not remember one. Everything durable here is
222
+ * the consuming application's — see {@link SequenceMediaBudget.onPlayConsumed}.
223
+ */
224
+ interface MediaBudgetBinding {
225
+ /** From `slotMediaKey(slotId)` / `stimulusMediaKey(slotId)` in lk-core. */
226
+ key: string;
227
+ /** Plays already spent, and where playback stood. Read at mount only. */
228
+ entry?: MediaPlayLedgerEntry;
229
+ /** Defaults to `renderMode !== 'review'`. An explicit boolean wins either way. */
230
+ enforced?: boolean;
231
+ /** Slot context stamped onto the claim and the interaction event. */
232
+ slotId: string;
233
+ index: number;
234
+ activityId?: string;
235
+ onPlayConsumed?: (claim: MediaPlayClaim) => undefined | Promise<MediaPlayGrant | undefined>;
236
+ onPlayRefunded?: (claim: MediaPlayClaim) => void;
237
+ onPosition?: (key: string, seconds: number) => void;
238
+ /** Translations for the transport chrome. */
239
+ strings?: Partial<MediaTransportStrings>;
240
+ }
241
+ /**
242
+ * The pager-level half of a play budget. One prop, because it is one concept.
243
+ */
244
+ interface SequenceMediaBudget {
245
+ /**
246
+ * `MediaPlayLedger.entries` goes straight in. An absent key means nothing
247
+ * spent. Read at mount, like `responses`.
248
+ */
249
+ plays?: Readonly<Record<string, MediaPlayLedgerEntry>>;
250
+ /**
251
+ * Re-seed token. Change this string and the budgets re-seed from `plays`
252
+ * WITHOUT remounting the pager — the invigilator path ("the audio never
253
+ * started, give her the play back") that would otherwise cost the learner
254
+ * their focus, their scroll position and an unsaved answer.
255
+ */
256
+ resumeKey?: string;
257
+ /** Explicit override of the default (`renderMode !== 'review'`). */
258
+ enforced?: boolean;
259
+ /**
260
+ * Called the instant a play is claimed, BEFORE any audio is audible.
261
+ *
262
+ * Two tiers, chosen by what you return:
263
+ *
264
+ * - **Return nothing (optimistic).** Playback starts immediately and the
265
+ * count is only as durable as your write. **Do not debounce this, and do
266
+ * not batch it with the answer autosave** — an eight-second debounce is
267
+ * exactly long enough to start a third play and hard-reload. A `pagehide`
268
+ * beacon is a backstop, not the mechanism. A crash between this call and
269
+ * your write landing RETURNS the play to the learner; that is the honest
270
+ * description of what you are buying.
271
+ * - **Return a promise (confirmed).** Playback is held — the button reads
272
+ * "Preparing…" and is `aria-busy` — until it settles. Resolve with
273
+ * `{ playsUsed }` from an ATOMIC server write (`UPDATE … SET plays = plays
274
+ * + 1 … RETURNING plays`, or a compare-and-set on
275
+ * `claim.previousPlaysUsed`). A resolved count above `maxPlays` refuses the
276
+ * play, which is how a second tab is caught: two mounts both seeded at 0
277
+ * both claim 1, and only an atomic increment can tell them apart. A
278
+ * rejection charges nothing and lets the learner retry. This is the only
279
+ * tier in which "consumed before audible" is true of storage rather than
280
+ * only of memory; use it for summative papers.
281
+ *
282
+ * Never settle the promise and the learner cannot play at all: settle it.
283
+ */
284
+ onPlayConsumed?: (claim: MediaPlayClaim) => undefined | Promise<MediaPlayGrant | undefined>;
285
+ /**
286
+ * Called when a charged play produced no audio — the element errored before
287
+ * playback advanced past 0.25 s, an expired signed URL being the realistic
288
+ * cause. Supply it to give the play back, decrementing with a compare-and-set
289
+ * on `claim.playsUsed`. Omit it and the play stays spent: the SDK will not
290
+ * decrement a ledger it has no channel to correct.
291
+ */
292
+ onPlayRefunded?: (claim: MediaPlayClaim) => void;
293
+ /**
294
+ * Position reports, so a refresh resumes the play the learner already paid
295
+ * for instead of charging them again. Fires on pause, on end (with 0), and at
296
+ * most once per whole second of playback.
297
+ *
298
+ * **This one you MAY throttle** — the granularity you persist is the
299
+ * granularity of the replay a crash grants. Three seconds is sane; three
300
+ * minutes is not.
301
+ */
302
+ onPosition?: (key: string, seconds: number) => void;
303
+ /** Translations for the transport chrome. Defaults are English. */
304
+ strings?: Partial<MediaTransportStrings>;
305
+ }
180
306
 
181
- export { type ActivityProps as A, type HtmlSanitizer as H, type RenderMode as R, type Renderable as a, type RenderableActivity as b, asRenderable as c, asRenderableSequence as d };
307
+ export { type ActivityProps as A, type HtmlSanitizer as H, type MediaBudgetBinding as M, type RenderMode as R, type SequenceMediaBudget as S, type MediaTransportStrings as a, type Renderable as b, type RenderableActivity as c, asRenderable as d, asRenderableSequence as e };
@@ -1,4 +1,4 @@
1
- import { ActivityData, ActivityResult, LearnerResponse, ItemOutcome, InteractionEvent, ThemeTokens, RedactedActivityData, ItemGroup, SequenceEntry } from '@intellectif/lk-core';
1
+ import { ActivityData, ActivityResult, LearnerResponse, ItemOutcome, MediaPlayLedgerEntry, MediaPlayClaim, MediaPlayGrant, InteractionEvent, ThemeTokens, RedactedActivityData, ItemGroup, SequenceEntry } from '@intellectif/lk-core';
2
2
 
3
3
  /**
4
4
  * How an activity is being presented. This is the single switch that decides
@@ -108,6 +108,14 @@ interface ActivityProps<TData extends ActivityData = ActivityData> {
108
108
  outcome?: ItemOutcome;
109
109
  /** Renders author-supplied rich text when provided. See {@link HtmlSanitizer}. */
110
110
  sanitizeHtml?: HtmlSanitizer;
111
+ /**
112
+ * Binds this activity's own `data.media` to a play budget the consumer
113
+ * persists. Usually supplied by `<ActivitySequence mediaBudget={…}>`; pass it
114
+ * yourself when rendering an activity standalone.
115
+ */
116
+ mediaBudget?: MediaBudgetBinding;
117
+ /** Translations for the audio transport chrome. See {@link MediaTransportStrings}. */
118
+ mediaStrings?: Partial<MediaTransportStrings>;
111
119
  onInteraction?: (event: InteractionEvent) => void;
112
120
  /** Per-instance token overrides, applied as inline CSS vars on the root. */
113
121
  theme?: Partial<ThemeTokens>;
@@ -177,5 +185,123 @@ declare function asRenderableSequence(entries: readonly (RedactedActivityData |
177
185
  type RedactedItemGroupData = ItemGroup<RedactedActivityData> & {
178
186
  redacted: true;
179
187
  };
188
+ /**
189
+ * Every English string the SDK's audio transport renders.
190
+ *
191
+ * Supply them to translate it. These are the highest-stakes strings on a
192
+ * listening paper — "No plays remaining" decides whether a learner believes
193
+ * they may try again — so shipping them as untranslatable English inside a
194
+ * Spanish panel was not acceptable. Supplying any of them also sets `lang` on
195
+ * the transport chrome, so a screen reader does not read the SDK's own words
196
+ * with the passage's phonetics.
197
+ */
198
+ interface MediaTransportStrings {
199
+ play: string;
200
+ pause: string;
201
+ preparing: string;
202
+ mute: string;
203
+ unmute: string;
204
+ volume: string;
205
+ speed: string;
206
+ seek: string;
207
+ /** e.g. `(1, 2) => '1 of 2 plays remaining'`. */
208
+ playsRemaining: (remaining: number, max: number) => string;
209
+ noPlaysRemaining: string;
210
+ /** Shown before the LAST play is spent, so a stray press cannot cost it. */
211
+ lastPlayConfirm: string;
212
+ lastPlayStart: string;
213
+ lastPlayCancel: string;
214
+ seekBlocked: string;
215
+ rateBlocked: string;
216
+ playFailed: string;
217
+ }
218
+ /**
219
+ * Binds ONE media block to a play budget the consumer persists.
220
+ *
221
+ * The SDK refuses a play; it does not remember one. Everything durable here is
222
+ * the consuming application's — see {@link SequenceMediaBudget.onPlayConsumed}.
223
+ */
224
+ interface MediaBudgetBinding {
225
+ /** From `slotMediaKey(slotId)` / `stimulusMediaKey(slotId)` in lk-core. */
226
+ key: string;
227
+ /** Plays already spent, and where playback stood. Read at mount only. */
228
+ entry?: MediaPlayLedgerEntry;
229
+ /** Defaults to `renderMode !== 'review'`. An explicit boolean wins either way. */
230
+ enforced?: boolean;
231
+ /** Slot context stamped onto the claim and the interaction event. */
232
+ slotId: string;
233
+ index: number;
234
+ activityId?: string;
235
+ onPlayConsumed?: (claim: MediaPlayClaim) => undefined | Promise<MediaPlayGrant | undefined>;
236
+ onPlayRefunded?: (claim: MediaPlayClaim) => void;
237
+ onPosition?: (key: string, seconds: number) => void;
238
+ /** Translations for the transport chrome. */
239
+ strings?: Partial<MediaTransportStrings>;
240
+ }
241
+ /**
242
+ * The pager-level half of a play budget. One prop, because it is one concept.
243
+ */
244
+ interface SequenceMediaBudget {
245
+ /**
246
+ * `MediaPlayLedger.entries` goes straight in. An absent key means nothing
247
+ * spent. Read at mount, like `responses`.
248
+ */
249
+ plays?: Readonly<Record<string, MediaPlayLedgerEntry>>;
250
+ /**
251
+ * Re-seed token. Change this string and the budgets re-seed from `plays`
252
+ * WITHOUT remounting the pager — the invigilator path ("the audio never
253
+ * started, give her the play back") that would otherwise cost the learner
254
+ * their focus, their scroll position and an unsaved answer.
255
+ */
256
+ resumeKey?: string;
257
+ /** Explicit override of the default (`renderMode !== 'review'`). */
258
+ enforced?: boolean;
259
+ /**
260
+ * Called the instant a play is claimed, BEFORE any audio is audible.
261
+ *
262
+ * Two tiers, chosen by what you return:
263
+ *
264
+ * - **Return nothing (optimistic).** Playback starts immediately and the
265
+ * count is only as durable as your write. **Do not debounce this, and do
266
+ * not batch it with the answer autosave** — an eight-second debounce is
267
+ * exactly long enough to start a third play and hard-reload. A `pagehide`
268
+ * beacon is a backstop, not the mechanism. A crash between this call and
269
+ * your write landing RETURNS the play to the learner; that is the honest
270
+ * description of what you are buying.
271
+ * - **Return a promise (confirmed).** Playback is held — the button reads
272
+ * "Preparing…" and is `aria-busy` — until it settles. Resolve with
273
+ * `{ playsUsed }` from an ATOMIC server write (`UPDATE … SET plays = plays
274
+ * + 1 … RETURNING plays`, or a compare-and-set on
275
+ * `claim.previousPlaysUsed`). A resolved count above `maxPlays` refuses the
276
+ * play, which is how a second tab is caught: two mounts both seeded at 0
277
+ * both claim 1, and only an atomic increment can tell them apart. A
278
+ * rejection charges nothing and lets the learner retry. This is the only
279
+ * tier in which "consumed before audible" is true of storage rather than
280
+ * only of memory; use it for summative papers.
281
+ *
282
+ * Never settle the promise and the learner cannot play at all: settle it.
283
+ */
284
+ onPlayConsumed?: (claim: MediaPlayClaim) => undefined | Promise<MediaPlayGrant | undefined>;
285
+ /**
286
+ * Called when a charged play produced no audio — the element errored before
287
+ * playback advanced past 0.25 s, an expired signed URL being the realistic
288
+ * cause. Supply it to give the play back, decrementing with a compare-and-set
289
+ * on `claim.playsUsed`. Omit it and the play stays spent: the SDK will not
290
+ * decrement a ledger it has no channel to correct.
291
+ */
292
+ onPlayRefunded?: (claim: MediaPlayClaim) => void;
293
+ /**
294
+ * Position reports, so a refresh resumes the play the learner already paid
295
+ * for instead of charging them again. Fires on pause, on end (with 0), and at
296
+ * most once per whole second of playback.
297
+ *
298
+ * **This one you MAY throttle** — the granularity you persist is the
299
+ * granularity of the replay a crash grants. Three seconds is sane; three
300
+ * minutes is not.
301
+ */
302
+ onPosition?: (key: string, seconds: number) => void;
303
+ /** Translations for the transport chrome. Defaults are English. */
304
+ strings?: Partial<MediaTransportStrings>;
305
+ }
180
306
 
181
- export { type ActivityProps as A, type HtmlSanitizer as H, type RenderMode as R, type Renderable as a, type RenderableActivity as b, asRenderable as c, asRenderableSequence as d };
307
+ export { type ActivityProps as A, type HtmlSanitizer as H, type MediaBudgetBinding as M, type RenderMode as R, type SequenceMediaBudget as S, type MediaTransportStrings as a, type Renderable as b, type RenderableActivity as c, asRenderable as d, asRenderableSequence as e };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intellectif/lk-react",
3
- "version": "6.1.0",
3
+ "version": "7.0.0",
4
4
  "description": "Accessible React 19 assessment components — multiple choice, fill-in-the-blanks, written response — with a resumable exam/review pager, theming, and xAPI delivery.",
5
5
  "keywords": [
6
6
  "react",
@@ -137,7 +137,7 @@
137
137
  "peerDependencies": {
138
138
  "react": "^19",
139
139
  "react-dom": "^19",
140
- "@intellectif/lk-core": "^0.7.1"
140
+ "@intellectif/lk-core": "^0.8.0"
141
141
  },
142
142
  "devDependencies": {
143
143
  "@axe-core/react": "^4.10.0",
@@ -156,7 +156,7 @@
156
156
  "tsup": "^8.3.0",
157
157
  "vitest": "^4.1.6",
158
158
  "vitest-axe": "^0.1.0",
159
- "@intellectif/lk-core": "0.7.1"
159
+ "@intellectif/lk-core": "0.8.0"
160
160
  },
161
161
  "license": "MIT",
162
162
  "engines": {
@@ -1,41 +0,0 @@
1
- 'use client';
2
- 'use strict';
3
-
4
- var jsxRuntime = require('react/jsx-runtime');
5
-
6
- // src/components/shared/ActivityMedia.tsx
7
- function ActivityMedia({ media }) {
8
- const { type, url, alt, captionsUrl } = media;
9
- if (type === "image") {
10
- return /* @__PURE__ */ jsxRuntime.jsx("figure", { className: "lk-media", children: /* @__PURE__ */ jsxRuntime.jsx("img", { className: "lk-media-el", src: url, alt: alt ?? "" }) });
11
- }
12
- if (type === "audio") {
13
- return /* @__PURE__ */ jsxRuntime.jsx("figure", { className: "lk-media", children: /* @__PURE__ */ jsxRuntime.jsxs("audio", { className: "lk-media-el", controls: true, "aria-label": alt || void 0, children: [
14
- /* @__PURE__ */ jsxRuntime.jsx("source", { src: url }),
15
- captionsUrl ? /* @__PURE__ */ jsxRuntime.jsx("track", { kind: "captions", src: captionsUrl, default: true }) : null
16
- ] }) });
17
- }
18
- if (type === "embed") {
19
- return /* @__PURE__ */ jsxRuntime.jsx("figure", { className: "lk-media", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "lk-media-embed", children: /* @__PURE__ */ jsxRuntime.jsx(
20
- "iframe",
21
- {
22
- className: "lk-media-el",
23
- src: url,
24
- title: alt ?? "Embedded media",
25
- loading: "lazy",
26
- referrerPolicy: "strict-origin-when-cross-origin",
27
- sandbox: "allow-scripts allow-same-origin allow-presentation",
28
- allow: "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share",
29
- allowFullScreen: true
30
- }
31
- ) }) });
32
- }
33
- return /* @__PURE__ */ jsxRuntime.jsx("figure", { className: "lk-media", children: /* @__PURE__ */ jsxRuntime.jsxs("video", { className: "lk-media-el", controls: true, "aria-label": alt || void 0, children: [
34
- /* @__PURE__ */ jsxRuntime.jsx("source", { src: url }),
35
- captionsUrl ? /* @__PURE__ */ jsxRuntime.jsx("track", { kind: "captions", src: captionsUrl, default: true }) : null
36
- ] }) });
37
- }
38
-
39
- exports.ActivityMedia = ActivityMedia;
40
- //# sourceMappingURL=chunk-4HZ7LQIH.cjs.map
41
- //# sourceMappingURL=chunk-4HZ7LQIH.cjs.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/components/shared/ActivityMedia.tsx"],"names":["jsx","jsxs"],"mappings":";;;;;AAUO,SAAS,aAAA,CAAc,EAAE,KAAA,EAAM,EAAoD;AACxF,EAAA,MAAM,EAAE,IAAA,EAAM,GAAA,EAAK,GAAA,EAAK,aAAY,GAAI,KAAA;AAExC,EAAA,IAAI,SAAS,OAAA,EAAS;AACpB,IAAA,uBACEA,cAAA,CAAC,QAAA,EAAA,EAAO,SAAA,EAAU,UAAA,EAChB,QAAA,kBAAAA,cAAA,CAAC,KAAA,EAAA,EAAI,SAAA,EAAU,aAAA,EAAc,GAAA,EAAK,GAAA,EAAK,GAAA,EAAK,GAAA,IAAO,IAAI,CAAA,EACzD,CAAA;AAAA,EAEJ;AAEA,EAAA,IAAI,SAAS,OAAA,EAAS;AACpB,IAAA,uBACEA,cAAA,CAAC,QAAA,EAAA,EAAO,SAAA,EAAU,UAAA,EAEhB,QAAA,kBAAAC,eAAA,CAAC,OAAA,EAAA,EAAM,SAAA,EAAU,aAAA,EAAc,QAAA,EAAQ,IAAA,EAAC,YAAA,EAAY,GAAA,IAAO,MAAA,EACzD,QAAA,EAAA;AAAA,sBAAAD,cAAA,CAAC,QAAA,EAAA,EAAO,KAAK,GAAA,EAAK,CAAA;AAAA,MACjB,WAAA,kCAAe,OAAA,EAAA,EAAM,IAAA,EAAK,YAAW,GAAA,EAAK,WAAA,EAAa,OAAA,EAAO,IAAA,EAAC,CAAA,GAAK;AAAA,KAAA,EACvE,CAAA,EACF,CAAA;AAAA,EAEJ;AAEA,EAAA,IAAI,SAAS,OAAA,EAAS;AACpB,IAAA,sCACG,QAAA,EAAA,EAAO,SAAA,EAAU,YAChB,QAAA,kBAAAA,cAAA,CAAC,KAAA,EAAA,EAAI,WAAU,gBAAA,EACb,QAAA,kBAAAA,cAAA;AAAA,MAAC,QAAA;AAAA,MAAA;AAAA,QACC,SAAA,EAAU,aAAA;AAAA,QACV,GAAA,EAAK,GAAA;AAAA,QACL,OAAO,GAAA,IAAO,gBAAA;AAAA,QACd,OAAA,EAAQ,MAAA;AAAA,QACR,cAAA,EAAe,iCAAA;AAAA,QAMf,OAAA,EAAQ,oDAAA;AAAA,QACR,KAAA,EAAM,qGAAA;AAAA,QACN,eAAA,EAAe;AAAA;AAAA,OAEnB,CAAA,EACF,CAAA;AAAA,EAEJ;AAEA,EAAA,uBACEA,cAAA,CAAC,QAAA,EAAA,EAAO,SAAA,EAAU,UAAA,EAEhB,QAAA,kBAAAC,eAAA,CAAC,OAAA,EAAA,EAAM,SAAA,EAAU,aAAA,EAAc,QAAA,EAAQ,IAAA,EAAC,YAAA,EAAY,GAAA,IAAO,MAAA,EACzD,QAAA,EAAA;AAAA,oBAAAD,cAAA,CAAC,QAAA,EAAA,EAAO,KAAK,GAAA,EAAK,CAAA;AAAA,IACjB,WAAA,kCAAe,OAAA,EAAA,EAAM,IAAA,EAAK,YAAW,GAAA,EAAK,WAAA,EAAa,OAAA,EAAO,IAAA,EAAC,CAAA,GAAK;AAAA,GAAA,EACvE,CAAA,EACF,CAAA;AAEJ","file":"chunk-4HZ7LQIH.cjs","sourcesContent":["import type { ActivityMedia as ActivityMediaData } from '@intellectif/lk-core';\n\n/**\n * Presentational media block shown above a question or passage. URL-only:\n * the SDK does not host media (consumer responsibility). Accessibility:\n * images use `alt` (schema requires it non-empty); audio/video expose an\n * optional label and a captions `<track>` when `captionsUrl` is provided;\n * `embed` renders a responsive sandboxed iframe with `alt` as its required\n * accessible `title` (Req 14.5).\n */\nexport function ActivityMedia({ media }: { media: ActivityMediaData }): React.JSX.Element {\n const { type, url, alt, captionsUrl } = media;\n\n if (type === 'image') {\n return (\n <figure className=\"lk-media\">\n <img className=\"lk-media-el\" src={url} alt={alt ?? ''} />\n </figure>\n );\n }\n\n if (type === 'audio') {\n return (\n <figure className=\"lk-media\">\n {/* biome-ignore lint/a11y/useMediaCaption: captions are optional in the data contract — a <track> is rendered when captionsUrl is provided; absence is the author's documented choice (Req 14.5) */}\n <audio className=\"lk-media-el\" controls aria-label={alt || undefined}>\n <source src={url} />\n {captionsUrl ? <track kind=\"captions\" src={captionsUrl} default /> : null}\n </audio>\n </figure>\n );\n }\n\n if (type === 'embed') {\n return (\n <figure className=\"lk-media\">\n <div className=\"lk-media-embed\">\n <iframe\n className=\"lk-media-el\"\n src={url}\n title={alt ?? 'Embedded media'}\n loading=\"lazy\"\n referrerPolicy=\"strict-origin-when-cross-origin\"\n // The docblock always promised a sandboxed iframe; now it is one.\n // allow-scripts + allow-same-origin are required by provider\n // players (YouTube/Vimeo); top-navigation, forms, downloads and\n // popups stay blocked. Scheme allow-listing in MediaSchema is the\n // primary defense; this is depth.\n sandbox=\"allow-scripts allow-same-origin allow-presentation\"\n allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share\"\n allowFullScreen\n />\n </div>\n </figure>\n );\n }\n\n return (\n <figure className=\"lk-media\">\n {/* biome-ignore lint/a11y/useMediaCaption: captions are optional in the data contract — a <track> is rendered when captionsUrl is provided; absence is the author's documented choice (Req 14.5) */}\n <video className=\"lk-media-el\" controls aria-label={alt || undefined}>\n <source src={url} />\n {captionsUrl ? <track kind=\"captions\" src={captionsUrl} default /> : null}\n </video>\n </figure>\n );\n}\n"]}