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
@@ -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
 
@@ -296,67 +301,3 @@ export function findLMSAPI(propName: string): unknown {
296
301
  // Check window.parent chain (iframe launch pattern)
297
302
  return scan(window);
298
303
  }
299
-
300
- /**
301
- * Format integer seconds as SCORM 1.2 `CMITimespan` (HHHH:MM:SS.SS).
302
- *
303
- * `DurationTracker.sessionSeconds` always feeds integer seconds via
304
- * `Math.floor`, so the centisecond field is always `.00`. The format
305
- * still includes it because `CMITimespan` is defined that way and some
306
- * older LMS importers reject the bare HHHH:MM:SS form.
307
- */
308
- export function formatHHMMSS(totalSeconds: number): string {
309
- const whole = Math.floor(totalSeconds);
310
- const hours = Math.floor(whole / 3600);
311
- const minutes = Math.floor((whole % 3600) / 60);
312
- const seconds = whole % 60;
313
- const hh = String(hours).padStart(4, '0');
314
- const mm = String(minutes).padStart(2, '0');
315
- const ss = String(seconds).padStart(2, '0');
316
- return `${hh}:${mm}:${ss}.00`;
317
- }
318
-
319
- /**
320
- * SCORM 2004 4E §4.2/§4.3 define CMIDecimal-like elements as real(10,7) —
321
- * `String(1/3)` exceeds that and trips SCORM Cloud with error 406. Rounds,
322
- * then trims trailing zeros (no padded "0.8500000" forms).
323
- */
324
- export function formatReal107(value: number): string {
325
- if (!Number.isFinite(value)) return '0';
326
- const rounded = Math.round(value * 1e7) / 1e7;
327
- return rounded
328
- .toFixed(7)
329
- .replace(/(\.\d*?)0+$/, '$1')
330
- .replace(/\.$/, '');
331
- }
332
-
333
- /**
334
- * SCORM 2004 4E §3.3.10.1 references ISO 8601 §5.3.3 — local date+time, no
335
- * zone designator. Strict validators reject `Z`, `±hh:mm`, and fractional
336
- * seconds with error 406. UTC components are used so writes don't drift
337
- * across local-TZ flips even though the format is zone-free.
338
- */
339
- export function formatISO8601Timestamp(date: Date): string {
340
- const yyyy = date.getUTCFullYear();
341
- const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
342
- const dd = String(date.getUTCDate()).padStart(2, '0');
343
- const hh = String(date.getUTCHours()).padStart(2, '0');
344
- const mi = String(date.getUTCMinutes()).padStart(2, '0');
345
- const ss = String(date.getUTCSeconds()).padStart(2, '0');
346
- return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}`;
347
- }
348
-
349
- /**
350
- * Format seconds as ISO 8601 duration: PT1H30M45S
351
- */
352
- export function formatISO8601Duration(totalSeconds: number): string {
353
- const hours = Math.floor(totalSeconds / 3600);
354
- const minutes = Math.floor((totalSeconds % 3600) / 60);
355
- const seconds = totalSeconds % 60;
356
-
357
- let result = 'PT';
358
- if (hours > 0) result += `${hours}H`;
359
- if (minutes > 0) result += `${minutes}M`;
360
- if (seconds > 0 || result === 'PT') result += `${seconds}S`;
361
- return result;
362
- }
@@ -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
 
@@ -4,7 +4,7 @@ import {
4
4
  } from '../interaction-format.js';
5
5
  import type { SavedState } from '../persistence.js';
6
6
  import { BaseScormAdapter, type ScormDialect } from './scorm-base.js';
7
- import { formatHHMMSS, formatReal107 } from './retry.js';
7
+ import { formatHHMMSS, formatReal107 } from './format.js';
8
8
 
9
9
  /**
10
10
  * SCORM 1.2 API interface.
@@ -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
  }
@@ -5,7 +5,7 @@ import {
5
5
  formatISO8601Duration,
6
6
  formatISO8601Timestamp,
7
7
  formatReal107,
8
- } from './retry.js';
8
+ } from './format.js';
9
9
 
10
10
  export interface SCORM2004API {
11
11
  Initialize(param: string): string;
@@ -48,13 +48,11 @@ export type SCORM2004Mode = 'browse' | 'normal' | 'review';
48
48
  /**
49
49
  * Per §4.2.1.5, the SCO MUST NOT alter the learner record in `browse` or
50
50
  * `review` mode — every write below is gated on `#mode === 'normal'`.
51
- * `#masteryScore` (§4.2.4.3) and `#completionThreshold` (§4.2.4.4) are
52
- * LMS-supplied thresholds in [0,1].
51
+ * `#masteryScore` (§4.2.4.3) is the LMS-supplied pass threshold in [0,1].
53
52
  */
54
53
  export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
55
54
  #mode: SCORM2004Mode = 'normal';
56
55
  #masteryScore: number | null = null;
57
- #completionThreshold: number | null = null;
58
56
 
59
57
  constructor(api: SCORM2004API) {
60
58
  super(api, SCORM2004_DIALECT);
@@ -64,9 +62,6 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
64
62
  await super.init();
65
63
  this.#mode = this.#readMode();
66
64
  this.#masteryScore = this.#readScaledThreshold('cmi.scaled_passing_score');
67
- this.#completionThreshold = this.#readScaledThreshold(
68
- 'cmi.completion_threshold'
69
- );
70
65
  }
71
66
 
72
67
  getLaunchMode(): SCORM2004Mode {
@@ -78,8 +73,8 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
78
73
  return this.#masteryScore;
79
74
  }
80
75
 
81
- getCompletionThreshold(): number | null {
82
- return this.#completionThreshold;
76
+ get #canWrite(): boolean {
77
+ return this.#mode === 'normal';
83
78
  }
84
79
 
85
80
  #readMode(): SCORM2004Mode {
@@ -91,7 +86,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
91
86
  }
92
87
 
93
88
  #readScaledThreshold(key: string): number | null {
94
- let raw = '';
89
+ let raw: string;
95
90
  try {
96
91
  raw = this.api.GetValue(key);
97
92
  } catch {
@@ -104,83 +99,80 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
104
99
  }
105
100
 
106
101
  saveState(state: SavedState): void {
107
- if (this.#mode !== 'normal') return;
102
+ if (!this.#canWrite) return;
108
103
  super.saveState(state);
109
104
  // §4.2.1.4 — bookmark for LMS "Resume from page N" affordances.
110
105
  this.queue.enqueue(
111
106
  () => this.api.SetValue('cmi.location', String(state.b)),
112
- 'cmi.location'
107
+ 'cmi.location',
113
108
  );
114
109
  }
115
110
 
116
111
  setDuration(seconds: number): void {
117
- if (this.#mode !== 'normal') return;
112
+ if (!this.#canWrite) return;
118
113
  super.setDuration(seconds);
119
114
  }
120
115
 
121
116
  reportInteraction(
122
117
  questionId: string,
123
118
  interaction: import('../interaction.js').Interaction,
124
- correct: boolean | null
119
+ correct: boolean | null,
125
120
  ): void {
126
- if (this.#mode !== 'normal') return;
121
+ if (!this.#canWrite) return;
127
122
  super.reportInteraction(questionId, interaction, correct);
128
123
  }
129
124
 
130
125
  setScore(score: number): void {
131
- if (this.#mode !== 'normal') return;
126
+ if (!this.#canWrite) return;
132
127
  const raw = formatReal107(score);
133
128
  // §4.2.4.3.5 — score.scaled is bounded to [-1, 1].
134
129
  const scaled = formatReal107(Math.max(0, Math.min(1, score / 100)));
135
130
  this.queue.enqueue(
136
131
  () => this.api.SetValue('cmi.score.raw', raw),
137
- 'cmi.score.raw'
132
+ 'cmi.score.raw',
138
133
  );
139
134
  this.queue.enqueue(
140
135
  () => this.api.SetValue('cmi.score.min', '0'),
141
- 'cmi.score.min'
136
+ 'cmi.score.min',
142
137
  );
143
138
  this.queue.enqueue(
144
139
  () => this.api.SetValue('cmi.score.max', '100'),
145
- 'cmi.score.max'
140
+ 'cmi.score.max',
146
141
  );
147
142
  this.queue.enqueue(
148
143
  () => this.api.SetValue('cmi.score.scaled', scaled),
149
- 'cmi.score.scaled'
144
+ 'cmi.score.scaled',
150
145
  );
151
146
  }
152
147
 
153
148
  setCompletionStatus(status: 'incomplete' | 'complete'): void {
154
- if (this.#mode !== 'normal') return;
149
+ if (!this.#canWrite) return;
155
150
  const value = status === 'complete' ? 'completed' : 'incomplete';
156
151
  this.queue.enqueue(
157
152
  () => this.api.SetValue('cmi.completion_status', value),
158
- 'cmi.completion_status'
153
+ 'cmi.completion_status',
159
154
  );
160
155
  // §4.2.4.2 — writing 1.0 surfaces a "100%" reading on LMS dashboards.
161
156
  if (status === 'complete') {
162
157
  this.queue.enqueue(
163
158
  () => this.api.SetValue('cmi.progress_measure', '1'),
164
- 'cmi.progress_measure'
159
+ 'cmi.progress_measure',
165
160
  );
166
161
  }
167
162
  }
168
163
 
169
164
  setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
170
- if (this.#mode !== 'normal') return;
165
+ if (!this.#canWrite) return;
171
166
  // Setting "unknown" explicitly prevents SCORM Cloud from rolling up
172
167
  // a null status to "passed".
173
168
  this.queue.enqueue(
174
169
  () => this.api.SetValue('cmi.success_status', status),
175
- 'cmi.success_status'
170
+ 'cmi.success_status',
176
171
  );
177
172
  }
178
173
 
179
174
  setExit(mode: 'suspend' | 'normal'): void {
180
- if (this.#mode !== 'normal') return;
181
- this.queue.enqueue(
182
- () => this.api.SetValue('cmi.exit', mode),
183
- 'cmi.exit'
184
- );
175
+ if (!this.#canWrite) return;
176
+ this.queue.enqueue(() => this.api.SetValue('cmi.exit', mode), 'cmi.exit');
185
177
  }
186
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