tessera-learn 0.2.1 → 0.2.2

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.
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,31 +17,32 @@ 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
+ `$shared` resolves to the workspace `shared/` directory and is bundled into each course's export. Import from it in any course:
45
46
 
46
47
  ```svelte
47
48
  <script>
@@ -52,80 +53,66 @@ A **bare command at the workspace root errors** and lists the available courses
52
53
  <Button>Continue</Button>
53
54
  ```
54
55
 
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.
56
-
57
56
  ---
58
57
 
59
58
  ## Running the project
60
59
 
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:
60
+ From the workspace root (`pnpm`; corepack provisions it). Each command takes the course name:
62
61
 
63
62
  ```bash
64
63
  pnpm install # first time only
65
64
  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
69
- ```
70
-
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
65
+ pnpm export <course> # build + package for the LMS standard in course.config.js
66
+ pnpm validate <course> # run validation only — no server, no bundle
67
+ pnpm a11y <course> # runtime a11y audit on its own (the audit half of check)
68
+ pnpm check <course> # validate, then the runtime a11y audit (axe) over the built course
79
69
  ```
80
70
 
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.
71
+ - `dev` hot-reloads pages, layouts, components, and `course.config.js`.
72
+ - `validate` runs the same static checks as `dev`/`export` and exits non-zero on failure. Use it as the fast feedback loop after editing.
73
+ - `check` runs `validate` then `tessera a11y` (builds, renders every page headless, runs axe-core). First run auto-installs Chromium. See [Accessibility](#accessibility).
74
+ - `dev` / `export` / `validate` / `a11y` / `check` are **reserved script names** aliasing the `tessera` subcommands. Don't repurpose them.
84
75
 
85
76
  ### Updating the framework
86
77
 
87
- Updating is a plain dependency bump from the project root — there is no `create-tessera upgrade`:
78
+ Plain dependency bump — there is no `create-tessera upgrade`:
88
79
 
89
80
  ```bash
90
- pnpm add tessera-learn@latest
81
+ pnpm add tessera-learn@latest # or @0.1.0 to pin
91
82
  ```
92
83
 
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.
84
+ The framework owns the build, the reserved scripts, and this guide, so a bump needs no reconciling. Your root `CLAUDE.md`/`AGENTS.md` point to this guide and aren't overwritten by updates add your own workspace standards to their Project notes section freely.
96
85
 
97
86
  ### Customising the build (optional)
98
87
 
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:
88
+ 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.
100
89
 
101
90
  ```js
102
- // tessera.config.js — merged on top of Tessera's Vite config
91
+ // tessera.config.js
103
92
  export default {
104
93
  server: { port: 4000 },
105
94
  resolve: { alias: { $lib: '/src/lib' } },
106
95
  };
107
96
  ```
108
97
 
109
- `tessera.config.js` is never scaffolded and never touched by updates — once you add it, it's yours.
98
+ It is never scaffolded and never touched by updates.
110
99
 
111
100
  ---
112
101
 
113
102
  ## Project Structure
114
103
 
115
- The framework imposes the **minimum** structure it needs to discover content. Everything else is convention you can opt into.
116
-
117
104
  ### Required
118
105
 
119
106
  ```
120
107
  my-course/
121
108
  ├── course.config.js # Course configuration
122
109
  ├── package.json
123
- └── pages/ # Course content (at least one section dir with .svelte files)
110
+ └── pages/ # at least one section dir with .svelte files
124
111
  └── intro/
125
112
  └── welcome.svelte
126
113
  ```
127
114
 
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.
115
+ `pages/` must contain one or more **section directories**, each with one or more `.svelte` files (directly or in lesson subdirectories).
129
116
 
130
117
  ### Optional
131
118
 
@@ -133,10 +120,9 @@ my-course/
133
120
  my-course/
134
121
  ├── layout.svelte # Custom chrome (replaces default sidebar/topbar)
135
122
  ├── quiz.svelte # Custom quiz shell (replaces built-in <Quiz>)
136
- ├── assets/ # Images, audio, video files (referenced via $assets/)
123
+ ├── assets/ # Images, audio, video (referenced via $assets/)
137
124
  ├── 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
125
+ ├── CLAUDE.md / AGENTS.md # Pointers to this guide
140
126
  └── pages/
141
127
  └── 01-intro/ # Numeric prefix → controls order
142
128
  ├── _meta.js # Override section title; control page order
@@ -146,76 +132,66 @@ my-course/
146
132
  └── overview.svelte
147
133
  ```
148
134
 
149
- ### What you can edit
150
-
151
- You own everything in the project directory: `pages/`, `course.config.js`, `layout.svelte`, `quiz.svelte`, custom components, `assets/`, and `styles/`. Edit those freely.
135
+ ### Editing rules
152
136
 
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:
137
+ - **Edit freely:** `pages/`, `course.config.js`, `layout.svelte`, `quiz.svelte`, custom components, `assets/`, `styles/`.
138
+ - **Never edit `node_modules/`.** Edits there are git-ignored and wiped on the next install/update. There is no `vite.config.js` to edit.
139
+ - To change framework behaviour, use an extension point instead of patching `node_modules/`:
154
140
 
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`.
141
+ | Need | Use |
142
+ | ---------------------------------------------- | -------------------------------------------- |
143
+ | New question type / interactive widget | custom component with the `useQuestion` hook |
144
+ | Different course chrome (header, nav) | `layout.svelte` |
145
+ | Different quiz UI | `quiz.svelte` with the `useQuiz` hook |
146
+ | Styling | `styles/` |
147
+ | Navigation, completion, scoring, export target | `course.config.js` |
160
148
 
161
- If none of those fit, the limitation is real surface it rather than patching around it in `node_modules/`.
149
+ If none fit, surface the limitation — don't patch around it in `node_modules/`.
162
150
 
163
151
  ### Hierarchy and ordering
164
152
 
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.
153
+ - 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.
154
+ - Sorting is alphabetical by directory/filename.
155
+ - Numeric prefixes on directories (`01-`, `02-`) set explicit order and are stripped from slugs/titles (`01-getting-started/` → slug `getting-started`, title "Getting Started").
156
+ - Control page order **within a lesson** with `_meta.js`, not filename prefixes.
166
157
 
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.
158
+ ### `_meta.js`
168
159
 
169
- ### `_meta.js` files
160
+ Optional everywhere. Default: titles fall back to the title-cased slug; pages sort alphabetically. **Omit the file when defaults are what you want** (`pages: ["only-page"]` and `title: "Splash"` on `01-splash/` are no-ops).
170
161
 
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.
172
-
173
- Reach for `_meta.js` only when the override is real:
162
+ Use it only for a real override:
174
163
 
175
164
  ```js
176
- // section or lesson _meta.js: title override (folder name doesn't auto-derive to what you want)
165
+ // title override (folder name doesn't derive to what you want)
177
166
  export default { title: 'How to play' }; // folder is `01-intro`
178
167
  ```
179
168
 
180
169
  ```js
181
- // lesson _meta.js: explicit page order
182
- export default {
183
- title: 'Welcome',
184
- pages: ['welcome', 'objectives'],
185
- };
170
+ // explicit page order — listed pages first, unlisted .svelte appended alphabetically
171
+ export default { title: 'Welcome', pages: ['welcome', 'objectives'] };
186
172
  ```
187
173
 
188
- Pages listed in `pages` come first in listed order; any unlisted `.svelte` files are appended alphabetically.
189
-
190
174
  ---
191
175
 
192
176
  ## Authoring Surfaces
193
177
 
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).
178
+ 1. **Built-in components** `Callout`, `Image`, `MultipleChoice`, `FillInTheBlank`, `Matching`, `Sorting`, etc. from `tessera-learn`. Import only what you use.
179
+ 2. **Hooks** `useQuestion`, `useQuiz`, `useNavigation`, `useProgress`, `useCompletion`, `usePersistence`. The stable contract between custom widgets and the runtime.
180
+ 3. **Custom layout** `layout.svelte` at the project root replaces the default chrome.
181
+ 4. **Custom quiz shell** `quiz.svelte` at the project root replaces the quiz UI for every page with `pageConfig.quiz`.
182
+ 5. **Custom xAPI** `useXAPI()` emits your own verbs. See [Custom xAPI](#custom-xapi-statements).
199
183
 
200
- A custom widget that calls `useQuestion` and emits an `Interaction` is treated identically to `<MultipleChoice>`, with the same scoring, LMS reporting, and persistence.
184
+ A custom widget that calls `useQuestion` and emits an `Interaction` is scored, reported, and persisted identically to `<MultipleChoice>`.
201
185
 
202
186
  ---
203
187
 
204
188
  ## Creating Pages
205
189
 
206
- Each page is a `.svelte` file inside a lesson folder.
207
-
208
- ### Basic page
209
-
210
- ```svelte
211
- <h1>Welcome</h1><p>Standard HTML works as-is.</p>
212
- ```
190
+ Each page is a `.svelte` file inside a lesson folder. Standard HTML works as-is.
213
191
 
214
192
  ### Page configuration
215
193
 
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.
194
+ `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.
219
195
 
220
196
  ```svelte
221
197
  <script module>
@@ -227,7 +203,7 @@ Both `<script module>` (Svelte 5) and `<script context="module">` (legacy) are a
227
203
  <h1>Introduction to the Topic</h1>
228
204
  ```
229
205
 
230
- If `pageConfig.title` is omitted, the title is derived from the filename: `my-page.svelte` → "My Page".
206
+ If `title` is omitted, it derives from the filename: `my-page.svelte` → "My Page".
231
207
 
232
208
  ### Importing components
233
209
 
@@ -236,43 +212,42 @@ If `pageConfig.title` is omitted, the title is derived from the filename: `my-pa
236
212
  import { Callout, Image } from 'tessera-learn';
237
213
  </script>
238
214
 
239
- <Callout type="info">
240
- <p>Helpful information.</p>
241
- </Callout>
215
+ <Callout type="info"><p>Helpful information.</p></Callout>
242
216
  ```
243
217
 
244
218
  ---
245
219
 
246
220
  ## Component Reference
247
221
 
248
- All components import from `tessera-learn`. Nothing is loaded automatically; import only what you use.
222
+ All components import from `tessera-learn`. Nothing loads automatically.
249
223
 
250
224
  ### Callout
251
225
 
252
- Styled box for highlighting information.
226
+ Styled box. A11y: `role="note"` with type-appropriate `aria-label`. Children become the body.
253
227
 
254
228
  | Prop | Type | Default |
255
229
  | ------ | --------------------------------------------- | -------- |
256
230
  | `type` | `"info" \| "warning" \| "tip" \| "important"` | `"info"` |
257
231
 
258
- Children become the body. A11y: `role="note"` with type-appropriate `aria-label`.
259
-
260
232
  ```svelte
261
233
  <Callout type="warning"><p>Be careful.</p></Callout>
262
234
  ```
263
235
 
264
236
  ### Image
265
237
 
266
- Lazy-loaded image with optional caption. Renders as `<figure>`/`<figcaption>`.
238
+ Lazy-loaded image, renders as `<figure>`/`<figcaption>`.
267
239
 
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 |
240
+ | Prop | Type | Description |
241
+ | ------------ | --------- | ---------------------------------------------------------------------- |
242
+ | `src` | `string` | Image URL. `$assets/` prefix supported |
243
+ | `alt` | `string` | **Required unless `decorative`.** Alt text |
244
+ | `decorative` | `boolean` | Ornamental image — empty `alt` + `aria-hidden`. Use _instead of_ `alt` |
245
+ | `caption` | `string` | Optional caption |
274
246
 
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).
247
+ Rules:
248
+
249
+ - Every `<Image>` needs exactly one of: meaningful `alt`, or `decorative={true}`. The validator errors if neither is present.
250
+ - `decorative` is a boolean — write `decorative` or `decorative={true}`, never `decorative="true"` (a string is truthy and rejected).
276
251
 
277
252
  ```svelte
278
253
  <Image
@@ -280,14 +255,12 @@ Every `<Image>` must resolve to exactly one of: meaningful `alt` text, or `decor
280
255
  alt="System architecture diagram"
281
256
  caption="Figure 1"
282
257
  />
283
-
284
- <!-- Ornamental divider that adds nothing for a screen reader: -->
285
258
  <Image src="$assets/flourish.svg" decorative={true} />
286
259
  ```
287
260
 
288
261
  ### Accordion / AccordionItem
289
262
 
290
- Expandable panels. Only one open at a time. A11y: `aria-expanded`, `aria-controls`, `role="region"`, keyboard Enter/Space.
263
+ Expandable panels, one open at a time. A11y: `aria-expanded`, `aria-controls`, `role="region"`, Enter/Space.
291
264
 
292
265
  ```svelte
293
266
  <Accordion>
@@ -302,28 +275,24 @@ Expandable panels. Only one open at a time. A11y: `aria-expanded`, `aria-control
302
275
 
303
276
  ### Carousel / CarouselSlide
304
277
 
305
- Slide-based viewer. A11y: `role="region"`, `aria-roledescription="carousel"`, arrow keys, mobile swipe.
278
+ Slide viewer. A11y: `role="region"`, `aria-roledescription="carousel"`, arrow keys, swipe.
306
279
 
307
280
  ```svelte
308
281
  <Carousel>
309
- <CarouselSlide
310
- ><h3>Step 1</h3>
311
- <p>Plan.</p></CarouselSlide
312
- >
313
- <CarouselSlide
314
- ><h3>Step 2</h3>
315
- <p>Build.</p></CarouselSlide
316
- >
317
- <CarouselSlide
318
- ><h3>Step 3</h3>
319
- <p>Deploy.</p></CarouselSlide
320
- >
282
+ <CarouselSlide>
283
+ <h3>Step 1</h3>
284
+ <p>Plan.</p>
285
+ </CarouselSlide>
286
+ <CarouselSlide>
287
+ <h3>Step 2</h3>
288
+ <p>Build.</p>
289
+ </CarouselSlide>
321
290
  </Carousel>
322
291
  ```
323
292
 
324
293
  ### RevealModal
325
294
 
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.
295
+ Modal triggered by interaction. Uses Svelte 5 snippets. A11y: `role="dialog"`, `aria-modal`, focus trap, Escape to close.
327
296
 
328
297
  | Prop | Type | Description |
329
298
  | --------- | --------- | --------------------------------- |
@@ -345,18 +314,17 @@ Modal triggered by user interaction. Uses Svelte 5 snippets for `trigger` and `c
345
314
 
346
315
  YouTube/Vimeo iframe (auto-detected, responsive 16:9) or native `<video>` for direct files. Lazy-loads on scroll.
347
316
 
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) |
317
+ | Prop | Type | Description |
318
+ | ------------ | -------- | -------------------------------------------------------------------------------------------- |
319
+ | `src` | `string` | Video URL or `$assets/` path |
320
+ | `title` | `string` | **Required.** Accessible label (empty/whitespace rejected) |
321
+ | `tracks` | `array` | Caption tracks for **native** video `<track>`. Ignored for YouTube/Vimeo |
322
+ | `transcript` | `string` | Transcript in a `<details>` below the player. Load from file via `?raw` import (see example) |
354
323
 
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? }`.
324
+ Captions rule (WCAG 1.2): native video needs `tracks` or `transcript`; an embed needs `transcript` (embeds can't carry `<track>` files). Each `tracks` entry is `{ src, kind?: 'captions' | 'subtitles', srclang?, label? }`.
356
325
 
357
326
  ```svelte
358
327
  <script>
359
- // ?raw inlines the file's text at build time — works under file://, SCORM, and subpaths
360
328
  import intro from '$assets/intro.txt?raw';
361
329
  </script>
362
330
 
@@ -383,14 +351,14 @@ YouTube/Vimeo iframe (auto-detected, responsive 16:9) or native `<video>` for di
383
351
 
384
352
  Native player. A11y: `aria-label` from title.
385
353
 
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) |
354
+ | Prop | Type | Description |
355
+ | ------------ | -------- | ------------------------------------------------------- |
356
+ | `src` | `string` | Audio URL or `$assets/` path |
357
+ | `title` | `string` | **Required.** Accessible label |
358
+ | `tracks` | `array` | Caption tracks `<track>` (same shape as `Video`) |
359
+ | `transcript` | `string` | Transcript in a `<details>` (load from file via `?raw`) |
392
360
 
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.
361
+ Transcript rule (WCAG 1.2.1): the validator warns when `<Audio>` has no `transcript`.
394
362
 
395
363
  ```svelte
396
364
  <script>
@@ -404,12 +372,10 @@ Native player. A11y: `aria-label` from title.
404
372
 
405
373
  ## Quizzes
406
374
 
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.
375
+ 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
376
 
409
377
  ### Setup
410
378
 
411
- A complete, copy-paste-ready quiz page — `pageConfig.quiz` set, components imported, questions dropped at the page root:
412
-
413
379
  ```svelte
414
380
  <script module>
415
381
  export const pageConfig = {
@@ -436,63 +402,53 @@ A complete, copy-paste-ready quiz page — `pageConfig.quiz` set, components imp
436
402
  />
437
403
  ```
438
404
 
439
- ### Common mistakes
440
-
441
- Watch for these:
405
+ ### Rules
442
406
 
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`.
407
+ - **`correct` is a 0-based index, not the answer text.** `correct={1}` is the second option; it must be in range for `options`.
408
+ - **All required props present:** `MultipleChoice` needs `question` + `options` + `correct`; `FillInTheBlank` needs `question` + `answers`; `Matching` needs `question` + `pairs`; `Sorting` needs `question` + `items` + `targets` + `correct`.
445
409
  - **`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.
410
+ - **Question `id`s are unique within a page.** Duplicates collide in `cmi.interactions`.
411
+ - **No `<Quiz>` wrapper.** Pages with `pageConfig.quiz` are wrapped automatically.
412
+ - **Custom widgets register through `useQuestion` and submit through `useQuiz().submit()`** — otherwise the LMS sees nothing. See [Data contract](#data-contract).
449
413
 
450
- ### Data contract: what the LMS sees
414
+ ### Data contract
451
415
 
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.
416
+ Whatever quiz UI you build, the LMS sees the same `cmi.interactions` as the built-in. Every question registered through `useQuestion` reports the moment its widget calls `q.commit()`; `useQuiz().submit()` commits any that haven't, as a safety net. **Bypass `useQuestion`/`useQuiz` and the quiz reports nothing.**
453
417
 
454
418
  ### `pageConfig.quiz` fields
455
419
 
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.
420
+ | Field | Type | Default | Description |
421
+ | --------------- | ------------------------------------ | ---------- | -------------------------------------------------------------------------------------------------- |
422
+ | `graded` | `boolean` | `false` | Whether the score counts toward course success |
423
+ | `gatesProgress` | `boolean` | `false` | Passing required to access the next page (works in `free` and `sequential`) |
424
+ | `maxAttempts` | `number` | `Infinity` | Max attempts |
425
+ | `feedbackMode` | `"review" \| "immediate" \| "never"` | `"review"` | `immediate`: after `revealFeedback(q)`, locks the answer. `review`: post-submit only. `never`: off |
426
+ | `retryMode` | `"full" \| "incorrect-only"` | `"full"` | `full` resets every answer on retry; `incorrect-only` keeps already-correct questions locked |
467
427
 
468
428
  ### Per-question weighting
469
429
 
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.
430
+ 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.
471
431
 
472
432
  ```svelte
473
433
  <MultipleChoice id="q-easy" weight={1} ... />
474
434
  <MultipleChoice id="q-hard" weight={3} ... />
475
435
  ```
476
436
 
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.*`.
437
+ Page score = weighted-correct percentage: `Σ(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
438
 
483
439
  ### Question types
484
440
 
485
441
  #### MultipleChoice
486
442
 
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). |
443
+ | Prop | Type | Description |
444
+ | ------------------- | ---------- | ------------------------------------ |
445
+ | `question` | `string` | Prompt |
446
+ | `options` | `string[]` | Answer options |
447
+ | `correct` | `number` | Index of correct option (0-based) |
448
+ | `correctFeedback` | `string` | Optional |
449
+ | `incorrectFeedback` | `string` | Optional |
450
+ | `optionFeedback` | `string[]` | Optional per-option feedback |
451
+ | `weight` | `number` | Page-level rollup weight (default 1) |
496
452
 
497
453
  ```svelte
498
454
  <MultipleChoice
@@ -511,7 +467,7 @@ The LMS still sees each question as a single pass/fail interaction; weights only
511
467
  | `caseSensitive` | `boolean` | `false` | Comparison casing |
512
468
  | `weight` | `number` | `1` | Page-level rollup weight |
513
469
 
514
- `answers` only needs distinct spellings; `caseSensitive: false` already handles case variants.
470
+ `answers` only needs distinct spellings; `caseSensitive: false` handles case variants.
515
471
 
516
472
  ```svelte
517
473
  <FillInTheBlank
@@ -522,13 +478,13 @@ The LMS still sees each question as a single pass/fail interaction; weights only
522
478
 
523
479
  #### Matching
524
480
 
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`) |
481
+ Right column auto-shuffled. Click left then right to match (tap on mobile); click a pair to unmatch. All pairs must be correct.
530
482
 
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.
483
+ | Prop | Type | Description |
484
+ | ---------- | --------------------------------- | ------------------------------------ |
485
+ | `question` | `string` | Prompt |
486
+ | `pairs` | `{left: string, right: string}[]` | Correct pairs |
487
+ | `weight` | `number` | Page-level rollup weight (default 1) |
532
488
 
533
489
  ```svelte
534
490
  <Matching
@@ -545,13 +501,13 @@ The right column is auto-shuffled. Click left then right to match (tap on mobile
545
501
 
546
502
  Drag-and-drop (or click-to-place) into labelled categories.
547
503
 
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`) |
504
+ | Prop | Type | Description |
505
+ | ---------- | ---------- | ---------------------------------------------------- |
506
+ | `question` | `string` | Prompt |
507
+ | `items` | `string[]` | Items to sort |
508
+ | `targets` | `string[]` | Category labels |
509
+ | `correct` | `number[]` | Per item, the index of its correct target (parallel) |
510
+ | `weight` | `number` | Page-level rollup weight (default 1) |
555
511
 
556
512
  ```svelte
557
513
  <Sorting
@@ -564,12 +520,12 @@ Drag-and-drop (or click-to-place) into labelled categories.
564
520
 
565
521
  ### Standalone questions
566
522
 
567
- All four question components also work outside `<Quiz>` for inline practice. Standalone widgets render their own Check / Retry buttons.
523
+ All four types work outside `<Quiz>` for inline practice and render their own Check/Retry.
568
524
 
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 |
525
+ | Prop | Type | Default | Description |
526
+ | ------------ | -------- | ---------- | ------------------------------ |
527
+ | `maxRetries` | `number` | `Infinity` | Max retries for standalone |
528
+ | `weight` | `number` | `1` | Per-question page-level weight |
573
529
 
574
530
  ```svelte
575
531
  <MultipleChoice
@@ -580,43 +536,33 @@ All four question components also work outside `<Quiz>` for inline practice. Sta
580
536
  />
581
537
  ```
582
538
 
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).
539
+ Standalone questions are not graded by default. To grade one, build it with `useQuestion`. See [Recipe 5](#recipe-5-graded-standalone-question).
584
540
 
585
541
  ---
586
542
 
587
543
  ## Manual completion
588
544
 
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:
545
+ Use `completion.mode: "manual"` when the author owns the completion moment (e.g. reading the final page, or a "click to acknowledge" button) rather than a quiz score or page-visit ratio.
590
546
 
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.
593
-
594
- Under manual mode, **both** triggers below are always active. First-to-fire wins; subsequent calls are idempotent.
547
+ Both triggers below are always active under manual mode. First-to-fire wins; subsequent calls are idempotent.
595
548
 
596
549
  ### Trigger A: page frontmatter
597
550
 
598
- Declare `completesOn: "view"` on any page. Completion fires the moment that page renders.
551
+ Declare `completesOn: "view"` (the only v1 value) on any page. Completion fires the moment that page renders.
599
552
 
600
553
  ```svelte
601
- <!-- pages/05-summary/finale.svelte -->
602
554
  <script module>
603
- export const pageConfig = {
604
- title: "You're done",
605
- completesOn: 'view',
606
- };
555
+ export const pageConfig = { title: "You're done", completesOn: 'view' };
607
556
  </script>
608
557
 
609
558
  <h1>Thanks for completing the briefing.</h1>
610
559
  ```
611
560
 
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
561
  ### Trigger B: runtime hook
615
562
 
616
563
  ```svelte
617
564
  <script>
618
565
  import { useCompletion } from 'tessera-learn';
619
-
620
566
  const { markComplete, completionStatus } = useCompletion();
621
567
  </script>
622
568
 
@@ -632,55 +578,54 @@ Declare `completesOn: "view"` on any page. Completion fires the moment that page
632
578
  {/if}
633
579
  ```
634
580
 
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.
581
+ `markComplete()` composes with any event (modal close, video-ended, timer). Outside `mode: "manual"` it is a no-op with a one-shot dev warning — safe to leave in shared components.
636
582
 
637
583
  ### `completion.trigger` (build-time check)
638
584
 
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.
585
+ Optional. Set to `"page"` to fail the build when no page declares `completesOn: "view"`. Both triggers still work regardless.
640
586
 
641
587
  ```js
642
588
  completion: { mode: "manual", trigger: "page" }
643
589
  ```
644
590
 
645
- When omitted, the dev runtime warns once after 60 s if completion has not fired.
591
+ When omitted, the dev runtime warns once after 60s if completion hasn't fired.
646
592
 
647
593
  ### Success status
648
594
 
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):
595
+ By default `successStatus` stays `"unknown"` under manual. For completion **and** an automatic pass:
650
596
 
651
597
  ```js
652
598
  completion: { mode: "manual", requireSuccessStatus: "passed" } // or "failed"
653
599
  ```
654
600
 
655
- | Adapter | What the LMS sees on `markComplete()` (no `requireSuccessStatus`) |
601
+ | Adapter | `markComplete()` with no `requireSuccessStatus` |
656
602
  | -------------- | ----------------------------------------------------------------------- |
657
603
  | SCORM 1.2 | `cmi.core.lesson_status = "completed"` |
658
604
  | SCORM 2004 4th | `cmi.completion_status = "completed"`, `cmi.success_status = "unknown"` |
659
605
  | cmi5 | **Completed** statement (no Passed / Failed) |
660
606
  | web | `localStorage` only |
661
607
 
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**.
608
+ With `requireSuccessStatus: "passed"`: SCORM 1.2 `lesson_status = "passed"`, SCORM 2004 `success_status = "passed"`, cmi5 **Passed** alongside **Completed**.
663
609
 
664
610
  ### Quizzes under manual mode
665
611
 
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.
612
+ A graded quiz reports its score to the gradebook but does **not** drive completion/success — `markComplete()`/`completesOn` does. The build warns. Set `graded: false` if that's not what you want.
667
613
 
668
614
  ### Non-goals
669
615
 
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.
616
+ - Combining manual + quiz/percentage rules use `useCompletion()` in a custom `$effect`.
617
+ - Per-learner conditional completion in config do it in a component with `useCompletion()`.
618
+ - Marking a course incomplete after completion. Completion is monotonic; re-marks are ignored.
673
619
 
674
620
  ---
675
621
 
676
622
  ## Assets
677
623
 
678
- Drop files into `assets/`. Reference them with `$assets/` in built-in component props:
624
+ Drop files into `assets/`. Reference with `$assets/` in built-in component props:
679
625
 
680
626
  ```svelte
681
627
  <Image src="$assets/photo.png" alt="Photo" />
682
628
  <Video src="$assets/demo.mp4" title="Demo" />
683
- <Audio src="$assets/lecture.mp3" title="Lecture" />
684
629
  ```
685
630
 
686
631
  In CSS, use a relative path from `styles/`:
@@ -691,28 +636,15 @@ In CSS, use a relative path from `styles/`:
691
636
  }
692
637
  ```
693
638
 
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
639
+ External URLs work too. At build the plugin copies `assets/` → `dist/assets/`, so `$assets/foo.png` resolves the same in dev and the shipped bundle.
699
640
 
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.
641
+ ### `$assets/` in custom components
701
642
 
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.
708
-
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.
710
-
711
- ### Asset references in custom components
643
+ `$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 CSS `url()` strings built in JS all 404 with no warning.
712
644
 
713
645
  Pick by use case:
714
646
 
715
- **One-off reference — ES import (preferred):**
647
+ **One-off — ES import (preferred).** Build-time bundling, hashing, fails the build if missing:
716
648
 
717
649
  ```svelte
718
650
  <script>
@@ -722,9 +654,7 @@ Pick by use case:
722
654
  <img src={url} alt="Diagram" />
723
655
  ```
724
656
 
725
- Build-time bundling, asset hashing, fails the build if missing.
726
-
727
- **Collection referenced by name — `import.meta.glob`:**
657
+ **Collection chosen at runtime `import.meta.glob`.** Same build-time guarantees:
728
658
 
729
659
  ```js
730
660
  const signs = import.meta.glob('$assets/signs/*.svg', {
@@ -732,29 +662,22 @@ const signs = import.meta.glob('$assets/signs/*.svg', {
732
662
  query: '?url',
733
663
  import: 'default',
734
664
  });
735
- // then look up by full key:
736
- const url = signs[`/assets/signs/${filename}`];
665
+ const url = signs[`/assets/signs/${filename}`]; // look up by full key
737
666
  ```
738
667
 
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):**
668
+ **Pure runtime string (last resort).** No build-time guarantees; use only when the filename comes from server data:
742
669
 
743
670
  ```js
744
671
  const src = `./assets/signs/${filename}`;
745
672
  ```
746
673
 
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.
748
-
749
674
  ---
750
675
 
751
676
  ## Styling
752
677
 
753
678
  Add `.css` files to `styles/`. They load after framework styles and override them.
754
679
 
755
- ### CSS custom properties
756
-
757
- Override these to theme globally:
680
+ Override these custom properties to theme globally:
758
681
 
759
682
  | Property | Default |
760
683
  | ---------------------------------------------- | ------------------------------------- |
@@ -783,7 +706,7 @@ Override these to theme globally:
783
706
  }
784
707
  ```
785
708
 
786
- `branding.primaryColor` and `branding.fontFamily` in `course.config.js` cover the common overrides without writing CSS.
709
+ For the common case, set `branding.primaryColor` and `branding.fontFamily` in `course.config.js` instead of writing CSS.
787
710
 
788
711
  ---
789
712
 
@@ -791,15 +714,14 @@ Override these to theme globally:
791
714
 
792
715
  ```js
793
716
  export default {
794
- // Metadata
795
- title: 'My Course', // required
717
+ title: 'My Course', // required — the only field with no default
796
718
  description: '',
797
719
  author: '',
798
720
  version: '1.0.0',
799
- language: 'en', // BCP-47 tag for <html lang> (e.g. "en", "fr-CA"); defaults to "en"
721
+ language: 'en', // BCP-47 tag for <html lang>; defaults to "en"
800
722
 
801
723
  branding: {
802
- logo: '', // e.g., "$assets/logo.png"
724
+ logo: '', // e.g. "$assets/logo.png"
803
725
  primaryColor: '#2563eb',
804
726
  fontFamily: 'Inter, sans-serif',
805
727
  },
@@ -823,31 +745,32 @@ export default {
823
745
  standard: 'web', // "web" | "scorm12" | "scorm2004" | "cmi5"
824
746
  },
825
747
 
826
- // Accessibility checker (all optional — sensible defaults apply)
827
748
  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
749
+ level: 'warn', // "warn" (default) | "error" — "error" makes promotable a11y rules block the build
750
+ standard: 'wcag2aa', // "wcag2a" | "wcag2aa" (default) | "wcag21aa" — axe ruleset
830
751
  ignore: [], // rule IDs to suppress, e.g. ["tessera/heading-order", "color-contrast"]
831
752
  },
832
753
  };
833
754
  ```
834
755
 
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`.
756
+ ### Field behaviour
838
757
 
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).
758
+ | Field | Behaviour |
759
+ | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
760
+ | `language` | Sets `<html lang>` (WCAG 3.1.1). Missing/implausible value warns and falls back to `"en"` |
761
+ | `navigation.mode: "free"` | All pages accessible except those blocked by gating quizzes |
762
+ | `navigation.mode: "sequential"` | Pages unlock one at a time as each completes |
763
+ | `completion.mode: "percentage"` | Completes when `visitedPages / totalPages * 100 >= percentageThreshold` |
764
+ | `completion.mode: "quiz"` | Completes when graded quiz average >= `scoring.passingScore` |
765
+ | `completion.mode: "manual"` | Completes when an author trigger fires. See [Manual completion](#manual-completion) |
766
+ | `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 |
767
+ | `a11y.ignore` | Flat list matched literally against every diagnostic rule ID across all tiers (`tessera/…`, `a11y_…`, bare axe IDs) |
844
768
 
845
769
  ### Minimum config
846
770
 
847
- Every field except `title` has a default. The build merges yours over:
771
+ 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">`). Effective defaults:
848
772
 
849
773
  ```js
850
- // effective defaults
851
774
  {
852
775
  title: "Untitled Course",
853
776
  language: "en",
@@ -858,17 +781,14 @@ Every field except `title` has a default. The build merges yours over:
858
781
  }
859
782
  ```
860
783
 
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.)
862
-
863
784
  ### Custom access rules
864
785
 
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.
786
+ For anything beyond the two presets (prereqs, instructor approval, time gating), supply `navigation.canAccess`. It runs synchronously on every navigation evaluation keep it cheap.
866
787
 
867
788
  ```js
868
789
  import { sequentialAccess } from 'tessera-learn';
869
790
 
870
791
  export default {
871
- // ...
872
792
  navigation: {
873
793
  mode: 'sequential',
874
794
  canAccess: (ctx) => {
@@ -888,58 +808,50 @@ export default {
888
808
  };
889
809
  ```
890
810
 
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.
811
+ `AccessContext` exposes `pageIndex`, `page`, `manifest`, `progress`, `config`. Presets `freeAccess` and `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
812
 
893
813
  ### Build output
894
814
 
895
- `pnpm export <course>` (which wraps `vite build`) writes:
815
+ `pnpm export <course>` writes:
896
816
 
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` |
817
+ | `export.standard` | What ships | Where |
818
+ | ----------------- | ------------------------------------- | ----------------------------- |
819
+ | `web` | Static site (HTML/CSS/JS + `assets/`) | `dist/` (any static host) |
820
+ | `scorm12` | SCORM 1.2 package | `dist/<course>-scorm12.zip` |
821
+ | `scorm2004` | SCORM 2004 4th Edition package | `dist/<course>-scorm2004.zip` |
822
+ | `cmi5` | cmi5 package (AU + manifest) | `dist/<course>-cmi5.zip` |
903
823
 
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.
824
+ Upload the LMS zips via your LMS's import flow. Drop `dist/` (web) on Netlify, GitHub Pages, S3, or any static host.
905
825
 
906
826
  ### Validation
907
827
 
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.
828
+ 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
829
 
910
830
  ---
911
831
 
912
832
  ## Accessibility
913
833
 
914
- Tessera checks accessibility in two passes, plus components that are accessible by construction.
834
+ Two passes plus components that are accessible by construction.
915
835
 
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.
836
+ **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.
917
837
 
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.
919
-
920
- The runtime audit drives Playwright, which needs a browser binary once per machine:
921
-
922
- ```bash
923
- pnpm exec playwright install chromium
924
- ```
838
+ **Runtime audit** (`tessera a11y`) is the opt-in deep pass. Run it directly or via `pnpm check <course>`:
925
839
 
926
840
  ```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
841
+ pnpm a11y <course> # audit (threshold: serious)
842
+ pnpm a11y <course> --threshold minor # stricter
843
+ pnpm a11y <course> --build # force a fresh build first
930
844
  ```
931
845
 
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.
846
+ It builds the course, renders **every** page headless (including quiz-gated pages), runs [axe-core](https://github.com/dequelabs/axe-core), writes `a11y-report.json` (git-ignored), and exits non-zero on any violation at/above the impact threshold (default `serious`). It catches what a static scan can't: computed ARIA, focus order, rendered contrast. First run auto-installs Chromium. It uses the web adapter, so it works regardless of `export.standard`.
935
847
 
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`.
848
+ 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
849
 
938
850
  ---
939
851
 
940
852
  ## Hooks Reference
941
853
 
942
- Six hooks plus one helper make up the stable contract between widgets and the runtime.
854
+ 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
855
 
944
856
  ```js
945
857
  import {
@@ -954,11 +866,9 @@ import {
954
866
  import type { Interaction } from 'tessera-learn';
955
867
  ```
956
868
 
957
- Each hook is synchronous and must be called during component setup, inside a Tessera course. Calling them outside the runtime throws.
958
-
959
869
  ### The `Question` model
960
870
 
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.
871
+ `useQuiz()` and `useQuestion()` traffic in the same per-question object. A shell iterates `quiz.questions`; a widget gets its `Question` from `useQuestion()`. No indexes, no `getContext`.
962
872
 
963
873
  ```ts
964
874
  interface Question {
@@ -967,19 +877,19 @@ interface Question {
967
877
  readonly correct: boolean | null;
968
878
  readonly answer: unknown;
969
879
  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
880
+ readonly locked: boolean; // input read-only: submitted OR feedbackVisible OR isLockedCorrect
881
+ readonly isLockedCorrect: boolean; // narrow case: retry policy preserved this as already-correct
972
882
  readonly render: unknown; // snippet the widget registered; shell calls {@render q.render()}
973
883
  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.
884
+ commit(): void; // mark answer final; triggers the per-question LMS write. Idempotent.
975
885
  }
976
886
  ```
977
887
 
978
- Widgets should gate input on `q.locked` and only branch on `q.isLockedCorrect` to render the "already correct" banner.
888
+ Gate input on `q.locked`; branch on `q.isLockedCorrect` only to render the "already correct" banner.
979
889
 
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.
890
+ `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
891
 
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.
892
+ 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
893
 
984
894
  ```ts
985
895
  response: () => ({
@@ -988,37 +898,33 @@ response: () => ({
988
898
  correct: ['speed-limit'],
989
899
  options: ['stop', 'yield', 'speed-limit', 'merge'],
990
900
  });
991
- // SCORM 1.2 → student_response: "2"
992
- // SCORM 2004 → learner_response: "speed-limit"
993
- // cmi5 → result.response: "speed-limit"
901
+ // SCORM 1.2 → "2" SCORM 2004 → "speed-limit" cmi5 → "speed-limit"
994
902
  ```
995
903
 
996
- Matching uses `optionPairs: { left, right }` for the same effect, mapping each pair's `[l, r]` to `"<leftIdx>.<rightIdx>"` on SCORM 1.2.
997
-
998
904
  ### `useQuestion`
999
905
 
1000
906
  Register a question widget so the runtime can submit, score, persist, and report it. Returns a `Question` plus standalone-only methods.
1001
907
 
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.
908
+ - **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.
909
+ - **Standalone:** the widget owns Check/Retry. Set `graded: true` to count toward course success.
1004
910
 
1005
911
  ```ts
1006
912
  function useQuestion(opts: {
1007
913
  id: string; // unique on the page; LMS interaction id
1008
914
  graded?: boolean; // standalone only
1009
- response: () => Interaction; // current learner answer; called on each commit() and on submit
915
+ response: () => Interaction; // current answer; called on each commit() and on submit
1010
916
  score?: () => number; // standalone-only override (0–100)
1011
917
  weight?: number; // page-level rollup weight (default 1)
1012
918
  maxRetries?: number; // standalone retry cap (default Infinity); ignored inside a quiz
1013
919
  reset?: () => void;
1014
920
  }): Question & {
1015
- submit(): void; // standalone: triggers own check. quiz: no-op (shell drives).
921
+ submit(): void; // standalone: own check. quiz: no-op
1016
922
  reset(): void;
1017
923
  retry(): void; // standalone only; no-op once maxRetries hit or inside a quiz
1018
924
  readonly canRetry: boolean;
1019
925
  readonly retryCount: number;
1020
926
  readonly mode: 'standalone' | 'quiz';
1021
- setRender(render: unknown): void; // registers the snippet for the parent shell to render
927
+ setRender(render: unknown): void;
1022
928
  };
1023
929
  ```
1024
930
 
@@ -1041,7 +947,6 @@ function useQuestion(opts: {
1041
947
  });
1042
948
  </script>
1043
949
 
1044
- <!-- drag-to-reorder UI bound to `order` -->
1045
950
  {#if q.mode === 'standalone'}
1046
951
  <button onclick={() => q.submit()} disabled={q.submitted}>Check</button>
1047
952
  {/if}
@@ -1049,7 +954,7 @@ function useQuestion(opts: {
1049
954
 
1050
955
  ### `useQuiz`
1051
956
 
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.
957
+ 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
958
 
1054
959
  ```ts
1055
960
  function useQuiz(opts: { element: () => HTMLElement | null }): {
@@ -1060,7 +965,7 @@ function useQuiz(opts: { element: () => HTMLElement | null }): {
1060
965
  readonly score: number;
1061
966
  readonly passingScore: number; // resolved at runtime (config + LMS mastery override)
1062
967
  readonly attemptCount: number;
1063
- submit(): void; // reports any uncommitted interactions, then dispatches tessera-quiz-complete
968
+ submit(): void;
1064
969
  retry(): void;
1065
970
  startReview(): void;
1066
971
  exitReview(): void;
@@ -1068,9 +973,7 @@ function useQuiz(opts: { element: () => HTMLElement | null }): {
1068
973
  };
1069
974
  ```
1070
975
 
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.
976
+ 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
977
 
1075
978
  ### `useNavigation`
1076
979
 
@@ -1105,19 +1008,18 @@ function useProgress(): {
1105
1008
 
1106
1009
  ### `useCompletion`
1107
1010
 
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).
1011
+ 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
1012
 
1110
1013
  ```ts
1111
1014
  function useCompletion(): {
1112
- /** Idempotent — only the first call per session has an effect. */
1113
- markComplete(): void;
1015
+ markComplete(): void; // idempotent — only the first call per session has an effect
1114
1016
  readonly completionStatus: 'incomplete' | 'complete';
1115
1017
  };
1116
1018
  ```
1117
1019
 
1118
1020
  ### `usePersistence<T>(key)`
1119
1021
 
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.
1022
+ 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
1023
 
1122
1024
  ```ts
1123
1025
  function usePersistence<T>(key: string): {
@@ -1138,7 +1040,7 @@ function usePersistence<T>(key: string): {
1138
1040
 
1139
1041
  ### `isCorrect(interaction)`
1140
1042
 
1141
- Pure helper. Returns `true`, `false`, or `null` (when the interaction has no `correct` field).
1043
+ Pure helper. Returns `true`, `false`, or `null` (when the interaction has no `correct`).
1142
1044
 
1143
1045
  ```ts
1144
1046
  function isCorrect(i: Interaction): boolean | null;
@@ -1148,7 +1050,7 @@ function isCorrect(i: Interaction): boolean | null;
1148
1050
 
1149
1051
  ## Custom xAPI statements
1150
1052
 
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()`:
1053
+ The lifecycle stream (Initialized / Completed / Passed / Failed / Terminated; `cmi.*` writes under SCORM) is sent automatically. To emit your own verbs, use `useXAPI()`:
1152
1054
 
1153
1055
  ```ts
1154
1056
  import { useXAPI } from 'tessera-learn';
@@ -1160,13 +1062,11 @@ xapi?.sendStatement({
1160
1062
  });
1161
1063
  ```
1162
1064
 
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`.
1065
+ `useXAPI()` is a plain function 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
1066
 
1167
- ### Configure the destination: `course.config.js`
1067
+ ### Configure the destination
1168
1068
 
1169
- `config.xapi` is one destination, or an array of them. The destination is always declared explicitly. There is no implicit default.
1069
+ `config.xapi` is one destination or an array. Always declared explicitly no implicit default.
1170
1070
 
1171
1071
  ```js
1172
1072
  xapi: {
@@ -1176,7 +1076,7 @@ xapi: {
1176
1076
  activityId: 'https://example.com/courses/intro-to-x',
1177
1077
  }
1178
1078
 
1179
- // cmi5 only: inherit the LMS launch LRS (endpoint+auth+actor+activityId+registration):
1079
+ // cmi5 only: inherit the LMS launch LRS:
1180
1080
  xapi: { endpoint: 'lms' }
1181
1081
 
1182
1082
  // Fan out (at most one 'lms' entry):
@@ -1186,206 +1086,75 @@ xapi: [
1186
1086
  ]
1187
1087
  ```
1188
1088
 
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).
1089
+ Each destination has its own queue, auth resolver, and retry loop. One UUID per `sendStatement` is reused across destinations (idempotent dedupe).
1190
1090
 
1191
1091
  ### Per-mode behaviour
1192
1092
 
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
1093
+ | Mode | `xapi` not set | `xapi.endpoint: 'lms'` | `xapi: {endpoint, ...}` (explicit) |
1094
+ | ------------- | ------------------ | ---------------------- | ------------------------------------------------------- |
1095
+ | **cmi5** | `useXAPI()` → null | Inherits launch LRS | Independent publisher; `actor` defaults to launch actor |
1096
+ | **scorm12** | `useXAPI()` → null | **Config error** | Independent; `actor` derived from `cmi.core.student_id` |
1097
+ | **scorm2004** | `useXAPI()` → null | **Config error** | Independent; `actor` derived from `cmi.learner_id` |
1098
+ | **web** | `useXAPI()` → null | **Config error** | Independent; `actor` **required** in config |
1201
1099
 
1202
- Priority order (top wins):
1100
+ ### Gotchas
1203
1101
 
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.
1229
-
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.
1231
-
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.
1102
+ - **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).
1103
+ - **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.
1104
+ - **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.
1105
+ - **`actor` is required on web export** and resolved once per page-load (no mid-session identity change in v1 — reload to switch).
1106
+ - **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`.
1107
+ - **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
1108
 
1239
1109
  ### `sendStatement` return shape
1240
1110
 
1241
1111
  ```ts
1242
1112
  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
- // }
1113
+ // { statementId, statement, destinations: [{ endpoint, ok, status?, error? }, ...] }
1248
1114
  ```
1249
1115
 
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
1116
+ `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`.
1263
1117
 
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.
1118
+ ### Not in v1
1265
1119
 
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.
1269
-
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`.
1271
-
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).
1120
+ 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
1121
 
1282
1122
  ---
1283
1123
 
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.
1124
+ ## LMS behaviour
1317
1125
 
1318
- **Bookmark.** `cmi.core.lesson_location` is written from `SavedState.b` on every `saveState` to surface "Resume from page N" in LMS UIs.
1126
+ The runtime translates author intent into adapter calls automatically; you don't write any of it. The author-relevant differences:
1319
1127
 
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.
1128
+ | Concern | SCORM 1.2 | SCORM 2004 4th | cmi5 |
1129
+ | -------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------- | ------------------------------------ |
1130
+ | 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 |
1131
+ | Score scale to LMS | `score.raw` (0–100) | `score.raw` (0–100) **and** `score.scaled` (0–1) | `result.score.scaled` (0–1) |
1132
+ | `usePersistence` cap | ~4 KB (plan for 4096 chars) | 64000 chars | LRS-defined (typically unbounded) |
1133
+ | Resume after reload | From `cmi.suspend_data` | From `cmi.suspend_data` | From `tessera-state` (State API) |
1321
1134
 
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.
1135
+ Author-facing consequences:
1323
1136
 
1324
- ### SCORM 2004 4th notes
1137
+ - **Keep persisted state small under SCORM 1.2** it shares the ~4 KB `suspend_data` budget with progress and bookmarks.
1138
+ - **SCORM 1.2 shows `incomplete` until a graded quiz produces a result** (no "unknown" status). Pass/fail uses `scoring.passingScore`, not the LMS's mastery field.
1139
+ - **SCORM 2004 / cmi5 honor an LMS-supplied mastery score** at launch, overriding `scoring.passingScore`. Read it via `useQuiz().passingScore`.
1140
+ - A failed `adapter.init()` renders a visible "This course can't run here" panel — never a silent degradation.
1325
1141
 
1326
- API discovery: `API_1484_11` via the same parent/opener walk.
1142
+ ### Local testing
1327
1143
 
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.
1144
+ | Standard | How to test |
1145
+ | --------- | --------------------------------------------------------------------------------------------- |
1146
+ | scorm12 | Upload `dist/*-scorm12.zip` to [SCORM Cloud](https://cloud.scorm.com) (free) or Reload Player |
1147
+ | scorm2004 | SCORM Cloud (easiest); also Moodle, Cornerstone, SuccessFactors, Canvas |
1148
+ | cmi5 | Upload `dist/*-cmi5.zip` to SCORM Cloud and use its generated cmi5 dispatch URL |
1149
+ | web | Serve `dist/` from any static host |
1329
1150
 
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).
1355
-
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.
1357
-
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.
1359
-
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()`.
1361
-
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).
1363
-
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.
1365
-
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.
1367
-
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.
1151
+ Inspect the LMS API call log to confirm `lesson_status` / `completion_status` / interactions look right.
1381
1152
 
1382
1153
  ---
1383
1154
 
1384
1155
  ## Custom Layouts
1385
1156
 
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.
1157
+ 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
1158
 
1390
1159
  ```svelte
1391
1160
  <!-- layout.svelte -->
@@ -1410,17 +1179,17 @@ The contract: the file receives a single `page` snippet prop and renders it wher
1410
1179
  </footer>
1411
1180
  ```
1412
1181
 
1413
- To keep most of the default chrome and swap one piece, import `DefaultLayout` from `tessera-learn` and compose around it.
1182
+ To keep most of the default chrome, import `DefaultLayout` from `tessera-learn` and compose around it.
1414
1183
 
1415
1184
  ---
1416
1185
 
1417
1186
  ## Cookbook
1418
1187
 
1419
- End-to-end recipes that exercise the full hooks API. Adapt to taste.
1188
+ End-to-end recipes exercising the full hooks API. Adapt to taste.
1420
1189
 
1421
1190
  ### Recipe 1: Custom "draw a line" question
1422
1191
 
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.
1192
+ Emits a `matching` interaction (scored like `<Matching>`); persists partial progress so an interrupted session resumes.
1424
1193
 
1425
1194
  ```svelte
1426
1195
  <!-- pages/05-pairs/01-pairs/draw-pairs.svelte -->
@@ -1475,7 +1244,7 @@ Learner connects a left-side label to a right-side label by drawing a line. Emit
1475
1244
 
1476
1245
  ### Recipe 2: Custom topbar layout
1477
1246
 
1478
- Replace the default sidebar with a horizontal topbar showing breadcrumb + progress %. Drop `layout.svelte` at the project root; no other changes needed.
1247
+ Horizontal topbar with breadcrumb + progress %.
1479
1248
 
1480
1249
  ```svelte
1481
1250
  <!-- layout.svelte -->
@@ -1533,7 +1302,7 @@ Replace the default sidebar with a horizontal topbar showing breadcrumb + progre
1533
1302
 
1534
1303
  ### Recipe 3: Prerequisite-based access
1535
1304
 
1536
- Lock lesson 5 until lessons 1–3 are visited. Composes with `sequentialAccess` instead of re-implementing it.
1305
+ Lock lesson 5 until lessons 1–3 are visited. Composes with `sequentialAccess`.
1537
1306
 
1538
1307
  ```js
1539
1308
  // course.config.js
@@ -1562,7 +1331,7 @@ export default {
1562
1331
 
1563
1332
  ### Recipe 4: Custom quiz shell via `quiz.svelte`
1564
1333
 
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/*`.
1334
+ Drop `quiz.svelte` at the project root. Use only the public `useQuiz()` API; no imports from `tessera-learn/runtime/*`.
1566
1335
 
1567
1336
  ```svelte
1568
1337
  <!-- quiz.svelte -->
@@ -1572,8 +1341,6 @@ Drop `quiz.svelte` at the project root to replace the built-in `<Quiz>`. The run
1572
1341
  let { children } = $props();
1573
1342
  let host;
1574
1343
 
1575
- // useQuiz owns submission, retry, review, score, and dispatching
1576
- // tessera-quiz-complete. The shell only drives the UI on top of it.
1577
1344
  const quiz = useQuiz({ element: () => host });
1578
1345
  </script>
1579
1346
 
@@ -1604,11 +1371,11 @@ Drop `quiz.svelte` at the project root to replace the built-in `<Quiz>`. The run
1604
1371
  </div>
1605
1372
  ```
1606
1373
 
1607
- Always submit through `useQuiz().submit()`. See [Data contract](#data-contract-what-the-lms-sees).
1374
+ Always submit through `useQuiz().submit()`.
1608
1375
 
1609
1376
  ### Recipe 4b: Custom question widget for a custom quiz shell
1610
1377
 
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.
1378
+ 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
1379
 
1613
1380
  ```svelte
1614
1381
  <!-- components/MyChoice.svelte -->
@@ -1631,9 +1398,7 @@ Companion to Recipe 4. The widget calls `useQuestion()` for a `Question` handle,
1631
1398
  },
1632
1399
  });
1633
1400
 
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));
1401
+ onMount(() => q.setRender(view)); // no-op in standalone mode
1637
1402
 
1638
1403
  function pick(i) {
1639
1404
  if (q.locked) return;
@@ -1664,7 +1429,6 @@ Companion to Recipe 4. The widget calls `useQuestion()` for a `Question` handle,
1664
1429
  {/if}
1665
1430
  {/snippet}
1666
1431
 
1667
- <!-- Render the same snippet inline for standalone use (mode === 'standalone'). -->
1668
1432
  {#if q.mode === 'standalone'}
1669
1433
  {@render view()}
1670
1434
  {#if !q.submitted}
@@ -1675,11 +1439,11 @@ Companion to Recipe 4. The widget calls `useQuestion()` for a `Question` handle,
1675
1439
  {/if}
1676
1440
  ```
1677
1441
 
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.
1442
+ 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.
1679
1443
 
1680
1444
  ### Recipe 5: Graded standalone question
1681
1445
 
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.
1446
+ A single inline reflection, not in a `<Quiz>` but `graded: true`, so it counts toward course success.
1683
1447
 
1684
1448
  ```svelte
1685
1449
  <!-- pages/04-reflection/01-reflect/reflect.svelte -->
@@ -1721,11 +1485,11 @@ A single inline reflection, not in a `<Quiz>` but `graded: true`, so it counts t
1721
1485
  {#if q.submitted}<p>Thanks. Your reflection has been recorded.</p>{/if}
1722
1486
  ```
1723
1487
 
1724
- The LMS sees a graded `long-fill-in` interaction. Course success rolls up across all graded items: quizzes and standalones alike.
1488
+ Course success rolls up across all graded items: quizzes and standalones alike.
1725
1489
 
1726
1490
  ### Recipe 6: Chunked-reveal page with `markChunk`
1727
1491
 
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.
1492
+ Reveals sections one at a time. `markChunk(pageIndex, chunkIndex)` records the highest revealed chunk so the page resumes mid-scroll on reload.
1729
1493
 
1730
1494
  ```svelte
1731
1495
  <!-- pages/02-deep-dive/01-concepts/long-read.svelte -->
@@ -1761,11 +1525,9 @@ A page that reveals sections one at a time as the learner advances. `markChunk(p
1761
1525
  {/if}
1762
1526
  ```
1763
1527
 
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
1528
  ### Recipe 7: Persisted UI state with `usePersistence`
1767
1529
 
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.
1530
+ Any JSON-serialisable value can survive reload here, a sidebar collapsed toggle.
1769
1531
 
1770
1532
  ```svelte
1771
1533
  <!-- in any page component, layout.svelte, or a custom widget -->
@@ -1782,13 +1544,13 @@ Use this when a page is long enough that "fully visited" is a meaningful state s
1782
1544
  </button>
1783
1545
  ```
1784
1546
 
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`.
1547
+ Keys are namespaced per course, so two courses on the same LMS don't collide.
1786
1548
 
1787
1549
  ---
1788
1550
 
1789
1551
  ## Constraints
1790
1552
 
1791
1553
  - **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.
1554
+ - **Public API only.** Import from `tessera-learn`. Never from `tessera-learn/runtime/*` those paths are internal and may change.
1555
+ - **`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
1556
  - **Third-party libraries** must be project dependencies in `package.json`.