@intellectif/lk-react 6.1.0 → 7.0.1
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.
- package/CHANGELOG.md +86 -0
- package/README.md +2 -0
- package/dist/{WrittenResponse-BXHidIaK.d.cts → WrittenResponse-BJL4dGsR.d.ts} +5 -1
- package/dist/{WrittenResponse-DJYGuMKk.d.ts → WrittenResponse-zWT-Go1g.d.cts} +5 -1
- package/dist/{chunk-IUODGQXE.js → chunk-3VQV7ICQ.js} +110 -33
- package/dist/chunk-3VQV7ICQ.js.map +1 -0
- package/dist/chunk-52A5UNCH.js +540 -0
- package/dist/chunk-52A5UNCH.js.map +1 -0
- package/dist/{chunk-NPY2F7F6.cjs → chunk-6DSA75TA.cjs} +16 -4
- package/dist/chunk-6DSA75TA.cjs.map +1 -0
- package/dist/{chunk-MDZMKC27.js → chunk-7YLY3G3E.js} +21 -4
- package/dist/chunk-7YLY3G3E.js.map +1 -0
- package/dist/{chunk-NEWGUDA5.cjs → chunk-ABHJW4LC.cjs} +21 -5
- package/dist/chunk-ABHJW4LC.cjs.map +1 -0
- package/dist/{chunk-ZLUMUJUQ.js → chunk-C3HR55JD.js} +16 -4
- package/dist/chunk-C3HR55JD.js.map +1 -0
- package/dist/{chunk-DUIWK272.cjs → chunk-FB6XKQFV.cjs} +112 -35
- package/dist/chunk-FB6XKQFV.cjs.map +1 -0
- package/dist/{chunk-MDYKYDRN.cjs → chunk-PPJ47THY.cjs} +21 -4
- package/dist/chunk-PPJ47THY.cjs.map +1 -0
- package/dist/chunk-UFWF3BKA.cjs +542 -0
- package/dist/chunk-UFWF3BKA.cjs.map +1 -0
- package/dist/{chunk-57E3E2H4.js → chunk-VHULVDPG.js} +16 -4
- package/dist/chunk-VHULVDPG.js.map +1 -0
- package/dist/{chunk-BE7R2X3S.cjs → chunk-XGQKID2P.cjs} +16 -4
- package/dist/chunk-XGQKID2P.cjs.map +1 -0
- package/dist/{chunk-UQ3BBEIT.js → chunk-XS2SZINH.js} +21 -5
- package/dist/chunk-XS2SZINH.js.map +1 -0
- package/dist/components/ActivitySequence.cjs +7 -7
- package/dist/components/ActivitySequence.d.cts +13 -15
- package/dist/components/ActivitySequence.d.ts +13 -15
- package/dist/components/ActivitySequence.js +6 -6
- package/dist/components/FillInTheBlanks.cjs +3 -3
- package/dist/components/FillInTheBlanks.d.cts +1 -1
- package/dist/components/FillInTheBlanks.d.ts +1 -1
- package/dist/components/FillInTheBlanks.js +2 -2
- package/dist/components/MultipleChoice.cjs +3 -3
- package/dist/components/MultipleChoice.d.cts +1 -1
- package/dist/components/MultipleChoice.d.ts +1 -1
- package/dist/components/MultipleChoice.js +2 -2
- package/dist/components/StimulusPanel.cjs +3 -3
- package/dist/components/StimulusPanel.d.cts +19 -3
- package/dist/components/StimulusPanel.d.ts +19 -3
- package/dist/components/StimulusPanel.js +2 -2
- package/dist/components/WrittenResponse.cjs +3 -3
- package/dist/components/WrittenResponse.d.cts +3 -3
- package/dist/components/WrittenResponse.d.ts +3 -3
- package/dist/components/WrittenResponse.js +2 -2
- package/dist/index.cjs +11 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/dist/theme/skin.css +204 -1
- package/dist/{types-9iS1Vs2I.d.ts → types-eXX4hzSj.d.cts} +128 -2
- package/dist/{types-9iS1Vs2I.d.cts → types-eXX4hzSj.d.ts} +128 -2
- package/package.json +3 -3
- package/dist/chunk-4HZ7LQIH.cjs +0 -41
- package/dist/chunk-4HZ7LQIH.cjs.map +0 -1
- package/dist/chunk-57E3E2H4.js.map +0 -1
- package/dist/chunk-BE7R2X3S.cjs.map +0 -1
- package/dist/chunk-DUIWK272.cjs.map +0 -1
- package/dist/chunk-GB5URWM4.js +0 -39
- package/dist/chunk-GB5URWM4.js.map +0 -1
- package/dist/chunk-IUODGQXE.js.map +0 -1
- package/dist/chunk-MDYKYDRN.cjs.map +0 -1
- package/dist/chunk-MDZMKC27.js.map +0 -1
- package/dist/chunk-NEWGUDA5.cjs.map +0 -1
- package/dist/chunk-NPY2F7F6.cjs.map +0 -1
- package/dist/chunk-UQ3BBEIT.js.map +0 -1
- package/dist/chunk-ZLUMUJUQ.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,91 @@
|
|
|
1
1
|
# @intellectif/lk-react
|
|
2
2
|
|
|
3
|
+
## 7.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Close two more fail-open leaks in `redact()`, bind a play budget on an essay slot, and make three guards that could not fail actually fail.
|
|
8
|
+
|
|
9
|
+
Everything here is a defect in 0.8.0 / 7.0.0, found by auditing that release rather than by using it.
|
|
10
|
+
|
|
11
|
+
**`redact()` leaked through two more object leaves.** 0.8.0 fixed `media`, which was classified as a single `public` leaf so the author's object was returned by reference without recursing. The same shape survived in two more places:
|
|
12
|
+
|
|
13
|
+
- **`rubric`.** A grader's `modelAnswer` or `aiModel` parked on the rubric reached the exam client and `assertRedacted` blessed it. The rubric stays learner-visible — it tells a learner what they are graded on — but that now means its _documented_ fields are visible (`label`, and each criterion's `name`, `description`, `weight`), not anything anyone stashes under it.
|
|
14
|
+
- **`feedback` and each blank's `match`.** These are `answer-key`, so the default `reveal: 'none'` drops them entirely and the flaw is invisible there. `reveal: 'after-submit'` keeps them, and kept them **whole**: a grader's private note or tuning knob parked beside the documented fields went to the learner along with the answer key, and the projection aliased the caller's object. An after-submit reveal now shows `correct` / `incorrect` and the documented `TextMatchPolicy` keys, and nothing else.
|
|
15
|
+
|
|
16
|
+
A nested projection that keeps no fields is now omitted rather than emitted as `{}`, so giving an all-`answer-key` object a nested policy does not turn its absence into an empty object. Verified against the published 0.7.1 build: well-formed content redacts byte-identically in **both** reveal modes, so the only behaviour change is that undocumented sidecars stop travelling.
|
|
17
|
+
|
|
18
|
+
**A budgeted recording never bound on a written-response slot.** An essay carrying a recording with `maxPlays` rendered an **unlimited** player — no plays-remaining, nothing counted — which on a listening-and-writing paper is exactly the failure the budget exists to prevent. `<WrittenResponse>` was the one branch of the pager whose props were written out by hand rather than spread, and it had been the branch left behind three separate times: `defaultSubmitted`, the redacted-in-`practice` guard, and the media budget. It now derives its props by subtracting `onComplete` from the same bag the other types receive, so a prop added to that bag cannot be forgotten there again.
|
|
19
|
+
|
|
20
|
+
**And the guard meant to catch that could never fire.** `ActivityMedia`'s "budget declared with no binding" throw sat _after_ the early return for `controls: 'minimal'` — which a budgeted policy always resolves to — so it was dead code for the one case it was written for. Moved ahead of the branch, and given the standalone test that the pager can never provide: `ActivitySequence` throws on a missing `onPlayConsumed` before children mount, and once that callback is supplied it passes a binding, so no pager test reaches this path either way.
|
|
21
|
+
|
|
22
|
+
**Three guards that could not fail.** Found by mutation-testing the suite — breaking each mechanism and checking whether any test noticed:
|
|
23
|
+
|
|
24
|
+
- The last-play confirmation gate had **no coverage at all**, and was silently absorbing the press in two neighbouring resume tests: both used `maxPlays: 2` with one play spent, so the press they made was swallowed by the gate and their assertions held whether playback resumed or a dialog opened. Gutting resume detection left the whole file green. Both now use a budget that clears the gate and assert that playback actually resumed.
|
|
25
|
+
- Partial multiple-choice scoring asserted only that a score with an off-paper option id lands in `[0, 1]` — which holds whether that id is ignored, credited, or treated as wrong. Now pinned to exact values, one of which is only reachable by the correct treatment.
|
|
26
|
+
- The empty-input fuzzy guard was probed with three spaces against a one-character answer, which the length-difference early exit refuses before the guard is ever consulted. A single space now probes the guard itself.
|
|
27
|
+
|
|
28
|
+
Also: `classifyBand`'s float tolerance was pinned only to within `[0, 0.01)` — seven orders of magnitude of slack, all of it in the promoting direction the function exists to prevent — and is now straddled at its real epsilon; and the cross-type resume test gained its `written-response` row, asserting the restored draft survives rather than only that the control is locked.
|
|
29
|
+
|
|
30
|
+
- Updated dependencies
|
|
31
|
+
- @intellectif/lk-core@0.8.1
|
|
32
|
+
|
|
33
|
+
## 7.0.0
|
|
34
|
+
|
|
35
|
+
### Major Changes
|
|
36
|
+
|
|
37
|
+
- 011e0d1: Media playback policy — a listening paper can now be played on the paper's terms, and two exam-integrity guards that were watching the wrong doors are closed.
|
|
38
|
+
|
|
39
|
+
Listening assessment was the last thing in v0.4 that could not be run through the SDK at all. An integrating application had replaced the SDK's audio rendering wholesale with a bespoke 56-line player, and the reason was capability, not styling: `<audio controls>` offers a scrubber, a speed control and a download, and a listening paper needs none of them.
|
|
40
|
+
|
|
41
|
+
**`media.playback`, on audio.** `{ maxPlays, seek, rate, controls, nativeControlHints }`, every field optional and every default reproducing the pre-0.8.0 behaviour byte for byte. `{ maxPlays: 2 }` alone is a complete policy: `controls` resolves to `minimal` and `seek` to `none`, because those are the only values that can keep the promise. The browser's own bar leaves its play button enabled after a budget is spent and keeps a scrubber that would move and silently snap back — a control that looks operable and does nothing (WCAG 3.2.2 / 4.1.3). So an enforcing policy replaces the bar rather than lying with it, and the schema **refuses** the combinations that would: `controls: 'native'` with a budget, `maxPlays` with `seek: 'allow'` (scrubbing back would replay the whole recording for free), a bare `minimal` with nothing to enforce, and hints that an enforcing policy would render inert. `playback` on `video`, `image` or `embed` is rejected outright, with the reason: a provider iframe needs the provider's own JS API, and a video transport would have to own fullscreen and Picture-in-Picture, which this release does not.
|
|
42
|
+
|
|
43
|
+
The policy object is **strict** while `media` stays loose. A typo one level down (`maxPlay`, `seeking`) would otherwise be accepted in silence and disarm the whole control on an exam that believed it was enforced.
|
|
44
|
+
|
|
45
|
+
**What replaces the browser's bar is a superset, minus what the policy removes on purpose.** Play/pause, elapsed and total time, mute, volume, an optional speed control, an optional scrubber, and a live plays-remaining status. Volume and mute are never restricted by any policy — they affect no assessment property, and a learner in a lab with a locked OS volume has no other lever once the native bar is gone. An exhausted play button uses `aria-disabled`, never `disabled`: a natively disabled button leaves the focus order, so a learner who tabs into nothing is told nothing, and disabling it mid-attempt would drop focus to `<body>`. Pressing it re-announces the refusal. Every string is overridable through `mediaStrings`, because "No plays remaining" is the highest-stakes text on a listening paper and shipping it as untranslatable English inside a Spanish panel was not acceptable. `review` mode never enforces: a graded paper cannot be changed by listening again, and taking away a learner's scrubber and speed control while they work out what they got wrong helps nobody.
|
|
46
|
+
|
|
47
|
+
**Honest about what a browser actually gives.** `maxPlays`, `seek: 'none'` and `rate: 'fixed'` are _enforced_: a refused play is stopped inside the browser's own `play` event, verified in a real engine at `currentTime < 0.25s`, and the budget lives on the element's event rather than on the button so a hardware media key or a scripted `play()` goes through it too. `nativeControlHints` is _advisory_: it emits `controlsList`, which some engines ignore entirely, and `hide-download` **never prevents a download** — the URL is in the page and the bytes are in the network panel. Nothing here survives devtools. Those are not adjectives; `packages/lk-react/e2e/media-capability-probe.spec.ts` exercises each mechanism in a real browser and runs in CI, so no documented claim outlives the behaviour it rests on.
|
|
48
|
+
|
|
49
|
+
**A budget is only as durable as your write.** The SDK reads and writes no store, so `maxPlays` means nothing across a refresh unless the consumer persists it. `mediaBudget.onPlayConsumed` is a payment, not telemetry: return a promise and playback is held until an atomic server write settles — the only tier in which "consumed before audible" is true of storage rather than only of memory, and the only construction that catches a second tab, since two mounts seeded at 0 both claim 1. Return nothing and playback starts immediately, which is fine for practice — but do not debounce it and do not batch it with the answer autosave; an eight-second debounce is exactly long enough to start a third play and hard-reload. In `exam` mode, budgeted media without `onPlayConsumed` throws at render, and so does a resumed attempt that omits the ledger: a paper granting unlimited plays while showing "2 plays remaining" is indistinguishable, to the learner and to an appeal, from one that works.
|
|
50
|
+
|
|
51
|
+
`MediaPlayLedger` is a **separate** persisted object, not a field on `AttemptState`. Two reasons, both of which bite in production: it is written on a different cadence, and `restoreAttemptState` rebuilds its output field by field — so a build predating this release would not merely fail to read the counts, it would write the snapshot back without them and erase spent plays across a staged rollout, at exactly the moment they matter. `serializeMediaPlayLedger` / `restoreMediaPlayLedger` bind it to its paper by `planHash` for the same reason `AttemptState` is bound.
|
|
52
|
+
|
|
53
|
+
Budgets are frozen into the plan by `planAttempt`, so editing `maxPlays: 2 → 4` mid-window cannot change what a past learner was held to. `planAttempt` also refuses a paper where one recording is budgeted under several keys: six questions each carrying the same clip at `maxPlays: 2` is twelve plays of one recording. Questions that share a recording belong in an item group — one stimulus, one budget, keyed by the entry so a six-question group gets one budget rather than six.
|
|
54
|
+
|
|
55
|
+
**`redact()` had a fail-open hole at exactly this nesting level, and it is fixed first.** `media` was classified as a single `public` leaf, so the whole object was forwarded without recursing — and an unclassified key nested under it survived `redact()` **and passed `assertRedacted()`**. A hand-planted `media.secretAnswerHint` reached the learner on 0.7.1. The documented fail-closed contract ("a field the policy does not classify is removed") stopped at the media boundary; it no longer does. Unknown keys under `media` are now dropped and rejected, `RedactedMediaSchema` is strict like the shapes that embed it, and `redact()` returns a copy rather than an alias. Verified against the published 0.7.1 build: every existing paper plans to a byte-identical `planHash` and redacts to identical output.
|
|
56
|
+
|
|
57
|
+
**Two exam-integrity guards that were watching the wrong doors:**
|
|
58
|
+
|
|
59
|
+
- **An unseeded item-level shuffle rendered happily in `exam` mode.** `ActivitySequence` demanded a `shuffleSeed` for `shuffle="entries"` and for a group's `shuffle: 'within-group'`, but never for an activity's own `data.shuffle` — and `<MultipleChoice>` invents a per-mount seed when none reaches it, in every render mode. A single item with `shuffle: true` produced four different option orders across four mounts, without complaint: an arrangement the server cannot rebuild, which is precisely what the guard exists to refuse, arriving through the one door it did not watch.
|
|
60
|
+
- **`<WrittenResponse>` had no redacted-in-`practice` guard.** Its two siblings have refused redacted data there since 0.5.0. The asymmetry looked harmless because this component never scores locally — but `practice` still runs the local submit path and emits a practice-mode xAPI statement for work the server is meant to grade, so an all-essay redacted paper mounted without `renderMode` rendered, stayed answerable, and reported success at every step. The one item type whose grading is entirely someone else's job was the one that failed silently.
|
|
61
|
+
|
|
62
|
+
Both now throw at render. That is the breaking half of this release for anyone who was relying on the old behaviour — though what they were relying on was an exam whose order could not be reproduced, or an essay graded nowhere.
|
|
63
|
+
|
|
64
|
+
**On `maxPlays` specifically, and stated plainly because this is a public changelog:** it is a roadmap hypothesis, not a replicated production behaviour. A search of the one integrating application for any play-count identifier, UI string or database column returned nothing. What it had actually built was seek, download and rate suppression — which is why those are the parts with a real usage story behind them, and why `maxPlays` ships with its persistence requirements spelled out rather than assumed.
|
|
65
|
+
|
|
66
|
+
**A blank left empty could score as correct.** Levenshtein distance from an empty string is just the answer's length, so `levenshtein: 1` on a one-letter blank — an article, or "I" — accepted an **unanswered** blank: `matchText('', ['a'], { levenshtein: 1 })` returned `{ matched: true, via: 'fuzzy' }`. On a partial-credit gap-fill that silently awarded a mark for work the learner never did, and no test covered the path. An empty or whitespace-only input no longer reaches the fuzzy stage at all. Exact and normalized matching are untouched, so an author who deliberately lists `""` as an accepted answer still gets it, one stage earlier.
|
|
67
|
+
|
|
68
|
+
**This can change a recorded grade** — for exactly one population: attempts where an unanswered blank was marked correct by fuzzy tolerance. Every one of those marks was wrong. If you have stored gap-fill grades under a `levenshtein` policy with very short accepted answers, they are worth recomputing. Related hazard, unchanged and now documented rather than silently fixed: `levenshtein: 1` against a one-character answer still accepts _any_ single character, because that is literally what an edit distance of 1 means. Prefer exact matching on single-letter blanks.
|
|
69
|
+
|
|
70
|
+
**Three skin bugs in the post-submit marking**, all reported by a consumer against 6.1.0 and all reproduced by computed style in a real engine before being fixed:
|
|
71
|
+
|
|
72
|
+
- **A correctly chosen option kept the selection colour.** Specificity, not order: `.lk-mc-option:has(input:checked)` is (0,2,1) and outranked `.lk-mc-option[data-correct="true"]` at (0,2,0), so the single most reassuring state in the component — "you picked this and it is right" — was the only one that did not mark. The wrong-and-chosen case worked only because someone had written the combined selector for it; this adds its missing counterpart.
|
|
73
|
+
- **Hovering a marked option repainted it in the selection colour.** `.lk-mc-option:hover` was unguarded, and for an unchosen distractor no marking rule matches at all — so after submit, hovering a disabled, already-marked option showed the "still selectable" affordance. Hover is now scoped to `:not([data-correct])`, which is precisely "not yet marked".
|
|
74
|
+
- **Option text wrapped under a lonely radio on narrow screens.** The option is a flex row and consumers commonly add `flex-wrap: wrap` so a per-option note can drop below it; in a wrapping container an item wraps _before_ it shrinks, and the text span had no flex contract. It now has one, with a minimum inline size so a wide sibling wraps instead of crushing the text into a one-character-per-line column.
|
|
75
|
+
|
|
76
|
+
`e2e/skin-marking.spec.ts` pins all three by **computed border colour and box geometry** in a real browser. Unit tests assert class names and `data-*` attributes and can never see which rule wins, which is why this class of bug survived several releases.
|
|
77
|
+
|
|
78
|
+
Fixing the first of those exposed a design gap the fix alone would have left in place: once a correctly chosen option marked green, it became indistinguishable from the correct answer a learner **missed**, which also marks green. The skin now separates them by weight rather than hue — chosen-and-right is solid with a tint and a `✓`, chosen-and-wrong is solid red with a `✗`, and the missed correct answer is a **dashed** green outline with a hollow `○` and no fill. Untouched distractors recede to 70% opacity, a figure chosen by computing the contrast (6.6:1; the first draft's 55% would have failed AA at 3.9:1). The glyphs also close a WCAG 1.4.1 gap: the component emits no correctness text, so a mark was previously carried by colour alone. They are decorative generated content on purpose — a translatable announcement belongs to the component and is tracked with the i18n work.
|
|
79
|
+
|
|
80
|
+
Also in this release: `slotKey` containing a `.` is now rejected at `validateItemGroup` rather than at `flattenSequence`, so an author finds it instead of a learner; `InteractionKind` gains `media-play-consumed` / `-refused` / `-errored` / `-refunded`, emitted only when a budget is in force so a `review` replay cannot pollute the record an appeal reads; and the pager's stimulus pane now keys on the set as well as the entry, so a set change to a different paper that reuses an entry key remounts the panel instead of leaking a mount-only seed into the next paper.
|
|
81
|
+
|
|
82
|
+
`@intellectif/lk-react` goes major because `@intellectif/lk-core` is a peer dependency and a core minor raises the floor, and because of the two new throws. Every new prop is optional.
|
|
83
|
+
|
|
84
|
+
### Patch Changes
|
|
85
|
+
|
|
86
|
+
- Updated dependencies [011e0d1]
|
|
87
|
+
- @intellectif/lk-core@0.8.0
|
|
88
|
+
|
|
3
89
|
## 6.1.0
|
|
4
90
|
|
|
5
91
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -78,6 +78,7 @@ export function Demo() {
|
|
|
78
78
|
- **Custom activity types** — `renderers={{ 'my-type': MyRenderer }}` on the sequence; a key matching a built-in overrides it, so you can replace a bundled renderer without forking the sequencer.
|
|
79
79
|
- **Rich text, opt-in** — `sanitizeHtml` renders author-supplied `questionHtml` / `promptHtml`. The SDK ships **no** sanitiser and injects no HTML without one; without it the escaped plain-text field is used. (`FillInTheBlanks` ignores `passageHtml` by design — its passage hosts the answer inputs.)
|
|
80
80
|
- **Media per question** — optional `image` / `audio` / `video` / `embed` (YouTube/Vimeo iframe) above the question; alt-text required for `image`/`embed` (WCAG).
|
|
81
|
+
- **Playback policy for listening papers** — an audio recording can declare `maxPlays`, `seek: 'none'` and `rate: 'fixed'`. The SDK then renders its own accessible transport (44px targets, a live plays-remaining status, an exhausted button that stays focusable and says why) and enforces the policy from the element's own events, so a hardware media key goes through the budget too. Wire `mediaBudget` on `<ActivitySequence>` to make a play budget survive a refresh.
|
|
81
82
|
- **Activity-level overall feedback** — `{ correct, incorrect }` shown after submit (h5p "Overall Feedback" parity).
|
|
82
83
|
- **`useXAPI(config)`** — fire-and-forget LRS delivery with retry/backoff for 5xx/network (1 s / 2 s / 4 s), immediate fail on 4xx, never throws.
|
|
83
84
|
- **`useActivityState()`** — `idle → in-progress → completed → reviewing` machine with `getTimeSpent()`.
|
|
@@ -121,6 +122,7 @@ subpath of their own.
|
|
|
121
122
|
| **Rich text** | Rendered only when you pass `sanitizeHtml`; the SDK bundles no sanitiser and injects no HTML without one. `FillInTheBlanks` deliberately **ignores** `passageHtml` — the passage hosts the answer inputs, so it is built from `passage` plus the blanks (dev-mode warning if you pass it). |
|
|
122
123
|
| **i18n** | `locale` sets the `lang` attribute on the rendered region and names xAPI statements. UI strings are English and not yet overridable, and there is no RTL-specific styling. |
|
|
123
124
|
| **Media** | `<audio>` / `<video>` are paused when the pager navigates away, preserving `currentTime` so a group resumes where the learner left it; nothing ever auto-plays. A provider `embed` (iframe) **cannot** be paused this way — controlling a third-party player needs its own JS API. Use `audio` / `video` for anything that must stop when the learner navigates. |
|
|
125
|
+
| **Playback policy** | Audio only. `maxPlays` / `seek` / `rate` are enforced by the SDK's own transport — a refused play is stopped inside the browser's `play` event, before a sample is audible. `nativeControlHints` is **advisory**: it emits `controlsList`, which some engines ignore, and never prevents a download. A budget is durable only if you persist it through `mediaBudget.onPlayConsumed`; the SDK stores nothing. Nothing here survives devtools. Not implemented for `video` or `embed`. |
|
|
124
126
|
| **Authoring / content storage / CDN / auth** | Consumer responsibility — typed schemas + `validateActivity` + JSON Schema export are provided for you to build authoring on. |
|
|
125
127
|
| **SSR / RSC** | Fully supported; every component carries `'use client'`. |
|
|
126
128
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { XAPIStatement, WrittenResponseData, LearnerResponse, ItemOutcome, InteractionEvent, ThemeTokens } from '@intellectif/lk-core';
|
|
2
|
-
import {
|
|
2
|
+
import { b as Renderable, R as RenderMode, H as HtmlSanitizer, M as MediaBudgetBinding, a as MediaTransportStrings } from './types-eXX4hzSj.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Payload delivered when the learner submits a written response. Grading is
|
|
@@ -83,6 +83,10 @@ interface WrittenResponseProps {
|
|
|
83
83
|
outcome?: ItemOutcome;
|
|
84
84
|
/** Renders `data.promptHtml` when provided. See `HtmlSanitizer`. */
|
|
85
85
|
sanitizeHtml?: HtmlSanitizer;
|
|
86
|
+
/** Binds this activity's own `data.media` to a play budget. See {@link MediaBudgetBinding}. */
|
|
87
|
+
mediaBudget?: MediaBudgetBinding;
|
|
88
|
+
/** Translations for the audio transport chrome. See {@link MediaTransportStrings}. */
|
|
89
|
+
mediaStrings?: Partial<MediaTransportStrings>;
|
|
86
90
|
onInteraction?: (event: InteractionEvent) => void;
|
|
87
91
|
/** Per-instance token overrides, applied as inline CSS vars on the root. */
|
|
88
92
|
theme?: Partial<ThemeTokens>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { XAPIStatement, WrittenResponseData, LearnerResponse, ItemOutcome, InteractionEvent, ThemeTokens } from '@intellectif/lk-core';
|
|
2
|
-
import {
|
|
2
|
+
import { b as Renderable, R as RenderMode, H as HtmlSanitizer, M as MediaBudgetBinding, a as MediaTransportStrings } from './types-eXX4hzSj.cjs';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Payload delivered when the learner submits a written response. Grading is
|
|
@@ -83,6 +83,10 @@ interface WrittenResponseProps {
|
|
|
83
83
|
outcome?: ItemOutcome;
|
|
84
84
|
/** Renders `data.promptHtml` when provided. See `HtmlSanitizer`. */
|
|
85
85
|
sanitizeHtml?: HtmlSanitizer;
|
|
86
|
+
/** Binds this activity's own `data.media` to a play budget. See {@link MediaBudgetBinding}. */
|
|
87
|
+
mediaBudget?: MediaBudgetBinding;
|
|
88
|
+
/** Translations for the audio transport chrome. See {@link MediaTransportStrings}. */
|
|
89
|
+
mediaStrings?: Partial<MediaTransportStrings>;
|
|
86
90
|
onInteraction?: (event: InteractionEvent) => void;
|
|
87
91
|
/** Per-instance token overrides, applied as inline CSS vars on the root. */
|
|
88
92
|
theme?: Partial<ThemeTokens>;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { MultipleChoice } from './chunk-
|
|
3
|
-
import { FillInTheBlanks } from './chunk-
|
|
4
|
-
import { WrittenResponse } from './chunk-
|
|
2
|
+
import { MultipleChoice } from './chunk-C3HR55JD.js';
|
|
3
|
+
import { FillInTheBlanks } from './chunk-VHULVDPG.js';
|
|
4
|
+
import { WrittenResponse } from './chunk-7YLY3G3E.js';
|
|
5
5
|
import { randomSessionId } from './chunk-6F4X2NKL.js';
|
|
6
|
-
import { StimulusPanel } from './chunk-
|
|
7
|
-
import { isItemGroup, flattenSequence } from '@intellectif/lk-core';
|
|
6
|
+
import { StimulusPanel } from './chunk-XS2SZINH.js';
|
|
7
|
+
import { isItemGroup, flattenSequence, resolvePlaybackPolicy, slotMediaKey, stimulusMediaKey } from '@intellectif/lk-core';
|
|
8
8
|
import { useRef, useMemo, useState, useEffect } from 'react';
|
|
9
9
|
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
10
10
|
|
|
@@ -34,6 +34,9 @@ function SequencePane({
|
|
|
34
34
|
}, [hidden]);
|
|
35
35
|
return /* @__PURE__ */ jsx("div", { className, hidden, ref, children });
|
|
36
36
|
}
|
|
37
|
+
function shufflesItsOwnOptions(entry) {
|
|
38
|
+
return entry?.shuffle === true;
|
|
39
|
+
}
|
|
37
40
|
function ActivitySequence({
|
|
38
41
|
activities,
|
|
39
42
|
renderers,
|
|
@@ -53,11 +56,15 @@ function ActivitySequence({
|
|
|
53
56
|
sanitizeHtml,
|
|
54
57
|
theme,
|
|
55
58
|
locale,
|
|
56
|
-
disabled
|
|
59
|
+
disabled,
|
|
60
|
+
mediaBudget
|
|
57
61
|
}) {
|
|
58
62
|
const sessionIdRef = useRef(null);
|
|
63
|
+
const mediaPlays = mediaBudget?.plays;
|
|
59
64
|
const shuffleEntries = shuffle === "entries";
|
|
60
|
-
const needsSeed = shuffleEntries || activities.some(
|
|
65
|
+
const needsSeed = shuffleEntries || activities.some(
|
|
66
|
+
(entry) => isItemGroup(entry) ? entry.shuffle === "within-group" || entry.items.some(shufflesItsOwnOptions) : shufflesItsOwnOptions(entry)
|
|
67
|
+
);
|
|
61
68
|
if (needsSeed && shuffleSeed === void 0 && renderMode !== "practice") {
|
|
62
69
|
throw new Error(
|
|
63
70
|
`ActivitySequence: renderMode "${renderMode}" requires a \`shuffleSeed\` when shuffling (shuffle="entries", or a group with shuffle: "within-group"). Pass the attempt id, so the server's flattenSequence(entries, { seed }) reproduces the order the learner saw.`
|
|
@@ -95,8 +102,14 @@ function ActivitySequence({
|
|
|
95
102
|
const mountedRef = useRef(false);
|
|
96
103
|
const setKey = JSON.stringify(slots.map((slot) => [slot.slotId, slot.activity.id]));
|
|
97
104
|
const [prevSetKey, setPrevSetKey] = useState(setKey);
|
|
105
|
+
const seedKey = `${setKey}::${mediaBudget?.resumeKey ?? ""}`;
|
|
98
106
|
const mountSetKeyRef = useRef(setKey);
|
|
107
|
+
const mountSeedKeyRef = useRef(seedKey);
|
|
99
108
|
const seedsApply = mountSetKeyRef.current === setKey;
|
|
109
|
+
const mediaSeedsApply = mountSeedKeyRef.current === seedKey || seedsApply;
|
|
110
|
+
if (mountSeedKeyRef.current !== seedKey) {
|
|
111
|
+
mountSeedKeyRef.current = seedKey;
|
|
112
|
+
}
|
|
100
113
|
const submitted = useMemo(() => new Set(submittedSlotIds ?? []), [submittedSlotIds]);
|
|
101
114
|
if (prevSetKey !== setKey) {
|
|
102
115
|
setPrevSetKey(setKey);
|
|
@@ -170,6 +183,63 @@ function ActivitySequence({
|
|
|
170
183
|
if (!current) {
|
|
171
184
|
return /* @__PURE__ */ jsx("div", { className: "lk-seq" });
|
|
172
185
|
}
|
|
186
|
+
const budgetedMedia = slots.flatMap((slot) => {
|
|
187
|
+
const found = [];
|
|
188
|
+
const own = slot.activity.media;
|
|
189
|
+
if (own !== void 0 && resolvePlaybackPolicy(own).maxPlays !== null) {
|
|
190
|
+
found.push({ key: slotMediaKey(slot.slotId), url: own.url });
|
|
191
|
+
}
|
|
192
|
+
const stimulus = slot.group?.stimulus.media;
|
|
193
|
+
if (stimulus !== void 0 && resolvePlaybackPolicy(stimulus).maxPlays !== null) {
|
|
194
|
+
found.push({ key: stimulusMediaKey(slot.slotId), url: stimulus.url });
|
|
195
|
+
}
|
|
196
|
+
return found;
|
|
197
|
+
});
|
|
198
|
+
const budgetEnforced = mediaBudget?.enforced ?? renderMode !== "review";
|
|
199
|
+
if (budgetedMedia.length > 0 && renderMode === "exam" && budgetEnforced) {
|
|
200
|
+
if (mediaBudget?.onPlayConsumed === void 0) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`ActivitySequence: renderMode "exam" with media that declares \`maxPlays\` requires \`mediaBudget.onPlayConsumed\`. The SDK persists nothing, so without it the count lives only in this mount: a refresh silently restores the full budget, and a paper that grants unlimited plays while showing "2 plays remaining" is indistinguishable \u2014 to the learner and to an appeal \u2014 from one that works. Budgeted media: ${[...new Set(budgetedMedia.map((m) => m.key))].join(", ")}.`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const resuming = responses !== void 0 && Object.keys(responses).length > 0 || submittedSlotIds !== void 0 && submittedSlotIds.length > 0;
|
|
206
|
+
if (resuming && mediaBudget.plays === void 0) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
"ActivitySequence: this is a resumed attempt (`responses`/`submittedSlotIds` were supplied) but `mediaBudget.plays` is absent. A resume that omits the ledger hands the learner a fresh budget at exactly the moment it matters. Pass `restoreMediaPlayLedger(plan, stored).entries`, or `{}` to state explicitly that nothing was spent."
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const keysByUrl = /* @__PURE__ */ new Map();
|
|
213
|
+
for (const { key, url } of budgetedMedia) {
|
|
214
|
+
const keys = keysByUrl.get(url) ?? /* @__PURE__ */ new Set();
|
|
215
|
+
keys.add(key);
|
|
216
|
+
keysByUrl.set(url, keys);
|
|
217
|
+
}
|
|
218
|
+
for (const [url, keys] of keysByUrl) {
|
|
219
|
+
if (keys.size > 1) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`ActivitySequence: media ${JSON.stringify(url)} is budgeted under ${keys.size} separate keys (${[...keys].join(", ")}), so one recording grants ${keys.size} \xD7 maxPlays. Put the questions that share a recording in an item group \u2014 a group's stimulus is one recording with one budget.`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const bindingFor = (key, slot) => {
|
|
226
|
+
if (mediaBudget === void 0) {
|
|
227
|
+
return void 0;
|
|
228
|
+
}
|
|
229
|
+
const entry = mediaSeedsApply && mediaPlays !== void 0 && Object.hasOwn(mediaPlays, key) ? mediaPlays[key] : void 0;
|
|
230
|
+
return {
|
|
231
|
+
key,
|
|
232
|
+
slotId: slot.slotId,
|
|
233
|
+
index: slot.index,
|
|
234
|
+
activityId: slot.activity.id,
|
|
235
|
+
...entry !== void 0 ? { entry } : {},
|
|
236
|
+
...mediaBudget.enforced !== void 0 ? { enforced: mediaBudget.enforced } : {},
|
|
237
|
+
...mediaBudget.onPlayConsumed !== void 0 ? { onPlayConsumed: mediaBudget.onPlayConsumed } : {},
|
|
238
|
+
...mediaBudget.onPlayRefunded !== void 0 ? { onPlayRefunded: mediaBudget.onPlayRefunded } : {},
|
|
239
|
+
...mediaBudget.onPosition !== void 0 ? { onPosition: mediaBudget.onPosition } : {},
|
|
240
|
+
...mediaBudget.strings !== void 0 ? { strings: mediaBudget.strings } : {}
|
|
241
|
+
};
|
|
242
|
+
};
|
|
173
243
|
const forwarded = {
|
|
174
244
|
...onInteraction ? { onInteraction } : {},
|
|
175
245
|
renderMode,
|
|
@@ -184,6 +254,7 @@ function ActivitySequence({
|
|
|
184
254
|
const activityId = activity.id;
|
|
185
255
|
const restored = seedsApply && responses !== void 0 && Object.hasOwn(responses, slotId) ? responses[slotId] : void 0;
|
|
186
256
|
const slotOutcome = outcomes !== void 0 && Object.hasOwn(outcomes, slotId) ? outcomes[slotId] : void 0;
|
|
257
|
+
const slotBinding = bindingFor(slotMediaKey(slotId), slot);
|
|
187
258
|
const childProps = {
|
|
188
259
|
// `defaultValue`, not `value`: the learner must be able to keep editing
|
|
189
260
|
// a restored answer. A controlled `value` would freeze it unless the
|
|
@@ -198,7 +269,8 @@ function ActivitySequence({
|
|
|
198
269
|
record({ kind: "responded", index: slotIndex, slotId, activityId, response });
|
|
199
270
|
}
|
|
200
271
|
},
|
|
201
|
-
...forwarded
|
|
272
|
+
...forwarded,
|
|
273
|
+
...slotBinding !== void 0 ? { mediaBudget: slotBinding } : {}
|
|
202
274
|
};
|
|
203
275
|
const CustomRenderer = renderers?.[activity.type];
|
|
204
276
|
if (CustomRenderer) {
|
|
@@ -218,16 +290,13 @@ function ActivitySequence({
|
|
|
218
290
|
return /* @__PURE__ */ jsx(FillInTheBlanks, { data: activity, ...childProps });
|
|
219
291
|
}
|
|
220
292
|
if (activity.type === "written-response") {
|
|
293
|
+
const { onComplete: _neverGrades, ...writtenResponseProps } = childProps;
|
|
221
294
|
return /* @__PURE__ */ jsx(
|
|
222
295
|
WrittenResponse,
|
|
223
296
|
{
|
|
224
297
|
data: activity,
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
...restored !== void 0 ? { defaultValue: restored } : {},
|
|
228
|
-
...seedsApply && submitted.has(slotId) ? { defaultSubmitted: true } : {},
|
|
229
|
-
...slotOutcome !== void 0 ? { outcome: slotOutcome } : {},
|
|
230
|
-
...forwarded
|
|
298
|
+
...writtenResponseProps,
|
|
299
|
+
onSubmitted: (submission) => record({ kind: "submitted", index: slotIndex, slotId, activityId, submission })
|
|
231
300
|
}
|
|
232
301
|
);
|
|
233
302
|
}
|
|
@@ -241,23 +310,31 @@ function ActivitySequence({
|
|
|
241
310
|
" of ",
|
|
242
311
|
total
|
|
243
312
|
] }),
|
|
244
|
-
stimulusMounts.map(({ entryKey, group, first, last }) =>
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
313
|
+
stimulusMounts.map(({ entryKey, group, first, last }) => {
|
|
314
|
+
const firstSlot = slots[first];
|
|
315
|
+
const stimulusBinding = firstSlot === void 0 ? void 0 : bindingFor(stimulusMediaKey(firstSlot.slotId), firstSlot);
|
|
316
|
+
return /* @__PURE__ */ jsx(
|
|
317
|
+
SequencePane,
|
|
318
|
+
{
|
|
319
|
+
className: "lk-seq-stimulus",
|
|
320
|
+
hidden: entryKey !== currentEntryKey,
|
|
321
|
+
children: /* @__PURE__ */ jsx(
|
|
322
|
+
StimulusPanel,
|
|
323
|
+
{
|
|
324
|
+
stimulus: group.stimulus,
|
|
325
|
+
range: { first: first + 1, last: last + 1 },
|
|
326
|
+
renderMode,
|
|
327
|
+
...sanitizeHtml ? { sanitizeHtml } : {},
|
|
328
|
+
...locale ? { locale } : {},
|
|
329
|
+
...onInteraction ? { onInteraction } : {},
|
|
330
|
+
...disabled ? { disabled } : {},
|
|
331
|
+
...stimulusBinding !== void 0 ? { mediaBudget: stimulusBinding } : {}
|
|
332
|
+
}
|
|
333
|
+
)
|
|
334
|
+
},
|
|
335
|
+
`${setKey}::group-${entryKey}`
|
|
336
|
+
);
|
|
337
|
+
}),
|
|
261
338
|
/* @__PURE__ */ jsx(
|
|
262
339
|
"section",
|
|
263
340
|
{
|
|
@@ -297,5 +374,5 @@ function ActivitySequence({
|
|
|
297
374
|
}
|
|
298
375
|
|
|
299
376
|
export { ActivitySequence };
|
|
300
|
-
//# sourceMappingURL=chunk-
|
|
301
|
-
//# sourceMappingURL=chunk-
|
|
377
|
+
//# sourceMappingURL=chunk-3VQV7ICQ.js.map
|
|
378
|
+
//# sourceMappingURL=chunk-3VQV7ICQ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/components/ActivitySequence/ActivitySequence.tsx"],"names":["current"],"mappings":"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","file":"chunk-3VQV7ICQ.js","sourcesContent":["'use client';\n\nimport {\n type ActivityMedia,\n type ActivityResult,\n flattenSequence,\n type InteractionEvent,\n type ItemOutcome,\n isItemGroup,\n type LearnerResponse,\n resolvePlaybackPolicy,\n type SequenceEntry,\n type SequenceSlot,\n type SequenceSlotGroup,\n slotMediaKey,\n stimulusMediaKey,\n type ThemeTokens,\n} from '@intellectif/lk-core';\nimport { type ComponentType, useEffect, useMemo, useRef, useState } from 'react';\nimport { randomSessionId } from '../_internal.js';\nimport { FillInTheBlanks } from '../FillInTheBlanks/index.js';\nimport { MultipleChoice } from '../MultipleChoice/index.js';\nimport { StimulusPanel } from '../StimulusPanel/index.js';\nimport type {\n ActivityProps,\n HtmlSanitizer,\n MediaBudgetBinding,\n RenderableActivity,\n RenderMode,\n SequenceMediaBudget,\n} from '../types.js';\nimport { WrittenResponse, type WrittenResponseSubmission } from '../WrittenResponse/index.js';\n\n/**\n * What one finished slot in a sequence produced. Two kinds, because two kinds\n * of activity exist: those the SDK scores at submit time, and those a grader\n * scores later. Collapsing them would mean inventing a score for ungraded\n * work — the exact defect the deferred outcome exists to prevent.\n */\nexport type SequenceItemOutcome =\n | {\n kind: 'scored';\n /** Presented position. */\n index: number;\n /** Slot identity from `flattenSequence` — stable under shuffling; the `slotId` to score with. */\n slotId: string;\n activityId: string;\n result: ActivityResult;\n }\n | {\n kind: 'submitted';\n index: number;\n slotId: string;\n activityId: string;\n /** Ungraded submission; the grade arrives asynchronously. */\n submission: WrittenResponseSubmission;\n }\n | {\n kind: 'restored';\n index: number;\n slotId: string;\n activityId: string;\n /**\n * The answer as restored, when one was. A slot can be submitted with no\n * stored response (a blank the learner committed), so this is optional —\n * inventing one would be worse than admitting it is absent.\n */\n response?: LearnerResponse;\n }\n | {\n kind: 'responded';\n index: number;\n slotId: string;\n activityId: string;\n /**\n * The raw answer, with no grade of any kind. This is what an `exam` slot\n * produces: the client is forbidden to score, so a response is all there\n * is until the server grades it.\n */\n response: LearnerResponse;\n };\n\n/**\n * A component that can render one activity inside a sequence. Register one per\n * activity `type` to put a consumer-defined type on screen — the React half of\n * the activity-type registry, matching `registerActivityType` in lk-core.\n */\nexport type ActivityRenderer = ComponentType<ActivityProps>;\n\nexport interface ActivitySequenceProps {\n /**\n * The ordered set of entries to present, one question at a time: loose\n * activities, and item groups — a shared passage, recording or image with\n * the questions that refer to it. A group's stimulus stays on screen\n * alongside each of its questions.\n *\n * Accepts `redact()` projections. For data straight off a server, pass it\n * through `asRenderableSequence()` — see that helper for why a cast is\n * unavoidable there — and set `renderMode=\"exam\"`.\n */\n activities: readonly SequenceEntry<RenderableActivity>[];\n /**\n * Renderers for activity types beyond the built-ins, keyed by `data.type`.\n * A key matching a built-in overrides it, so a consumer can replace the\n * bundled renderer without forking the sequencer.\n */\n renderers?: Readonly<Record<string, ActivityRenderer>>;\n /**\n * Called whenever an individual activity is scored at submit time — the\n * incremental persist hook.\n *\n * `slotId` is the identity to store the result against, NOT `index`.\n * `index` is where the question was PRESENTED, which is not stable: it moves\n * under shuffling, and it already differs from the slot identity whenever a\n * group is present (the second entry of a sequence is presented at index 1\n * but is slot `\"1.0\"` if it is a group's first question). `composeAssessmentScore`\n * scores by `slotId`, so persisting `index` cannot be reconciled with it.\n */\n onActivityComplete?: (result: ActivityResult, index: number, slotId: string) => void;\n /**\n * Called on every submit with the learner's raw response and the identity of\n * the slot that produced it — before any grading, in every render mode.\n *\n * **This is the only response channel an `exam` sequence has.** In `exam`\n * mode the components never grade, so `onActivityComplete` cannot fire and\n * `onComplete` never will; without this prop nothing a learner submitted\n * would reach the consumer at all. Persist `response` against `slotId`.\n */\n onSubmit?: (\n response: LearnerResponse,\n slot: { slotId: string; index: number; activityId: string },\n ) => void;\n /**\n * Called once every activity has been completed, with the scored results.\n *\n * Fires only when EVERY item produced a score. A set containing a\n * deferred-graded activity (a written response) can never satisfy that, so\n * use `onFinished` for mixed sets — it is the general completion signal and\n * reports both kinds of outcome. It also carries `slotId` per item, which\n * this callback's bare array cannot.\n */\n onComplete?: (results: ActivityResult[]) => void;\n /**\n * Called once every activity has been completed, whether it was scored at\n * submit time or submitted for later grading. Use this for any set that\n * mixes graded and deferred-graded activities.\n */\n onFinished?: (items: SequenceItemOutcome[]) => void;\n /** Forwarded to each activity. */\n onInteraction?: (event: InteractionEvent) => void;\n /** Forwarded to each activity. `exam` and `review` disable local scoring. */\n renderMode?: RenderMode;\n /**\n * Binds every budgeted recording in this sequence to a play ledger the\n * consumer persists.\n *\n * The SDK refuses a play; it does not remember one. `plays` seeds the counts\n * at mount (pass `restoreMediaPlayLedger(plan, stored).entries`), and\n * `onPlayConsumed` is how a play becomes durable — see\n * {@link SequenceMediaBudget}.\n */\n mediaBudget?: SequenceMediaBudget;\n /**\n * `entries` shuffles the top-level entries; a group moves as one block, and\n * the order INSIDE a group follows the group's own `shuffle` setting.\n * Default `none`: authored order.\n */\n shuffle?: 'none' | 'entries';\n /**\n * Seed for every shuffle in this sequence (`shuffle: 'entries'` and any\n * group with `shuffle: 'within-group'`). Supply the attempt id, so the\n * server's `flattenSequence(entries, { seed })` derives the same order this\n * pager shows.\n *\n * **Required whenever this sequence shuffles** — `shuffle: 'entries'`, or any\n * group with `shuffle: 'within-group'` — in `exam` or `review` mode; omitting\n * it then throws. An order nobody can reproduce cannot be reconciled with a\n * recorded attempt, and failing at render is the only way that mistake\n * surfaces before a learner sits the paper. A sequence that does not shuffle\n * needs no seed in any mode.\n *\n * Note the guard does NOT cover an activity's own `data.shuffle`:\n * `<MultipleChoice>` falls back to a per-mount seed there in every mode, so\n * pass this whenever any item shuffles its options.\n *\n * In `practice` mode it stays optional: a random per-mount seed is used,\n * stable within the mount and deliberately not reproducible. That fallback is\n * also not SSR-safe (server and client would invent different orders and\n * hydration would mismatch), so supply a seed for any server-rendered\n * sequence regardless of mode.\n */\n shuffleSeed?: string;\n /**\n * Where to open. Defaults to the first question; pass a stored\n * `AttemptState.index` to reopen an interrupted attempt where it was left.\n * Read at mount only, like any `default*` prop.\n */\n defaultIndex?: number;\n /**\n * Fires whenever the learner moves. Persist it and the next resume reopens\n * on the right question — without it, the pager's position is the one piece\n * of an attempt a consumer cannot recover.\n */\n onIndexChange?: (index: number) => void;\n /**\n * Answers to restore, keyed by `slotId`, seeded into each slot as its\n * `defaultValue`. This is the other half of resume: `AttemptState.responses`\n * goes straight in.\n *\n * Read at mount only. To restore a different attempt, remount with a `key`.\n */\n responses?: Readonly<Record<string, LearnerResponse>>;\n /**\n * Slots the learner had already submitted, from `AttemptState.submittedSlotIds`.\n * Each is mounted already submitted, so a resumed paper does not reopen a\n * locked question as answerable — without this a learner can change and\n * re-submit work they had already committed.\n *\n * Read at mount only, alongside `responses`.\n */\n submittedSlotIds?: readonly string[];\n /**\n * Server-computed outcomes keyed by `slotId`, forwarded to each slot. In\n * `review` mode this is what marks correctness — the client never scores, so\n * without it a review render has nothing to show.\n */\n outcomes?: Readonly<Record<string, ItemOutcome>>;\n /** Renders author-supplied rich text; forwarded to the stimulus panel and every activity. */\n sanitizeHtml?: HtmlSanitizer;\n theme?: Partial<ThemeTokens>;\n locale?: string;\n disabled?: boolean;\n}\n\n/** One stimulus panel to keep mounted: the group, and the presented span of its questions. */\ninterface StimulusMount {\n /** Authored entry position — unique per group in the sequence even if two groups share an id. */\n entryKey: string;\n group: SequenceSlotGroup;\n first: number;\n last: number;\n}\n\n/** The authored-entry prefix of a slot id: `\"3\"` for `\"3\"` and for `\"3.1\"`. */\nfunction entryKeyOf(slot: SequenceSlot<RenderableActivity>): string {\n return slot.slotId.split('.')[0] ?? slot.slotId;\n}\n\n/**\n * A pane that is kept MOUNTED but shown only when it is the current one, and\n * that stops any media it contains on the way out.\n *\n * `hidden` alone is not enough. It resolves to `display: none`, and CSS does\n * not touch playback: an `<audio>` or `<video>` inside a hidden subtree keeps\n * playing to the end. (The HTML spec pauses a media element when it is\n * REMOVED from the document — a different thing, and the thing this pager\n * deliberately does not do, because unmounting is what used to destroy the\n * learner's answers.) So a recording started for one question would carry on\n * underneath the next, which for a listening paper means the passage plays\n * while the learner is somewhere else entirely.\n *\n * Pausing preserves `currentTime`: coming back to a group resumes where the\n * learner left off. Nothing auto-plays on the way in — starting audio the\n * learner did not ask for is its own defect.\n *\n * `embed` media (a provider iframe) cannot be paused this way: controlling a\n * third-party player needs its own JS API, which the SDK has no reliable\n * access to because the author supplies the embed URL. Use `audio`/`video`\n * media for anything that must stop when the learner navigates.\n */\nfunction SequencePane({\n className,\n hidden,\n children,\n}: {\n className: string;\n hidden: boolean;\n children: React.ReactNode;\n}): React.JSX.Element {\n const ref = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n if (!hidden) {\n return;\n }\n const node = ref.current;\n if (node === null) {\n return;\n }\n for (const media of node.querySelectorAll('audio, video')) {\n const element = media as HTMLMediaElement;\n if (!element.paused) {\n element.pause();\n }\n }\n }, [hidden]);\n\n return (\n <div className={className} hidden={hidden} ref={ref}>\n {children}\n </div>\n );\n}\n\n/**\n * Presents a set of activities as an in-place pager: one question visible at a\n * time, navigated with Prev/Next (no page scrolling). Linear free navigation —\n * the learner may move back and forth; activities keep their own submit and\n * scoring. On navigation, focus moves to the question region and the position\n * is announced via an aria-live region.\n *\n * Item groups are flattened into consecutive questions; the group's stimulus\n * is mounted ONCE, beside the question region rather than inside it, and shown\n * alongside every question in the group — so a passage stays put, and a\n * recording keeps its position, as the learner moves between its questions.\n */\n/**\n * Whether an activity shuffles its own options, and therefore needs a seed of\n * its own. Read structurally: `shuffle` is a per-type content field, not part\n * of the sequence entry contract.\n */\nfunction shufflesItsOwnOptions(entry: unknown): boolean {\n return (entry as { shuffle?: unknown } | null)?.shuffle === true;\n}\n\nexport function ActivitySequence({\n activities,\n renderers,\n onActivityComplete,\n onComplete,\n onFinished,\n onSubmit,\n onInteraction,\n renderMode = 'practice',\n shuffle,\n shuffleSeed,\n defaultIndex,\n onIndexChange,\n responses,\n submittedSlotIds,\n outcomes,\n sanitizeHtml,\n theme,\n locale,\n disabled,\n mediaBudget,\n}: ActivitySequenceProps): React.JSX.Element {\n const sessionIdRef = useRef<string | null>(null);\n const mediaPlays = mediaBudget?.plays;\n\n // The presented order comes from lk-core, never computed here: the server\n // that records an attempt calls the same function with the same seed.\n const shuffleEntries = shuffle === 'entries';\n // Every shuffle this sequence can produce, not just the ones it performs\n // itself. An ACTIVITY's own `data.shuffle` is the third source: MultipleChoice\n // falls back to a per-mount seed when none reaches it, so an unseeded item\n // shuffle used to render happily under `exam` and hand the learner a\n // different option order on every mount — exactly the unreproducible\n // arrangement this guard exists to refuse, arriving through the one door it\n // did not watch.\n const needsSeed =\n shuffleEntries ||\n activities.some((entry) =>\n isItemGroup(entry)\n ? entry.shuffle === 'within-group' || entry.items.some(shufflesItsOwnOptions)\n : shufflesItsOwnOptions(entry),\n );\n\n // An unreproducible order is a practice-only affordance. Under `exam` or\n // `review` the server has to be able to rebuild exactly what the learner\n // saw, so a missing seed is an error rather than something to paper over —\n // the same reason `flattenSequence` refuses to invent one.\n if (needsSeed && shuffleSeed === undefined && renderMode !== 'practice') {\n throw new Error(\n `ActivitySequence: renderMode \"${renderMode}\" requires a \\`shuffleSeed\\` when shuffling ` +\n '(shuffle=\"entries\", or a group with shuffle: \"within-group\"). Pass the attempt id, so ' +\n \"the server's flattenSequence(entries, { seed }) reproduces the order the learner saw.\",\n );\n }\n\n // Lazily created only when shuffling in practice without a caller seed (same\n // contract as MultipleChoice.shuffleSeed): a per-mount session seed, stable\n // across re-renders, never reproducible.\n const seed = needsSeed\n ? // biome-ignore lint/suspicious/noAssignInExpressions: sanctioned lazy ref initialization\n (shuffleSeed ?? (sessionIdRef.current ??= randomSessionId()))\n : undefined;\n const slots = useMemo(\n () => flattenSequence(activities, { shuffleEntries, ...(seed !== undefined ? { seed } : {}) }),\n [activities, shuffleEntries, seed],\n );\n\n // Clamped rather than trusted: a stored position from a paper that has since\n // lost its last question would open the pager on a slot that is not there,\n // which renders as an empty shell with no way forward.\n const [index, setIndex] = useState(() => {\n // NaN passes through Math.min/Math.max unchanged, so it survived every\n // clamp and indexed the slots with NaN — the pager then rendered the empty\n // shell this clamp exists to prevent. `NaN` is a `number`, so the prop type\n // gives no protection, and `Number(row.last_index)` on a NULL column\n // produces exactly that.\n const requested = Math.trunc(defaultIndex ?? 0);\n if (!Number.isFinite(requested)) {\n return 0;\n }\n return Math.min(Math.max(0, requested), Math.max(0, slots.length - 1));\n });\n // Outcomes live in a ref, not state: nothing renders from them, and a ref is\n // written SYNCHRONOUSLY. Reading them from a `useState` closure meant two\n // slots completing in the same tick both saw the pre-update array — the\n // second overwrote the first, so one answer vanished and completion never\n // fired. Every slot is mounted at once here, so same-tick completions are\n // reachable (a custom renderer that completes from a mount effect).\n const outcomesRef = useRef<(SequenceItemOutcome | null)[] | null>(null);\n if (outcomesRef.current === null) {\n // Slots the learner had already submitted are seeded as `restored`. They\n // will not submit again — they mount locked — so leaving them null meant\n // `onFinished` waited forever on outcomes that could never arrive, and a\n // resumed attempt could never signal completion however many of the\n // remaining questions the learner answered. Seeding fires no callback: the\n // attempt was already this far along before this mount existed.\n const submittedAtMount = new Set(submittedSlotIds ?? []);\n outcomesRef.current = slots.map((slot) =>\n submittedAtMount.has(slot.slotId)\n ? {\n kind: 'restored' as const,\n index: slot.index,\n slotId: slot.slotId,\n activityId: slot.activity.id,\n ...(responses !== undefined && Object.hasOwn(responses, slot.slotId)\n ? { response: responses[slot.slotId] as LearnerResponse }\n : {}),\n }\n : null,\n );\n }\n const regionRef = useRef<HTMLDivElement>(null);\n const mountedRef = useRef(false);\n\n // Reset pager position and outcomes when the presented SET changes (B8):\n // without this, results from the previous set leak into the new one and can\n // fire completion with a mixed old/new array. Keyed on the set's CONTENT\n // identity (ordered slot and activity ids), not the array reference — a\n // parent that re-creates a structurally identical array on every render\n // (inline literals, .map() in render) must not wipe in-progress answers.\n // JSON.stringify (not join) so ids containing the separator cannot collide.\n const setKey = JSON.stringify(slots.map((slot) => [slot.slotId, slot.activity.id]));\n const [prevSetKey, setPrevSetKey] = useState(setKey);\n // The set this component mounted with. `responses` seeds slots BY slotId,\n // and slot ids are short and repeat across papers (\"0\", \"1.0\"), so applying\n // them after the set changed would drop one paper's answers under another\n // paper's questions — a review modal stepping to the next attempt did\n // exactly that. Seeding stops at the first set change; to show a different\n // attempt, remount with a `key`, which is what \"read at mount only\" means.\n // `mediaBudget.resumeKey` participates so an invigilator can hand a play\n // back — re-seeding the budgets in place — without remounting the pager and\n // costing the learner their focus, their scroll position and an unsaved\n // answer.\n const seedKey = `${setKey}::${mediaBudget?.resumeKey ?? ''}`;\n const mountSetKeyRef = useRef(setKey);\n const mountSeedKeyRef = useRef(seedKey);\n const seedsApply = mountSetKeyRef.current === setKey;\n const mediaSeedsApply = mountSeedKeyRef.current === seedKey || seedsApply;\n if (mountSeedKeyRef.current !== seedKey) {\n mountSeedKeyRef.current = seedKey;\n }\n const submitted = useMemo(() => new Set(submittedSlotIds ?? []), [submittedSlotIds]);\n if (prevSetKey !== setKey) {\n setPrevSetKey(setKey);\n setIndex(0);\n outcomesRef.current = slots.map(() => null);\n }\n\n // After a navigation (not the initial mount) move focus to the question\n // region so screen-reader / keyboard users land on the new question.\n // biome-ignore lint/correctness/useExhaustiveDependencies: index is the intended trigger\n useEffect(() => {\n if (mountedRef.current) {\n regionRef.current?.focus();\n } else {\n mountedRef.current = true;\n }\n }, [index]);\n\n // Report where the pager actually IS, from an effect rather than from the\n // navigation handler. Two positions the learner never chose are still\n // positions a consumer has to persist: a `defaultIndex` that was clamped\n // (stored 99, showing 2) and the reset a set change performs. Reporting only\n // clicks left storage disagreeing with the screen, and the next autosave\n // then wrote a position the plan does not have.\n const reportedIndexRef = useRef<number | null>(null);\n useEffect(() => {\n if (reportedIndexRef.current === index) {\n return;\n }\n const first = reportedIndexRef.current === null;\n reportedIndexRef.current = index;\n // On mount, stay silent unless the requested position was corrected.\n if (first && index === Math.trunc(defaultIndex ?? 0)) {\n return;\n }\n onIndexChange?.(index);\n }, [index, defaultIndex, onIndexChange]);\n\n // One panel per group, spanning the presented positions of its questions.\n const stimulusMounts = useMemo<StimulusMount[]>(() => {\n const mounts = new Map<string, StimulusMount>();\n for (const slot of slots) {\n if (slot.group === undefined) {\n continue;\n }\n const entryKey = entryKeyOf(slot);\n const existing = mounts.get(entryKey);\n if (existing === undefined) {\n mounts.set(entryKey, { entryKey, group: slot.group, first: slot.index, last: slot.index });\n } else {\n existing.last = slot.index;\n }\n }\n return [...mounts.values()];\n }, [slots]);\n\n const total = slots.length;\n const current = slots[index];\n\n const go = (next: number): void => {\n setIndex(next);\n };\n\n /**\n * Records a finished slot and fires completion callbacks when the set is\n * done. A slot that already holds an outcome is FINAL: re-recording it would\n * fire completion a second time for one attempt, so a consumer persisting on\n * `onFinished` would write the same attempt twice.\n */\n const record = (outcome: SequenceItemOutcome): void => {\n const current = outcomesRef.current;\n if (current === null || current[outcome.index] != null) {\n return;\n }\n const next = current.slice();\n next[outcome.index] = outcome;\n // Commit before firing anything: a second record() in the same tick must\n // see this one.\n outcomesRef.current = next;\n\n if (outcome.kind === 'scored') {\n onActivityComplete?.(outcome.result, outcome.index, outcome.slotId);\n }\n\n if (!next.every((item): item is SequenceItemOutcome => item !== null)) {\n return;\n }\n onFinished?.(next);\n\n // `onComplete` predates deferred grading and promises ActivityResult[].\n // Fire it only when every slot really was scored, rather than inventing a\n // result for work nobody has graded.\n const scored: ActivityResult[] = [];\n for (const item of next) {\n if (item.kind !== 'scored') {\n return;\n }\n scored.push(item.result);\n }\n onComplete?.(scored);\n };\n\n if (!current) {\n return <div className=\"lk-seq\" />;\n }\n\n // Every budgeted recording this paper presents, and the key it is budgeted\n // under. Derived from the resolved policy, so the pager, the plan and the\n // schema cannot disagree about what is budgeted.\n const budgetedMedia = slots.flatMap((slot) => {\n const found: { key: string; url: string }[] = [];\n const own = (slot.activity as { media?: ActivityMedia }).media;\n if (own !== undefined && resolvePlaybackPolicy(own).maxPlays !== null) {\n found.push({ key: slotMediaKey(slot.slotId), url: own.url });\n }\n const stimulus = slot.group?.stimulus.media;\n if (stimulus !== undefined && resolvePlaybackPolicy(stimulus).maxPlays !== null) {\n found.push({ key: stimulusMediaKey(slot.slotId), url: stimulus.url });\n }\n return found;\n });\n\n const budgetEnforced = mediaBudget?.enforced ?? renderMode !== 'review';\n\n if (budgetedMedia.length > 0 && renderMode === 'exam' && budgetEnforced) {\n if (mediaBudget?.onPlayConsumed === undefined) {\n throw new Error(\n 'ActivitySequence: renderMode \"exam\" with media that declares `maxPlays` requires ' +\n '`mediaBudget.onPlayConsumed`. The SDK persists nothing, so without it the count lives ' +\n 'only in this mount: a refresh silently restores the full budget, and a paper that ' +\n 'grants unlimited plays while showing \"2 plays remaining\" is indistinguishable — to the ' +\n 'learner and to an appeal — from one that works. Budgeted media: ' +\n `${[...new Set(budgetedMedia.map((m) => m.key))].join(', ')}.`,\n );\n }\n const resuming =\n (responses !== undefined && Object.keys(responses).length > 0) ||\n (submittedSlotIds !== undefined && submittedSlotIds.length > 0);\n if (resuming && mediaBudget.plays === undefined) {\n throw new Error(\n 'ActivitySequence: this is a resumed attempt (`responses`/`submittedSlotIds` were ' +\n 'supplied) but `mediaBudget.plays` is absent. A resume that omits the ledger hands the ' +\n 'learner a fresh budget at exactly the moment it matters. Pass ' +\n '`restoreMediaPlayLedger(plan, stored).entries`, or `{}` to state explicitly that ' +\n 'nothing was spent.',\n );\n }\n }\n\n // One recording must not carry two budgets, in any mode — six questions each\n // holding the same clip at maxPlays 2 is twelve plays of one recording.\n const keysByUrl = new Map<string, Set<string>>();\n for (const { key, url } of budgetedMedia) {\n const keys = keysByUrl.get(url) ?? new Set<string>();\n keys.add(key);\n keysByUrl.set(url, keys);\n }\n for (const [url, keys] of keysByUrl) {\n if (keys.size > 1) {\n throw new Error(\n `ActivitySequence: media ${JSON.stringify(url)} is budgeted under ${keys.size} separate ` +\n `keys (${[...keys].join(', ')}), so one recording grants ${keys.size} × maxPlays. Put ` +\n \"the questions that share a recording in an item group — a group's stimulus is one \" +\n 'recording with one budget.',\n );\n }\n }\n\n /** The per-media binding handed to a component or to the stimulus panel. */\n const bindingFor = (\n key: string,\n slot: { slotId: string; index: number; activity: { id: string } },\n ): MediaBudgetBinding | undefined => {\n if (mediaBudget === undefined) {\n return undefined;\n }\n const entry =\n mediaSeedsApply && mediaPlays !== undefined && Object.hasOwn(mediaPlays, key)\n ? mediaPlays[key]\n : undefined;\n return {\n key,\n slotId: slot.slotId,\n index: slot.index,\n activityId: slot.activity.id,\n ...(entry !== undefined ? { entry } : {}),\n ...(mediaBudget.enforced !== undefined ? { enforced: mediaBudget.enforced } : {}),\n ...(mediaBudget.onPlayConsumed !== undefined\n ? { onPlayConsumed: mediaBudget.onPlayConsumed }\n : {}),\n ...(mediaBudget.onPlayRefunded !== undefined\n ? { onPlayRefunded: mediaBudget.onPlayRefunded }\n : {}),\n ...(mediaBudget.onPosition !== undefined ? { onPosition: mediaBudget.onPosition } : {}),\n ...(mediaBudget.strings !== undefined ? { strings: mediaBudget.strings } : {}),\n };\n };\n\n const forwarded = {\n ...(onInteraction ? { onInteraction } : {}),\n renderMode,\n ...(sanitizeHtml ? { sanitizeHtml } : {}),\n ...(theme ? { theme } : {}),\n ...(locale ? { locale } : {}),\n ...(disabled ? { disabled } : {}),\n };\n\n const renderSlot = (slot: SequenceSlot<RenderableActivity>): React.JSX.Element => {\n const activity = slot.activity;\n const { slotId, index: slotIndex } = slot;\n const activityId = activity.id;\n const restored =\n seedsApply && responses !== undefined && Object.hasOwn(responses, slotId)\n ? responses[slotId]\n : undefined;\n const slotOutcome =\n outcomes !== undefined && Object.hasOwn(outcomes, slotId) ? outcomes[slotId] : undefined;\n const slotBinding = bindingFor(slotMediaKey(slotId), slot);\n const childProps = {\n // `defaultValue`, not `value`: the learner must be able to keep editing\n // a restored answer. A controlled `value` would freeze it unless the\n // consumer also threaded state back, which is not what resume means.\n ...(restored !== undefined ? { defaultValue: restored } : {}),\n ...(seedsApply && submitted.has(slotId) ? { defaultSubmitted: true } : {}),\n ...(slotOutcome !== undefined ? { outcome: slotOutcome } : {}),\n onComplete: (result: ActivityResult) =>\n record({ kind: 'scored', index: slotIndex, slotId, activityId, result }),\n onSubmit: (response: LearnerResponse) => {\n onSubmit?.(response, { slotId, index: slotIndex, activityId });\n // Outside `practice` the components do not grade, so a raw response is\n // the ONLY outcome this slot will ever produce — record it, or the set\n // could never complete and `onFinished` would be dead in exam mode.\n // Written responses are excluded because they report something richer\n // through `onSubmitted` a moment later, and the first outcome wins.\n if (renderMode !== 'practice' && activity.type !== 'written-response') {\n record({ kind: 'responded', index: slotIndex, slotId, activityId, response });\n }\n },\n ...forwarded,\n ...(slotBinding !== undefined ? { mediaBudget: slotBinding } : {}),\n };\n\n const CustomRenderer = renderers?.[activity.type];\n if (CustomRenderer) {\n return <CustomRenderer data={activity} {...childProps} />;\n }\n if (activity.type === 'multiple-choice') {\n return (\n <MultipleChoice\n data={activity}\n // The sequence seed reaches the OPTION shuffle too. Without it a\n // resumed or reviewed item invented a fresh per-mount order, so the\n // learner saw their answers against a different arrangement than the\n // one they sat — the exact reproducibility the attempt id is for.\n {...(shuffleSeed !== undefined ? { shuffleSeed } : {})}\n {...childProps}\n />\n );\n }\n if (activity.type === 'fill-in-the-blanks') {\n return <FillInTheBlanks data={activity} {...childProps} />;\n }\n if (activity.type === 'written-response') {\n // Derived from the SAME bag the other two get, minus the one prop this\n // component genuinely does not have. Listing the props by hand is what\n // made this the branch left behind three separate times —\n // `defaultSubmitted`, the redacted-in-`practice` guard and the media\n // budget were each added to `childProps` and each silently missed here.\n // Subtracting from the shared bag cannot forget a prop that is added to\n // it; listing them can.\n const { onComplete: _neverGrades, ...writtenResponseProps } = childProps;\n return (\n <WrittenResponse\n data={activity}\n {...writtenResponseProps}\n onSubmitted={(submission) =>\n record({ kind: 'submitted', index: slotIndex, slotId, activityId, submission })\n }\n />\n );\n }\n return (\n <div className=\"lk-seq-unsupported\" role=\"note\">\n This activity type has no renderer. Supply one through the renderers prop.\n </div>\n );\n };\n\n const currentEntryKey = entryKeyOf(current);\n\n return (\n <div className=\"lk-seq\" lang={locale}>\n <div className=\"lk-seq-progress\" aria-live=\"polite\" role=\"status\">\n Question {index + 1} of {total}\n </div>\n\n {/*\n Each group's stimulus is mounted ONCE and only hidden while the current\n question is outside its group. That — not re-rendering it per slot — is\n what makes the passage the same DOM node throughout, and what lets a\n recording keep its position between questions of one group.\n\n It sits BESIDE the question region, not inside it. Inside, every\n navigation dropped focus onto a container whose first content was the\n whole passage: the region promised \"Question 3 of 5\" and then began\n with the passage, keyboard users tabbed through the stimulus (audio\n controls included) before reaching the question, and the passage was a\n landmark nested inside another landmark. As a sibling it stays a\n landmark the learner can jump back to, while focus lands on the\n question that was actually navigated to.\n */}\n {stimulusMounts.map(({ entryKey, group, first, last }) => {\n // `setKey` leads for the same reason it does on a slot pane: entry keys\n // are short and repeat across papers, so a set change to a DIFFERENT\n // paper that happens to reuse an entry key would not remount this\n // panel — and a mount-only budget seed would leak into the next paper.\n const firstSlot = slots[first];\n const stimulusBinding =\n firstSlot === undefined\n ? undefined\n : bindingFor(stimulusMediaKey(firstSlot.slotId), firstSlot);\n return (\n <SequencePane\n className=\"lk-seq-stimulus\"\n hidden={entryKey !== currentEntryKey}\n key={`${setKey}::group-${entryKey}`}\n >\n <StimulusPanel\n stimulus={group.stimulus}\n range={{ first: first + 1, last: last + 1 }}\n renderMode={renderMode}\n {...(sanitizeHtml ? { sanitizeHtml } : {})}\n {...(locale ? { locale } : {})}\n {...(onInteraction ? { onInteraction } : {})}\n {...(disabled ? { disabled } : {})}\n {...(stimulusBinding !== undefined ? { mediaBudget: stimulusBinding } : {})}\n />\n </SequencePane>\n );\n })}\n\n <section\n className=\"lk-seq-question\"\n ref={regionRef}\n tabIndex={-1}\n aria-label={`Question ${index + 1} of ${total}`}\n >\n {/*\n Every slot stays MOUNTED; only the current one is visible. Rendering\n just the current activity unmounted the others, so navigating back to\n an answered question showed it blank and re-answerable — the learner\n silently lost their answer and a second submit fired completion\n again. `hidden` removes the inactive slots from the accessibility\n tree and from tab order, so only the current question is reachable.\n */}\n {slots.map((slot) => {\n // The slot id (authored position) plus the activity id: the same\n // activity may legitimately fill two slots, and each must keep its\n // own answer state; and a different activity in the same slot must\n // remount rather than inherit the previous one's state.\n //\n // `setKey` leads, so a set change remounts every child. Without it a\n // pure REORDER changed `setKey` — clearing the outcomes — while each\n // child kept its key and therefore its `completed` state. Those\n // slots could not be answered again and no outcome would ever refill\n // them, so the sequence could never finish. A reset has to reset\n // both halves or neither. A parent re-creating a structurally\n // identical array leaves `setKey` untouched, so answers still\n // survive that.\n const slotKey = `${setKey}::slot-${slot.slotId}-${slot.activity.id}`;\n return (\n <SequencePane className=\"lk-seq-slot\" hidden={slot.index !== index} key={slotKey}>\n {renderSlot(slot)}\n </SequencePane>\n );\n })}\n </section>\n\n <div className=\"lk-seq-nav\">\n <button\n type=\"button\"\n className=\"lk-seq-prev\"\n onClick={() => go(index - 1)}\n disabled={index === 0}\n >\n Previous\n </button>\n <button\n type=\"button\"\n className=\"lk-seq-next\"\n onClick={() => go(index + 1)}\n disabled={index === total - 1}\n >\n Next\n </button>\n </div>\n </div>\n );\n}\n"]}
|