tessera-learn 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +161 -535
- package/README.md +2 -2
- package/dist/{audit-B9VHgVjk.js → audit-DkXqQTqn.js} +92 -38
- package/dist/audit-DkXqQTqn.js.map +1 -0
- package/dist/{build-commands-D127jw0J.js → build-commands-CyzuCDXg.js} +2 -2
- package/dist/{build-commands-D127jw0J.js.map → build-commands-CyzuCDXg.js.map} +1 -1
- package/dist/{inline-config-eHjv9XuA.js → inline-config-BEXyRqsJ.js} +2 -2
- package/dist/{inline-config-eHjv9XuA.js.map → inline-config-BEXyRqsJ.js.map} +1 -1
- package/dist/plugin/cli.d.ts.map +1 -1
- package/dist/plugin/cli.js +62 -54
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +280 -3
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +3 -3
- package/dist/{plugin--8H9xQIl.js → plugin-CFUFgwHB.js} +126 -83
- package/dist/plugin-CFUFgwHB.js.map +1 -0
- package/package.json +12 -9
- package/src/components/DefaultLayout.svelte +2 -5
- package/src/components/Quiz.svelte +18 -26
- package/src/plugin/a11y/audit.ts +8 -13
- package/src/plugin/a11y-cli.ts +1 -4
- package/src/plugin/ast.ts +9 -2
- package/src/plugin/cli.ts +46 -48
- package/src/plugin/csp.ts +59 -0
- package/src/plugin/duplicate-cli.ts +37 -1
- package/src/plugin/export.ts +56 -27
- package/src/plugin/index.ts +117 -61
- package/src/plugin/manifest.ts +3 -23
- package/src/plugin/new-cli.ts +2 -0
- package/src/plugin/validate-cli.ts +10 -4
- package/src/plugin/validation.ts +48 -12
- package/src/runtime/App.svelte +10 -8
- package/src/runtime/Sidebar.svelte +3 -1
- package/src/runtime/adapters/cmi5.ts +59 -402
- package/src/runtime/adapters/discovery.ts +11 -0
- package/src/runtime/adapters/index.ts +27 -60
- package/src/runtime/adapters/lms-error.ts +61 -0
- package/src/runtime/adapters/scorm2004.ts +2 -1
- package/src/runtime/adapters/web.ts +19 -4
- package/src/runtime/adapters/xapi-launch-base.ts +346 -0
- package/src/runtime/adapters/xapi.ts +26 -0
- package/src/runtime/types.ts +19 -1
- package/src/runtime/xapi/publisher.ts +5 -1
- package/src/runtime/xapi/setup.ts +24 -15
- package/src/virtual.d.ts +4 -1
- package/templates/course/course.config.js +1 -0
- package/dist/audit-B9VHgVjk.js.map +0 -1
- package/dist/plugin--8H9xQIl.js.map +0 -1
package/AGENTS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# AGENTS.md: Tessera Course Authoring Guide
|
|
2
2
|
|
|
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.
|
|
3
|
+
Tessera is an LMS-tracking runtime for interactive learning content (SCORM 1.2 / SCORM 2004 4e / cmi5 / xAPI 1.0.3 / static web). It owns tracking, progress, completion/success rollup, persistence, and navigation gating. You own the presentation layer.
|
|
4
4
|
|
|
5
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
|
|
|
@@ -42,16 +42,7 @@ The scaffolded root scripts (`pnpm dev`, `pnpm export`, …) pass through: `pnpm
|
|
|
42
42
|
|
|
43
43
|
### `$shared`
|
|
44
44
|
|
|
45
|
-
`$shared` resolves to the workspace `shared/` directory and is bundled into each course's export. Import from it in any course:
|
|
46
|
-
|
|
47
|
-
```svelte
|
|
48
|
-
<script>
|
|
49
|
-
import Button from '$shared/Button.svelte';
|
|
50
|
-
import '$shared/tokens.css';
|
|
51
|
-
</script>
|
|
52
|
-
|
|
53
|
-
<Button>Continue</Button>
|
|
54
|
-
```
|
|
45
|
+
`$shared` resolves to the workspace `shared/` directory and is bundled into each course's export. Import from it in any course: `import Button from '$shared/Button.svelte'`, `import '$shared/tokens.css'`.
|
|
55
46
|
|
|
56
47
|
---
|
|
57
48
|
|
|
@@ -69,23 +60,17 @@ pnpm check <course> # validate, then the runtime a11y audit (axe) over the
|
|
|
69
60
|
```
|
|
70
61
|
|
|
71
62
|
- `dev` hot-reloads pages, layouts, components, and `course.config.js`.
|
|
72
|
-
- `validate` runs the same static checks as `dev`/`export
|
|
73
|
-
- `check` runs `validate` then `tessera a11y` (builds, renders every page headless, runs axe-core
|
|
63
|
+
- `validate` runs the same static checks as `dev`/`export`, exits non-zero on failure — the fast feedback loop.
|
|
64
|
+
- `check` runs `validate` then `tessera a11y` (builds, renders every page headless, runs axe-core; first run auto-installs Chromium). See [Accessibility](#accessibility).
|
|
74
65
|
- `dev` / `export` / `validate` / `a11y` / `check` are **reserved script names** aliasing the `tessera` subcommands. Don't repurpose them.
|
|
75
66
|
|
|
76
67
|
### Updating the framework
|
|
77
68
|
|
|
78
|
-
Plain dependency bump — there is no `create-tessera upgrade`:
|
|
79
|
-
|
|
80
|
-
```bash
|
|
81
|
-
pnpm add tessera-learn@latest # or @0.1.0 to pin
|
|
82
|
-
```
|
|
83
|
-
|
|
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.
|
|
69
|
+
Plain dependency bump — there is no `create-tessera upgrade`: `pnpm add tessera-learn@latest` (or `@0.1.0` to pin). The framework owns the build, reserved scripts, and this guide, so a bump needs no reconciling; your root `CLAUDE.md`/`AGENTS.md` aren't overwritten — add workspace standards to their Project notes freely.
|
|
85
70
|
|
|
86
71
|
### Customising the build (optional)
|
|
87
72
|
|
|
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
|
|
73
|
+
You never write `vite.config.js`. To extend the build, add `tessera.config.js` at the project root — a **partial** Vite config merged on top of Tessera's (`tesseraPlugin()` and the Svelte compiler stay wired in). Never scaffolded, never touched by updates.
|
|
89
74
|
|
|
90
75
|
```js
|
|
91
76
|
// tessera.config.js
|
|
@@ -95,8 +80,6 @@ export default {
|
|
|
95
80
|
};
|
|
96
81
|
```
|
|
97
82
|
|
|
98
|
-
It is never scaffolded and never touched by updates.
|
|
99
|
-
|
|
100
83
|
---
|
|
101
84
|
|
|
102
85
|
## Project Structure
|
|
@@ -150,25 +133,16 @@ If none fit, surface the limitation — don't patch around it in `node_modules/`
|
|
|
150
133
|
|
|
151
134
|
### Hierarchy and ordering
|
|
152
135
|
|
|
153
|
-
- Manifest is always **section → lesson → page**. Files directly in a section folder flatten into one implicit lesson titled after the section
|
|
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").
|
|
136
|
+
- Manifest is always **section → lesson → page**. Files directly in a section folder flatten into one implicit lesson titled after the section; lesson subdirectories nest. Both shapes can coexist.
|
|
137
|
+
- Sorting is alphabetical by directory/filename. Numeric prefixes on directories (`01-`, `02-`) set explicit order and are stripped from slugs/titles (`01-getting-started/` → slug `getting-started`, title "Getting Started").
|
|
156
138
|
- Control page order **within a lesson** with `_meta.js`, not filename prefixes.
|
|
157
139
|
|
|
158
140
|
### `_meta.js`
|
|
159
141
|
|
|
160
|
-
Optional everywhere
|
|
161
|
-
|
|
162
|
-
Use it only for a real override:
|
|
163
|
-
|
|
164
|
-
```js
|
|
165
|
-
// title override (folder name doesn't derive to what you want)
|
|
166
|
-
export default { title: 'How to play' }; // folder is `01-intro`
|
|
167
|
-
```
|
|
142
|
+
Optional everywhere; defaults are title-cased slug + alphabetical pages. **Omit it unless you need a real override.** Two fields: `title` (folder name doesn't derive to what you want) and `pages` (explicit order — listed first, unlisted `.svelte` appended alphabetically):
|
|
168
143
|
|
|
169
144
|
```js
|
|
170
|
-
|
|
171
|
-
export default { title: 'Welcome', pages: ['welcome', 'objectives'] };
|
|
145
|
+
export default { title: 'How to play', pages: ['welcome', 'objectives'] };
|
|
172
146
|
```
|
|
173
147
|
|
|
174
148
|
---
|
|
@@ -187,47 +161,27 @@ A custom widget that calls `useQuestion` and emits an `Interaction` is scored, r
|
|
|
187
161
|
|
|
188
162
|
## Creating Pages
|
|
189
163
|
|
|
190
|
-
Each page is a `.svelte` file inside a lesson folder
|
|
164
|
+
Each page is a `.svelte` file inside a lesson folder; standard HTML works as-is. Import components from `tessera-learn` (`import { Callout, Image } from 'tessera-learn'`).
|
|
191
165
|
|
|
192
|
-
|
|
193
|
-
|
|
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.
|
|
166
|
+
`pageConfig` sets the title and configures quizzes. It must be a **static object literal** in a module script block — no variables, function calls, or computed values. Both `<script module>` (Svelte 5) and `<script context="module">` (legacy) parse. If `title` is omitted it derives from the filename (`my-page.svelte` → "My Page").
|
|
195
167
|
|
|
196
168
|
```svelte
|
|
197
169
|
<script module>
|
|
198
|
-
export const pageConfig = {
|
|
199
|
-
title: 'Introduction to the Topic',
|
|
200
|
-
};
|
|
170
|
+
export const pageConfig = { title: 'Introduction to the Topic' };
|
|
201
171
|
</script>
|
|
202
172
|
|
|
203
173
|
<h1>Introduction to the Topic</h1>
|
|
204
174
|
```
|
|
205
175
|
|
|
206
|
-
If `title` is omitted, it derives from the filename: `my-page.svelte` → "My Page".
|
|
207
|
-
|
|
208
|
-
### Importing components
|
|
209
|
-
|
|
210
|
-
```svelte
|
|
211
|
-
<script>
|
|
212
|
-
import { Callout, Image } from 'tessera-learn';
|
|
213
|
-
</script>
|
|
214
|
-
|
|
215
|
-
<Callout type="info"><p>Helpful information.</p></Callout>
|
|
216
|
-
```
|
|
217
|
-
|
|
218
176
|
---
|
|
219
177
|
|
|
220
178
|
## Component Reference
|
|
221
179
|
|
|
222
|
-
All components import from `tessera-learn`. Nothing loads automatically.
|
|
180
|
+
All components import from `tessera-learn`. Nothing loads automatically. Each is accessible by construction (ARIA roles, keyboard, focus management) — you only supply the props below.
|
|
223
181
|
|
|
224
182
|
### Callout
|
|
225
183
|
|
|
226
|
-
Styled box.
|
|
227
|
-
|
|
228
|
-
| Prop | Type | Default |
|
|
229
|
-
| ------ | --------------------------------------------- | -------- |
|
|
230
|
-
| `type` | `"info" \| "warning" \| "tip" \| "important"` | `"info"` |
|
|
184
|
+
Styled box; children become the body. Prop `type`: `"info"` (default) | `"warning"` | `"tip"` | `"important"`.
|
|
231
185
|
|
|
232
186
|
```svelte
|
|
233
187
|
<Callout type="warning"><p>Be careful.</p></Callout>
|
|
@@ -235,7 +189,7 @@ Styled box. A11y: `role="note"` with type-appropriate `aria-label`. Children bec
|
|
|
235
189
|
|
|
236
190
|
### Image
|
|
237
191
|
|
|
238
|
-
Lazy-loaded
|
|
192
|
+
Lazy-loaded, renders as `<figure>`/`<figcaption>`.
|
|
239
193
|
|
|
240
194
|
| Prop | Type | Description |
|
|
241
195
|
| ------------ | --------- | ---------------------------------------------------------------------- |
|
|
@@ -260,7 +214,7 @@ Rules:
|
|
|
260
214
|
|
|
261
215
|
### Accordion / AccordionItem
|
|
262
216
|
|
|
263
|
-
Expandable panels, one open at a time.
|
|
217
|
+
Expandable panels, one open at a time. `AccordionItem` takes a `title` prop; children are the body.
|
|
264
218
|
|
|
265
219
|
```svelte
|
|
266
220
|
<Accordion>
|
|
@@ -275,24 +229,24 @@ Expandable panels, one open at a time. A11y: `aria-expanded`, `aria-controls`, `
|
|
|
275
229
|
|
|
276
230
|
### Carousel / CarouselSlide
|
|
277
231
|
|
|
278
|
-
Slide viewer
|
|
232
|
+
Slide viewer; wrap each slide's content in `<CarouselSlide>`.
|
|
279
233
|
|
|
280
234
|
```svelte
|
|
281
235
|
<Carousel>
|
|
282
|
-
<CarouselSlide
|
|
283
|
-
|
|
284
|
-
<p>Plan.</p
|
|
285
|
-
|
|
286
|
-
<CarouselSlide
|
|
287
|
-
|
|
288
|
-
<p>Build.</p
|
|
289
|
-
|
|
236
|
+
<CarouselSlide
|
|
237
|
+
><h3>Step 1</h3>
|
|
238
|
+
<p>Plan.</p></CarouselSlide
|
|
239
|
+
>
|
|
240
|
+
<CarouselSlide
|
|
241
|
+
><h3>Step 2</h3>
|
|
242
|
+
<p>Build.</p></CarouselSlide
|
|
243
|
+
>
|
|
290
244
|
</Carousel>
|
|
291
245
|
```
|
|
292
246
|
|
|
293
247
|
### RevealModal
|
|
294
248
|
|
|
295
|
-
Modal triggered by interaction. Uses Svelte 5 snippets.
|
|
249
|
+
Modal triggered by interaction. Uses Svelte 5 snippets.
|
|
296
250
|
|
|
297
251
|
| Prop | Type | Description |
|
|
298
252
|
| --------- | --------- | --------------------------------- |
|
|
@@ -310,29 +264,25 @@ Modal triggered by interaction. Uses Svelte 5 snippets. A11y: `role="dialog"`, `
|
|
|
310
264
|
</RevealModal>
|
|
311
265
|
```
|
|
312
266
|
|
|
313
|
-
### Video
|
|
267
|
+
### Video / Audio
|
|
314
268
|
|
|
315
|
-
YouTube/Vimeo iframe (auto-detected, responsive 16:9) or native `<video>` for direct files.
|
|
269
|
+
`Video` is a YouTube/Vimeo iframe (auto-detected, responsive 16:9) or native `<video>` for direct files; `Audio` is a native player. Both lazy-load and share these props:
|
|
316
270
|
|
|
317
|
-
| Prop | Type | Description
|
|
318
|
-
| ------------ | -------- |
|
|
319
|
-
| `src` | `string` |
|
|
320
|
-
| `title` | `string` | **Required.** Accessible label (empty/whitespace rejected)
|
|
321
|
-
| `tracks` | `array` | Caption tracks
|
|
322
|
-
| `transcript` | `string` | Transcript in a `<details
|
|
271
|
+
| Prop | Type | Description |
|
|
272
|
+
| ------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
273
|
+
| `src` | `string` | URL or `$assets/` path |
|
|
274
|
+
| `title` | `string` | **Required.** Accessible label (empty/whitespace rejected) |
|
|
275
|
+
| `tracks` | `array` | Caption tracks → `<track>`; `{ src, kind?: 'captions' \| 'subtitles', srclang?, label? }`. Native only (ignored for YouTube/Vimeo) |
|
|
276
|
+
| `transcript` | `string` | Transcript in a `<details>`. Load from file via `?raw` import |
|
|
323
277
|
|
|
324
|
-
Captions rule (WCAG 1.2): native video needs `tracks` or `transcript
|
|
278
|
+
Captions rule (WCAG 1.2): native video needs `tracks` or `transcript`, an embed needs `transcript` (embeds can't carry `<track>`); the validator warns when `<Audio>` has no `transcript`.
|
|
325
279
|
|
|
326
280
|
```svelte
|
|
327
281
|
<script>
|
|
328
282
|
import intro from '$assets/intro.txt?raw';
|
|
329
283
|
</script>
|
|
330
284
|
|
|
331
|
-
<Video
|
|
332
|
-
src="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
|
333
|
-
title="Intro"
|
|
334
|
-
transcript={intro}
|
|
335
|
-
/>
|
|
285
|
+
<Video src="https://youtube.com/watch?v=ID" title="Intro" transcript={intro} />
|
|
336
286
|
<Video
|
|
337
287
|
src="$assets/demo.mp4"
|
|
338
288
|
title="Demo"
|
|
@@ -345,27 +295,7 @@ Captions rule (WCAG 1.2): native video needs `tracks` or `transcript`; an embed
|
|
|
345
295
|
},
|
|
346
296
|
]}
|
|
347
297
|
/>
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
### Audio
|
|
351
|
-
|
|
352
|
-
Native player. A11y: `aria-label` from title.
|
|
353
|
-
|
|
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`) |
|
|
360
|
-
|
|
361
|
-
Transcript rule (WCAG 1.2.1): the validator warns when `<Audio>` has no `transcript`.
|
|
362
|
-
|
|
363
|
-
```svelte
|
|
364
|
-
<script>
|
|
365
|
-
import lecture from '$assets/lecture-01.txt?raw';
|
|
366
|
-
</script>
|
|
367
|
-
|
|
368
|
-
<Audio src="$assets/lecture-01.mp3" title="Lecture 1" transcript={lecture} />
|
|
298
|
+
<Audio src="$assets/lecture.mp3" title="Lecture 1" transcript={intro} />
|
|
369
299
|
```
|
|
370
300
|
|
|
371
301
|
---
|
|
@@ -409,11 +339,7 @@ A quiz page is a normal page with `pageConfig.quiz` set. The runtime wraps it in
|
|
|
409
339
|
- **`Sorting.correct` is a parallel array to `items`** — same length, each entry a valid index into `targets`.
|
|
410
340
|
- **Question `id`s are unique within a page.** Duplicates collide in `cmi.interactions`.
|
|
411
341
|
- **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.
|
|
413
|
-
|
|
414
|
-
### Data contract
|
|
415
|
-
|
|
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.**
|
|
342
|
+
- **Custom widgets register through `useQuestion` and submit through `useQuiz().submit()`** — otherwise the LMS sees nothing.
|
|
417
343
|
|
|
418
344
|
### `pageConfig.quiz` fields
|
|
419
345
|
|
|
@@ -427,64 +353,17 @@ Whatever quiz UI you build, the LMS sees the same `cmi.interactions` as the buil
|
|
|
427
353
|
|
|
428
354
|
### Per-question weighting
|
|
429
355
|
|
|
430
|
-
Pass `weight` (default 1; non-positive treated as 1) to change how much a question pulls on the page score
|
|
431
|
-
|
|
432
|
-
```svelte
|
|
433
|
-
<MultipleChoice id="q-easy" weight={1} ... />
|
|
434
|
-
<MultipleChoice id="q-hard" weight={3} ... />
|
|
435
|
-
```
|
|
436
|
-
|
|
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).
|
|
356
|
+
Pass `weight` (default 1; non-positive treated as 1) to change how much a question pulls on the page score; works identically inside `<Quiz>` and standalone. Page score = `Σ(weight × correct) / Σ(weight) × 100`, rounded. Weights affect only the page-level `cmi.core.score.raw` rollup, not `cmi.interactions.*` (each question is still one pass/fail interaction).
|
|
438
357
|
|
|
439
358
|
### Question types
|
|
440
359
|
|
|
441
|
-
|
|
442
|
-
|
|
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) |
|
|
452
|
-
|
|
453
|
-
```svelte
|
|
454
|
-
<MultipleChoice
|
|
455
|
-
question="What is the capital of France?"
|
|
456
|
-
options={['London', 'Berlin', 'Paris', 'Madrid']}
|
|
457
|
-
correct={2}
|
|
458
|
-
/>
|
|
459
|
-
```
|
|
360
|
+
Every type also accepts `weight` (page-level rollup, default 1). Syntax is shown in [Setup](#setup); the complex shapes get an example below.
|
|
460
361
|
|
|
461
|
-
|
|
362
|
+
**MultipleChoice** — `question` `string`, `options` `string[]`, `correct` `number` (0-based index). Optional: `correctFeedback` / `incorrectFeedback` `string`, `optionFeedback` `string[]`.
|
|
462
363
|
|
|
463
|
-
|
|
464
|
-
| --------------- | ---------- | ------- | ------------------------ |
|
|
465
|
-
| `question` | `string` | | Prompt |
|
|
466
|
-
| `answers` | `string[]` | | Acceptable answers |
|
|
467
|
-
| `caseSensitive` | `boolean` | `false` | Comparison casing |
|
|
468
|
-
| `weight` | `number` | `1` | Page-level rollup weight |
|
|
364
|
+
**FillInTheBlank** — `question` `string`, `answers` `string[]` (distinct spellings only), `caseSensitive` `boolean` (default `false`, handles case variants).
|
|
469
365
|
|
|
470
|
-
`
|
|
471
|
-
|
|
472
|
-
```svelte
|
|
473
|
-
<FillInTheBlank
|
|
474
|
-
question="What element has the symbol 'O'?"
|
|
475
|
-
answers={['Oxygen']}
|
|
476
|
-
/>
|
|
477
|
-
```
|
|
478
|
-
|
|
479
|
-
#### Matching
|
|
480
|
-
|
|
481
|
-
Right column auto-shuffled. Click left then right to match (tap on mobile); click a pair to unmatch. All pairs must be correct.
|
|
482
|
-
|
|
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) |
|
|
366
|
+
**Matching** — `question` `string`, `pairs` `{left, right}[]`. Right column auto-shuffled; click left then right to match (tap on mobile), click a pair to unmatch; all pairs must be correct.
|
|
488
367
|
|
|
489
368
|
```svelte
|
|
490
369
|
<Matching
|
|
@@ -492,74 +371,40 @@ Right column auto-shuffled. Click left then right to match (tap on mobile); clic
|
|
|
492
371
|
pairs={[
|
|
493
372
|
{ left: 'France', right: 'Paris' },
|
|
494
373
|
{ left: 'Germany', right: 'Berlin' },
|
|
495
|
-
{ left: 'Japan', right: 'Tokyo' },
|
|
496
374
|
]}
|
|
497
375
|
/>
|
|
498
376
|
```
|
|
499
377
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
Drag-and-drop (or click-to-place) into labelled categories.
|
|
503
|
-
|
|
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) |
|
|
378
|
+
**Sorting** — `question` `string`, `items` `string[]`, `targets` `string[]` (category labels), `correct` `number[]` (parallel to `items`; each entry an index into `targets`). Drag-and-drop or click-to-place.
|
|
511
379
|
|
|
512
380
|
```svelte
|
|
513
381
|
<Sorting
|
|
514
382
|
question="Sort each animal:"
|
|
515
|
-
items={['Dog', 'Eagle', 'Salmon', 'Cat'
|
|
383
|
+
items={['Dog', 'Eagle', 'Salmon', 'Cat']}
|
|
516
384
|
targets={['Mammals', 'Birds', 'Fish']}
|
|
517
|
-
correct={[0, 1, 2, 0
|
|
385
|
+
correct={[0, 1, 2, 0]}
|
|
518
386
|
/>
|
|
519
387
|
```
|
|
520
388
|
|
|
521
389
|
### Standalone questions
|
|
522
390
|
|
|
523
|
-
All four types work outside `<Quiz>` for inline practice
|
|
524
|
-
|
|
525
|
-
| Prop | Type | Default | Description |
|
|
526
|
-
| ------------ | -------- | ---------- | ------------------------------ |
|
|
527
|
-
| `maxRetries` | `number` | `Infinity` | Max retries for standalone |
|
|
528
|
-
| `weight` | `number` | `1` | Per-question page-level weight |
|
|
529
|
-
|
|
530
|
-
```svelte
|
|
531
|
-
<MultipleChoice
|
|
532
|
-
question="What color is the sky on a clear day?"
|
|
533
|
-
options={['Red', 'Blue', 'Green']}
|
|
534
|
-
correct={1}
|
|
535
|
-
maxRetries={2}
|
|
536
|
-
/>
|
|
537
|
-
```
|
|
538
|
-
|
|
539
|
-
Standalone questions are not graded by default. To grade one, build it with `useQuestion`. See [Recipe 5](#recipe-5-graded-standalone-question).
|
|
391
|
+
All four types work outside `<Quiz>` for inline practice, rendering their own Check/Retry. They accept `maxRetries` (`number`, default `Infinity`). Not graded by default — to grade one, build it with `useQuestion` (see [Recipe 3](#recipe-3-graded-standalone-question)).
|
|
540
392
|
|
|
541
393
|
---
|
|
542
394
|
|
|
543
395
|
## Manual completion
|
|
544
396
|
|
|
545
|
-
Use `completion.mode: "manual"` when the author owns the completion moment (
|
|
546
|
-
|
|
547
|
-
Both triggers below are always active under manual mode. First-to-fire wins; subsequent calls are idempotent.
|
|
548
|
-
|
|
549
|
-
### Trigger A: page frontmatter
|
|
397
|
+
Use `completion.mode: "manual"` when the author owns the completion moment (final page read, "click to acknowledge") rather than a quiz score or visit ratio. Two triggers, both always active; first-to-fire wins, re-marks are idempotent (completion is monotonic — you can't un-complete):
|
|
550
398
|
|
|
551
|
-
|
|
399
|
+
- **Page frontmatter** — `completesOn: "view"` (only v1 value) in a page's `pageConfig`; fires when that page renders.
|
|
400
|
+
- **Runtime hook** — `useCompletion().markComplete()`, composed with any event (modal close, video-ended, timer). Outside manual mode it's a no-op with a one-shot dev warning, so it's safe in shared components.
|
|
552
401
|
|
|
553
402
|
```svelte
|
|
554
403
|
<script module>
|
|
555
404
|
export const pageConfig = { title: "You're done", completesOn: 'view' };
|
|
556
405
|
</script>
|
|
557
|
-
|
|
558
|
-
<h1>Thanks for completing the briefing.</h1>
|
|
559
406
|
```
|
|
560
407
|
|
|
561
|
-
### Trigger B: runtime hook
|
|
562
|
-
|
|
563
408
|
```svelte
|
|
564
409
|
<script>
|
|
565
410
|
import { useCompletion } from 'tessera-learn';
|
|
@@ -572,77 +417,36 @@ Declare `completesOn: "view"` (the only v1 value) on any page. Completion fires
|
|
|
572
417
|
>
|
|
573
418
|
I acknowledge
|
|
574
419
|
</button>
|
|
575
|
-
|
|
576
|
-
{#if completionStatus === 'complete'}
|
|
577
|
-
<p>Recorded. You may now close this window.</p>
|
|
578
|
-
{/if}
|
|
579
|
-
```
|
|
580
|
-
|
|
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.
|
|
582
|
-
|
|
583
|
-
### `completion.trigger` (build-time check)
|
|
584
|
-
|
|
585
|
-
Optional. Set to `"page"` to fail the build when no page declares `completesOn: "view"`. Both triggers still work regardless.
|
|
586
|
-
|
|
587
|
-
```js
|
|
588
|
-
completion: { mode: "manual", trigger: "page" }
|
|
589
420
|
```
|
|
590
421
|
|
|
591
|
-
|
|
422
|
+
**Build-time check:** `completion: { mode: "manual", trigger: "page" }` fails the build when no page declares `completesOn: "view"`. Omitted, the dev runtime warns once after 60s if completion hasn't fired.
|
|
592
423
|
|
|
593
424
|
### Success status
|
|
594
425
|
|
|
595
|
-
By default `successStatus` stays `"unknown"`
|
|
426
|
+
By default `successStatus` stays `"unknown"`. Set `requireSuccessStatus: "passed"` (or `"failed"`) for an automatic pass alongside completion:
|
|
596
427
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
|
602
|
-
|
|
|
603
|
-
|
|
|
604
|
-
| SCORM 2004 4th | `cmi.completion_status = "completed"`, `cmi.success_status = "unknown"` |
|
|
605
|
-
| cmi5 | **Completed** statement (no Passed / Failed) |
|
|
606
|
-
| web | `localStorage` only |
|
|
607
|
-
|
|
608
|
-
With `requireSuccessStatus: "passed"`: SCORM 1.2 → `lesson_status = "passed"`, SCORM 2004 → `success_status = "passed"`, cmi5 → **Passed** alongside **Completed**.
|
|
428
|
+
| Adapter | `markComplete()`, default | with `requireSuccessStatus: "passed"` |
|
|
429
|
+
| -------------- | --------------------------------------------------------------- | ------------------------------------- |
|
|
430
|
+
| SCORM 1.2 | `lesson_status = "completed"` | `lesson_status = "passed"` |
|
|
431
|
+
| SCORM 2004 4th | `completion_status = "completed"`, `success_status = "unknown"` | `success_status = "passed"` |
|
|
432
|
+
| cmi5 | **Completed** (no Passed/Failed) | **Passed** alongside **Completed** |
|
|
433
|
+
| xapi | **Completed** (no Passed/Failed) | **Passed** alongside **Completed** |
|
|
434
|
+
| web | `localStorage` only | `localStorage` only |
|
|
609
435
|
|
|
610
|
-
###
|
|
436
|
+
### Rules and non-goals
|
|
611
437
|
|
|
612
|
-
A graded quiz reports its score
|
|
613
|
-
|
|
614
|
-
### Non-goals
|
|
615
|
-
|
|
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.
|
|
438
|
+
- A graded quiz reports its score but does **not** drive completion/success under manual — `markComplete()`/`completesOn` does (the build warns; set `graded: false` to silence).
|
|
439
|
+
- Combining manual + quiz/percentage rules, or per-learner conditional completion → use `useCompletion()` in a custom `$effect`/component, not config.
|
|
619
440
|
|
|
620
441
|
---
|
|
621
442
|
|
|
622
443
|
## Assets
|
|
623
444
|
|
|
624
|
-
Drop files into `assets/`. Reference with `$assets/` in built-in component props
|
|
625
|
-
|
|
626
|
-
```svelte
|
|
627
|
-
<Image src="$assets/photo.png" alt="Photo" />
|
|
628
|
-
<Video src="$assets/demo.mp4" title="Demo" />
|
|
629
|
-
```
|
|
630
|
-
|
|
631
|
-
In CSS, use a relative path from `styles/`:
|
|
632
|
-
|
|
633
|
-
```css
|
|
634
|
-
.bg {
|
|
635
|
-
background-image: url('../assets/bg.png');
|
|
636
|
-
}
|
|
637
|
-
```
|
|
638
|
-
|
|
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.
|
|
445
|
+
Drop files into `assets/`. Reference with `$assets/` in built-in component props (`<Image src="$assets/photo.png" alt="…" />`); in CSS use a relative path (`url('../assets/bg.png')`). External URLs work too. At build the plugin copies `assets/` → `dist/assets/`, so paths resolve the same in dev and the bundle.
|
|
640
446
|
|
|
641
447
|
### `$assets/` in custom components
|
|
642
448
|
|
|
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
|
|
644
|
-
|
|
645
|
-
Pick by use case:
|
|
449
|
+
`$assets/` is **only** rewritten in two places: ES `import` statements (Vite alias) and the `src` prop of built-in `Image`/`Audio`/`Video`. **Raw HTML attributes are NOT rewritten** — `<img src="$assets/foo.svg">`, `new Audio('$assets/...')`, and JS-built CSS `url()` strings all 404 silently. Pick by use case:
|
|
646
450
|
|
|
647
451
|
**One-off — ES import (preferred).** Build-time bundling, hashing, fails the build if missing:
|
|
648
452
|
|
|
@@ -665,19 +469,13 @@ const signs = import.meta.glob('$assets/signs/*.svg', {
|
|
|
665
469
|
const url = signs[`/assets/signs/${filename}`]; // look up by full key
|
|
666
470
|
```
|
|
667
471
|
|
|
668
|
-
**Pure runtime string (last resort).** No build-time guarantees;
|
|
669
|
-
|
|
670
|
-
```js
|
|
671
|
-
const src = `./assets/signs/${filename}`;
|
|
672
|
-
```
|
|
472
|
+
**Pure runtime string (last resort).** No build-time guarantees; only when the filename comes from server data: `` const src = `./assets/signs/${filename}` ``.
|
|
673
473
|
|
|
674
474
|
---
|
|
675
475
|
|
|
676
476
|
## Styling
|
|
677
477
|
|
|
678
|
-
Add `.css` files to `styles
|
|
679
|
-
|
|
680
|
-
Override these custom properties to theme globally:
|
|
478
|
+
Add `.css` files to `styles/`; they load after framework styles and override them. Theme globally by overriding these custom properties:
|
|
681
479
|
|
|
682
480
|
| Property | Default |
|
|
683
481
|
| ---------------------------------------------- | ------------------------------------- |
|
|
@@ -715,6 +513,7 @@ For the common case, set `branding.primaryColor` and `branding.fontFamily` in `c
|
|
|
715
513
|
```js
|
|
716
514
|
export default {
|
|
717
515
|
title: 'My Course', // required — the only field with no default
|
|
516
|
+
id: 'urn:uuid:…', // unique course identity; scaffolders generate one — keep it
|
|
718
517
|
description: '',
|
|
719
518
|
author: '',
|
|
720
519
|
version: '1.0.0',
|
|
@@ -733,8 +532,7 @@ export default {
|
|
|
733
532
|
completion: {
|
|
734
533
|
mode: 'percentage', // "percentage" | "quiz" | "manual"
|
|
735
534
|
percentageThreshold: 100, // 0–100 (percentage mode)
|
|
736
|
-
// trigger: "page",
|
|
737
|
-
// requireSuccessStatus: "passed", // (manual only) "passed" | "failed"
|
|
535
|
+
// (manual only) trigger: "page", requireSuccessStatus: "passed" | "failed"
|
|
738
536
|
},
|
|
739
537
|
|
|
740
538
|
scoring: {
|
|
@@ -742,86 +540,85 @@ export default {
|
|
|
742
540
|
},
|
|
743
541
|
|
|
744
542
|
export: {
|
|
745
|
-
standard: 'web', // "web" | "scorm12" | "scorm2004" | "cmi5"
|
|
543
|
+
standard: 'web', // "web" | "scorm12" | "scorm2004" | "cmi5" | "xapi"
|
|
746
544
|
},
|
|
747
545
|
|
|
748
546
|
a11y: {
|
|
749
|
-
level: 'warn', // "warn" (default) | "error"
|
|
750
|
-
standard: 'wcag2aa', // "wcag2a" | "wcag2aa" (default) | "wcag21aa"
|
|
751
|
-
ignore: [], // rule IDs to suppress, e.g. ["tessera/heading-order"
|
|
547
|
+
level: 'warn', // "warn" (default) | "error"
|
|
548
|
+
standard: 'wcag2aa', // "wcag2a" | "wcag2aa" (default) | "wcag21aa"
|
|
549
|
+
ignore: [], // rule IDs to suppress, e.g. ["tessera/heading-order"]
|
|
752
550
|
},
|
|
753
551
|
};
|
|
754
552
|
```
|
|
755
553
|
|
|
756
554
|
### Field behaviour
|
|
757
555
|
|
|
758
|
-
| Field | Behaviour
|
|
759
|
-
| ------------------------------- |
|
|
760
|
-
| `
|
|
761
|
-
| `
|
|
762
|
-
| `navigation.mode: "
|
|
763
|
-
| `
|
|
764
|
-
| `completion.mode: "
|
|
765
|
-
| `completion.mode: "
|
|
766
|
-
| `
|
|
767
|
-
| `a11y.
|
|
556
|
+
| Field | Behaviour |
|
|
557
|
+
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
558
|
+
| `id` | Unique course identity; seeds the web localStorage key and the cmi5/xAPI activity id. Scaffolders mint a `urn:uuid:…`. Missing → falls back to a fixed value (collides across courses) and the build warns. `tessera duplicate` regenerates it. |
|
|
559
|
+
| `language` | Sets `<html lang>` (WCAG 3.1.1). Missing/implausible value warns and falls back to `"en"` |
|
|
560
|
+
| `navigation.mode: "free"` | All pages accessible except those blocked by gating quizzes |
|
|
561
|
+
| `navigation.mode: "sequential"` | Pages unlock one at a time as each completes |
|
|
562
|
+
| `completion.mode: "percentage"` | Completes when `visitedPages / totalPages * 100 >= percentageThreshold` |
|
|
563
|
+
| `completion.mode: "quiz"` | Completes when graded quiz average >= `scoring.passingScore` |
|
|
564
|
+
| `completion.mode: "manual"` | Completes when an author trigger fires. See [Manual completion](#manual-completion) |
|
|
565
|
+
| `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 |
|
|
566
|
+
| `a11y.ignore` | Flat list matched literally against every diagnostic rule ID across all tiers (`tessera/…`, `a11y_…`, bare axe IDs) |
|
|
768
567
|
|
|
769
568
|
### Minimum config
|
|
770
569
|
|
|
771
|
-
Every field except `title` has a default, so `export default { title: "My Course" }` is complete
|
|
772
|
-
|
|
773
|
-
```js
|
|
774
|
-
{
|
|
775
|
-
title: "Untitled Course",
|
|
776
|
-
language: "en",
|
|
777
|
-
navigation: { mode: "free" },
|
|
778
|
-
completion: { mode: "percentage", percentageThreshold: 100 },
|
|
779
|
-
scoring: { passingScore: 70 },
|
|
780
|
-
export: { standard: "web" },
|
|
781
|
-
}
|
|
782
|
-
```
|
|
570
|
+
Every field except `title` has a default, so `export default { title: "My Course" }` is complete: free nav, full-percentage completion, web export, `<html lang="en">`, `passingScore: 70`.
|
|
783
571
|
|
|
784
572
|
### Custom access rules
|
|
785
573
|
|
|
786
|
-
For anything beyond the two presets (prereqs, instructor approval, time gating), supply `navigation.canAccess
|
|
574
|
+
For anything beyond the two presets (prereqs, instructor approval, time gating), supply `navigation.canAccess` (with `mode`). It runs synchronously on every navigation evaluation — keep it cheap. Here, gate `lesson-5` on a prior quiz score:
|
|
787
575
|
|
|
788
576
|
```js
|
|
789
577
|
import { sequentialAccess } from 'tessera-learn';
|
|
790
578
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
(p) => p.slug === 'lesson-2-quiz',
|
|
799
|
-
);
|
|
800
|
-
return (
|
|
801
|
-
(ctx.progress.quizScores.get(i) ?? 0) >=
|
|
802
|
-
ctx.config.scoring.passingScore
|
|
803
|
-
);
|
|
804
|
-
}
|
|
805
|
-
return true;
|
|
806
|
-
},
|
|
807
|
-
},
|
|
579
|
+
canAccess: (ctx) => {
|
|
580
|
+
if (!sequentialAccess(ctx)) return false;
|
|
581
|
+
if (ctx.page.slug !== 'lesson-5') return true;
|
|
582
|
+
const i = ctx.manifest.pages.findIndex((p) => p.slug === 'lesson-2-quiz');
|
|
583
|
+
return (
|
|
584
|
+
(ctx.progress.quizScores.get(i) ?? 0) >= ctx.config.scoring.passingScore
|
|
585
|
+
);
|
|
808
586
|
};
|
|
809
587
|
```
|
|
810
588
|
|
|
811
|
-
`AccessContext` exposes `pageIndex`, `page`, `manifest`, `progress`, `config`. Presets `freeAccess`
|
|
589
|
+
`AccessContext` (`ctx`) exposes `pageIndex`, `page`, `manifest`, `progress`, `config`. Presets `freeAccess` / `sequentialAccess` are re-exported for composition; `resolveAccess(config)` returns the predicate the runtime would use (custom `canAccess` if set, else the matching preset) — use it to wrap rather than replace.
|
|
812
590
|
|
|
813
591
|
### Build output
|
|
814
592
|
|
|
815
593
|
`pnpm export <course>` writes:
|
|
816
594
|
|
|
817
|
-
| `export.standard` | What ships
|
|
818
|
-
| ----------------- |
|
|
819
|
-
| `web` | Static site (HTML/CSS/JS + `assets/`)
|
|
820
|
-
| `scorm12` | SCORM 1.2 package
|
|
821
|
-
| `scorm2004` | SCORM 2004 4th Edition package
|
|
822
|
-
| `cmi5` | cmi5 package (AU + manifest)
|
|
595
|
+
| `export.standard` | What ships | Where |
|
|
596
|
+
| ----------------- | ------------------------------------------- | ----------------------------- |
|
|
597
|
+
| `web` | Static site (HTML/CSS/JS + `assets/`) | `dist/` (any static host) |
|
|
598
|
+
| `scorm12` | SCORM 1.2 package | `dist/<course>-scorm12.zip` |
|
|
599
|
+
| `scorm2004` | SCORM 2004 4th Edition package | `dist/<course>-scorm2004.zip` |
|
|
600
|
+
| `cmi5` | cmi5 package (AU + manifest) | `dist/<course>-cmi5.zip` |
|
|
601
|
+
| `xapi` | xAPI 1.0.3 "Tin Can" package (`tincan.xml`) | `dist/<course>-xapi.zip` |
|
|
602
|
+
|
|
603
|
+
Upload the LMS zips via your LMS's import flow; drop `dist/` (web) on any static host.
|
|
604
|
+
|
|
605
|
+
### Web export Content-Security-Policy
|
|
606
|
+
|
|
607
|
+
Web builds emit a baseline CSP `<meta>` (LMS packages and the dev server don't). It allows any `https:` for images/media/frames/network, but **not** for scripts, styles, or fonts — so a CDN script/stylesheet/font is blocked until you allow its origin. Extend per-directive via `export.csp` (sources are **appended** to the baseline, never replaced):
|
|
608
|
+
|
|
609
|
+
```js
|
|
610
|
+
export: {
|
|
611
|
+
standard: 'web',
|
|
612
|
+
csp: {
|
|
613
|
+
'style-src': ['https://fonts.googleapis.com'],
|
|
614
|
+
'font-src': ['https://fonts.gstatic.com'],
|
|
615
|
+
},
|
|
616
|
+
}
|
|
617
|
+
```
|
|
823
618
|
|
|
824
|
-
|
|
619
|
+
- `export.csp: false` drops the meta entirely (use when your host sets a CSP header).
|
|
620
|
+
- To **tighten** or replace a directive (not just add), use a `transformIndexHtml` hook — `export.csp` only adds.
|
|
621
|
+
- Ignored unless `standard` is `'web'`.
|
|
825
622
|
|
|
826
623
|
### Validation
|
|
827
624
|
|
|
@@ -835,15 +632,7 @@ Two passes plus components that are accessible by construction.
|
|
|
835
632
|
|
|
836
633
|
**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.
|
|
837
634
|
|
|
838
|
-
**Runtime audit** (`tessera a11y`) is the opt-in deep pass.
|
|
839
|
-
|
|
840
|
-
```bash
|
|
841
|
-
pnpm a11y <course> # audit (threshold: serious)
|
|
842
|
-
pnpm a11y <course> --threshold minor # stricter
|
|
843
|
-
pnpm a11y <course> --build # force a fresh build first
|
|
844
|
-
```
|
|
845
|
-
|
|
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`.
|
|
635
|
+
**Runtime audit** (`tessera a11y`, or via `pnpm check`) is the opt-in deep pass: builds the course, renders **every** page headless (incl. quiz-gated), runs [axe-core](https://github.com/dequelabs/axe-core), writes `a11y-report.json` (git-ignored), exits non-zero at/above the impact threshold (default `serious`; `--threshold minor` is stricter). It catches what static can't — computed ARIA, focus order, rendered contrast — and uses the web adapter, so it works regardless of `export.standard`. First run auto-installs Chromium.
|
|
847
636
|
|
|
848
637
|
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"`.
|
|
849
638
|
|
|
@@ -868,7 +657,7 @@ import type { Interaction } from 'tessera-learn';
|
|
|
868
657
|
|
|
869
658
|
### The `Question` model
|
|
870
659
|
|
|
871
|
-
`useQuiz()` and `useQuestion()`
|
|
660
|
+
`useQuiz()` and `useQuestion()` share the same per-question object: a shell iterates `quiz.questions`, a widget gets its `Question` from `useQuestion()`. No indexes, no `getContext`.
|
|
872
661
|
|
|
873
662
|
```ts
|
|
874
663
|
interface Question {
|
|
@@ -889,7 +678,7 @@ Gate input on `q.locked`; branch on `q.isLockedCorrect` only to render the "alre
|
|
|
889
678
|
|
|
890
679
|
`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).
|
|
891
680
|
|
|
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
|
|
681
|
+
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).
|
|
893
682
|
|
|
894
683
|
```ts
|
|
895
684
|
response: () => ({
|
|
@@ -899,6 +688,14 @@ response: () => ({
|
|
|
899
688
|
options: ['stop', 'yield', 'speed-limit', 'merge'],
|
|
900
689
|
});
|
|
901
690
|
// SCORM 1.2 → "2" SCORM 2004 → "speed-limit" cmi5 → "speed-limit"
|
|
691
|
+
|
|
692
|
+
// sequencing: response/correct are ordered id lists; options carries every id
|
|
693
|
+
response: () => ({
|
|
694
|
+
type: 'sequencing',
|
|
695
|
+
response: order, // e.g. ['mercury', 'venus', 'earth']
|
|
696
|
+
correct: ['mercury', 'venus', 'earth'],
|
|
697
|
+
options: ['venus', 'earth', 'mercury'],
|
|
698
|
+
});
|
|
902
699
|
```
|
|
903
700
|
|
|
904
701
|
### `useQuestion`
|
|
@@ -928,29 +725,7 @@ function useQuestion(opts: {
|
|
|
928
725
|
};
|
|
929
726
|
```
|
|
930
727
|
|
|
931
|
-
|
|
932
|
-
<script>
|
|
933
|
-
import { useQuestion } from 'tessera-learn';
|
|
934
|
-
|
|
935
|
-
let order = $state(['Mercury', 'Venus', 'Earth', 'Mars']);
|
|
936
|
-
|
|
937
|
-
const q = useQuestion({
|
|
938
|
-
id: 'planet-rank',
|
|
939
|
-
response: () => ({
|
|
940
|
-
type: 'sequencing',
|
|
941
|
-
response: order,
|
|
942
|
-
correct: ['Mercury', 'Venus', 'Earth', 'Mars'],
|
|
943
|
-
}),
|
|
944
|
-
reset: () => {
|
|
945
|
-
order = ['Mercury', 'Venus', 'Earth', 'Mars'];
|
|
946
|
-
},
|
|
947
|
-
});
|
|
948
|
-
</script>
|
|
949
|
-
|
|
950
|
-
{#if q.mode === 'standalone'}
|
|
951
|
-
<button onclick={() => q.submit()} disabled={q.submitted}>Check</button>
|
|
952
|
-
{/if}
|
|
953
|
-
```
|
|
728
|
+
See [Recipe 2b](#recipe-2b-custom-question-widget-for-a-custom-quiz-shell) for a full widget and [Recipe 3](#recipe-3-graded-standalone-question) for a graded standalone.
|
|
954
729
|
|
|
955
730
|
### `useQuiz`
|
|
956
731
|
|
|
@@ -992,6 +767,8 @@ function useNavigation(): {
|
|
|
992
767
|
};
|
|
993
768
|
```
|
|
994
769
|
|
|
770
|
+
Each `ManifestPage` exposes `slug`, `title`, and `index`.
|
|
771
|
+
|
|
995
772
|
### `useProgress`
|
|
996
773
|
|
|
997
774
|
```ts
|
|
@@ -1028,15 +805,7 @@ function usePersistence<T>(key: string): {
|
|
|
1028
805
|
};
|
|
1029
806
|
```
|
|
1030
807
|
|
|
1031
|
-
|
|
1032
|
-
<script>
|
|
1033
|
-
import { usePersistence } from 'tessera-learn';
|
|
1034
|
-
|
|
1035
|
-
const store = usePersistence('whiteboard');
|
|
1036
|
-
let state = $state(store.get() ?? { strokes: [] });
|
|
1037
|
-
$effect(() => store.set(state));
|
|
1038
|
-
</script>
|
|
1039
|
-
```
|
|
808
|
+
Usage in [Recipe 1](#recipe-1-custom-draw-a-line-question) (persists partial progress).
|
|
1040
809
|
|
|
1041
810
|
### `isCorrect(interaction)`
|
|
1042
811
|
|
|
@@ -1062,11 +831,11 @@ xapi?.sendStatement({
|
|
|
1062
831
|
});
|
|
1063
832
|
```
|
|
1064
833
|
|
|
1065
|
-
`useXAPI()` is
|
|
834
|
+
`useXAPI()` is callable anywhere (setup, handlers, async, `.ts` modules). It returns `null` when no LRS is configured or before adapter init resolves — **null-check and degrade gracefully**. The publisher fills in `actor`, `timestamp`, `id`, `context.contextActivities.grouping`, and (cmi5) `context.registration` + `sessionid`; you supply `verb`, optionally `object` (defaults to the activity), `result`, `context`, `attachments`.
|
|
1066
835
|
|
|
1067
836
|
### Configure the destination
|
|
1068
837
|
|
|
1069
|
-
`config.xapi` is one destination or an array
|
|
838
|
+
`config.xapi` is one destination or an array, always explicit (no implicit default):
|
|
1070
839
|
|
|
1071
840
|
```js
|
|
1072
841
|
xapi: {
|
|
@@ -1076,7 +845,7 @@ xapi: {
|
|
|
1076
845
|
activityId: 'https://example.com/courses/intro-to-x',
|
|
1077
846
|
}
|
|
1078
847
|
|
|
1079
|
-
// cmi5 only: inherit the LMS launch LRS:
|
|
848
|
+
// cmi5 / xapi only: inherit the LMS launch LRS:
|
|
1080
849
|
xapi: { endpoint: 'lms' }
|
|
1081
850
|
|
|
1082
851
|
// Fan out (at most one 'lms' entry):
|
|
@@ -1093,6 +862,7 @@ Each destination has its own queue, auth resolver, and retry loop. One UUID per
|
|
|
1093
862
|
| Mode | `xapi` not set | `xapi.endpoint: 'lms'` | `xapi: {endpoint, ...}` (explicit) |
|
|
1094
863
|
| ------------- | ------------------ | ---------------------- | ------------------------------------------------------- |
|
|
1095
864
|
| **cmi5** | `useXAPI()` → null | Inherits launch LRS | Independent publisher; `actor` defaults to launch actor |
|
|
865
|
+
| **xapi** | `useXAPI()` → null | Inherits launch LRS | Independent publisher; `actor` defaults to launch actor |
|
|
1096
866
|
| **scorm12** | `useXAPI()` → null | **Config error** | Independent; `actor` derived from `cmi.core.student_id` |
|
|
1097
867
|
| **scorm2004** | `useXAPI()` → null | **Config error** | Independent; `actor` derived from `cmi.learner_id` |
|
|
1098
868
|
| **web** | `useXAPI()` → null | **Config error** | Independent; `actor` **required** in config |
|
|
@@ -1101,7 +871,7 @@ Each destination has its own queue, auth resolver, and retry loop. One UUID per
|
|
|
1101
871
|
|
|
1102
872
|
- **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
873
|
- **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
|
|
874
|
+
- **`course.config.js` is serialized verbatim into the client bundle** — every field is public, not just `auth`. Never put a static `auth` string, API key, or any secret in it; use a function that fetches a server-brokered short-lived token. CORS must allow the served origin.
|
|
1105
875
|
- **`actor` is required on web export** and resolved once per page-load (no mid-session identity change in v1 — reload to switch).
|
|
1106
876
|
- **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
877
|
- **Retry:** 3 attempts, exponential backoff; 5xx/network retry, 4xx short-circuits, 409 treated as success. Opt out per call with `sendStatement(stmt, { retry: false })`.
|
|
@@ -1123,7 +893,7 @@ OAuth at the publisher level, statement signing/attachment helpers, offline/Inde
|
|
|
1123
893
|
|
|
1124
894
|
## LMS behaviour
|
|
1125
895
|
|
|
1126
|
-
The runtime translates author intent into adapter calls automatically
|
|
896
|
+
The runtime translates author intent into adapter calls automatically. The author-relevant differences:
|
|
1127
897
|
|
|
1128
898
|
| Concern | SCORM 1.2 | SCORM 2004 4th | cmi5 |
|
|
1129
899
|
| -------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------- | ------------------------------------ |
|
|
@@ -1135,8 +905,8 @@ The runtime translates author intent into adapter calls automatically; you don't
|
|
|
1135
905
|
Author-facing consequences:
|
|
1136
906
|
|
|
1137
907
|
- **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"
|
|
1139
|
-
- **SCORM 2004 / cmi5 honor an LMS-supplied mastery score** at launch, overriding `scoring.passingScore
|
|
908
|
+
- **SCORM 1.2 shows `incomplete` until a graded quiz produces a result** (no "unknown"); pass/fail uses `scoring.passingScore`, not the LMS's mastery field.
|
|
909
|
+
- **SCORM 2004 / cmi5 honor an LMS-supplied mastery score** at launch, overriding `scoring.passingScore` — read it via `useQuiz().passingScore`.
|
|
1140
910
|
- A failed `adapter.init()` renders a visible "This course can't run here" panel — never a silent degradation.
|
|
1141
911
|
|
|
1142
912
|
### Local testing
|
|
@@ -1146,6 +916,7 @@ Author-facing consequences:
|
|
|
1146
916
|
| scorm12 | Upload `dist/*-scorm12.zip` to [SCORM Cloud](https://cloud.scorm.com) (free) or Reload Player |
|
|
1147
917
|
| scorm2004 | SCORM Cloud (easiest); also Moodle, Cornerstone, SuccessFactors, Canvas |
|
|
1148
918
|
| cmi5 | Upload `dist/*-cmi5.zip` to SCORM Cloud and use its generated cmi5 dispatch URL |
|
|
919
|
+
| xapi | Upload `dist/*-xapi.zip` to SCORM Cloud (imports `tincan.xml`) and launch the generated URL |
|
|
1149
920
|
| web | Serve `dist/` from any static host |
|
|
1150
921
|
|
|
1151
922
|
Inspect the LMS API call log to confirm `lesson_status` / `completion_status` / interactions look right.
|
|
@@ -1154,7 +925,7 @@ Inspect the LMS API call log to confirm `lesson_status` / `completion_status` /
|
|
|
1154
925
|
|
|
1155
926
|
## Custom Layouts
|
|
1156
927
|
|
|
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
|
|
928
|
+
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.
|
|
1158
929
|
|
|
1159
930
|
```svelte
|
|
1160
931
|
<!-- layout.svelte -->
|
|
@@ -1225,14 +996,7 @@ Emits a `matching` interaction (scored like `<Matching>`); persists partial prog
|
|
|
1225
996
|
}
|
|
1226
997
|
</script>
|
|
1227
998
|
|
|
1228
|
-
|
|
1229
|
-
width="400"
|
|
1230
|
-
height="200"
|
|
1231
|
-
role="img"
|
|
1232
|
-
aria-label="Drag to match elements to their symbols"
|
|
1233
|
-
>
|
|
1234
|
-
<!-- canvas + line-drawing UI calls connect(l, r) on drop -->
|
|
1235
|
-
</svg>
|
|
999
|
+
<!-- line-drawing UI calls connect(l, r) on drop -->
|
|
1236
1000
|
|
|
1237
1001
|
{#if q.mode === 'standalone'}
|
|
1238
1002
|
<button onclick={() => q.submit()} disabled={q.submitted}>Check</button>
|
|
@@ -1242,94 +1006,7 @@ Emits a `matching` interaction (scored like `<Matching>`); persists partial prog
|
|
|
1242
1006
|
{/if}
|
|
1243
1007
|
```
|
|
1244
1008
|
|
|
1245
|
-
### Recipe 2: Custom
|
|
1246
|
-
|
|
1247
|
-
Horizontal topbar with breadcrumb + progress %.
|
|
1248
|
-
|
|
1249
|
-
```svelte
|
|
1250
|
-
<!-- layout.svelte -->
|
|
1251
|
-
<script>
|
|
1252
|
-
import { useNavigation, useProgress } from 'tessera-learn';
|
|
1253
|
-
|
|
1254
|
-
let { page } = $props();
|
|
1255
|
-
const nav = useNavigation();
|
|
1256
|
-
const progress = useProgress();
|
|
1257
|
-
|
|
1258
|
-
const percent = $derived(
|
|
1259
|
-
Math.round((progress.visitedPages.size / nav.pages.length) * 100),
|
|
1260
|
-
);
|
|
1261
|
-
</script>
|
|
1262
|
-
|
|
1263
|
-
<header class="topbar">
|
|
1264
|
-
<span class="brand">My Course</span>
|
|
1265
|
-
<span class="crumb">{nav.currentPage.section} › {nav.currentPage.title}</span>
|
|
1266
|
-
<span class="progress" aria-live="polite">{percent}% complete</span>
|
|
1267
|
-
</header>
|
|
1268
|
-
|
|
1269
|
-
<main class="content">{@render page()}</main>
|
|
1270
|
-
|
|
1271
|
-
<nav class="footer">
|
|
1272
|
-
<button disabled={!nav.canGoPrev} onclick={() => nav.prev()}>← Back</button>
|
|
1273
|
-
<select
|
|
1274
|
-
onchange={(e) => nav.goTo(e.currentTarget.value)}
|
|
1275
|
-
value={nav.currentPage.slug}
|
|
1276
|
-
>
|
|
1277
|
-
{#each nav.pages as p}<option value={p.slug}>{p.title}</option>{/each}
|
|
1278
|
-
</select>
|
|
1279
|
-
<button disabled={!nav.canGoNext} onclick={() => nav.next()}>Next →</button>
|
|
1280
|
-
</nav>
|
|
1281
|
-
|
|
1282
|
-
<style>
|
|
1283
|
-
.topbar {
|
|
1284
|
-
display: flex;
|
|
1285
|
-
gap: 1rem;
|
|
1286
|
-
padding: 0.75rem 1.5rem;
|
|
1287
|
-
border-bottom: 1px solid var(--tessera-border);
|
|
1288
|
-
}
|
|
1289
|
-
.content {
|
|
1290
|
-
max-width: var(--tessera-content-max-width);
|
|
1291
|
-
margin: 0 auto;
|
|
1292
|
-
padding: 2rem;
|
|
1293
|
-
}
|
|
1294
|
-
.footer {
|
|
1295
|
-
display: flex;
|
|
1296
|
-
gap: 1rem;
|
|
1297
|
-
padding: 1rem 1.5rem;
|
|
1298
|
-
border-top: 1px solid var(--tessera-border);
|
|
1299
|
-
}
|
|
1300
|
-
</style>
|
|
1301
|
-
```
|
|
1302
|
-
|
|
1303
|
-
### Recipe 3: Prerequisite-based access
|
|
1304
|
-
|
|
1305
|
-
Lock lesson 5 until lessons 1–3 are visited. Composes with `sequentialAccess`.
|
|
1306
|
-
|
|
1307
|
-
```js
|
|
1308
|
-
// course.config.js
|
|
1309
|
-
import { sequentialAccess } from 'tessera-learn';
|
|
1310
|
-
|
|
1311
|
-
const PREREQS = ['lesson-1', 'lesson-2', 'lesson-3'];
|
|
1312
|
-
|
|
1313
|
-
export default {
|
|
1314
|
-
title: 'My Course',
|
|
1315
|
-
navigation: {
|
|
1316
|
-
mode: 'sequential',
|
|
1317
|
-
canAccess: (ctx) => {
|
|
1318
|
-
if (!sequentialAccess(ctx)) return false;
|
|
1319
|
-
if (ctx.page.slug !== 'lesson-5') return true;
|
|
1320
|
-
return PREREQS.every((slug) => {
|
|
1321
|
-
const i = ctx.manifest.pages.findIndex((p) => p.slug === slug);
|
|
1322
|
-
return i >= 0 && ctx.progress.visitedPages.has(i);
|
|
1323
|
-
});
|
|
1324
|
-
},
|
|
1325
|
-
},
|
|
1326
|
-
completion: { mode: 'percentage', percentageThreshold: 100 },
|
|
1327
|
-
scoring: { passingScore: 70 },
|
|
1328
|
-
export: { standard: 'web' },
|
|
1329
|
-
};
|
|
1330
|
-
```
|
|
1331
|
-
|
|
1332
|
-
### Recipe 4: Custom quiz shell via `quiz.svelte`
|
|
1009
|
+
### Recipe 2: Custom quiz shell via `quiz.svelte`
|
|
1333
1010
|
|
|
1334
1011
|
Drop `quiz.svelte` at the project root. Use only the public `useQuiz()` API; no imports from `tessera-learn/runtime/*`.
|
|
1335
1012
|
|
|
@@ -1373,7 +1050,7 @@ Drop `quiz.svelte` at the project root. Use only the public `useQuiz()` API; no
|
|
|
1373
1050
|
|
|
1374
1051
|
Always submit through `useQuiz().submit()`.
|
|
1375
1052
|
|
|
1376
|
-
### Recipe
|
|
1053
|
+
### Recipe 2b: Custom question widget for a custom quiz shell
|
|
1377
1054
|
|
|
1378
1055
|
The widget calls `useQuestion()`, registers a render snippet with `setRender`, pushes answers up with `setAnswer`, calls `commit()` when final, and reads `locked`/`feedbackVisible`/`answer`.
|
|
1379
1056
|
|
|
@@ -1441,53 +1118,23 @@ The widget calls `useQuestion()`, registers a render snippet with `setRender`, p
|
|
|
1441
1118
|
|
|
1442
1119
|
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.
|
|
1443
1120
|
|
|
1444
|
-
### Recipe
|
|
1445
|
-
|
|
1446
|
-
A single inline reflection, not in a `<Quiz>` but `graded: true`, so it counts toward course success.
|
|
1447
|
-
|
|
1448
|
-
```svelte
|
|
1449
|
-
<!-- pages/04-reflection/01-reflect/reflect.svelte -->
|
|
1450
|
-
<script module>
|
|
1451
|
-
export const pageConfig = { title: 'Reflection' };
|
|
1452
|
-
</script>
|
|
1453
|
-
|
|
1454
|
-
<script>
|
|
1455
|
-
import { useQuestion } from 'tessera-learn';
|
|
1456
|
-
|
|
1457
|
-
let answer = $state('');
|
|
1458
|
-
|
|
1459
|
-
const q = useQuestion({
|
|
1460
|
-
id: 'why-it-matters',
|
|
1461
|
-
graded: true,
|
|
1462
|
-
response: () => ({
|
|
1463
|
-
type: 'long-fill-in',
|
|
1464
|
-
response: answer,
|
|
1465
|
-
// No `correct`: any answer accepted; we just want completion.
|
|
1466
|
-
}),
|
|
1467
|
-
score: () => (answer.trim().length >= 50 ? 100 : 0),
|
|
1468
|
-
reset: () => {
|
|
1469
|
-
answer = '';
|
|
1470
|
-
},
|
|
1471
|
-
});
|
|
1472
|
-
</script>
|
|
1473
|
-
|
|
1474
|
-
<h1>Why does this matter to you?</h1>
|
|
1475
|
-
<p>At least 50 characters required to pass.</p>
|
|
1121
|
+
### Recipe 3: Graded standalone question
|
|
1476
1122
|
|
|
1477
|
-
|
|
1478
|
-
<button
|
|
1479
|
-
onclick={() => q.submit()}
|
|
1480
|
-
disabled={q.submitted || answer.trim().length < 50}
|
|
1481
|
-
>
|
|
1482
|
-
Submit
|
|
1483
|
-
</button>
|
|
1123
|
+
A standalone question (no `<Quiz>`) counts toward course success when built with `graded: true` + a `score()` returning 0–100; omit `correct` to accept any answer. Course success rolls up across all graded items, quizzes and standalones alike.
|
|
1484
1124
|
|
|
1485
|
-
|
|
1125
|
+
```js
|
|
1126
|
+
const q = useQuestion({
|
|
1127
|
+
id: 'why-it-matters',
|
|
1128
|
+
graded: true,
|
|
1129
|
+
response: () => ({ type: 'long-fill-in', response: answer }),
|
|
1130
|
+
score: () => (answer.trim().length >= 50 ? 100 : 0),
|
|
1131
|
+
reset: () => {
|
|
1132
|
+
answer = '';
|
|
1133
|
+
},
|
|
1134
|
+
});
|
|
1486
1135
|
```
|
|
1487
1136
|
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
### Recipe 6: Chunked-reveal page with `markChunk`
|
|
1137
|
+
### Recipe 4: Chunked-reveal page with `markChunk`
|
|
1491
1138
|
|
|
1492
1139
|
Reveals sections one at a time. `markChunk(pageIndex, chunkIndex)` records the highest revealed chunk so the page resumes mid-scroll on reload.
|
|
1493
1140
|
|
|
@@ -1525,27 +1172,6 @@ Reveals sections one at a time. `markChunk(pageIndex, chunkIndex)` records the h
|
|
|
1525
1172
|
{/if}
|
|
1526
1173
|
```
|
|
1527
1174
|
|
|
1528
|
-
### Recipe 7: Persisted UI state with `usePersistence`
|
|
1529
|
-
|
|
1530
|
-
Any JSON-serialisable value can survive reload — here, a sidebar collapsed toggle.
|
|
1531
|
-
|
|
1532
|
-
```svelte
|
|
1533
|
-
<!-- in any page component, layout.svelte, or a custom widget -->
|
|
1534
|
-
<script>
|
|
1535
|
-
import { usePersistence } from 'tessera-learn';
|
|
1536
|
-
|
|
1537
|
-
const ui = usePersistence('sidebar-prefs');
|
|
1538
|
-
let collapsed = $state(ui.get()?.collapsed ?? false);
|
|
1539
|
-
$effect(() => ui.set({ collapsed }));
|
|
1540
|
-
</script>
|
|
1541
|
-
|
|
1542
|
-
<button onclick={() => (collapsed = !collapsed)}>
|
|
1543
|
-
{collapsed ? 'Expand' : 'Collapse'}
|
|
1544
|
-
</button>
|
|
1545
|
-
```
|
|
1546
|
-
|
|
1547
|
-
Keys are namespaced per course, so two courses on the same LMS don't collide.
|
|
1548
|
-
|
|
1549
1175
|
---
|
|
1550
1176
|
|
|
1551
1177
|
## Constraints
|