tessera-learn 0.0.1

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 (71) hide show
  1. package/AGENTS.md +1228 -0
  2. package/LICENSE +21 -0
  3. package/README.md +21 -0
  4. package/dist/plugin/index.d.ts +7 -0
  5. package/dist/plugin/index.d.ts.map +1 -0
  6. package/dist/plugin/index.js +1239 -0
  7. package/dist/plugin/index.js.map +1 -0
  8. package/package.json +77 -0
  9. package/src/archiver.d.ts +27 -0
  10. package/src/components/Accordion.svelte +32 -0
  11. package/src/components/AccordionItem.svelte +144 -0
  12. package/src/components/Audio.svelte +38 -0
  13. package/src/components/Callout.svelte +81 -0
  14. package/src/components/Carousel.svelte +194 -0
  15. package/src/components/CarouselSlide.svelte +32 -0
  16. package/src/components/DefaultLayout.svelte +108 -0
  17. package/src/components/FillInTheBlank.svelte +345 -0
  18. package/src/components/Image.svelte +47 -0
  19. package/src/components/Matching.svelte +513 -0
  20. package/src/components/MultipleChoice.svelte +363 -0
  21. package/src/components/Quiz.svelte +569 -0
  22. package/src/components/RevealModal.svelte +228 -0
  23. package/src/components/Sorting.svelte +663 -0
  24. package/src/components/Video.svelte +118 -0
  25. package/src/components/index.ts +15 -0
  26. package/src/components/quiz-payload.ts +71 -0
  27. package/src/components/util.ts +24 -0
  28. package/src/index.ts +56 -0
  29. package/src/plugin/export.ts +264 -0
  30. package/src/plugin/index.ts +464 -0
  31. package/src/plugin/layout.ts +55 -0
  32. package/src/plugin/manifest.ts +330 -0
  33. package/src/plugin/quiz.ts +65 -0
  34. package/src/plugin/validation.ts +838 -0
  35. package/src/runtime/App.svelte +435 -0
  36. package/src/runtime/ErrorPage.svelte +14 -0
  37. package/src/runtime/LoadingSkeleton.svelte +26 -0
  38. package/src/runtime/Sidebar.svelte +76 -0
  39. package/src/runtime/access.ts +55 -0
  40. package/src/runtime/adapters/cmi5.ts +341 -0
  41. package/src/runtime/adapters/discovery.ts +38 -0
  42. package/src/runtime/adapters/index.ts +99 -0
  43. package/src/runtime/adapters/retry.ts +284 -0
  44. package/src/runtime/adapters/scorm12.ts +172 -0
  45. package/src/runtime/adapters/scorm2004.ts +162 -0
  46. package/src/runtime/adapters/web.ts +62 -0
  47. package/src/runtime/contexts.ts +76 -0
  48. package/src/runtime/duration.ts +29 -0
  49. package/src/runtime/hooks.svelte.ts +543 -0
  50. package/src/runtime/interaction-format.ts +132 -0
  51. package/src/runtime/interaction.ts +96 -0
  52. package/src/runtime/navigation.svelte.ts +117 -0
  53. package/src/runtime/persistence.ts +56 -0
  54. package/src/runtime/progress.svelte.ts +168 -0
  55. package/src/runtime/quiz-policy.ts +227 -0
  56. package/src/runtime/slugify.ts +17 -0
  57. package/src/runtime/types.ts +92 -0
  58. package/src/runtime/xapi/agent-rules.ts +93 -0
  59. package/src/runtime/xapi/client.ts +133 -0
  60. package/src/runtime/xapi/derive-actor.ts +90 -0
  61. package/src/runtime/xapi/publisher.ts +604 -0
  62. package/src/runtime/xapi/registry.ts +38 -0
  63. package/src/runtime/xapi/setup.ts +250 -0
  64. package/src/runtime/xapi/types.ts +106 -0
  65. package/src/runtime/xapi/uuid.ts +21 -0
  66. package/src/runtime/xapi/validation.ts +71 -0
  67. package/src/runtime/xapi/version.ts +23 -0
  68. package/src/virtual.d.ts +16 -0
  69. package/styles/base.css +194 -0
  70. package/styles/layout.css +408 -0
  71. package/styles/theme.css +36 -0
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Optional callback that surfaces the LMS's last-error code/message after
3
+ * a failed call, so warning logs can name the actual cause instead of a
4
+ * generic "LMS call failed".
5
+ */
6
+ export interface LMSErrorReporter {
7
+ /** Last error from `LMSGetLastError` / `GetLastError`. */
8
+ code(): string;
9
+ /** Human-readable message from `LMSGetErrorString` / `GetErrorString`. */
10
+ message(code: string): string;
11
+ }
12
+
13
+ /**
14
+ * Retry wrapper for LMS API calls.
15
+ * Retries up to maxRetries times with exponential backoff.
16
+ * Returns true if the call eventually succeeded, false otherwise.
17
+ *
18
+ * If `errorReporter` is provided, the SCORM `GetLastError` /
19
+ * `GetErrorString` pair is read after each failure and surfaced in the
20
+ * final warning so production triage can name the real failure
21
+ * (e.g., "201 Invalid argument error" or "405 Incorrect Data Type").
22
+ *
23
+ * Note: During page unload (pagehide/beforeunload), only the first
24
+ * synchronous attempt will execute — async retries with setTimeout
25
+ * won't run because the page is being torn down. This is acceptable
26
+ * for SCORM adapters where the underlying API calls are synchronous.
27
+ */
28
+ export async function withRetry(
29
+ fn: () => any,
30
+ maxRetries = 3,
31
+ errorReporter?: LMSErrorReporter,
32
+ context?: string
33
+ ): Promise<boolean> {
34
+ let lastErrCode = '';
35
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
36
+ try {
37
+ const result = fn();
38
+ if (result !== false && result !== 'false') return true;
39
+ } catch {
40
+ // API call threw — treat as failure
41
+ }
42
+ if (errorReporter) {
43
+ try { lastErrCode = errorReporter.code(); } catch {}
44
+ }
45
+ if (attempt < maxRetries - 1) {
46
+ await new Promise((r) => setTimeout(r, 100 * Math.pow(2, attempt)));
47
+ }
48
+ }
49
+ let detail = '';
50
+ if (errorReporter && lastErrCode && lastErrCode !== '0') {
51
+ try {
52
+ const msg = errorReporter.message(lastErrCode);
53
+ detail = ` (LMS error ${lastErrCode}${msg ? `: ${msg}` : ''})`;
54
+ } catch {}
55
+ }
56
+ const ctx = context ? ` [${context}]` : '';
57
+ console.warn(
58
+ `Tessera: LMS call failed after retries${ctx}${detail}, continuing without persistence`
59
+ );
60
+ return false;
61
+ }
62
+
63
+ /**
64
+ * Synchronous single-attempt LMS call. Used during page unload
65
+ * where async retries cannot run.
66
+ */
67
+ export function callSync(fn: () => any): boolean {
68
+ try {
69
+ const result = fn();
70
+ return result !== false && result !== 'false';
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Sequential write queue for LMS operations.
78
+ * Enqueues operations and flushes them sequentially with retry.
79
+ * If an operation fails after retries, the queue stops and retries
80
+ * the failed operation on the next flush trigger.
81
+ */
82
+ interface QueueEntry {
83
+ fn: () => any;
84
+ context?: string;
85
+ }
86
+
87
+ export class WriteQueue {
88
+ #queue: QueueEntry[] = [];
89
+ #flushing = false;
90
+ #aborted = false;
91
+ /**
92
+ * The entry that the async flush has shifted off the queue and is
93
+ * currently awaiting a retry on. drainSync needs to know about this so
94
+ * it can re-run the entry synchronously — otherwise an entry caught
95
+ * mid-backoff at unload time vanishes silently.
96
+ */
97
+ #inFlight: QueueEntry | null = null;
98
+
99
+ errorReporter?: LMSErrorReporter;
100
+
101
+ /**
102
+ * Enqueue an operation and trigger a flush.
103
+ */
104
+ enqueue(fn: () => any, context?: string): void {
105
+ this.#queue.push({ fn, context });
106
+ if (!this.#flushing) {
107
+ this.#flush();
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Flush the queue sequentially. If an operation fails after retries,
113
+ * re-insert it at the front and stop — retry on next trigger.
114
+ *
115
+ * Retry is inlined (not delegated to `withRetry`) so we can mark
116
+ * `#inFlight` *only* while awaiting a backoff. drainSync re-runs an
117
+ * in-flight entry synchronously, which is only safe when the current
118
+ * attempt has failed and the next attempt is what we're waiting on.
119
+ */
120
+ async #flush(): Promise<void> {
121
+ if (this.#flushing) return;
122
+ this.#flushing = true;
123
+ this.#aborted = false;
124
+
125
+ const MAX_ATTEMPTS = 3;
126
+
127
+ while (this.#queue.length > 0) {
128
+ if (this.#aborted) {
129
+ this.#flushing = false;
130
+ return;
131
+ }
132
+
133
+ const entry = this.#queue.shift()!;
134
+ let succeeded = false;
135
+ let lastErrCode = '';
136
+
137
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
138
+ let ok = false;
139
+ try {
140
+ const result = entry.fn();
141
+ ok = result !== false && result !== 'false';
142
+ } catch {
143
+ // API call threw — treat as failure
144
+ }
145
+ if (ok) {
146
+ succeeded = true;
147
+ break;
148
+ }
149
+ if (this.errorReporter) {
150
+ try { lastErrCode = this.errorReporter.code(); } catch {}
151
+ }
152
+ if (attempt < MAX_ATTEMPTS - 1) {
153
+ // The next attempt is gated on a backoff timer that won't fire
154
+ // during page unload. Mark in-flight so drainSync can re-run
155
+ // the entry synchronously if it interrupts here.
156
+ this.#inFlight = entry;
157
+ await new Promise((r) => setTimeout(r, 100 * Math.pow(2, attempt)));
158
+ this.#inFlight = null;
159
+ if (this.#aborted) {
160
+ this.#flushing = false;
161
+ return;
162
+ }
163
+ }
164
+ }
165
+
166
+ if (!succeeded) {
167
+ let detail = '';
168
+ if (this.errorReporter && lastErrCode && lastErrCode !== '0') {
169
+ try {
170
+ const msg = this.errorReporter.message(lastErrCode);
171
+ detail = ` (LMS error ${lastErrCode}${msg ? `: ${msg}` : ''})`;
172
+ } catch {}
173
+ }
174
+ const ctx = entry.context ? ` [${entry.context}]` : '';
175
+ console.warn(
176
+ `Tessera: LMS call failed after retries${ctx}${detail}, continuing without persistence`
177
+ );
178
+ this.#queue.unshift(entry);
179
+ this.#flushing = false;
180
+ return;
181
+ }
182
+ }
183
+
184
+ this.#flushing = false;
185
+ }
186
+
187
+ /**
188
+ * Synchronously drain the queue (best-effort, single attempt each).
189
+ * Used during page unload where async operations cannot complete.
190
+ * Aborts any in-progress async flush and re-runs its in-flight entry
191
+ * synchronously (the awaited backoff timer won't fire during unload).
192
+ */
193
+ drainSync(): void {
194
+ this.#aborted = true;
195
+ this.#flushing = false;
196
+ if (this.#inFlight) {
197
+ // The async flush's withRetry was suspended at a setTimeout backoff
198
+ // that won't fire before the page tears down. Run the entry once
199
+ // synchronously so its write isn't lost. The async flush will see
200
+ // the abort flag when (if) it ever resumes and exit cleanly.
201
+ const entry = this.#inFlight;
202
+ this.#inFlight = null;
203
+ callSync(entry.fn);
204
+ }
205
+ while (this.#queue.length > 0) {
206
+ const entry = this.#queue.shift()!;
207
+ callSync(entry.fn);
208
+ }
209
+ }
210
+
211
+ get pending(): number {
212
+ return this.#queue.length;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Walk the window.opener and window.parent chains looking for an LMS API object.
218
+ * Shared by SCORM 1.2 (property "API") and SCORM 2004 (property "API_1484_11").
219
+ * Returns null if not found within 10 levels or a cross-origin boundary is hit.
220
+ */
221
+ export function findLMSAPI(propName: string): unknown {
222
+ function scan(win: Window): unknown {
223
+ for (let i = 0; i < 10; i++) {
224
+ try {
225
+ const value = (win as unknown as Record<string, unknown>)[propName];
226
+ if (value) return value;
227
+ } catch {
228
+ // Cross-origin frame — stop
229
+ return null;
230
+ }
231
+ if (win.parent === win) break;
232
+ try {
233
+ win = win.parent;
234
+ } catch {
235
+ // Cross-origin frame — stop
236
+ break;
237
+ }
238
+ }
239
+ return null;
240
+ }
241
+
242
+ // Check window.opener chain first (popup launch pattern)
243
+ if (window.opener) {
244
+ const api = scan(window.opener as Window);
245
+ if (api) return api;
246
+ }
247
+
248
+ // Check window.parent chain (iframe launch pattern)
249
+ return scan(window);
250
+ }
251
+
252
+ /**
253
+ * Format integer seconds as SCORM 1.2 `CMITimespan` (HHHH:MM:SS.SS).
254
+ *
255
+ * `DurationTracker.sessionSeconds` always feeds integer seconds via
256
+ * `Math.floor`, so the centisecond field is always `.00`. The format
257
+ * still includes it because `CMITimespan` is defined that way and some
258
+ * older LMS importers reject the bare HHHH:MM:SS form.
259
+ */
260
+ export function formatHHMMSS(totalSeconds: number): string {
261
+ const whole = Math.floor(totalSeconds);
262
+ const hours = Math.floor(whole / 3600);
263
+ const minutes = Math.floor((whole % 3600) / 60);
264
+ const seconds = whole % 60;
265
+ const hh = String(hours).padStart(4, '0');
266
+ const mm = String(minutes).padStart(2, '0');
267
+ const ss = String(seconds).padStart(2, '0');
268
+ return `${hh}:${mm}:${ss}.00`;
269
+ }
270
+
271
+ /**
272
+ * Format seconds as ISO 8601 duration: PT1H30M45S
273
+ */
274
+ export function formatISO8601Duration(totalSeconds: number): string {
275
+ const hours = Math.floor(totalSeconds / 3600);
276
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
277
+ const seconds = totalSeconds % 60;
278
+
279
+ let result = 'PT';
280
+ if (hours > 0) result += `${hours}H`;
281
+ if (minutes > 0) result += `${minutes}M`;
282
+ if (seconds > 0 || result === 'PT') result += `${seconds}S`;
283
+ return result;
284
+ }
@@ -0,0 +1,172 @@
1
+ import type { PersistenceAdapter, SavedState } from '../persistence.js';
2
+ import type { Interaction } from '../interaction.js';
3
+ import { buildScormInteractionFields, scorm12Type } from '../interaction-format.js';
4
+ import { WriteQueue, callSync, withRetry, formatHHMMSS } from './retry.js';
5
+
6
+ /**
7
+ * SCORM 1.2 API interface.
8
+ */
9
+ export interface SCORM12API {
10
+ LMSInitialize(param: string): string;
11
+ LMSFinish(param: string): string;
12
+ LMSGetValue(element: string): string;
13
+ LMSSetValue(element: string, value: string): string;
14
+ LMSCommit(param: string): string;
15
+ LMSGetLastError(): string;
16
+ LMSGetErrorString(errorCode: string): string;
17
+ LMSGetDiagnostic(errorCode: string): string;
18
+ }
19
+
20
+ /**
21
+ * SCORM 1.2 persistence adapter.
22
+ *
23
+ * Uses a sequential write queue for all LMS SetValue/Commit calls.
24
+ * On terminate, the queue is drained synchronously (single attempt)
25
+ * since async retries cannot complete during page unload.
26
+ */
27
+ export class SCORM12Adapter implements PersistenceAdapter {
28
+ #api: SCORM12API;
29
+ #queue = new WriteQueue();
30
+ #state: SavedState | null = null;
31
+ #terminated = false;
32
+
33
+ // SCORM 1.2 combines completion and success into a single lesson_status field
34
+ #completionStatus: string = 'incomplete';
35
+ #successStatus: string | null = null;
36
+ #interactionCount = 0;
37
+
38
+ constructor(api: SCORM12API) {
39
+ this.#api = api;
40
+ // Wire up GetLastError/GetErrorString so retry warnings can name the
41
+ // real LMS failure (e.g. "201 Invalid argument error") instead of a
42
+ // generic "LMS call failed" — production triage needs the code.
43
+ this.#queue.errorReporter = {
44
+ code: () => this.#api.LMSGetLastError(),
45
+ message: (c) => this.#api.LMSGetErrorString(c),
46
+ };
47
+ }
48
+
49
+ /** Expose the underlying SCORM 1.2 API so xAPI actor synthesis can read learner fields. */
50
+ getAPI(): SCORM12API {
51
+ return this.#api;
52
+ }
53
+
54
+ async init(): Promise<void> {
55
+ await withRetry(() => this.#api.LMSInitialize(''));
56
+
57
+ try {
58
+ const raw = this.#api.LMSGetValue('cmi.suspend_data');
59
+ if (raw && raw.trim()) {
60
+ this.#state = JSON.parse(raw);
61
+ }
62
+ } catch {
63
+ this.#state = null;
64
+ }
65
+
66
+ // Continue cmi.interactions.n indexing where the previous session left
67
+ // off. Restarting at 0 would overwrite prior records (the LMS uses n
68
+ // as the array key, not an upsert field).
69
+ try {
70
+ const count = this.#api.LMSGetValue('cmi.interactions._count');
71
+ const n = parseInt(count, 10);
72
+ if (Number.isFinite(n) && n >= 0) this.#interactionCount = n;
73
+ } catch {
74
+ // Some LMSes throw on _count when no interactions exist — fall back to 0.
75
+ }
76
+ }
77
+
78
+ getState(): SavedState | null {
79
+ return this.#state;
80
+ }
81
+
82
+ saveState(state: SavedState): void {
83
+ this.#state = state;
84
+ const json = JSON.stringify(state);
85
+ this.#queue.enqueue(() => this.#api.LMSSetValue('cmi.suspend_data', json));
86
+ }
87
+
88
+ setScore(score: number): void {
89
+ this.#queue.enqueue(() =>
90
+ this.#api.LMSSetValue('cmi.core.score.raw', String(score))
91
+ );
92
+ this.#queue.enqueue(() =>
93
+ this.#api.LMSSetValue('cmi.core.score.min', '0')
94
+ );
95
+ this.#queue.enqueue(() =>
96
+ this.#api.LMSSetValue('cmi.core.score.max', '100')
97
+ );
98
+ }
99
+
100
+ setCompletionStatus(status: 'incomplete' | 'complete'): void {
101
+ this.#completionStatus = status === 'complete' ? 'completed' : 'incomplete';
102
+ this.#flushLessonStatus();
103
+ }
104
+
105
+ setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
106
+ // SCORM 1.2 has no "unknown" lesson_status — clear the success override
107
+ // so completion status drives lesson_status until a real result is known.
108
+ this.#successStatus = status === 'unknown' ? null : status;
109
+ this.#flushLessonStatus();
110
+ }
111
+
112
+ #flushLessonStatus(): void {
113
+ // Success status takes priority — it's the more specific status
114
+ const value = this.#successStatus ?? this.#completionStatus;
115
+ this.#queue.enqueue(() =>
116
+ this.#api.LMSSetValue('cmi.core.lesson_status', value)
117
+ );
118
+ }
119
+
120
+ setDuration(seconds: number): void {
121
+ const formatted = formatHHMMSS(seconds);
122
+ this.#queue.enqueue(() =>
123
+ this.#api.LMSSetValue('cmi.core.session_time', formatted)
124
+ );
125
+ }
126
+
127
+ setExit(mode: 'suspend' | 'normal'): void {
128
+ // SCORM 1.2 §4.2.2 vocabulary: time-out, suspend, logout, "" (normal).
129
+ // We only map 'suspend' and the empty/normal case.
130
+ const value = mode === 'suspend' ? 'suspend' : '';
131
+ this.#queue.enqueue(() => this.#api.LMSSetValue('cmi.core.exit', value));
132
+ }
133
+
134
+ reportInteraction(
135
+ questionId: string,
136
+ interaction: Interaction,
137
+ correct: boolean | null
138
+ ): void {
139
+ const n = this.#interactionCount++;
140
+ const fields = buildScormInteractionFields(
141
+ `cmi.interactions.${n}`,
142
+ questionId,
143
+ interaction,
144
+ correct,
145
+ {
146
+ responseField: 'student_response',
147
+ timestampField: 'time',
148
+ timestamp: new Date().toTimeString().slice(0, 8),
149
+ typeValue: scorm12Type(interaction.type),
150
+ resultLabels: { correct: 'correct', incorrect: 'wrong' },
151
+ }
152
+ );
153
+ for (const [key, value] of fields) {
154
+ this.#queue.enqueue(() => this.#api.LMSSetValue(key, value));
155
+ }
156
+ }
157
+
158
+ commit(): void {
159
+ this.#queue.enqueue(() => this.#api.LMSCommit(''));
160
+ }
161
+
162
+ terminate(): void {
163
+ if (this.#terminated) return;
164
+ this.#terminated = true;
165
+ // During page unload, async retries can't run.
166
+ // Drain any pending queue operations synchronously (single attempt each),
167
+ // then commit and finish synchronously.
168
+ this.#queue.drainSync();
169
+ callSync(() => this.#api.LMSCommit(''));
170
+ callSync(() => this.#api.LMSFinish(''));
171
+ }
172
+ }
@@ -0,0 +1,162 @@
1
+ import type { PersistenceAdapter, SavedState } from '../persistence.js';
2
+ import type { Interaction } from '../interaction.js';
3
+ import { buildScormInteractionFields } from '../interaction-format.js';
4
+ import { WriteQueue, callSync, withRetry, formatISO8601Duration } from './retry.js';
5
+
6
+ /**
7
+ * SCORM 2004 API interface.
8
+ */
9
+ export interface SCORM2004API {
10
+ Initialize(param: string): string;
11
+ Terminate(param: string): string;
12
+ GetValue(element: string): string;
13
+ SetValue(element: string, value: string): string;
14
+ Commit(param: string): string;
15
+ GetLastError(): string;
16
+ GetErrorString(errorCode: string): string;
17
+ GetDiagnostic(errorCode: string): string;
18
+ }
19
+
20
+ /**
21
+ * SCORM 2004 persistence adapter.
22
+ *
23
+ * Uses a sequential write queue for all LMS SetValue/Commit calls.
24
+ * On terminate, the queue is drained synchronously (single attempt)
25
+ * since async retries cannot complete during page unload.
26
+ */
27
+ export class SCORM2004Adapter implements PersistenceAdapter {
28
+ #api: SCORM2004API;
29
+ #queue = new WriteQueue();
30
+ #state: SavedState | null = null;
31
+ #terminated = false;
32
+ #interactionCount = 0;
33
+
34
+ constructor(api: SCORM2004API) {
35
+ this.#api = api;
36
+ // Wire up GetLastError/GetErrorString so retry warnings can name the
37
+ // real LMS failure (e.g. "405 Incorrect Data Type") instead of a
38
+ // generic "LMS call failed" — production triage needs the code.
39
+ this.#queue.errorReporter = {
40
+ code: () => this.#api.GetLastError(),
41
+ message: (c) => this.#api.GetErrorString(c),
42
+ };
43
+ }
44
+
45
+ /** Expose the underlying SCORM 2004 API so xAPI actor synthesis can read learner fields. */
46
+ getAPI(): SCORM2004API {
47
+ return this.#api;
48
+ }
49
+
50
+ async init(): Promise<void> {
51
+ await withRetry(() => this.#api.Initialize(''));
52
+
53
+ try {
54
+ const raw = this.#api.GetValue('cmi.suspend_data');
55
+ if (raw && raw.trim()) {
56
+ this.#state = JSON.parse(raw);
57
+ }
58
+ } catch {
59
+ this.#state = null;
60
+ }
61
+
62
+ // Continue cmi.interactions.n indexing where the previous session left
63
+ // off. Restarting at 0 would overwrite prior records.
64
+ try {
65
+ const count = this.#api.GetValue('cmi.interactions._count');
66
+ const n = parseInt(count, 10);
67
+ if (Number.isFinite(n) && n >= 0) this.#interactionCount = n;
68
+ } catch {
69
+ // Fallback to 0 if _count read fails.
70
+ }
71
+ }
72
+
73
+ getState(): SavedState | null {
74
+ return this.#state;
75
+ }
76
+
77
+ saveState(state: SavedState): void {
78
+ this.#state = state;
79
+ const json = JSON.stringify(state);
80
+ this.#queue.enqueue(() => this.#api.SetValue('cmi.suspend_data', json));
81
+ }
82
+
83
+ setScore(score: number): void {
84
+ this.#queue.enqueue(() =>
85
+ this.#api.SetValue('cmi.score.raw', String(score))
86
+ );
87
+ this.#queue.enqueue(() => this.#api.SetValue('cmi.score.min', '0'));
88
+ this.#queue.enqueue(() => this.#api.SetValue('cmi.score.max', '100'));
89
+ this.#queue.enqueue(() =>
90
+ this.#api.SetValue('cmi.score.scaled', String(score / 100))
91
+ );
92
+ }
93
+
94
+ // Note: cmi.completion_threshold and cmi.scaled_passing_score are typically
95
+ // set by the LMS, not the SCO. Tessera manages completion and passing
96
+ // logic internally via course.config.js settings.
97
+ setCompletionStatus(status: 'incomplete' | 'complete'): void {
98
+ const value = status === 'complete' ? 'completed' : 'incomplete';
99
+ this.#queue.enqueue(() =>
100
+ this.#api.SetValue('cmi.completion_status', value)
101
+ );
102
+ }
103
+
104
+ setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
105
+ // "unknown" is a valid SCORM 2004 value — setting it explicitly prevents
106
+ // LMSes (notably SCORM Cloud) from rolling up a null status to "passed".
107
+ this.#queue.enqueue(() =>
108
+ this.#api.SetValue('cmi.success_status', status)
109
+ );
110
+ }
111
+
112
+ setDuration(seconds: number): void {
113
+ const formatted = formatISO8601Duration(seconds);
114
+ this.#queue.enqueue(() =>
115
+ this.#api.SetValue('cmi.session_time', formatted)
116
+ );
117
+ }
118
+
119
+ setExit(mode: 'suspend' | 'normal'): void {
120
+ // SCORM 2004 §4.2 cmi.exit vocabulary: time-out, suspend, logout, normal, "".
121
+ this.#queue.enqueue(() => this.#api.SetValue('cmi.exit', mode));
122
+ }
123
+
124
+ reportInteraction(
125
+ questionId: string,
126
+ interaction: Interaction,
127
+ correct: boolean | null
128
+ ): void {
129
+ const n = this.#interactionCount++;
130
+ const fields = buildScormInteractionFields(
131
+ `cmi.interactions.${n}`,
132
+ questionId,
133
+ interaction,
134
+ correct,
135
+ {
136
+ responseField: 'learner_response',
137
+ timestampField: 'timestamp',
138
+ timestamp: new Date().toISOString(),
139
+ typeValue: interaction.type,
140
+ resultLabels: { correct: 'correct', incorrect: 'incorrect' },
141
+ }
142
+ );
143
+ for (const [key, value] of fields) {
144
+ this.#queue.enqueue(() => this.#api.SetValue(key, value));
145
+ }
146
+ }
147
+
148
+ commit(): void {
149
+ this.#queue.enqueue(() => this.#api.Commit(''));
150
+ }
151
+
152
+ terminate(): void {
153
+ if (this.#terminated) return;
154
+ this.#terminated = true;
155
+ // During page unload, async retries can't run.
156
+ // Drain any pending queue operations synchronously (single attempt each),
157
+ // then commit and terminate synchronously.
158
+ this.#queue.drainSync();
159
+ callSync(() => this.#api.Commit(''));
160
+ callSync(() => this.#api.Terminate(''));
161
+ }
162
+ }
@@ -0,0 +1,62 @@
1
+ import type { PersistenceAdapter, SavedState } from '../persistence.js';
2
+ import type { CourseConfig } from '../types.js';
3
+ import type { Interaction } from '../interaction.js';
4
+ import { slugify } from '../slugify.js';
5
+
6
+ /**
7
+ * Web persistence adapter — stores course state in localStorage.
8
+ * Used for standalone web deployments (no LMS).
9
+ */
10
+ export class WebAdapter implements PersistenceAdapter {
11
+ #storageKey: string;
12
+ #state: SavedState | null = null;
13
+
14
+ constructor(config: CourseConfig) {
15
+ const courseId = slugify(config.title || '') || 'tessera-course';
16
+ this.#storageKey = `tessera-${courseId}`;
17
+ }
18
+
19
+ async init(): Promise<void> {
20
+ try {
21
+ const raw = localStorage.getItem(this.#storageKey);
22
+ if (raw) {
23
+ this.#state = JSON.parse(raw);
24
+ }
25
+ } catch {
26
+ // Corrupted data or localStorage unavailable — start fresh
27
+ this.#state = null;
28
+ }
29
+ }
30
+
31
+ getState(): SavedState | null {
32
+ return this.#state;
33
+ }
34
+
35
+ saveState(state: SavedState): void {
36
+ this.#state = state;
37
+ try {
38
+ localStorage.setItem(this.#storageKey, JSON.stringify(state));
39
+ } catch {
40
+ // localStorage full or unavailable — silently fail
41
+ console.warn('Tessera: Failed to save state to localStorage');
42
+ }
43
+ }
44
+
45
+ // No-ops for web adapter — these are used by LMS adapters
46
+ setScore(_score: number): void {}
47
+ setCompletionStatus(_status: 'incomplete' | 'complete'): void {}
48
+ setSuccessStatus(_status: 'passed' | 'failed' | 'unknown'): void {}
49
+ setDuration(_seconds: number): void {}
50
+ setExit(_mode: 'suspend' | 'normal'): void {}
51
+ reportInteraction(
52
+ _questionId: string,
53
+ _interaction: Interaction,
54
+ _correct: boolean | null
55
+ ): void {
56
+ // Web adapter has no external LMS; learner interaction data lives only
57
+ // in memory. Authors who want to persist per-question state can use
58
+ // `usePersistence(key)` which writes into SavedState.u.
59
+ }
60
+ commit(): void {}
61
+ terminate(): void {}
62
+ }