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
package/AGENTS.md ADDED
@@ -0,0 +1,1228 @@
1
+ # AGENTS.md — Tessera Course Authoring Guide
2
+
3
+ Tessera is an **LMS tracking runtime** for interactive learning content. It handles SCORM 1.2 / SCORM 2004 / cmi5 / xAPI statements, progress state, completion and success rollup, persistence, and navigation gating — and gets out of the way for the presentation layer.
4
+
5
+ **Lock the data contract. Free the presentation.** Build a course with built-in components, your own (via the hooks), or any mix. This file is the canonical reference for any agent or human author working in a Tessera project — read it before generating or editing course code.
6
+
7
+ ---
8
+
9
+ ## Project Structure
10
+
11
+ The framework imposes the **minimum** structure it needs to discover content. Everything else is convention you can opt into.
12
+
13
+ ### Required
14
+
15
+ ```
16
+ my-course/
17
+ ├── course.config.js # Course configuration
18
+ ├── vite.config.js # Vite config (do not modify)
19
+ ├── package.json
20
+ └── pages/ # Course content (at least one section dir with .svelte files)
21
+ └── intro/
22
+ └── welcome.svelte
23
+ ```
24
+
25
+ That's it. `pages/` exists, contains one or more **section directories**, each containing one or more `.svelte` files (directly or inside lesson subdirectories). The runtime works with that alone.
26
+
27
+ ### Optional
28
+
29
+ ```
30
+ my-course/
31
+ ├── layout.svelte # Custom chrome (replaces default sidebar/topbar)
32
+ ├── quiz.svelte # Custom quiz shell (replaces built-in <Quiz>)
33
+ ├── assets/ # Images, audio, video files (referenced via $assets/)
34
+ ├── styles/ # Custom CSS overrides
35
+ ├── AGENTS.md # This file (written by the scaffolder)
36
+ └── pages/
37
+ └── 01-intro/ # Numeric prefix → controls order
38
+ ├── _meta.js # Override section title; control page order
39
+ ├── welcome.svelte # Page directly in the section ("flat" shape)
40
+ └── 01-getting-started/ # Lesson subdirectory ("nested" shape)
41
+ ├── _meta.js
42
+ └── overview.svelte
43
+ ```
44
+
45
+ ### Hierarchy and ordering
46
+
47
+ The manifest is always **section → lesson → page**. Files directly in a section folder are flattened into one implicit lesson with the section's title; lesson subdirectories nest as expected. Both shapes can coexist.
48
+
49
+ Sorting is alphabetical by directory / filename. Numeric prefixes on directories (`01-`, `02-`, …) give explicit ordering without renaming the files inside, and are stripped from slugs and titles (`01-getting-started/` → slug `getting-started`, title "Getting Started"). Use `_meta.js` to control page order within a lesson rather than prefixing page filenames.
50
+
51
+ ### `_meta.js` files
52
+
53
+ **Optional everywhere.** When absent, titles fall back to the title-cased slug.
54
+
55
+ ```js
56
+ // section or lesson _meta.js — title override
57
+ export default { title: "Getting Started" };
58
+ ```
59
+
60
+ ```js
61
+ // lesson _meta.js — explicit page order
62
+ export default {
63
+ title: "Welcome",
64
+ pages: ["welcome", "objectives"],
65
+ };
66
+ ```
67
+
68
+ Pages listed in `pages` come first in listed order; any unlisted `.svelte` files are appended alphabetically.
69
+
70
+ ---
71
+
72
+ ## Authoring Surfaces
73
+
74
+ There are five:
75
+
76
+ 1. **Built-in components** — `Callout`, `Image`, `MultipleChoice`, `FillInTheBlank`, `Matching`, `Sorting`, etc., from `tessera-learn`. Use, compose, or skip.
77
+ 2. **Hooks** — `useQuestion`, `useQuiz`, `useNavigation`, `useProgress`, `usePersistence`. The stable contract between custom widgets and the runtime. Anything the built-ins do, you can do.
78
+ 3. **Custom layout** — drop `layout.svelte` at the project root to replace the default chrome.
79
+ 4. **Custom quiz shell** — drop `quiz.svelte` at the project root to replace the built-in quiz UI for every page that has `pageConfig.quiz`. Authors call `useQuiz()` for state and dispatch; question widgets continue to register through `useQuestion`.
80
+ 5. **Custom xAPI** — `useXAPI()` returns a publisher for emitting your own xAPI verbs to one or more LRSes. See [Custom xAPI statements](#custom-xapi-statements).
81
+
82
+ The built-ins are reference implementations of the hooks. A custom widget that calls `useQuestion` and emits an `Interaction` is treated identically to `<MultipleChoice>` — same scoring, same LMS reporting, same persistence.
83
+
84
+ ---
85
+
86
+ ## Creating Pages
87
+
88
+ Each page is a `.svelte` file inside a lesson folder.
89
+
90
+ ### Basic page
91
+
92
+ ```svelte
93
+ <h1>Welcome</h1>
94
+ <p>Standard HTML works as-is.</p>
95
+ ```
96
+
97
+ ### Page configuration
98
+
99
+ `pageConfig` sets the page title and configures quizzes. It must be a **static object literal** in a module script block — no variables, function calls, or computed values.
100
+
101
+ Both `<script module>` (Svelte 5) and `<script context="module">` (legacy) are accepted by the manifest parser.
102
+
103
+ ```svelte
104
+ <script module>
105
+ export const pageConfig = {
106
+ title: "Introduction to the Topic",
107
+ };
108
+ </script>
109
+
110
+ <h1>Introduction to the Topic</h1>
111
+ ```
112
+
113
+ If `pageConfig.title` is omitted, the title is derived from the filename: `my-page.svelte` → "My Page".
114
+
115
+ ### Importing components
116
+
117
+ ```svelte
118
+ <script>
119
+ import { Callout, Image } from 'tessera-learn';
120
+ </script>
121
+
122
+ <Callout type="info">
123
+ <p>Helpful information.</p>
124
+ </Callout>
125
+ ```
126
+
127
+ ---
128
+
129
+ ## Component Reference
130
+
131
+ All components import from `tessera-learn`. Nothing is loaded automatically; import only what you use.
132
+
133
+ ### Callout
134
+
135
+ Styled box for highlighting information.
136
+
137
+ | Prop | Type | Default |
138
+ |------|------|---------|
139
+ | `type` | `"info" \| "warning" \| "tip" \| "important"` | `"info"` |
140
+
141
+ Children become the body. A11y: `role="note"` with type-appropriate `aria-label`.
142
+
143
+ ```svelte
144
+ <Callout type="warning"><p>Be careful.</p></Callout>
145
+ ```
146
+
147
+ ### Image
148
+
149
+ Lazy-loaded image with optional caption. Renders as `<figure>`/`<figcaption>`.
150
+
151
+ | Prop | Type | Description |
152
+ |------|------|-------------|
153
+ | `src` | `string` | Image URL. `$assets/` prefix supported |
154
+ | `alt` | `string` | **Required.** Alt text |
155
+ | `caption` | `string` | Optional caption |
156
+
157
+ ```svelte
158
+ <Image src="$assets/diagram.png" alt="System architecture diagram" caption="Figure 1" />
159
+ ```
160
+
161
+ ### Accordion / AccordionItem
162
+
163
+ Expandable panels. Only one open at a time. A11y: `aria-expanded`, `aria-controls`, `role="region"`, keyboard Enter/Space.
164
+
165
+ ```svelte
166
+ <Accordion>
167
+ <AccordionItem title="What is Tessera?">
168
+ <p>An LMS tracking runtime for interactive learning content.</p>
169
+ </AccordionItem>
170
+ <AccordionItem title="How do I start?">
171
+ <p>Add pages in <code>pages/</code> and import components.</p>
172
+ </AccordionItem>
173
+ </Accordion>
174
+ ```
175
+
176
+ ### Carousel / CarouselSlide
177
+
178
+ Slide-based viewer. A11y: `role="region"`, `aria-roledescription="carousel"`, arrow keys, mobile swipe.
179
+
180
+ ```svelte
181
+ <Carousel>
182
+ <CarouselSlide><h3>Step 1</h3><p>Plan.</p></CarouselSlide>
183
+ <CarouselSlide><h3>Step 2</h3><p>Build.</p></CarouselSlide>
184
+ <CarouselSlide><h3>Step 3</h3><p>Deploy.</p></CarouselSlide>
185
+ </Carousel>
186
+ ```
187
+
188
+ ### RevealModal
189
+
190
+ Modal triggered by user interaction. Uses Svelte 5 snippets for `trigger` and `content`. A11y: `role="dialog"`, `aria-modal="true"`, focus trap, Escape to close.
191
+
192
+ | Prop | Type | Description |
193
+ |------|------|-------------|
194
+ | `title` | `string` | Modal label for screen readers |
195
+ | `trigger` | `snippet` | Click target that opens the modal |
196
+ | `content` | `snippet` | Modal body |
197
+
198
+ ```svelte
199
+ <RevealModal title="Details">
200
+ {#snippet trigger()}<button>More info</button>{/snippet}
201
+ {#snippet content()}
202
+ <h3>Additional Information</h3>
203
+ <p>Press Escape or click outside to close.</p>
204
+ {/snippet}
205
+ </RevealModal>
206
+ ```
207
+
208
+ ### Video
209
+
210
+ YouTube/Vimeo iframe (auto-detected, responsive 16:9) or native `<video>` for direct files. Lazy-loads on scroll.
211
+
212
+ | Prop | Type | Description |
213
+ |------|------|-------------|
214
+ | `src` | `string` | Video URL or `$assets/` path |
215
+ | `title` | `string` | Accessible label |
216
+
217
+ ```svelte
218
+ <Video src="https://www.youtube.com/watch?v=dQw4w9WgXcQ" title="Intro" />
219
+ <Video src="$assets/demo.mp4" title="Demo" />
220
+ ```
221
+
222
+ ### Audio
223
+
224
+ Native player. A11y: `aria-label` from title.
225
+
226
+ ```svelte
227
+ <Audio src="$assets/lecture-01.mp3" title="Lecture 1" />
228
+ ```
229
+
230
+ ---
231
+
232
+ ## Quizzes
233
+
234
+ A quiz page is a normal page with `pageConfig.quiz` set. The runtime wraps the page in the resolved quiz shell (built-in `<Quiz>` by default; a project-supplied `quiz.svelte` if one exists at the project root) — page authors no longer need their own `<Quiz>` wrapper. Drop question components directly at the page root.
235
+
236
+ ### Setup
237
+
238
+ ```svelte
239
+ <script module>
240
+ export const pageConfig = {
241
+ title: "Module 1 Quiz",
242
+ quiz: { graded: true, maxAttempts: 3 },
243
+ };
244
+ </script>
245
+
246
+ <script>
247
+ import { MultipleChoice } from 'tessera-learn';
248
+ </script>
249
+
250
+ <MultipleChoice
251
+ question="Which planet is closest to the Sun?"
252
+ options={["Venus", "Mercury", "Earth", "Mars"]}
253
+ correct={1}
254
+ />
255
+ ```
256
+
257
+ ### Data contract — what the LMS sees
258
+
259
+ Whatever quiz UI you build, the LMS sees the same `cmi.interactions` it would from the built-in: every question registered through `useQuestion` flows through `useQuiz().submit()` → `tessera-quiz-complete` → the persistence adapter. Bypass the hook and the quiz reports nothing.
260
+
261
+ ### `pageConfig.quiz` fields
262
+
263
+ | Field | Type | Default | Description |
264
+ |-------|------|---------|-------------|
265
+ | `graded` | `boolean` | `false` | Whether the score counts toward course success |
266
+ | `gatesProgress` | `boolean` | `false` | Whether passing is required to access the next page |
267
+ | `maxAttempts` | `number` | `Infinity` | Max attempts |
268
+ | `showFeedback` | `boolean` | `true` | Master gate. When `false`, feedback never renders regardless of `feedbackMode`. |
269
+ | `feedbackMode` | `"review" \| "immediate" \| (qIndex, attempt) => boolean` | `"review"` | When feedback renders (only consulted if `showFeedback` is true). `"immediate"` shows feedback after each answer; `"review"` after submit. Predicates have full control. |
270
+ | `retryMode` | `"full" \| "incorrect-only" \| (results) => Set<number>` | `"full"` | Enum sugar or a predicate that returns the set of question indices to lock as "already correct" on retry. |
271
+ | `canSubmit` | `(answered, total) => boolean` | all-answered | Custom Submit gate. Default requires every question to have an answer. |
272
+ | `score` | `(results) => number` | weighted-correct % | Returns 0–100. Default: `Σ(weight × correct) / Σ(weight) × 100`. With every weight = 1 (the default), this matches the unweighted mean. |
273
+
274
+ Enum sugar and predicate forms are equally first-class; pick whichever fits the course. `gatesProgress: true` blocks navigation to the next page until the learner passes. Works in both `free` and `sequential` navigation modes.
275
+
276
+ ### Per-question weighting
277
+
278
+ Pass `weight` to `useQuestion` (and through built-in widget props) to change how much a question pulls on the page-level score. Defaults to 1.
279
+
280
+ ```svelte
281
+ <MultipleChoice id="q-easy" weight={1} ... />
282
+ <MultipleChoice id="q-hard" weight={3} ... />
283
+ ```
284
+
285
+ Weights apply identically inside a `<Quiz>` and to standalone questions on a plain page. Both paths roll up using `Σ(weight × score) / Σ(weight)` — the same widget answered the same way produces the same page score whether it's wrapped in a quiz or scattered across the page. Non-positive weights are treated as 1.
286
+
287
+ The LMS still sees each question as a single pass/fail interaction — weights only affect the page-level `cmi.core.score.raw` rollup, not `cmi.interactions.*`.
288
+
289
+ ### Question types
290
+
291
+ #### MultipleChoice
292
+
293
+ | Prop | Type | Description |
294
+ |------|------|-------------|
295
+ | `question` | `string` | Prompt |
296
+ | `options` | `string[]` | Answer options |
297
+ | `correct` | `number` | Index of correct option (0-based) |
298
+ | `correctFeedback` | `string` | Optional |
299
+ | `incorrectFeedback` | `string` | Optional |
300
+ | `optionFeedback` | `string[]` | Optional per-option feedback |
301
+ | `weight` | `number` | Page-level rollup weight (default `1`). See [Per-question weighting](#per-question-weighting). |
302
+
303
+ ```svelte
304
+ <MultipleChoice
305
+ question="What is the capital of France?"
306
+ options={["London", "Berlin", "Paris", "Madrid"]}
307
+ correct={2}
308
+ />
309
+ ```
310
+
311
+ #### FillInTheBlank
312
+
313
+ | Prop | Type | Default | Description |
314
+ |------|------|---------|-------------|
315
+ | `question` | `string` | — | Prompt |
316
+ | `answers` | `string[]` | — | Acceptable answers |
317
+ | `caseSensitive` | `boolean` | `false` | Comparison casing |
318
+ | `weight` | `number` | `1` | Page-level rollup weight |
319
+
320
+ `answers` only needs distinct spellings; `caseSensitive: false` already handles case variants.
321
+
322
+ ```svelte
323
+ <FillInTheBlank
324
+ question="What element has the symbol 'O'?"
325
+ answers={["Oxygen"]}
326
+ />
327
+ ```
328
+
329
+ #### Matching
330
+
331
+ | Prop | Type | Description |
332
+ |------|------|-------------|
333
+ | `question` | `string` | Prompt |
334
+ | `pairs` | `{left: string, right: string}[]` | Correct pairs |
335
+ | `weight` | `number` | Page-level rollup weight (default `1`) |
336
+
337
+ The right column is auto-shuffled. Click left then right to match (tap on mobile). Click a matched pair to unmatch. All pairs must be correct.
338
+
339
+ ```svelte
340
+ <Matching
341
+ question="Match country to capital:"
342
+ pairs={[
343
+ { left: "France", right: "Paris" },
344
+ { left: "Germany", right: "Berlin" },
345
+ { left: "Japan", right: "Tokyo" },
346
+ ]}
347
+ />
348
+ ```
349
+
350
+ #### Sorting
351
+
352
+ Drag-and-drop (or click-to-place) into labelled categories.
353
+
354
+ | Prop | Type | Description |
355
+ |------|------|-------------|
356
+ | `question` | `string` | Prompt |
357
+ | `items` | `string[]` | Items to sort |
358
+ | `targets` | `string[]` | Category labels |
359
+ | `correct` | `number[]` | For each item, the index of its correct target (parallel array) |
360
+ | `weight` | `number` | Page-level rollup weight (default `1`) |
361
+
362
+ ```svelte
363
+ <Sorting
364
+ question="Sort each animal:"
365
+ items={["Dog", "Eagle", "Salmon", "Cat", "Robin", "Trout"]}
366
+ targets={["Mammals", "Birds", "Fish"]}
367
+ correct={[0, 1, 2, 0, 1, 2]}
368
+ />
369
+ ```
370
+
371
+ ### Standalone questions
372
+
373
+ All four question components also work outside `<Quiz>` for inline practice. Standalone widgets render their own Check / Retry buttons.
374
+
375
+ | Prop | Type | Default | Description |
376
+ |------|------|---------|-------------|
377
+ | `maxRetries` | `number` | `Infinity` | Max retries for standalone widgets |
378
+ | `weight` | `number` | `1` | Per-question weight for page-level rollup |
379
+
380
+ ```svelte
381
+ <MultipleChoice
382
+ question="What color is the sky on a clear day?"
383
+ options={["Red", "Blue", "Green"]}
384
+ correct={1}
385
+ maxRetries={2}
386
+ />
387
+ ```
388
+
389
+ Standalone questions are not graded by default. To grade one (e.g., a required reflection that affects course success), build it with the `useQuestion` hook directly — see [Recipe 5](#recipe-5-graded-standalone-question).
390
+
391
+ ---
392
+
393
+ ## Assets
394
+
395
+ Drop files into `assets/`. Reference them with `$assets/` in component props:
396
+
397
+ ```svelte
398
+ <Image src="$assets/photo.png" alt="Photo" />
399
+ <Video src="$assets/demo.mp4" title="Demo" />
400
+ <Audio src="$assets/lecture.mp3" title="Lecture" />
401
+ ```
402
+
403
+ In CSS, use a relative path from `styles/`:
404
+
405
+ ```css
406
+ .bg { background-image: url('../assets/bg.png'); }
407
+ ```
408
+
409
+ External URLs work too: `<Image src="https://example.com/img.jpg" alt="..." />`.
410
+
411
+ At build time the plugin copies `assets/` into `dist/assets/` so `$assets/foo.png` resolves the same way in the shipped bundle as it does in the dev server.
412
+
413
+ ---
414
+
415
+ ## Styling
416
+
417
+ Add `.css` files to `styles/`. They load after framework styles and override them.
418
+
419
+ ### CSS custom properties
420
+
421
+ Override these to theme globally:
422
+
423
+ | Property | Default |
424
+ |----------|---------|
425
+ | `--tessera-primary` | `#2563eb` |
426
+ | `--tessera-primary-light` | `#dbeafe` |
427
+ | `--tessera-primary-dark` | `#1e40af` |
428
+ | `--tessera-text` | `#1f2937` |
429
+ | `--tessera-text-light` | `#6b7280` |
430
+ | `--tessera-bg` | `#ffffff` |
431
+ | `--tessera-bg-secondary` | `#f9fafb` |
432
+ | `--tessera-border` | `#e5e7eb` |
433
+ | `--tessera-success` | `#16a34a` |
434
+ | `--tessera-error` | `#dc2626` |
435
+ | `--tessera-warning` | `#d97706` |
436
+ | `--tessera-font-family` | `'Inter', system-ui, sans-serif` |
437
+ | `--tessera-font-size-base` | `1rem` |
438
+ | `--tessera-line-height` | `1.6` |
439
+ | `--tessera-spacing-sm` / `-md` / `-lg` / `-xl` | `0.5rem` / `1rem` / `1.5rem` / `2rem` |
440
+ | `--tessera-sidebar-width` | `280px` |
441
+ | `--tessera-content-max-width` | `800px` |
442
+
443
+ ```css
444
+ :root {
445
+ --tessera-primary: #9333ea;
446
+ --tessera-font-family: 'Georgia', serif;
447
+ }
448
+ ```
449
+
450
+ `branding.primaryColor` and `branding.fontFamily` in `course.config.js` cover the common overrides without writing CSS.
451
+
452
+ ---
453
+
454
+ ## `course.config.js`
455
+
456
+ ```js
457
+ export default {
458
+ // Metadata
459
+ title: "My Course", // required
460
+ description: "",
461
+ author: "",
462
+ version: "1.0.0",
463
+
464
+ branding: {
465
+ logo: "", // e.g., "$assets/logo.png"
466
+ primaryColor: "#2563eb",
467
+ fontFamily: "Inter, sans-serif",
468
+ },
469
+
470
+ navigation: {
471
+ mode: "free", // "free" or "sequential"
472
+ },
473
+
474
+ completion: {
475
+ mode: "percentage", // "percentage" or "quiz"
476
+ percentageThreshold: 100, // 0–100 (percentage mode)
477
+ },
478
+
479
+ scoring: {
480
+ passingScore: 70,
481
+ },
482
+
483
+ export: {
484
+ standard: "web", // "web" | "scorm12" | "scorm2004" | "cmi5"
485
+ },
486
+ };
487
+ ```
488
+
489
+ - `navigation.mode: "free"` → all pages accessible except those blocked by gating quizzes.
490
+ - `navigation.mode: "sequential"` → pages unlock one at a time as each is completed.
491
+ - `completion.mode: "percentage"` → course completes when `visitedPages / totalPages * 100 >= percentageThreshold`.
492
+ - `completion.mode: "quiz"` → course completes when graded quiz average >= `scoring.passingScore`.
493
+
494
+ ### Minimum config
495
+
496
+ Every field except `title` has a default. The build merges yours over:
497
+
498
+ ```js
499
+ // effective defaults
500
+ {
501
+ title: "Untitled Course",
502
+ navigation: { mode: "free" },
503
+ completion: { mode: "percentage", percentageThreshold: 100 },
504
+ scoring: { passingScore: 70 },
505
+ export: { standard: "web" },
506
+ }
507
+ ```
508
+
509
+ So `export default { title: "My Course" }` is a complete config — free navigation, full-percentage completion, web export.
510
+
511
+ ### Custom access rules
512
+
513
+ For anything beyond the two presets (prereqs, instructor approval, time gating), supply `navigation.canAccess`. It runs synchronously on every navigation evaluation — keep it cheap.
514
+
515
+ ```js
516
+ import { sequentialAccess } from 'tessera-learn';
517
+
518
+ export default {
519
+ // ...
520
+ navigation: {
521
+ mode: 'sequential',
522
+ canAccess: (ctx) => {
523
+ if (!sequentialAccess(ctx)) return false;
524
+ if (ctx.page.slug === 'lesson-5') {
525
+ const i = ctx.manifest.pages.findIndex(p => p.slug === 'lesson-2-quiz');
526
+ return (ctx.progress.quizScores.get(i) ?? 0) >= ctx.config.scoring.passingScore;
527
+ }
528
+ return true;
529
+ },
530
+ },
531
+ };
532
+ ```
533
+
534
+ `AccessContext` exposes `pageIndex`, `page`, `manifest`, `progress`, and `config`. The presets `freeAccess` and `sequentialAccess` are re-exported from `tessera-learn` for composition. `resolveAccess(config)` is also exported — it returns the predicate the runtime would use (custom `canAccess` if set, otherwise the matching preset). Useful when you want to wrap rather than replace.
535
+
536
+ ### Build output
537
+
538
+ `npm run export` (which wraps `vite build`) writes:
539
+
540
+ | `export.standard` | What ships | Where |
541
+ |-------------------|------------|-------|
542
+ | `web` | Static site (HTML/CSS/JS + `assets/`) | `dist/` — host on any static file server |
543
+ | `scorm12` | SCORM 1.2 package | `dist/<course>-scorm12.zip` |
544
+ | `scorm2004` | SCORM 2004 4th Edition package | `dist/<course>-scorm2004.zip` |
545
+ | `cmi5` | cmi5 package (AU + manifest) | `dist/<course>-cmi5.zip` |
546
+
547
+ For LMS exports, upload the zip via your LMS's import flow. For web export, the bundle is a self-contained static site — drop `dist/` on Netlify, GitHub Pages, S3, or any static host.
548
+
549
+ ### Validation
550
+
551
+ The Vite plugin runs project validation on every dev start and build (manifest shape, `pageConfig` parseability, asset references, etc.). Errors abort the build and print as `[tessera error] ...`; warnings print as `[tessera warning] ...` and don't block. The npm scripts in a scaffolded project are `npm run preview` (wraps `vite dev` — local dev server with HMR) and `npm run export` (wraps `vite build` — full validation + bundle + adapter packaging). Names diverge from Vite's defaults because they describe the authoring intent ("preview the course", "export for an LMS") rather than the underlying tool.
552
+
553
+ ---
554
+
555
+ ## Hooks Reference
556
+
557
+ Five hooks plus one helper make up the stable contract between widgets and the runtime.
558
+
559
+ ```js
560
+ import {
561
+ useQuestion,
562
+ useQuiz,
563
+ useNavigation,
564
+ useProgress,
565
+ usePersistence,
566
+ isCorrect,
567
+ } from 'tessera-learn';
568
+ import type { Interaction } from 'tessera-learn';
569
+ ```
570
+
571
+ Each hook is synchronous and must be called during component setup, inside a Tessera course. Calling them outside the runtime throws.
572
+
573
+ ### `useQuestion`
574
+
575
+ Register a question widget so the runtime can submit, score, persist, and report it.
576
+
577
+ - **Inside `<Quiz>`** — the parent Quiz drives submission. The widget renders the prompt + answer UI; nothing else.
578
+ - **Standalone** — the widget owns its own Check/Retry. Set `graded: true` to count toward course success.
579
+
580
+ ```ts
581
+ function useQuestion(opts: {
582
+ id: string; // unique on the page; LMS interaction id
583
+ graded?: boolean; // standalone only
584
+ response: () => Interaction; // current learner answer; called on submit
585
+ score?: () => number; // standalone-only override (0–100)
586
+ weight?: number; // page-level rollup weight (default 1)
587
+ reset?: () => void;
588
+ }): {
589
+ submit(): void;
590
+ reset(): void;
591
+ readonly submitted: boolean;
592
+ readonly correct: boolean | null;
593
+ readonly mode: 'standalone' | 'quiz';
594
+ readonly quizIndex: number | undefined;
595
+ };
596
+ ```
597
+
598
+ `Interaction` follows SCORM 2004 4th Edition vocabulary verbatim: `choice`, `true-false`, `fill-in`, `long-fill-in`, `matching`, `sequencing`, `numeric`, `likert`, `performance`, `other`. Each is `{ type, response, correct? }`. Omit `correct` if the runtime should not auto-judge — `useQuestion` reports a `null` correctness flag and your widget renders its own UI.
599
+
600
+ ```svelte
601
+ <script>
602
+ import { useQuestion } from 'tessera-learn';
603
+
604
+ let order = $state(['Mercury', 'Venus', 'Earth', 'Mars']);
605
+
606
+ const q = useQuestion({
607
+ id: 'planet-rank',
608
+ response: () => ({
609
+ type: 'sequencing',
610
+ response: order,
611
+ correct: ['Mercury', 'Venus', 'Earth', 'Mars'],
612
+ }),
613
+ reset: () => { order = ['Mercury', 'Venus', 'Earth', 'Mars']; },
614
+ });
615
+ </script>
616
+
617
+ <!-- drag-to-reorder UI bound to `order` -->
618
+ {#if q.mode === 'standalone'}
619
+ <button onclick={() => q.submit()} disabled={q.submitted}>Check</button>
620
+ {/if}
621
+ ```
622
+
623
+ ### `useQuiz`
624
+
625
+ Quiz orchestration hook used by both the built-in `<Quiz>` and any project-supplied `quiz.svelte`. A custom shell calls `useQuiz` to drive submission/retry/review; **`submit()` is the only sanctioned dispatcher of `tessera-quiz-complete`** — bypassing it means the quiz reports nothing to the LMS.
626
+
627
+ ```ts
628
+ function useQuiz(opts: { element: () => HTMLElement | null }): {
629
+ registerQuestion(api: {
630
+ id: string;
631
+ weight?: number;
632
+ checkAnswer: () => boolean;
633
+ reset?: () => void;
634
+ interaction: () => Interaction;
635
+ }): number;
636
+ setRender(index: number, render: unknown): void;
637
+ setAnswer(index: number, answer: unknown): void;
638
+ submit(): void; // dispatches tessera-quiz-complete; runtime forwards interactions to the adapter
639
+ retry(): void;
640
+ startReview(): void;
641
+ exitReview(): void;
642
+ revealFeedback(index: number): void; // immediate-feedback flow
643
+ getAnswer(index: number): unknown;
644
+ getRender(index: number): unknown;
645
+ feedbackVisible(index: number): boolean;
646
+ isLockedCorrect(index: number): boolean;
647
+ readonly questions: ReadonlyArray<{ id: string; submitted: boolean; correct: boolean | null }>;
648
+ readonly state: 'answering' | 'submitted' | 'reviewing';
649
+ readonly score: number;
650
+ readonly attemptCount: number;
651
+ readonly canSubmit: boolean;
652
+ readonly canRetry: boolean;
653
+ };
654
+ ```
655
+
656
+ Throws when called on a page without `pageConfig.quiz`. Three telemetry-only DOM events also fire — `tessera-quiz-question-answered`, `tessera-quiz-before-submit`, `tessera-quiz-retry` — none of them write to the adapter.
657
+
658
+ ### `useNavigation`
659
+
660
+ ```ts
661
+ function useNavigation(): {
662
+ readonly currentPage: ManifestPage;
663
+ readonly currentPageIndex: number;
664
+ readonly pages: ManifestPage[];
665
+ goTo(slug: string): void;
666
+ goToIndex(index: number): void;
667
+ next(): void;
668
+ prev(): void;
669
+ readonly canGoNext: boolean;
670
+ readonly canGoPrev: boolean;
671
+ canAccess(slug: string): boolean;
672
+ };
673
+ ```
674
+
675
+ ### `useProgress`
676
+
677
+ ```ts
678
+ function useProgress(): {
679
+ readonly visitedPages: Set<number>;
680
+ readonly quizScores: Map<number, number>; // pageIndex → score 0–100
681
+ readonly chunkProgress: Map<number, number>; // pageIndex → highest revealed chunk index
682
+ readonly completionStatus: 'incomplete' | 'complete';
683
+ readonly successStatus: 'unknown' | 'passed' | 'failed';
684
+ markVisited(pageIndex: number): void;
685
+ markChunk(pageIndex: number, chunkIndex: number): void;
686
+ };
687
+ ```
688
+
689
+ ### `usePersistence<T>(key)`
690
+
691
+ Per-widget persistent state. Survives reload on every adapter — `localStorage` for web, SCORM `cmi.suspend_data` for SCORM 1.2/2004, xAPI State API for cmi5. Reads sync; writes batched by the adapter. JSON-serializable values only.
692
+
693
+ ```ts
694
+ function usePersistence<T>(key: string): {
695
+ get(): T | null;
696
+ set(value: T): void;
697
+ };
698
+ ```
699
+
700
+ ```svelte
701
+ <script>
702
+ import { usePersistence } from 'tessera-learn';
703
+
704
+ const store = usePersistence('whiteboard');
705
+ let state = $state(store.get() ?? { strokes: [] });
706
+ $effect(() => store.set(state));
707
+ </script>
708
+ ```
709
+
710
+ ### `isCorrect(interaction)`
711
+
712
+ Pure helper. Returns `true`, `false`, or `null` (when the interaction has no `correct` field).
713
+
714
+ ```ts
715
+ function isCorrect(i: Interaction): boolean | null;
716
+ ```
717
+
718
+ ---
719
+
720
+ ## Custom xAPI statements
721
+
722
+ The lifecycle stream (Initialized / Completed / Passed / Failed / Terminated under cmi5; `cmi.*` writes under SCORM) is sent automatically — see [LMS Adapter Reference](#lms-adapter-reference). To emit your own xAPI verbs, use `useXAPI()`:
723
+
724
+ ```ts
725
+ import { useXAPI } from 'tessera-learn';
726
+
727
+ const xapi = useXAPI(); // XAPIClient | null
728
+ xapi?.sendStatement({
729
+ verb: { id: 'http://adlnet.gov/expapi/verbs/experienced' },
730
+ object: { id: `${xapi.getActivityId()}#diagram-1` },
731
+ });
732
+ ```
733
+
734
+ `useXAPI()` is a plain function (not a Svelte context hook), callable from anywhere — component setup, event handlers, async callbacks, plain `.ts` modules. Returns `null` when no LRS is configured or before adapter init resolves; null-check and degrade gracefully.
735
+
736
+ The publisher fills in `actor`, `timestamp`, `id` (UUID), `context.contextActivities.grouping`, `context.registration` (cmi5), and the `sessionid` extension (cmi5). You supply `verb`, `object` (defaults to the activity), and optionally `result`, `context`, `attachments`.
737
+
738
+ ### Configure the destination — `course.config.js`
739
+
740
+ `config.xapi` is one destination, or an array of them. The destination is always declared explicitly — there is no implicit default.
741
+
742
+ ```js
743
+ xapi: {
744
+ endpoint: 'https://lrs.example.com/xapi/',
745
+ auth: () => fetch('/api/lrs-token').then(r => r.text()),
746
+ actor: () => getCurrentUser(), // or a static Agent object
747
+ activityId: 'https://example.com/courses/intro-to-x',
748
+ }
749
+
750
+ // cmi5 only — inherit the LMS launch LRS (endpoint+auth+actor+activityId+registration):
751
+ xapi: { endpoint: 'lms' }
752
+
753
+ // Fan out (at most one 'lms' entry):
754
+ xapi: [
755
+ { endpoint: 'lms' },
756
+ { endpoint: 'https://analytics.example.com/xapi/', auth, actor, activityId },
757
+ ]
758
+ ```
759
+
760
+ Each destination has its own queue, auth resolver, and retry loop. One UUID is minted per `sendStatement` and reused across destinations, so all LRSes see the same statement id (idempotent dedupe works).
761
+
762
+ ### Per-mode behaviour
763
+
764
+ | Mode | `xapi` not set | `xapi.endpoint: 'lms'` | `xapi: {endpoint, ...}` (explicit) |
765
+ |------|---------------|------------------------|-----------------------------------|
766
+ | **cmi5** | `useXAPI()` → null | Inherits launch LRS; shares queue with lifecycle stream | Independent publisher; `actor` defaults to launch actor |
767
+ | **scorm12** | `useXAPI()` → null | **Config error** | Independent publisher; `actor` derived from `cmi.core.student_id` |
768
+ | **scorm2004** | `useXAPI()` → null | **Config error** | Independent publisher; `actor` derived from `cmi.learner_id` |
769
+ | **web** | `useXAPI()` → null | **Config error** | Independent publisher; `actor` **required** in config |
770
+
771
+ ### Actor resolution
772
+
773
+ Priority order (top wins):
774
+
775
+ 1. **Author-supplied `xapi.actor`** — always wins.
776
+ 2. **cmi5 launch actor** — under cmi5, the publisher uses the same Agent the LMS handed us at launch.
777
+ 3. **SCORM-derived actor** — under scorm12/scorm2004, the publisher synthesizes:
778
+ ```ts
779
+ {
780
+ account: {
781
+ homePage: xapi.actorAccountHomePage ?? originOf(xapi.activityId),
782
+ name: <cmi.core.student_id | cmi.learner_id>,
783
+ },
784
+ name: <cmi.core.student_name | cmi.learner_name>,
785
+ objectType: 'Agent',
786
+ }
787
+ ```
788
+ The `account` IFI satisfies xAPI's Identified Agent rule. `homePage` defaults to the activityId origin; override via `actorAccountHomePage` if your authority namespace is elsewhere. Required if `activityId` is a non-URL IRI.
789
+ 4. **Fallback: error.** Web export with no `actor` fails at config time.
790
+
791
+ Mid-session identity change (e.g., learner logs in/out without reloading) is **not supported in v1** — actor is resolved once per page-load and cached. Reload the runtime on identity change.
792
+
793
+ ### Auth
794
+
795
+ v1 supports **Basic auth only**. The publisher prepends `Basic ` to whatever your `auth` value resolves to — pass the credential value, not the full header.
796
+
797
+ For OAuth-protected LRSes, wrap the token exchange in your `auth` function and return a Basic credential the LRS accepts (or run a thin proxy that converts).
798
+
799
+ The function form is re-invoked once on a 401 to cover short-lived tokens that have just expired. Two consecutive 401s mark the auth resolver dead for the publisher's lifetime — every subsequent send fails fast without hitting the LRS. Reload the runtime to retry.
800
+
801
+ **Static-string `auth` ships in your bundle** — fine for demos, never for production. Use a function that fetches a server-brokered short-lived token instead.
802
+
803
+ ### Retry policy
804
+
805
+ - **Default:** 3 attempts with exponential backoff (100ms, 200ms, 400ms).
806
+ - **5xx / network errors** retry. **4xx** short-circuits — retrying won't help.
807
+ - **HTTP 409 Conflict** is treated as **success** (xAPI rejects POSTs with a duplicate statement id, so a 409 on retry means the LRS already accepted the statement).
808
+ - **Per-statement opt-out:** `sendStatement(stmt, { retry: false })` for fire-and-forget telemetry where the author would rather drop than block.
809
+
810
+ ### `sendStatement` return shape
811
+
812
+ ```ts
813
+ const result = await xapi.sendStatement({ verb, object });
814
+ // result: {
815
+ // statementId: string,
816
+ // statement: Statement, // fully resolved: actor, context, timestamp filled in
817
+ // destinations: [{ endpoint, ok, status?, error? }, ...]
818
+ // }
819
+ ```
820
+
821
+ `destinations[]` lets you act on partial failures under fan-out — one LRS can be down without affecting the others.
822
+
823
+ ### Validation
824
+
825
+ The publisher checks three things before sending:
826
+
827
+ 1. `verb.id` — present, non-empty string.
828
+ 2. `object.id` — non-empty string when `object` is supplied.
829
+ 3. `result.score.scaled` — number in `[-1, 1]` when supplied.
830
+
831
+ Everything else passes through. The LRS gives clearer errors for IRI / extension / attachment shape issues than we can; failures surface via `destinations[].error`.
832
+
833
+ ### Mode-specific caveats
834
+
835
+ **SCORM (1.2 / 2004).** Actor is auto-derived from the LMS data model; supply `actor` explicitly to use a different IFI (`mbox`, `openid`). **CORS** is the painful one — the LRS must allow the LMS-served origin, and many don't by default. cmi5's `sessionid` extension does not exist here; attach your own extension if you need to group statements by session. In dev (WebAdapter fallback), an explicit `xapi` destination with no author actor cannot synthesize an Agent — `sendStatement` rejects with an explicit error.
836
+
837
+ **Web.** The bundle is public, so static `auth: 'Basic abc123'` leaks. Always use a function that fetches a server-brokered short-lived token. CORS matters for the token endpoint too. Three actor patterns: hardcoded anonymous, author-wired (`actor: () => getCurrentUser()`), or query-string `?actor=...` mirroring cmi5.
838
+
839
+ **cmi5 with `endpoint: 'lms'`.** Author and adapter share one publisher instance and one queue, so ordering is preserved (no race between an author's `experienced` and the adapter's `Completed`). Running locally without launch params, `sendStatement` rejects with a missing-params error — no silent fallback. Point `endpoint` at a local LRS for dev.
840
+
841
+ **Page unload.** Once unload begins, every publisher is marked unloading and `useXAPI()?.sendStatement(...)` calls reject — required to keep cmi5 Terminated last on the wire (§9.3.6). Record-at-the-end work belongs in a child component's `onDestroy`, not `beforeunload`.
842
+
843
+ ### Non-goals (v1)
844
+
845
+ - Bearer / OAuth credentials at the publisher level (wrap in your `auth` function).
846
+ - Statement signing / attachments helpers (the publisher accepts attachments but doesn't help build them).
847
+ - Offline queue / IndexedDB durability.
848
+ - LRS State API access for non-cmi5 modes.
849
+ - Voiding statements.
850
+ - Mid-session actor refresh (`refreshActor()`).
851
+ - Group actors (Agent only).
852
+
853
+ ---
854
+
855
+ ## LMS Adapter Reference
856
+
857
+ The runtime translates author intent — page visits, quiz scores, completion, persistence — into a fixed set of adapter calls. Each export standard maps those calls onto a different LMS contract. This section is the source-of-truth view of what the LMS sees for any given runtime event.
858
+
859
+ ### Cross-mode rollup
860
+
861
+ | Runtime event | SCORM 1.2 | SCORM 2004 4th | cmi5 |
862
+ |---------------|-----------|----------------|------|
863
+ | Session start | `LMSInitialize("")`; read `cmi.suspend_data` and `cmi.interactions._count` | `Initialize("")`; read `cmi.suspend_data` and `cmi.interactions._count` | `POST` cmi5 `fetch` URL → token; build publisher; `GET` State API; send **Initialized** statement |
864
+ | State persisted (page visited, bookmark moved, chunk revealed, `usePersistence` write, etc.) | `LMSSetValue("cmi.suspend_data", json)` (microtask-coalesced) | `SetValue("cmi.suspend_data", json)` (microtask-coalesced) | State API `PUT` `tessera-state` document, chained on the publisher queue |
865
+ | Graded quiz scored | `LMSSetValue("cmi.core.score.raw"\|"min"\|"max", …)` then `LMSSetValue("cmi.core.lesson_status", "passed"\|"failed")` | `SetValue("cmi.score.raw"\|"min"\|"max"\|"scaled", …)` then `SetValue("cmi.success_status", "passed"\|"failed")` | **Passed** or **Failed** statement, with `result.score.scaled` and `result.duration` (one-shot per session) |
866
+ | Course completion changes | Funneled into `cmi.core.lesson_status` (only one field exists) | `SetValue("cmi.completion_status", "completed"\|"incomplete")` | **Completed** statement with `result.completion = true`, `result.duration`, `result.score?` (one-shot per session) |
867
+ | Question answered (graded or standalone, inside or outside a quiz) | `cmi.interactions.{n}.id` / `student_response` / `result` / `time` / `type` (n continues from prior `_count`) | `cmi.interactions.{n}.id` / `learner_response` / `result` / `timestamp` / `type` (n continues from prior `_count`) | **Answered** statement; object `${activityId}#${questionId}`, definition `cmi.interaction` + `interactionType`, `result.response`, `result.success` |
868
+ | Resume after reload | Read `cmi.suspend_data` on init; manifest is rebuilt from code, not LMS | Read `cmi.suspend_data` on init | State API `GET` `tessera-state`; lifecycle replays from where the prior session left off |
869
+ | Author exit / unload | `LMSSetValue("cmi.core.exit", "suspend"\|"")`, `LMSCommit("")`, `LMSFinish("")` (queue drained synchronously) | `SetValue("cmi.exit", "suspend"\|"normal"\|...)`, `Commit("")`, `Terminate("")` (queue drained synchronously) | If course not yet **Completed**, send **Suspended**; then **Terminated** (always last on the wire — cmi5 §9.3.6) |
870
+ | Learner identity (xAPI actor synthesis) | `cmi.core.student_id` + `cmi.core.student_name` | `cmi.learner_id` + `cmi.learner_name` | Launch-supplied actor JSON (Identified Agent) |
871
+ | Persistence cap | ~4096 chars per spec; many LMSes allow more, but plan for 4 KB | 64000 chars per spec | LRS-defined (typically unbounded for State API documents) |
872
+ | Score scale exposed to LMS | `score.raw` only (0–100) | `score.raw` (0–100) **and** `score.scaled` (0–1) | `result.score.scaled` (0–1) |
873
+
874
+ `commit()` is microtask-coalesced. Multiple state mutations within one tick collapse to a single `LMSCommit` / `Commit`. cmi5 statements are individual (no batched commit).
875
+
876
+ ### SCORM 1.2 — notes
877
+
878
+ API discovery: walks `window.parent` / `window.opener` up to 10 levels looking for `API`.
879
+
880
+ **One status field.** `cmi.core.lesson_status` collapses completion and pass/fail. The runtime resolves them by priority — success (`passed` / `failed`) wins when known; otherwise completion (`completed` / `incomplete`) is written. There is no "unknown"; until a graded quiz produces a result, the LMS sees `incomplete`.
881
+
882
+ **Mastery is Tessera's, not the LMS's.** Pass/fail is computed from `scoring.passingScore`. `cmi.student_data.mastery_score` is read-only for this runtime.
883
+
884
+ **Not implemented.** No `cmi.objectives.*` writes. No SCORM 1.2 sequencing — `navigation.canAccess` is the only gating layer; the LMS sees one SCO. SCORM 1.2 `time-out` / `logout` exit values are not emitted.
885
+
886
+ **Local testing.** Upload `dist/*-scorm12.zip` to [SCORM Cloud](https://cloud.scorm.com) (free tier) or [Reload SCORM Player](https://github.com/reload/reload). Inspect the LMS API call log to confirm `lesson_status` and `cmi.interactions.*` look right.
887
+
888
+ ### SCORM 2004 4th — notes
889
+
890
+ API discovery: `API_1484_11` via the same parent/opener walk.
891
+
892
+ **Two status fields, both written.** `cmi.completion_status` and `cmi.success_status` are independent. `unknown` is written *explicitly* when no graded result exists — leaving it null causes some LMSes (notably SCORM Cloud) to roll a null up to `passed` during status rollup.
893
+
894
+ **LMS-side fields untouched.** `cmi.completion_threshold` and `cmi.scaled_passing_score` are LMS-owned; Tessera owns the threshold via `scoring.passingScore`.
895
+
896
+ **Not implemented.** `imsss:sequencing` rules are omitted from `imsmanifest.xml` by design. No `cmi.objectives.*`, no `cmi.adl.nav.*` writes.
897
+
898
+ **Local testing.** SCORM Cloud is the easiest end-to-end check. Moodle, Cornerstone, SuccessFactors, and Canvas (via Rustici Engine) accept `dist/*-scorm2004.zip` directly.
899
+
900
+ ### cmi5 — notes
901
+
902
+ **Launch contract.** The LMS opens the course URL with `endpoint`, `fetch`, `actor` (JSON-encoded Identified Agent), `activityId`, and optionally `registration`. Discovery succeeds when all four required params are present; otherwise `LMSAdapterError`.
903
+
904
+ **Token fetch is single-use** (cmi5 §6.2). On failure, reload from the LMS to retry. The token is used as a `Basic` credential, not Bearer.
905
+
906
+ **Lifecycle order.** **Initialized** → **Answered** (per question on submit) → **Completed** → **Passed** / **Failed** → **Suspended** (only if not Completed) → **Terminated** (always last, cmi5 §9.3.6). Completed / Passed / Failed are one-shot per session — once dispatched, the corresponding setter no-ops. A reloaded session may re-dispatch them, which is intended: each session sends its lifecycle exactly once.
907
+
908
+ **Required result fields.** Completed: `completion: true`, `duration`. Passed/Failed: `success`, `duration`. Terminated: `duration` (§9.5.4.1). All include `result.score.scaled` when a score is known.
909
+
910
+ **Sessionid extension.** `cmi5Mode` injects the spec-required `sessionid` context extension on every statement.
911
+
912
+ **State persistence.** `tessera-state` document via the State API, keyed by `activityId` + `agent` + `registration?` + `stateId='tessera-state'`. Writes chain onto the publisher's queue so the suspend payload lands before Terminated.
913
+
914
+ **Not implemented.** No multi-AU courses (one course = one AU in v1). No **Waived** or **Abandoned** verbs. No mid-session actor refresh. No `MoveOn` criterion in `cmi5.xml` — completion is decided runtime-side; the LMS evaluates MoveOn against the verbs the runtime *does* emit.
915
+
916
+ **Local testing.** Upload `dist/*-cmi5.zip` to SCORM Cloud and use the cmi5 dispatch URL it generates — closest free equivalent to a real LMS launch.
917
+
918
+ ### Common adapter behaviour
919
+
920
+ **Queue + retry.** SCORM adapters serialize every `LMSSetValue` / `LMSCommit` through a sequential queue with exponential-backoff retry on transient errors. Retry warnings include the real LMS error code (`GetLastError`) — e.g. `405 Incorrect Data Type` rather than a generic "LMS call failed".
921
+
922
+ **Unload.** `terminate()` cannot run async retries — the page is going away. SCORM drains the queue synchronously (single attempt per pending op) before `Commit` + `Terminate` / `LMSFinish`. cmi5 marks the publisher unloading and uses `keepalive: true` so the browser does not cancel in-flight statements.
923
+
924
+ **Interaction encoding.** `formatResponse` / `formatCorrectPattern` follow SCORM 2004 4th RTE §4.2.7 delimiters — `[,]` items, `[.]` pairs, `[:]` ranges. SCORM 1.2 and cmi5 reuse the encoding (cmi5 embeds it in `result.response` / `definition.correctResponsesPattern`).
925
+
926
+ **Failure surface.** Anything thrown from `adapter.init()` is caught by `App.svelte` and rendered as a visible "This course can't run here" panel — never a silent degradation.
927
+
928
+ ---
929
+
930
+ ## Custom Layouts
931
+
932
+ Drop `layout.svelte` at the project root to replace the default sidebar/topbar/prev-next chrome. The runtime uses it whenever it exists.
933
+
934
+ The contract: the file receives a single `page` snippet prop and renders it where the active page should appear. Use the hooks for everything else.
935
+
936
+ ```svelte
937
+ <!-- layout.svelte -->
938
+ <script>
939
+ import { useNavigation, useProgress } from 'tessera-learn';
940
+
941
+ let { page } = $props();
942
+ const nav = useNavigation();
943
+ const progress = useProgress();
944
+ </script>
945
+
946
+ <header>
947
+ <h1>{nav.currentPage.title}</h1>
948
+ <span>{progress.visitedPages.size} / {nav.pages.length} visited</span>
949
+ </header>
950
+
951
+ <main>{@render page()}</main>
952
+
953
+ <footer>
954
+ <button disabled={!nav.canGoPrev} onclick={() => nav.prev()}>Prev</button>
955
+ <button disabled={!nav.canGoNext} onclick={() => nav.next()}>Next</button>
956
+ </footer>
957
+ ```
958
+
959
+ To keep most of the default chrome and swap one piece, import `DefaultLayout` from `tessera-learn` and compose around it.
960
+
961
+ ---
962
+
963
+ ## Cookbook
964
+
965
+ End-to-end recipes that exercise the full hooks API. Adapt to taste.
966
+
967
+ ### Recipe 1: Custom "draw a line" question
968
+
969
+ Learner connects a left-side label to a right-side label by drawing a line. Emits a `matching` interaction so the runtime scores it identically to `<Matching>`. Persists partial progress so an interrupted session resumes cleanly.
970
+
971
+ ```svelte
972
+ <!-- pages/05-pairs/01-pairs/draw-pairs.svelte -->
973
+ <script module>
974
+ export const pageConfig = { title: "Match the elements" };
975
+ </script>
976
+
977
+ <script>
978
+ import { useQuestion, usePersistence } from 'tessera-learn';
979
+
980
+ const store = usePersistence('draw-pairs:v1');
981
+ let pairs = $state(store.get() ?? []);
982
+ $effect(() => store.set(pairs));
983
+
984
+ const q = useQuestion({
985
+ id: 'draw-pairs-1',
986
+ response: () => ({
987
+ type: 'matching',
988
+ response: pairs,
989
+ correct: [['Hydrogen', 'H'], ['Helium', 'He'], ['Lithium', 'Li']],
990
+ }),
991
+ reset: () => { pairs = []; },
992
+ });
993
+
994
+ function connect(l, r) {
995
+ pairs = [...pairs.filter(([a]) => a !== l), [l, r]];
996
+ }
997
+ </script>
998
+
999
+ <svg width="400" height="200" role="img" aria-label="Drag to match elements to their symbols">
1000
+ <!-- canvas + line-drawing UI calls connect(l, r) on drop -->
1001
+ </svg>
1002
+
1003
+ {#if q.mode === 'standalone'}
1004
+ <button onclick={() => q.submit()} disabled={q.submitted}>Check</button>
1005
+ {#if q.correct === true}<p>Correct.</p>{/if}
1006
+ {#if q.correct === false}<button onclick={() => q.reset()}>Try again</button>{/if}
1007
+ {/if}
1008
+ ```
1009
+
1010
+ ### Recipe 2: Custom topbar layout
1011
+
1012
+ Replace the default sidebar with a horizontal topbar showing breadcrumb + progress %. Drop `layout.svelte` at the project root; no other changes needed.
1013
+
1014
+ ```svelte
1015
+ <!-- layout.svelte -->
1016
+ <script>
1017
+ import { useNavigation, useProgress } from 'tessera-learn';
1018
+
1019
+ let { page } = $props();
1020
+ const nav = useNavigation();
1021
+ const progress = useProgress();
1022
+
1023
+ const percent = $derived(
1024
+ Math.round((progress.visitedPages.size / nav.pages.length) * 100)
1025
+ );
1026
+ </script>
1027
+
1028
+ <header class="topbar">
1029
+ <span class="brand">My Course</span>
1030
+ <span class="crumb">{nav.currentPage.section} › {nav.currentPage.title}</span>
1031
+ <span class="progress" aria-live="polite">{percent}% complete</span>
1032
+ </header>
1033
+
1034
+ <main class="content">{@render page()}</main>
1035
+
1036
+ <nav class="footer">
1037
+ <button disabled={!nav.canGoPrev} onclick={() => nav.prev()}>← Back</button>
1038
+ <select onchange={(e) => nav.goTo(e.currentTarget.value)} value={nav.currentPage.slug}>
1039
+ {#each nav.pages as p}<option value={p.slug}>{p.title}</option>{/each}
1040
+ </select>
1041
+ <button disabled={!nav.canGoNext} onclick={() => nav.next()}>Next →</button>
1042
+ </nav>
1043
+
1044
+ <style>
1045
+ .topbar { display: flex; gap: 1rem; padding: 0.75rem 1.5rem; border-bottom: 1px solid var(--tessera-border); }
1046
+ .content { max-width: var(--tessera-content-max-width); margin: 0 auto; padding: 2rem; }
1047
+ .footer { display: flex; gap: 1rem; padding: 1rem 1.5rem; border-top: 1px solid var(--tessera-border); }
1048
+ </style>
1049
+ ```
1050
+
1051
+ ### Recipe 3: Prerequisite-based access
1052
+
1053
+ Lock lesson 5 until lessons 1–3 are visited. Composes with `sequentialAccess` instead of re-implementing it.
1054
+
1055
+ ```js
1056
+ // course.config.js
1057
+ import { sequentialAccess } from 'tessera-learn';
1058
+
1059
+ const PREREQS = ['lesson-1', 'lesson-2', 'lesson-3'];
1060
+
1061
+ export default {
1062
+ title: 'My Course',
1063
+ navigation: {
1064
+ mode: 'sequential',
1065
+ canAccess: (ctx) => {
1066
+ if (!sequentialAccess(ctx)) return false;
1067
+ if (ctx.page.slug !== 'lesson-5') return true;
1068
+ return PREREQS.every((slug) => {
1069
+ const i = ctx.manifest.pages.findIndex((p) => p.slug === slug);
1070
+ return i >= 0 && ctx.progress.visitedPages.has(i);
1071
+ });
1072
+ },
1073
+ },
1074
+ completion: { mode: 'percentage', percentageThreshold: 100 },
1075
+ scoring: { passingScore: 70 },
1076
+ export: { standard: 'web' },
1077
+ };
1078
+ ```
1079
+
1080
+ ### Recipe 4: Custom quiz shell via `quiz.svelte`
1081
+
1082
+ Drop `quiz.svelte` at the project root to replace the built-in `<Quiz>`. The runtime wraps every page with `pageConfig.quiz` in your shell instead of the carousel default. The shell uses only the public `useQuiz()` API — no imports from `tessera/runtime/*`.
1083
+
1084
+ ```svelte
1085
+ <!-- quiz.svelte -->
1086
+ <script>
1087
+ import { useQuiz } from 'tessera-learn';
1088
+
1089
+ let { children } = $props();
1090
+ let host;
1091
+
1092
+ // useQuiz owns submission, retry, review, score, and dispatching
1093
+ // tessera-quiz-complete. The shell only drives the UI on top of it.
1094
+ const quiz = useQuiz({ element: () => host });
1095
+ </script>
1096
+
1097
+ <div bind:this={host} class="my-quiz">
1098
+ <p>Question {quiz.questions.findIndex((q) => !q.submitted) + 1} of {quiz.questions.length}</p>
1099
+
1100
+ {#each quiz.questions as q, i}
1101
+ {@const renderFn = quiz.getRender(i)}
1102
+ <section data-question-id={q.id}>{#if renderFn}{@render renderFn()}{/if}</section>
1103
+ {/each}
1104
+
1105
+ {#if quiz.state === 'answering'}
1106
+ <button disabled={!quiz.canSubmit} onclick={() => quiz.submit()}>Submit</button>
1107
+ {:else if quiz.state === 'submitted'}
1108
+ {#if quiz.canRetry}<button onclick={() => quiz.retry()}>Retry</button>{/if}
1109
+ <button onclick={() => quiz.startReview()}>Review</button>
1110
+ {/if}
1111
+
1112
+ <!-- Children render hidden so widget state survives submit/review. -->
1113
+ <div style="display:none">{@render children?.()}</div>
1114
+ </div>
1115
+ ```
1116
+
1117
+ Always submit through `useQuiz().submit()` — see [Data contract](#data-contract--what-the-lms-sees).
1118
+
1119
+ ### Recipe 5: Graded standalone question
1120
+
1121
+ A single inline reflection — not in a `<Quiz>` but `graded: true`, so it counts toward course success. Useful for "must answer to pass" gates without the quiz wrapper.
1122
+
1123
+ ```svelte
1124
+ <!-- pages/04-reflection/01-reflect/reflect.svelte -->
1125
+ <script module>
1126
+ export const pageConfig = { title: "Reflection" };
1127
+ </script>
1128
+
1129
+ <script>
1130
+ import { useQuestion } from 'tessera-learn';
1131
+
1132
+ let answer = $state('');
1133
+
1134
+ const q = useQuestion({
1135
+ id: 'why-it-matters',
1136
+ graded: true,
1137
+ response: () => ({
1138
+ type: 'long-fill-in',
1139
+ response: answer,
1140
+ // No `correct` — any answer accepted; we just want completion.
1141
+ }),
1142
+ score: () => answer.trim().length >= 50 ? 100 : 0,
1143
+ reset: () => { answer = ''; },
1144
+ });
1145
+ </script>
1146
+
1147
+ <h1>Why does this matter to you?</h1>
1148
+ <p>At least 50 characters required to pass.</p>
1149
+
1150
+ <textarea bind:value={answer} rows="6" disabled={q.submitted}></textarea>
1151
+ <button onclick={() => q.submit()} disabled={q.submitted || answer.trim().length < 50}>
1152
+ Submit
1153
+ </button>
1154
+
1155
+ {#if q.submitted}<p>Thanks — your reflection has been recorded.</p>{/if}
1156
+ ```
1157
+
1158
+ The LMS sees a graded `long-fill-in` interaction. Course success rolls up across all graded items — quizzes and standalones alike.
1159
+
1160
+ ### Recipe 6: Chunked-reveal page with `markChunk`
1161
+
1162
+ A page that reveals sections one at a time as the learner advances. `markChunk(pageIndex, chunkIndex)` records the highest revealed chunk so the page resumes mid-scroll on reload. `chunkProgress` is the page-keyed map of those highs.
1163
+
1164
+ ```svelte
1165
+ <!-- pages/02-deep-dive/01-concepts/long-read.svelte -->
1166
+ <script module>
1167
+ export const pageConfig = { title: "How it works" };
1168
+ </script>
1169
+
1170
+ <script>
1171
+ import { useNavigation, useProgress } from 'tessera-learn';
1172
+
1173
+ const nav = useNavigation();
1174
+ const progress = useProgress();
1175
+ const pageIndex = $derived(nav.currentPageIndex);
1176
+
1177
+ const TOTAL_CHUNKS = 4;
1178
+ let revealed = $state(progress.chunkProgress.get(pageIndex) ?? 0);
1179
+
1180
+ function reveal() {
1181
+ revealed = Math.min(revealed + 1, TOTAL_CHUNKS - 1);
1182
+ progress.markChunk(pageIndex, revealed);
1183
+ }
1184
+ </script>
1185
+
1186
+ {#each Array(revealed + 1) as _, i}
1187
+ <section>
1188
+ <h2>Step {i + 1}</h2>
1189
+ <p>Content for step {i + 1}.</p>
1190
+ </section>
1191
+ {/each}
1192
+
1193
+ {#if revealed < TOTAL_CHUNKS - 1}
1194
+ <button onclick={reveal}>Show next</button>
1195
+ {/if}
1196
+ ```
1197
+
1198
+ Use this when a page is long enough that "fully visited" is a meaningful state separate from "loaded once." The runtime persists chunk progress through the same adapter pipeline as everything else.
1199
+
1200
+ ### Recipe 7: Persisted UI state with `usePersistence`
1201
+
1202
+ `usePersistence` is not just for question state — any JSON-serialisable value the learner produces can survive reload through it. Here, a sidebar collapsed/expanded toggle that the learner expects to stay set across sessions.
1203
+
1204
+ ```svelte
1205
+ <!-- in any page component, layout.svelte, or a custom widget -->
1206
+ <script>
1207
+ import { usePersistence } from 'tessera-learn';
1208
+
1209
+ const ui = usePersistence('sidebar-prefs');
1210
+ let collapsed = $state(ui.get()?.collapsed ?? false);
1211
+ $effect(() => ui.set({ collapsed }));
1212
+ </script>
1213
+
1214
+ <button onclick={() => (collapsed = !collapsed)}>
1215
+ {collapsed ? 'Expand' : 'Collapse'}
1216
+ </button>
1217
+ ```
1218
+
1219
+ Keys are namespaced per course, so two courses on the same LMS don't collide. Under SCORM the value rides in `cmi.suspend_data`; under cmi5 in the xAPI State API; under web in `localStorage`.
1220
+
1221
+ ---
1222
+
1223
+ ## Constraints
1224
+
1225
+ - **No runtime data fetching in pages.** Page content is static — no `fetch()` or dynamic loaders in page components.
1226
+ - **Public API only.** Import from `tessera-learn`. Do **not** import from `tessera-learn/runtime/*` — those paths are internal and may change.
1227
+ - **`pageConfig` is JSON5-parseable.** Trailing commas, unquoted keys, single quotes are fine; variables, function calls, template literals, and computed values are not.
1228
+ - **Third-party libraries** must be project dependencies in `package.json`.