tessera-learn 0.0.11 → 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 (75) 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 +2 -1
  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 +85 -10
  12. package/dist/plugin/index.js.map +1 -1
  13. package/dist/{validation-D9DXlqNP.js → validation-B-xTvM9B.js} +342 -18
  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 +17 -3
  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 +16 -6
  23. package/src/components/Image.svelte +12 -3
  24. package/src/components/LockedBanner.svelte +2 -1
  25. package/src/components/Matching.svelte +48 -19
  26. package/src/components/MediaTracks.svelte +21 -0
  27. package/src/components/MultipleChoice.svelte +33 -13
  28. package/src/components/Quiz.svelte +61 -20
  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 +21 -18
  34. package/src/components/util.ts +3 -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 +4 -1
  41. package/src/plugin/export.ts +42 -14
  42. package/src/plugin/index.ts +216 -44
  43. package/src/plugin/manifest.ts +62 -22
  44. package/src/plugin/validation.ts +736 -122
  45. package/src/runtime/App.svelte +119 -48
  46. package/src/runtime/LoadingBar.svelte +12 -3
  47. package/src/runtime/Sidebar.svelte +24 -8
  48. package/src/runtime/access.ts +15 -3
  49. package/src/runtime/adapters/cmi5.ts +55 -33
  50. package/src/runtime/adapters/index.ts +22 -10
  51. package/src/runtime/adapters/retry.ts +25 -20
  52. package/src/runtime/adapters/scorm-base.ts +19 -15
  53. package/src/runtime/adapters/scorm12.ts +7 -8
  54. package/src/runtime/adapters/scorm2004.ts +11 -14
  55. package/src/runtime/adapters/web.ts +1 -1
  56. package/src/runtime/hooks.svelte.ts +152 -326
  57. package/src/runtime/interaction-format.ts +30 -12
  58. package/src/runtime/interaction.ts +44 -11
  59. package/src/runtime/navigation.svelte.ts +27 -11
  60. package/src/runtime/persistence.ts +2 -2
  61. package/src/runtime/progress.svelte.ts +13 -9
  62. package/src/runtime/quiz-engine.svelte.ts +361 -0
  63. package/src/runtime/quiz-policy.ts +9 -3
  64. package/src/runtime/types.ts +24 -2
  65. package/src/runtime/xapi/agent-rules.ts +4 -1
  66. package/src/runtime/xapi/client.ts +5 -5
  67. package/src/runtime/xapi/derive-actor.ts +2 -2
  68. package/src/runtime/xapi/publisher.ts +32 -29
  69. package/src/runtime/xapi/setup.ts +18 -15
  70. package/src/runtime/xapi/validation.ts +15 -6
  71. package/src/virtual.d.ts +4 -1
  72. package/styles/base.css +32 -11
  73. package/styles/layout.css +39 -18
  74. package/styles/theme.css +15 -3
  75. package/dist/validation-D9DXlqNP.js.map +0 -1
@@ -20,7 +20,7 @@ export class LMSAdapterError extends Error {
20
20
  }
21
21
 
22
22
  function missingApiError(
23
- standard: 'scorm12' | 'scorm2004' | 'cmi5'
23
+ standard: 'scorm12' | 'scorm2004' | 'cmi5',
24
24
  ): LMSAdapterError {
25
25
  const label =
26
26
  standard === 'scorm12'
@@ -36,7 +36,7 @@ function missingApiError(
36
36
  standard,
37
37
  `Tessera: this course is configured for ${label} but ${detail} ` +
38
38
  `The course must be launched from an LMS that provides the ${label} runtime. ` +
39
- `If you are testing locally, run \`npm run dev\` instead, or set export.standard to "web".`
39
+ `If you are testing locally, run \`npm run dev\` instead, or set export.standard to "web".`,
40
40
  );
41
41
  }
42
42
 
@@ -63,13 +63,22 @@ export interface CreateAdapterOptions {
63
63
  type LMSStandard = 'scorm12' | 'scorm2004' | 'cmi5';
64
64
 
65
65
  /** Per-standard LMS detection. `detect` returns an adapter when the LMS runtime is reachable, else null. */
66
- const LMS_ADAPTERS: Record<LMSStandard, { detect: () => PersistenceAdapter | null; label: string }> = {
66
+ const LMS_ADAPTERS: Record<
67
+ LMSStandard,
68
+ { detect: () => PersistenceAdapter | null; label: string }
69
+ > = {
67
70
  scorm12: {
68
- detect: () => { const api = findSCORM12API(); return api ? new SCORM12Adapter(api) : null; },
71
+ detect: () => {
72
+ const api = findSCORM12API();
73
+ return api ? new SCORM12Adapter(api) : null;
74
+ },
69
75
  label: 'SCORM 1.2 API',
70
76
  },
71
77
  scorm2004: {
72
- detect: () => { const api = findSCORM2004API(); return api ? new SCORM2004Adapter(api) : null; },
78
+ detect: () => {
79
+ const api = findSCORM2004API();
80
+ return api ? new SCORM2004Adapter(api) : null;
81
+ },
73
82
  label: 'SCORM 2004 API',
74
83
  },
75
84
  cmi5: {
@@ -80,18 +89,21 @@ const LMS_ADAPTERS: Record<LMSStandard, { detect: () => PersistenceAdapter | nul
80
89
 
81
90
  export function createAdapter(
82
91
  config: CourseConfig,
83
- options: CreateAdapterOptions = {}
92
+ options: CreateAdapterOptions = {},
84
93
  ): PersistenceAdapter {
85
- const allowFallback =
86
- options.allowFallback ?? import.meta.env?.DEV === true;
94
+ const allowFallback = options.allowFallback ?? import.meta.env?.DEV === true;
87
95
  const standard = config.export?.standard;
88
- if (standard === 'scorm12' || standard === 'scorm2004' || standard === 'cmi5') {
96
+ if (
97
+ standard === 'scorm12' ||
98
+ standard === 'scorm2004' ||
99
+ standard === 'cmi5'
100
+ ) {
89
101
  const entry = LMS_ADAPTERS[standard];
90
102
  const adapter = entry.detect();
91
103
  if (adapter) return adapter;
92
104
  if (!allowFallback) throw missingApiError(standard);
93
105
  console.warn(
94
- `Tessera (dev): ${entry.label} not found — falling back to localStorage`
106
+ `Tessera (dev): ${entry.label} not found — falling back to localStorage`,
95
107
  );
96
108
  }
97
109
  return new WebAdapter(config);
@@ -24,29 +24,37 @@ function lmsCallSucceeded(result: unknown): boolean {
24
24
 
25
25
  function readLastErrorCode(reporter: LMSErrorReporter | undefined): string {
26
26
  if (!reporter) return '';
27
- try { return reporter.code(); } catch { return ''; }
27
+ try {
28
+ return reporter.code();
29
+ } catch {
30
+ return '';
31
+ }
28
32
  }
29
33
 
30
34
  function logRetryGiveUp(
31
35
  errorReporter: LMSErrorReporter | undefined,
32
36
  lastErrCode: string,
33
- context: string | undefined
37
+ context: string | undefined,
34
38
  ): void {
35
39
  const ctx = context ? ` [${context}]` : '';
36
40
  console.warn(
37
- `Tessera: LMS call failed after retries${ctx}${formatLMSErrorDetail(errorReporter, lastErrCode)}, continuing without persistence`
41
+ `Tessera: LMS call failed after retries${ctx}${formatLMSErrorDetail(errorReporter, lastErrCode)}, continuing without persistence`,
38
42
  );
39
43
  }
40
44
 
41
45
  export function formatLMSErrorDetail(
42
46
  errorReporter: LMSErrorReporter | undefined,
43
- code: string
47
+ code: string,
44
48
  ): string {
45
49
  if (!errorReporter || !code || code === '0') return '';
46
50
  let msg = '';
47
51
  let diag = '';
48
- try { msg = errorReporter.message(code); } catch {}
49
- try { diag = errorReporter.diagnostic?.(code) ?? ''; } catch {}
52
+ try {
53
+ msg = errorReporter.message(code);
54
+ } catch {}
55
+ try {
56
+ diag = errorReporter.diagnostic?.(code) ?? '';
57
+ } catch {}
50
58
  let detail = ` (LMS error ${code}`;
51
59
  if (msg) detail += `: ${msg}`;
52
60
  if (diag && diag !== msg) detail += ` — ${diag}`;
@@ -56,24 +64,21 @@ export function formatLMSErrorDetail(
56
64
 
57
65
  /** Sync call that warns with the LMS error code on failure (terminate-path). */
58
66
  export function callSyncOrWarn(
59
- fn: () => any,
67
+ fn: () => unknown,
60
68
  context: string,
61
- errorReporter?: LMSErrorReporter
69
+ errorReporter?: LMSErrorReporter,
62
70
  ): boolean {
63
- let ok = false;
71
+ let ok: boolean;
64
72
  try {
65
73
  ok = lmsCallSucceeded(fn());
66
74
  } catch (err) {
67
- console.warn(
68
- `Tessera: LMS call threw [${context}] during terminate`,
69
- err
70
- );
75
+ console.warn(`Tessera: LMS call threw [${context}] during terminate`, err);
71
76
  return false;
72
77
  }
73
78
  if (!ok) {
74
79
  const code = readLastErrorCode(errorReporter);
75
80
  console.warn(
76
- `Tessera: LMS call failed [${context}] during terminate${formatLMSErrorDetail(errorReporter, code)}`
81
+ `Tessera: LMS call failed [${context}] during terminate${formatLMSErrorDetail(errorReporter, code)}`,
77
82
  );
78
83
  }
79
84
  return ok;
@@ -95,10 +100,10 @@ export function callSyncOrWarn(
95
100
  * for SCORM adapters where the underlying API calls are synchronous.
96
101
  */
97
102
  export async function withRetry(
98
- fn: () => any,
103
+ fn: () => unknown,
99
104
  maxRetries = RETRY_ATTEMPTS,
100
105
  errorReporter?: LMSErrorReporter,
101
- context?: string
106
+ context?: string,
102
107
  ): Promise<boolean> {
103
108
  let lastErrCode = '';
104
109
  let threw = false;
@@ -128,7 +133,7 @@ export async function withRetry(
128
133
  * Synchronous single-attempt LMS call. Used during page unload
129
134
  * where async retries cannot run.
130
135
  */
131
- export function callSync(fn: () => any): boolean {
136
+ export function callSync(fn: () => unknown): boolean {
132
137
  try {
133
138
  return lmsCallSucceeded(fn());
134
139
  } catch {
@@ -143,7 +148,7 @@ export function callSync(fn: () => any): boolean {
143
148
  * the failed operation on the next flush trigger.
144
149
  */
145
150
  interface QueueEntry {
146
- fn: () => any;
151
+ fn: () => unknown;
147
152
  context?: string;
148
153
  }
149
154
 
@@ -164,10 +169,10 @@ export class WriteQueue {
164
169
  /**
165
170
  * Enqueue an operation and trigger a flush.
166
171
  */
167
- enqueue(fn: () => any, context?: string): void {
172
+ enqueue(fn: () => unknown, context?: string): void {
168
173
  this.#queue.push({ fn, context });
169
174
  if (!this.#flushing) {
170
- this.#flush();
175
+ void this.#flush();
171
176
  }
172
177
  }
173
178
 
@@ -71,11 +71,11 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
71
71
  () => this.dialect.initialize(this.api),
72
72
  undefined,
73
73
  this.errorReporter,
74
- 'Initialize'
74
+ 'Initialize',
75
75
  );
76
76
  if (!initialized) {
77
77
  console.warn(
78
- 'Tessera: LMS Initialize failed — all subsequent persistence calls will fail with error 301 (Not Initialized). Reload the launch from the LMS.'
78
+ 'Tessera: LMS Initialize failed — all subsequent persistence calls will fail with error 301 (Not Initialized). Reload the launch from the LMS.',
79
79
  );
80
80
  return;
81
81
  }
@@ -86,7 +86,7 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
86
86
  } catch (err) {
87
87
  console.warn(
88
88
  'Tessera: LMS threw on GetValue(cmi.suspend_data); resume disabled for this launch',
89
- err
89
+ err,
90
90
  );
91
91
  }
92
92
  if (raw && raw.trim()) {
@@ -95,7 +95,7 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
95
95
  } catch (err) {
96
96
  console.warn(
97
97
  'Tessera: cmi.suspend_data is not valid JSON; resume disabled for this launch (the LMS may have truncated a prior write)',
98
- err
98
+ err,
99
99
  );
100
100
  this.#state = null;
101
101
  }
@@ -103,13 +103,13 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
103
103
 
104
104
  // n indexing must continue from _count — restarting at 0 would overwrite
105
105
  // the prior session's records (the LMS uses n as the array key).
106
- let countRaw = '';
106
+ let countRaw: string;
107
107
  try {
108
108
  countRaw = this.dialect.getValue(this.api, 'cmi.interactions._count');
109
109
  } catch (err) {
110
110
  console.warn(
111
111
  'Tessera: LMS threw on GetValue(cmi.interactions._count); new interactions will be written from index 0 and may overwrite prior session records',
112
- err
112
+ err,
113
113
  );
114
114
  return;
115
115
  }
@@ -119,7 +119,7 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
119
119
  this.interactionCount = n;
120
120
  } else {
121
121
  console.warn(
122
- `Tessera: LMS returned non-numeric cmi.interactions._count="${countRaw}"; new interactions will be written from index 0 and may overwrite prior session records`
122
+ `Tessera: LMS returned non-numeric cmi.interactions._count="${countRaw}"; new interactions will be written from index 0 and may overwrite prior session records`,
123
123
  );
124
124
  }
125
125
  }
@@ -141,12 +141,12 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
141
141
  `${this.dialect.suspendDataLimitLabel} limit. The LMS will likely ` +
142
142
  `truncate it and the next resume will lose state. Reduce ` +
143
143
  `usePersistence() payloads or switch export.standard to a ` +
144
- `larger-limit standard (scorm2004/cmi5).`
144
+ `larger-limit standard (scorm2004/cmi5).`,
145
145
  );
146
146
  }
147
147
  this.queue.enqueue(
148
148
  () => this.dialect.setValue(this.api, 'cmi.suspend_data', json),
149
- 'cmi.suspend_data'
149
+ 'cmi.suspend_data',
150
150
  );
151
151
  }
152
152
 
@@ -155,14 +155,14 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
155
155
  this.queue.enqueue(
156
156
  () =>
157
157
  this.dialect.setValue(this.api, this.dialect.sessionTimeKey, formatted),
158
- this.dialect.sessionTimeKey
158
+ this.dialect.sessionTimeKey,
159
159
  );
160
160
  }
161
161
 
162
162
  reportInteraction(
163
163
  questionId: string,
164
164
  interaction: Interaction,
165
- correct: boolean | null
165
+ correct: boolean | null,
166
166
  ): void {
167
167
  const n = this.interactionCount++;
168
168
  const fields = buildScormInteractionFields(
@@ -177,12 +177,12 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
177
177
  typeValue: this.dialect.interactionFields.typeValue(interaction.type),
178
178
  resultLabels: this.dialect.interactionFields.resultLabels,
179
179
  format: this.dialect.interactionFields.format,
180
- }
180
+ },
181
181
  );
182
182
  for (const [key, value] of fields) {
183
183
  this.queue.enqueue(
184
184
  () => this.dialect.setValue(this.api, key, value),
185
- key
185
+ key,
186
186
  );
187
187
  }
188
188
  }
@@ -196,11 +196,15 @@ export abstract class BaseScormAdapter<TApi> implements PersistenceAdapter {
196
196
  this.#terminated = true;
197
197
  // Async retries can't run during page unload — drain + commit + finish synchronously.
198
198
  this.queue.drainSync();
199
- callSyncOrWarn(() => this.dialect.commit(this.api), 'Commit', this.errorReporter);
199
+ callSyncOrWarn(
200
+ () => this.dialect.commit(this.api),
201
+ 'Commit',
202
+ this.errorReporter,
203
+ );
200
204
  callSyncOrWarn(
201
205
  () => this.dialect.terminate(this.api),
202
206
  'Terminate',
203
- this.errorReporter
207
+ this.errorReporter,
204
208
  );
205
209
  }
206
210
 
@@ -67,24 +67,23 @@ export class SCORM12Adapter extends BaseScormAdapter<SCORM12API> {
67
67
  super.saveState(state);
68
68
  // §3.4.5.3 — bookmark for LMS "Resume from page N" affordances.
69
69
  this.queue.enqueue(
70
- () =>
71
- this.api.LMSSetValue('cmi.core.lesson_location', String(state.b)),
72
- 'cmi.core.lesson_location'
70
+ () => this.api.LMSSetValue('cmi.core.lesson_location', String(state.b)),
71
+ 'cmi.core.lesson_location',
73
72
  );
74
73
  }
75
74
 
76
75
  setScore(score: number): void {
77
76
  this.queue.enqueue(
78
77
  () => this.api.LMSSetValue('cmi.core.score.raw', formatReal107(score)),
79
- 'cmi.core.score.raw'
78
+ 'cmi.core.score.raw',
80
79
  );
81
80
  this.queue.enqueue(
82
81
  () => this.api.LMSSetValue('cmi.core.score.min', '0'),
83
- 'cmi.core.score.min'
82
+ 'cmi.core.score.min',
84
83
  );
85
84
  this.queue.enqueue(
86
85
  () => this.api.LMSSetValue('cmi.core.score.max', '100'),
87
- 'cmi.core.score.max'
86
+ 'cmi.core.score.max',
88
87
  );
89
88
  }
90
89
 
@@ -104,7 +103,7 @@ export class SCORM12Adapter extends BaseScormAdapter<SCORM12API> {
104
103
  const value = this.#successStatus ?? this.#completionStatus;
105
104
  this.queue.enqueue(
106
105
  () => this.api.LMSSetValue('cmi.core.lesson_status', value),
107
- 'cmi.core.lesson_status'
106
+ 'cmi.core.lesson_status',
108
107
  );
109
108
  }
110
109
 
@@ -113,7 +112,7 @@ export class SCORM12Adapter extends BaseScormAdapter<SCORM12API> {
113
112
  const value = mode === 'suspend' ? 'suspend' : '';
114
113
  this.queue.enqueue(
115
114
  () => this.api.LMSSetValue('cmi.core.exit', value),
116
- 'cmi.core.exit'
115
+ 'cmi.core.exit',
117
116
  );
118
117
  }
119
118
  }
@@ -86,7 +86,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
86
86
  }
87
87
 
88
88
  #readScaledThreshold(key: string): number | null {
89
- let raw = '';
89
+ let raw: string;
90
90
  try {
91
91
  raw = this.api.GetValue(key);
92
92
  } catch {
@@ -104,7 +104,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
104
104
  // §4.2.1.4 — bookmark for LMS "Resume from page N" affordances.
105
105
  this.queue.enqueue(
106
106
  () => this.api.SetValue('cmi.location', String(state.b)),
107
- 'cmi.location'
107
+ 'cmi.location',
108
108
  );
109
109
  }
110
110
 
@@ -116,7 +116,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
116
116
  reportInteraction(
117
117
  questionId: string,
118
118
  interaction: import('../interaction.js').Interaction,
119
- correct: boolean | null
119
+ correct: boolean | null,
120
120
  ): void {
121
121
  if (!this.#canWrite) return;
122
122
  super.reportInteraction(questionId, interaction, correct);
@@ -129,19 +129,19 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
129
129
  const scaled = formatReal107(Math.max(0, Math.min(1, score / 100)));
130
130
  this.queue.enqueue(
131
131
  () => this.api.SetValue('cmi.score.raw', raw),
132
- 'cmi.score.raw'
132
+ 'cmi.score.raw',
133
133
  );
134
134
  this.queue.enqueue(
135
135
  () => this.api.SetValue('cmi.score.min', '0'),
136
- 'cmi.score.min'
136
+ 'cmi.score.min',
137
137
  );
138
138
  this.queue.enqueue(
139
139
  () => this.api.SetValue('cmi.score.max', '100'),
140
- 'cmi.score.max'
140
+ 'cmi.score.max',
141
141
  );
142
142
  this.queue.enqueue(
143
143
  () => this.api.SetValue('cmi.score.scaled', scaled),
144
- 'cmi.score.scaled'
144
+ 'cmi.score.scaled',
145
145
  );
146
146
  }
147
147
 
@@ -150,13 +150,13 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
150
150
  const value = status === 'complete' ? 'completed' : 'incomplete';
151
151
  this.queue.enqueue(
152
152
  () => this.api.SetValue('cmi.completion_status', value),
153
- 'cmi.completion_status'
153
+ 'cmi.completion_status',
154
154
  );
155
155
  // §4.2.4.2 — writing 1.0 surfaces a "100%" reading on LMS dashboards.
156
156
  if (status === 'complete') {
157
157
  this.queue.enqueue(
158
158
  () => this.api.SetValue('cmi.progress_measure', '1'),
159
- 'cmi.progress_measure'
159
+ 'cmi.progress_measure',
160
160
  );
161
161
  }
162
162
  }
@@ -167,15 +167,12 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
167
167
  // a null status to "passed".
168
168
  this.queue.enqueue(
169
169
  () => this.api.SetValue('cmi.success_status', status),
170
- 'cmi.success_status'
170
+ 'cmi.success_status',
171
171
  );
172
172
  }
173
173
 
174
174
  setExit(mode: 'suspend' | 'normal'): void {
175
175
  if (!this.#canWrite) return;
176
- this.queue.enqueue(
177
- () => this.api.SetValue('cmi.exit', mode),
178
- 'cmi.exit'
179
- );
176
+ this.queue.enqueue(() => this.api.SetValue('cmi.exit', mode), 'cmi.exit');
180
177
  }
181
178
  }
@@ -51,7 +51,7 @@ export class WebAdapter implements PersistenceAdapter {
51
51
  reportInteraction(
52
52
  _questionId: string,
53
53
  _interaction: Interaction,
54
- _correct: boolean | null
54
+ _correct: boolean | null,
55
55
  ): void {
56
56
  // Web adapter has no external LMS; learner interaction data lives only
57
57
  // in memory. Authors who want to persist per-question state can use