tessera-learn 0.0.9 → 0.0.11

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 (45) hide show
  1. package/dist/plugin/cli.js +5 -3
  2. package/dist/plugin/cli.js.map +1 -1
  3. package/dist/plugin/index.d.ts.map +1 -1
  4. package/dist/plugin/index.js +215 -124
  5. package/dist/plugin/index.js.map +1 -1
  6. package/dist/{validation-BxWAMMnJ.js → validation-D9DXlqNP.js} +77 -65
  7. package/dist/validation-D9DXlqNP.js.map +1 -0
  8. package/package.json +9 -6
  9. package/src/components/Audio.svelte +5 -2
  10. package/src/components/DefaultLayout.svelte +2 -0
  11. package/src/components/FillInTheBlank.svelte +60 -98
  12. package/src/components/Image.svelte +3 -8
  13. package/src/components/LockedBanner.svelte +3 -4
  14. package/src/components/MultipleChoice.svelte +53 -94
  15. package/src/components/Quiz.svelte +2 -1
  16. package/src/components/Video.svelte +4 -2
  17. package/src/components/util.ts +1 -0
  18. package/src/plugin/cli.ts +2 -7
  19. package/src/plugin/export.ts +23 -41
  20. package/src/plugin/index.ts +197 -56
  21. package/src/plugin/layout.ts +6 -51
  22. package/src/plugin/manifest.ts +31 -5
  23. package/src/plugin/override-plugin.ts +68 -0
  24. package/src/plugin/quiz.ts +9 -54
  25. package/src/plugin/validation.ts +38 -67
  26. package/src/runtime/App.svelte +48 -36
  27. package/src/runtime/LoadingBar.svelte +47 -0
  28. package/src/runtime/Sidebar.svelte +2 -0
  29. package/src/runtime/adapters/cmi5.ts +13 -83
  30. package/src/runtime/adapters/format.ts +67 -0
  31. package/src/runtime/adapters/index.ts +28 -29
  32. package/src/runtime/adapters/retry.ts +0 -64
  33. package/src/runtime/adapters/scorm12.ts +1 -1
  34. package/src/runtime/adapters/scorm2004.ts +11 -16
  35. package/src/runtime/hooks.svelte.ts +14 -16
  36. package/src/runtime/navigation.svelte.ts +51 -45
  37. package/src/runtime/progress.svelte.ts +25 -10
  38. package/src/runtime/quiz-policy.ts +21 -178
  39. package/src/runtime/xapi/agent-rules.ts +7 -2
  40. package/src/runtime/xapi/publisher.ts +1 -11
  41. package/src/runtime/xapi/validation.ts +1 -1
  42. package/src/virtual.d.ts +13 -0
  43. package/styles/layout.css +34 -24
  44. package/dist/validation-BxWAMMnJ.js.map +0 -1
  45. package/src/runtime/LoadingSkeleton.svelte +0 -26
@@ -5,7 +5,7 @@ import {
5
5
  formatCorrectPattern,
6
6
  XAPI_INTERACTION_FORMAT,
7
7
  } from '../interaction-format.js';
8
- import { formatISO8601Duration } from './retry.js';
8
+ import { formatISO8601Duration } from './format.js';
9
9
  import { XAPIPublisher } from '../xapi/publisher.js';
10
10
  import { X_API_VERSION } from '../xapi/version.js';
11
11
  import type { XAPIAgent } from '../xapi/types.js';
@@ -41,21 +41,6 @@ const CMI5_CATEGORY_CMI5 =
41
41
  const CMI5_CATEGORY_MOVEON =
42
42
  'https://w3id.org/xapi/cmi5/context/categories/moveon';
43
43
 
44
- export type CMI5MoveOn =
45
- | 'Passed'
46
- | 'Completed'
47
- | 'CompletedAndPassed'
48
- | 'CompletedOrPassed'
49
- | 'NotApplicable';
50
-
51
- const VALID_MOVE_ON: ReadonlySet<CMI5MoveOn> = new Set([
52
- 'Passed',
53
- 'Completed',
54
- 'CompletedAndPassed',
55
- 'CompletedOrPassed',
56
- 'NotApplicable',
57
- ]);
58
-
59
44
  /** cmi5 §10.2.2 — launch mode dictates which Defined Statements the AU may emit. */
60
45
  export type CMI5LaunchMode = 'Normal' | 'Browse' | 'Review';
61
46
  const VALID_LAUNCH_MODE: ReadonlySet<CMI5LaunchMode> = new Set([
@@ -87,21 +72,12 @@ interface CMI5LaunchData {
87
72
  };
88
73
  launchMode?: CMI5LaunchMode;
89
74
  launchMethod?: 'OwnWindow' | 'AnyWindow';
90
- launchParameters?: string;
91
75
  returnURL?: string;
92
76
  masteryScore?: number;
93
- moveOn?: CMI5MoveOn;
94
77
  entitlementKey?: Record<string, string>;
95
78
  [k: string]: unknown;
96
79
  }
97
80
 
98
- /** cmi5 §11.1 Learner Preferences Agent Profile document. */
99
- interface CMI5LearnerPreferences {
100
- languagePreference?: string;
101
- audioPreference?: 'on' | 'off';
102
- [k: string]: unknown;
103
- }
104
-
105
81
  /** `.then` handler that warns on LRS non-2xx. Publisher resolves successfully on 4xx/5xx (failure is in the outcome), so `.catch` alone misses them. */
106
82
  function warnOnLRSReject(
107
83
  verbName: string
@@ -148,10 +124,8 @@ export class CMI5Adapter implements PersistenceAdapter {
148
124
  #terminated = false;
149
125
 
150
126
  // cmi5 §8 launch params. masteryScore (when present) overrides the
151
- // course's manifest passingScore for this launch — the LMS is the
152
- // authority. moveOn drives the optional Satisfied statement (§9.5.3).
127
+ // course's manifest passingScore for this launch — the LMS is the authority.
153
128
  #masteryScore: number | null = null;
154
- #moveOn: CMI5MoveOn = 'NotApplicable';
155
129
 
156
130
  // cmi5 §10 LMS.LaunchData. `contextTemplate` is the AU's base context
157
131
  // (§9.6.2) — Publisher Activity and session id live there, and strict
@@ -161,10 +135,6 @@ export class CMI5Adapter implements PersistenceAdapter {
161
135
  #launchMode: CMI5LaunchMode = 'Normal';
162
136
  /** cmi5 §10.2.6 — AU redirects here on `exit()`. */
163
137
  #returnURL: string | undefined;
164
- /** cmi5 §10.2.3 — opaque per-launch content config string. */
165
- #launchParameters: string | undefined;
166
- /** cmi5 §11.1 Learner Preferences. */
167
- #learnerPreferences: CMI5LearnerPreferences | null = null;
168
138
 
169
139
  async init(): Promise<void> {
170
140
  const params = new URLSearchParams(window.location.search);
@@ -189,17 +159,6 @@ export class CMI5Adapter implements PersistenceAdapter {
189
159
  }
190
160
  }
191
161
 
192
- const rawMoveOn = params.get('moveOn');
193
- if (rawMoveOn !== null && rawMoveOn !== '') {
194
- if (VALID_MOVE_ON.has(rawMoveOn as CMI5MoveOn)) {
195
- this.#moveOn = rawMoveOn as CMI5MoveOn;
196
- } else {
197
- console.warn(
198
- `Tessera cmi5: launch parameter 'moveOn' is not a recognized value (got "${rawMoveOn}"); defaulting to NotApplicable.`
199
- );
200
- }
201
- }
202
-
203
162
  // Malformed actor JSON is a launch-time failure: an empty {} actor
204
163
  // would fail every Identified-Agent check downstream and produce
205
164
  // confusing 400s on every send. Fail loud here instead.
@@ -280,9 +239,9 @@ export class CMI5Adapter implements PersistenceAdapter {
280
239
 
281
240
  // cmi5 §10 — LaunchData is the only spec-defined channel for the
282
241
  // session id (§9.6.3.1) and Publisher Activity (§9.6.2.3) the LRS
283
- // validates against, plus launchMode/returnURL/launchParameters/
284
- // masteryScore/moveOn (§10.2). LaunchData values override the URL
285
- // masteryScore parsed earlier (§10.2.4 makes it authoritative).
242
+ // validates against, plus launchMode/returnURL/masteryScore (§10.2).
243
+ // LaunchData values override the URL masteryScore parsed earlier
244
+ // (§10.2.4 makes it authoritative).
286
245
  this.#launchData = await this.#fetchLaunchData();
287
246
  const tmpl = this.#launchData?.contextTemplate ?? {};
288
247
  let sessionId: string | undefined;
@@ -303,9 +262,6 @@ export class CMI5Adapter implements PersistenceAdapter {
303
262
  ) {
304
263
  this.#returnURL = this.#launchData.returnURL;
305
264
  }
306
- if (typeof this.#launchData.launchParameters === 'string') {
307
- this.#launchParameters = this.#launchData.launchParameters;
308
- }
309
265
  if (
310
266
  typeof this.#launchData.masteryScore === 'number' &&
311
267
  Number.isFinite(this.#launchData.masteryScore) &&
@@ -314,18 +270,12 @@ export class CMI5Adapter implements PersistenceAdapter {
314
270
  ) {
315
271
  this.#masteryScore = this.#launchData.masteryScore;
316
272
  }
317
- if (
318
- typeof this.#launchData.moveOn === 'string' &&
319
- VALID_MOVE_ON.has(this.#launchData.moveOn)
320
- ) {
321
- this.#moveOn = this.#launchData.moveOn;
322
- }
323
273
  }
324
274
 
325
275
  // cmi5 §11 — fetch the Agent Profile BEFORE Initialized. Strict
326
276
  // LRSes track the GET and reject Initialized otherwise. A 404 here
327
277
  // is legitimate (no prefs set); the GET itself is what's required.
328
- this.#learnerPreferences = await this.#fetchLearnerPreferences();
278
+ await this.#fetchLearnerPreferences();
329
279
 
330
280
  this.#publisher = new XAPIPublisher({
331
281
  endpoint: this.#endpoint,
@@ -391,31 +341,11 @@ export class CMI5Adapter implements PersistenceAdapter {
391
341
  return this.#masteryScore;
392
342
  }
393
343
 
394
- /** LMS-supplied moveOn criterion (defaults to "NotApplicable"). */
395
- getMoveOn(): CMI5MoveOn {
396
- return this.#moveOn;
397
- }
398
-
399
344
  /** cmi5 §10.2.2 — "Normal" is the only mode where progress-bearing Defined Statements are permitted. */
400
345
  getLaunchMode(): CMI5LaunchMode {
401
346
  return this.#launchMode;
402
347
  }
403
348
 
404
- /** cmi5 §10.2.6 — URL the AU navigates to on `exit()`. Returns undefined when the LMS didn't supply one. */
405
- getReturnURL(): string | undefined {
406
- return this.#returnURL;
407
- }
408
-
409
- /** cmi5 §10.2.3 — opaque per-launch content-config string. */
410
- getLaunchParameters(): string | undefined {
411
- return this.#launchParameters;
412
- }
413
-
414
- /** cmi5 §11.1 Learner Preferences. Null when the LMS didn't publish one. */
415
- getLearnerPreferences(): CMI5LearnerPreferences | null {
416
- return this.#learnerPreferences;
417
- }
418
-
419
349
  getState(): SavedState | null {
420
350
  return this.#state;
421
351
  }
@@ -725,15 +655,16 @@ export class CMI5Adapter implements PersistenceAdapter {
725
655
  return null;
726
656
  }
727
657
 
728
- /** GET cmi5 §11.1 Learner Preferences. 404 is normal (no prefs set). */
729
- async #fetchLearnerPreferences(): Promise<CMI5LearnerPreferences | null> {
658
+ /**
659
+ * GET cmi5 §11.1 Learner Preferences. The GET itself is the §11
660
+ * obligation (it must precede Initialized); the response body is not
661
+ * consumed. 404 is normal (no prefs set).
662
+ */
663
+ async #fetchLearnerPreferences(): Promise<void> {
730
664
  try {
731
665
  const url = this.#buildAgentProfileUrl(CMI5_LEARNER_PREFS_PROFILE_ID);
732
666
  const resp = await this.#xapiFetch(url, { method: 'GET' });
733
- if (resp.ok) {
734
- return (await resp.json()) as CMI5LearnerPreferences;
735
- }
736
- if (resp.status !== 404) {
667
+ if (!resp.ok && resp.status !== 404) {
737
668
  console.warn(
738
669
  `Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) returned ${resp.status}.`
739
670
  );
@@ -743,7 +674,6 @@ export class CMI5Adapter implements PersistenceAdapter {
743
674
  `Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) failed (${err instanceof Error ? err.message : String(err)}).`
744
675
  );
745
676
  }
746
- return null;
747
677
  }
748
678
 
749
679
  async #xapiFetch(url: string, options: RequestInit = {}): Promise<Response> {
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Time / number formatters for SCORM & cmi5 data-model writes.
3
+ */
4
+
5
+ /**
6
+ * Format integer seconds as SCORM 1.2 `CMITimespan` (HHHH:MM:SS.SS).
7
+ *
8
+ * `DurationTracker.sessionSeconds` always feeds integer seconds via
9
+ * `Math.floor`, so the centisecond field is always `.00`. The format
10
+ * still includes it because `CMITimespan` is defined that way and some
11
+ * older LMS importers reject the bare HHHH:MM:SS form.
12
+ */
13
+ export function formatHHMMSS(totalSeconds: number): string {
14
+ const whole = Math.floor(totalSeconds);
15
+ const hours = Math.floor(whole / 3600);
16
+ const minutes = Math.floor((whole % 3600) / 60);
17
+ const seconds = whole % 60;
18
+ const hh = String(hours).padStart(4, '0');
19
+ const mm = String(minutes).padStart(2, '0');
20
+ const ss = String(seconds).padStart(2, '0');
21
+ return `${hh}:${mm}:${ss}.00`;
22
+ }
23
+
24
+ /**
25
+ * SCORM 2004 4E §4.2/§4.3 define CMIDecimal-like elements as real(10,7) —
26
+ * `String(1/3)` exceeds that and trips SCORM Cloud with error 406. Rounds,
27
+ * then trims trailing zeros (no padded "0.8500000" forms).
28
+ */
29
+ export function formatReal107(value: number): string {
30
+ if (!Number.isFinite(value)) return '0';
31
+ const rounded = Math.round(value * 1e7) / 1e7;
32
+ return rounded
33
+ .toFixed(7)
34
+ .replace(/(\.\d*?)0+$/, '$1')
35
+ .replace(/\.$/, '');
36
+ }
37
+
38
+ /**
39
+ * SCORM 2004 4E §3.3.10.1 references ISO 8601 §5.3.3 — local date+time, no
40
+ * zone designator. Strict validators reject `Z`, `±hh:mm`, and fractional
41
+ * seconds with error 406. UTC components are used so writes don't drift
42
+ * across local-TZ flips even though the format is zone-free.
43
+ */
44
+ export function formatISO8601Timestamp(date: Date): string {
45
+ const yyyy = date.getUTCFullYear();
46
+ const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
47
+ const dd = String(date.getUTCDate()).padStart(2, '0');
48
+ const hh = String(date.getUTCHours()).padStart(2, '0');
49
+ const mi = String(date.getUTCMinutes()).padStart(2, '0');
50
+ const ss = String(date.getUTCSeconds()).padStart(2, '0');
51
+ return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}`;
52
+ }
53
+
54
+ /**
55
+ * Format seconds as ISO 8601 duration: PT1H30M45S
56
+ */
57
+ export function formatISO8601Duration(totalSeconds: number): string {
58
+ const hours = Math.floor(totalSeconds / 3600);
59
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
60
+ const seconds = totalSeconds % 60;
61
+
62
+ let result = 'PT';
63
+ if (hours > 0) result += `${hours}H`;
64
+ if (minutes > 0) result += `${minutes}M`;
65
+ if (seconds > 0 || result === 'PT') result += `${seconds}S`;
66
+ return result;
67
+ }
@@ -60,40 +60,39 @@ export interface CreateAdapterOptions {
60
60
  * In dev mode, missing APIs warn and fall back to `WebAdapter` so authors
61
61
  * can still iterate locally.
62
62
  */
63
+ type LMSStandard = 'scorm12' | 'scorm2004' | 'cmi5';
64
+
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 }> = {
67
+ scorm12: {
68
+ detect: () => { const api = findSCORM12API(); return api ? new SCORM12Adapter(api) : null; },
69
+ label: 'SCORM 1.2 API',
70
+ },
71
+ scorm2004: {
72
+ detect: () => { const api = findSCORM2004API(); return api ? new SCORM2004Adapter(api) : null; },
73
+ label: 'SCORM 2004 API',
74
+ },
75
+ cmi5: {
76
+ detect: () => (hasCMI5LaunchParams() ? new CMI5Adapter() : null),
77
+ label: 'cmi5 launch parameters',
78
+ },
79
+ };
80
+
63
81
  export function createAdapter(
64
82
  config: CourseConfig,
65
83
  options: CreateAdapterOptions = {}
66
84
  ): PersistenceAdapter {
67
85
  const allowFallback =
68
86
  options.allowFallback ?? import.meta.env?.DEV === true;
69
- switch (config.export?.standard) {
70
- case 'scorm12': {
71
- const api = findSCORM12API();
72
- if (api) return new SCORM12Adapter(api);
73
- if (!allowFallback) throw missingApiError('scorm12');
74
- console.warn(
75
- 'Tessera (dev): SCORM 1.2 API not found — falling back to localStorage'
76
- );
77
- return new WebAdapter(config);
78
- }
79
- case 'scorm2004': {
80
- const api = findSCORM2004API();
81
- if (api) return new SCORM2004Adapter(api);
82
- if (!allowFallback) throw missingApiError('scorm2004');
83
- console.warn(
84
- 'Tessera (dev): SCORM 2004 API not found — falling back to localStorage'
85
- );
86
- return new WebAdapter(config);
87
- }
88
- case 'cmi5': {
89
- if (hasCMI5LaunchParams()) return new CMI5Adapter();
90
- if (!allowFallback) throw missingApiError('cmi5');
91
- console.warn(
92
- 'Tessera (dev): cmi5 launch parameters not found — falling back to localStorage'
93
- );
94
- return new WebAdapter(config);
95
- }
96
- default:
97
- return new WebAdapter(config);
87
+ const standard = config.export?.standard;
88
+ if (standard === 'scorm12' || standard === 'scorm2004' || standard === 'cmi5') {
89
+ const entry = LMS_ADAPTERS[standard];
90
+ const adapter = entry.detect();
91
+ if (adapter) return adapter;
92
+ if (!allowFallback) throw missingApiError(standard);
93
+ console.warn(
94
+ `Tessera (dev): ${entry.label} not found — falling back to localStorage`
95
+ );
98
96
  }
97
+ return new WebAdapter(config);
99
98
  }
@@ -296,67 +296,3 @@ export function findLMSAPI(propName: string): unknown {
296
296
  // Check window.parent chain (iframe launch pattern)
297
297
  return scan(window);
298
298
  }
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
- }
@@ -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.
@@ -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 {
@@ -104,7 +99,7 @@ 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(
@@ -114,7 +109,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
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
 
@@ -123,12 +118,12 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
123
118
  interaction: import('../interaction.js').Interaction,
124
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)));
@@ -151,7 +146,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
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),
@@ -167,7 +162,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
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(
@@ -177,7 +172,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
177
172
  }
178
173
 
179
174
  setExit(mode: 'suspend' | 'normal'): void {
180
- if (this.#mode !== 'normal') return;
175
+ if (!this.#canWrite) return;
181
176
  this.queue.enqueue(
182
177
  () => this.api.SetValue('cmi.exit', mode),
183
178
  'cmi.exit'
@@ -1,4 +1,5 @@
1
1
  import { getContext, setContext, onDestroy, onMount, tick } from 'svelte';
2
+ import { SvelteSet } from 'svelte/reactivity';
2
3
  import type { Interaction } from './interaction.js';
3
4
  import { isCorrect as isCorrectInteraction } from './interaction.js';
4
5
  import {
@@ -11,7 +12,6 @@ import {
11
12
  import {
12
13
  resolveFeedbackMode,
13
14
  resolveRetryStrategy,
14
- type QuizPolicyConfig,
15
15
  type QuizQuestionResult,
16
16
  } from './quiz-policy.js';
17
17
 
@@ -185,8 +185,8 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
185
185
  if (opts.graded && navCtx) {
186
186
  const pageIndex = navCtx.nav.currentPageIndex;
187
187
  navCtx.progress.markStandaloneQuestion(pageIndex, opts.id, score, true);
188
- navCtx.progress.recalculateCompletion(navCtx.manifest, navCtx.config);
189
- navCtx.progress.recalculateSuccess(navCtx.manifest, navCtx.config);
188
+ navCtx.progress.recalculateCompletion(navCtx.manifest.totalPages, navCtx.config);
189
+ navCtx.progress.recalculateSuccess(navCtx.config);
190
190
  } else if (navCtx) {
191
191
  const pageIndex = navCtx.nav.currentPageIndex;
192
192
  navCtx.progress.markStandaloneQuestion(pageIndex, opts.id, score, false);
@@ -291,7 +291,7 @@ export function useCompletion(): {
291
291
  return;
292
292
  }
293
293
  progress.markCompleteManually();
294
- progress.recalculateSuccess(manifest, config);
294
+ progress.recalculateSuccess(config);
295
295
  },
296
296
  get completionStatus() {
297
297
  return progress.completionStatus;
@@ -410,9 +410,8 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
410
410
  }
411
411
 
412
412
  const maxAttempts = quizConfig.maxAttempts ?? Infinity;
413
- const policyCfg = quizConfig as QuizPolicyConfig;
414
- const feedbackPredicate = resolveFeedbackMode(policyCfg);
415
- const retryPredicate = resolveRetryStrategy(policyCfg);
413
+ const feedbackPredicate = resolveFeedbackMode(quizConfig);
414
+ const retryPredicate = resolveRetryStrategy(quizConfig);
416
415
 
417
416
  let internalQuestions = $state<InternalQuestion[]>([]);
418
417
  const answers = new Map<number, unknown>();
@@ -422,8 +421,8 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
422
421
  let reviewing = $state(false);
423
422
  let score = $state(0);
424
423
  let attemptCount = $state(0);
425
- let feedbackShown = $state(new Set<number>());
426
- let lockedCorrect = $state(new Set<number>());
424
+ const feedbackShown = new SvelteSet<number>();
425
+ const lockedCorrect = new SvelteSet<number>();
427
426
  let submitCalled = false;
428
427
 
429
428
  const seenIds = new Set<string>();
@@ -477,7 +476,7 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
477
476
  }
478
477
 
479
478
  function feedbackVisibleInternal(index: number): boolean {
480
- if (policyCfg.feedbackMode === 'never') return false;
479
+ if (quizConfig.feedbackMode === 'never') return false;
481
480
  return feedbackPredicate({
482
481
  questionIndex: index,
483
482
  submitted,
@@ -489,10 +488,8 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
489
488
  }
490
489
 
491
490
  function revealFeedbackInternal(index: number): void {
492
- if (policyCfg.feedbackMode === 'never') return;
493
- const next = new Set(feedbackShown);
494
- next.add(index);
495
- feedbackShown = next;
491
+ if (quizConfig.feedbackMode === 'never') return;
492
+ feedbackShown.add(index);
496
493
  }
497
494
 
498
495
  function isLockedCorrectInternal(index: number): boolean {
@@ -618,7 +615,8 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
618
615
  for (const i of newLocked) {
619
616
  if (answers.has(i)) preserved.set(i, answers.get(i));
620
617
  }
621
- lockedCorrect = newLocked;
618
+ lockedCorrect.clear();
619
+ for (const i of newLocked) lockedCorrect.add(i);
622
620
  answers.clear();
623
621
  reportedAnswers.clear();
624
622
  for (const [i, a] of preserved) answers.set(i, a);
@@ -626,7 +624,7 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
626
624
  if (!newLocked.has(i) && internalQuestions[i].reset) internalQuestions[i].reset!();
627
625
  }
628
626
  answersVersion++;
629
- feedbackShown = new Set();
627
+ feedbackShown.clear();
630
628
  submitted = false;
631
629
  reviewing = false;
632
630
  score = 0;