@intellectif/lk-react 18.0.0 → 19.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 (149) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +4 -2
  3. package/dist/{WrittenResponse-B4kY-U0v.d.ts → WrittenResponse-CeHY5u3W.d.ts} +1 -1
  4. package/dist/{WrittenResponse-Dl3zah22.d.cts → WrittenResponse-DT_Yqbq1.d.cts} +1 -1
  5. package/dist/ai/useAiHelp.cjs +20 -0
  6. package/dist/ai/useAiHelp.cjs.map +1 -0
  7. package/dist/ai/useAiHelp.d.cts +146 -0
  8. package/dist/ai/useAiHelp.d.ts +146 -0
  9. package/dist/ai/useAiHelp.js +7 -0
  10. package/dist/ai/useAiHelp.js.map +1 -0
  11. package/dist/{chunk-XEGBGKHV.cjs → chunk-2X2NZF46.cjs} +206 -52
  12. package/dist/chunk-2X2NZF46.cjs.map +1 -0
  13. package/dist/{chunk-V5BTVGPA.js → chunk-4NERCBOW.js} +4 -3
  14. package/dist/chunk-4NERCBOW.js.map +1 -0
  15. package/dist/{chunk-Z5XY66PO.cjs → chunk-55W2472T.cjs} +16 -16
  16. package/dist/chunk-55W2472T.cjs.map +1 -0
  17. package/dist/{chunk-HZMILQYO.js → chunk-5QCZZBOX.js} +5 -4
  18. package/dist/chunk-5QCZZBOX.js.map +1 -0
  19. package/dist/{chunk-H3IRVX7J.js → chunk-7NNMDZUU.js} +9 -9
  20. package/dist/chunk-7NNMDZUU.js.map +1 -0
  21. package/dist/{chunk-BAKLJKFZ.cjs → chunk-APZ6JFN6.cjs} +4 -29
  22. package/dist/chunk-APZ6JFN6.cjs.map +1 -0
  23. package/dist/{chunk-H63O5HPW.cjs → chunk-CQNKZ435.cjs} +8 -7
  24. package/dist/chunk-CQNKZ435.cjs.map +1 -0
  25. package/dist/chunk-EANGKAKG.cjs +32 -0
  26. package/dist/chunk-EANGKAKG.cjs.map +1 -0
  27. package/dist/{chunk-YTGRXD23.js → chunk-FEOFX4QW.js} +4 -25
  28. package/dist/chunk-FEOFX4QW.js.map +1 -0
  29. package/dist/{chunk-QJW3K6JN.js → chunk-G7AGM2Y7.js} +4 -3
  30. package/dist/chunk-G7AGM2Y7.js.map +1 -0
  31. package/dist/chunk-JVWLXVYG.cjs +27 -0
  32. package/dist/chunk-JVWLXVYG.cjs.map +1 -0
  33. package/dist/chunk-KGDGYRBL.cjs +117 -0
  34. package/dist/chunk-KGDGYRBL.cjs.map +1 -0
  35. package/dist/{chunk-UYQBEPNZ.js → chunk-KOFA54C7.js} +15 -8
  36. package/dist/chunk-KOFA54C7.js.map +1 -0
  37. package/dist/{chunk-UQIRDE52.cjs → chunk-LX2X4KC7.cjs} +6 -5
  38. package/dist/chunk-LX2X4KC7.cjs.map +1 -0
  39. package/dist/{chunk-RZ52SEYV.cjs → chunk-NGFTSRKO.cjs} +10 -9
  40. package/dist/chunk-NGFTSRKO.cjs.map +1 -0
  41. package/dist/{chunk-ALH6TUX2.js → chunk-NLTU5JFD.js} +5 -4
  42. package/dist/chunk-NLTU5JFD.js.map +1 -0
  43. package/dist/{chunk-6RRFH7C4.cjs → chunk-NZM3M5IR.cjs} +39 -54
  44. package/dist/chunk-NZM3M5IR.cjs.map +1 -0
  45. package/dist/{chunk-KWKB74KL.js → chunk-OWV7VRMQ.js} +198 -44
  46. package/dist/chunk-OWV7VRMQ.js.map +1 -0
  47. package/dist/chunk-PPFVTXGJ.js +27 -0
  48. package/dist/chunk-PPFVTXGJ.js.map +1 -0
  49. package/dist/chunk-QRT2W5N5.js +114 -0
  50. package/dist/chunk-QRT2W5N5.js.map +1 -0
  51. package/dist/{chunk-QHK6PTUR.js → chunk-SEQTGOOU.js} +5 -4
  52. package/dist/chunk-SEQTGOOU.js.map +1 -0
  53. package/dist/chunk-SXU26SFO.cjs +253 -0
  54. package/dist/chunk-SXU26SFO.cjs.map +1 -0
  55. package/dist/chunk-TEAVPUMX.js +22 -0
  56. package/dist/chunk-TEAVPUMX.js.map +1 -0
  57. package/dist/{chunk-KP57YCYQ.js → chunk-UXV3V5NH.js} +23 -35
  58. package/dist/chunk-UXV3V5NH.js.map +1 -0
  59. package/dist/{chunk-3TJSFPO4.cjs → chunk-WKA6FUR4.cjs} +26 -19
  60. package/dist/chunk-WKA6FUR4.cjs.map +1 -0
  61. package/dist/{chunk-QA42VDTA.cjs → chunk-XAIJNGTU.cjs} +12 -11
  62. package/dist/chunk-XAIJNGTU.cjs.map +1 -0
  63. package/dist/{chunk-6DTW5T3Z.js → chunk-XHF376ES.js} +5 -4
  64. package/dist/chunk-XHF376ES.js.map +1 -0
  65. package/dist/{chunk-TI23GIFM.cjs → chunk-XUQ4KRTT.cjs} +12 -11
  66. package/dist/chunk-XUQ4KRTT.cjs.map +1 -0
  67. package/dist/chunk-YIYMBCPV.js +250 -0
  68. package/dist/chunk-YIYMBCPV.js.map +1 -0
  69. package/dist/{chunk-JKMBFHER.cjs → chunk-ZME5FMED.cjs} +12 -11
  70. package/dist/chunk-ZME5FMED.cjs.map +1 -0
  71. package/dist/components/ActivityPreview.cjs +14 -11
  72. package/dist/components/ActivityPreview.d.cts +7 -3
  73. package/dist/components/ActivityPreview.d.ts +7 -3
  74. package/dist/components/ActivityPreview.js +13 -10
  75. package/dist/components/ActivitySequence.cjs +14 -11
  76. package/dist/components/ActivitySequence.d.cts +75 -11
  77. package/dist/components/ActivitySequence.d.ts +75 -11
  78. package/dist/components/ActivitySequence.js +13 -10
  79. package/dist/components/Dictation.cjs +7 -4
  80. package/dist/components/Dictation.d.cts +1 -1
  81. package/dist/components/Dictation.d.ts +1 -1
  82. package/dist/components/Dictation.js +6 -3
  83. package/dist/components/FillInTheBlanks.cjs +7 -4
  84. package/dist/components/FillInTheBlanks.d.cts +1 -1
  85. package/dist/components/FillInTheBlanks.d.ts +1 -1
  86. package/dist/components/FillInTheBlanks.js +6 -3
  87. package/dist/components/GapSelect.cjs +7 -4
  88. package/dist/components/GapSelect.d.cts +1 -1
  89. package/dist/components/GapSelect.d.ts +1 -1
  90. package/dist/components/GapSelect.js +6 -3
  91. package/dist/components/InteractiveVideo.cjs +14 -11
  92. package/dist/components/InteractiveVideo.d.cts +2 -2
  93. package/dist/components/InteractiveVideo.d.ts +2 -2
  94. package/dist/components/InteractiveVideo.js +12 -9
  95. package/dist/components/MultipleChoice.cjs +7 -4
  96. package/dist/components/MultipleChoice.d.cts +1 -1
  97. package/dist/components/MultipleChoice.d.ts +1 -1
  98. package/dist/components/MultipleChoice.js +6 -3
  99. package/dist/components/PronunciationFeedback.cjs +4 -3
  100. package/dist/components/PronunciationFeedback.d.cts +1 -1
  101. package/dist/components/PronunciationFeedback.d.ts +1 -1
  102. package/dist/components/PronunciationFeedback.js +3 -2
  103. package/dist/components/ReadAloud.cjs +6 -4
  104. package/dist/components/ReadAloud.d.cts +2 -2
  105. package/dist/components/ReadAloud.d.ts +2 -2
  106. package/dist/components/ReadAloud.js +5 -3
  107. package/dist/components/StimulusPanel.d.cts +1 -1
  108. package/dist/components/StimulusPanel.d.ts +1 -1
  109. package/dist/components/WrittenResponse.cjs +4 -3
  110. package/dist/components/WrittenResponse.d.cts +3 -3
  111. package/dist/components/WrittenResponse.d.ts +3 -3
  112. package/dist/components/WrittenResponse.js +3 -2
  113. package/dist/i18n/LkIntlProvider.d.cts +2 -2
  114. package/dist/i18n/LkIntlProvider.d.ts +2 -2
  115. package/dist/index.cjs +34 -23
  116. package/dist/index.cjs.map +1 -1
  117. package/dist/index.d.cts +4 -3
  118. package/dist/index.d.ts +4 -3
  119. package/dist/index.js +15 -12
  120. package/dist/index.js.map +1 -1
  121. package/dist/{strings-PCYKWCwd.d.ts → types-BY-4S5Rd.d.ts} +761 -761
  122. package/dist/{strings-BOPKhnS-.d.cts → types-DtPYG1hh.d.cts} +761 -761
  123. package/package.json +13 -3
  124. package/dist/chunk-3TJSFPO4.cjs.map +0 -1
  125. package/dist/chunk-5F4Q6JT7.cjs +0 -289
  126. package/dist/chunk-5F4Q6JT7.cjs.map +0 -1
  127. package/dist/chunk-6DTW5T3Z.js.map +0 -1
  128. package/dist/chunk-6RRFH7C4.cjs.map +0 -1
  129. package/dist/chunk-ALH6TUX2.js.map +0 -1
  130. package/dist/chunk-BAKLJKFZ.cjs.map +0 -1
  131. package/dist/chunk-GR33G5QM.js +0 -286
  132. package/dist/chunk-GR33G5QM.js.map +0 -1
  133. package/dist/chunk-H3IRVX7J.js.map +0 -1
  134. package/dist/chunk-H63O5HPW.cjs.map +0 -1
  135. package/dist/chunk-HZMILQYO.js.map +0 -1
  136. package/dist/chunk-JKMBFHER.cjs.map +0 -1
  137. package/dist/chunk-KP57YCYQ.js.map +0 -1
  138. package/dist/chunk-KWKB74KL.js.map +0 -1
  139. package/dist/chunk-QA42VDTA.cjs.map +0 -1
  140. package/dist/chunk-QHK6PTUR.js.map +0 -1
  141. package/dist/chunk-QJW3K6JN.js.map +0 -1
  142. package/dist/chunk-RZ52SEYV.cjs.map +0 -1
  143. package/dist/chunk-TI23GIFM.cjs.map +0 -1
  144. package/dist/chunk-UQIRDE52.cjs.map +0 -1
  145. package/dist/chunk-UYQBEPNZ.js.map +0 -1
  146. package/dist/chunk-V5BTVGPA.js.map +0 -1
  147. package/dist/chunk-XEGBGKHV.cjs.map +0 -1
  148. package/dist/chunk-YTGRXD23.js.map +0 -1
  149. package/dist/chunk-Z5XY66PO.cjs.map +0 -1
@@ -1,6 +1,6 @@
1
- import { SpeechAssessment, GradeRecord, ReadAloudData, RecordingRef, LearnerResponse, ActivityData, ActivityResult, ItemOutcome, MediaPlayLedgerEntry, MediaPlayClaim, MediaPlayGrant, InteractionEvent, ThemeTokens, RedactedActivityData, ItemGroup, SequenceEntry, ReadAloudDimension, ReadAloudWordState, Stimulus } from '@intellectif/lk-core';
1
+ import { ReadAloudDimension, ReadAloudWordState, Stimulus, SpeechAssessment, GradeRecord, ReadAloudData, RecordingRef, LearnerResponse, ActivityData, ActivityResult, ItemOutcome, MediaPlayLedgerEntry, MediaPlayClaim, MediaPlayGrant, InteractionEvent, ThemeTokens, RedactedActivityData, ItemGroup, SequenceEntry } from '@intellectif/lk-core';
2
2
  import { LearnerAi } from './ai/LkAiProvider.js';
3
- import { R as RecordedTake, S as SpeechRecorderError } from './machine-CuwKqWXC.js';
3
+ import { S as SpeechRecorderError, R as RecordedTake } from './machine-CuwKqWXC.js';
4
4
 
5
5
  /** The size captions are drawn at. */
6
6
  type CaptionSize = 'small' | 'medium' | 'large';
@@ -39,861 +39,861 @@ interface VideoPreferences {
39
39
  type VideoShortcutAction = 'play-pause' | 'jump-10' | 'jump-5' | 'jump-1' | 'frame' | 'speed' | 'percent' | 'start-end' | 'chapter' | 'mute' | 'captions' | 'secondary-captions' | 'transcript' | 'picture-in-picture' | 'fullscreen' | 'shortcut-list';
40
40
 
41
41
  /**
42
- * What an application's assessor came back with.
42
+ * Every string the SDK's own chrome renders.
43
43
  *
44
- * `code` is a plain `string` and not `SpeechUnscorableCode`: an application's
45
- * own assessor may refuse a take for a reason the SDK has no name for, and a
46
- * narrower type would make it unreportable. This component classifies the two
47
- * codes that mean "nothing was heard" and treats every other one the same way.
48
- */
49
- type ReadAloudAssessResult = {
50
- status: 'graded';
51
- assessment: SpeechAssessment;
52
- grade: GradeRecord;
53
- } | {
54
- status: 'unscorable';
55
- code: string;
56
- assessment?: SpeechAssessment;
57
- } | {
58
- status: 'failed';
59
- retryable: boolean;
60
- };
61
- /**
62
- * How a take reaches the application, and how a judgement comes back.
44
+ * Until 0.8.1 these were literals, and `locale` set only the `lang` attribute —
45
+ * so a learner sitting a Spanish paper read a Spanish passage inside an English
46
+ * scaffold, and "No plays remaining" on a listening exam was untranslatable.
47
+ * This is the whole surface, in one place, so a consumer can replace it.
63
48
  *
64
- * Named `recordingBinding` rather than `recording` because both
65
- * `ReadAloudData.recording` (the bounds) and `ReadAloudLearnerResponse.recording`
66
- * (the stored take) already exist: the plain name would read as one of those.
49
+ * **Interpolation and plurals are functions, not format strings.** A message
50
+ * like `"Question {n} of {total}"` needs a parser, and a parser needs plural
51
+ * rules for every locale the SDK does not know. A function hands both problems
52
+ * to the consumer, who has `Intl.PluralRules` and knows their own language:
53
+ *
54
+ * ```ts
55
+ * questionProgress: (index, total) => `Pregunta ${index} de ${total}`,
56
+ * wordCount: (n) => `${n} ${n === 1 ? 'palabra' : 'palabras'}`,
57
+ * ```
58
+ *
59
+ * It also lets a translation reorder its arguments, which a positional token
60
+ * cannot, and TypeScript checks the arity. No parser, no message catalogue
61
+ * format, no runtime dependency.
62
+ *
63
+ * **What is deliberately NOT here: thrown errors.** Every `throw` in this
64
+ * package is addressed to the developer who wired the component up, not to the
65
+ * learner. Translating them would make them unsearchable and would put the one
66
+ * audience who can act on them behind a translation. They stay English.
67
67
  */
68
- interface RecordingBinding {
68
+ interface LkStrings {
69
+ /** Multiple Choice submit button, and Written Response. */
70
+ submit: string;
71
+ /** Fill-in-the-Blanks in `practice`: the learner is checking their own work. */
72
+ checkAnswers: string;
73
+ /** Fill-in-the-Blanks in `exam`: nothing is checked on the client. */
74
+ submitAnswers: string;
69
75
  /**
70
- * Puts the take in your storage and returns its key. **Required** outside
71
- * `review`: without it a learner can speak into a control that submits
72
- * nothing. Settle it — a promise that never does leaves the submit pending
73
- * for ever.
76
+ * The scored result, read by a screen reader on submit.
77
+ *
78
+ * `percent` is already rounded to a whole number. The authored overall
79
+ * feedback is appended SEPARATELY by the component, so a translation of this
80
+ * sentence never has to carry the author's words — the two were previously
81
+ * concatenated into one literal, which made the score sentence untranslatable
82
+ * without also touching authored content.
74
83
  */
75
- upload(take: RecordedTake): Promise<RecordingRef>;
84
+ scoreAnnouncement: (percent: number, passed: boolean) => string;
85
+ /** Prefix announced on submit, before the score sentence. */
86
+ answerSubmitted: string;
87
+ /** An answer submitted for grading that has none yet. Never rendered as 0%. */
88
+ notGradedYet: string;
89
+ /** An outcome that carries no grade and never will. */
90
+ noGradeAvailable: string;
91
+ showFeedback: string;
92
+ hideFeedback: string;
93
+ /** Accessible name of a blank's input. `ordinal` is 1-based. */
94
+ blankLabel: (ordinal: number) => string;
95
+ /** Accessible name of the selector for one gap in a Gap Select passage. */
96
+ gapLabel: (ordinal: number) => string;
76
97
  /**
77
- * Judges the stored take. `practice` only — an exam never assesses on the
78
- * client. Optional: a binding without it records, uploads and submits, and
79
- * the feedback is replaced by a notice.
98
+ * The empty first entry of a gap's selector — how a learner leaves a gap
99
+ * alone, and how they take an answer back. Never a real choice.
80
100
  */
81
- assess?(ref: RecordingRef): Promise<ReadAloudAssessResult>;
82
- /** A playable link to a stored take. `review` only. */
83
- playbackUrl?(ref: RecordingRef): Promise<string>;
84
- }
85
- interface ReadAloudProps extends ActivityProps<ReadAloudData> {
86
- /** How a take is stored and judged. See {@link RecordingBinding}. */
87
- recordingBinding?: RecordingBinding;
101
+ gapPlaceholder: string;
102
+ showHint: string;
103
+ hideHint: string;
88
104
  /**
89
- * The evidence behind a stored grade, for `review`. Without it a graded
90
- * outcome still shows its marks, rebuilt from `outcome.grade.details`; with
91
- * neither, the score is shown and no marks are.
105
+ * Announced when an essay is handed in. Distinct from {@link LkStrings.awaitingGrade},
106
+ * which is the `review`-mode state of a submission made earlier: this one
107
+ * confirms the act, and is the only thing a learner hears at the moment they
108
+ * commit work that nothing on the client will grade.
92
109
  */
93
- assessment?: SpeechAssessment;
110
+ responseSubmitted: string;
111
+ /** Live word counter. Needs the count for languages whose plural rules differ. */
112
+ wordCount: (count: number) => string;
113
+ /** The authored bounds, e.g. "50–200 words". `min` is 0 when unbounded below. */
114
+ wordBounds: (min: number, max: number) => string;
115
+ /** A rubric criterion the grader marked not applicable. */
116
+ notApplicable: string;
117
+ /** A returned grade flagged for a human to look at — learner-visible by design. */
118
+ awaitingHumanReview: string;
119
+ /** Submitted, no grade back yet. */
120
+ awaitingGrade: string;
121
+ /** The grader could not produce a grade. */
122
+ couldNotBeGraded: string;
123
+ previous: string;
124
+ next: string;
125
+ /** Progress label, also the question region's accessible name. `index` is 1-based. */
126
+ questionProgress: (index: number, total: number) => string;
127
+ /** Shown when no renderer is registered for an activity type. */
128
+ unsupportedActivity: string;
129
+ /** Accessible name of the box the learner types the recording into. */
130
+ dictationInputLabel: string;
131
+ /** Accessible name of the group holding the recording. */
132
+ dictationRecording: string;
133
+ /** Accessible name of the group holding the slower recording, and its default `alt`. */
134
+ dictationSlowRecording: string;
135
+ /** The progressive-hint button, with how many transcript words are shown so far. */
136
+ dictationRevealNextWord: (revealed: number, total: number) => string;
137
+ /** Hides every revealed hint word again. */
138
+ dictationResetHints: string;
139
+ /** The solution toggle: shows the transcript after the attempt is marked. */
140
+ showSolution: string;
141
+ hideSolution: string;
142
+ /** Accessible name of the revealed transcript. */
143
+ dictationSolutionLabel: string;
144
+ /** Accessible name of the word-by-word marked result. */
145
+ dictationMarksLabel: string;
146
+ /** Tells the learner the marks compare the normalised text, not what they typed. */
147
+ dictationDiffNote: string;
94
148
  /**
95
- * Confidence above which a break note is shown, 0..1, forwarded to
96
- * {@link PronunciationFeedback}. No default — see its own prop.
149
+ * The four sentences a screen reader hears for a marked word — the only
150
+ * channel the marks have for assistive technology; the glyphs and the
151
+ * character diff are decoration.
97
152
  */
98
- breakThreshold?: number;
99
- /** The same, for the monotone note. */
100
- monotoneThreshold?: number;
153
+ dictationWordCorrect: (word: string) => string;
154
+ dictationWordWrong: (word: string, expected: string) => string;
155
+ dictationWordMissing: (expected: string) => string;
156
+ dictationWordExtra: (word: string) => string;
157
+ /** The legend rows; the character-level marks reuse the same four. */
158
+ dictationLegendCorrect: string;
159
+ dictationLegendWrong: string;
160
+ dictationLegendMissing: string;
161
+ dictationLegendExtra: string;
162
+ /** Marked result of an attempt with no words in it once punctuation and spacing are ignored, an empty one included. */
163
+ dictationNothingTyped: string;
101
164
  /**
102
- * The URL of a self-hosted copy of `CAPTURE_PROCESSOR_SOURCE`, handed to the
103
- * recorder, for a Content-Security-Policy whose `script-src` does not allow
104
- * `blob:`. Without it the capture module is loaded from a `blob:` URL, and a
105
- * policy that refuses one sends every take through the deprecated
106
- * main-thread `ScriptProcessorNode` — a take that still records, so nothing
107
- * says so. See `useSpeechRecorder`'s option of the same name.
165
+ * Appended to the score announcement, e.g. "4 of 6 words correct." — a whole
166
+ * sentence, because authored feedback may follow it.
108
167
  */
109
- workletUrl?: string;
168
+ dictationWordsSummary: (correct: number, total: number) => string;
169
+ /** Accessible name of the group holding the model recording. */
170
+ readAloudModelRecording: string;
171
+ /** Accessible name of the group holding the slower model recording, and its default `alt`. */
172
+ readAloudSlowRecording: string;
173
+ /** Starts a take. */
174
+ readAloudRecord: string;
175
+ /** Ends the take in progress. */
176
+ readAloudStop: string;
177
+ /** Throws the take away so the learner can start over. */
178
+ readAloudRerecord: string;
179
+ /** Accessible name of the player for the take the learner just made. */
180
+ readAloudYourRecording: string;
110
181
  /**
111
- * Never called. A read-aloud answer is a key in your storage, and there is
112
- * none until the take has been uploaded — so there is no intermediate
113
- * response to report, and reporting the take itself would offer a host a
114
- * recording it has nowhere to put. `onSubmit` carries the response, once.
182
+ * In place of the player for the learner's own take, and of the buttons that
183
+ * play one word of it, when the page refuses to play it — most often a
184
+ * Content-Security-Policy whose `media-src` does not allow `blob:`. Said
185
+ * rather than left as controls that do nothing when pressed.
115
186
  */
116
- onChange?: (response: LearnerResponse) => void;
117
- }
118
-
119
- /**
120
- * How an activity is being presented. This is the single switch that decides
121
- * whether the component may grade, reveal, or submit — the three things a
122
- * summative exam must take away from the client.
123
- *
124
- * - `practice` (default, and the only behaviour before `renderMode` existed):
125
- * the component owns the attempt. It
126
- * scores locally on submit, reveals correctness and feedback, and calls
127
- * `onComplete` with a full {@link ActivityResult}.
128
- * - `exam`: the component NEVER scores and NEVER reveals correctness. Submit
129
- * emits the raw learner response through `onSubmit`; the server grades it.
130
- * Safe to render redacted data, because nothing here needs the answer key.
131
- * - `review`: read-only. Renders the learner's submitted answer, and marks
132
- * correctness only from an `outcome` the caller supplies (which the server
133
- * computed). Nothing is submittable.
134
- */
135
- type RenderMode = 'practice' | 'exam' | 'review';
136
- /**
137
- * Makes the answer-key-bearing parts of an activity optional, so a component
138
- * can render either full activity data OR a `redact()` projection with the
139
- * same props. Fields the SDK classifies `answer-key` become optional here;
140
- * a component in `exam` mode must not read them at all.
141
- */
142
- type Renderable<TData> = Omit<TData, 'scoringStrategy'> & {
143
- scoringStrategy?: unknown;
144
- /** Present on a `redact()` projection. */
145
- redacted?: true;
146
- };
147
- /**
148
- * {@link Renderable} DISTRIBUTED over a union of activity types.
149
- *
150
- * This distinction is load-bearing, not cosmetic. `Renderable<T>` is built on
151
- * `Omit`, and `Omit` does not distribute: `Omit<A | B, K>` collapses to the
152
- * keys A and B have IN COMMON, so `Renderable<ActivityData>` is a single
153
- * object type carrying only the fields every activity shares. Narrowing it
154
- * dies with it — after `if (data.type === 'multiple-choice')` the compiler
155
- * still refuses `data.options`, because the union it would narrow to no
156
- * longer exists.
157
- *
158
- * The conditional below re-distributes, so `RenderableActivity` is a real
159
- * union of per-type renderables and `.type` narrows again. Anything that
160
- * accepts "some renderable activity, I don't know which" — a custom renderer,
161
- * a sequence entry — must use THIS, not `Renderable<ActivityData>`.
162
- */
163
- type RenderableActivity<TData extends ActivityData = ActivityData> = TData extends unknown ? Renderable<TData> : never;
164
- /**
165
- * Sanitiser for author-supplied rich text (`questionHtml`, `promptHtml`, and a
166
- * stimulus's `bodyHtml`). The SDK deliberately ships NO sanitiser — that would
167
- * add a dependency and, worse, a false promise. Rich text is rendered only when
168
- * you supply this function; without it the component falls back to the
169
- * plain-text field, which is always escaped. Fail-safe by construction: the SDK
170
- * never injects HTML it was not explicitly given a sanitiser for.
171
- *
172
- * `FillInTheBlanks.passageHtml` is the one exception, and is **never**
173
- * rendered: the passage hosts the answer inputs, so it cannot be split at the
174
- * `{{blank}}` placeholders without voiding the sanitiser. The plain `passage`
175
- * is always used, and passing `passageHtml` warns in development.
176
- */
177
- type HtmlSanitizer = (html: string) => string;
178
- /**
179
- * The prop contract shared by every activity component (Req 3.1). Defined
180
- * here (React-specific) rather than in lk-core, which is React-free.
181
- *
182
- * Controlled / uncontrolled follows the React convention: pass `value` +
183
- * `onChange` to own the learner's answer (restore an in-progress attempt,
184
- * autosave a delta, drive a review); pass `defaultValue` to seed an
185
- * uncontrolled component; pass neither to keep the pre-2.1.0 behaviour, where
186
- * the component owns the answer outright.
187
- */
188
- interface ActivityProps<TData extends ActivityData = ActivityData> {
189
- /** Activity content. Accepts a `redact()` projection in `exam` mode. */
190
- data: RenderableActivity<TData>;
187
+ readAloudPlaybackUnavailable: string;
191
188
  /**
192
- * Called when the component scored the attempt itself. Only ever fires in
193
- * `practice` mode — in `exam` mode the client does not grade, so there is
194
- * no `ActivityResult` to give you; use `onSubmit`.
189
+ * Live progress while recording. Whole seconds, not a `m:ss` clock: a
190
+ * recording is bounded in seconds by the item, and a translation should not
191
+ * have to reimplement a clock to change the word around it.
192
+ *
193
+ * Shown on screen and hidden from assistive technology: it changes every
194
+ * second, and a screen reader that spoke each change would speak into the
195
+ * take being assessed. The two sentences below are what is announced instead.
195
196
  */
196
- onComplete?: (result: ActivityResult) => void;
197
+ readAloudRecordingProgress: (seconds: number, maxSeconds: number) => string;
198
+ /** Announced once when a capture begins. */
199
+ readAloudRecordingStarted: string;
197
200
  /**
198
- * Called on submit with the raw learner response and no grade. Fires in
199
- * every mode, before `onComplete`, so an exam runner can persist the
200
- * response and let the server score it.
201
+ * Announced once when a capture ends, by the learner or at its own bound.
202
+ * Carries the length because the progress counter is not announced: a take
203
+ * that reads back as "20 of 20 seconds" is one that stopped itself.
201
204
  */
202
- onSubmit?: (response: LearnerResponse) => void;
203
- /** Controlled value: the learner's current response. */
204
- value?: LearnerResponse;
205
- /** Initial response for an uncontrolled component (ignored when `value` is set). */
206
- defaultValue?: LearnerResponse;
205
+ readAloudRecordingStopped: (seconds: number, maxSeconds: number) => string;
206
+ /** How many takes are left. Called with 0 as well, so the wording is one translation's. */
207
+ readAloudTakesRemaining: (remaining: number, max: number) => string;
208
+ /** `exam` only: hands the item in with no recording at all, deliberately. */
209
+ readAloudSubmitWithoutRecording: string;
210
+ /** Pending, while the take travels to the application's storage. */
211
+ readAloudUploading: string;
212
+ /** The take never reached storage, so nothing was submitted. */
213
+ readAloudUploadFailed: string;
214
+ /** Retries whichever step just failed. */
215
+ readAloudTryAgain: string;
216
+ /** Pending, while the assessor judges the take. */
217
+ readAloudAssessing: string;
218
+ /** The binding stores recordings but judges none, so there is no feedback to show. */
219
+ readAloudAssessmentUnavailable: string;
220
+ /** The assessor found no speech, or too little of it, in the take. */
221
+ readAloudNotHeard: string;
222
+ /** The assessor produced no grade for any other reason. The code itself is never shown. */
223
+ readAloudNotAssessed: string;
224
+ /** The assessment could not be run at all — a failure of the call, not of the reading. */
225
+ readAloudAssessmentFailed: string;
207
226
  /**
208
- * Mount the component as already submitted — read at mount only, like any
209
- * `default*` prop.
210
- *
211
- * Restoring an attempt without it reopens a question the learner had already
212
- * submitted as answerable, so on a summative paper they can change and
213
- * re-submit it. `AttemptState.submittedSlotIds` is what this consumes.
227
+ * What went wrong with the microphone or with the take. One function rather
228
+ * than five keys, so a translation keeps the five sentences together and the
229
+ * SDK keeps one name for the concept.
214
230
  */
215
- defaultSubmitted?: boolean;
216
- /** Fires on every change to the learner's response. Required for a controlled component. */
217
- onChange?: (response: LearnerResponse) => void;
218
- /** Presentation mode. Defaults to `practice`. */
219
- renderMode?: RenderMode;
231
+ readAloudRecorderError: (reason: SpeechRecorderError) => string;
232
+ /** Accessible name of the whole feedback region. */
233
+ pronunciationFeedbackLabel: string;
234
+ /** Name of one scored dimension, as a learner reads it. */
235
+ pronunciationDimension: (dimension: ReadAloudDimension) => string;
236
+ /** A dimension or a word the engine did not measure. NEVER rendered as 0%. */
237
+ pronunciationNotAssessed: string;
238
+ /** Accessible name of the word-by-word marks. */
239
+ pronunciationWordsLabel: string;
240
+ /** One legend row per marking state — the same four states the marks carry. */
241
+ pronunciationLegend: (state: ReadAloudWordState) => string;
220
242
  /**
221
- * Server-computed outcome, used by `review` mode to mark correctness
222
- * without the client ever scoring. Ignored in other modes.
243
+ * The four sentences a screen reader hears for a marked word: the only
244
+ * channel the marks have for assistive technology, since the glyph and the
245
+ * decoration are both hidden from it.
223
246
  */
224
- outcome?: ItemOutcome;
225
- /** Renders author-supplied rich text when provided. See {@link HtmlSanitizer}. */
226
- sanitizeHtml?: HtmlSanitizer;
247
+ pronunciationWordCorrect: (word: string) => string;
248
+ pronunciationWordMispronounced: (word: string) => string;
249
+ pronunciationWordOmitted: (word: string) => string;
250
+ pronunciationWordInserted: (word: string) => string;
251
+ /** Opens one word's syllables, sounds and timings. */
252
+ pronunciationWordDetails: (word: string) => string;
253
+ pronunciationSyllables: string;
254
+ pronunciationPhonemes: string;
255
+ /** A syllable the engine also spelled out, e.g. `('ˈhæ', 'ha')`. */
256
+ pronunciationSyllableSpelling: (syllable: string, grapheme: string) => string;
257
+ /** A sound the engine named no symbol for; `ordinal` is 1-based. */
258
+ pronunciationPhonemePosition: (ordinal: number) => string;
259
+ /** Introduces the sounds the engine thought it heard in a sound's place. */
260
+ pronunciationHeardAs: string;
261
+ /** Plays one word out of the learner's own take. */
262
+ pronunciationPlayWord: (word: string) => string;
263
+ pronunciationBreakUnexpected: string;
264
+ pronunciationBreakMissing: string;
265
+ /** Shown only past the caller's own monotone threshold; the SDK sets none. */
266
+ pronunciationMonotone: string;
267
+ /** Names the alphabet the phoneme symbols are written in. */
268
+ pronunciationIpaNote: string;
269
+ /** Jump buttons, e.g. "Back 10 seconds". */
270
+ videoBack: (seconds: number) => string;
271
+ videoForward: (seconds: number) => string;
272
+ /** The centre button once the video has ended. */
273
+ videoReplay: string;
274
+ /** The time readout flips between elapsed and remaining; these name what a press shows next. */
275
+ videoShowRemaining: string;
276
+ videoShowElapsed: string;
277
+ /** The captions button, named by what a press does. */
278
+ videoCaptionsShow: string;
279
+ videoCaptionsHide: string;
280
+ /** In the captions menu when a caption file could not be read. */
281
+ videoCaptionsFailed: string;
282
+ /** The same, for the second language: the first line keeps working. */
283
+ videoSecondaryCaptionsFailed: string;
284
+ /** A speed in the speed menu, e.g. "1.25×". */
285
+ videoSpeedValue: (rate: number) => string;
286
+ videoSettings: string;
287
+ videoCaptionLanguage: string;
288
+ /** The captions page's item, and page, for a second caption line under the first. */
289
+ videoSecondCaptionLanguage: string;
227
290
  /**
228
- * Binds this activity's own `data.media` to a play budget the consumer
229
- * persists. Usually supplied by `<ActivitySequence mediaBudget={…}>`; pass it
230
- * yourself when rendering an activity standalone.
291
+ * The captions row of the settings menu when two languages show, from the
292
+ * tracks' own labels: "English + Español". A translation may order them as its
293
+ * language reads, but the first argument is always the upper line.
231
294
  */
232
- mediaBudget?: MediaBudgetBinding;
233
- /** Translations for the audio transport chrome. See {@link MediaTransportStrings}. */
234
- mediaStrings?: Partial<MediaTransportStrings>;
295
+ videoCaptionPair: (primary: string, secondary: string) => string;
296
+ /** Announced when Shift + C finds no other language to show. */
297
+ videoNoSecondLanguage: string;
298
+ videoCaptionSize: string;
299
+ videoCaptionSizeValue: (size: CaptionSize) => string;
300
+ videoCaptionBackground: string;
301
+ videoKeyboardShortcuts: string;
302
+ videoShortcutList: string;
303
+ /** Closes the shortcut list. */
304
+ videoClose: string;
305
+ /** A setting's state in the settings menu. */
306
+ videoOn: string;
307
+ videoOff: string;
308
+ /** What each row of the shortcut list does. */
309
+ videoShortcut: (action: VideoShortcutAction) => string;
310
+ /** The button that opens the panel below the video, and its two tabs. */
311
+ videoPanel: string;
312
+ videoContents: string;
313
+ videoTranscript: string;
314
+ videoTranscriptSearch: string;
315
+ videoTranscriptNoMatch: string;
316
+ videoPictureInPicture: string;
317
+ videoFullscreen: string;
318
+ videoExitFullscreen: string;
235
319
  /**
236
- * Overrides the SDK's chrome text for this activity, layered on whatever
237
- * `LkIntlProvider` supplies. `mediaStrings` still works and is merged after
238
- * this, so an existing 0.8.x call site keeps behaving as it did.
320
+ * Why the video will not play, by `MediaError.code` (1 aborted, 2 network,
321
+ * 3 decode, 4 source), with 0 for anything else.
239
322
  */
240
- strings?: LkStringsOverride;
241
- onInteraction?: (event: InteractionEvent) => void;
323
+ videoError: (code: number) => string;
324
+ videoTryAgain: string;
325
+ /** A quiz with no title of its own. */
326
+ videoQuiz: string;
327
+ /** Where the learner is inside a quiz, e.g. "Question 2 of 5". */
328
+ videoQuestionProgress: (index: number, total: number) => string;
329
+ /** A step of the quiz's step indicator, for assistive technology. */
330
+ videoQuestionStep: (index: number, answered: boolean) => string;
331
+ videoNextQuestion: string;
332
+ videoContinue: string;
333
+ videoSkipQuiz: string;
334
+ videoRewatch: string;
335
+ /** Exam mode, once a question is submitted: saved, not graded. */
336
+ videoAnswerSaved: string;
337
+ /** The end card, while an answer is still being stored or graded: why Finish waits. */
338
+ videoAnswerPending: string;
339
+ videoRequired: string;
340
+ /** A quiz's progress in the contents panel and on the end screen. */
341
+ videoQuizProgress: (answered: number, total: number) => string;
342
+ /** Announced when a quiz opens. */
343
+ videoQuizOpened: (time: string, questions: number) => string;
344
+ /** Announced when a seek stopped at a required quiz. */
345
+ videoHeldAtQuiz: (time: string) => string;
346
+ /** Announced when a seek stopped at the furthest point reached. */
347
+ videoHeldAhead: string;
348
+ videoEnded: string;
349
+ /** Practice end screen: questions answered, and the score. */
350
+ videoEndScore: (answered: number, total: number, percent: number) => string;
351
+ /** Exam end screen: questions answered, no score. */
352
+ videoEndAnswered: (answered: number, total: number) => string;
353
+ videoWatchAgain: string;
354
+ videoFinish: string;
242
355
  /**
243
- * The host's AI ports for this activity — an explanation after grading, hints
244
- * before submit — overriding `LkAiProvider`'s. Leave a port out to switch
245
- * that help off. Nothing AI appears in `exam`, and an item whose author
246
- * switched a feature off (`data.ai`) never shows it. See {@link LearnerAi}.
356
+ * `<ActivityPreview>`, in place of a draft that still has something missing.
357
+ * Author-facing, not learner-facing — but an editor is translated too.
247
358
  */
248
- ai?: LearnerAi;
249
- /** Per-instance token overrides, applied as inline CSS vars on the root. */
250
- theme?: Partial<ThemeTokens>;
359
+ previewIncomplete: string;
360
+ /** `<ActivityPreview>`, in place of a draft with something wrong in it. */
361
+ previewInvalid: string;
251
362
  /**
252
- * BCP 47 tag stamped as `lang` on this component's root. This is the
253
- * INTERFACE language — the SDK's own chrome renders inside that element — so
254
- * it should carry the same value you give `<LkIntlProvider locale>`. Passing
255
- * a different one re-declares the language of every SDK string in this
256
- * subtree without changing the words.
257
- *
258
- * It is NOT `data.locale`, which labels xAPI statements — and, on a
259
- * dictation, places the dictation's own words: `<Dictation>` puts it, and
260
- * the direction it names, on its title, hints, marks and solution. Other
261
- * authored content in another language belongs on `stimulus.locale`, which
262
- * `<StimulusPanel>` puts on the passage alone.
363
+ * Accessible name of a provider iframe whose author supplied no `alt`.
364
+ * `MediaSchema` requires `alt` on an embed, so validated content never
365
+ * reaches this — but `validateActivity` is opt-in, and a screen-reader
366
+ * learner meeting an unvalidated one should not meet it in English.
263
367
  */
264
- locale?: string;
265
- disabled?: boolean;
266
- }
267
- /**
268
- * Bridges a server-produced `redact()` projection into the `data` prop.
269
- *
270
- * `RedactedActivityData` is deliberately index-signature typed in lk-core — it
271
- * proves a payload is learner-safe, not what shape it has — so TypeScript
272
- * cannot know it still carries the public fields a renderer needs. This is the
273
- * SDK's single, documented crossing of that gap, so an exam runner does not
274
- * have to write `as unknown as` at every call site:
275
- *
276
- * ```tsx
277
- * <MultipleChoice
278
- * data={asRenderable<MultipleChoiceData>(redactedFromServer)}
279
- * renderMode="exam"
280
- * onSubmit={persist}
281
- * />
282
- * ```
283
- *
284
- * Safe because `exam` mode reads only public fields; the answer-key fields the
285
- * type claims are exactly the ones the component is forbidden to touch there.
286
- *
287
- * Per-type redacted interfaces (`RedactedMultipleChoiceData`, …) ship from
288
- * lk-core since 0.6.0, but they are not yet *assignable* to `data`:
289
- * {@link Renderable} widens `scoringStrategy` and leaves nested answer-key
290
- * fields (`options[].isCorrect`, `blanks[].acceptedAnswers`) required, so a
291
- * real redacted payload still needs this bridge. Closing that gap is tracked
292
- * in the roadmap.
293
- */
294
- declare function asRenderable<TData extends ActivityData>(redacted: RedactedActivityData): Renderable<TData>;
368
+ embeddedMedia: string;
369
+ /**
370
+ * Accessible name of the stimulus region when the author gave it no title.
371
+ * Keyed by `Stimulus['kind']`, because "Recording" and "Passage" do not share
372
+ * a gender or an article in every language.
373
+ */
374
+ stimulusKind: Record<Stimulus['kind'], string>;
375
+ /**
376
+ * How far the material carries, e.g. "Questions 3–8". Called with equal
377
+ * `first` and `last` for a single question, so a translation can choose its
378
+ * own singular rather than receive a pre-pluralised English one.
379
+ */
380
+ stimulusRange: (first: number, last: number) => string;
381
+ /** Production fallback when an activity throws during render. */
382
+ activityFailed: string;
383
+ /** The same, when the activity's title is known. */
384
+ activityFailedNamed: (title: string) => string;
385
+ /** Production fallback when the activity has no title to name. */
386
+ activityFailedUnnamed: string;
387
+ /** The button that asks for an explanation of a graded answer. */
388
+ aiExplain: string;
389
+ /** The same button while the explanation is being written. */
390
+ aiExplaining: string;
391
+ /** The heading over an explanation, and its accessible name. */
392
+ aiExplanation: string;
393
+ /** When no explanation can be shown: the port failed, or its answer was refused. */
394
+ aiExplanationUnavailable: string;
395
+ /** The button that asks for a hint. */
396
+ aiHint: string;
397
+ /** The same button while the hint is being written. */
398
+ aiHintLoading: string;
399
+ /** The label before each hint. */
400
+ aiHintNumber: (hintNumber: number) => string;
401
+ /** The accessible name of the list of hints. */
402
+ aiHints: string;
403
+ /** When no hint can be shown: the port failed, or its hint gave the answer away. */
404
+ aiHintUnavailable: string;
405
+ /** In place of the hint button once every hint has been used. */
406
+ aiNoMoreHints: string;
407
+ /** Beneath every AI text: who wrote it. */
408
+ aiNotice: string;
409
+ /**
410
+ * The listening-paper transport. Shipped in 0.8.0 as its own `mediaStrings`
411
+ * prop, which still works — this is the same shape, reachable from the one
412
+ * provider so a consumer does not wire two mechanisms.
413
+ */
414
+ media: MediaTransportStrings;
415
+ }
416
+ /** A partial override, nested one level for {@link LkStrings.media}. */
417
+ type LkStringsOverride = Partial<Omit<LkStrings, 'media' | 'stimulusKind'>> & {
418
+ media?: Partial<MediaTransportStrings>;
419
+ stimulusKind?: Partial<Record<Stimulus['kind'], string>>;
420
+ };
295
421
  /**
296
- * The same bridge as {@link asRenderable}, for a whole sequence: activities
297
- * and item groups as a server hands them over, ready for `<ActivitySequence>`.
298
- *
299
- * Needed for the same reason and no other. `redactItemGroup` returns
300
- * `ItemGroup<RedactedActivityData>`, and `RedactedActivityData` is an
301
- * index-signature type whose fields are all `unknown` — so its `question` is
302
- * not a `string` and it satisfies no per-type renderable, however the prop is
303
- * widened. Widening alone cannot fix this; a crossing point is required, and
304
- * having exactly one keeps `as unknown as` out of consumer code.
305
- *
306
- * ```tsx
307
- * const entries = await fetchExam(); // redacted, server-side
308
- * <ActivitySequence
309
- * activities={asRenderableSequence(entries)}
310
- * renderMode="exam" // REQUIRED: see below
311
- * shuffleSeed={attemptId}
312
- * onSubmit={persist}
313
- * />
314
- * ```
315
- *
316
- * Pass `renderMode="exam"` (or `"review"`). Redacted data has no answer key, and
317
- * almost every built-in activity throws at render in the default `practice`
318
- * mode rather than fail later: `<MultipleChoice>`, `<FillInTheBlanks>`,
319
- * `<GapSelect>` and `<Dictation>` because they grade locally, and
320
- * `<WrittenResponse>` because `practice` still runs its local submit path and
321
- * emits a practice-mode xAPI statement.
322
- *
323
- * `<ReadAloud>` is the exception and renders a projection in every mode: a
324
- * read-aloud item has no answer key to withhold, so `redact()` removes only the
325
- * authored feedback and leaves the reading, its language and its bounds — which
326
- * is everything the component puts on screen. It grades nothing locally either;
327
- * the grade comes back from whatever the application asked to judge the take.
422
+ * English defaults, byte-identical to what every component rendered before the
423
+ * strings surface existed. Supplying no override changes nothing on screen.
328
424
  */
329
- declare function asRenderableSequence(entries: readonly (RedactedActivityData | RedactedItemGroupData)[]): readonly SequenceEntry<RenderableActivity>[];
330
- /** Structural shape of a `redactItemGroup()` projection, as it arrives from a server. */
331
- type RedactedItemGroupData = ItemGroup<RedactedActivityData> & {
332
- redacted: true;
333
- };
425
+ declare const DEFAULT_STRINGS: LkStrings;
334
426
  /**
335
- * Every word the SDK's audio transport renders.
336
- *
337
- * Words, not characters: the `m:ss / m:ss` clock is digits and punctuation and
338
- * is formatted by the component, because it reads identically in every locale
339
- * this SDK targets. The scrubber's SPOKEN value does have a word in it and does
340
- * have a key ({@link MediaTransportStrings.timeValue}).
427
+ * Layers an override onto a base dictionary.
341
428
  *
342
- * Supply them to translate it. These are the highest-stakes strings on a
343
- * listening paper — "No plays remaining" decides whether a learner believes
344
- * they may try again — so shipping them as untranslatable English inside a
345
- * Spanish panel was not acceptable. Supplying any of them also sets `lang` on
346
- * the transport chrome, so a screen reader does not read the SDK's own words
347
- * with the passage's phonetics.
429
+ * Two levels deep and no further, because the shape is two levels deep. A
430
+ * generic deep merge would silently accept a nested object where a function
431
+ * belongs and fail at render instead of at the call site.
348
432
  */
349
- interface MediaTransportStrings {
350
- play: string;
351
- pause: string;
352
- preparing: string;
353
- mute: string;
354
- unmute: string;
355
- volume: string;
356
- speed: string;
357
- seek: string;
358
- /**
359
- * Spoken value of the scrubber, e.g. `('1:05', '4:30') => '1:05 of 4:30'`.
360
- * Takes ALREADY-FORMATTED `m:ss` strings: a translation should not have to
361
- * reimplement the clock to change the word between them.
362
- */
363
- timeValue: (elapsed: string, duration: string) => string;
364
- /** e.g. `(1, 2) => '1 of 2 plays remaining'`. */
365
- playsRemaining: (remaining: number, max: number) => string;
366
- noPlaysRemaining: string;
367
- /** Shown before the LAST play is spent, so a stray press cannot cost it. */
368
- lastPlayConfirm: string;
369
- lastPlayStart: string;
370
- lastPlayCancel: string;
371
- seekBlocked: string;
372
- rateBlocked: string;
373
- playFailed: string;
374
- }
433
+ declare function mergeStrings(base: LkStrings, override?: LkStringsOverride): LkStrings;
434
+
375
435
  /**
376
- * Binds ONE media block to a play budget the consumer persists.
436
+ * What an application's assessor came back with.
377
437
  *
378
- * The SDK refuses a play; it does not remember one. Everything durable here is
379
- * the consuming application's — see {@link SequenceMediaBudget.onPlayConsumed}.
438
+ * `code` is a plain `string` and not `SpeechUnscorableCode`: an application's
439
+ * own assessor may refuse a take for a reason the SDK has no name for, and a
440
+ * narrower type would make it unreportable. This component classifies the two
441
+ * codes that mean "nothing was heard" and treats every other one the same way.
380
442
  */
381
- interface MediaBudgetBinding {
382
- /** From `slotMediaKey(slotId)` / `stimulusMediaKey(slotId)` in lk-core. */
383
- key: string;
384
- /** Plays already spent, and where playback stood. Read at mount only. */
385
- entry?: MediaPlayLedgerEntry;
386
- /** Defaults to `renderMode !== 'review'`. An explicit boolean wins either way. */
387
- enforced?: boolean;
388
- /** Slot context stamped onto the claim and the interaction event. */
389
- slotId: string;
390
- index: number;
391
- activityId?: string;
392
- onPlayConsumed?: (claim: MediaPlayClaim) => undefined | Promise<MediaPlayGrant | undefined>;
393
- onPlayRefunded?: (claim: MediaPlayClaim) => void;
394
- onPosition?: (key: string, seconds: number) => void;
395
- /** Translations for the transport chrome. */
396
- strings?: Partial<MediaTransportStrings>;
397
- }
443
+ type ReadAloudAssessResult = {
444
+ status: 'graded';
445
+ assessment: SpeechAssessment;
446
+ grade: GradeRecord;
447
+ } | {
448
+ status: 'unscorable';
449
+ code: string;
450
+ assessment?: SpeechAssessment;
451
+ } | {
452
+ status: 'failed';
453
+ retryable: boolean;
454
+ };
398
455
  /**
399
- * The pager-level half of a play budget. One prop, because it is one concept.
456
+ * How a take reaches the application, and how a judgement comes back.
457
+ *
458
+ * Named `recordingBinding` rather than `recording` because both
459
+ * `ReadAloudData.recording` (the bounds) and `ReadAloudLearnerResponse.recording`
460
+ * (the stored take) already exist: the plain name would read as one of those.
400
461
  */
401
- interface SequenceMediaBudget {
462
+ interface RecordingBinding {
402
463
  /**
403
- * `MediaPlayLedger.entries` goes straight in. An absent key means nothing
404
- * spent. Read at mount, like `responses`.
464
+ * Puts the take in your storage and returns its key. **Required** outside
465
+ * `review`: without it a learner can speak into a control that submits
466
+ * nothing. Settle it — a promise that never does leaves the submit pending
467
+ * for ever.
405
468
  */
406
- plays?: Readonly<Record<string, MediaPlayLedgerEntry>>;
469
+ upload(take: RecordedTake): Promise<RecordingRef>;
407
470
  /**
408
- * Re-seed token. Change this string and the budgets re-seed from `plays`
409
- * WITHOUT remounting the pager — the invigilator path ("the audio never
410
- * started, give her the play back") that would otherwise cost the learner
411
- * their focus, their scroll position and an unsaved answer.
471
+ * Judges the stored take. `practice` only — an exam never assesses on the
472
+ * client. Optional: a binding without it records, uploads and submits, and
473
+ * the feedback is replaced by a notice.
412
474
  */
413
- resumeKey?: string;
414
- /** Explicit override of the default (`renderMode !== 'review'`). */
415
- enforced?: boolean;
475
+ assess?(ref: RecordingRef): Promise<ReadAloudAssessResult>;
476
+ /** A playable link to a stored take. `review` only. */
477
+ playbackUrl?(ref: RecordingRef): Promise<string>;
478
+ }
479
+ interface ReadAloudProps extends ActivityProps<ReadAloudData> {
480
+ /** How a take is stored and judged. See {@link RecordingBinding}. */
481
+ recordingBinding?: RecordingBinding;
416
482
  /**
417
- * Called the instant a play is claimed, BEFORE any audio is audible.
418
- *
419
- * Two tiers, chosen by what you return:
420
- *
421
- * - **Return nothing (optimistic).** Playback starts immediately and the
422
- * count is only as durable as your write. **Do not debounce this, and do
423
- * not batch it with the answer autosave** — an eight-second debounce is
424
- * exactly long enough to start a third play and hard-reload. A `pagehide`
425
- * beacon is a backstop, not the mechanism. A crash between this call and
426
- * your write landing RETURNS the play to the learner; that is the honest
427
- * description of what you are buying.
428
- * - **Return a promise (confirmed).** Playback is held — the button reads
429
- * "Preparing…" and is `aria-busy` — until it settles. Resolve with
430
- * `{ playsUsed }` from an ATOMIC server write (`UPDATE … SET plays = plays
431
- * + 1 … RETURNING plays`, or a compare-and-set on
432
- * `claim.previousPlaysUsed`). A resolved count above `maxPlays` refuses the
433
- * play, which is how a second tab is caught: two mounts both seeded at 0
434
- * both claim 1, and only an atomic increment can tell them apart. A
435
- * rejection charges nothing and lets the learner retry. This is the only
436
- * tier in which "consumed before audible" is true of storage rather than
437
- * only of memory; use it for summative papers.
438
- *
439
- * Never settle the promise and the learner cannot play at all: settle it.
483
+ * The evidence behind a stored grade, for `review`. Without it a graded
484
+ * outcome still shows its marks, rebuilt from `outcome.grade.details`; with
485
+ * neither, the score is shown and no marks are.
440
486
  */
441
- onPlayConsumed?: (claim: MediaPlayClaim) => undefined | Promise<MediaPlayGrant | undefined>;
487
+ assessment?: SpeechAssessment;
442
488
  /**
443
- * Called when a charged play produced no audio — the element errored before
444
- * playback advanced past 0.25 s, an expired signed URL being the realistic
445
- * cause. Supply it to give the play back, decrementing with a compare-and-set
446
- * on `claim.playsUsed`. Omit it and the play stays spent: the SDK will not
447
- * decrement a ledger it has no channel to correct.
489
+ * Confidence above which a break note is shown, 0..1, forwarded to
490
+ * {@link PronunciationFeedback}. No default — see its own prop.
448
491
  */
449
- onPlayRefunded?: (claim: MediaPlayClaim) => void;
492
+ breakThreshold?: number;
493
+ /** The same, for the monotone note. */
494
+ monotoneThreshold?: number;
450
495
  /**
451
- * Position reports, so a refresh resumes the play the learner already paid
452
- * for instead of charging them again. Fires on pause, on end (with 0), and at
453
- * most once per whole second of playback.
454
- *
455
- * **This one you MAY throttle** — the granularity you persist is the
456
- * granularity of the replay a crash grants. Three seconds is sane; three
457
- * minutes is not.
496
+ * The URL of a self-hosted copy of `CAPTURE_PROCESSOR_SOURCE`, handed to the
497
+ * recorder, for a Content-Security-Policy whose `script-src` does not allow
498
+ * `blob:`. Without it the capture module is loaded from a `blob:` URL, and a
499
+ * policy that refuses one sends every take through the deprecated
500
+ * main-thread `ScriptProcessorNode` — a take that still records, so nothing
501
+ * says so. See `useSpeechRecorder`'s option of the same name.
458
502
  */
459
- onPosition?: (key: string, seconds: number) => void;
460
- /** Translations for the transport chrome. Defaults are English. */
461
- strings?: Partial<MediaTransportStrings>;
503
+ workletUrl?: string;
504
+ /**
505
+ * Never called. A read-aloud answer is a key in your storage, and there is
506
+ * none until the take has been uploaded — so there is no intermediate
507
+ * response to report, and reporting the take itself would offer a host a
508
+ * recording it has nowhere to put. `onSubmit` carries the response, once.
509
+ */
510
+ onChange?: (response: LearnerResponse) => void;
462
511
  }
512
+
463
513
  /**
464
- * Which slot a recording belongs to, stamped on every call the pager makes.
514
+ * How an activity is being presented. This is the single switch that decides
515
+ * whether the component may grade, reveal, or submit — the three things a
516
+ * summative exam must take away from the client.
465
517
  *
466
- * Exactly the shape {@link ActivitySequenceProps.onSubmit} already passes, so
467
- * one identity travels with the answer and with the take that answer points at
468
- * — and a consumer writes `slotId` in both places rather than reconciling two
469
- * spellings of "which question was this".
518
+ * - `practice` (default, and the only behaviour before `renderMode` existed):
519
+ * the component owns the attempt. It
520
+ * scores locally on submit, reveals correctness and feedback, and calls
521
+ * `onComplete` with a full {@link ActivityResult}.
522
+ * - `exam`: the component NEVER scores and NEVER reveals correctness. Submit
523
+ * emits the raw learner response through `onSubmit`; the server grades it.
524
+ * Safe to render redacted data, because nothing here needs the answer key.
525
+ * - `review`: read-only. Renders the learner's submitted answer, and marks
526
+ * correctness only from an `outcome` the caller supplies (which the server
527
+ * computed). Nothing is submittable.
470
528
  */
471
- interface SequenceRecordingSlot {
472
- /** Identity from `flattenSequence` — stable under shuffling. Store against THIS. */
473
- slotId: string;
474
- /** Presented position, which moves under shuffling. */
475
- index: number;
476
- activityId: string;
477
- }
529
+ type RenderMode = 'practice' | 'exam' | 'review';
478
530
  /**
479
- * The pager-level half of a read-aloud recording binding: where every take in
480
- * this sequence is stored, and how a judgement comes back.
481
- *
482
- * One prop at both levels, because it is one concept — the media-budget
483
- * precedent ({@link SequenceMediaBudget} beside {@link MediaBudgetBinding}).
484
- * The difference is the second argument: a per-slot binding knows which slot it
485
- * belongs to, so a sequence-level one is told.
486
- *
487
- * The SDK records and hands over; it never judges. `assess` is `practice` only
488
- * and optional — a sequence that only stores takes gets the "assessment is not
489
- * available" notice rather than a throw — and `playbackUrl` is `review` only.
490
- * A slot whose activity is not a read-aloud never calls any of them.
531
+ * Makes the answer-key-bearing parts of an activity optional, so a component
532
+ * can render either full activity data OR a `redact()` projection with the
533
+ * same props. Fields the SDK classifies `answer-key` become optional here;
534
+ * a component in `exam` mode must not read them at all.
491
535
  */
492
- interface SequenceRecordingBinding {
493
- /**
494
- * Puts the take in your storage and returns its key. **Required** outside
495
- * `review`: without it a learner can speak into a control that submits
496
- * nothing, which the component refuses to render. Settle it.
497
- */
498
- upload(take: RecordedTake, slot: SequenceRecordingSlot): Promise<RecordingRef>;
499
- /** Judges the stored take. `practice` only; an exam never assesses on the client. */
500
- assess?(ref: RecordingRef, slot: SequenceRecordingSlot): Promise<ReadAloudAssessResult>;
501
- /** A playable link to a stored take. `review` only. */
502
- playbackUrl?(ref: RecordingRef, slot: SequenceRecordingSlot): Promise<string>;
503
- }
504
-
536
+ type Renderable<TData> = Omit<TData, 'scoringStrategy'> & {
537
+ scoringStrategy?: unknown;
538
+ /** Present on a `redact()` projection. */
539
+ redacted?: true;
540
+ };
505
541
  /**
506
- * Every string the SDK's own chrome renders.
507
- *
508
- * Until 0.8.1 these were literals, and `locale` set only the `lang` attribute —
509
- * so a learner sitting a Spanish paper read a Spanish passage inside an English
510
- * scaffold, and "No plays remaining" on a listening exam was untranslatable.
511
- * This is the whole surface, in one place, so a consumer can replace it.
542
+ * {@link Renderable} DISTRIBUTED over a union of activity types.
512
543
  *
513
- * **Interpolation and plurals are functions, not format strings.** A message
514
- * like `"Question {n} of {total}"` needs a parser, and a parser needs plural
515
- * rules for every locale the SDK does not know. A function hands both problems
516
- * to the consumer, who has `Intl.PluralRules` and knows their own language:
544
+ * This distinction is load-bearing, not cosmetic. `Renderable<T>` is built on
545
+ * `Omit`, and `Omit` does not distribute: `Omit<A | B, K>` collapses to the
546
+ * keys A and B have IN COMMON, so `Renderable<ActivityData>` is a single
547
+ * object type carrying only the fields every activity shares. Narrowing it
548
+ * dies with it — after `if (data.type === 'multiple-choice')` the compiler
549
+ * still refuses `data.options`, because the union it would narrow to no
550
+ * longer exists.
517
551
  *
518
- * ```ts
519
- * questionProgress: (index, total) => `Pregunta ${index} de ${total}`,
520
- * wordCount: (n) => `${n} ${n === 1 ? 'palabra' : 'palabras'}`,
521
- * ```
552
+ * The conditional below re-distributes, so `RenderableActivity` is a real
553
+ * union of per-type renderables and `.type` narrows again. Anything that
554
+ * accepts "some renderable activity, I don't know which" — a custom renderer,
555
+ * a sequence entry — must use THIS, not `Renderable<ActivityData>`.
556
+ */
557
+ type RenderableActivity<TData extends ActivityData = ActivityData> = TData extends unknown ? Renderable<TData> : never;
558
+ /**
559
+ * Sanitiser for author-supplied rich text (`questionHtml`, `promptHtml`, and a
560
+ * stimulus's `bodyHtml`). The SDK deliberately ships NO sanitiser — that would
561
+ * add a dependency and, worse, a false promise. Rich text is rendered only when
562
+ * you supply this function; without it the component falls back to the
563
+ * plain-text field, which is always escaped. Fail-safe by construction: the SDK
564
+ * never injects HTML it was not explicitly given a sanitiser for.
522
565
  *
523
- * It also lets a translation reorder its arguments, which a positional token
524
- * cannot, and TypeScript checks the arity. No parser, no message catalogue
525
- * format, no runtime dependency.
566
+ * `FillInTheBlanks.passageHtml` is the one exception, and is **never**
567
+ * rendered: the passage hosts the answer inputs, so it cannot be split at the
568
+ * `{{blank}}` placeholders without voiding the sanitiser. The plain `passage`
569
+ * is always used, and passing `passageHtml` warns in development.
570
+ */
571
+ type HtmlSanitizer = (html: string) => string;
572
+ /**
573
+ * The prop contract shared by every activity component (Req 3.1). Defined
574
+ * here (React-specific) rather than in lk-core, which is React-free.
526
575
  *
527
- * **What is deliberately NOT here: thrown errors.** Every `throw` in this
528
- * package is addressed to the developer who wired the component up, not to the
529
- * learner. Translating them would make them unsearchable and would put the one
530
- * audience who can act on them behind a translation. They stay English.
576
+ * Controlled / uncontrolled follows the React convention: pass `value` +
577
+ * `onChange` to own the learner's answer (restore an in-progress attempt,
578
+ * autosave a delta, drive a review); pass `defaultValue` to seed an
579
+ * uncontrolled component; pass neither to keep the pre-2.1.0 behaviour, where
580
+ * the component owns the answer outright.
531
581
  */
532
- interface LkStrings {
533
- /** Multiple Choice submit button, and Written Response. */
534
- submit: string;
535
- /** Fill-in-the-Blanks in `practice`: the learner is checking their own work. */
536
- checkAnswers: string;
537
- /** Fill-in-the-Blanks in `exam`: nothing is checked on the client. */
538
- submitAnswers: string;
539
- /**
540
- * The scored result, read by a screen reader on submit.
541
- *
542
- * `percent` is already rounded to a whole number. The authored overall
543
- * feedback is appended SEPARATELY by the component, so a translation of this
544
- * sentence never has to carry the author's words — the two were previously
545
- * concatenated into one literal, which made the score sentence untranslatable
546
- * without also touching authored content.
547
- */
548
- scoreAnnouncement: (percent: number, passed: boolean) => string;
549
- /** Prefix announced on submit, before the score sentence. */
550
- answerSubmitted: string;
551
- /** An answer submitted for grading that has none yet. Never rendered as 0%. */
552
- notGradedYet: string;
553
- /** An outcome that carries no grade and never will. */
554
- noGradeAvailable: string;
555
- showFeedback: string;
556
- hideFeedback: string;
557
- /** Accessible name of a blank's input. `ordinal` is 1-based. */
558
- blankLabel: (ordinal: number) => string;
559
- /** Accessible name of the selector for one gap in a Gap Select passage. */
560
- gapLabel: (ordinal: number) => string;
561
- /**
562
- * The empty first entry of a gap's selector — how a learner leaves a gap
563
- * alone, and how they take an answer back. Never a real choice.
564
- */
565
- gapPlaceholder: string;
566
- showHint: string;
567
- hideHint: string;
568
- /**
569
- * Announced when an essay is handed in. Distinct from {@link LkStrings.awaitingGrade},
570
- * which is the `review`-mode state of a submission made earlier: this one
571
- * confirms the act, and is the only thing a learner hears at the moment they
572
- * commit work that nothing on the client will grade.
573
- */
574
- responseSubmitted: string;
575
- /** Live word counter. Needs the count for languages whose plural rules differ. */
576
- wordCount: (count: number) => string;
577
- /** The authored bounds, e.g. "50–200 words". `min` is 0 when unbounded below. */
578
- wordBounds: (min: number, max: number) => string;
579
- /** A rubric criterion the grader marked not applicable. */
580
- notApplicable: string;
581
- /** A returned grade flagged for a human to look at — learner-visible by design. */
582
- awaitingHumanReview: string;
583
- /** Submitted, no grade back yet. */
584
- awaitingGrade: string;
585
- /** The grader could not produce a grade. */
586
- couldNotBeGraded: string;
587
- previous: string;
588
- next: string;
589
- /** Progress label, also the question region's accessible name. `index` is 1-based. */
590
- questionProgress: (index: number, total: number) => string;
591
- /** Shown when no renderer is registered for an activity type. */
592
- unsupportedActivity: string;
593
- /** Accessible name of the box the learner types the recording into. */
594
- dictationInputLabel: string;
595
- /** Accessible name of the group holding the recording. */
596
- dictationRecording: string;
597
- /** Accessible name of the group holding the slower recording, and its default `alt`. */
598
- dictationSlowRecording: string;
599
- /** The progressive-hint button, with how many transcript words are shown so far. */
600
- dictationRevealNextWord: (revealed: number, total: number) => string;
601
- /** Hides every revealed hint word again. */
602
- dictationResetHints: string;
603
- /** The solution toggle: shows the transcript after the attempt is marked. */
604
- showSolution: string;
605
- hideSolution: string;
606
- /** Accessible name of the revealed transcript. */
607
- dictationSolutionLabel: string;
608
- /** Accessible name of the word-by-word marked result. */
609
- dictationMarksLabel: string;
610
- /** Tells the learner the marks compare the normalised text, not what they typed. */
611
- dictationDiffNote: string;
582
+ interface ActivityProps<TData extends ActivityData = ActivityData> {
583
+ /** Activity content. Accepts a `redact()` projection in `exam` mode. */
584
+ data: RenderableActivity<TData>;
612
585
  /**
613
- * The four sentences a screen reader hears for a marked word — the only
614
- * channel the marks have for assistive technology; the glyphs and the
615
- * character diff are decoration.
586
+ * Called when the component scored the attempt itself. Only ever fires in
587
+ * `practice` mode — in `exam` mode the client does not grade, so there is
588
+ * no `ActivityResult` to give you; use `onSubmit`.
616
589
  */
617
- dictationWordCorrect: (word: string) => string;
618
- dictationWordWrong: (word: string, expected: string) => string;
619
- dictationWordMissing: (expected: string) => string;
620
- dictationWordExtra: (word: string) => string;
621
- /** The legend rows; the character-level marks reuse the same four. */
622
- dictationLegendCorrect: string;
623
- dictationLegendWrong: string;
624
- dictationLegendMissing: string;
625
- dictationLegendExtra: string;
626
- /** Marked result of an attempt with no words in it once punctuation and spacing are ignored, an empty one included. */
627
- dictationNothingTyped: string;
590
+ onComplete?: (result: ActivityResult) => void;
628
591
  /**
629
- * Appended to the score announcement, e.g. "4 of 6 words correct." — a whole
630
- * sentence, because authored feedback may follow it.
592
+ * Called on submit with the raw learner response and no grade. Fires in
593
+ * every mode, before `onComplete`, so an exam runner can persist the
594
+ * response and let the server score it.
631
595
  */
632
- dictationWordsSummary: (correct: number, total: number) => string;
633
- /** Accessible name of the group holding the model recording. */
634
- readAloudModelRecording: string;
635
- /** Accessible name of the group holding the slower model recording, and its default `alt`. */
636
- readAloudSlowRecording: string;
637
- /** Starts a take. */
638
- readAloudRecord: string;
639
- /** Ends the take in progress. */
640
- readAloudStop: string;
641
- /** Throws the take away so the learner can start over. */
642
- readAloudRerecord: string;
643
- /** Accessible name of the player for the take the learner just made. */
644
- readAloudYourRecording: string;
596
+ onSubmit?: (response: LearnerResponse) => void;
597
+ /** Controlled value: the learner's current response. */
598
+ value?: LearnerResponse;
599
+ /** Initial response for an uncontrolled component (ignored when `value` is set). */
600
+ defaultValue?: LearnerResponse;
645
601
  /**
646
- * In place of the player for the learner's own take, and of the buttons that
647
- * play one word of it, when the page refuses to play it — most often a
648
- * Content-Security-Policy whose `media-src` does not allow `blob:`. Said
649
- * rather than left as controls that do nothing when pressed.
602
+ * Mount the component as already submitted — read at mount only, like any
603
+ * `default*` prop.
604
+ *
605
+ * Restoring an attempt without it reopens a question the learner had already
606
+ * submitted as answerable, so on a summative paper they can change and
607
+ * re-submit it. `AttemptState.submittedSlotIds` is what this consumes.
650
608
  */
651
- readAloudPlaybackUnavailable: string;
609
+ defaultSubmitted?: boolean;
610
+ /** Fires on every change to the learner's response. Required for a controlled component. */
611
+ onChange?: (response: LearnerResponse) => void;
612
+ /** Presentation mode. Defaults to `practice`. */
613
+ renderMode?: RenderMode;
652
614
  /**
653
- * Live progress while recording. Whole seconds, not a `m:ss` clock: a
654
- * recording is bounded in seconds by the item, and a translation should not
655
- * have to reimplement a clock to change the word around it.
656
- *
657
- * Shown on screen and hidden from assistive technology: it changes every
658
- * second, and a screen reader that spoke each change would speak into the
659
- * take being assessed. The two sentences below are what is announced instead.
615
+ * Server-computed outcome, used by `review` mode to mark correctness
616
+ * without the client ever scoring. Ignored in other modes.
660
617
  */
661
- readAloudRecordingProgress: (seconds: number, maxSeconds: number) => string;
662
- /** Announced once when a capture begins. */
663
- readAloudRecordingStarted: string;
618
+ outcome?: ItemOutcome;
619
+ /** Renders author-supplied rich text when provided. See {@link HtmlSanitizer}. */
620
+ sanitizeHtml?: HtmlSanitizer;
664
621
  /**
665
- * Announced once when a capture ends, by the learner or at its own bound.
666
- * Carries the length because the progress counter is not announced: a take
667
- * that reads back as "20 of 20 seconds" is one that stopped itself.
622
+ * Binds this activity's own `data.media` to a play budget the consumer
623
+ * persists. Usually supplied by `<ActivitySequence mediaBudget={…}>`; pass it
624
+ * yourself when rendering an activity standalone.
668
625
  */
669
- readAloudRecordingStopped: (seconds: number, maxSeconds: number) => string;
670
- /** How many takes are left. Called with 0 as well, so the wording is one translation's. */
671
- readAloudTakesRemaining: (remaining: number, max: number) => string;
672
- /** `exam` only: hands the item in with no recording at all, deliberately. */
673
- readAloudSubmitWithoutRecording: string;
674
- /** Pending, while the take travels to the application's storage. */
675
- readAloudUploading: string;
676
- /** The take never reached storage, so nothing was submitted. */
677
- readAloudUploadFailed: string;
678
- /** Retries whichever step just failed. */
679
- readAloudTryAgain: string;
680
- /** Pending, while the assessor judges the take. */
681
- readAloudAssessing: string;
682
- /** The binding stores recordings but judges none, so there is no feedback to show. */
683
- readAloudAssessmentUnavailable: string;
684
- /** The assessor found no speech, or too little of it, in the take. */
685
- readAloudNotHeard: string;
686
- /** The assessor produced no grade for any other reason. The code itself is never shown. */
687
- readAloudNotAssessed: string;
688
- /** The assessment could not be run at all — a failure of the call, not of the reading. */
689
- readAloudAssessmentFailed: string;
626
+ mediaBudget?: MediaBudgetBinding;
627
+ /** Translations for the audio transport chrome. See {@link MediaTransportStrings}. */
628
+ mediaStrings?: Partial<MediaTransportStrings>;
690
629
  /**
691
- * What went wrong with the microphone or with the take. One function rather
692
- * than five keys, so a translation keeps the five sentences together and the
693
- * SDK keeps one name for the concept.
630
+ * Overrides the SDK's chrome text for this activity, layered on whatever
631
+ * `LkIntlProvider` supplies. `mediaStrings` still works and is merged after
632
+ * this, so an existing 0.8.x call site keeps behaving as it did.
694
633
  */
695
- readAloudRecorderError: (reason: SpeechRecorderError) => string;
696
- /** Accessible name of the whole feedback region. */
697
- pronunciationFeedbackLabel: string;
698
- /** Name of one scored dimension, as a learner reads it. */
699
- pronunciationDimension: (dimension: ReadAloudDimension) => string;
700
- /** A dimension or a word the engine did not measure. NEVER rendered as 0%. */
701
- pronunciationNotAssessed: string;
702
- /** Accessible name of the word-by-word marks. */
703
- pronunciationWordsLabel: string;
704
- /** One legend row per marking state — the same four states the marks carry. */
705
- pronunciationLegend: (state: ReadAloudWordState) => string;
634
+ strings?: LkStringsOverride;
635
+ onInteraction?: (event: InteractionEvent) => void;
706
636
  /**
707
- * The four sentences a screen reader hears for a marked word: the only
708
- * channel the marks have for assistive technology, since the glyph and the
709
- * decoration are both hidden from it.
637
+ * The host's AI ports for this activity — an explanation after grading, hints
638
+ * before submit — overriding `LkAiProvider`'s. Leave a port out to switch
639
+ * that help off. Nothing AI appears in `exam`, and an item whose author
640
+ * switched a feature off (`data.ai`) never shows it. See {@link LearnerAi}.
710
641
  */
711
- pronunciationWordCorrect: (word: string) => string;
712
- pronunciationWordMispronounced: (word: string) => string;
713
- pronunciationWordOmitted: (word: string) => string;
714
- pronunciationWordInserted: (word: string) => string;
715
- /** Opens one word's syllables, sounds and timings. */
716
- pronunciationWordDetails: (word: string) => string;
717
- pronunciationSyllables: string;
718
- pronunciationPhonemes: string;
719
- /** A syllable the engine also spelled out, e.g. `('ˈhæ', 'ha')`. */
720
- pronunciationSyllableSpelling: (syllable: string, grapheme: string) => string;
721
- /** A sound the engine named no symbol for; `ordinal` is 1-based. */
722
- pronunciationPhonemePosition: (ordinal: number) => string;
723
- /** Introduces the sounds the engine thought it heard in a sound's place. */
724
- pronunciationHeardAs: string;
725
- /** Plays one word out of the learner's own take. */
726
- pronunciationPlayWord: (word: string) => string;
727
- pronunciationBreakUnexpected: string;
728
- pronunciationBreakMissing: string;
729
- /** Shown only past the caller's own monotone threshold; the SDK sets none. */
730
- pronunciationMonotone: string;
731
- /** Names the alphabet the phoneme symbols are written in. */
732
- pronunciationIpaNote: string;
733
- /** Jump buttons, e.g. "Back 10 seconds". */
734
- videoBack: (seconds: number) => string;
735
- videoForward: (seconds: number) => string;
736
- /** The centre button once the video has ended. */
737
- videoReplay: string;
738
- /** The time readout flips between elapsed and remaining; these name what a press shows next. */
739
- videoShowRemaining: string;
740
- videoShowElapsed: string;
741
- /** The captions button, named by what a press does. */
742
- videoCaptionsShow: string;
743
- videoCaptionsHide: string;
744
- /** In the captions menu when a caption file could not be read. */
745
- videoCaptionsFailed: string;
746
- /** The same, for the second language: the first line keeps working. */
747
- videoSecondaryCaptionsFailed: string;
748
- /** A speed in the speed menu, e.g. "1.25×". */
749
- videoSpeedValue: (rate: number) => string;
750
- videoSettings: string;
751
- videoCaptionLanguage: string;
752
- /** The captions page's item, and page, for a second caption line under the first. */
753
- videoSecondCaptionLanguage: string;
642
+ ai?: LearnerAi;
643
+ /** Per-instance token overrides, applied as inline CSS vars on the root. */
644
+ theme?: Partial<ThemeTokens>;
754
645
  /**
755
- * The captions row of the settings menu when two languages show, from the
756
- * tracks' own labels: "English + Español". A translation may order them as its
757
- * language reads, but the first argument is always the upper line.
646
+ * BCP 47 tag stamped as `lang` on this component's root. This is the
647
+ * INTERFACE language — the SDK's own chrome renders inside that element — so
648
+ * it should carry the same value you give `<LkIntlProvider locale>`. Passing
649
+ * a different one re-declares the language of every SDK string in this
650
+ * subtree without changing the words.
651
+ *
652
+ * It is NOT `data.locale`, which labels xAPI statements — and, on a
653
+ * dictation, places the dictation's own words: `<Dictation>` puts it, and
654
+ * the direction it names, on its title, hints, marks and solution. Other
655
+ * authored content in another language belongs on `stimulus.locale`, which
656
+ * `<StimulusPanel>` puts on the passage alone.
758
657
  */
759
- videoCaptionPair: (primary: string, secondary: string) => string;
760
- /** Announced when Shift + C finds no other language to show. */
761
- videoNoSecondLanguage: string;
762
- videoCaptionSize: string;
763
- videoCaptionSizeValue: (size: CaptionSize) => string;
764
- videoCaptionBackground: string;
765
- videoKeyboardShortcuts: string;
766
- videoShortcutList: string;
767
- /** Closes the shortcut list. */
768
- videoClose: string;
769
- /** A setting's state in the settings menu. */
770
- videoOn: string;
771
- videoOff: string;
772
- /** What each row of the shortcut list does. */
773
- videoShortcut: (action: VideoShortcutAction) => string;
774
- /** The button that opens the panel below the video, and its two tabs. */
775
- videoPanel: string;
776
- videoContents: string;
777
- videoTranscript: string;
778
- videoTranscriptSearch: string;
779
- videoTranscriptNoMatch: string;
780
- videoPictureInPicture: string;
781
- videoFullscreen: string;
782
- videoExitFullscreen: string;
658
+ locale?: string;
659
+ disabled?: boolean;
660
+ }
661
+ /**
662
+ * Bridges a server-produced `redact()` projection into the `data` prop.
663
+ *
664
+ * `RedactedActivityData` is deliberately index-signature typed in lk-core — it
665
+ * proves a payload is learner-safe, not what shape it has — so TypeScript
666
+ * cannot know it still carries the public fields a renderer needs. This is the
667
+ * SDK's single, documented crossing of that gap, so an exam runner does not
668
+ * have to write `as unknown as` at every call site:
669
+ *
670
+ * ```tsx
671
+ * <MultipleChoice
672
+ * data={asRenderable<MultipleChoiceData>(redactedFromServer)}
673
+ * renderMode="exam"
674
+ * onSubmit={persist}
675
+ * />
676
+ * ```
677
+ *
678
+ * Safe because `exam` mode reads only public fields; the answer-key fields the
679
+ * type claims are exactly the ones the component is forbidden to touch there.
680
+ *
681
+ * Per-type redacted interfaces (`RedactedMultipleChoiceData`, …) ship from
682
+ * lk-core since 0.6.0, but they are not yet *assignable* to `data`:
683
+ * {@link Renderable} widens `scoringStrategy` and leaves nested answer-key
684
+ * fields (`options[].isCorrect`, `blanks[].acceptedAnswers`) required, so a
685
+ * real redacted payload still needs this bridge. Closing that gap is tracked
686
+ * in the roadmap.
687
+ */
688
+ declare function asRenderable<TData extends ActivityData>(redacted: RedactedActivityData): Renderable<TData>;
689
+ /**
690
+ * The same bridge as {@link asRenderable}, for a whole sequence: activities
691
+ * and item groups as a server hands them over, ready for `<ActivitySequence>`.
692
+ *
693
+ * Needed for the same reason and no other. `redactItemGroup` returns
694
+ * `ItemGroup<RedactedActivityData>`, and `RedactedActivityData` is an
695
+ * index-signature type whose fields are all `unknown` — so its `question` is
696
+ * not a `string` and it satisfies no per-type renderable, however the prop is
697
+ * widened. Widening alone cannot fix this; a crossing point is required, and
698
+ * having exactly one keeps `as unknown as` out of consumer code.
699
+ *
700
+ * ```tsx
701
+ * const entries = await fetchExam(); // redacted, server-side
702
+ * <ActivitySequence
703
+ * activities={asRenderableSequence(entries)}
704
+ * renderMode="exam" // REQUIRED: see below
705
+ * shuffleSeed={attemptId}
706
+ * onSubmit={persist}
707
+ * />
708
+ * ```
709
+ *
710
+ * Pass `renderMode="exam"` (or `"review"`). Redacted data has no answer key, and
711
+ * almost every built-in activity throws at render in the default `practice`
712
+ * mode rather than fail later: `<MultipleChoice>`, `<FillInTheBlanks>`,
713
+ * `<GapSelect>` and `<Dictation>` because they grade locally, and
714
+ * `<WrittenResponse>` because `practice` still runs its local submit path and
715
+ * emits a practice-mode xAPI statement.
716
+ *
717
+ * `<ReadAloud>` is the exception and renders a projection in every mode: a
718
+ * read-aloud item has no answer key to withhold, so `redact()` removes only the
719
+ * authored feedback and leaves the reading, its language and its bounds — which
720
+ * is everything the component puts on screen. It grades nothing locally either;
721
+ * the grade comes back from whatever the application asked to judge the take.
722
+ */
723
+ declare function asRenderableSequence(entries: readonly (RedactedActivityData | RedactedItemGroupData)[]): readonly SequenceEntry<RenderableActivity>[];
724
+ /** Structural shape of a `redactItemGroup()` projection, as it arrives from a server. */
725
+ type RedactedItemGroupData = ItemGroup<RedactedActivityData> & {
726
+ redacted: true;
727
+ };
728
+ /**
729
+ * Every word the SDK's audio transport renders.
730
+ *
731
+ * Words, not characters: the `m:ss / m:ss` clock is digits and punctuation and
732
+ * is formatted by the component, because it reads identically in every locale
733
+ * this SDK targets. The scrubber's SPOKEN value does have a word in it and does
734
+ * have a key ({@link MediaTransportStrings.timeValue}).
735
+ *
736
+ * Supply them to translate it. These are the highest-stakes strings on a
737
+ * listening paper — "No plays remaining" decides whether a learner believes
738
+ * they may try again — so shipping them as untranslatable English inside a
739
+ * Spanish panel was not acceptable. Supplying any of them also sets `lang` on
740
+ * the transport chrome, so a screen reader does not read the SDK's own words
741
+ * with the passage's phonetics.
742
+ */
743
+ interface MediaTransportStrings {
744
+ play: string;
745
+ pause: string;
746
+ preparing: string;
747
+ mute: string;
748
+ unmute: string;
749
+ volume: string;
750
+ speed: string;
751
+ seek: string;
783
752
  /**
784
- * Why the video will not play, by `MediaError.code` (1 aborted, 2 network,
785
- * 3 decode, 4 source), with 0 for anything else.
753
+ * Spoken value of the scrubber, e.g. `('1:05', '4:30') => '1:05 of 4:30'`.
754
+ * Takes ALREADY-FORMATTED `m:ss` strings: a translation should not have to
755
+ * reimplement the clock to change the word between them.
786
756
  */
787
- videoError: (code: number) => string;
788
- videoTryAgain: string;
789
- /** A quiz with no title of its own. */
790
- videoQuiz: string;
791
- /** Where the learner is inside a quiz, e.g. "Question 2 of 5". */
792
- videoQuestionProgress: (index: number, total: number) => string;
793
- /** A step of the quiz's step indicator, for assistive technology. */
794
- videoQuestionStep: (index: number, answered: boolean) => string;
795
- videoNextQuestion: string;
796
- videoContinue: string;
797
- videoSkipQuiz: string;
798
- videoRewatch: string;
799
- /** Exam mode, once a question is submitted: saved, not graded. */
800
- videoAnswerSaved: string;
801
- /** The end card, while an answer is still being stored or graded: why Finish waits. */
802
- videoAnswerPending: string;
803
- videoRequired: string;
804
- /** A quiz's progress in the contents panel and on the end screen. */
805
- videoQuizProgress: (answered: number, total: number) => string;
806
- /** Announced when a quiz opens. */
807
- videoQuizOpened: (time: string, questions: number) => string;
808
- /** Announced when a seek stopped at a required quiz. */
809
- videoHeldAtQuiz: (time: string) => string;
810
- /** Announced when a seek stopped at the furthest point reached. */
811
- videoHeldAhead: string;
812
- videoEnded: string;
813
- /** Practice end screen: questions answered, and the score. */
814
- videoEndScore: (answered: number, total: number, percent: number) => string;
815
- /** Exam end screen: questions answered, no score. */
816
- videoEndAnswered: (answered: number, total: number) => string;
817
- videoWatchAgain: string;
818
- videoFinish: string;
757
+ timeValue: (elapsed: string, duration: string) => string;
758
+ /** e.g. `(1, 2) => '1 of 2 plays remaining'`. */
759
+ playsRemaining: (remaining: number, max: number) => string;
760
+ noPlaysRemaining: string;
761
+ /** Shown before the LAST play is spent, so a stray press cannot cost it. */
762
+ lastPlayConfirm: string;
763
+ lastPlayStart: string;
764
+ lastPlayCancel: string;
765
+ seekBlocked: string;
766
+ rateBlocked: string;
767
+ playFailed: string;
768
+ }
769
+ /**
770
+ * Binds ONE media block to a play budget the consumer persists.
771
+ *
772
+ * The SDK refuses a play; it does not remember one. Everything durable here is
773
+ * the consuming application's — see {@link SequenceMediaBudget.onPlayConsumed}.
774
+ */
775
+ interface MediaBudgetBinding {
776
+ /** From `slotMediaKey(slotId)` / `stimulusMediaKey(slotId)` in lk-core. */
777
+ key: string;
778
+ /** Plays already spent, and where playback stood. Read at mount only. */
779
+ entry?: MediaPlayLedgerEntry;
780
+ /** Defaults to `renderMode !== 'review'`. An explicit boolean wins either way. */
781
+ enforced?: boolean;
782
+ /** Slot context stamped onto the claim and the interaction event. */
783
+ slotId: string;
784
+ index: number;
785
+ activityId?: string;
786
+ onPlayConsumed?: (claim: MediaPlayClaim) => undefined | Promise<MediaPlayGrant | undefined>;
787
+ onPlayRefunded?: (claim: MediaPlayClaim) => void;
788
+ onPosition?: (key: string, seconds: number) => void;
789
+ /** Translations for the transport chrome. */
790
+ strings?: Partial<MediaTransportStrings>;
791
+ }
792
+ /**
793
+ * The pager-level half of a play budget. One prop, because it is one concept.
794
+ */
795
+ interface SequenceMediaBudget {
819
796
  /**
820
- * `<ActivityPreview>`, in place of a draft that still has something missing.
821
- * Author-facing, not learner-facing — but an editor is translated too.
797
+ * `MediaPlayLedger.entries` goes straight in. An absent key means nothing
798
+ * spent. Read at mount, like `responses`.
822
799
  */
823
- previewIncomplete: string;
824
- /** `<ActivityPreview>`, in place of a draft with something wrong in it. */
825
- previewInvalid: string;
800
+ plays?: Readonly<Record<string, MediaPlayLedgerEntry>>;
826
801
  /**
827
- * Accessible name of a provider iframe whose author supplied no `alt`.
828
- * `MediaSchema` requires `alt` on an embed, so validated content never
829
- * reaches this — but `validateActivity` is opt-in, and a screen-reader
830
- * learner meeting an unvalidated one should not meet it in English.
802
+ * Re-seed token. Change this string and the budgets re-seed from `plays`
803
+ * WITHOUT remounting the pager — the invigilator path ("the audio never
804
+ * started, give her the play back") that would otherwise cost the learner
805
+ * their focus, their scroll position and an unsaved answer.
831
806
  */
832
- embeddedMedia: string;
807
+ resumeKey?: string;
808
+ /** Explicit override of the default (`renderMode !== 'review'`). */
809
+ enforced?: boolean;
833
810
  /**
834
- * Accessible name of the stimulus region when the author gave it no title.
835
- * Keyed by `Stimulus['kind']`, because "Recording" and "Passage" do not share
836
- * a gender or an article in every language.
811
+ * Called the instant a play is claimed, BEFORE any audio is audible.
812
+ *
813
+ * Two tiers, chosen by what you return:
814
+ *
815
+ * - **Return nothing (optimistic).** Playback starts immediately and the
816
+ * count is only as durable as your write. **Do not debounce this, and do
817
+ * not batch it with the answer autosave** — an eight-second debounce is
818
+ * exactly long enough to start a third play and hard-reload. A `pagehide`
819
+ * beacon is a backstop, not the mechanism. A crash between this call and
820
+ * your write landing RETURNS the play to the learner; that is the honest
821
+ * description of what you are buying.
822
+ * - **Return a promise (confirmed).** Playback is held — the button reads
823
+ * "Preparing…" and is `aria-busy` — until it settles. Resolve with
824
+ * `{ playsUsed }` from an ATOMIC server write (`UPDATE … SET plays = plays
825
+ * + 1 … RETURNING plays`, or a compare-and-set on
826
+ * `claim.previousPlaysUsed`). A resolved count above `maxPlays` refuses the
827
+ * play, which is how a second tab is caught: two mounts both seeded at 0
828
+ * both claim 1, and only an atomic increment can tell them apart. A
829
+ * rejection charges nothing and lets the learner retry. This is the only
830
+ * tier in which "consumed before audible" is true of storage rather than
831
+ * only of memory; use it for summative papers.
832
+ *
833
+ * Never settle the promise and the learner cannot play at all: settle it.
837
834
  */
838
- stimulusKind: Record<Stimulus['kind'], string>;
835
+ onPlayConsumed?: (claim: MediaPlayClaim) => undefined | Promise<MediaPlayGrant | undefined>;
839
836
  /**
840
- * How far the material carries, e.g. "Questions 3–8". Called with equal
841
- * `first` and `last` for a single question, so a translation can choose its
842
- * own singular rather than receive a pre-pluralised English one.
837
+ * Called when a charged play produced no audio — the element errored before
838
+ * playback advanced past 0.25 s, an expired signed URL being the realistic
839
+ * cause. Supply it to give the play back, decrementing with a compare-and-set
840
+ * on `claim.playsUsed`. Omit it and the play stays spent: the SDK will not
841
+ * decrement a ledger it has no channel to correct.
843
842
  */
844
- stimulusRange: (first: number, last: number) => string;
845
- /** Production fallback when an activity throws during render. */
846
- activityFailed: string;
847
- /** The same, when the activity's title is known. */
848
- activityFailedNamed: (title: string) => string;
849
- /** Production fallback when the activity has no title to name. */
850
- activityFailedUnnamed: string;
851
- /** The button that asks for an explanation of a graded answer. */
852
- aiExplain: string;
853
- /** The same button while the explanation is being written. */
854
- aiExplaining: string;
855
- /** The heading over an explanation, and its accessible name. */
856
- aiExplanation: string;
857
- /** When no explanation can be shown: the port failed, or its answer was refused. */
858
- aiExplanationUnavailable: string;
859
- /** The button that asks for a hint. */
860
- aiHint: string;
861
- /** The same button while the hint is being written. */
862
- aiHintLoading: string;
863
- /** The label before each hint. */
864
- aiHintNumber: (hintNumber: number) => string;
865
- /** The accessible name of the list of hints. */
866
- aiHints: string;
867
- /** When no hint can be shown: the port failed, or its hint gave the answer away. */
868
- aiHintUnavailable: string;
869
- /** In place of the hint button once every hint has been used. */
870
- aiNoMoreHints: string;
871
- /** Beneath every AI text: who wrote it. */
872
- aiNotice: string;
843
+ onPlayRefunded?: (claim: MediaPlayClaim) => void;
873
844
  /**
874
- * The listening-paper transport. Shipped in 0.8.0 as its own `mediaStrings`
875
- * prop, which still works — this is the same shape, reachable from the one
876
- * provider so a consumer does not wire two mechanisms.
845
+ * Position reports, so a refresh resumes the play the learner already paid
846
+ * for instead of charging them again. Fires on pause, on end (with 0), and at
847
+ * most once per whole second of playback.
848
+ *
849
+ * **This one you MAY throttle** — the granularity you persist is the
850
+ * granularity of the replay a crash grants. Three seconds is sane; three
851
+ * minutes is not.
877
852
  */
878
- media: MediaTransportStrings;
853
+ onPosition?: (key: string, seconds: number) => void;
854
+ /** Translations for the transport chrome. Defaults are English. */
855
+ strings?: Partial<MediaTransportStrings>;
879
856
  }
880
- /** A partial override, nested one level for {@link LkStrings.media}. */
881
- type LkStringsOverride = Partial<Omit<LkStrings, 'media' | 'stimulusKind'>> & {
882
- media?: Partial<MediaTransportStrings>;
883
- stimulusKind?: Partial<Record<Stimulus['kind'], string>>;
884
- };
885
857
  /**
886
- * English defaults, byte-identical to what every component rendered before the
887
- * strings surface existed. Supplying no override changes nothing on screen.
858
+ * Which slot a recording belongs to, stamped on every call the pager makes.
859
+ *
860
+ * Exactly the shape {@link ActivitySequenceProps.onSubmit} already passes, so
861
+ * one identity travels with the answer and with the take that answer points at
862
+ * — and a consumer writes `slotId` in both places rather than reconciling two
863
+ * spellings of "which question was this".
888
864
  */
889
- declare const DEFAULT_STRINGS: LkStrings;
865
+ interface SequenceRecordingSlot {
866
+ /** Identity from `flattenSequence` — stable under shuffling. Store against THIS. */
867
+ slotId: string;
868
+ /** Presented position, which moves under shuffling. */
869
+ index: number;
870
+ activityId: string;
871
+ }
890
872
  /**
891
- * Layers an override onto a base dictionary.
873
+ * The pager-level half of a read-aloud recording binding: where every take in
874
+ * this sequence is stored, and how a judgement comes back.
892
875
  *
893
- * Two levels deep and no further, because the shape is two levels deep. A
894
- * generic deep merge would silently accept a nested object where a function
895
- * belongs and fail at render instead of at the call site.
876
+ * One prop at both levels, because it is one concept — the media-budget
877
+ * precedent ({@link SequenceMediaBudget} beside {@link MediaBudgetBinding}).
878
+ * The difference is the second argument: a per-slot binding knows which slot it
879
+ * belongs to, so a sequence-level one is told.
880
+ *
881
+ * The SDK records and hands over; it never judges. `assess` is `practice` only
882
+ * and optional — a sequence that only stores takes gets the "assessment is not
883
+ * available" notice rather than a throw — and `playbackUrl` is `review` only.
884
+ * A slot whose activity is not a read-aloud never calls any of them.
896
885
  */
897
- declare function mergeStrings(base: LkStrings, override?: LkStringsOverride): LkStrings;
886
+ interface SequenceRecordingBinding {
887
+ /**
888
+ * Puts the take in your storage and returns its key. **Required** outside
889
+ * `review`: without it a learner can speak into a control that submits
890
+ * nothing, which the component refuses to render. Settle it.
891
+ */
892
+ upload(take: RecordedTake, slot: SequenceRecordingSlot): Promise<RecordingRef>;
893
+ /** Judges the stored take. `practice` only; an exam never assesses on the client. */
894
+ assess?(ref: RecordingRef, slot: SequenceRecordingSlot): Promise<ReadAloudAssessResult>;
895
+ /** A playable link to a stored take. `review` only. */
896
+ playbackUrl?(ref: RecordingRef, slot: SequenceRecordingSlot): Promise<string>;
897
+ }
898
898
 
899
899
  export { type ActivityProps as A, type CaptionSize as C, DEFAULT_STRINGS as D, type HtmlSanitizer as H, type LkStrings as L, type MediaBudgetBinding as M, type ReadAloudAssessResult as R, type SequenceMediaBudget as S, type VideoPreferences as V, type LkStringsOverride as a, type MediaTransportStrings as b, type ReadAloudProps as c, type RecordingBinding as d, type RenderMode as e, type Renderable as f, type RenderableActivity as g, type SequenceRecordingBinding as h, type SequenceRecordingSlot as i, asRenderable as j, asRenderableSequence as k, mergeStrings as m };