tessera-learn 0.0.10 → 0.0.13

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 (79) hide show
  1. package/README.md +1 -0
  2. package/dist/audit-BBJpQGqb.js +204 -0
  3. package/dist/audit-BBJpQGqb.js.map +1 -0
  4. package/dist/plugin/a11y-cli.d.ts +1 -0
  5. package/dist/plugin/a11y-cli.js +36 -0
  6. package/dist/plugin/a11y-cli.js.map +1 -0
  7. package/dist/plugin/cli.js +6 -3
  8. package/dist/plugin/cli.js.map +1 -1
  9. package/dist/plugin/index.d.ts +16 -1
  10. package/dist/plugin/index.d.ts.map +1 -1
  11. package/dist/plugin/index.js +171 -140
  12. package/dist/plugin/index.js.map +1 -1
  13. package/dist/{validation-BxWAMMnJ.js → validation-B-xTvM9B.js} +417 -81
  14. package/dist/validation-B-xTvM9B.js.map +1 -0
  15. package/package.json +17 -2
  16. package/src/components/Accordion.svelte +3 -1
  17. package/src/components/AccordionItem.svelte +1 -5
  18. package/src/components/Audio.svelte +22 -5
  19. package/src/components/Callout.svelte +5 -1
  20. package/src/components/Carousel.svelte +24 -8
  21. package/src/components/DefaultLayout.svelte +41 -12
  22. package/src/components/FillInTheBlank.svelte +75 -103
  23. package/src/components/Image.svelte +14 -10
  24. package/src/components/LockedBanner.svelte +5 -5
  25. package/src/components/Matching.svelte +48 -19
  26. package/src/components/MediaTracks.svelte +21 -0
  27. package/src/components/MultipleChoice.svelte +81 -102
  28. package/src/components/Quiz.svelte +63 -21
  29. package/src/components/ResultIcon.svelte +20 -4
  30. package/src/components/RevealModal.svelte +25 -22
  31. package/src/components/Sorting.svelte +61 -26
  32. package/src/components/Transcript.svelte +37 -0
  33. package/src/components/Video.svelte +25 -20
  34. package/src/components/util.ts +4 -1
  35. package/src/components/video-embed.ts +25 -0
  36. package/src/index.ts +2 -7
  37. package/src/plugin/a11y/audit.ts +299 -0
  38. package/src/plugin/a11y/contrast.ts +67 -0
  39. package/src/plugin/a11y-cli.ts +35 -0
  40. package/src/plugin/cli.ts +6 -8
  41. package/src/plugin/export.ts +60 -50
  42. package/src/plugin/index.ts +244 -101
  43. package/src/plugin/layout.ts +6 -51
  44. package/src/plugin/manifest.ts +90 -24
  45. package/src/plugin/override-plugin.ts +68 -0
  46. package/src/plugin/quiz.ts +9 -54
  47. package/src/plugin/validation.ts +768 -183
  48. package/src/runtime/App.svelte +128 -64
  49. package/src/runtime/LoadingBar.svelte +12 -3
  50. package/src/runtime/Sidebar.svelte +24 -8
  51. package/src/runtime/access.ts +15 -3
  52. package/src/runtime/adapters/cmi5.ts +68 -116
  53. package/src/runtime/adapters/format.ts +67 -0
  54. package/src/runtime/adapters/index.ts +45 -34
  55. package/src/runtime/adapters/retry.ts +25 -84
  56. package/src/runtime/adapters/scorm-base.ts +19 -15
  57. package/src/runtime/adapters/scorm12.ts +8 -9
  58. package/src/runtime/adapters/scorm2004.ts +22 -30
  59. package/src/runtime/adapters/web.ts +1 -1
  60. package/src/runtime/hooks.svelte.ts +152 -328
  61. package/src/runtime/interaction-format.ts +30 -12
  62. package/src/runtime/interaction.ts +44 -11
  63. package/src/runtime/navigation.svelte.ts +29 -40
  64. package/src/runtime/persistence.ts +2 -2
  65. package/src/runtime/progress.svelte.ts +22 -9
  66. package/src/runtime/quiz-engine.svelte.ts +361 -0
  67. package/src/runtime/quiz-policy.ts +28 -179
  68. package/src/runtime/types.ts +24 -2
  69. package/src/runtime/xapi/agent-rules.ts +11 -3
  70. package/src/runtime/xapi/client.ts +5 -5
  71. package/src/runtime/xapi/derive-actor.ts +2 -2
  72. package/src/runtime/xapi/publisher.ts +33 -40
  73. package/src/runtime/xapi/setup.ts +18 -15
  74. package/src/runtime/xapi/validation.ts +15 -6
  75. package/src/virtual.d.ts +4 -1
  76. package/styles/base.css +32 -11
  77. package/styles/layout.css +39 -18
  78. package/styles/theme.css +15 -3
  79. package/dist/validation-BxWAMMnJ.js.map +0 -1
@@ -0,0 +1,361 @@
1
+ import { SvelteSet } from 'svelte/reactivity';
2
+ import type { Interaction } from './interaction.js';
3
+ import type { QuizConfig } from './types.js';
4
+ import {
5
+ resolveFeedbackMode,
6
+ resolveRetryStrategy,
7
+ type QuizQuestionResult,
8
+ type FeedbackModePredicate,
9
+ type RetryStrategyPredicate,
10
+ } from './quiz-policy.js';
11
+ import type {
12
+ UseQuizInternalHandle,
13
+ UseQuizQuestionApi,
14
+ QuestionInternal,
15
+ Question,
16
+ } from './hooks.svelte.js';
17
+
18
+ /**
19
+ * Dependencies injected into {@link QuizEngine} so the engine itself stays
20
+ * framework- and DOM-free. The Svelte wrapper (`useQuiz`) provides the two
21
+ * callbacks that bridge to the host element and the LMS adapter.
22
+ */
23
+ export interface QuizEngineDeps {
24
+ quizConfig: QuizConfig;
25
+ /**
26
+ * Live accessor for the resolved passing threshold (config + LMS mastery
27
+ * override). Read on each access rather than captured, because the cmi5/SCORM
28
+ * mastery override mutates `pageContext.passingScore` after `useQuiz()` may
29
+ * already have run.
30
+ */
31
+ passingScore: () => number;
32
+ /** Wraps `adapterCtx.adapter.reportInteraction`; a no-op when there is no adapter. */
33
+ report: (
34
+ id: string,
35
+ interaction: Interaction,
36
+ correct: boolean | null,
37
+ ) => void;
38
+ /**
39
+ * Wraps the host-element `CustomEvent` dispatch. Returns `false` when the host
40
+ * element is null (the engine treats that as "no LMS bridge listener").
41
+ */
42
+ dispatch: (name: string, detail?: unknown) => boolean;
43
+ }
44
+
45
+ interface InternalQuestion {
46
+ id: string;
47
+ weight: number;
48
+ checkAnswer: (answer?: unknown) => boolean;
49
+ reset?: () => void;
50
+ interaction?: () => Interaction;
51
+ render: unknown;
52
+ }
53
+
54
+ /**
55
+ * The quiz engine: all reactive state, scoring, retry/feedback policy and the
56
+ * register/submit/retry lifecycle that used to live inside the `useQuiz`
57
+ * closure. Directly instantiable (and unit-testable) because the only two
58
+ * side-effecting touchpoints — DOM events and LMS reporting — are injected.
59
+ */
60
+ export class QuizEngine implements UseQuizInternalHandle {
61
+ #deps: QuizEngineDeps;
62
+ #feedbackPredicate: FeedbackModePredicate;
63
+ #retryPredicate: RetryStrategyPredicate;
64
+ #maxAttempts: number;
65
+
66
+ #internalQuestions = $state<InternalQuestion[]>([]);
67
+ #questionHandles = $state<QuestionInternal[]>([]);
68
+ #answers = new Map<number, unknown>();
69
+ #reportedAnswers = new Map<number, string>();
70
+ #answersVersion = $state(0);
71
+ #submitted = $state(false);
72
+ #reviewing = $state(false);
73
+ #score = $state(0);
74
+ #attemptCount = $state(0);
75
+ #submitCalled = false; // plain field, not $state — only the wrapper's onDestroy reads it
76
+ #feedbackShown = new SvelteSet<number>();
77
+ #lockedCorrect = new SvelteSet<number>();
78
+ #seenIds = new Set<string>();
79
+
80
+ constructor(deps: QuizEngineDeps) {
81
+ this.#deps = deps;
82
+ this.#maxAttempts = deps.quizConfig.maxAttempts ?? Infinity;
83
+ this.#feedbackPredicate = resolveFeedbackMode(deps.quizConfig);
84
+ this.#retryPredicate = resolveRetryStrategy(deps.quizConfig);
85
+ }
86
+
87
+ // Derived values are plain getters over $state, mirroring ProgressState (which
88
+ // has no $derived). Getters recompute on read with or without a tracking
89
+ // effect, so engine-construction tests need no $effect.root.
90
+ get #totalQuestions(): number {
91
+ return this.#internalQuestions.length;
92
+ }
93
+
94
+ get #allAnswered(): boolean {
95
+ void this.#answersVersion;
96
+ return (
97
+ this.#totalQuestions > 0 && this.#answers.size >= this.#totalQuestions
98
+ );
99
+ }
100
+
101
+ get state(): 'answering' | 'submitted' | 'reviewing' {
102
+ return this.#reviewing
103
+ ? 'reviewing'
104
+ : this.#submitted
105
+ ? 'submitted'
106
+ : 'answering';
107
+ }
108
+
109
+ get questions(): ReadonlyArray<Question> {
110
+ return this.#questionHandles;
111
+ }
112
+
113
+ get canSubmit(): boolean {
114
+ return !this.#submitted && this.#allAnswered;
115
+ }
116
+
117
+ get canRetry(): boolean {
118
+ return this.#submitted && this.#attemptCount < this.#maxAttempts;
119
+ }
120
+
121
+ get score(): number {
122
+ return this.#score;
123
+ }
124
+
125
+ get passingScore(): number {
126
+ return this.#deps.passingScore();
127
+ }
128
+
129
+ get attemptCount(): number {
130
+ return this.#attemptCount;
131
+ }
132
+
133
+ /** Dev-warning inputs the wrapper reads in onDestroy. */
134
+ get stats(): {
135
+ questionsCount: number;
136
+ answersCount: number;
137
+ submitCalled: boolean;
138
+ } {
139
+ return {
140
+ questionsCount: this.#internalQuestions.length,
141
+ answersCount: this.#answers.size,
142
+ submitCalled: this.#submitCalled,
143
+ };
144
+ }
145
+
146
+ registerQuestion(api: UseQuizQuestionApi): QuestionInternal {
147
+ if (this.#seenIds.has(api.id)) {
148
+ console.warn(
149
+ `[tessera] useQuiz: duplicate question id "${api.id}" — ` +
150
+ 'each question id must be unique within a quiz (LMS interaction records key by id).',
151
+ );
152
+ }
153
+ this.#seenIds.add(api.id);
154
+ const internal: InternalQuestion = {
155
+ id: api.id,
156
+ weight: typeof api.weight === 'number' && api.weight > 0 ? api.weight : 1,
157
+ checkAnswer: api.checkAnswer,
158
+ reset: api.reset,
159
+ interaction: api.interaction,
160
+ render: undefined,
161
+ };
162
+ this.#internalQuestions.push(internal);
163
+ const handle = this.#makeQuestionHandle(this.#internalQuestions.length - 1);
164
+ this.#questionHandles.push(handle);
165
+ return handle;
166
+ }
167
+
168
+ setAnswer(index: number, answer: unknown): void {
169
+ this.#answers.set(index, answer);
170
+ this.#answersVersion++;
171
+ this.#deps.dispatch('tessera-quiz-question-answered', { index });
172
+ }
173
+
174
+ getAnswer(index: number): unknown {
175
+ void this.#answersVersion;
176
+ return this.#answers.get(index);
177
+ }
178
+
179
+ setRender(index: number, render: unknown): void {
180
+ if (this.#internalQuestions[index])
181
+ this.#internalQuestions[index].render = render;
182
+ }
183
+
184
+ getRender(index: number): unknown {
185
+ return this.#internalQuestions[index]?.render;
186
+ }
187
+
188
+ feedbackVisible(index: number): boolean {
189
+ if (this.#deps.quizConfig.feedbackMode === 'never') return false;
190
+ return this.#feedbackPredicate({
191
+ questionIndex: index,
192
+ submitted: this.#submitted,
193
+ reviewing: this.#reviewing,
194
+ hasAnswered: this.#answers.has(index),
195
+ revealed: this.#feedbackShown.has(index),
196
+ attemptCount: this.#attemptCount,
197
+ });
198
+ }
199
+
200
+ revealFeedbackByIndex(index: number): void {
201
+ if (this.#deps.quizConfig.feedbackMode === 'never') return;
202
+ this.#feedbackShown.add(index);
203
+ }
204
+
205
+ isLockedCorrect(index: number): boolean {
206
+ return this.#lockedCorrect.has(index);
207
+ }
208
+
209
+ revealFeedback(q: Question): void {
210
+ const index = this.#internalQuestions.findIndex((iq) => iq.id === q.id);
211
+ if (index >= 0) this.revealFeedbackByIndex(index);
212
+ }
213
+
214
+ submit(): void {
215
+ this.#submitCalled = true;
216
+ if (this.#submitted) return;
217
+ if (!this.#allAnswered) return;
218
+ // Combined null-host guard + before-submit dispatch: dispatch() returns false
219
+ // when the host element is null, which is the silent-LMS-dropout case.
220
+ if (!this.#deps.dispatch('tessera-quiz-before-submit')) {
221
+ console.warn(
222
+ '[tessera] useQuiz: submit() ran but the host element was null — no LMS bridge ' +
223
+ 'listener exists, so this score will not be persisted. Make sure your custom ' +
224
+ 'quiz shell binds the element it passes to useQuiz({ element: () => ... }).',
225
+ );
226
+ return;
227
+ }
228
+
229
+ for (let i = 0; i < this.#internalQuestions.length; i++) this.#commit(i);
230
+
231
+ const { rounded } = this.#computeScore();
232
+ this.#score = rounded;
233
+ this.#submitted = true;
234
+ this.#attemptCount++;
235
+
236
+ this.#deps.dispatch('tessera-quiz-complete', { score: rounded });
237
+ }
238
+
239
+ startReview(): void {
240
+ if (!this.#submitted) return;
241
+ this.#reviewing = true;
242
+ }
243
+
244
+ exitReview(): void {
245
+ this.#reviewing = false;
246
+ }
247
+
248
+ retry(): void {
249
+ if (!this.canRetry) return;
250
+ const results: QuizQuestionResult[] = [];
251
+ for (let i = 0; i < this.#internalQuestions.length; i++) {
252
+ const a = this.#answers.has(i) ? this.#answers.get(i) : undefined;
253
+ results.push({
254
+ interaction:
255
+ this.#internalQuestions[i].interaction?.() ?? ({} as never),
256
+ correct: this.#internalQuestions[i].checkAnswer(a),
257
+ weight: this.#internalQuestions[i].weight,
258
+ });
259
+ }
260
+ const newLocked = this.#retryPredicate(results);
261
+ const preserved = new Map<number, unknown>();
262
+ for (const i of newLocked) {
263
+ if (this.#answers.has(i)) preserved.set(i, this.#answers.get(i));
264
+ }
265
+ this.#lockedCorrect.clear();
266
+ for (const i of newLocked) this.#lockedCorrect.add(i);
267
+ this.#answers.clear();
268
+ this.#reportedAnswers.clear();
269
+ for (const [i, a] of preserved) this.#answers.set(i, a);
270
+ for (let i = 0; i < this.#internalQuestions.length; i++) {
271
+ if (!newLocked.has(i) && this.#internalQuestions[i].reset)
272
+ this.#internalQuestions[i].reset!();
273
+ }
274
+ this.#answersVersion++;
275
+ this.#feedbackShown.clear();
276
+ this.#submitted = false;
277
+ this.#reviewing = false;
278
+ this.#score = 0;
279
+ this.#deps.dispatch('tessera-quiz-retry');
280
+ }
281
+
282
+ #commit(index: number): void {
283
+ const q = this.#internalQuestions[index];
284
+ if (!q || typeof q.interaction !== 'function') return;
285
+ const interaction = q.interaction();
286
+ if (!interaction) return;
287
+ const fingerprint = JSON.stringify(interaction);
288
+ if (this.#reportedAnswers.get(index) === fingerprint) return;
289
+ const answer = this.#answers.has(index)
290
+ ? this.#answers.get(index)
291
+ : undefined;
292
+ this.#deps.report(q.id, interaction, q.checkAnswer(answer));
293
+ this.#reportedAnswers.set(index, fingerprint);
294
+ }
295
+
296
+ #computeScore(): { rounded: number; correctCount: number } {
297
+ let weighted = 0;
298
+ let totalWeight = 0;
299
+ let correctCount = 0;
300
+ for (let i = 0; i < this.#internalQuestions.length; i++) {
301
+ const q = this.#internalQuestions[i];
302
+ const a = this.#answers.has(i) ? this.#answers.get(i) : undefined;
303
+ const ok = q.checkAnswer(a);
304
+ totalWeight += q.weight;
305
+ if (ok) {
306
+ weighted += q.weight;
307
+ correctCount++;
308
+ }
309
+ }
310
+ if (totalWeight === 0) return { rounded: 0, correctCount: 0 };
311
+ return {
312
+ rounded: Math.round((weighted / totalWeight) * 100),
313
+ correctCount,
314
+ };
315
+ }
316
+
317
+ #makeQuestionHandle(i: number): QuestionInternal {
318
+ const engine = this;
319
+ return {
320
+ get id() {
321
+ return engine.#internalQuestions[i].id;
322
+ },
323
+ get submitted() {
324
+ return engine.#submitted;
325
+ },
326
+ get correct() {
327
+ if (!engine.#submitted) return null;
328
+ const a = engine.#answers.has(i) ? engine.#answers.get(i) : undefined;
329
+ return engine.#internalQuestions[i].checkAnswer(a);
330
+ },
331
+ get answer() {
332
+ return engine.getAnswer(i);
333
+ },
334
+ get feedbackVisible() {
335
+ return engine.feedbackVisible(i);
336
+ },
337
+ get locked() {
338
+ return (
339
+ engine.#submitted ||
340
+ engine.feedbackVisible(i) ||
341
+ engine.isLockedCorrect(i)
342
+ );
343
+ },
344
+ get isLockedCorrect() {
345
+ return engine.isLockedCorrect(i);
346
+ },
347
+ get render() {
348
+ return engine.getRender(i);
349
+ },
350
+ setAnswer(a: unknown) {
351
+ engine.setAnswer(i, a);
352
+ },
353
+ commit() {
354
+ engine.#commit(i);
355
+ },
356
+ setRender(r: unknown) {
357
+ engine.setRender(i, r);
358
+ },
359
+ };
360
+ }
361
+ }
@@ -1,10 +1,12 @@
1
1
  /**
2
- * Quiz config desugaring. Authors drive feedback / retry / submit-gating /
3
- * scoring with either string enums or predicate functions; this module
4
- * normalizes both forms into predicates so `useQuiz` only ever interacts
5
- * with the predicate API.
2
+ * Quiz config desugaring. Authors pick feedback / retry behavior with string
3
+ * enums in `pageConfig.quiz`; this module normalizes them into predicates so
4
+ * `useQuiz` only ever interacts with the predicate API. Config is extracted
5
+ * from source as a static object literal (JSON5), so only the enum forms are
6
+ * representable — there are no function-valued options.
6
7
  */
7
8
  import type { Interaction } from './interaction.js';
9
+ import type { QuizConfig } from './types.js';
8
10
 
9
11
  export interface QuizQuestionResult {
10
12
  /** The original interaction reported for the question. */
@@ -15,12 +17,7 @@ export interface QuizQuestionResult {
15
17
  weight: number;
16
18
  }
17
19
 
18
- /**
19
- * State the feedback predicate is given so it can decide independently of
20
- * the string-enum branches inside `useQuiz`. The predicate is the single
21
- * source of truth — the enums (`'immediate'` / `'review'`) desugar into
22
- * predicates over this same state.
23
- */
20
+ /** State the feedback predicate decides over. */
24
21
  export interface FeedbackVisibilityState {
25
22
  /** Index of the question being asked about. */
26
23
  questionIndex: number;
@@ -30,114 +27,43 @@ export interface FeedbackVisibilityState {
30
27
  reviewing: boolean;
31
28
  /** Has the question been answered (the shell called `setAnswer`)? */
32
29
  hasAnswered: boolean;
33
- /**
34
- * Has the shell explicitly revealed feedback for this question via
35
- * `revealFeedback(index)`? Lets `'immediate'` flows distinguish "answered
36
- * but not yet revealed" from "Check Answer button pressed."
37
- */
30
+ /** Has the shell revealed feedback for this question via `revealFeedback`? */
38
31
  revealed: boolean;
39
32
  /** Number of times `submit()` has fired for this quiz instance. */
40
33
  attemptCount: number;
41
34
  }
42
35
 
43
36
  export type FeedbackModePredicate = (state: FeedbackVisibilityState) => boolean;
44
- export type RetryStrategyPredicate = (results: QuizQuestionResult[]) => Set<number>;
45
- export type CanSubmitPredicate = (answeredCount: number, totalCount: number) => boolean;
46
- export type ScorePredicate = (results: QuizQuestionResult[]) => number;
47
-
48
- export interface QuizPolicyConfig {
49
- /**
50
- * When feedback for a question should render:
51
- * - `'immediate'` — after the shell calls `revealFeedback(q)` for the question.
52
- * - `'review'` (default) — only while the quiz is in review mode.
53
- * - `'never'` — feedback never renders, no Review button.
54
- * - predicate `(state) => boolean` — full control over visibility.
55
- *
56
- * Predicates receive a `FeedbackVisibilityState` so they can decide
57
- * independently of the enum branches — the enums themselves desugar to
58
- * predicates over the same state.
59
- */
60
- feedbackMode?: 'immediate' | 'review' | 'never' | FeedbackModePredicate;
61
- /**
62
- * On retry, clear every answer (`'full'`), preserve correct answers
63
- * (`'incorrect-only'`), or pass a custom predicate that takes the previous
64
- * attempt's results and returns the set of question indices to keep locked.
65
- */
66
- retryMode?: 'full' | 'incorrect-only' | RetryStrategyPredicate;
67
- /**
68
- * Custom gate for the Submit button. Defaults to "every registered
69
- * question has an answer". Predicates take (answered, total).
70
- */
71
- canSubmit?: CanSubmitPredicate;
72
- /**
73
- * Custom score formula. Defaults to weighted-correct percentage —
74
- * `Σ(weight × correct) / Σ(weight) × 100`. Authors must return a value in
75
- * 0–100; values outside that range warn in dev mode.
76
- */
77
- score?: ScorePredicate;
78
- }
37
+ export type RetryStrategyPredicate = (
38
+ results: QuizQuestionResult[],
39
+ ) => Set<number>;
79
40
 
80
41
  /**
81
- * Resolve the configured feedback policy into a single predicate that owns
82
- * the "should this question's feedback be visible right now?" decision.
83
- *
84
- * The shipping enums desugar to:
85
- * - `'immediate'` — visible after the shell calls `revealFeedback(q)` for
86
- * the question, OR while the quiz is in review mode.
87
- * - `'review'` (default) — visible only while the quiz is in review mode.
88
- * - `'never'` — never visible. `useQuiz` short-circuits before calling here.
89
- *
90
- * Predicates receive the full visibility state so they can encode any policy
91
- * — e.g. "only after first wrong attempt": `(s) => s.attemptCount > 0 && s.submitted`.
42
+ * Resolve the configured feedback policy into the "should this question's
43
+ * feedback be visible now?" predicate.
44
+ * - `'immediate'` — visible after the shell calls `revealFeedback(q)`, or in review.
45
+ * - `'review'` (default) visible only while reviewing.
46
+ * - `'never'` — never visible (`useQuiz` short-circuits before calling here).
92
47
  */
93
- export function resolveFeedbackMode(cfg: QuizPolicyConfig | undefined | null): FeedbackModePredicate {
48
+ export function resolveFeedbackMode(
49
+ cfg: QuizConfig | undefined | null,
50
+ ): FeedbackModePredicate {
94
51
  const mode = cfg?.feedbackMode;
95
- if (typeof mode === 'function') return mode;
96
- if (mode === 'immediate') {
97
- return (s) => s.revealed || s.reviewing;
98
- }
99
- if (mode === 'never') {
100
- return () => false;
101
- }
102
- // Default + 'review'
52
+ if (mode === 'immediate') return (s) => s.revealed || s.reviewing;
53
+ if (mode === 'never') return () => false;
103
54
  return (s) => s.reviewing;
104
55
  }
105
56
 
106
- function isDevMode(): boolean {
107
- return import.meta.env?.DEV === true;
108
- }
109
-
110
57
  /**
111
- * Resolve the configured retry strategy into a predicate that returns the
112
- * set of question indices to lock as "already correct" on the next attempt.
113
- *
114
- * - `'full'` (default) — reset everything.
58
+ * Resolve the retry strategy into a predicate returning the set of question
59
+ * indices to lock as "already correct" on the next attempt.
115
60
  * - `'incorrect-only'` — keep questions the learner got right.
116
- * - functionauthor decides per result.
117
- *
118
- * Author predicates are wrapped: a non-Set return turns into "lock nothing"
119
- * in production and throws in dev so the bug stays local. An author returning
120
- * `[0, 1]` instead of `new Set([0, 1])` would otherwise silently no-op the
121
- * lock and quietly break `'incorrect-only'`-style retries.
61
+ * - `'full'` (default) reset everything.
122
62
  */
123
- export function resolveRetryStrategy(cfg: QuizPolicyConfig | undefined | null): RetryStrategyPredicate {
124
- const mode = cfg?.retryMode;
125
- if (typeof mode === 'function') {
126
- return (results) => {
127
- const raw = mode(results);
128
- if (!(raw instanceof Set)) {
129
- if (isDevMode()) {
130
- throw new TypeError(
131
- `[tessera] quiz retryMode predicate returned ${Object.prototype.toString.call(raw)}; ` +
132
- `expected a Set<number> of question indices to lock.`
133
- );
134
- }
135
- return new Set<number>();
136
- }
137
- return raw;
138
- };
139
- }
140
- if (mode === 'incorrect-only') {
63
+ export function resolveRetryStrategy(
64
+ cfg: QuizConfig | undefined | null,
65
+ ): RetryStrategyPredicate {
66
+ if (cfg?.retryMode === 'incorrect-only') {
141
67
  return (results) => {
142
68
  const locked = new Set<number>();
143
69
  results.forEach((r, i) => {
@@ -146,82 +72,5 @@ export function resolveRetryStrategy(cfg: QuizPolicyConfig | undefined | null):
146
72
  return locked;
147
73
  };
148
74
  }
149
- // Default 'full': clear every answer.
150
75
  return () => new Set<number>();
151
76
  }
152
-
153
- /**
154
- * Resolve the Submit gate. Default — all answered.
155
- *
156
- * Author predicates are wrapped: a non-boolean return is coerced with `!!` in
157
- * production and throws in dev. Authors returning `answered` (a number) would
158
- * otherwise enable Submit on `0` answered ↔ disable on a count that happens
159
- * to equal `NaN` — silently wrong gates either way.
160
- */
161
- export function resolveCanSubmit(cfg: QuizPolicyConfig | undefined | null): CanSubmitPredicate {
162
- if (typeof cfg?.canSubmit === 'function') {
163
- const fn = cfg.canSubmit;
164
- return (answered, total) => {
165
- const raw = fn(answered, total);
166
- if (typeof raw !== 'boolean') {
167
- if (isDevMode()) {
168
- throw new TypeError(
169
- `[tessera] quiz canSubmit predicate returned ${typeof raw}; expected a boolean.`
170
- );
171
- }
172
- return !!raw;
173
- }
174
- return raw;
175
- };
176
- }
177
- return (answered, total) => total > 0 && answered >= total;
178
- }
179
-
180
- /**
181
- * Resolve the score formula. Default — weighted-correct percentage. With all
182
- * weights = 1 (the default for every existing course), the output equals the
183
- * pre-Phase-5 unweighted formula.
184
- */
185
- export function resolveScore(cfg: QuizPolicyConfig | undefined | null): ScorePredicate {
186
- if (typeof cfg?.score === 'function') {
187
- return (results) => {
188
- const raw = cfg.score!(results);
189
- const isDev = isDevMode();
190
- if (typeof raw !== 'number' || !Number.isFinite(raw)) {
191
- // NaN/Infinity/non-number can't ride through to setScore(...) — the LMS
192
- // either rejects the cmi write or rolls it up to nonsense. Throw in dev
193
- // so the bug stays local; clamp to 0 in prod so a runaway predicate
194
- // can't crash the learner's session.
195
- if (isDev) {
196
- throw new TypeError(
197
- `[tessera] quiz score predicate returned ${String(raw)}; expected a finite number in 0–100.`
198
- );
199
- }
200
- return 0;
201
- }
202
- if (raw < 0 || raw > 100) {
203
- if (isDev) {
204
- // eslint-disable-next-line no-console
205
- console.warn(
206
- `[tessera] quiz score predicate returned ${raw}; expected a finite number in 0–100. ` +
207
- `Clamping to range — LMSes reject out-of-range cmi.score.raw values.`
208
- );
209
- }
210
- return Math.max(0, Math.min(100, raw));
211
- }
212
- return raw;
213
- };
214
- }
215
- return (results) => {
216
- if (results.length === 0) return 0;
217
- let weighted = 0;
218
- let totalWeight = 0;
219
- for (const r of results) {
220
- const w = r.weight > 0 ? r.weight : 1;
221
- totalWeight += w;
222
- if (r.correct) weighted += w;
223
- }
224
- if (totalWeight === 0) return 0;
225
- return Math.round((weighted / totalWeight) * 100);
226
- };
227
- }
@@ -1,6 +1,14 @@
1
1
  import type { AccessFn } from './access.js';
2
2
  import type { XAPIAgent } from './xapi/types.js';
3
3
 
4
+ /**
5
+ * Quiz enum domains as runtime tuples. The unions below derive from these, and
6
+ * the build-time validator imports them too — so the accepted value set has a
7
+ * single source and can't drift between the types and the validator.
8
+ */
9
+ export const FEEDBACK_MODES = ['review', 'immediate', 'never'] as const;
10
+ export const RETRY_MODES = ['full', 'incorrect-only'] as const;
11
+
4
12
  /**
5
13
  * Per-page quiz configuration. Single source of truth — the build plugin
6
14
  * extracts this from `pageConfig.quiz` and embeds it in the manifest;
@@ -10,8 +18,8 @@ export interface QuizConfig {
10
18
  graded?: boolean;
11
19
  gatesProgress?: boolean;
12
20
  maxAttempts?: number;
13
- feedbackMode?: 'review' | 'immediate' | 'never';
14
- retryMode?: 'full' | 'incorrect-only';
21
+ feedbackMode?: (typeof FEEDBACK_MODES)[number];
22
+ retryMode?: (typeof RETRY_MODES)[number];
15
23
  }
16
24
 
17
25
  export interface CourseConfig {
@@ -19,6 +27,10 @@ export interface CourseConfig {
19
27
  description?: string;
20
28
  author?: string;
21
29
  version?: string;
30
+ /** BCP-47 language tag for <html lang>. Defaults to 'en'. WCAG 3.1.1. */
31
+ language?: string;
32
+ /** Accessibility checker configuration. */
33
+ a11y?: A11yConfig;
22
34
  branding?: {
23
35
  logo?: string;
24
36
  primaryColor?: string;
@@ -45,6 +57,16 @@ export interface CourseConfig {
45
57
  xapi?: XAPIConfig | XAPIConfig[];
46
58
  }
47
59
 
60
+ /** Accessibility checker configuration. */
61
+ export interface A11yConfig {
62
+ /** Build-gate severity for promotable Tier-1 rules + Tier-1a warnings. */
63
+ level?: 'warn' | 'error';
64
+ /** axe ruleset tags for the Tier-2 runtime auditor. */
65
+ standard?: 'wcag2a' | 'wcag2aa' | 'wcag21aa';
66
+ /** Per-rule escape hatch matched literally against each diagnostic's ID. */
67
+ ignore?: string[];
68
+ }
69
+
48
70
  export interface ManualCompletion {
49
71
  mode: 'manual';
50
72
  /**
@@ -7,6 +7,11 @@
7
7
  * Keeping the rules in one place prevents the two callsites from drifting.
8
8
  */
9
9
 
10
+ /** Join a field label with a validator suffix: `.foo` chains, others get `: `. */
11
+ export function joinFieldError(label: string, suffix: string): string {
12
+ return suffix.startsWith('.') ? `${label}${suffix}` : `${label}: ${suffix}`;
13
+ }
14
+
10
15
  /**
11
16
  * Validate that a candidate is an Identified Agent per xAPI 1.0.3.
12
17
  * Returns null on success or a human-readable error suffix on failure.
@@ -40,7 +45,10 @@ export function validateAgent(actor: unknown): string | null {
40
45
  }
41
46
  }
42
47
  if (a.mbox_sha1sum !== undefined) {
43
- if (typeof a.mbox_sha1sum !== 'string' || !/^[0-9a-f]{40}$/i.test(a.mbox_sha1sum)) {
48
+ if (
49
+ typeof a.mbox_sha1sum !== 'string' ||
50
+ !/^[0-9a-f]{40}$/i.test(a.mbox_sha1sum)
51
+ ) {
44
52
  return '.mbox_sha1sum must be a 40-character hex string';
45
53
  }
46
54
  }
@@ -81,10 +89,10 @@ export function validateAgent(actor: unknown): string | null {
81
89
  */
82
90
  export function validateAuthCredential(auth: string): string | null {
83
91
  if (typeof auth !== 'string' || !auth) {
84
- return 'auth must be a non-empty string';
92
+ return 'must be a non-empty string';
85
93
  }
86
94
  if (/^basic\s/i.test(auth)) {
87
- return "auth must be the Basic credential value only, not the full header. Drop the 'Basic ' prefix.";
95
+ return "must be the Basic credential value only, not the full header. Drop the 'Basic ' prefix.";
88
96
  }
89
97
  if (/^bearer\s/i.test(auth)) {
90
98
  return 'Bearer/OAuth credentials are not supported in v1. Use Basic auth, or wrap your token-exchange in an auth function that returns a Basic credential.';