tessera-learn 0.0.13 → 0.1.0

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 (56) hide show
  1. package/AGENTS.md +1744 -0
  2. package/README.md +2 -2
  3. package/dist/{validation-B-xTvM9B.js → audit-CzKAXy3Y.js} +591 -268
  4. package/dist/audit-CzKAXy3Y.js.map +1 -0
  5. package/dist/build-commands-D101M_qb.js +27 -0
  6. package/dist/build-commands-D101M_qb.js.map +1 -0
  7. package/dist/inline-config-DYHT51G8.js +29 -0
  8. package/dist/inline-config-DYHT51G8.js.map +1 -0
  9. package/dist/plugin/cli.d.ts +5 -1
  10. package/dist/plugin/cli.d.ts.map +1 -0
  11. package/dist/plugin/cli.js +108 -15
  12. package/dist/plugin/cli.js.map +1 -1
  13. package/dist/plugin/index.d.ts.map +1 -1
  14. package/dist/plugin/index.js +2 -763
  15. package/dist/plugin-y35ym9A3.js +744 -0
  16. package/dist/plugin-y35ym9A3.js.map +1 -0
  17. package/package.json +12 -9
  18. package/src/components/FillInTheBlank.svelte +2 -2
  19. package/src/components/Matching.svelte +2 -2
  20. package/src/components/MultipleChoice.svelte +2 -2
  21. package/src/components/RevealModal.svelte +48 -103
  22. package/src/components/Sorting.svelte +2 -2
  23. package/src/components/util.ts +9 -0
  24. package/src/plugin/a11y/audit.ts +35 -8
  25. package/src/plugin/a11y-cli.ts +35 -22
  26. package/src/plugin/ast.ts +276 -0
  27. package/src/plugin/build-commands.ts +25 -0
  28. package/src/plugin/cli.ts +53 -21
  29. package/src/plugin/index.ts +87 -122
  30. package/src/plugin/inline-config.ts +43 -0
  31. package/src/plugin/manifest.ts +103 -136
  32. package/src/plugin/package-root.ts +24 -0
  33. package/src/plugin/quiz.ts +8 -9
  34. package/src/plugin/validate-cli.ts +30 -0
  35. package/src/plugin/validation.ts +152 -244
  36. package/src/runtime/App.svelte +11 -97
  37. package/src/runtime/Sidebar.svelte +3 -1
  38. package/src/runtime/adapters/cmi5.ts +6 -10
  39. package/src/runtime/adapters/format.ts +6 -0
  40. package/src/runtime/adapters/retry.ts +1 -1
  41. package/src/runtime/adapters/scorm2004.ts +2 -4
  42. package/src/runtime/branding.ts +90 -0
  43. package/src/runtime/defaults.ts +3 -0
  44. package/src/runtime/hooks.svelte.ts +16 -53
  45. package/src/runtime/interaction-format.ts +3 -8
  46. package/src/runtime/progress.svelte.ts +47 -83
  47. package/src/runtime/xapi/derive-actor.ts +41 -48
  48. package/src/runtime/xapi/publisher.ts +14 -14
  49. package/src/runtime/xapi/setup.ts +39 -46
  50. package/dist/audit-BBJpQGqb.js +0 -204
  51. package/dist/audit-BBJpQGqb.js.map +0 -1
  52. package/dist/plugin/a11y-cli.d.ts +0 -1
  53. package/dist/plugin/a11y-cli.js +0 -36
  54. package/dist/plugin/a11y-cli.js.map +0 -1
  55. package/dist/plugin/index.js.map +0 -1
  56. package/dist/validation-B-xTvM9B.js.map +0 -1
package/AGENTS.md ADDED
@@ -0,0 +1,1744 @@
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
+ 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
+ ## Running the project
10
+
11
+ From the project root (the project is set up for `pnpm` — Node's corepack provisions it automatically):
12
+
13
+ ```bash
14
+ pnpm install # first time only
15
+ pnpm dev # dev server at http://localhost:5173 (Ctrl+C to stop)
16
+ pnpm export # build + package for the LMS standard configured in course.config.js
17
+ pnpm validate # run project validation only — no server, no bundle
18
+ pnpm check # validate, then the runtime accessibility audit (axe) over the built course
19
+ ```
20
+
21
+ The dev server hot-reloads as you edit pages, layouts, components, and `course.config.js`. The `export` command produces a SCORM 1.2, SCORM 2004, cmi5, or static-web bundle depending on `course.config.js`.
22
+
23
+ `pnpm validate` runs the same checks as `dev` and `export` (page syntax, manifest shape, `pageConfig`, question components, asset references, LMS data-contract bypass, and the static accessibility rules) and exits non-zero if any fail. Use it as a fast feedback loop after editing — it's the quickest way to confirm a change is structurally sound.
24
+
25
+ `pnpm check` runs `validate` and then the deeper, opt-in pass (`tessera a11y`): it builds the course, renders every page in a headless browser, and runs [axe-core](https://github.com/dequelabs/axe-core) to catch issues a static scan can't see (computed ARIA, real rendered contrast). The runtime audit drives Playwright, which needs a browser binary once per machine:
26
+
27
+ ```bash
28
+ pnpm exec playwright install chromium
29
+ ```
30
+
31
+ See [Accessibility](#accessibility).
32
+
33
+ `dev`, `export`, `validate`, and `check` are **reserved script names** — each is a thin alias for the matching `tessera` subcommand. Don't repurpose them.
34
+
35
+ ### Updating the framework
36
+
37
+ Updating is a plain dependency bump from the project root — there is no `create-tessera upgrade`:
38
+
39
+ ```bash
40
+ pnpm add tessera-learn@latest
41
+ ```
42
+
43
+ You don't have to take the newest release — pin a specific version with `pnpm add tessera-learn@0.1.0` (or set the version in `package.json` and run `pnpm install`) for a reproducible build or to skip a major.
44
+
45
+ The framework owns the build (there is no `vite.config.js`), the reserved scripts, and this authoring guide, so nothing in your tree needs reconciling. This guide ships _inside_ `tessera-learn` (you're reading `node_modules/tessera-learn/AGENTS.md`), so bumping the dependency updates it automatically. Your project's root `CLAUDE.md` and `AGENTS.md` are just small pointers to this file — they never need to change.
46
+
47
+ ### Customising the build (optional)
48
+
49
+ Tessera runs Vite for you with the right plugins; you never write a `vite.config.js`. If you genuinely need to extend the build, add a `tessera.config.js` at the project root. It is a **partial** Vite config that Tessera merges on top of its own — you only specify the delta, and `tesseraPlugin()` (with the Svelte compiler) stays wired in automatically:
50
+
51
+ ```js
52
+ // tessera.config.js — merged on top of Tessera's Vite config
53
+ export default {
54
+ server: { port: 4000 },
55
+ resolve: { alias: { $lib: '/src/lib' } },
56
+ };
57
+ ```
58
+
59
+ `tessera.config.js` is never scaffolded and never touched by updates — once you add it, it's yours.
60
+
61
+ ---
62
+
63
+ ## Project Structure
64
+
65
+ The framework imposes the **minimum** structure it needs to discover content. Everything else is convention you can opt into.
66
+
67
+ ### Required
68
+
69
+ ```
70
+ my-course/
71
+ ├── course.config.js # Course configuration
72
+ ├── package.json
73
+ └── pages/ # Course content (at least one section dir with .svelte files)
74
+ └── intro/
75
+ └── welcome.svelte
76
+ ```
77
+
78
+ `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.
79
+
80
+ ### Optional
81
+
82
+ ```
83
+ my-course/
84
+ ├── layout.svelte # Custom chrome (replaces default sidebar/topbar)
85
+ ├── quiz.svelte # Custom quiz shell (replaces built-in <Quiz>)
86
+ ├── assets/ # Images, audio, video files (referenced via $assets/)
87
+ ├── styles/ # Custom CSS overrides
88
+ ├── CLAUDE.md # Pointer that imports this guide for Claude Code
89
+ ├── AGENTS.md # Pointer to this guide for other agents
90
+ └── pages/
91
+ └── 01-intro/ # Numeric prefix → controls order
92
+ ├── _meta.js # Override section title; control page order
93
+ ├── welcome.svelte # Page directly in the section ("flat" shape)
94
+ └── 01-getting-started/ # Lesson subdirectory ("nested" shape)
95
+ ├── _meta.js
96
+ └── overview.svelte
97
+ ```
98
+
99
+ ### What you can edit
100
+
101
+ You own everything in the project directory: `pages/`, `course.config.js`, `layout.svelte`, `quiz.svelte`, custom components, `assets/`, and `styles/`. Edit those freely.
102
+
103
+ **Never edit `node_modules/`.** `node_modules/tessera-learn/` is the framework itself — edits there are git-ignored, work only until the next `pnpm install`, and are silently wiped when the course's tessera-learn version is updated. (There is no `vite.config.js` to edit either; the build is the framework's. For a genuine build tweak, add a `tessera.config.js` — see [Customising the build](#customising-the-build-optional).) If you think you need to change framework behaviour, you're looking for an extension point instead:
104
+
105
+ - **New question type or interactive widget** → a custom component using the `useQuestion` hook.
106
+ - **Different course chrome** (header, nav, layout) → `layout.svelte`.
107
+ - **Different quiz UI** → `quiz.svelte` using the `useQuiz` hook.
108
+ - **Styling** → `styles/`.
109
+ - **Navigation, completion, scoring, or export target** → `course.config.js`.
110
+
111
+ If none of those fit, the limitation is real — surface it rather than patching around it in `node_modules/`.
112
+
113
+ ### Hierarchy and ordering
114
+
115
+ 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.
116
+
117
+ 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.
118
+
119
+ ### `_meta.js` files
120
+
121
+ **Optional everywhere.** When absent, titles fall back to the title-cased slug (`01-getting-started/` → "Getting Started") and pages sort alphabetically by filename. **Omit the file entirely** when those defaults are what you want — `pages: ["only-page"]` on a single-page lesson is a no-op, and `title: "Splash"` on `01-splash/` duplicates the auto-derived title.
122
+
123
+ Reach for `_meta.js` only when the override is real:
124
+
125
+ ```js
126
+ // section or lesson _meta.js: title override (folder name doesn't auto-derive to what you want)
127
+ export default { title: 'How to play' }; // folder is `01-intro`
128
+ ```
129
+
130
+ ```js
131
+ // lesson _meta.js: explicit page order
132
+ export default {
133
+ title: 'Welcome',
134
+ pages: ['welcome', 'objectives'],
135
+ };
136
+ ```
137
+
138
+ Pages listed in `pages` come first in listed order; any unlisted `.svelte` files are appended alphabetically.
139
+
140
+ ---
141
+
142
+ ## Authoring Surfaces
143
+
144
+ 1. **Built-in components**: `Callout`, `Image`, `MultipleChoice`, `FillInTheBlank`, `Matching`, `Sorting`, etc., from `tessera-learn`. Use, compose, or skip.
145
+ 2. **Hooks**: `useQuestion`, `useQuiz`, `useNavigation`, `useProgress`, `useCompletion`, `usePersistence`. The stable contract between custom widgets and the runtime. Anything the built-ins do, you can do.
146
+ 3. **Custom layout**: drop `layout.svelte` at the project root to replace the default chrome.
147
+ 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`.
148
+ 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).
149
+
150
+ A custom widget that calls `useQuestion` and emits an `Interaction` is treated identically to `<MultipleChoice>`, with the same scoring, LMS reporting, and persistence.
151
+
152
+ ---
153
+
154
+ ## Creating Pages
155
+
156
+ Each page is a `.svelte` file inside a lesson folder.
157
+
158
+ ### Basic page
159
+
160
+ ```svelte
161
+ <h1>Welcome</h1><p>Standard HTML works as-is.</p>
162
+ ```
163
+
164
+ ### Page configuration
165
+
166
+ `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.
167
+
168
+ Both `<script module>` (Svelte 5) and `<script context="module">` (legacy) are accepted by the manifest parser.
169
+
170
+ ```svelte
171
+ <script module>
172
+ export const pageConfig = {
173
+ title: 'Introduction to the Topic',
174
+ };
175
+ </script>
176
+
177
+ <h1>Introduction to the Topic</h1>
178
+ ```
179
+
180
+ If `pageConfig.title` is omitted, the title is derived from the filename: `my-page.svelte` → "My Page".
181
+
182
+ ### Importing components
183
+
184
+ ```svelte
185
+ <script>
186
+ import { Callout, Image } from 'tessera-learn';
187
+ </script>
188
+
189
+ <Callout type="info">
190
+ <p>Helpful information.</p>
191
+ </Callout>
192
+ ```
193
+
194
+ ---
195
+
196
+ ## Component Reference
197
+
198
+ All components import from `tessera-learn`. Nothing is loaded automatically; import only what you use.
199
+
200
+ ### Callout
201
+
202
+ Styled box for highlighting information.
203
+
204
+ | Prop | Type | Default |
205
+ | ------ | --------------------------------------------- | -------- |
206
+ | `type` | `"info" \| "warning" \| "tip" \| "important"` | `"info"` |
207
+
208
+ Children become the body. A11y: `role="note"` with type-appropriate `aria-label`.
209
+
210
+ ```svelte
211
+ <Callout type="warning"><p>Be careful.</p></Callout>
212
+ ```
213
+
214
+ ### Image
215
+
216
+ Lazy-loaded image with optional caption. Renders as `<figure>`/`<figcaption>`.
217
+
218
+ | Prop | Type | Description |
219
+ | ------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
220
+ | `src` | `string` | Image URL. `$assets/` prefix supported |
221
+ | `alt` | `string` | **Required unless `decorative`.** Alt text describing the image |
222
+ | `decorative` | `boolean` | Set `decorative={true}` for a purely ornamental image — renders an empty `alt` + `aria-hidden` so assistive tech skips it. Use this _instead of_ `alt`, not alongside it |
223
+ | `caption` | `string` | Optional caption |
224
+
225
+ Every `<Image>` must resolve to exactly one of: meaningful `alt` text, or `decorative={true}`. The validator errors if neither is present (a missing/empty `alt` is the most common accessibility miss). `decorative` is a **boolean** — write `decorative` or `decorative={true}`, never `decorative="true"` (a string is always truthy, so the validator rejects it).
226
+
227
+ ```svelte
228
+ <Image
229
+ src="$assets/diagram.png"
230
+ alt="System architecture diagram"
231
+ caption="Figure 1"
232
+ />
233
+
234
+ <!-- Ornamental divider that adds nothing for a screen reader: -->
235
+ <Image src="$assets/flourish.svg" decorative={true} />
236
+ ```
237
+
238
+ ### Accordion / AccordionItem
239
+
240
+ Expandable panels. Only one open at a time. A11y: `aria-expanded`, `aria-controls`, `role="region"`, keyboard Enter/Space.
241
+
242
+ ```svelte
243
+ <Accordion>
244
+ <AccordionItem title="What is Tessera?">
245
+ <p>An LMS tracking runtime for interactive learning content.</p>
246
+ </AccordionItem>
247
+ <AccordionItem title="How do I start?">
248
+ <p>Add pages in <code>pages/</code> and import components.</p>
249
+ </AccordionItem>
250
+ </Accordion>
251
+ ```
252
+
253
+ ### Carousel / CarouselSlide
254
+
255
+ Slide-based viewer. A11y: `role="region"`, `aria-roledescription="carousel"`, arrow keys, mobile swipe.
256
+
257
+ ```svelte
258
+ <Carousel>
259
+ <CarouselSlide
260
+ ><h3>Step 1</h3>
261
+ <p>Plan.</p></CarouselSlide
262
+ >
263
+ <CarouselSlide
264
+ ><h3>Step 2</h3>
265
+ <p>Build.</p></CarouselSlide
266
+ >
267
+ <CarouselSlide
268
+ ><h3>Step 3</h3>
269
+ <p>Deploy.</p></CarouselSlide
270
+ >
271
+ </Carousel>
272
+ ```
273
+
274
+ ### RevealModal
275
+
276
+ Modal triggered by user interaction. Uses Svelte 5 snippets for `trigger` and `content`. A11y: `role="dialog"`, `aria-modal="true"`, focus trap, Escape to close.
277
+
278
+ | Prop | Type | Description |
279
+ | --------- | --------- | --------------------------------- |
280
+ | `title` | `string` | Modal label for screen readers |
281
+ | `trigger` | `snippet` | Click target that opens the modal |
282
+ | `content` | `snippet` | Modal body |
283
+
284
+ ```svelte
285
+ <RevealModal title="Details">
286
+ {#snippet trigger()}<button>More info</button>{/snippet}
287
+ {#snippet content()}
288
+ <h3>Additional Information</h3>
289
+ <p>Press Escape or click outside to close.</p>
290
+ {/snippet}
291
+ </RevealModal>
292
+ ```
293
+
294
+ ### Video
295
+
296
+ YouTube/Vimeo iframe (auto-detected, responsive 16:9) or native `<video>` for direct files. Lazy-loads on scroll.
297
+
298
+ | Prop | Type | Description |
299
+ | ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
300
+ | `src` | `string` | Video URL or `$assets/` path |
301
+ | `title` | `string` | **Required.** Accessible label for the player |
302
+ | `tracks` | `array` | Caption/subtitle tracks for **native** video, rendered as `<track>` (see shape below). Ignored for YouTube/Vimeo embeds — the platform owns their captions |
303
+ | `transcript` | `string` | Transcript text shown in a `<details>` disclosure below the player. To load it from a file, import the file with a `?raw` suffix and pass it in (see example) |
304
+
305
+ `title` is the accessible name and is required (empty/whitespace is rejected). For **WCAG 1.2** the validator also warns when a video has no captions: native video with no `tracks` and no `transcript`, or an embed with no `transcript` (embeds can't carry your `<track>` files, so supply a transcript). Each `tracks` entry is `{ src, kind?: 'captions' | 'subtitles', srclang?, label? }`.
306
+
307
+ ```svelte
308
+ <script>
309
+ // ?raw inlines the file's text at build time — works under file://, SCORM, and subpaths
310
+ import intro from '$assets/intro.txt?raw';
311
+ </script>
312
+
313
+ <Video
314
+ src="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
315
+ title="Intro"
316
+ transcript={intro}
317
+ />
318
+ <Video
319
+ src="$assets/demo.mp4"
320
+ title="Demo"
321
+ tracks={[
322
+ {
323
+ src: '$assets/demo.en.vtt',
324
+ kind: 'captions',
325
+ srclang: 'en',
326
+ label: 'English',
327
+ },
328
+ ]}
329
+ />
330
+ ```
331
+
332
+ ### Audio
333
+
334
+ Native player. A11y: `aria-label` from title.
335
+
336
+ | Prop | Type | Description |
337
+ | ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
338
+ | `src` | `string` | Audio URL or `$assets/` path |
339
+ | `title` | `string` | **Required.** Accessible label for the player |
340
+ | `tracks` | `array` | Caption tracks rendered as `<track>` (same shape as `Video`) |
341
+ | `transcript` | `string` | Transcript text shown in a `<details>` disclosure below the player. To load it from a file, import the file with a `?raw` suffix and pass it in (see example) |
342
+
343
+ `title` is required. For **WCAG 1.2.1** the validator warns when an `<Audio>` has no `transcript` — audio-only content needs a text alternative.
344
+
345
+ ```svelte
346
+ <script>
347
+ import lecture from '$assets/lecture-01.txt?raw';
348
+ </script>
349
+
350
+ <Audio src="$assets/lecture-01.mp3" title="Lecture 1" transcript={lecture} />
351
+ ```
352
+
353
+ ---
354
+
355
+ ## Quizzes
356
+
357
+ 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.
358
+
359
+ ### Setup
360
+
361
+ A complete, copy-paste-ready quiz page — `pageConfig.quiz` set, components imported, questions dropped at the page root:
362
+
363
+ ```svelte
364
+ <script module>
365
+ export const pageConfig = {
366
+ title: 'Module 1 Quiz',
367
+ quiz: { graded: true, maxAttempts: 3 },
368
+ };
369
+ </script>
370
+
371
+ <script>
372
+ import { MultipleChoice, FillInTheBlank } from 'tessera-learn';
373
+ </script>
374
+
375
+ <MultipleChoice
376
+ id="q-planet"
377
+ question="Which planet is closest to the Sun?"
378
+ options={['Venus', 'Mercury', 'Earth', 'Mars']}
379
+ correct={1}
380
+ />
381
+
382
+ <FillInTheBlank
383
+ id="q-symbol"
384
+ question="What element has the symbol 'O'?"
385
+ answers={['Oxygen']}
386
+ />
387
+ ```
388
+
389
+ ### Common mistakes
390
+
391
+ Watch for these:
392
+
393
+ - **`correct` is a 0-based index, not the answer text.** `correct={1}` means the second option. It must be in range for `options`.
394
+ - **Every required prop must be present.** `MultipleChoice` needs `question` + `options` + `correct`; `FillInTheBlank` needs `question` + `answers`; `Matching` needs `question` + `pairs`; `Sorting` needs `question` + `items` + `targets` + `correct`.
395
+ - **`Sorting.correct` is a parallel array to `items`** — same length, each entry a valid index into `targets`.
396
+ - **Question `id`s must be unique within a page.** Duplicates collide in `cmi.interactions`.
397
+ - **Don't add your own `<Quiz>` wrapper.** A page with `pageConfig.quiz` is wrapped automatically — just drop the question components at the page root.
398
+ - **Custom widgets must register through `useQuestion` and submit through `useQuiz().submit()`.** See [Data contract](#data-contract-what-the-lms-sees) below.
399
+
400
+ ### Data contract: what the LMS sees
401
+
402
+ 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 the persistence adapter. Each interaction is reported the moment the widget calls `q.commit()` — atomic widgets (MCQ, true-false, likert) call it on click, composite widgets (matching, sorting, fill-in) call it on blur or final-state. `useQuiz().submit()` calls commit on any question whose widget hasn't yet, as a safety net. The reporting cost — one xAPI Answered / one `cmi.interactions.n` block per call — happens incrementally throughout the session rather than batching at the end, so a learner closing the tab after the last commit still gets credit. Bypass `useQuestion`/`useQuiz` and the quiz reports nothing.
403
+
404
+ ### `pageConfig.quiz` fields
405
+
406
+ | Field | Type | Default | Description |
407
+ | --------------- | ------------------------------------ | ---------- | -------------------------------------------------------------------------------------------------------------------------- |
408
+ | `graded` | `boolean` | `false` | Whether the score counts toward course success |
409
+ | `gatesProgress` | `boolean` | `false` | Whether passing is required to access the next page |
410
+ | `maxAttempts` | `number` | `Infinity` | Max attempts |
411
+ | `feedbackMode` | `"review" \| "immediate" \| "never"` | `"review"` | When feedback renders. See below. |
412
+ | `retryMode` | `"full" \| "incorrect-only"` | `"full"` | `"full"` resets every answer on retry; `"incorrect-only"` keeps questions the learner already got right locked as correct. |
413
+
414
+ `feedbackMode` values: `"immediate"` reveals after the shell calls `revealFeedback(q)` and locks the answer; `"review"` shows feedback only on the post-submit review screen; `"never"` disables feedback entirely (the built-in `<Quiz>` hides the Review button).
415
+
416
+ `gatesProgress: true` blocks navigation to the next page until the learner passes. Works in both `free` and `sequential` navigation modes.
417
+
418
+ ### Per-question weighting
419
+
420
+ 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; non-positive values are treated as 1.
421
+
422
+ ```svelte
423
+ <MultipleChoice id="q-easy" weight={1} ... />
424
+ <MultipleChoice id="q-hard" weight={3} ... />
425
+ ```
426
+
427
+ Weights apply identically inside a `<Quiz>` and to standalone questions on a plain page — the same widget answered the same way produces the same page score either way.
428
+
429
+ The page-level score is the weighted-correct percentage: `Σ(weight × correct) / Σ(weight) × 100`, rounded. With every weight at the default 1 this is the plain correct-count percentage.
430
+
431
+ 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.*`.
432
+
433
+ ### Question types
434
+
435
+ #### MultipleChoice
436
+
437
+ | Prop | Type | Description |
438
+ | ------------------- | ---------- | ---------------------------------------------------------------------------------------------- |
439
+ | `question` | `string` | Prompt |
440
+ | `options` | `string[]` | Answer options |
441
+ | `correct` | `number` | Index of correct option (0-based) |
442
+ | `correctFeedback` | `string` | Optional |
443
+ | `incorrectFeedback` | `string` | Optional |
444
+ | `optionFeedback` | `string[]` | Optional per-option feedback |
445
+ | `weight` | `number` | Page-level rollup weight (default `1`). See [Per-question weighting](#per-question-weighting). |
446
+
447
+ ```svelte
448
+ <MultipleChoice
449
+ question="What is the capital of France?"
450
+ options={['London', 'Berlin', 'Paris', 'Madrid']}
451
+ correct={2}
452
+ />
453
+ ```
454
+
455
+ #### FillInTheBlank
456
+
457
+ | Prop | Type | Default | Description |
458
+ | --------------- | ---------- | ------- | ------------------------ |
459
+ | `question` | `string` | | Prompt |
460
+ | `answers` | `string[]` | | Acceptable answers |
461
+ | `caseSensitive` | `boolean` | `false` | Comparison casing |
462
+ | `weight` | `number` | `1` | Page-level rollup weight |
463
+
464
+ `answers` only needs distinct spellings; `caseSensitive: false` already handles case variants.
465
+
466
+ ```svelte
467
+ <FillInTheBlank
468
+ question="What element has the symbol 'O'?"
469
+ answers={['Oxygen']}
470
+ />
471
+ ```
472
+
473
+ #### Matching
474
+
475
+ | Prop | Type | Description |
476
+ | ---------- | --------------------------------- | -------------------------------------- |
477
+ | `question` | `string` | Prompt |
478
+ | `pairs` | `{left: string, right: string}[]` | Correct pairs |
479
+ | `weight` | `number` | Page-level rollup weight (default `1`) |
480
+
481
+ 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.
482
+
483
+ ```svelte
484
+ <Matching
485
+ question="Match country to capital:"
486
+ pairs={[
487
+ { left: 'France', right: 'Paris' },
488
+ { left: 'Germany', right: 'Berlin' },
489
+ { left: 'Japan', right: 'Tokyo' },
490
+ ]}
491
+ />
492
+ ```
493
+
494
+ #### Sorting
495
+
496
+ Drag-and-drop (or click-to-place) into labelled categories.
497
+
498
+ | Prop | Type | Description |
499
+ | ---------- | ---------- | --------------------------------------------------------------- |
500
+ | `question` | `string` | Prompt |
501
+ | `items` | `string[]` | Items to sort |
502
+ | `targets` | `string[]` | Category labels |
503
+ | `correct` | `number[]` | For each item, the index of its correct target (parallel array) |
504
+ | `weight` | `number` | Page-level rollup weight (default `1`) |
505
+
506
+ ```svelte
507
+ <Sorting
508
+ question="Sort each animal:"
509
+ items={['Dog', 'Eagle', 'Salmon', 'Cat', 'Robin', 'Trout']}
510
+ targets={['Mammals', 'Birds', 'Fish']}
511
+ correct={[0, 1, 2, 0, 1, 2]}
512
+ />
513
+ ```
514
+
515
+ ### Standalone questions
516
+
517
+ All four question components also work outside `<Quiz>` for inline practice. Standalone widgets render their own Check / Retry buttons.
518
+
519
+ | Prop | Type | Default | Description |
520
+ | ------------ | -------- | ---------- | ----------------------------------------- |
521
+ | `maxRetries` | `number` | `Infinity` | Max retries for standalone widgets |
522
+ | `weight` | `number` | `1` | Per-question weight for page-level rollup |
523
+
524
+ ```svelte
525
+ <MultipleChoice
526
+ question="What color is the sky on a clear day?"
527
+ options={['Red', 'Blue', 'Green']}
528
+ correct={1}
529
+ maxRetries={2}
530
+ />
531
+ ```
532
+
533
+ 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).
534
+
535
+ ---
536
+
537
+ ## Manual completion
538
+
539
+ `completion.mode: "manual"` is for courses where the author — not a quiz score or a page-visit ratio — owns the moment of completion. Two examples:
540
+
541
+ - A short policy briefing where reading the final page **is** the proof of completion.
542
+ - A compliance "click to acknowledge" button at the end of a module.
543
+
544
+ Under manual mode, **both** triggers below are always active. First-to-fire wins; subsequent calls are idempotent.
545
+
546
+ ### Trigger A: page frontmatter
547
+
548
+ Declare `completesOn: "view"` on any page. Completion fires the moment that page renders.
549
+
550
+ ```svelte
551
+ <!-- pages/05-summary/finale.svelte -->
552
+ <script module>
553
+ export const pageConfig = {
554
+ title: "You're done",
555
+ completesOn: 'view',
556
+ };
557
+ </script>
558
+
559
+ <h1>Thanks for completing the briefing.</h1>
560
+ ```
561
+
562
+ `completesOn` accepts the literal string `"view"` (only value in v1). The page is marked visited and completion fires in the same effect — the LMS sees one `setCompletionStatus("complete")` immediately after the page renders.
563
+
564
+ ### Trigger B: runtime hook
565
+
566
+ ```svelte
567
+ <script>
568
+ import { useCompletion } from 'tessera-learn';
569
+
570
+ const { markComplete, completionStatus } = useCompletion();
571
+ </script>
572
+
573
+ <button
574
+ onclick={() => markComplete()}
575
+ disabled={completionStatus === 'complete'}
576
+ >
577
+ I acknowledge
578
+ </button>
579
+
580
+ {#if completionStatus === 'complete'}
581
+ <p>Recorded. You may now close this window.</p>
582
+ {/if}
583
+ ```
584
+
585
+ Composes cleanly with custom widgets, modal close handlers, video-ended events, timer expirations, etc. Calling `markComplete()` outside `completion.mode: "manual"` is a no-op with a one-shot dev warning per session — safe to leave in shared components.
586
+
587
+ ### `completion.trigger` (build-time check)
588
+
589
+ Optional. Set to `"page"` to fail the build when no page declares `completesOn: "view"`. Useful when the page-view path is load-bearing and a typo should fail the build, not the launch. Both triggers still work either way; the field only adds a static check.
590
+
591
+ ```js
592
+ completion: { mode: "manual", trigger: "page" }
593
+ ```
594
+
595
+ When omitted, the dev runtime warns once after 60 s if completion has not fired.
596
+
597
+ ### Success status
598
+
599
+ By default `successStatus` stays `"unknown"` under manual — the LMS sees completion without a pass/fail verdict. If you want completion **and** an automatic pass (typical for "acknowledge" flows):
600
+
601
+ ```js
602
+ completion: { mode: "manual", requireSuccessStatus: "passed" } // or "failed"
603
+ ```
604
+
605
+ | Adapter | What the LMS sees on `markComplete()` (no `requireSuccessStatus`) |
606
+ | -------------- | ----------------------------------------------------------------------- |
607
+ | SCORM 1.2 | `cmi.core.lesson_status = "completed"` |
608
+ | SCORM 2004 4th | `cmi.completion_status = "completed"`, `cmi.success_status = "unknown"` |
609
+ | cmi5 | **Completed** statement (no Passed / Failed) |
610
+ | web | `localStorage` only |
611
+
612
+ With `requireSuccessStatus: "passed"`, SCORM 1.2 writes `lesson_status = "passed"`, SCORM 2004 writes `success_status = "passed"`, and cmi5 emits a **Passed** statement alongside **Completed**.
613
+
614
+ ### Quizzes under manual mode
615
+
616
+ A graded quiz under `mode: "manual"` reports its score to the LMS gradebook but does **not** drive completion or success — `markComplete()` / `completesOn` does. The build emits a warning to make this explicit. Set `graded: false` (or remove the quiz) if that's not what you want.
617
+
618
+ ### Non-goals
619
+
620
+ - Combining manual + quiz/percentage rules ("complete when X **and** quiz passed"). Use a `useCompletion()` call inside a custom `$effect` if you need conditional logic.
621
+ - Per-learner conditional completion expressed in config — same answer: do it in a component with `useCompletion()`.
622
+ - Marking a course **incomplete** after it has been completed. Completion is monotonic in every spec we target. The runtime ignores re-marks.
623
+
624
+ ---
625
+
626
+ ## Assets
627
+
628
+ Drop files into `assets/`. Reference them with `$assets/` in built-in component props:
629
+
630
+ ```svelte
631
+ <Image src="$assets/photo.png" alt="Photo" />
632
+ <Video src="$assets/demo.mp4" title="Demo" />
633
+ <Audio src="$assets/lecture.mp3" title="Lecture" />
634
+ ```
635
+
636
+ In CSS, use a relative path from `styles/`:
637
+
638
+ ```css
639
+ .bg {
640
+ background-image: url('../assets/bg.png');
641
+ }
642
+ ```
643
+
644
+ External URLs work too: `<Image src="https://example.com/img.jpg" alt="..." />`.
645
+
646
+ 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.
647
+
648
+ ### `$assets/` is three things — know which you're using
649
+
650
+ `$assets/` is presented as a single convention, but it's actually three distinct mechanisms with different scopes. Custom components have to pick one explicitly; the wrong choice gives a silent 404, not a build error.
651
+
652
+ 1. **Vite import alias** — works in ES `import` statements. Vite resolves `$assets/...` to the project's `assets/` directory and bundles the asset:
653
+ ```js
654
+ import logoUrl from '$assets/logo.svg?url';
655
+ ```
656
+ 2. **Built-in component prop rewrite** — `Image`, `Audio`, and `Video` rewrite `$assets/foo` → `./assets/foo` internally before rendering. This is why `<Image src="$assets/photo.png">` works.
657
+ 3. **Build-time copy** — the plugin copies `assets/` to `dist/assets/`, so the document-relative path `./assets/foo.png` resolves identically in dev and in the shipped bundle.
658
+
659
+ **Raw HTML attributes are not rewritten.** `<img src="$assets/foo.svg">` in a custom component fetches the literal string `/$assets/foo.svg` and 404s — there's no validator warning for this. Same for `new Audio('$assets/...')`, CSS `url()` strings built in JS, etc.
660
+
661
+ ### Asset references in custom components
662
+
663
+ Pick by use case:
664
+
665
+ **One-off reference — ES import (preferred):**
666
+
667
+ ```svelte
668
+ <script>
669
+ import url from '$assets/diagram.svg?url';
670
+ </script>
671
+
672
+ <img src={url} alt="Diagram" />
673
+ ```
674
+
675
+ Build-time bundling, asset hashing, fails the build if missing.
676
+
677
+ **Collection referenced by name — `import.meta.glob`:**
678
+
679
+ ```js
680
+ const signs = import.meta.glob('$assets/signs/*.svg', {
681
+ eager: true,
682
+ query: '?url',
683
+ import: 'default',
684
+ });
685
+ // then look up by full key:
686
+ const url = signs[`/assets/signs/${filename}`];
687
+ ```
688
+
689
+ Use this when the asset is chosen at runtime by ID/filename. Same build-time guarantees as a single import.
690
+
691
+ **Pure runtime string (last resort):**
692
+
693
+ ```js
694
+ const src = `./assets/signs/${filename}`;
695
+ ```
696
+
697
+ No build-time guarantees, but works when neither pattern above fits (e.g., filenames that come from server data). Equivalent to what `Image`/`Audio`/`Video` do internally.
698
+
699
+ ---
700
+
701
+ ## Styling
702
+
703
+ Add `.css` files to `styles/`. They load after framework styles and override them.
704
+
705
+ ### CSS custom properties
706
+
707
+ Override these to theme globally:
708
+
709
+ | Property | Default |
710
+ | ---------------------------------------------- | ------------------------------------- |
711
+ | `--tessera-primary` | `#2563eb` |
712
+ | `--tessera-primary-light` | `#dbeafe` |
713
+ | `--tessera-primary-dark` | `#1e40af` |
714
+ | `--tessera-text` | `#1f2937` |
715
+ | `--tessera-text-light` | `#6b7280` |
716
+ | `--tessera-bg` | `#ffffff` |
717
+ | `--tessera-bg-secondary` | `#f9fafb` |
718
+ | `--tessera-border` | `#e5e7eb` |
719
+ | `--tessera-success` | `#16a34a` |
720
+ | `--tessera-error` | `#dc2626` |
721
+ | `--tessera-warning` | `#d97706` |
722
+ | `--tessera-font-family` | `'Inter', system-ui, sans-serif` |
723
+ | `--tessera-font-size-base` | `1rem` |
724
+ | `--tessera-line-height` | `1.6` |
725
+ | `--tessera-spacing-sm` / `-md` / `-lg` / `-xl` | `0.5rem` / `1rem` / `1.5rem` / `2rem` |
726
+ | `--tessera-sidebar-width` | `280px` |
727
+ | `--tessera-content-max-width` | `800px` |
728
+
729
+ ```css
730
+ :root {
731
+ --tessera-primary: #9333ea;
732
+ --tessera-font-family: 'Georgia', serif;
733
+ }
734
+ ```
735
+
736
+ `branding.primaryColor` and `branding.fontFamily` in `course.config.js` cover the common overrides without writing CSS.
737
+
738
+ ---
739
+
740
+ ## `course.config.js`
741
+
742
+ ```js
743
+ export default {
744
+ // Metadata
745
+ title: 'My Course', // required
746
+ description: '',
747
+ author: '',
748
+ version: '1.0.0',
749
+ language: 'en', // BCP-47 tag for <html lang> (e.g. "en", "fr-CA"); defaults to "en"
750
+
751
+ branding: {
752
+ logo: '', // e.g., "$assets/logo.png"
753
+ primaryColor: '#2563eb',
754
+ fontFamily: 'Inter, sans-serif',
755
+ },
756
+
757
+ navigation: {
758
+ mode: 'free', // "free" or "sequential"
759
+ },
760
+
761
+ completion: {
762
+ mode: 'percentage', // "percentage" | "quiz" | "manual"
763
+ percentageThreshold: 100, // 0–100 (percentage mode)
764
+ // trigger: "page", // (manual only) opt into build-time check
765
+ // requireSuccessStatus: "passed", // (manual only) "passed" | "failed"
766
+ },
767
+
768
+ scoring: {
769
+ passingScore: 70, // optional under "manual" (defaults to 0)
770
+ },
771
+
772
+ export: {
773
+ standard: 'web', // "web" | "scorm12" | "scorm2004" | "cmi5"
774
+ },
775
+
776
+ // Accessibility checker (all optional — sensible defaults apply)
777
+ a11y: {
778
+ level: 'warn', // "warn" (default) or "error" — "error" makes the promotable a11y rules block the build
779
+ standard: 'wcag2aa', // "wcag2a" | "wcag2aa" (default) | "wcag21aa" — axe ruleset for tessera a11y
780
+ ignore: [], // rule IDs to suppress, e.g. ["tessera/heading-order", "color-contrast"]
781
+ },
782
+ };
783
+ ```
784
+
785
+ - `language` sets `<html lang>` for screen readers (WCAG 3.1.1). Set it to your course's language as a [BCP-47](https://www.w3.org/International/articles/language-tags/) tag. A missing or implausible value warns and falls back to `"en"`.
786
+ - `a11y.level: "error"` promotes the "promotable" accessibility warnings (captions/transcript, heading order, contrast, language, and the Svelte compiler's a11y warnings) to build-blocking errors. Hard contract errors (missing `alt`, missing media `title`) always block regardless of `level`.
787
+ - `a11y.ignore` is a flat list matched literally against each diagnostic's rule ID across **all tiers** — the `tessera/…` IDs printed by `validate`, the `a11y_…` IDs from the Svelte compiler, and the bare axe rule IDs (e.g. `color-contrast`) from `tessera a11y`.
788
+
789
+ - `navigation.mode: "free"` → all pages accessible except those blocked by gating quizzes.
790
+ - `navigation.mode: "sequential"` → pages unlock one at a time as each is completed.
791
+ - `completion.mode: "percentage"` → course completes when `visitedPages / totalPages * 100 >= percentageThreshold`.
792
+ - `completion.mode: "quiz"` → course completes when graded quiz average >= `scoring.passingScore`.
793
+ - `completion.mode: "manual"` → course completes when an author-declared trigger fires. See [Manual completion](#manual-completion).
794
+
795
+ ### Minimum config
796
+
797
+ Every field except `title` has a default. The build merges yours over:
798
+
799
+ ```js
800
+ // effective defaults
801
+ {
802
+ title: "Untitled Course",
803
+ language: "en",
804
+ navigation: { mode: "free" },
805
+ completion: { mode: "percentage", percentageThreshold: 100 },
806
+ scoring: { passingScore: 70 },
807
+ export: { standard: "web" },
808
+ }
809
+ ```
810
+
811
+ So `export default { title: "My Course" }` is a complete config: free navigation, full-percentage completion, web export, `<html lang="en">`. (The scaffold seeds `language: 'en'` so a fresh course starts without the language warning; set it to your actual language.)
812
+
813
+ ### Custom access rules
814
+
815
+ For anything beyond the two presets (prereqs, instructor approval, time gating), supply `navigation.canAccess`. It runs synchronously on every navigation evaluation. Keep it cheap.
816
+
817
+ ```js
818
+ import { sequentialAccess } from 'tessera-learn';
819
+
820
+ export default {
821
+ // ...
822
+ navigation: {
823
+ mode: 'sequential',
824
+ canAccess: (ctx) => {
825
+ if (!sequentialAccess(ctx)) return false;
826
+ if (ctx.page.slug === 'lesson-5') {
827
+ const i = ctx.manifest.pages.findIndex(
828
+ (p) => p.slug === 'lesson-2-quiz',
829
+ );
830
+ return (
831
+ (ctx.progress.quizScores.get(i) ?? 0) >=
832
+ ctx.config.scoring.passingScore
833
+ );
834
+ }
835
+ return true;
836
+ },
837
+ },
838
+ };
839
+ ```
840
+
841
+ `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.
842
+
843
+ ### Build output
844
+
845
+ `pnpm export` (which wraps `vite build`) writes:
846
+
847
+ | `export.standard` | What ships | Where |
848
+ | ----------------- | ------------------------------------- | ---------------------------------------- |
849
+ | `web` | Static site (HTML/CSS/JS + `assets/`) | `dist/` (host on any static file server) |
850
+ | `scorm12` | SCORM 1.2 package | `dist/<course>-scorm12.zip` |
851
+ | `scorm2004` | SCORM 2004 4th Edition package | `dist/<course>-scorm2004.zip` |
852
+ | `cmi5` | cmi5 package (AU + manifest) | `dist/<course>-cmi5.zip` |
853
+
854
+ 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.
855
+
856
+ ### Validation
857
+
858
+ The Vite plugin runs project validation on every dev start and build (page syntax, manifest shape, `pageConfig` parseability, question components, asset references, LMS data-contract bypass, etc.). Errors abort the build and print as `[tessera error] ...`; warnings print as `[tessera warning] ...` and don't block. Run `pnpm validate` to check without building.
859
+
860
+ ---
861
+
862
+ ## Accessibility
863
+
864
+ Tessera checks accessibility in two passes, plus components that are accessible by construction.
865
+
866
+ **Static checks** run inside `validate`, `dev`, and `export` — no extra setup. They cover what's visible in your source: `<Image>` alt-or-`decorative`, `<Video>`/`<Audio>` `title` + captions/transcript, empty question option/answer labels, skipped heading levels (e.g. `h2` → `h4`), `branding.primaryColor` contrast against white, and a well-formed `language` tag. They also route the Svelte compiler's own `a11y_*` warnings through the reporter. Each diagnostic carries a rule ID in brackets (e.g. `[tessera/image-alt]`, `[a11y_missing_attribute]`) — that ID is what `a11y.ignore` and `a11y.level` match.
867
+
868
+ **Runtime audit** is the opt-in deep pass: `tessera a11y` (run it directly, or via `pnpm check`, which runs `validate` first) builds the course, renders **every** page in a headless browser (including pages gated behind a quiz), runs [axe-core](https://github.com/dequelabs/axe-core), writes `a11y-report.json`, and exits non-zero on any violation at or above an impact threshold (default `serious`). It catches what a static scan can't — computed ARIA, focus order, real rendered contrast.
869
+
870
+ The runtime audit drives Playwright, which needs a browser binary once per machine:
871
+
872
+ ```bash
873
+ pnpm exec playwright install chromium
874
+ ```
875
+
876
+ ```bash
877
+ tessera a11y # audit (threshold: serious)
878
+ tessera a11y --threshold minor # stricter
879
+ tessera a11y --build # force a fresh build first
880
+ ```
881
+
882
+ The audit renders the course with the web adapter, so it works regardless of your `export.standard` — you don't need an LMS to run it.
883
+
884
+ The audit's ruleset and severity come from the `a11y` block in `course.config.js` (`standard`, `ignore`); see [`course.config.js`](#courseconfigjs). `a11y-report.json` is build output — it's git-ignored by default.
885
+
886
+ Hard contract errors (missing `alt`, missing media `title`) always block the build. Everything else is a warning unless you set `a11y.level: "error"`. To silence a specific rule everywhere, add its ID to `a11y.ignore`.
887
+
888
+ ---
889
+
890
+ ## Hooks Reference
891
+
892
+ Six hooks plus one helper make up the stable contract between widgets and the runtime.
893
+
894
+ ```js
895
+ import {
896
+ useQuestion,
897
+ useQuiz,
898
+ useNavigation,
899
+ useProgress,
900
+ useCompletion,
901
+ usePersistence,
902
+ isCorrect,
903
+ } from 'tessera-learn';
904
+ import type { Interaction } from 'tessera-learn';
905
+ ```
906
+
907
+ Each hook is synchronous and must be called during component setup, inside a Tessera course. Calling them outside the runtime throws.
908
+
909
+ ### The `Question` model
910
+
911
+ Both `useQuiz()` and `useQuestion()` traffic in the same per-question object. A quiz shell iterates `quiz.questions`; a widget gets its own `Question` directly from `useQuestion()`. No indexes, no `getContext('tessera-quiz')` — both halves use the same handle.
912
+
913
+ ```ts
914
+ interface Question {
915
+ readonly id: string;
916
+ readonly submitted: boolean;
917
+ readonly correct: boolean | null;
918
+ readonly answer: unknown;
919
+ readonly feedbackVisible: boolean;
920
+ readonly locked: boolean; // input must be read-only: submitted OR feedbackVisible OR isLockedCorrect
921
+ readonly isLockedCorrect: boolean; // narrow case: locked because retry policy preserved this as already-correct
922
+ readonly render: unknown; // snippet the widget registered; shell calls {@render q.render()}
923
+ setAnswer(answer: unknown): void;
924
+ commit(): void; // signal the answer is final; triggers the per-question LMS write. Idempotent — a second call with the same answer is a no-op.
925
+ }
926
+ ```
927
+
928
+ Widgets should gate input on `q.locked` and only branch on `q.isLockedCorrect` to render the "already correct" banner.
929
+
930
+ `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.
931
+
932
+ For `choice` / `sequencing` / `matching`, name your responses with readable ids (`response: ['speed-limit']`) and pass the full option list alongside via `options` (or `optionPairs` for matching). The encoder is then adaptive per export: cmi5 and SCORM 2004 ship the names through unchanged for self-describing traces; SCORM 1.2 maps each name to its position index in `options` so SCORM Cloud's strict validator accepts the value. Omit `options` and SCORM 1.2 falls back to slugging the literal identifier.
933
+
934
+ ```ts
935
+ response: () => ({
936
+ type: 'choice',
937
+ response: selected ? [selected] : [],
938
+ correct: ['speed-limit'],
939
+ options: ['stop', 'yield', 'speed-limit', 'merge'],
940
+ });
941
+ // SCORM 1.2 → student_response: "2"
942
+ // SCORM 2004 → learner_response: "speed-limit"
943
+ // cmi5 → result.response: "speed-limit"
944
+ ```
945
+
946
+ Matching uses `optionPairs: { left, right }` for the same effect, mapping each pair's `[l, r]` to `"<leftIdx>.<rightIdx>"` on SCORM 1.2.
947
+
948
+ ### `useQuestion`
949
+
950
+ Register a question widget so the runtime can submit, score, persist, and report it. Returns a `Question` plus standalone-only methods.
951
+
952
+ - **Inside a quiz**: the parent shell drives submission. The widget calls `setAnswer()` on user input, `commit()` when the answer is final, `setRender(snippet)` once at mount, and reads `locked` / `feedbackVisible` / `answer` to render. `submit()`, `retry()`, `setRender()` etc. degrade to no-ops in the irrelevant mode — the same widget works in both.
953
+ - **Standalone**: the widget owns its own Check/Retry. Set `graded: true` to count toward course success.
954
+
955
+ ```ts
956
+ function useQuestion(opts: {
957
+ id: string; // unique on the page; LMS interaction id
958
+ graded?: boolean; // standalone only
959
+ response: () => Interaction; // current learner answer; called on each commit() and on submit
960
+ score?: () => number; // standalone-only override (0–100)
961
+ weight?: number; // page-level rollup weight (default 1)
962
+ maxRetries?: number; // standalone retry cap (default Infinity); ignored inside a quiz
963
+ reset?: () => void;
964
+ }): Question & {
965
+ submit(): void; // standalone: triggers own check. quiz: no-op (shell drives).
966
+ reset(): void;
967
+ retry(): void; // standalone only; no-op once maxRetries hit or inside a quiz
968
+ readonly canRetry: boolean;
969
+ readonly retryCount: number;
970
+ readonly mode: 'standalone' | 'quiz';
971
+ setRender(render: unknown): void; // registers the snippet for the parent shell to render
972
+ };
973
+ ```
974
+
975
+ ```svelte
976
+ <script>
977
+ import { useQuestion } from 'tessera-learn';
978
+
979
+ let order = $state(['Mercury', 'Venus', 'Earth', 'Mars']);
980
+
981
+ const q = useQuestion({
982
+ id: 'planet-rank',
983
+ response: () => ({
984
+ type: 'sequencing',
985
+ response: order,
986
+ correct: ['Mercury', 'Venus', 'Earth', 'Mars'],
987
+ }),
988
+ reset: () => {
989
+ order = ['Mercury', 'Venus', 'Earth', 'Mars'];
990
+ },
991
+ });
992
+ </script>
993
+
994
+ <!-- drag-to-reorder UI bound to `order` -->
995
+ {#if q.mode === 'standalone'}
996
+ <button onclick={() => q.submit()} disabled={q.submitted}>Check</button>
997
+ {/if}
998
+ ```
999
+
1000
+ ### `useQuiz`
1001
+
1002
+ Quiz orchestration hook for any project-supplied `quiz.svelte` (and the built-in `<Quiz>`). A custom shell calls `useQuiz` to drive submission/retry/review. Question widgets call `q.commit()` when their answer is final; that's what triggers the per-question LMS write. `submit()` calls commit for any uncommitted questions as a safety net, then dispatches `tessera-quiz-complete`. **`submit()` is the only sanctioned dispatcher of `tessera-quiz-complete`** — bypassing it means the quiz never marks Completed / Passed / Failed.
1003
+
1004
+ ```ts
1005
+ function useQuiz(opts: { element: () => HTMLElement | null }): {
1006
+ readonly state: 'answering' | 'submitted' | 'reviewing';
1007
+ readonly questions: ReadonlyArray<Question>;
1008
+ readonly canSubmit: boolean;
1009
+ readonly canRetry: boolean;
1010
+ readonly score: number;
1011
+ readonly passingScore: number; // resolved at runtime (config + LMS mastery override)
1012
+ readonly attemptCount: number;
1013
+ submit(): void; // reports any uncommitted interactions, then dispatches tessera-quiz-complete
1014
+ retry(): void;
1015
+ startReview(): void;
1016
+ exitReview(): void;
1017
+ revealFeedback(q: Question): void; // immediate-feedback flow
1018
+ };
1019
+ ```
1020
+
1021
+ 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.
1022
+
1023
+ `passingScore` reads the resolved threshold: config's `scoring.passingScore`, overridden when the LMS supplies one (SCORM 2004 `cmi.scaled_passing_score`, cmi5 `masteryScore`). Use this instead of importing `course.config.js` directly — importing the config skips the LMS override.
1024
+
1025
+ ### `useNavigation`
1026
+
1027
+ ```ts
1028
+ function useNavigation(): {
1029
+ readonly currentPage: ManifestPage;
1030
+ readonly currentPageIndex: number;
1031
+ readonly pages: ManifestPage[];
1032
+ goTo(slug: string): void;
1033
+ goToIndex(index: number): void;
1034
+ next(): void;
1035
+ prev(): void;
1036
+ readonly canGoNext: boolean;
1037
+ readonly canGoPrev: boolean;
1038
+ canAccess(slug: string): boolean;
1039
+ };
1040
+ ```
1041
+
1042
+ ### `useProgress`
1043
+
1044
+ ```ts
1045
+ function useProgress(): {
1046
+ readonly visitedPages: Set<number>;
1047
+ readonly quizScores: Map<number, number>; // pageIndex → score 0–100
1048
+ readonly chunkProgress: Map<number, number>; // pageIndex → highest revealed chunk index
1049
+ readonly completionStatus: 'incomplete' | 'complete';
1050
+ readonly successStatus: 'unknown' | 'passed' | 'failed';
1051
+ markVisited(pageIndex: number): void;
1052
+ markChunk(pageIndex: number, chunkIndex: number): void;
1053
+ };
1054
+ ```
1055
+
1056
+ ### `useCompletion`
1057
+
1058
+ Trigger course completion from any component, and reactively read the current completion status. Active under `completion.mode: "manual"`; in any other mode `markComplete()` is a no-op with a one-shot dev warning. See [Manual completion](#manual-completion).
1059
+
1060
+ ```ts
1061
+ function useCompletion(): {
1062
+ /** Idempotent — only the first call per session has an effect. */
1063
+ markComplete(): void;
1064
+ readonly completionStatus: 'incomplete' | 'complete';
1065
+ };
1066
+ ```
1067
+
1068
+ ### `usePersistence<T>(key)`
1069
+
1070
+ 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.
1071
+
1072
+ ```ts
1073
+ function usePersistence<T>(key: string): {
1074
+ get(): T | null;
1075
+ set(value: T): void;
1076
+ };
1077
+ ```
1078
+
1079
+ ```svelte
1080
+ <script>
1081
+ import { usePersistence } from 'tessera-learn';
1082
+
1083
+ const store = usePersistence('whiteboard');
1084
+ let state = $state(store.get() ?? { strokes: [] });
1085
+ $effect(() => store.set(state));
1086
+ </script>
1087
+ ```
1088
+
1089
+ ### `isCorrect(interaction)`
1090
+
1091
+ Pure helper. Returns `true`, `false`, or `null` (when the interaction has no `correct` field).
1092
+
1093
+ ```ts
1094
+ function isCorrect(i: Interaction): boolean | null;
1095
+ ```
1096
+
1097
+ ---
1098
+
1099
+ ## Custom xAPI statements
1100
+
1101
+ 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()`:
1102
+
1103
+ ```ts
1104
+ import { useXAPI } from 'tessera-learn';
1105
+
1106
+ const xapi = useXAPI(); // XAPIClient | null
1107
+ xapi?.sendStatement({
1108
+ verb: { id: 'http://adlnet.gov/expapi/verbs/experienced' },
1109
+ object: { id: `${xapi.getActivityId()}#diagram-1` },
1110
+ });
1111
+ ```
1112
+
1113
+ `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.
1114
+
1115
+ 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`.
1116
+
1117
+ ### Configure the destination: `course.config.js`
1118
+
1119
+ `config.xapi` is one destination, or an array of them. The destination is always declared explicitly. There is no implicit default.
1120
+
1121
+ ```js
1122
+ xapi: {
1123
+ endpoint: 'https://lrs.example.com/xapi/',
1124
+ auth: () => fetch('/api/lrs-token').then(r => r.text()),
1125
+ actor: () => getCurrentUser(), // or a static Agent object
1126
+ activityId: 'https://example.com/courses/intro-to-x',
1127
+ }
1128
+
1129
+ // cmi5 only: inherit the LMS launch LRS (endpoint+auth+actor+activityId+registration):
1130
+ xapi: { endpoint: 'lms' }
1131
+
1132
+ // Fan out (at most one 'lms' entry):
1133
+ xapi: [
1134
+ { endpoint: 'lms' },
1135
+ { endpoint: 'https://analytics.example.com/xapi/', auth, actor, activityId },
1136
+ ]
1137
+ ```
1138
+
1139
+ 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).
1140
+
1141
+ ### Per-mode behaviour
1142
+
1143
+ | Mode | `xapi` not set | `xapi.endpoint: 'lms'` | `xapi: {endpoint, ...}` (explicit) |
1144
+ | ------------- | ------------------ | ------------------------------------------------------- | ----------------------------------------------------------------- |
1145
+ | **cmi5** | `useXAPI()` → null | Inherits launch LRS; shares queue with lifecycle stream | Independent publisher; `actor` defaults to launch actor |
1146
+ | **scorm12** | `useXAPI()` → null | **Config error** | Independent publisher; `actor` derived from `cmi.core.student_id` |
1147
+ | **scorm2004** | `useXAPI()` → null | **Config error** | Independent publisher; `actor` derived from `cmi.learner_id` |
1148
+ | **web** | `useXAPI()` → null | **Config error** | Independent publisher; `actor` **required** in config |
1149
+
1150
+ ### Actor resolution
1151
+
1152
+ Priority order (top wins):
1153
+
1154
+ 1. **Author-supplied `xapi.actor`**: always wins.
1155
+ 2. **cmi5 launch actor**: under cmi5, the publisher uses the same Agent the LMS handed us at launch.
1156
+ 3. **SCORM-derived actor**: under scorm12/scorm2004, the publisher synthesizes:
1157
+ ```ts
1158
+ {
1159
+ account: {
1160
+ homePage: xapi.actorAccountHomePage ?? originOf(xapi.activityId),
1161
+ name: <cmi.core.student_id | cmi.learner_id>,
1162
+ },
1163
+ name: <cmi.core.student_name | cmi.learner_name>,
1164
+ objectType: 'Agent',
1165
+ }
1166
+ ```
1167
+ 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.
1168
+ 4. **Fallback: error.** Web export with no `actor` fails at config time.
1169
+
1170
+ 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.
1171
+
1172
+ ### Auth
1173
+
1174
+ v1 supports **Basic auth only**. The publisher prepends `Basic ` to whatever your `auth` value resolves to; pass the credential value, not the full header.
1175
+
1176
+ 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).
1177
+
1178
+ 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.
1179
+
1180
+ **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.
1181
+
1182
+ ### Retry policy
1183
+
1184
+ - **Default:** 3 attempts with exponential backoff (100ms, 200ms, 400ms).
1185
+ - **5xx / network errors** retry. **4xx** short-circuits; retrying won't help.
1186
+ - **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).
1187
+ - **Per-statement opt-out:** `sendStatement(stmt, { retry: false })` for fire-and-forget telemetry where the author would rather drop than block.
1188
+
1189
+ ### `sendStatement` return shape
1190
+
1191
+ ```ts
1192
+ const result = await xapi.sendStatement({ verb, object });
1193
+ // result: {
1194
+ // statementId: string,
1195
+ // statement: Statement, // fully resolved: actor, context, timestamp filled in
1196
+ // destinations: [{ endpoint, ok, status?, error? }, ...]
1197
+ // }
1198
+ ```
1199
+
1200
+ `destinations[]` lets you act on partial failures under fan-out: one LRS can be down without affecting the others.
1201
+
1202
+ ### Validation
1203
+
1204
+ The publisher checks three things before sending:
1205
+
1206
+ 1. `verb.id`: present, non-empty string.
1207
+ 2. `object.id`: non-empty string when `object` is supplied.
1208
+ 3. `result.score.scaled`: number in `[-1, 1]` when supplied.
1209
+
1210
+ Everything else passes through. The LRS gives clearer errors for IRI / extension / attachment shape issues than we can; failures surface via `destinations[].error`.
1211
+
1212
+ ### Mode-specific caveats
1213
+
1214
+ **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, so `sendStatement` rejects with an explicit error.
1215
+
1216
+ **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.
1217
+
1218
+ **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.
1219
+
1220
+ **Page unload.** Once unload begins, every publisher is marked unloading and `useXAPI()?.sendStatement(...)` calls reject; this is 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`.
1221
+
1222
+ ### Non-goals (v1)
1223
+
1224
+ - Bearer / OAuth credentials at the publisher level (wrap in your `auth` function).
1225
+ - Statement signing / attachments helpers (the publisher accepts attachments but doesn't help build them).
1226
+ - Offline queue / IndexedDB durability.
1227
+ - LRS State API access for non-cmi5 modes.
1228
+ - Voiding statements.
1229
+ - Mid-session actor refresh (`refreshActor()`).
1230
+ - Group actors (Agent only).
1231
+
1232
+ ---
1233
+
1234
+ ## LMS Adapter Reference
1235
+
1236
+ 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.
1237
+
1238
+ ### Cross-mode rollup
1239
+
1240
+ | Runtime event | SCORM 1.2 | SCORM 2004 4th | cmi5 |
1241
+ | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1242
+ | 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; `GET` `LMS.LaunchData` State (§10, → session id + Publisher Activity + launchMode + returnURL + masteryScore + moveOn); `GET` `cmi5LearnerPreferences` Agent Profile (§11); build publisher; send **Initialized**; `GET` `tessera-state` for resume |
1243
+ | 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 |
1244
+ | 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) |
1245
+ | 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` and `result.duration` (one-shot per session). cmi5 §9.5.1 forbids `score` on Completed — the score rides on the subsequent **Passed**/**Failed** instead. |
1246
+ | Author marks complete (`completion.mode: "manual"`) | `cmi.core.lesson_status = "completed"` (or `"passed"`/`"failed"` if `requireSuccessStatus` set) | `cmi.completion_status = "completed"`; `cmi.success_status = "unknown"` (or `"passed"`/`"failed"` if `requireSuccessStatus` set) | **Completed** statement; **Passed**/**Failed** if `requireSuccessStatus` set |
1247
+ | 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` |
1248
+ | 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 |
1249
+ | Author exit / unload | `LMSSetValue("cmi.core.exit", "suspend"\|"")`, `LMSCommit("")`, `LMSFinish("")` (queue drained synchronously) | `SetValue("cmi.exit", "suspend"\|"normal"\|...)`, `Commit("")`, `Terminate("")` (queue drained synchronously) | **Terminated** (always last on the wire, cmi5 §9.3.6). Explicit-exit path: `adapter.exit()` drains the queue then redirects to `returnURL` (§10.2.6). No Suspended verb — incomplete exit is signalled by Terminated without a preceding Completed; the LMS handles Abandoned and resume on next launch. |
1250
+ | 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) |
1251
+ | 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) |
1252
+ | Score scale exposed to LMS | `score.raw` only (0–100) | `score.raw` (0–100) **and** `score.scaled` (0–1) | `result.score.scaled` (0–1) |
1253
+
1254
+ The SCORM adapter's internal `commit()` (the `LMSCommit` / `Commit` call) is microtask-coalesced — multiple state mutations within one tick collapse to a single API call. cmi5 statements are individual (no batched commit).
1255
+
1256
+ ### SCORM 1.2 notes
1257
+
1258
+ API discovery: walks `window.parent` / `window.opener` up to 10 levels looking for `API`.
1259
+
1260
+ **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`.
1261
+
1262
+ **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.
1263
+
1264
+ **Interaction encoding (§3.4.7).** Plain `,` items, `.` pairs, `:` ranges (not the bracketed `[,]` 2004 form). `cmi.interactions.n.id` and response/correct identifiers are slugged to `CMIIdentifier` (alphanumeric + underscore, max 250 chars) — raw option text like `"88 Earth days"` becomes `88_Earth_days`, and an id like `q-1` becomes `q_1`, to dodge `405 Incorrect Data Type`. `true-false` writes `t`/`f`. Numeric `correct_responses.n.pattern` is a single CMIDecimal; ranges are dropped (`result` still carries pass/fail).
1265
+
1266
+ **Field write order.** `id` → `type` → `correct_responses.0.pattern` → `student_response` → `result` → `time`, matching the spec's `interactions._children` ordering. SCORM Cloud's strict validator rejects `student_response` with the misleading "must be consistent with interaction type" if `correct_responses.0.pattern` hasn't been declared first — the LMS has no expected pattern to validate against. Other LMSes (Moodle, Reload, scorm-again) accept any order, but the spec ordering is the safest.
1267
+
1268
+ **Bookmark.** `cmi.core.lesson_location` is written from `SavedState.b` on every `saveState` to surface "Resume from page N" in LMS UIs.
1269
+
1270
+ **Not implemented.** No `cmi.objectives.*` writes. No SCORM 1.2 sequencing; `navigation.canAccess` is the only gating layer, and the LMS sees one SCO. SCORM 1.2 `time-out` / `logout` exit values are not emitted.
1271
+
1272
+ **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.
1273
+
1274
+ ### SCORM 2004 4th notes
1275
+
1276
+ API discovery: `API_1484_11` via the same parent/opener walk.
1277
+
1278
+ **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.
1279
+
1280
+ **LMS-supplied thresholds.** `cmi.scaled_passing_score` (§4.2.4.3) is read on init and exposed via `adapter.getMasteryScore()`. `App.svelte` picks up `masteryScore` and overrides `scoring.passingScore` for the launch — parity with cmi5's launch-time mastery.
1281
+
1282
+ **Launch mode (§4.2.1.5).** `cmi.mode` is read on init. In `browse` and `review` launches every learner-record write is silently suppressed (`setScore` / `setCompletionStatus` / `setSuccessStatus` / `setExit` / `setDuration` / `reportInteraction` / `saveState` — including the `cmi.suspend_data` write). Mirrors cmi5's launchMode handling; exposed via `adapter.getLaunchMode()`.
1283
+
1284
+ **Interaction encoding (§4.2.7 / Appendix A).** Bracketed delimiters `[,]` / `[.]` / `[:]` (literal text, not regex). Identifiers are passed through unchanged — §4.2.7 / Appendix A's `short_identifier_type` allows any printable, and 2004's `cmi.interactions.n.id` upgraded to `long_identifier_type` (4000 chars). Slugging would only obscure LMS-side reports without buying anything. `cmi.interactions.n.timestamp` is `time(second,10,0)` per §3.3.10.1 / ISO 8601 §5.3.3 — zone-free, second-resolution (`YYYY-MM-DDThh:mm:ss`); SCORM Cloud rejects fractional seconds and `Z` / `±hh:mm` suffixes with 406.
1285
+
1286
+ **Bookmark + progress.** `cmi.location` is written from `SavedState.b` on every `saveState`. `cmi.progress_measure = 1` fires on `setCompletionStatus('complete')` so LMS dashboards show 100%.
1287
+
1288
+ **Real precision.** All CMIDecimal-like writes (`score.raw`, `score.scaled`, etc.) round through `formatReal107` — SCORM 2004 4E defines them as `real(10,7)`, and `String(1/3)` would otherwise trip 406.
1289
+
1290
+ **Not implemented.** `imsss:sequencing` rules are omitted from `imsmanifest.xml` by design. No `cmi.objectives.*`, no `cmi.adl.nav.*` writes.
1291
+
1292
+ **Local testing.** SCORM Cloud is the easiest end-to-end check. Moodle, Cornerstone, SuccessFactors, and Canvas (via Rustici Engine) accept `dist/*-scorm2004.zip` directly.
1293
+
1294
+ ### cmi5 notes
1295
+
1296
+ **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`.
1297
+
1298
+ **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. If the fetch URL responds with the spec-defined `{"error-code":...,"error-text":...}` shape (§8.2.3 — typically the single-use violation on a refresh), `adapter.init()` throws with the LMS's error-code/text instead of stuffing the JSON blob into the `Basic` credential and 400-spamming the LRS.
1299
+
1300
+ **Lifecycle order.** **Initialized** → **Answered** (one per question, as each widget calls `q.commit()`; uncommitted ones flush at submit) → **Completed** → **Passed** / **Failed** → **Terminated** (always last, cmi5 §9.3.6). Completed is one-shot per registration (never re-emitted on resume); Passed/Failed are re-emitted only on a _status transition_ (e.g., a learner who failed in session 1 and passes in session 2 fires a fresh Passed in session 2, but a learner who passed before and resumes does not re-emit). The runtime seeds the adapter at restore time via `seedLifecycle()` so the LMS isn't spammed with duplicates that 403 as "completion status already determined." **Satisfied** and **Suspended** are not emitted by the AU — Satisfied is LMS-only (§9.3.9), and Suspended isn't a cmi5 verb (§9.3 enumerates nine; the LMS handles Abandoned / resume on relaunch).
1301
+
1302
+ **Required result fields.** Completed: `completion: true`, `duration` (no `score` — §9.5.1 forbids it). Passed: `success: true`, `duration`, `result.score.scaled` when known (§9.3.4 requires `scaled >= masteryScore` when present). Failed: `success: false`, `duration`, `result.score.scaled` when known (§9.3.5 requires `scaled < masteryScore` when present). Terminated: `duration` (§9.5.4.1). On contradiction the verb is preserved and the score is dropped with a console warning.
1303
+
1304
+ **Context per Defined Statement.** Categories: `cmi5` Category Activity on every Defined Statement (§9.6.2.1); plus `moveOn` Category on Completed / Passed / Failed (§9.6.2.2). Extensions: `sessionid` (§9.6.3.1) on every statement (Defined and Allowed) — value sourced from `LMS.LaunchData.contextTemplate` when supplied, else minted UUID. `masteryScore` extension on Passed / Failed only (§9.6.3.2). The full `contextTemplate` from `LMS.LaunchData` is merged in (§9.6.2 makes it the AU's base context; §10.2.1 says AU MUST NOT overwrite template values, so the AU's categories are concatenated and deduped against the template's, never replacing them).
1305
+
1306
+ **`LMS.LaunchData` (§10).** Fetched once at init from the State API under `stateId='LMS.LaunchData'`. The AU reads `contextTemplate`, `launchMode`, `returnURL`, and `masteryScore` from it. LaunchData values override anything parsed from the launch URL (§10.2.4 makes LaunchData authoritative). When the document is absent, statements ship without the LMS-supplied Publisher Activity and may be rejected by strict LRSes — a console warning fires.
1307
+
1308
+ **Learner Preferences (§11).** `cmi5LearnerPreferences` from the Agent Profile API, fetched _before_ Initialized — strict LRSes (SCORM Cloud) track that the GET happened and reject Initialized otherwise. A 404 here is normal (no preferences set); only the GET itself is required, and the response body is not consumed.
1309
+
1310
+ **Launch mode (§10.2.2).** "Normal" launches emit the full lifecycle. "Browse" and "Review" launches emit only Initialized and Terminated — every other Defined Statement is silently suppressed. Exposed via `adapter.getLaunchMode()`.
1311
+
1312
+ **Return URL (§10.2.6).** `adapter.exit()` is the explicit-exit path: calls `terminate()`, awaits the publisher queue so Terminated lands before navigation, then `window.location.assign(returnURL)`. The page-unload `terminate()` path can't redirect (the browser is already navigating).
1313
+
1314
+ **State persistence.** `tessera-state` document via the State API, keyed by `activityId` + `agent` + `registration?` + `stateId='tessera-state'` (distinct from the LMS-owned `LMS.LaunchData` and `cmi5LearnerPreferences` documents). Writes chain onto the publisher's queue so the suspend payload lands before Terminated.
1315
+
1316
+ **Manifest (`cmi5.xml`).** Generated by the plugin: course id + AU id are stable URNs derived from `config.title` (`urn:tessera:{course,au}:<hex>`). `<au>` carries `launchMethod="AnyWindow"` (CourseStructure XSD requires it), `moveOn` (`Completed` for percentage/manual, `CompletedAndPassed` for quiz mode), and `masteryScore` (rounded to 4 decimal places per §10.2.4). The `<url>` is a child element of `<au>`, not an attribute.
1317
+
1318
+ **Not implemented.** No multi-AU courses (one course = one AU in v1). No **Waived** or **Abandoned** verbs (LMS-only). No mid-session actor refresh.
1319
+
1320
+ **Local testing.** Upload `dist/*-cmi5.zip` to SCORM Cloud and use the cmi5 dispatch URL it generates, the closest free equivalent to a real LMS launch.
1321
+
1322
+ ### Common adapter behaviour
1323
+
1324
+ **Queue + retry.** SCORM adapters serialize every `LMSSetValue` / `LMSCommit` through a sequential queue with exponential-backoff retry on transient errors. Each enqueue carries the cmi key as `context`; retry warnings include the real LMS error code (`GetLastError`), the message (`GetErrorString`), and — when supplied — the verbose diagnostic (`GetDiagnostic`, which SCORM Cloud uses to name the offending element). The give-up log reads e.g. `[cmi.interactions.0.timestamp] (LMS error 406: Data Model Element Type Mismatch — is not a valid time type)`.
1325
+
1326
+ **Init / terminate logging.** `Initialize` failures fire a top-level warning that names the LMS error code and notes downstream writes will all 301. Malformed `cmi.suspend_data` and non-numeric `cmi.interactions._count` are logged loudly — the latter is dangerous to silently fall back to 0 (the next session would overwrite prior records). Terminate-path `Commit` / `Terminate` / `LMSFinish` failures route through `callSyncOrWarn` so the last-chance writes aren't silent.
1327
+
1328
+ **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.
1329
+
1330
+ **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.
1331
+
1332
+ ---
1333
+
1334
+ ## Custom Layouts
1335
+
1336
+ Drop `layout.svelte` at the project root to replace the default sidebar/topbar/prev-next chrome. The runtime uses it whenever it exists.
1337
+
1338
+ 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.
1339
+
1340
+ ```svelte
1341
+ <!-- layout.svelte -->
1342
+ <script>
1343
+ import { useNavigation, useProgress } from 'tessera-learn';
1344
+
1345
+ let { page } = $props();
1346
+ const nav = useNavigation();
1347
+ const progress = useProgress();
1348
+ </script>
1349
+
1350
+ <header>
1351
+ <h1>{nav.currentPage.title}</h1>
1352
+ <span>{progress.visitedPages.size} / {nav.pages.length} visited</span>
1353
+ </header>
1354
+
1355
+ <main>{@render page()}</main>
1356
+
1357
+ <footer>
1358
+ <button disabled={!nav.canGoPrev} onclick={() => nav.prev()}>Prev</button>
1359
+ <button disabled={!nav.canGoNext} onclick={() => nav.next()}>Next</button>
1360
+ </footer>
1361
+ ```
1362
+
1363
+ To keep most of the default chrome and swap one piece, import `DefaultLayout` from `tessera-learn` and compose around it.
1364
+
1365
+ ---
1366
+
1367
+ ## Cookbook
1368
+
1369
+ End-to-end recipes that exercise the full hooks API. Adapt to taste.
1370
+
1371
+ ### Recipe 1: Custom "draw a line" question
1372
+
1373
+ 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.
1374
+
1375
+ ```svelte
1376
+ <!-- pages/05-pairs/01-pairs/draw-pairs.svelte -->
1377
+ <script module>
1378
+ export const pageConfig = { title: 'Match the elements' };
1379
+ </script>
1380
+
1381
+ <script>
1382
+ import { useQuestion, usePersistence } from 'tessera-learn';
1383
+
1384
+ const store = usePersistence('draw-pairs:v1');
1385
+ let pairs = $state(store.get() ?? []);
1386
+ $effect(() => store.set(pairs));
1387
+
1388
+ const q = useQuestion({
1389
+ id: 'draw-pairs-1',
1390
+ response: () => ({
1391
+ type: 'matching',
1392
+ response: pairs,
1393
+ correct: [
1394
+ ['Hydrogen', 'H'],
1395
+ ['Helium', 'He'],
1396
+ ['Lithium', 'Li'],
1397
+ ],
1398
+ }),
1399
+ reset: () => {
1400
+ pairs = [];
1401
+ },
1402
+ });
1403
+
1404
+ function connect(l, r) {
1405
+ pairs = [...pairs.filter(([a]) => a !== l), [l, r]];
1406
+ }
1407
+ </script>
1408
+
1409
+ <svg
1410
+ width="400"
1411
+ height="200"
1412
+ role="img"
1413
+ aria-label="Drag to match elements to their symbols"
1414
+ >
1415
+ <!-- canvas + line-drawing UI calls connect(l, r) on drop -->
1416
+ </svg>
1417
+
1418
+ {#if q.mode === 'standalone'}
1419
+ <button onclick={() => q.submit()} disabled={q.submitted}>Check</button>
1420
+ {#if q.correct === true}<p>Correct.</p>{/if}
1421
+ {#if q.correct === false}<button onclick={() => q.reset()}>Try again</button
1422
+ >{/if}
1423
+ {/if}
1424
+ ```
1425
+
1426
+ ### Recipe 2: Custom topbar layout
1427
+
1428
+ Replace the default sidebar with a horizontal topbar showing breadcrumb + progress %. Drop `layout.svelte` at the project root; no other changes needed.
1429
+
1430
+ ```svelte
1431
+ <!-- layout.svelte -->
1432
+ <script>
1433
+ import { useNavigation, useProgress } from 'tessera-learn';
1434
+
1435
+ let { page } = $props();
1436
+ const nav = useNavigation();
1437
+ const progress = useProgress();
1438
+
1439
+ const percent = $derived(
1440
+ Math.round((progress.visitedPages.size / nav.pages.length) * 100),
1441
+ );
1442
+ </script>
1443
+
1444
+ <header class="topbar">
1445
+ <span class="brand">My Course</span>
1446
+ <span class="crumb">{nav.currentPage.section} › {nav.currentPage.title}</span>
1447
+ <span class="progress" aria-live="polite">{percent}% complete</span>
1448
+ </header>
1449
+
1450
+ <main class="content">{@render page()}</main>
1451
+
1452
+ <nav class="footer">
1453
+ <button disabled={!nav.canGoPrev} onclick={() => nav.prev()}>← Back</button>
1454
+ <select
1455
+ onchange={(e) => nav.goTo(e.currentTarget.value)}
1456
+ value={nav.currentPage.slug}
1457
+ >
1458
+ {#each nav.pages as p}<option value={p.slug}>{p.title}</option>{/each}
1459
+ </select>
1460
+ <button disabled={!nav.canGoNext} onclick={() => nav.next()}>Next →</button>
1461
+ </nav>
1462
+
1463
+ <style>
1464
+ .topbar {
1465
+ display: flex;
1466
+ gap: 1rem;
1467
+ padding: 0.75rem 1.5rem;
1468
+ border-bottom: 1px solid var(--tessera-border);
1469
+ }
1470
+ .content {
1471
+ max-width: var(--tessera-content-max-width);
1472
+ margin: 0 auto;
1473
+ padding: 2rem;
1474
+ }
1475
+ .footer {
1476
+ display: flex;
1477
+ gap: 1rem;
1478
+ padding: 1rem 1.5rem;
1479
+ border-top: 1px solid var(--tessera-border);
1480
+ }
1481
+ </style>
1482
+ ```
1483
+
1484
+ ### Recipe 3: Prerequisite-based access
1485
+
1486
+ Lock lesson 5 until lessons 1–3 are visited. Composes with `sequentialAccess` instead of re-implementing it.
1487
+
1488
+ ```js
1489
+ // course.config.js
1490
+ import { sequentialAccess } from 'tessera-learn';
1491
+
1492
+ const PREREQS = ['lesson-1', 'lesson-2', 'lesson-3'];
1493
+
1494
+ export default {
1495
+ title: 'My Course',
1496
+ navigation: {
1497
+ mode: 'sequential',
1498
+ canAccess: (ctx) => {
1499
+ if (!sequentialAccess(ctx)) return false;
1500
+ if (ctx.page.slug !== 'lesson-5') return true;
1501
+ return PREREQS.every((slug) => {
1502
+ const i = ctx.manifest.pages.findIndex((p) => p.slug === slug);
1503
+ return i >= 0 && ctx.progress.visitedPages.has(i);
1504
+ });
1505
+ },
1506
+ },
1507
+ completion: { mode: 'percentage', percentageThreshold: 100 },
1508
+ scoring: { passingScore: 70 },
1509
+ export: { standard: 'web' },
1510
+ };
1511
+ ```
1512
+
1513
+ ### Recipe 4: Custom quiz shell via `quiz.svelte`
1514
+
1515
+ 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 default. The shell uses only the public `useQuiz()` API; no imports from `tessera-learn/runtime/*`.
1516
+
1517
+ ```svelte
1518
+ <!-- quiz.svelte -->
1519
+ <script>
1520
+ import { useQuiz } from 'tessera-learn';
1521
+
1522
+ let { children } = $props();
1523
+ let host;
1524
+
1525
+ // useQuiz owns submission, retry, review, score, and dispatching
1526
+ // tessera-quiz-complete. The shell only drives the UI on top of it.
1527
+ const quiz = useQuiz({ element: () => host });
1528
+ </script>
1529
+
1530
+ <div bind:this={host} class="my-quiz">
1531
+ <p>
1532
+ Question {quiz.questions.findIndex((q) => !q.submitted) + 1} of {quiz
1533
+ .questions.length}
1534
+ </p>
1535
+
1536
+ {#each quiz.questions as q (q.id)}
1537
+ <section data-question-id={q.id}>
1538
+ {#if q.render}{@render q.render()}{/if}
1539
+ </section>
1540
+ {/each}
1541
+
1542
+ {#if quiz.state === 'answering'}
1543
+ <button disabled={!quiz.canSubmit} onclick={() => quiz.submit()}
1544
+ >Submit</button
1545
+ >
1546
+ {:else if quiz.state === 'submitted'}
1547
+ <p>You scored {quiz.score}% (pass at {quiz.passingScore}%)</p>
1548
+ {#if quiz.canRetry}<button onclick={() => quiz.retry()}>Retry</button>{/if}
1549
+ <button onclick={() => quiz.startReview()}>Review</button>
1550
+ {/if}
1551
+
1552
+ <!-- Children render hidden so widget state survives submit/review. -->
1553
+ <div style="display:none">{@render children?.()}</div>
1554
+ </div>
1555
+ ```
1556
+
1557
+ Always submit through `useQuiz().submit()`. See [Data contract](#data-contract-what-the-lms-sees).
1558
+
1559
+ ### Recipe 4b: Custom question widget for a custom quiz shell
1560
+
1561
+ Companion to Recipe 4. The widget calls `useQuestion()` for a `Question` handle, registers a render snippet for the shell with `setRender`, pushes the learner's answer up with `setAnswer`, calls `commit()` when the answer is final, and reads `locked` / `feedbackVisible` / `answer` to render. No `getContext('tessera-quiz')`, no index tracking — `useQuestion` and `useQuiz` traffic in the same `Question` object.
1562
+
1563
+ ```svelte
1564
+ <!-- components/MyChoice.svelte -->
1565
+ <script>
1566
+ import { onMount } from 'svelte';
1567
+ import { useQuestion } from 'tessera-learn';
1568
+
1569
+ let { id, prompt, options, correct } = $props();
1570
+ let selected = $state(null);
1571
+
1572
+ const q = useQuestion({
1573
+ id,
1574
+ response: () => ({
1575
+ type: 'choice',
1576
+ response: selected !== null ? [String(selected)] : [],
1577
+ correct: [String(correct)],
1578
+ }),
1579
+ reset: () => {
1580
+ selected = null;
1581
+ },
1582
+ });
1583
+
1584
+ // Register the snippet the shell will render. mode === 'quiz' inside a quiz host;
1585
+ // 'standalone' when used outside one. setRender is a no-op in standalone.
1586
+ onMount(() => q.setRender(view));
1587
+
1588
+ function pick(i) {
1589
+ if (q.locked) return;
1590
+ selected = i;
1591
+ q.setAnswer(i);
1592
+ q.commit();
1593
+ }
1594
+ </script>
1595
+
1596
+ {#snippet view()}
1597
+ <fieldset disabled={q.locked}>
1598
+ <legend>{prompt}</legend>
1599
+ {#each options as opt, i}
1600
+ {@const chosen = (q.feedbackVisible ? q.answer : selected) === i}
1601
+ <label>
1602
+ <input type="radio" checked={chosen} onchange={() => pick(i)} />
1603
+ {opt}
1604
+ </label>
1605
+ {/each}
1606
+ </fieldset>
1607
+
1608
+ {#if q.feedbackVisible}
1609
+ <p>
1610
+ {q.answer === correct
1611
+ ? 'Correct.'
1612
+ : 'The right answer was ' + options[correct] + '.'}
1613
+ </p>
1614
+ {/if}
1615
+ {/snippet}
1616
+
1617
+ <!-- Render the same snippet inline for standalone use (mode === 'standalone'). -->
1618
+ {#if q.mode === 'standalone'}
1619
+ {@render view()}
1620
+ {#if !q.submitted}
1621
+ <button disabled={selected === null} onclick={() => q.submit()}
1622
+ >Check</button
1623
+ >
1624
+ {/if}
1625
+ {/if}
1626
+ ```
1627
+
1628
+ Under `feedbackMode: 'immediate'`, the shell calls `quiz.revealFeedback(q)` when it wants the next click to show feedback; that flips `q.feedbackVisible`, which in turn flips `q.locked`. Under `'review'`, feedback only appears after `quiz.submit()` followed by `quiz.startReview()`. Under `'never'`, `feedbackVisible` stays false, but `q.locked` still flips on submit.
1629
+
1630
+ ### Recipe 5: Graded standalone question
1631
+
1632
+ 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.
1633
+
1634
+ ```svelte
1635
+ <!-- pages/04-reflection/01-reflect/reflect.svelte -->
1636
+ <script module>
1637
+ export const pageConfig = { title: 'Reflection' };
1638
+ </script>
1639
+
1640
+ <script>
1641
+ import { useQuestion } from 'tessera-learn';
1642
+
1643
+ let answer = $state('');
1644
+
1645
+ const q = useQuestion({
1646
+ id: 'why-it-matters',
1647
+ graded: true,
1648
+ response: () => ({
1649
+ type: 'long-fill-in',
1650
+ response: answer,
1651
+ // No `correct`: any answer accepted; we just want completion.
1652
+ }),
1653
+ score: () => (answer.trim().length >= 50 ? 100 : 0),
1654
+ reset: () => {
1655
+ answer = '';
1656
+ },
1657
+ });
1658
+ </script>
1659
+
1660
+ <h1>Why does this matter to you?</h1>
1661
+ <p>At least 50 characters required to pass.</p>
1662
+
1663
+ <textarea bind:value={answer} rows="6" disabled={q.submitted}></textarea>
1664
+ <button
1665
+ onclick={() => q.submit()}
1666
+ disabled={q.submitted || answer.trim().length < 50}
1667
+ >
1668
+ Submit
1669
+ </button>
1670
+
1671
+ {#if q.submitted}<p>Thanks. Your reflection has been recorded.</p>{/if}
1672
+ ```
1673
+
1674
+ The LMS sees a graded `long-fill-in` interaction. Course success rolls up across all graded items: quizzes and standalones alike.
1675
+
1676
+ ### Recipe 6: Chunked-reveal page with `markChunk`
1677
+
1678
+ 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.
1679
+
1680
+ ```svelte
1681
+ <!-- pages/02-deep-dive/01-concepts/long-read.svelte -->
1682
+ <script module>
1683
+ export const pageConfig = { title: 'How it works' };
1684
+ </script>
1685
+
1686
+ <script>
1687
+ import { useNavigation, useProgress } from 'tessera-learn';
1688
+
1689
+ const nav = useNavigation();
1690
+ const progress = useProgress();
1691
+ const pageIndex = $derived(nav.currentPageIndex);
1692
+
1693
+ const TOTAL_CHUNKS = 4;
1694
+ let revealed = $state(progress.chunkProgress.get(pageIndex) ?? 0);
1695
+
1696
+ function reveal() {
1697
+ revealed = Math.min(revealed + 1, TOTAL_CHUNKS - 1);
1698
+ progress.markChunk(pageIndex, revealed);
1699
+ }
1700
+ </script>
1701
+
1702
+ {#each Array(revealed + 1) as _, i}
1703
+ <section>
1704
+ <h2>Step {i + 1}</h2>
1705
+ <p>Content for step {i + 1}.</p>
1706
+ </section>
1707
+ {/each}
1708
+
1709
+ {#if revealed < TOTAL_CHUNKS - 1}
1710
+ <button onclick={reveal}>Show next</button>
1711
+ {/if}
1712
+ ```
1713
+
1714
+ 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.
1715
+
1716
+ ### Recipe 7: Persisted UI state with `usePersistence`
1717
+
1718
+ `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.
1719
+
1720
+ ```svelte
1721
+ <!-- in any page component, layout.svelte, or a custom widget -->
1722
+ <script>
1723
+ import { usePersistence } from 'tessera-learn';
1724
+
1725
+ const ui = usePersistence('sidebar-prefs');
1726
+ let collapsed = $state(ui.get()?.collapsed ?? false);
1727
+ $effect(() => ui.set({ collapsed }));
1728
+ </script>
1729
+
1730
+ <button onclick={() => (collapsed = !collapsed)}>
1731
+ {collapsed ? 'Expand' : 'Collapse'}
1732
+ </button>
1733
+ ```
1734
+
1735
+ 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`.
1736
+
1737
+ ---
1738
+
1739
+ ## Constraints
1740
+
1741
+ - **No runtime data fetching in pages.** Page content is static; no `fetch()` or dynamic loaders in page components.
1742
+ - **Public API only.** Import from `tessera-learn`. Do **not** import from `tessera-learn/runtime/*`; those paths are internal and may change.
1743
+ - **`pageConfig` must be a static object literal.** Trailing commas, unquoted keys, and single quotes are fine (JSON5-parseable); variables, function calls, template literals, and computed values are not.
1744
+ - **Third-party libraries** must be project dependencies in `package.json`.