tessera-learn 0.2.1 → 0.2.3

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 (30) hide show
  1. package/AGENTS.md +280 -916
  2. package/README.md +3 -3
  3. package/dist/{audit-BNrvFHq_.js → audit--fSWIOgK.js} +156 -33
  4. package/dist/{audit-BNrvFHq_.js.map → audit--fSWIOgK.js.map} +1 -1
  5. package/dist/{build-commands-BWnATKat.js → build-commands-Qyrlsp3n.js} +2 -2
  6. package/dist/{build-commands-BWnATKat.js.map → build-commands-Qyrlsp3n.js.map} +1 -1
  7. package/dist/{inline-config-Dudu5r8w.js → inline-config-DqAKsCNl.js} +2 -2
  8. package/dist/{inline-config-Dudu5r8w.js.map → inline-config-DqAKsCNl.js.map} +1 -1
  9. package/dist/plugin/cli.d.ts.map +1 -1
  10. package/dist/plugin/cli.js +33 -18
  11. package/dist/plugin/cli.js.map +1 -1
  12. package/dist/plugin/index.d.ts +0 -2
  13. package/dist/plugin/index.d.ts.map +1 -1
  14. package/dist/plugin/index.js +2 -2
  15. package/dist/{plugin-diNZaDJK.js → plugin-B-aiL9-V.js} +2 -2
  16. package/dist/{plugin-diNZaDJK.js.map → plugin-B-aiL9-V.js.map} +1 -1
  17. package/package.json +11 -8
  18. package/src/components/FillInTheBlank.svelte +3 -27
  19. package/src/components/Matching.svelte +4 -26
  20. package/src/components/MultipleChoice.svelte +8 -27
  21. package/src/components/QuestionShell.svelte +35 -0
  22. package/src/components/Sorting.svelte +4 -26
  23. package/src/plugin/a11y/audit.ts +239 -39
  24. package/src/plugin/a11y-cli.ts +1 -4
  25. package/src/plugin/cli.ts +2 -3
  26. package/src/plugin/course-root.ts +37 -9
  27. package/src/plugin/validate-cli.ts +10 -4
  28. package/src/runtime/adapters/cmi5.ts +5 -14
  29. package/src/runtime/adapters/index.ts +41 -38
  30. package/src/runtime/adapters/scorm12.ts +1 -1
package/AGENTS.md CHANGED
@@ -1,14 +1,14 @@
1
1
  # AGENTS.md: Tessera Course Authoring Guide
2
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.
3
+ Tessera is an LMS-tracking runtime for interactive learning content (SCORM 1.2 / SCORM 2004 4e / cmi5 / static web). It owns tracking, progress, completion/success rollup, persistence, and navigation gating. You own the presentation layer.
4
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.
5
+ This is the canonical reference for authoring a Tessera course. Read it before generating or editing course code. You are reading `node_modules/tessera-learn/AGENTS.md`; it updates when you bump `tessera-learn`.
6
6
 
7
7
  ---
8
8
 
9
9
  ## Workspaces
10
10
 
11
- A Tessera project is a **workspace**: one `package.json` and one `node_modules` shared by **many courses**, plus a `shared/` design system. Each course is a self-contained folder under `courses/`.
11
+ A Tessera project is a **workspace**: one `package.json` and one `node_modules` shared by many courses, plus a `shared/` design system. Each course is a self-contained folder under `courses/`.
12
12
 
13
13
  ```
14
14
  my-courses/
@@ -17,115 +17,85 @@ my-courses/
17
17
  │ ├── Button.svelte
18
18
  │ └── tokens.css
19
19
  ├── courses/
20
- │ ├── starter-course/ # a course = a content folder (course.config.js, pages/, …)
20
+ │ ├── starter-course/ # a course = course.config.js, pages/, …
21
21
  │ └── <next course>/
22
22
  └── AGENTS.md / CLAUDE.md # pointers to this guide (workspace root only)
23
23
  ```
24
24
 
25
- **Everything else in this guide describes a single course** — i.e. the contents of one `courses/<name>/` folder (`course.config.js`, `layout.svelte`, `pages/`, `styles/`).
25
+ Rules:
26
26
 
27
- **Open the workspace folder** (not an individual course) so this guide stays in scope and `$shared` resolves.
27
+ - **Open the workspace folder**, not an individual course this keeps the guide in scope and `$shared` resolving.
28
+ - Everything below "Project Structure" describes a single course: the contents of one `courses/<name>/`.
29
+ - Name the course on every command. A bare command at the workspace root errors and lists courses; it never picks one.
28
30
 
29
- ### Working with courses
31
+ ### Course commands
30
32
 
31
33
  ```bash
32
- pnpm tessera new <name> # scaffold courses/<name>/ (no install — deps already here)
33
- pnpm tessera duplicate <source> <new> # copy an existing course to courses/<new>/
34
- pnpm tessera dev <name> # run a command against a named course
35
- cd courses/<name> && pnpm exec tessera dev # …or cd into the course and run it without a name
36
- pnpm tessera export <name> # each course exports independently to its own LMS package
37
- cd courses/<name> && pnpm exec tessera export # …this works for every command, not just dev
34
+ pnpm tessera new <name> # scaffold courses/<name>/
35
+ pnpm tessera duplicate <source> <new> # copy a course to courses/<new>/
36
+ pnpm tessera dev <name> # run a command against a named course
37
+ cd courses/<name> && pnpm exec tessera dev # …or cd in and omit the name
38
+ pnpm tessera export <name> # each course exports independently
38
39
  ```
39
40
 
40
- A **bare command at the workspace root errors** and lists the available courses — it never silently picks one, so its meaning can't change as you add courses. Name the course, or `cd` into its folder. (The scaffolded root scripts `pnpm dev`, `pnpm export`, … pass straight through, so `pnpm dev <course>` runs that course and a bare `pnpm dev` errors just the same.)
41
+ The scaffolded root scripts (`pnpm dev`, `pnpm export`, …) pass through: `pnpm dev <course>` runs that course; bare `pnpm dev` errors.
41
42
 
42
- ### Sharing across courses with `$shared`
43
+ ### `$shared`
43
44
 
44
- `$shared` resolves to the workspace `shared/` directory, so any course can import the shared design system:
45
-
46
- ```svelte
47
- <script>
48
- import Button from '$shared/Button.svelte';
49
- import '$shared/tokens.css';
50
- </script>
51
-
52
- <Button>Continue</Button>
53
- ```
54
-
55
- `$shared` is bundled into each course's export at build time, so it ships in every SCORM/cmi5/web package with no extra wiring.
45
+ `$shared` resolves to the workspace `shared/` directory and is bundled into each course's export. Import from it in any course: `import Button from '$shared/Button.svelte'`, `import '$shared/tokens.css'`.
56
46
 
57
47
  ---
58
48
 
59
49
  ## Running the project
60
50
 
61
- From the workspace root (set up for `pnpm` — Node's corepack provisions it automatically). The `dev`/`export`/`validate`/`check` scripts take the course to run; a bare command lists the workspace's courses rather than picking one:
51
+ From the workspace root (`pnpm`; corepack provisions it). Each command takes the course name:
62
52
 
63
53
  ```bash
64
54
  pnpm install # first time only
65
55
  pnpm dev <course> # dev server at http://localhost:5173 (Ctrl+C to stop)
66
- pnpm export <course> # build + package for the LMS standard configured in course.config.js
67
- pnpm validate <course> # run project validation only — no server, no bundle
68
- pnpm check <course> # validate, then the runtime accessibility audit (axe) over the built course
56
+ pnpm export <course> # build + package for the LMS standard in course.config.js
57
+ pnpm validate <course> # run validation only — no server, no bundle
58
+ pnpm a11y <course> # runtime a11y audit on its own (the audit half of check)
59
+ pnpm check <course> # validate, then the runtime a11y audit (axe) over the built course
69
60
  ```
70
61
 
71
- 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`.
72
-
73
- `pnpm validate <course>` 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.
74
-
75
- `pnpm check <course>` 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:
76
-
77
- ```bash
78
- pnpm exec playwright install chromium
79
- ```
80
-
81
- See [Accessibility](#accessibility).
82
-
83
- `dev`, `export`, `validate`, and `check` are **reserved script names** — each is a thin alias for the matching `tessera` subcommand. Don't repurpose them.
62
+ - `dev` hot-reloads pages, layouts, components, and `course.config.js`.
63
+ - `validate` runs the same static checks as `dev`/`export`, exits non-zero on failure — the fast feedback loop.
64
+ - `check` runs `validate` then `tessera a11y` (builds, renders every page headless, runs axe-core; first run auto-installs Chromium). See [Accessibility](#accessibility).
65
+ - `dev` / `export` / `validate` / `a11y` / `check` are **reserved script names** aliasing the `tessera` subcommands. Don't repurpose them.
84
66
 
85
67
  ### Updating the framework
86
68
 
87
- Updating is a plain dependency bump from the project root — there is no `create-tessera upgrade`:
88
-
89
- ```bash
90
- pnpm add tessera-learn@latest
91
- ```
92
-
93
- 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.
94
-
95
- 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.
69
+ Plain dependency bump — there is no `create-tessera upgrade`: `pnpm add tessera-learn@latest` (or `@0.1.0` to pin). The framework owns the build, reserved scripts, and this guide, so a bump needs no reconciling; your root `CLAUDE.md`/`AGENTS.md` aren't overwritten — add workspace standards to their Project notes freely.
96
70
 
97
71
  ### Customising the build (optional)
98
72
 
99
- 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:
73
+ You never write `vite.config.js`. To extend the build, add `tessera.config.js` at the project root a **partial** Vite config merged on top of Tessera's (`tesseraPlugin()` and the Svelte compiler stay wired in). Never scaffolded, never touched by updates.
100
74
 
101
75
  ```js
102
- // tessera.config.js — merged on top of Tessera's Vite config
76
+ // tessera.config.js
103
77
  export default {
104
78
  server: { port: 4000 },
105
79
  resolve: { alias: { $lib: '/src/lib' } },
106
80
  };
107
81
  ```
108
82
 
109
- `tessera.config.js` is never scaffolded and never touched by updates — once you add it, it's yours.
110
-
111
83
  ---
112
84
 
113
85
  ## Project Structure
114
86
 
115
- The framework imposes the **minimum** structure it needs to discover content. Everything else is convention you can opt into.
116
-
117
87
  ### Required
118
88
 
119
89
  ```
120
90
  my-course/
121
91
  ├── course.config.js # Course configuration
122
92
  ├── package.json
123
- └── pages/ # Course content (at least one section dir with .svelte files)
93
+ └── pages/ # at least one section dir with .svelte files
124
94
  └── intro/
125
95
  └── welcome.svelte
126
96
  ```
127
97
 
128
- `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.
98
+ `pages/` must contain one or more **section directories**, each with one or more `.svelte` files (directly or in lesson subdirectories).
129
99
 
130
100
  ### Optional
131
101
 
@@ -133,10 +103,9 @@ my-course/
133
103
  my-course/
134
104
  ├── layout.svelte # Custom chrome (replaces default sidebar/topbar)
135
105
  ├── quiz.svelte # Custom quiz shell (replaces built-in <Quiz>)
136
- ├── assets/ # Images, audio, video files (referenced via $assets/)
106
+ ├── assets/ # Images, audio, video (referenced via $assets/)
137
107
  ├── styles/ # Custom CSS overrides
138
- ├── CLAUDE.md # Pointer that imports this guide for Claude Code
139
- ├── AGENTS.md # Pointer to this guide for other agents
108
+ ├── CLAUDE.md / AGENTS.md # Pointers to this guide
140
109
  └── pages/
141
110
  └── 01-intro/ # Numeric prefix → controls order
142
111
  ├── _meta.js # Override section title; control page order
@@ -146,116 +115,73 @@ my-course/
146
115
  └── overview.svelte
147
116
  ```
148
117
 
149
- ### What you can edit
118
+ ### Editing rules
150
119
 
151
- You own everything in the project directory: `pages/`, `course.config.js`, `layout.svelte`, `quiz.svelte`, custom components, `assets/`, and `styles/`. Edit those freely.
120
+ - **Edit freely:** `pages/`, `course.config.js`, `layout.svelte`, `quiz.svelte`, custom components, `assets/`, `styles/`.
121
+ - **Never edit `node_modules/`.** Edits there are git-ignored and wiped on the next install/update. There is no `vite.config.js` to edit.
122
+ - To change framework behaviour, use an extension point instead of patching `node_modules/`:
152
123
 
153
- **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:
124
+ | Need | Use |
125
+ | ---------------------------------------------- | -------------------------------------------- |
126
+ | New question type / interactive widget | custom component with the `useQuestion` hook |
127
+ | Different course chrome (header, nav) | `layout.svelte` |
128
+ | Different quiz UI | `quiz.svelte` with the `useQuiz` hook |
129
+ | Styling | `styles/` |
130
+ | Navigation, completion, scoring, export target | `course.config.js` |
154
131
 
155
- - **New question type or interactive widget** a custom component using the `useQuestion` hook.
156
- - **Different course chrome** (header, nav, layout) → `layout.svelte`.
157
- - **Different quiz UI** → `quiz.svelte` using the `useQuiz` hook.
158
- - **Styling** → `styles/`.
159
- - **Navigation, completion, scoring, or export target** → `course.config.js`.
160
-
161
- If none of those fit, the limitation is real — surface it rather than patching around it in `node_modules/`.
132
+ If none fit, surface the limitation don't patch around it in `node_modules/`.
162
133
 
163
134
  ### Hierarchy and ordering
164
135
 
165
- 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.
166
-
167
- 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.
168
-
169
- ### `_meta.js` files
136
+ - Manifest is always **section → lesson → page**. Files directly in a section folder flatten into one implicit lesson titled after the section; lesson subdirectories nest. Both shapes can coexist.
137
+ - Sorting is alphabetical by directory/filename. Numeric prefixes on directories (`01-`, `02-`) set explicit order and are stripped from slugs/titles (`01-getting-started/` → slug `getting-started`, title "Getting Started").
138
+ - Control page order **within a lesson** with `_meta.js`, not filename prefixes.
170
139
 
171
- **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.
140
+ ### `_meta.js`
172
141
 
173
- Reach for `_meta.js` only when the override is real:
174
-
175
- ```js
176
- // section or lesson _meta.js: title override (folder name doesn't auto-derive to what you want)
177
- export default { title: 'How to play' }; // folder is `01-intro`
178
- ```
142
+ Optional everywhere; defaults are title-cased slug + alphabetical pages. **Omit it unless you need a real override.** Two fields: `title` (folder name doesn't derive to what you want) and `pages` (explicit order — listed first, unlisted `.svelte` appended alphabetically):
179
143
 
180
144
  ```js
181
- // lesson _meta.js: explicit page order
182
- export default {
183
- title: 'Welcome',
184
- pages: ['welcome', 'objectives'],
185
- };
145
+ export default { title: 'How to play', pages: ['welcome', 'objectives'] };
186
146
  ```
187
147
 
188
- Pages listed in `pages` come first in listed order; any unlisted `.svelte` files are appended alphabetically.
189
-
190
148
  ---
191
149
 
192
150
  ## Authoring Surfaces
193
151
 
194
- 1. **Built-in components**: `Callout`, `Image`, `MultipleChoice`, `FillInTheBlank`, `Matching`, `Sorting`, etc., from `tessera-learn`. Use, compose, or skip.
195
- 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.
196
- 3. **Custom layout**: drop `layout.svelte` at the project root to replace the default chrome.
197
- 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`.
198
- 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).
152
+ 1. **Built-in components** `Callout`, `Image`, `MultipleChoice`, `FillInTheBlank`, `Matching`, `Sorting`, etc. from `tessera-learn`. Import only what you use.
153
+ 2. **Hooks** `useQuestion`, `useQuiz`, `useNavigation`, `useProgress`, `useCompletion`, `usePersistence`. The stable contract between custom widgets and the runtime.
154
+ 3. **Custom layout** `layout.svelte` at the project root replaces the default chrome.
155
+ 4. **Custom quiz shell** `quiz.svelte` at the project root replaces the quiz UI for every page with `pageConfig.quiz`.
156
+ 5. **Custom xAPI** `useXAPI()` emits your own verbs. See [Custom xAPI](#custom-xapi-statements).
199
157
 
200
- A custom widget that calls `useQuestion` and emits an `Interaction` is treated identically to `<MultipleChoice>`, with the same scoring, LMS reporting, and persistence.
158
+ A custom widget that calls `useQuestion` and emits an `Interaction` is scored, reported, and persisted identically to `<MultipleChoice>`.
201
159
 
202
160
  ---
203
161
 
204
162
  ## Creating Pages
205
163
 
206
- Each page is a `.svelte` file inside a lesson folder.
207
-
208
- ### Basic page
164
+ Each page is a `.svelte` file inside a lesson folder; standard HTML works as-is. Import components from `tessera-learn` (`import { Callout, Image } from 'tessera-learn'`).
209
165
 
210
- ```svelte
211
- <h1>Welcome</h1><p>Standard HTML works as-is.</p>
212
- ```
213
-
214
- ### Page configuration
215
-
216
- `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.
217
-
218
- Both `<script module>` (Svelte 5) and `<script context="module">` (legacy) are accepted by the manifest parser.
166
+ `pageConfig` sets the title and configures quizzes. It must be a **static object literal** in a module script block — no variables, function calls, or computed values. Both `<script module>` (Svelte 5) and `<script context="module">` (legacy) parse. If `title` is omitted it derives from the filename (`my-page.svelte` → "My Page").
219
167
 
220
168
  ```svelte
221
169
  <script module>
222
- export const pageConfig = {
223
- title: 'Introduction to the Topic',
224
- };
170
+ export const pageConfig = { title: 'Introduction to the Topic' };
225
171
  </script>
226
172
 
227
173
  <h1>Introduction to the Topic</h1>
228
174
  ```
229
175
 
230
- If `pageConfig.title` is omitted, the title is derived from the filename: `my-page.svelte` → "My Page".
231
-
232
- ### Importing components
233
-
234
- ```svelte
235
- <script>
236
- import { Callout, Image } from 'tessera-learn';
237
- </script>
238
-
239
- <Callout type="info">
240
- <p>Helpful information.</p>
241
- </Callout>
242
- ```
243
-
244
176
  ---
245
177
 
246
178
  ## Component Reference
247
179
 
248
- All components import from `tessera-learn`. Nothing is loaded automatically; import only what you use.
180
+ All components import from `tessera-learn`. Nothing loads automatically. Each is accessible by construction (ARIA roles, keyboard, focus management) — you only supply the props below.
249
181
 
250
182
  ### Callout
251
183
 
252
- Styled box for highlighting information.
253
-
254
- | Prop | Type | Default |
255
- | ------ | --------------------------------------------- | -------- |
256
- | `type` | `"info" \| "warning" \| "tip" \| "important"` | `"info"` |
257
-
258
- Children become the body. A11y: `role="note"` with type-appropriate `aria-label`.
184
+ Styled box; children become the body. Prop `type`: `"info"` (default) | `"warning"` | `"tip"` | `"important"`.
259
185
 
260
186
  ```svelte
261
187
  <Callout type="warning"><p>Be careful.</p></Callout>
@@ -263,16 +189,19 @@ Children become the body. A11y: `role="note"` with type-appropriate `aria-label`
263
189
 
264
190
  ### Image
265
191
 
266
- Lazy-loaded image with optional caption. Renders as `<figure>`/`<figcaption>`.
192
+ Lazy-loaded, renders as `<figure>`/`<figcaption>`.
193
+
194
+ | Prop | Type | Description |
195
+ | ------------ | --------- | ---------------------------------------------------------------------- |
196
+ | `src` | `string` | Image URL. `$assets/` prefix supported |
197
+ | `alt` | `string` | **Required unless `decorative`.** Alt text |
198
+ | `decorative` | `boolean` | Ornamental image — empty `alt` + `aria-hidden`. Use _instead of_ `alt` |
199
+ | `caption` | `string` | Optional caption |
267
200
 
268
- | Prop | Type | Description |
269
- | ------------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
270
- | `src` | `string` | Image URL. `$assets/` prefix supported |
271
- | `alt` | `string` | **Required unless `decorative`.** Alt text describing the image |
272
- | `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 |
273
- | `caption` | `string` | Optional caption |
201
+ Rules:
274
202
 
275
- 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).
203
+ - Every `<Image>` needs exactly one of: meaningful `alt`, or `decorative={true}`. The validator errors if neither is present.
204
+ - `decorative` is a boolean — write `decorative` or `decorative={true}`, never `decorative="true"` (a string is truthy and rejected).
276
205
 
277
206
  ```svelte
278
207
  <Image
@@ -280,14 +209,12 @@ Every `<Image>` must resolve to exactly one of: meaningful `alt` text, or `decor
280
209
  alt="System architecture diagram"
281
210
  caption="Figure 1"
282
211
  />
283
-
284
- <!-- Ornamental divider that adds nothing for a screen reader: -->
285
212
  <Image src="$assets/flourish.svg" decorative={true} />
286
213
  ```
287
214
 
288
215
  ### Accordion / AccordionItem
289
216
 
290
- Expandable panels. Only one open at a time. A11y: `aria-expanded`, `aria-controls`, `role="region"`, keyboard Enter/Space.
217
+ Expandable panels, one open at a time. `AccordionItem` takes a `title` prop; children are the body.
291
218
 
292
219
  ```svelte
293
220
  <Accordion>
@@ -302,7 +229,7 @@ Expandable panels. Only one open at a time. A11y: `aria-expanded`, `aria-control
302
229
 
303
230
  ### Carousel / CarouselSlide
304
231
 
305
- Slide-based viewer. A11y: `role="region"`, `aria-roledescription="carousel"`, arrow keys, mobile swipe.
232
+ Slide viewer; wrap each slide's content in `<CarouselSlide>`.
306
233
 
307
234
  ```svelte
308
235
  <Carousel>
@@ -314,16 +241,12 @@ Slide-based viewer. A11y: `role="region"`, `aria-roledescription="carousel"`, ar
314
241
  ><h3>Step 2</h3>
315
242
  <p>Build.</p></CarouselSlide
316
243
  >
317
- <CarouselSlide
318
- ><h3>Step 3</h3>
319
- <p>Deploy.</p></CarouselSlide
320
- >
321
244
  </Carousel>
322
245
  ```
323
246
 
324
247
  ### RevealModal
325
248
 
326
- Modal triggered by user interaction. Uses Svelte 5 snippets for `trigger` and `content`. A11y: `role="dialog"`, `aria-modal="true"`, focus trap, Escape to close.
249
+ Modal triggered by interaction. Uses Svelte 5 snippets.
327
250
 
328
251
  | Prop | Type | Description |
329
252
  | --------- | --------- | --------------------------------- |
@@ -341,30 +264,25 @@ Modal triggered by user interaction. Uses Svelte 5 snippets for `trigger` and `c
341
264
  </RevealModal>
342
265
  ```
343
266
 
344
- ### Video
267
+ ### Video / Audio
345
268
 
346
- YouTube/Vimeo iframe (auto-detected, responsive 16:9) or native `<video>` for direct files. Lazy-loads on scroll.
269
+ `Video` is a YouTube/Vimeo iframe (auto-detected, responsive 16:9) or native `<video>` for direct files; `Audio` is a native player. Both lazy-load and share these props:
347
270
 
348
- | Prop | Type | Description |
349
- | ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
350
- | `src` | `string` | Video URL or `$assets/` path |
351
- | `title` | `string` | **Required.** Accessible label for the player |
352
- | `tracks` | `array` | Caption/subtitle tracks for **native** video, rendered as `<track>` (see shape below). Ignored for YouTube/Vimeo embeds — the platform owns their captions |
353
- | `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) |
271
+ | Prop | Type | Description |
272
+ | ------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
273
+ | `src` | `string` | URL or `$assets/` path |
274
+ | `title` | `string` | **Required.** Accessible label (empty/whitespace rejected) |
275
+ | `tracks` | `array` | Caption tracks `<track>`; `{ src, kind?: 'captions' \| 'subtitles', srclang?, label? }`. Native only (ignored for YouTube/Vimeo) |
276
+ | `transcript` | `string` | Transcript in a `<details>`. Load from file via `?raw` import |
354
277
 
355
- `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? }`.
278
+ Captions rule (WCAG 1.2): native video needs `tracks` or `transcript`, an embed needs `transcript` (embeds can't carry `<track>`); the validator warns when `<Audio>` has no `transcript`.
356
279
 
357
280
  ```svelte
358
281
  <script>
359
- // ?raw inlines the file's text at build time — works under file://, SCORM, and subpaths
360
282
  import intro from '$assets/intro.txt?raw';
361
283
  </script>
362
284
 
363
- <Video
364
- src="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
365
- title="Intro"
366
- transcript={intro}
367
- />
285
+ <Video src="https://youtube.com/watch?v=ID" title="Intro" transcript={intro} />
368
286
  <Video
369
287
  src="$assets/demo.mp4"
370
288
  title="Demo"
@@ -377,39 +295,17 @@ YouTube/Vimeo iframe (auto-detected, responsive 16:9) or native `<video>` for di
377
295
  },
378
296
  ]}
379
297
  />
380
- ```
381
-
382
- ### Audio
383
-
384
- Native player. A11y: `aria-label` from title.
385
-
386
- | Prop | Type | Description |
387
- | ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
388
- | `src` | `string` | Audio URL or `$assets/` path |
389
- | `title` | `string` | **Required.** Accessible label for the player |
390
- | `tracks` | `array` | Caption tracks rendered as `<track>` (same shape as `Video`) |
391
- | `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) |
392
-
393
- `title` is required. For **WCAG 1.2.1** the validator warns when an `<Audio>` has no `transcript` — audio-only content needs a text alternative.
394
-
395
- ```svelte
396
- <script>
397
- import lecture from '$assets/lecture-01.txt?raw';
398
- </script>
399
-
400
- <Audio src="$assets/lecture-01.mp3" title="Lecture 1" transcript={lecture} />
298
+ <Audio src="$assets/lecture.mp3" title="Lecture 1" transcript={intro} />
401
299
  ```
402
300
 
403
301
  ---
404
302
 
405
303
  ## Quizzes
406
304
 
407
- 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.
305
+ A quiz page is a normal page with `pageConfig.quiz` set. The runtime wraps it in the resolved quiz shell (built-in `<Quiz>`, or a project `quiz.svelte` if present). Drop question components at the page root no `<Quiz>` wrapper.
408
306
 
409
307
  ### Setup
410
308
 
411
- A complete, copy-paste-ready quiz page — `pageConfig.quiz` set, components imported, questions dropped at the page root:
412
-
413
309
  ```svelte
414
310
  <script module>
415
311
  export const pageConfig = {
@@ -436,99 +332,38 @@ A complete, copy-paste-ready quiz page — `pageConfig.quiz` set, components imp
436
332
  />
437
333
  ```
438
334
 
439
- ### Common mistakes
335
+ ### Rules
440
336
 
441
- Watch for these:
442
-
443
- - **`correct` is a 0-based index, not the answer text.** `correct={1}` means the second option. It must be in range for `options`.
444
- - **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`.
337
+ - **`correct` is a 0-based index, not the answer text.** `correct={1}` is the second option; it must be in range for `options`.
338
+ - **All required props present:** `MultipleChoice` needs `question` + `options` + `correct`; `FillInTheBlank` needs `question` + `answers`; `Matching` needs `question` + `pairs`; `Sorting` needs `question` + `items` + `targets` + `correct`.
445
339
  - **`Sorting.correct` is a parallel array to `items`** — same length, each entry a valid index into `targets`.
446
- - **Question `id`s must be unique within a page.** Duplicates collide in `cmi.interactions`.
447
- - **Don't add your own `<Quiz>` wrapper.** A page with `pageConfig.quiz` is wrapped automatically — just drop the question components at the page root.
448
- - **Custom widgets must register through `useQuestion` and submit through `useQuiz().submit()`.** See [Data contract](#data-contract-what-the-lms-sees) below.
449
-
450
- ### Data contract: what the LMS sees
451
-
452
- 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.
340
+ - **Question `id`s are unique within a page.** Duplicates collide in `cmi.interactions`.
341
+ - **No `<Quiz>` wrapper.** Pages with `pageConfig.quiz` are wrapped automatically.
342
+ - **Custom widgets register through `useQuestion` and submit through `useQuiz().submit()`** otherwise the LMS sees nothing.
453
343
 
454
344
  ### `pageConfig.quiz` fields
455
345
 
456
- | Field | Type | Default | Description |
457
- | --------------- | ------------------------------------ | ---------- | -------------------------------------------------------------------------------------------------------------------------- |
458
- | `graded` | `boolean` | `false` | Whether the score counts toward course success |
459
- | `gatesProgress` | `boolean` | `false` | Whether passing is required to access the next page |
460
- | `maxAttempts` | `number` | `Infinity` | Max attempts |
461
- | `feedbackMode` | `"review" \| "immediate" \| "never"` | `"review"` | When feedback renders. See below. |
462
- | `retryMode` | `"full" \| "incorrect-only"` | `"full"` | `"full"` resets every answer on retry; `"incorrect-only"` keeps questions the learner already got right locked as correct. |
463
-
464
- `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).
465
-
466
- `gatesProgress: true` blocks navigation to the next page until the learner passes. Works in both `free` and `sequential` navigation modes.
346
+ | Field | Type | Default | Description |
347
+ | --------------- | ------------------------------------ | ---------- | -------------------------------------------------------------------------------------------------- |
348
+ | `graded` | `boolean` | `false` | Whether the score counts toward course success |
349
+ | `gatesProgress` | `boolean` | `false` | Passing required to access the next page (works in `free` and `sequential`) |
350
+ | `maxAttempts` | `number` | `Infinity` | Max attempts |
351
+ | `feedbackMode` | `"review" \| "immediate" \| "never"` | `"review"` | `immediate`: after `revealFeedback(q)`, locks the answer. `review`: post-submit only. `never`: off |
352
+ | `retryMode` | `"full" \| "incorrect-only"` | `"full"` | `full` resets every answer on retry; `incorrect-only` keeps already-correct questions locked |
467
353
 
468
354
  ### Per-question weighting
469
355
 
470
- 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.
471
-
472
- ```svelte
473
- <MultipleChoice id="q-easy" weight={1} ... />
474
- <MultipleChoice id="q-hard" weight={3} ... />
475
- ```
476
-
477
- 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.
478
-
479
- 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.
480
-
481
- 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.*`.
356
+ Pass `weight` (default 1; non-positive treated as 1) to change how much a question pulls on the page score; works identically inside `<Quiz>` and standalone. Page score = `Σ(weight × correct) / Σ(weight) × 100`, rounded. Weights affect only the page-level `cmi.core.score.raw` rollup, not `cmi.interactions.*` (each question is still one pass/fail interaction).
482
357
 
483
358
  ### Question types
484
359
 
485
- #### MultipleChoice
486
-
487
- | Prop | Type | Description |
488
- | ------------------- | ---------- | ---------------------------------------------------------------------------------------------- |
489
- | `question` | `string` | Prompt |
490
- | `options` | `string[]` | Answer options |
491
- | `correct` | `number` | Index of correct option (0-based) |
492
- | `correctFeedback` | `string` | Optional |
493
- | `incorrectFeedback` | `string` | Optional |
494
- | `optionFeedback` | `string[]` | Optional per-option feedback |
495
- | `weight` | `number` | Page-level rollup weight (default `1`). See [Per-question weighting](#per-question-weighting). |
496
-
497
- ```svelte
498
- <MultipleChoice
499
- question="What is the capital of France?"
500
- options={['London', 'Berlin', 'Paris', 'Madrid']}
501
- correct={2}
502
- />
503
- ```
360
+ Every type also accepts `weight` (page-level rollup, default 1). Syntax is shown in [Setup](#setup); the complex shapes get an example below.
504
361
 
505
- #### FillInTheBlank
362
+ **MultipleChoice** — `question` `string`, `options` `string[]`, `correct` `number` (0-based index). Optional: `correctFeedback` / `incorrectFeedback` `string`, `optionFeedback` `string[]`.
506
363
 
507
- | Prop | Type | Default | Description |
508
- | --------------- | ---------- | ------- | ------------------------ |
509
- | `question` | `string` | | Prompt |
510
- | `answers` | `string[]` | | Acceptable answers |
511
- | `caseSensitive` | `boolean` | `false` | Comparison casing |
512
- | `weight` | `number` | `1` | Page-level rollup weight |
364
+ **FillInTheBlank** `question` `string`, `answers` `string[]` (distinct spellings only), `caseSensitive` `boolean` (default `false`, handles case variants).
513
365
 
514
- `answers` only needs distinct spellings; `caseSensitive: false` already handles case variants.
515
-
516
- ```svelte
517
- <FillInTheBlank
518
- question="What element has the symbol 'O'?"
519
- answers={['Oxygen']}
520
- />
521
- ```
522
-
523
- #### Matching
524
-
525
- | Prop | Type | Description |
526
- | ---------- | --------------------------------- | -------------------------------------- |
527
- | `question` | `string` | Prompt |
528
- | `pairs` | `{left: string, right: string}[]` | Correct pairs |
529
- | `weight` | `number` | Page-level rollup weight (default `1`) |
530
-
531
- 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.
366
+ **Matching** — `question` `string`, `pairs` `{left, right}[]`. Right column auto-shuffled; click left then right to match (tap on mobile), click a pair to unmatch; all pairs must be correct.
532
367
 
533
368
  ```svelte
534
369
  <Matching
@@ -536,87 +371,43 @@ The right column is auto-shuffled. Click left then right to match (tap on mobile
536
371
  pairs={[
537
372
  { left: 'France', right: 'Paris' },
538
373
  { left: 'Germany', right: 'Berlin' },
539
- { left: 'Japan', right: 'Tokyo' },
540
374
  ]}
541
375
  />
542
376
  ```
543
377
 
544
- #### Sorting
545
-
546
- Drag-and-drop (or click-to-place) into labelled categories.
547
-
548
- | Prop | Type | Description |
549
- | ---------- | ---------- | --------------------------------------------------------------- |
550
- | `question` | `string` | Prompt |
551
- | `items` | `string[]` | Items to sort |
552
- | `targets` | `string[]` | Category labels |
553
- | `correct` | `number[]` | For each item, the index of its correct target (parallel array) |
554
- | `weight` | `number` | Page-level rollup weight (default `1`) |
378
+ **Sorting** — `question` `string`, `items` `string[]`, `targets` `string[]` (category labels), `correct` `number[]` (parallel to `items`; each entry an index into `targets`). Drag-and-drop or click-to-place.
555
379
 
556
380
  ```svelte
557
381
  <Sorting
558
382
  question="Sort each animal:"
559
- items={['Dog', 'Eagle', 'Salmon', 'Cat', 'Robin', 'Trout']}
383
+ items={['Dog', 'Eagle', 'Salmon', 'Cat']}
560
384
  targets={['Mammals', 'Birds', 'Fish']}
561
- correct={[0, 1, 2, 0, 1, 2]}
385
+ correct={[0, 1, 2, 0]}
562
386
  />
563
387
  ```
564
388
 
565
389
  ### Standalone questions
566
390
 
567
- All four question components also work outside `<Quiz>` for inline practice. Standalone widgets render their own Check / Retry buttons.
568
-
569
- | Prop | Type | Default | Description |
570
- | ------------ | -------- | ---------- | ----------------------------------------- |
571
- | `maxRetries` | `number` | `Infinity` | Max retries for standalone widgets |
572
- | `weight` | `number` | `1` | Per-question weight for page-level rollup |
573
-
574
- ```svelte
575
- <MultipleChoice
576
- question="What color is the sky on a clear day?"
577
- options={['Red', 'Blue', 'Green']}
578
- correct={1}
579
- maxRetries={2}
580
- />
581
- ```
582
-
583
- 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).
391
+ All four types work outside `<Quiz>` for inline practice, rendering their own Check/Retry. They accept `maxRetries` (`number`, default `Infinity`). Not graded by default — to grade one, build it with `useQuestion` (see [Recipe 3](#recipe-3-graded-standalone-question)).
584
392
 
585
393
  ---
586
394
 
587
395
  ## Manual completion
588
396
 
589
- `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:
590
-
591
- - A short policy briefing where reading the final page **is** the proof of completion.
592
- - A compliance "click to acknowledge" button at the end of a module.
397
+ Use `completion.mode: "manual"` when the author owns the completion moment (final page read, "click to acknowledge") rather than a quiz score or visit ratio. Two triggers, both always active; first-to-fire wins, re-marks are idempotent (completion is monotonic — you can't un-complete):
593
398
 
594
- Under manual mode, **both** triggers below are always active. First-to-fire wins; subsequent calls are idempotent.
595
-
596
- ### Trigger A: page frontmatter
597
-
598
- Declare `completesOn: "view"` on any page. Completion fires the moment that page renders.
399
+ - **Page frontmatter** `completesOn: "view"` (only v1 value) in a page's `pageConfig`; fires when that page renders.
400
+ - **Runtime hook** — `useCompletion().markComplete()`, composed with any event (modal close, video-ended, timer). Outside manual mode it's a no-op with a one-shot dev warning, so it's safe in shared components.
599
401
 
600
402
  ```svelte
601
- <!-- pages/05-summary/finale.svelte -->
602
403
  <script module>
603
- export const pageConfig = {
604
- title: "You're done",
605
- completesOn: 'view',
606
- };
404
+ export const pageConfig = { title: "You're done", completesOn: 'view' };
607
405
  </script>
608
-
609
- <h1>Thanks for completing the briefing.</h1>
610
406
  ```
611
407
 
612
- `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.
613
-
614
- ### Trigger B: runtime hook
615
-
616
408
  ```svelte
617
409
  <script>
618
410
  import { useCompletion } from 'tessera-learn';
619
-
620
411
  const { markComplete, completionStatus } = useCompletion();
621
412
  </script>
622
413
 
@@ -626,93 +417,37 @@ Declare `completesOn: "view"` on any page. Completion fires the moment that page
626
417
  >
627
418
  I acknowledge
628
419
  </button>
629
-
630
- {#if completionStatus === 'complete'}
631
- <p>Recorded. You may now close this window.</p>
632
- {/if}
633
420
  ```
634
421
 
635
- 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.
636
-
637
- ### `completion.trigger` (build-time check)
638
-
639
- 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.
640
-
641
- ```js
642
- completion: { mode: "manual", trigger: "page" }
643
- ```
644
-
645
- When omitted, the dev runtime warns once after 60 s if completion has not fired.
422
+ **Build-time check:** `completion: { mode: "manual", trigger: "page" }` fails the build when no page declares `completesOn: "view"`. Omitted, the dev runtime warns once after 60s if completion hasn't fired.
646
423
 
647
424
  ### Success status
648
425
 
649
- 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):
650
-
651
- ```js
652
- completion: { mode: "manual", requireSuccessStatus: "passed" } // or "failed"
653
- ```
654
-
655
- | Adapter | What the LMS sees on `markComplete()` (no `requireSuccessStatus`) |
656
- | -------------- | ----------------------------------------------------------------------- |
657
- | SCORM 1.2 | `cmi.core.lesson_status = "completed"` |
658
- | SCORM 2004 4th | `cmi.completion_status = "completed"`, `cmi.success_status = "unknown"` |
659
- | cmi5 | **Completed** statement (no Passed / Failed) |
660
- | web | `localStorage` only |
426
+ By default `successStatus` stays `"unknown"`. Set `requireSuccessStatus: "passed"` (or `"failed"`) for an automatic pass alongside completion:
661
427
 
662
- With `requireSuccessStatus: "passed"`, SCORM 1.2 writes `lesson_status = "passed"`, SCORM 2004 writes `success_status = "passed"`, and cmi5 emits a **Passed** statement alongside **Completed**.
428
+ | Adapter | `markComplete()`, default | with `requireSuccessStatus: "passed"` |
429
+ | -------------- | --------------------------------------------------------------- | ------------------------------------- |
430
+ | SCORM 1.2 | `lesson_status = "completed"` | `lesson_status = "passed"` |
431
+ | SCORM 2004 4th | `completion_status = "completed"`, `success_status = "unknown"` | `success_status = "passed"` |
432
+ | cmi5 | **Completed** (no Passed/Failed) | **Passed** alongside **Completed** |
433
+ | web | `localStorage` only | `localStorage` only |
663
434
 
664
- ### Quizzes under manual mode
435
+ ### Rules and non-goals
665
436
 
666
- 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.
667
-
668
- ### Non-goals
669
-
670
- - Combining manual + quiz/percentage rules ("complete when X **and** quiz passed"). Use a `useCompletion()` call inside a custom `$effect` if you need conditional logic.
671
- - Per-learner conditional completion expressed in config — same answer: do it in a component with `useCompletion()`.
672
- - Marking a course **incomplete** after it has been completed. Completion is monotonic in every spec we target. The runtime ignores re-marks.
437
+ - A graded quiz reports its score but does **not** drive completion/success under manual — `markComplete()`/`completesOn` does (the build warns; set `graded: false` to silence).
438
+ - Combining manual + quiz/percentage rules, or per-learner conditional completion → use `useCompletion()` in a custom `$effect`/component, not config.
673
439
 
674
440
  ---
675
441
 
676
442
  ## Assets
677
443
 
678
- Drop files into `assets/`. Reference them with `$assets/` in built-in component props:
679
-
680
- ```svelte
681
- <Image src="$assets/photo.png" alt="Photo" />
682
- <Video src="$assets/demo.mp4" title="Demo" />
683
- <Audio src="$assets/lecture.mp3" title="Lecture" />
684
- ```
685
-
686
- In CSS, use a relative path from `styles/`:
687
-
688
- ```css
689
- .bg {
690
- background-image: url('../assets/bg.png');
691
- }
692
- ```
693
-
694
- External URLs work too: `<Image src="https://example.com/img.jpg" alt="..." />`.
695
-
696
- 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.
697
-
698
- ### `$assets/` is three things — know which you're using
699
-
700
- `$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.
701
-
702
- 1. **Vite import alias** — works in ES `import` statements. Vite resolves `$assets/...` to the project's `assets/` directory and bundles the asset:
703
- ```js
704
- import logoUrl from '$assets/logo.svg?url';
705
- ```
706
- 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.
707
- 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.
444
+ Drop files into `assets/`. Reference with `$assets/` in built-in component props (`<Image src="$assets/photo.png" alt="…" />`); in CSS use a relative path (`url('../assets/bg.png')`). External URLs work too. At build the plugin copies `assets/` → `dist/assets/`, so paths resolve the same in dev and the bundle.
708
445
 
709
- **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.
446
+ ### `$assets/` in custom components
710
447
 
711
- ### Asset references in custom components
448
+ `$assets/` is **only** rewritten in two places: ES `import` statements (Vite alias) and the `src` prop of built-in `Image`/`Audio`/`Video`. **Raw HTML attributes are NOT rewritten** — `<img src="$assets/foo.svg">`, `new Audio('$assets/...')`, and JS-built CSS `url()` strings all 404 silently. Pick by use case:
712
449
 
713
- Pick by use case:
714
-
715
- **One-off reference — ES import (preferred):**
450
+ **One-off ES import (preferred).** Build-time bundling, hashing, fails the build if missing:
716
451
 
717
452
  ```svelte
718
453
  <script>
@@ -722,9 +457,7 @@ Pick by use case:
722
457
  <img src={url} alt="Diagram" />
723
458
  ```
724
459
 
725
- Build-time bundling, asset hashing, fails the build if missing.
726
-
727
- **Collection referenced by name — `import.meta.glob`:**
460
+ **Collection chosen at runtime `import.meta.glob`.** Same build-time guarantees:
728
461
 
729
462
  ```js
730
463
  const signs = import.meta.glob('$assets/signs/*.svg', {
@@ -732,29 +465,16 @@ const signs = import.meta.glob('$assets/signs/*.svg', {
732
465
  query: '?url',
733
466
  import: 'default',
734
467
  });
735
- // then look up by full key:
736
- const url = signs[`/assets/signs/${filename}`];
737
- ```
738
-
739
- Use this when the asset is chosen at runtime by ID/filename. Same build-time guarantees as a single import.
740
-
741
- **Pure runtime string (last resort):**
742
-
743
- ```js
744
- const src = `./assets/signs/${filename}`;
468
+ const url = signs[`/assets/signs/${filename}`]; // look up by full key
745
469
  ```
746
470
 
747
- 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.
471
+ **Pure runtime string (last resort).** No build-time guarantees; only when the filename comes from server data: `` const src = `./assets/signs/${filename}` ``.
748
472
 
749
473
  ---
750
474
 
751
475
  ## Styling
752
476
 
753
- Add `.css` files to `styles/`. They load after framework styles and override them.
754
-
755
- ### CSS custom properties
756
-
757
- Override these to theme globally:
477
+ Add `.css` files to `styles/`; they load after framework styles and override them. Theme globally by overriding these custom properties:
758
478
 
759
479
  | Property | Default |
760
480
  | ---------------------------------------------- | ------------------------------------- |
@@ -783,7 +503,7 @@ Override these to theme globally:
783
503
  }
784
504
  ```
785
505
 
786
- `branding.primaryColor` and `branding.fontFamily` in `course.config.js` cover the common overrides without writing CSS.
506
+ For the common case, set `branding.primaryColor` and `branding.fontFamily` in `course.config.js` instead of writing CSS.
787
507
 
788
508
  ---
789
509
 
@@ -791,15 +511,14 @@ Override these to theme globally:
791
511
 
792
512
  ```js
793
513
  export default {
794
- // Metadata
795
- title: 'My Course', // required
514
+ title: 'My Course', // required — the only field with no default
796
515
  description: '',
797
516
  author: '',
798
517
  version: '1.0.0',
799
- language: 'en', // BCP-47 tag for <html lang> (e.g. "en", "fr-CA"); defaults to "en"
518
+ language: 'en', // BCP-47 tag for <html lang>; defaults to "en"
800
519
 
801
520
  branding: {
802
- logo: '', // e.g., "$assets/logo.png"
521
+ logo: '', // e.g. "$assets/logo.png"
803
522
  primaryColor: '#2563eb',
804
523
  fontFamily: 'Inter, sans-serif',
805
524
  },
@@ -811,8 +530,7 @@ export default {
811
530
  completion: {
812
531
  mode: 'percentage', // "percentage" | "quiz" | "manual"
813
532
  percentageThreshold: 100, // 0–100 (percentage mode)
814
- // trigger: "page", // (manual only) opt into build-time check
815
- // requireSuccessStatus: "passed", // (manual only) "passed" | "failed"
533
+ // (manual only) trigger: "page", requireSuccessStatus: "passed" | "failed"
816
534
  },
817
535
 
818
536
  scoring: {
@@ -823,123 +541,84 @@ export default {
823
541
  standard: 'web', // "web" | "scorm12" | "scorm2004" | "cmi5"
824
542
  },
825
543
 
826
- // Accessibility checker (all optional — sensible defaults apply)
827
544
  a11y: {
828
- level: 'warn', // "warn" (default) or "error" — "error" makes the promotable a11y rules block the build
829
- standard: 'wcag2aa', // "wcag2a" | "wcag2aa" (default) | "wcag21aa" — axe ruleset for tessera a11y
830
- ignore: [], // rule IDs to suppress, e.g. ["tessera/heading-order", "color-contrast"]
545
+ level: 'warn', // "warn" (default) | "error"
546
+ standard: 'wcag2aa', // "wcag2a" | "wcag2aa" (default) | "wcag21aa"
547
+ ignore: [], // rule IDs to suppress, e.g. ["tessera/heading-order"]
831
548
  },
832
549
  };
833
550
  ```
834
551
 
835
- - `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"`.
836
- - `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`.
837
- - `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`.
552
+ ### Field behaviour
838
553
 
839
- - `navigation.mode: "free"` → all pages accessible except those blocked by gating quizzes.
840
- - `navigation.mode: "sequential"` pages unlock one at a time as each is completed.
841
- - `completion.mode: "percentage"` course completes when `visitedPages / totalPages * 100 >= percentageThreshold`.
842
- - `completion.mode: "quiz"` course completes when graded quiz average >= `scoring.passingScore`.
843
- - `completion.mode: "manual"` course completes when an author-declared trigger fires. See [Manual completion](#manual-completion).
554
+ | Field | Behaviour |
555
+ | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
556
+ | `language` | Sets `<html lang>` (WCAG 3.1.1). Missing/implausible value warns and falls back to `"en"` |
557
+ | `navigation.mode: "free"` | All pages accessible except those blocked by gating quizzes |
558
+ | `navigation.mode: "sequential"` | Pages unlock one at a time as each completes |
559
+ | `completion.mode: "percentage"` | Completes when `visitedPages / totalPages * 100 >= percentageThreshold` |
560
+ | `completion.mode: "quiz"` | Completes when graded quiz average >= `scoring.passingScore` |
561
+ | `completion.mode: "manual"` | Completes when an author trigger fires. See [Manual completion](#manual-completion) |
562
+ | `a11y.level: "error"` | Promotes captions/transcript, heading order, contrast, language, Svelte a11y warnings to errors. Hard errors (missing `alt`, missing media `title`) always block regardless |
563
+ | `a11y.ignore` | Flat list matched literally against every diagnostic rule ID across all tiers (`tessera/…`, `a11y_…`, bare axe IDs) |
844
564
 
845
565
  ### Minimum config
846
566
 
847
- Every field except `title` has a default. The build merges yours over:
848
-
849
- ```js
850
- // effective defaults
851
- {
852
- title: "Untitled Course",
853
- language: "en",
854
- navigation: { mode: "free" },
855
- completion: { mode: "percentage", percentageThreshold: 100 },
856
- scoring: { passingScore: 70 },
857
- export: { standard: "web" },
858
- }
859
- ```
860
-
861
- 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.)
567
+ Every field except `title` has a default, so `export default { title: "My Course" }` is complete: free nav, full-percentage completion, web export, `<html lang="en">`, `passingScore: 70`.
862
568
 
863
569
  ### Custom access rules
864
570
 
865
- For anything beyond the two presets (prereqs, instructor approval, time gating), supply `navigation.canAccess`. It runs synchronously on every navigation evaluation. Keep it cheap.
571
+ For anything beyond the two presets (prereqs, instructor approval, time gating), supply `navigation.canAccess` (with `mode`). It runs synchronously on every navigation evaluation keep it cheap. Here, gate `lesson-5` on a prior quiz score:
866
572
 
867
573
  ```js
868
574
  import { sequentialAccess } from 'tessera-learn';
869
575
 
870
- export default {
871
- // ...
872
- navigation: {
873
- mode: 'sequential',
874
- canAccess: (ctx) => {
875
- if (!sequentialAccess(ctx)) return false;
876
- if (ctx.page.slug === 'lesson-5') {
877
- const i = ctx.manifest.pages.findIndex(
878
- (p) => p.slug === 'lesson-2-quiz',
879
- );
880
- return (
881
- (ctx.progress.quizScores.get(i) ?? 0) >=
882
- ctx.config.scoring.passingScore
883
- );
884
- }
885
- return true;
886
- },
887
- },
576
+ canAccess: (ctx) => {
577
+ if (!sequentialAccess(ctx)) return false;
578
+ if (ctx.page.slug !== 'lesson-5') return true;
579
+ const i = ctx.manifest.pages.findIndex((p) => p.slug === 'lesson-2-quiz');
580
+ return (
581
+ (ctx.progress.quizScores.get(i) ?? 0) >= ctx.config.scoring.passingScore
582
+ );
888
583
  };
889
584
  ```
890
585
 
891
- `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.
586
+ `AccessContext` (`ctx`) exposes `pageIndex`, `page`, `manifest`, `progress`, `config`. Presets `freeAccess` / `sequentialAccess` are re-exported for composition; `resolveAccess(config)` returns the predicate the runtime would use (custom `canAccess` if set, else the matching preset) use it to wrap rather than replace.
892
587
 
893
588
  ### Build output
894
589
 
895
- `pnpm export <course>` (which wraps `vite build`) writes:
590
+ `pnpm export <course>` writes:
896
591
 
897
- | `export.standard` | What ships | Where |
898
- | ----------------- | ------------------------------------- | ---------------------------------------- |
899
- | `web` | Static site (HTML/CSS/JS + `assets/`) | `dist/` (host on any static file server) |
900
- | `scorm12` | SCORM 1.2 package | `dist/<course>-scorm12.zip` |
901
- | `scorm2004` | SCORM 2004 4th Edition package | `dist/<course>-scorm2004.zip` |
902
- | `cmi5` | cmi5 package (AU + manifest) | `dist/<course>-cmi5.zip` |
592
+ | `export.standard` | What ships | Where |
593
+ | ----------------- | ------------------------------------- | ----------------------------- |
594
+ | `web` | Static site (HTML/CSS/JS + `assets/`) | `dist/` (any static host) |
595
+ | `scorm12` | SCORM 1.2 package | `dist/<course>-scorm12.zip` |
596
+ | `scorm2004` | SCORM 2004 4th Edition package | `dist/<course>-scorm2004.zip` |
597
+ | `cmi5` | cmi5 package (AU + manifest) | `dist/<course>-cmi5.zip` |
903
598
 
904
- 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.
599
+ Upload the LMS zips via your LMS's import flow; drop `dist/` (web) on any static host.
905
600
 
906
601
  ### Validation
907
602
 
908
- 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 <course>` to check without building.
603
+ The plugin validates on every dev start and build (page syntax, manifest shape, `pageConfig`, question components, asset references, data-contract bypass). Errors abort the build and print `[tessera error] ...`; warnings print `[tessera warning] ...` and don't block. Run `pnpm validate <course>` to check without building.
909
604
 
910
605
  ---
911
606
 
912
607
  ## Accessibility
913
608
 
914
- Tessera checks accessibility in two passes, plus components that are accessible by construction.
915
-
916
- **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.
609
+ Two passes plus components that are accessible by construction.
917
610
 
918
- **Runtime audit** is the opt-in deep pass: `pnpm a11y <course>` (run it directly, or via `pnpm check <course>`, 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.
611
+ **Static checks** run inside `validate` / `dev` / `export` no setup. They cover: `<Image>` alt-or-`decorative`; `<Video>`/`<Audio>` `title` + captions/transcript; empty question labels; skipped heading levels; `branding.primaryColor` contrast against white; well-formed `language`; and the Svelte compiler's `a11y_*` warnings. Each diagnostic carries a rule ID (`[tessera/image-alt]`, `[a11y_missing_attribute]`) — that ID is what `a11y.ignore` and `a11y.level` match.
919
612
 
920
- The runtime audit drives Playwright, which needs a browser binary once per machine:
613
+ **Runtime audit** (`tessera a11y`, or via `pnpm check`) is the opt-in deep pass: builds the course, renders **every** page headless (incl. quiz-gated), runs [axe-core](https://github.com/dequelabs/axe-core), writes `a11y-report.json` (git-ignored), exits non-zero at/above the impact threshold (default `serious`; `--threshold minor` is stricter). It catches what static can't — computed ARIA, focus order, rendered contrast — and uses the web adapter, so it works regardless of `export.standard`. First run auto-installs Chromium.
921
614
 
922
- ```bash
923
- pnpm exec playwright install chromium
924
- ```
925
-
926
- ```bash
927
- pnpm exec tessera a11y # audit (threshold: serious)
928
- pnpm exec tessera a11y --threshold minor # stricter
929
- pnpm exec tessera a11y --build # force a fresh build first
930
- ```
931
-
932
- 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.
933
-
934
- 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.
935
-
936
- 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`.
615
+ Ruleset/severity come from the `a11y` block (`standard`, `ignore`). Hard errors (missing `alt`, missing media `title`) always block; everything else is a warning unless `a11y.level: "error"`.
937
616
 
938
617
  ---
939
618
 
940
619
  ## Hooks Reference
941
620
 
942
- Six hooks plus one helper make up the stable contract between widgets and the runtime.
621
+ Six hooks plus one helper. Each is synchronous, must be called during component setup inside a Tessera course, and throws if called outside the runtime.
943
622
 
944
623
  ```js
945
624
  import {
@@ -954,11 +633,9 @@ import {
954
633
  import type { Interaction } from 'tessera-learn';
955
634
  ```
956
635
 
957
- Each hook is synchronous and must be called during component setup, inside a Tessera course. Calling them outside the runtime throws.
958
-
959
636
  ### The `Question` model
960
637
 
961
- 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.
638
+ `useQuiz()` and `useQuestion()` share the same per-question object: a shell iterates `quiz.questions`, a widget gets its `Question` from `useQuestion()`. No indexes, no `getContext`.
962
639
 
963
640
  ```ts
964
641
  interface Question {
@@ -967,19 +644,19 @@ interface Question {
967
644
  readonly correct: boolean | null;
968
645
  readonly answer: unknown;
969
646
  readonly feedbackVisible: boolean;
970
- readonly locked: boolean; // input must be read-only: submitted OR feedbackVisible OR isLockedCorrect
971
- readonly isLockedCorrect: boolean; // narrow case: locked because retry policy preserved this as already-correct
647
+ readonly locked: boolean; // input read-only: submitted OR feedbackVisible OR isLockedCorrect
648
+ readonly isLockedCorrect: boolean; // narrow case: retry policy preserved this as already-correct
972
649
  readonly render: unknown; // snippet the widget registered; shell calls {@render q.render()}
973
650
  setAnswer(answer: unknown): void;
974
- 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.
651
+ commit(): void; // mark answer final; triggers the per-question LMS write. Idempotent.
975
652
  }
976
653
  ```
977
654
 
978
- Widgets should gate input on `q.locked` and only branch on `q.isLockedCorrect` to render the "already correct" banner.
655
+ Gate input on `q.locked`; branch on `q.isLockedCorrect` only to render the "already correct" banner.
979
656
 
980
- `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.
657
+ `Interaction` uses SCORM 2004 vocabulary: `choice`, `true-false`, `fill-in`, `long-fill-in`, `matching`, `sequencing`, `numeric`, `likert`, `performance`, `other`. Each is `{ type, response, correct? }`. Omit `correct` to skip auto-judging (`useQuestion` reports `null` correctness; your widget renders its own UI).
981
658
 
982
- 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.
659
+ For `choice` / `sequencing` / `matching`, name responses with readable ids and pass the full option list via `options` (or `optionPairs` for matching). The encoder adapts per export: cmi5/SCORM 2004 keep the names, SCORM 1.2 maps each to its index in `options` (omit `options` and SCORM 1.2 slugs the literal identifier).
983
660
 
984
661
  ```ts
985
662
  response: () => ({
@@ -988,68 +665,49 @@ response: () => ({
988
665
  correct: ['speed-limit'],
989
666
  options: ['stop', 'yield', 'speed-limit', 'merge'],
990
667
  });
991
- // SCORM 1.2 → student_response: "2"
992
- // SCORM 2004 → learner_response: "speed-limit"
993
- // cmi5 → result.response: "speed-limit"
994
- ```
668
+ // SCORM 1.2 → "2" SCORM 2004 → "speed-limit" cmi5 → "speed-limit"
995
669
 
996
- Matching uses `optionPairs: { left, right }` for the same effect, mapping each pair's `[l, r]` to `"<leftIdx>.<rightIdx>"` on SCORM 1.2.
670
+ // sequencing: response/correct are ordered id lists; options carries every id
671
+ response: () => ({
672
+ type: 'sequencing',
673
+ response: order, // e.g. ['mercury', 'venus', 'earth']
674
+ correct: ['mercury', 'venus', 'earth'],
675
+ options: ['venus', 'earth', 'mercury'],
676
+ });
677
+ ```
997
678
 
998
679
  ### `useQuestion`
999
680
 
1000
681
  Register a question widget so the runtime can submit, score, persist, and report it. Returns a `Question` plus standalone-only methods.
1001
682
 
1002
- - **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.
1003
- - **Standalone**: the widget owns its own Check/Retry. Set `graded: true` to count toward course success.
683
+ - **Inside a quiz:** the shell drives submission. The widget calls `setAnswer()` on input, `commit()` when final, `setRender(snippet)` once at mount, and reads `locked`/`feedbackVisible`/`answer`. `submit()`/`retry()` are no-ops here.
684
+ - **Standalone:** the widget owns Check/Retry. Set `graded: true` to count toward course success.
1004
685
 
1005
686
  ```ts
1006
687
  function useQuestion(opts: {
1007
688
  id: string; // unique on the page; LMS interaction id
1008
689
  graded?: boolean; // standalone only
1009
- response: () => Interaction; // current learner answer; called on each commit() and on submit
690
+ response: () => Interaction; // current answer; called on each commit() and on submit
1010
691
  score?: () => number; // standalone-only override (0–100)
1011
692
  weight?: number; // page-level rollup weight (default 1)
1012
693
  maxRetries?: number; // standalone retry cap (default Infinity); ignored inside a quiz
1013
694
  reset?: () => void;
1014
695
  }): Question & {
1015
- submit(): void; // standalone: triggers own check. quiz: no-op (shell drives).
696
+ submit(): void; // standalone: own check. quiz: no-op
1016
697
  reset(): void;
1017
698
  retry(): void; // standalone only; no-op once maxRetries hit or inside a quiz
1018
699
  readonly canRetry: boolean;
1019
700
  readonly retryCount: number;
1020
701
  readonly mode: 'standalone' | 'quiz';
1021
- setRender(render: unknown): void; // registers the snippet for the parent shell to render
702
+ setRender(render: unknown): void;
1022
703
  };
1023
704
  ```
1024
705
 
1025
- ```svelte
1026
- <script>
1027
- import { useQuestion } from 'tessera-learn';
1028
-
1029
- let order = $state(['Mercury', 'Venus', 'Earth', 'Mars']);
1030
-
1031
- const q = useQuestion({
1032
- id: 'planet-rank',
1033
- response: () => ({
1034
- type: 'sequencing',
1035
- response: order,
1036
- correct: ['Mercury', 'Venus', 'Earth', 'Mars'],
1037
- }),
1038
- reset: () => {
1039
- order = ['Mercury', 'Venus', 'Earth', 'Mars'];
1040
- },
1041
- });
1042
- </script>
1043
-
1044
- <!-- drag-to-reorder UI bound to `order` -->
1045
- {#if q.mode === 'standalone'}
1046
- <button onclick={() => q.submit()} disabled={q.submitted}>Check</button>
1047
- {/if}
1048
- ```
706
+ See [Recipe 2b](#recipe-2b-custom-question-widget-for-a-custom-quiz-shell) for a full widget and [Recipe 3](#recipe-3-graded-standalone-question) for a graded standalone.
1049
707
 
1050
708
  ### `useQuiz`
1051
709
 
1052
- 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.
710
+ Orchestration hook for any `quiz.svelte` (and the built-in `<Quiz>`). Question widgets call `q.commit()` when final; that triggers the per-question LMS write. `submit()` commits any uncommitted questions, then dispatches `tessera-quiz-complete`. **`submit()` is the only sanctioned dispatcher of `tessera-quiz-complete`** — bypass it and the quiz never marks Completed/Passed/Failed.
1053
711
 
1054
712
  ```ts
1055
713
  function useQuiz(opts: { element: () => HTMLElement | null }): {
@@ -1060,7 +718,7 @@ function useQuiz(opts: { element: () => HTMLElement | null }): {
1060
718
  readonly score: number;
1061
719
  readonly passingScore: number; // resolved at runtime (config + LMS mastery override)
1062
720
  readonly attemptCount: number;
1063
- submit(): void; // reports any uncommitted interactions, then dispatches tessera-quiz-complete
721
+ submit(): void;
1064
722
  retry(): void;
1065
723
  startReview(): void;
1066
724
  exitReview(): void;
@@ -1068,9 +726,7 @@ function useQuiz(opts: { element: () => HTMLElement | null }): {
1068
726
  };
1069
727
  ```
1070
728
 
1071
- 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.
1072
-
1073
- `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.
729
+ Throws on a page without `pageConfig.quiz`. Use `passingScore` from here, not `course.config.js` directly importing the config skips the LMS mastery override (SCORM 2004 `cmi.scaled_passing_score`, cmi5 `masteryScore`).
1074
730
 
1075
731
  ### `useNavigation`
1076
732
 
@@ -1089,6 +745,8 @@ function useNavigation(): {
1089
745
  };
1090
746
  ```
1091
747
 
748
+ Each `ManifestPage` exposes `slug`, `title`, and `index`.
749
+
1092
750
  ### `useProgress`
1093
751
 
1094
752
  ```ts
@@ -1105,19 +763,18 @@ function useProgress(): {
1105
763
 
1106
764
  ### `useCompletion`
1107
765
 
1108
- 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).
766
+ 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).
1109
767
 
1110
768
  ```ts
1111
769
  function useCompletion(): {
1112
- /** Idempotent — only the first call per session has an effect. */
1113
- markComplete(): void;
770
+ markComplete(): void; // idempotent — only the first call per session has an effect
1114
771
  readonly completionStatus: 'incomplete' | 'complete';
1115
772
  };
1116
773
  ```
1117
774
 
1118
775
  ### `usePersistence<T>(key)`
1119
776
 
1120
- 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.
777
+ Per-widget persistent state, JSON-serializable only. Survives reload on every adapter (`localStorage` / SCORM `cmi.suspend_data` / xAPI State API). Reads sync; writes batched. Keys are namespaced per course. Mind the SCORM 1.2 ~4 KB suspend-data cap (see [LMS behaviour](#lms-behaviour)).
1121
778
 
1122
779
  ```ts
1123
780
  function usePersistence<T>(key: string): {
@@ -1126,19 +783,11 @@ function usePersistence<T>(key: string): {
1126
783
  };
1127
784
  ```
1128
785
 
1129
- ```svelte
1130
- <script>
1131
- import { usePersistence } from 'tessera-learn';
1132
-
1133
- const store = usePersistence('whiteboard');
1134
- let state = $state(store.get() ?? { strokes: [] });
1135
- $effect(() => store.set(state));
1136
- </script>
1137
- ```
786
+ Usage in [Recipe 1](#recipe-1-custom-draw-a-line-question) (persists partial progress).
1138
787
 
1139
788
  ### `isCorrect(interaction)`
1140
789
 
1141
- Pure helper. Returns `true`, `false`, or `null` (when the interaction has no `correct` field).
790
+ Pure helper. Returns `true`, `false`, or `null` (when the interaction has no `correct`).
1142
791
 
1143
792
  ```ts
1144
793
  function isCorrect(i: Interaction): boolean | null;
@@ -1148,7 +797,7 @@ function isCorrect(i: Interaction): boolean | null;
1148
797
 
1149
798
  ## Custom xAPI statements
1150
799
 
1151
- 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()`:
800
+ The lifecycle stream (Initialized / Completed / Passed / Failed / Terminated; `cmi.*` writes under SCORM) is sent automatically. To emit your own verbs, use `useXAPI()`:
1152
801
 
1153
802
  ```ts
1154
803
  import { useXAPI } from 'tessera-learn';
@@ -1160,13 +809,11 @@ xapi?.sendStatement({
1160
809
  });
1161
810
  ```
1162
811
 
1163
- `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.
1164
-
1165
- 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`.
812
+ `useXAPI()` is callable anywhere (setup, handlers, async, `.ts` modules). It returns `null` when no LRS is configured or before adapter init resolves — **null-check and degrade gracefully**. The publisher fills in `actor`, `timestamp`, `id`, `context.contextActivities.grouping`, and (cmi5) `context.registration` + `sessionid`; you supply `verb`, optionally `object` (defaults to the activity), `result`, `context`, `attachments`.
1166
813
 
1167
- ### Configure the destination: `course.config.js`
814
+ ### Configure the destination
1168
815
 
1169
- `config.xapi` is one destination, or an array of them. The destination is always declared explicitly. There is no implicit default.
816
+ `config.xapi` is one destination or an array, always explicit (no implicit default):
1170
817
 
1171
818
  ```js
1172
819
  xapi: {
@@ -1176,7 +823,7 @@ xapi: {
1176
823
  activityId: 'https://example.com/courses/intro-to-x',
1177
824
  }
1178
825
 
1179
- // cmi5 only: inherit the LMS launch LRS (endpoint+auth+actor+activityId+registration):
826
+ // cmi5 only: inherit the LMS launch LRS:
1180
827
  xapi: { endpoint: 'lms' }
1181
828
 
1182
829
  // Fan out (at most one 'lms' entry):
@@ -1186,206 +833,75 @@ xapi: [
1186
833
  ]
1187
834
  ```
1188
835
 
1189
- 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).
836
+ Each destination has its own queue, auth resolver, and retry loop. One UUID per `sendStatement` is reused across destinations (idempotent dedupe).
1190
837
 
1191
838
  ### Per-mode behaviour
1192
839
 
1193
- | Mode | `xapi` not set | `xapi.endpoint: 'lms'` | `xapi: {endpoint, ...}` (explicit) |
1194
- | ------------- | ------------------ | ------------------------------------------------------- | ----------------------------------------------------------------- |
1195
- | **cmi5** | `useXAPI()` → null | Inherits launch LRS; shares queue with lifecycle stream | Independent publisher; `actor` defaults to launch actor |
1196
- | **scorm12** | `useXAPI()` → null | **Config error** | Independent publisher; `actor` derived from `cmi.core.student_id` |
1197
- | **scorm2004** | `useXAPI()` → null | **Config error** | Independent publisher; `actor` derived from `cmi.learner_id` |
1198
- | **web** | `useXAPI()` → null | **Config error** | Independent publisher; `actor` **required** in config |
1199
-
1200
- ### Actor resolution
1201
-
1202
- Priority order (top wins):
1203
-
1204
- 1. **Author-supplied `xapi.actor`**: always wins.
1205
- 2. **cmi5 launch actor**: under cmi5, the publisher uses the same Agent the LMS handed us at launch.
1206
- 3. **SCORM-derived actor**: under scorm12/scorm2004, the publisher synthesizes:
1207
- ```ts
1208
- {
1209
- account: {
1210
- homePage: xapi.actorAccountHomePage ?? originOf(xapi.activityId),
1211
- name: <cmi.core.student_id | cmi.learner_id>,
1212
- },
1213
- name: <cmi.core.student_name | cmi.learner_name>,
1214
- objectType: 'Agent',
1215
- }
1216
- ```
1217
- 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.
1218
- 4. **Fallback: error.** Web export with no `actor` fails at config time.
1219
-
1220
- 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.
1221
-
1222
- ### Auth
1223
-
1224
- v1 supports **Basic auth only**. The publisher prepends `Basic ` to whatever your `auth` value resolves to; pass the credential value, not the full header.
1225
-
1226
- 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).
1227
-
1228
- 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.
840
+ | Mode | `xapi` not set | `xapi.endpoint: 'lms'` | `xapi: {endpoint, ...}` (explicit) |
841
+ | ------------- | ------------------ | ---------------------- | ------------------------------------------------------- |
842
+ | **cmi5** | `useXAPI()` → null | Inherits launch LRS | Independent publisher; `actor` defaults to launch actor |
843
+ | **scorm12** | `useXAPI()` → null | **Config error** | Independent; `actor` derived from `cmi.core.student_id` |
844
+ | **scorm2004** | `useXAPI()` → null | **Config error** | Independent; `actor` derived from `cmi.learner_id` |
845
+ | **web** | `useXAPI()` → null | **Config error** | Independent; `actor` **required** in config |
1229
846
 
1230
- **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.
847
+ ### Gotchas
1231
848
 
1232
- ### Retry policy
1233
-
1234
- - **Default:** 3 attempts with exponential backoff (100ms, 200ms, 400ms).
1235
- - **5xx / network errors** retry. **4xx** short-circuits; retrying won't help.
1236
- - **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).
1237
- - **Per-statement opt-out:** `sendStatement(stmt, { retry: false })` for fire-and-forget telemetry where the author would rather drop than block.
849
+ - **Actor priority:** author-supplied `xapi.actor` always wins; else cmi5 launch actor; else SCORM-derived from the LMS data model; else error. Override the SCORM-derived `homePage` via `actorAccountHomePage` (required if `activityId` is a non-URL IRI).
850
+ - **Auth is Basic-only.** Pass the credential value, not the full header (the publisher prepends `Basic `). For OAuth, return a Basic credential from your `auth` function or run a proxy.
851
+ - **Never ship a static `auth` string on web** — the bundle is public. Use a function that fetches a server-brokered short-lived token. CORS must allow the served origin.
852
+ - **`actor` is required on web export** and resolved once per page-load (no mid-session identity change in v1 — reload to switch).
853
+ - **Page unload rejects sends.** Once unload begins, `sendStatement` rejects (keeps cmi5 Terminated last). Do end-of-session work in a child component's `onDestroy`, not `beforeunload`.
854
+ - **Retry:** 3 attempts, exponential backoff; 5xx/network retry, 4xx short-circuits, 409 treated as success. Opt out per call with `sendStatement(stmt, { retry: false })`.
1238
855
 
1239
856
  ### `sendStatement` return shape
1240
857
 
1241
858
  ```ts
1242
859
  const result = await xapi.sendStatement({ verb, object });
1243
- // result: {
1244
- // statementId: string,
1245
- // statement: Statement, // fully resolved: actor, context, timestamp filled in
1246
- // destinations: [{ endpoint, ok, status?, error? }, ...]
1247
- // }
860
+ // { statementId, statement, destinations: [{ endpoint, ok, status?, error? }, ...] }
1248
861
  ```
1249
862
 
1250
- `destinations[]` lets you act on partial failures under fan-out: one LRS can be down without affecting the others.
1251
-
1252
- ### Validation
1253
-
1254
- The publisher checks three things before sending:
1255
-
1256
- 1. `verb.id`: present, non-empty string.
1257
- 2. `object.id`: non-empty string when `object` is supplied.
1258
- 3. `result.score.scaled`: number in `[-1, 1]` when supplied.
1259
-
1260
- Everything else passes through. The LRS gives clearer errors for IRI / extension / attachment shape issues than we can; failures surface via `destinations[].error`.
1261
-
1262
- ### Mode-specific caveats
1263
-
1264
- **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.
1265
-
1266
- **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.
1267
-
1268
- **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.
863
+ `destinations[]` lets you handle partial failures under fan-out. The publisher validates only: `verb.id` non-empty, `object.id` non-empty when supplied, `result.score.scaled` in `[-1, 1]` when supplied. Everything else passes through; the LRS reports shape errors via `destinations[].error`.
1269
864
 
1270
- **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`.
865
+ ### Not in v1
1271
866
 
1272
- ### Non-goals (v1)
1273
-
1274
- - Bearer / OAuth credentials at the publisher level (wrap in your `auth` function).
1275
- - Statement signing / attachments helpers (the publisher accepts attachments but doesn't help build them).
1276
- - Offline queue / IndexedDB durability.
1277
- - LRS State API access for non-cmi5 modes.
1278
- - Voiding statements.
1279
- - Mid-session actor refresh (`refreshActor()`).
1280
- - Group actors (Agent only).
867
+ OAuth at the publisher level, statement signing/attachment helpers, offline/IndexedDB queue, State API for non-cmi5 modes, voiding, mid-session actor refresh, group actors.
1281
868
 
1282
869
  ---
1283
870
 
1284
- ## LMS Adapter Reference
1285
-
1286
- 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.
1287
-
1288
- ### Cross-mode rollup
1289
-
1290
- | Runtime event | SCORM 1.2 | SCORM 2004 4th | cmi5 |
1291
- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1292
- | 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 |
1293
- | 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 |
1294
- | 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) |
1295
- | 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. |
1296
- | 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 |
1297
- | 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` |
1298
- | 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 |
1299
- | 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. |
1300
- | 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) |
1301
- | 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) |
1302
- | Score scale exposed to LMS | `score.raw` only (0–100) | `score.raw` (0–100) **and** `score.scaled` (0–1) | `result.score.scaled` (0–1) |
1303
-
1304
- 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).
1305
-
1306
- ### SCORM 1.2 notes
1307
-
1308
- API discovery: walks `window.parent` / `window.opener` up to 10 levels looking for `API`.
1309
-
1310
- **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`.
1311
-
1312
- **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.
1313
-
1314
- **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).
1315
-
1316
- **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.
1317
-
1318
- **Bookmark.** `cmi.core.lesson_location` is written from `SavedState.b` on every `saveState` to surface "Resume from page N" in LMS UIs.
1319
-
1320
- **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.
1321
-
1322
- **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.
1323
-
1324
- ### SCORM 2004 4th notes
1325
-
1326
- API discovery: `API_1484_11` via the same parent/opener walk.
1327
-
1328
- **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.
1329
-
1330
- **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.
1331
-
1332
- **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()`.
1333
-
1334
- **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.
1335
-
1336
- **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%.
1337
-
1338
- **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.
1339
-
1340
- **Not implemented.** `imsss:sequencing` rules are omitted from `imsmanifest.xml` by design. No `cmi.objectives.*`, no `cmi.adl.nav.*` writes.
1341
-
1342
- **Local testing.** SCORM Cloud is the easiest end-to-end check. Moodle, Cornerstone, SuccessFactors, and Canvas (via Rustici Engine) accept `dist/*-scorm2004.zip` directly.
1343
-
1344
- ### cmi5 notes
1345
-
1346
- **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`.
1347
-
1348
- **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.
1349
-
1350
- **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).
1351
-
1352
- **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.
1353
-
1354
- **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).
871
+ ## LMS behaviour
1355
872
 
1356
- **`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.
873
+ The runtime translates author intent into adapter calls automatically. The author-relevant differences:
1357
874
 
1358
- **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.
875
+ | Concern | SCORM 1.2 | SCORM 2004 4th | cmi5 |
876
+ | -------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------- | ------------------------------------ |
877
+ | Completion + success | One field (`lesson_status`); no "unknown" — success wins when known, else completion | Two independent fields (`completion_status`, `success_status`) | Completed + Passed/Failed statements |
878
+ | Score scale to LMS | `score.raw` (0–100) | `score.raw` (0–100) **and** `score.scaled` (0–1) | `result.score.scaled` (0–1) |
879
+ | `usePersistence` cap | ~4 KB (plan for 4096 chars) | 64000 chars | LRS-defined (typically unbounded) |
880
+ | Resume after reload | From `cmi.suspend_data` | From `cmi.suspend_data` | From `tessera-state` (State API) |
1359
881
 
1360
- **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()`.
882
+ Author-facing consequences:
1361
883
 
1362
- **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).
884
+ - **Keep persisted state small under SCORM 1.2** it shares the ~4 KB `suspend_data` budget with progress and bookmarks.
885
+ - **SCORM 1.2 shows `incomplete` until a graded quiz produces a result** (no "unknown"); pass/fail uses `scoring.passingScore`, not the LMS's mastery field.
886
+ - **SCORM 2004 / cmi5 honor an LMS-supplied mastery score** at launch, overriding `scoring.passingScore` — read it via `useQuiz().passingScore`.
887
+ - A failed `adapter.init()` renders a visible "This course can't run here" panel — never a silent degradation.
1363
888
 
1364
- **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.
889
+ ### Local testing
1365
890
 
1366
- **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.
891
+ | Standard | How to test |
892
+ | --------- | --------------------------------------------------------------------------------------------- |
893
+ | scorm12 | Upload `dist/*-scorm12.zip` to [SCORM Cloud](https://cloud.scorm.com) (free) or Reload Player |
894
+ | scorm2004 | SCORM Cloud (easiest); also Moodle, Cornerstone, SuccessFactors, Canvas |
895
+ | cmi5 | Upload `dist/*-cmi5.zip` to SCORM Cloud and use its generated cmi5 dispatch URL |
896
+ | web | Serve `dist/` from any static host |
1367
897
 
1368
- **Not implemented.** No multi-AU courses (one course = one AU in v1). No **Waived** or **Abandoned** verbs (LMS-only). No mid-session actor refresh.
1369
-
1370
- **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.
1371
-
1372
- ### Common adapter behaviour
1373
-
1374
- **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)`.
1375
-
1376
- **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.
1377
-
1378
- **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.
1379
-
1380
- **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.
898
+ Inspect the LMS API call log to confirm `lesson_status` / `completion_status` / interactions look right.
1381
899
 
1382
900
  ---
1383
901
 
1384
902
  ## Custom Layouts
1385
903
 
1386
- Drop `layout.svelte` at the project root to replace the default sidebar/topbar/prev-next chrome. The runtime uses it whenever it exists.
1387
-
1388
- 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.
904
+ Drop `layout.svelte` at the project root to replace the default chrome. The contract: it receives a single `page` snippet prop and renders it where the active page goes; use hooks for everything else.
1389
905
 
1390
906
  ```svelte
1391
907
  <!-- layout.svelte -->
@@ -1410,17 +926,17 @@ The contract: the file receives a single `page` snippet prop and renders it wher
1410
926
  </footer>
1411
927
  ```
1412
928
 
1413
- To keep most of the default chrome and swap one piece, import `DefaultLayout` from `tessera-learn` and compose around it.
929
+ To keep most of the default chrome, import `DefaultLayout` from `tessera-learn` and compose around it.
1414
930
 
1415
931
  ---
1416
932
 
1417
933
  ## Cookbook
1418
934
 
1419
- End-to-end recipes that exercise the full hooks API. Adapt to taste.
935
+ End-to-end recipes exercising the full hooks API. Adapt to taste.
1420
936
 
1421
937
  ### Recipe 1: Custom "draw a line" question
1422
938
 
1423
- 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.
939
+ Emits a `matching` interaction (scored like `<Matching>`); persists partial progress so an interrupted session resumes.
1424
940
 
1425
941
  ```svelte
1426
942
  <!-- pages/05-pairs/01-pairs/draw-pairs.svelte -->
@@ -1456,14 +972,7 @@ Learner connects a left-side label to a right-side label by drawing a line. Emit
1456
972
  }
1457
973
  </script>
1458
974
 
1459
- <svg
1460
- width="400"
1461
- height="200"
1462
- role="img"
1463
- aria-label="Drag to match elements to their symbols"
1464
- >
1465
- <!-- canvas + line-drawing UI calls connect(l, r) on drop -->
1466
- </svg>
975
+ <!-- line-drawing UI calls connect(l, r) on drop -->
1467
976
 
1468
977
  {#if q.mode === 'standalone'}
1469
978
  <button onclick={() => q.submit()} disabled={q.submitted}>Check</button>
@@ -1473,96 +982,9 @@ Learner connects a left-side label to a right-side label by drawing a line. Emit
1473
982
  {/if}
1474
983
  ```
1475
984
 
1476
- ### Recipe 2: Custom topbar layout
985
+ ### Recipe 2: Custom quiz shell via `quiz.svelte`
1477
986
 
1478
- Replace the default sidebar with a horizontal topbar showing breadcrumb + progress %. Drop `layout.svelte` at the project root; no other changes needed.
1479
-
1480
- ```svelte
1481
- <!-- layout.svelte -->
1482
- <script>
1483
- import { useNavigation, useProgress } from 'tessera-learn';
1484
-
1485
- let { page } = $props();
1486
- const nav = useNavigation();
1487
- const progress = useProgress();
1488
-
1489
- const percent = $derived(
1490
- Math.round((progress.visitedPages.size / nav.pages.length) * 100),
1491
- );
1492
- </script>
1493
-
1494
- <header class="topbar">
1495
- <span class="brand">My Course</span>
1496
- <span class="crumb">{nav.currentPage.section} › {nav.currentPage.title}</span>
1497
- <span class="progress" aria-live="polite">{percent}% complete</span>
1498
- </header>
1499
-
1500
- <main class="content">{@render page()}</main>
1501
-
1502
- <nav class="footer">
1503
- <button disabled={!nav.canGoPrev} onclick={() => nav.prev()}>← Back</button>
1504
- <select
1505
- onchange={(e) => nav.goTo(e.currentTarget.value)}
1506
- value={nav.currentPage.slug}
1507
- >
1508
- {#each nav.pages as p}<option value={p.slug}>{p.title}</option>{/each}
1509
- </select>
1510
- <button disabled={!nav.canGoNext} onclick={() => nav.next()}>Next →</button>
1511
- </nav>
1512
-
1513
- <style>
1514
- .topbar {
1515
- display: flex;
1516
- gap: 1rem;
1517
- padding: 0.75rem 1.5rem;
1518
- border-bottom: 1px solid var(--tessera-border);
1519
- }
1520
- .content {
1521
- max-width: var(--tessera-content-max-width);
1522
- margin: 0 auto;
1523
- padding: 2rem;
1524
- }
1525
- .footer {
1526
- display: flex;
1527
- gap: 1rem;
1528
- padding: 1rem 1.5rem;
1529
- border-top: 1px solid var(--tessera-border);
1530
- }
1531
- </style>
1532
- ```
1533
-
1534
- ### Recipe 3: Prerequisite-based access
1535
-
1536
- Lock lesson 5 until lessons 1–3 are visited. Composes with `sequentialAccess` instead of re-implementing it.
1537
-
1538
- ```js
1539
- // course.config.js
1540
- import { sequentialAccess } from 'tessera-learn';
1541
-
1542
- const PREREQS = ['lesson-1', 'lesson-2', 'lesson-3'];
1543
-
1544
- export default {
1545
- title: 'My Course',
1546
- navigation: {
1547
- mode: 'sequential',
1548
- canAccess: (ctx) => {
1549
- if (!sequentialAccess(ctx)) return false;
1550
- if (ctx.page.slug !== 'lesson-5') return true;
1551
- return PREREQS.every((slug) => {
1552
- const i = ctx.manifest.pages.findIndex((p) => p.slug === slug);
1553
- return i >= 0 && ctx.progress.visitedPages.has(i);
1554
- });
1555
- },
1556
- },
1557
- completion: { mode: 'percentage', percentageThreshold: 100 },
1558
- scoring: { passingScore: 70 },
1559
- export: { standard: 'web' },
1560
- };
1561
- ```
1562
-
1563
- ### Recipe 4: Custom quiz shell via `quiz.svelte`
1564
-
1565
- 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/*`.
987
+ Drop `quiz.svelte` at the project root. Use only the public `useQuiz()` API; no imports from `tessera-learn/runtime/*`.
1566
988
 
1567
989
  ```svelte
1568
990
  <!-- quiz.svelte -->
@@ -1572,8 +994,6 @@ Drop `quiz.svelte` at the project root to replace the built-in `<Quiz>`. The run
1572
994
  let { children } = $props();
1573
995
  let host;
1574
996
 
1575
- // useQuiz owns submission, retry, review, score, and dispatching
1576
- // tessera-quiz-complete. The shell only drives the UI on top of it.
1577
997
  const quiz = useQuiz({ element: () => host });
1578
998
  </script>
1579
999
 
@@ -1604,11 +1024,11 @@ Drop `quiz.svelte` at the project root to replace the built-in `<Quiz>`. The run
1604
1024
  </div>
1605
1025
  ```
1606
1026
 
1607
- Always submit through `useQuiz().submit()`. See [Data contract](#data-contract-what-the-lms-sees).
1027
+ Always submit through `useQuiz().submit()`.
1608
1028
 
1609
- ### Recipe 4b: Custom question widget for a custom quiz shell
1029
+ ### Recipe 2b: Custom question widget for a custom quiz shell
1610
1030
 
1611
- 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.
1031
+ The widget calls `useQuestion()`, registers a render snippet with `setRender`, pushes answers up with `setAnswer`, calls `commit()` when final, and reads `locked`/`feedbackVisible`/`answer`.
1612
1032
 
1613
1033
  ```svelte
1614
1034
  <!-- components/MyChoice.svelte -->
@@ -1631,9 +1051,7 @@ Companion to Recipe 4. The widget calls `useQuestion()` for a `Question` handle,
1631
1051
  },
1632
1052
  });
1633
1053
 
1634
- // Register the snippet the shell will render. mode === 'quiz' inside a quiz host;
1635
- // 'standalone' when used outside one. setRender is a no-op in standalone.
1636
- onMount(() => q.setRender(view));
1054
+ onMount(() => q.setRender(view)); // no-op in standalone mode
1637
1055
 
1638
1056
  function pick(i) {
1639
1057
  if (q.locked) return;
@@ -1664,7 +1082,6 @@ Companion to Recipe 4. The widget calls `useQuestion()` for a `Question` handle,
1664
1082
  {/if}
1665
1083
  {/snippet}
1666
1084
 
1667
- <!-- Render the same snippet inline for standalone use (mode === 'standalone'). -->
1668
1085
  {#if q.mode === 'standalone'}
1669
1086
  {@render view()}
1670
1087
  {#if !q.submitted}
@@ -1675,57 +1092,27 @@ Companion to Recipe 4. The widget calls `useQuestion()` for a `Question` handle,
1675
1092
  {/if}
1676
1093
  ```
1677
1094
 
1678
- 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.
1679
-
1680
- ### Recipe 5: Graded standalone question
1681
-
1682
- 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.
1683
-
1684
- ```svelte
1685
- <!-- pages/04-reflection/01-reflect/reflect.svelte -->
1686
- <script module>
1687
- export const pageConfig = { title: 'Reflection' };
1688
- </script>
1689
-
1690
- <script>
1691
- import { useQuestion } from 'tessera-learn';
1692
-
1693
- let answer = $state('');
1694
-
1695
- const q = useQuestion({
1696
- id: 'why-it-matters',
1697
- graded: true,
1698
- response: () => ({
1699
- type: 'long-fill-in',
1700
- response: answer,
1701
- // No `correct`: any answer accepted; we just want completion.
1702
- }),
1703
- score: () => (answer.trim().length >= 50 ? 100 : 0),
1704
- reset: () => {
1705
- answer = '';
1706
- },
1707
- });
1708
- </script>
1095
+ Feedback timing: `feedbackMode: 'immediate'` shell calls `quiz.revealFeedback(q)`, flipping `feedbackVisible` (and `locked`). `'review'` after `submit()` + `startReview()`. `'never'` `feedbackVisible` stays false but `locked` still flips on submit.
1709
1096
 
1710
- <h1>Why does this matter to you?</h1>
1711
- <p>At least 50 characters required to pass.</p>
1097
+ ### Recipe 3: Graded standalone question
1712
1098
 
1713
- <textarea bind:value={answer} rows="6" disabled={q.submitted}></textarea>
1714
- <button
1715
- onclick={() => q.submit()}
1716
- disabled={q.submitted || answer.trim().length < 50}
1717
- >
1718
- Submit
1719
- </button>
1099
+ A standalone question (no `<Quiz>`) counts toward course success when built with `graded: true` + a `score()` returning 0–100; omit `correct` to accept any answer. Course success rolls up across all graded items, quizzes and standalones alike.
1720
1100
 
1721
- {#if q.submitted}<p>Thanks. Your reflection has been recorded.</p>{/if}
1101
+ ```js
1102
+ const q = useQuestion({
1103
+ id: 'why-it-matters',
1104
+ graded: true,
1105
+ response: () => ({ type: 'long-fill-in', response: answer }),
1106
+ score: () => (answer.trim().length >= 50 ? 100 : 0),
1107
+ reset: () => {
1108
+ answer = '';
1109
+ },
1110
+ });
1722
1111
  ```
1723
1112
 
1724
- The LMS sees a graded `long-fill-in` interaction. Course success rolls up across all graded items: quizzes and standalones alike.
1725
-
1726
- ### Recipe 6: Chunked-reveal page with `markChunk`
1113
+ ### Recipe 4: Chunked-reveal page with `markChunk`
1727
1114
 
1728
- 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.
1115
+ Reveals sections one at a time. `markChunk(pageIndex, chunkIndex)` records the highest revealed chunk so the page resumes mid-scroll on reload.
1729
1116
 
1730
1117
  ```svelte
1731
1118
  <!-- pages/02-deep-dive/01-concepts/long-read.svelte -->
@@ -1761,34 +1148,11 @@ A page that reveals sections one at a time as the learner advances. `markChunk(p
1761
1148
  {/if}
1762
1149
  ```
1763
1150
 
1764
- 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.
1765
-
1766
- ### Recipe 7: Persisted UI state with `usePersistence`
1767
-
1768
- `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.
1769
-
1770
- ```svelte
1771
- <!-- in any page component, layout.svelte, or a custom widget -->
1772
- <script>
1773
- import { usePersistence } from 'tessera-learn';
1774
-
1775
- const ui = usePersistence('sidebar-prefs');
1776
- let collapsed = $state(ui.get()?.collapsed ?? false);
1777
- $effect(() => ui.set({ collapsed }));
1778
- </script>
1779
-
1780
- <button onclick={() => (collapsed = !collapsed)}>
1781
- {collapsed ? 'Expand' : 'Collapse'}
1782
- </button>
1783
- ```
1784
-
1785
- 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`.
1786
-
1787
1151
  ---
1788
1152
 
1789
1153
  ## Constraints
1790
1154
 
1791
1155
  - **No runtime data fetching in pages.** Page content is static; no `fetch()` or dynamic loaders in page components.
1792
- - **Public API only.** Import from `tessera-learn`. Do **not** import from `tessera-learn/runtime/*`; those paths are internal and may change.
1793
- - **`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.
1156
+ - **Public API only.** Import from `tessera-learn`. Never from `tessera-learn/runtime/*` those paths are internal and may change.
1157
+ - **`pageConfig` must be a static object literal.** Trailing commas, unquoted keys, single quotes are fine (JSON5); variables, function calls, template literals, and computed values are not.
1794
1158
  - **Third-party libraries** must be project dependencies in `package.json`.