tessera-learn 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -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` 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).
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. `tesseraPlugin()` and the Svelte compiler stay wired in.
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. 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").
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. 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).
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
- // explicit page order listed pages first, unlisted .svelte appended alphabetically
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. Standard HTML works as-is.
191
-
192
- ### Page configuration
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'`).
193
165
 
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. A11y: `role="note"` with type-appropriate `aria-label`. Children become the body.
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 image, renders as `<figure>`/`<figcaption>`.
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. A11y: `aria-expanded`, `aria-controls`, `role="region"`, Enter/Space.
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. A11y: `role="region"`, `aria-roledescription="carousel"`, arrow keys, swipe.
232
+ Slide viewer; wrap each slide's content in `<CarouselSlide>`.
279
233
 
280
234
  ```svelte
281
235
  <Carousel>
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>
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. A11y: `role="dialog"`, `aria-modal`, focus trap, Escape to close.
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. Lazy-loads on scroll.
269
+ `Video` is a YouTube/Vimeo iframe (auto-detected, responsive 16:9) or native `<video>` for direct files; `Audio` is a native player. Both lazy-load and share these props:
316
270
 
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) |
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`; an embed needs `transcript` (embeds can't carry `<track>` files). Each `tracks` entry is `{ src, kind?: 'captions' | 'subtitles', srclang?, label? }`.
278
+ Captions rule (WCAG 1.2): native video needs `tracks` or `transcript`, an embed needs `transcript` (embeds can't carry `<track>`); the validator warns when `<Audio>` has no `transcript`.
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. See [Data contract](#data-contract).
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. Works identically inside `<Quiz>` and standalone.
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
- #### MultipleChoice
360
+ Every type also accepts `weight` (page-level rollup, default 1). Syntax is shown in [Setup](#setup); the complex shapes get an example below.
442
361
 
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) |
362
+ **MultipleChoice** `question` `string`, `options` `string[]`, `correct` `number` (0-based index). Optional: `correctFeedback` / `incorrectFeedback` `string`, `optionFeedback` `string[]`.
452
363
 
453
- ```svelte
454
- <MultipleChoice
455
- question="What is the capital of France?"
456
- options={['London', 'Berlin', 'Paris', 'Madrid']}
457
- correct={2}
458
- />
459
- ```
460
-
461
- #### FillInTheBlank
364
+ **FillInTheBlank** — `question` `string`, `answers` `string[]` (distinct spellings only), `caseSensitive` `boolean` (default `false`, handles case variants).
462
365
 
463
- | Prop | Type | Default | Description |
464
- | --------------- | ---------- | ------- | ------------------------ |
465
- | `question` | `string` | | Prompt |
466
- | `answers` | `string[]` | | Acceptable answers |
467
- | `caseSensitive` | `boolean` | `false` | Comparison casing |
468
- | `weight` | `number` | `1` | Page-level rollup weight |
469
-
470
- `answers` only needs distinct spellings; `caseSensitive: false` handles case variants.
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
- #### Sorting
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', 'Robin', 'Trout']}
383
+ items={['Dog', 'Eagle', 'Salmon', 'Cat']}
516
384
  targets={['Mammals', 'Birds', 'Fish']}
517
- correct={[0, 1, 2, 0, 1, 2]}
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 and render their own Check/Retry.
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 (e.g. reading the final page, or a "click to acknowledge" button) rather than a quiz score or page-visit ratio.
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):
546
398
 
547
- Both triggers below are always active under manual mode. First-to-fire wins; subsequent calls are idempotent.
548
-
549
- ### Trigger A: page frontmatter
550
-
551
- Declare `completesOn: "view"` (the only v1 value) on any page. Completion fires the moment that page renders.
399
+ - **Page frontmatter** `completesOn: "view"` (only v1 value) in a page's `pageConfig`; fires when that page renders.
400
+ - **Runtime hook** — `useCompletion().markComplete()`, composed with any event (modal close, video-ended, timer). Outside manual mode it's a no-op with a one-shot dev warning, so it's safe in shared components.
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,35 @@ 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
- When omitted, the dev runtime warns once after 60s if completion hasn't fired.
422
+ **Build-time check:** `completion: { mode: "manual", trigger: "page" }` fails the build when no page declares `completesOn: "view"`. Omitted, the dev runtime warns once after 60s if completion hasn't fired.
592
423
 
593
424
  ### Success status
594
425
 
595
- By default `successStatus` stays `"unknown"` under manual. For completion **and** an automatic pass:
596
-
597
- ```js
598
- completion: { mode: "manual", requireSuccessStatus: "passed" } // or "failed"
599
- ```
600
-
601
- | Adapter | `markComplete()` with no `requireSuccessStatus` |
602
- | -------------- | ----------------------------------------------------------------------- |
603
- | SCORM 1.2 | `cmi.core.lesson_status = "completed"` |
604
- | SCORM 2004 4th | `cmi.completion_status = "completed"`, `cmi.success_status = "unknown"` |
605
- | cmi5 | **Completed** statement (no Passed / Failed) |
606
- | web | `localStorage` only |
426
+ By default `successStatus` stays `"unknown"`. Set `requireSuccessStatus: "passed"` (or `"failed"`) for an automatic pass alongside completion:
607
427
 
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
+ | web | `localStorage` only | `localStorage` only |
609
434
 
610
- ### Quizzes under manual mode
435
+ ### Rules and non-goals
611
436
 
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.
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.
437
+ - A graded quiz reports its score but does **not** drive completion/success under manual — `markComplete()`/`completesOn` does (the build warns; set `graded: false` to silence).
438
+ - Combining manual + quiz/percentage rules, or per-learner conditional completion → use `useCompletion()` in a custom `$effect`/component, not config.
619
439
 
620
440
  ---
621
441
 
622
442
  ## Assets
623
443
 
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.
444
+ Drop files into `assets/`. Reference with `$assets/` in built-in component props (`<Image src="$assets/photo.png" alt="…" />`); in CSS use a relative path (`url('../assets/bg.png')`). External URLs work too. At build the plugin copies `assets/` → `dist/assets/`, so paths resolve the same in dev and the bundle.
640
445
 
641
446
  ### `$assets/` in custom components
642
447
 
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.
644
-
645
- Pick by use case:
448
+ `$assets/` is **only** rewritten in two places: ES `import` statements (Vite alias) and the `src` prop of built-in `Image`/`Audio`/`Video`. **Raw HTML attributes are NOT rewritten** — `<img src="$assets/foo.svg">`, `new Audio('$assets/...')`, and JS-built CSS `url()` strings all 404 silently. Pick by use case:
646
449
 
647
450
  **One-off — ES import (preferred).** Build-time bundling, hashing, fails the build if missing:
648
451
 
@@ -665,19 +468,13 @@ const signs = import.meta.glob('$assets/signs/*.svg', {
665
468
  const url = signs[`/assets/signs/${filename}`]; // look up by full key
666
469
  ```
667
470
 
668
- **Pure runtime string (last resort).** No build-time guarantees; use only when the filename comes from server data:
669
-
670
- ```js
671
- const src = `./assets/signs/${filename}`;
672
- ```
471
+ **Pure runtime string (last resort).** No build-time guarantees; only when the filename comes from server data: `` const src = `./assets/signs/${filename}` ``.
673
472
 
674
473
  ---
675
474
 
676
475
  ## Styling
677
476
 
678
- Add `.css` files to `styles/`. They load after framework styles and override them.
679
-
680
- Override these custom properties to theme globally:
477
+ Add `.css` files to `styles/`; they load after framework styles and override them. Theme globally by overriding these custom properties:
681
478
 
682
479
  | Property | Default |
683
480
  | ---------------------------------------------- | ------------------------------------- |
@@ -733,8 +530,7 @@ export default {
733
530
  completion: {
734
531
  mode: 'percentage', // "percentage" | "quiz" | "manual"
735
532
  percentageThreshold: 100, // 0–100 (percentage mode)
736
- // trigger: "page", // (manual only) opt into build-time check
737
- // requireSuccessStatus: "passed", // (manual only) "passed" | "failed"
533
+ // (manual only) trigger: "page", requireSuccessStatus: "passed" | "failed"
738
534
  },
739
535
 
740
536
  scoring: {
@@ -746,9 +542,9 @@ export default {
746
542
  },
747
543
 
748
544
  a11y: {
749
- level: 'warn', // "warn" (default) | "error" — "error" makes promotable a11y rules block the build
750
- standard: 'wcag2aa', // "wcag2a" | "wcag2aa" (default) | "wcag21aa" — axe ruleset
751
- ignore: [], // rule IDs to suppress, e.g. ["tessera/heading-order", "color-contrast"]
545
+ level: 'warn', // "warn" (default) | "error"
546
+ standard: 'wcag2aa', // "wcag2a" | "wcag2aa" (default) | "wcag21aa"
547
+ ignore: [], // rule IDs to suppress, e.g. ["tessera/heading-order"]
752
548
  },
753
549
  };
754
550
  ```
@@ -768,47 +564,26 @@ export default {
768
564
 
769
565
  ### Minimum config
770
566
 
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:
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
- ```
567
+ Every field except `title` has a default, so `export default { title: "My Course" }` is complete: free nav, full-percentage completion, web export, `<html lang="en">`, `passingScore: 70`.
783
568
 
784
569
  ### Custom access rules
785
570
 
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.
571
+ For anything beyond the two presets (prereqs, instructor approval, time gating), supply `navigation.canAccess` (with `mode`). It runs synchronously on every navigation evaluation — keep it cheap. Here, gate `lesson-5` on a prior quiz score:
787
572
 
788
573
  ```js
789
574
  import { sequentialAccess } from 'tessera-learn';
790
575
 
791
- export default {
792
- navigation: {
793
- mode: 'sequential',
794
- canAccess: (ctx) => {
795
- if (!sequentialAccess(ctx)) return false;
796
- if (ctx.page.slug === 'lesson-5') {
797
- const i = ctx.manifest.pages.findIndex(
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
- },
576
+ canAccess: (ctx) => {
577
+ if (!sequentialAccess(ctx)) return false;
578
+ if (ctx.page.slug !== 'lesson-5') return true;
579
+ const i = ctx.manifest.pages.findIndex((p) => p.slug === 'lesson-2-quiz');
580
+ return (
581
+ (ctx.progress.quizScores.get(i) ?? 0) >= ctx.config.scoring.passingScore
582
+ );
808
583
  };
809
584
  ```
810
585
 
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.
586
+ `AccessContext` (`ctx`) exposes `pageIndex`, `page`, `manifest`, `progress`, `config`. Presets `freeAccess` / `sequentialAccess` are re-exported for composition; `resolveAccess(config)` returns the predicate the runtime would use (custom `canAccess` if set, else the matching preset) — use it to wrap rather than replace.
812
587
 
813
588
  ### Build output
814
589
 
@@ -821,7 +596,7 @@ export default {
821
596
  | `scorm2004` | SCORM 2004 4th Edition package | `dist/<course>-scorm2004.zip` |
822
597
  | `cmi5` | cmi5 package (AU + manifest) | `dist/<course>-cmi5.zip` |
823
598
 
824
- Upload the LMS zips via your LMS's import flow. Drop `dist/` (web) on Netlify, GitHub Pages, S3, or any static host.
599
+ Upload the LMS zips via your LMS's import flow; drop `dist/` (web) on any static host.
825
600
 
826
601
  ### Validation
827
602
 
@@ -835,15 +610,7 @@ Two passes plus components that are accessible by construction.
835
610
 
836
611
  **Static checks** run inside `validate` / `dev` / `export` — no setup. They cover: `<Image>` alt-or-`decorative`; `<Video>`/`<Audio>` `title` + captions/transcript; empty question labels; skipped heading levels; `branding.primaryColor` contrast against white; well-formed `language`; and the Svelte compiler's `a11y_*` warnings. Each diagnostic carries a rule ID (`[tessera/image-alt]`, `[a11y_missing_attribute]`) — that ID is what `a11y.ignore` and `a11y.level` match.
837
612
 
838
- **Runtime audit** (`tessera a11y`) is the opt-in deep pass. Run it directly or via `pnpm check <course>`:
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`.
613
+ **Runtime audit** (`tessera a11y`, or via `pnpm check`) is the opt-in deep pass: builds the course, renders **every** page headless (incl. quiz-gated), runs [axe-core](https://github.com/dequelabs/axe-core), writes `a11y-report.json` (git-ignored), exits non-zero at/above the impact threshold (default `serious`; `--threshold minor` is stricter). It catches what static can't — computed ARIA, focus order, rendered contrast — and uses the web adapter, so it works regardless of `export.standard`. First run auto-installs Chromium.
847
614
 
848
615
  Ruleset/severity come from the `a11y` block (`standard`, `ignore`). Hard errors (missing `alt`, missing media `title`) always block; everything else is a warning unless `a11y.level: "error"`.
849
616
 
@@ -868,7 +635,7 @@ import type { Interaction } from 'tessera-learn';
868
635
 
869
636
  ### The `Question` model
870
637
 
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`.
638
+ `useQuiz()` and `useQuestion()` share the same per-question object: a shell iterates `quiz.questions`, a widget gets its `Question` from `useQuestion()`. No indexes, no `getContext`.
872
639
 
873
640
  ```ts
874
641
  interface Question {
@@ -889,7 +656,7 @@ Gate input on `q.locked`; branch on `q.isLockedCorrect` only to render the "alre
889
656
 
890
657
  `Interaction` uses SCORM 2004 vocabulary: `choice`, `true-false`, `fill-in`, `long-fill-in`, `matching`, `sequencing`, `numeric`, `likert`, `performance`, `other`. Each is `{ type, response, correct? }`. Omit `correct` to skip auto-judging (`useQuestion` reports `null` correctness; your widget renders its own UI).
891
658
 
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.
659
+ For `choice` / `sequencing` / `matching`, name responses with readable ids and pass the full option list via `options` (or `optionPairs` for matching). The encoder adapts per export: cmi5/SCORM 2004 keep the names, SCORM 1.2 maps each to its index in `options` (omit `options` and SCORM 1.2 slugs the literal identifier).
893
660
 
894
661
  ```ts
895
662
  response: () => ({
@@ -899,6 +666,14 @@ response: () => ({
899
666
  options: ['stop', 'yield', 'speed-limit', 'merge'],
900
667
  });
901
668
  // SCORM 1.2 → "2" SCORM 2004 → "speed-limit" cmi5 → "speed-limit"
669
+
670
+ // sequencing: response/correct are ordered id lists; options carries every id
671
+ response: () => ({
672
+ type: 'sequencing',
673
+ response: order, // e.g. ['mercury', 'venus', 'earth']
674
+ correct: ['mercury', 'venus', 'earth'],
675
+ options: ['venus', 'earth', 'mercury'],
676
+ });
902
677
  ```
903
678
 
904
679
  ### `useQuestion`
@@ -928,29 +703,7 @@ function useQuestion(opts: {
928
703
  };
929
704
  ```
930
705
 
931
- ```svelte
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
- ```
706
+ See [Recipe 2b](#recipe-2b-custom-question-widget-for-a-custom-quiz-shell) for a full widget and [Recipe 3](#recipe-3-graded-standalone-question) for a graded standalone.
954
707
 
955
708
  ### `useQuiz`
956
709
 
@@ -992,6 +745,8 @@ function useNavigation(): {
992
745
  };
993
746
  ```
994
747
 
748
+ Each `ManifestPage` exposes `slug`, `title`, and `index`.
749
+
995
750
  ### `useProgress`
996
751
 
997
752
  ```ts
@@ -1028,15 +783,7 @@ function usePersistence<T>(key: string): {
1028
783
  };
1029
784
  ```
1030
785
 
1031
- ```svelte
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
- ```
786
+ Usage in [Recipe 1](#recipe-1-custom-draw-a-line-question) (persists partial progress).
1040
787
 
1041
788
  ### `isCorrect(interaction)`
1042
789
 
@@ -1062,11 +809,11 @@ xapi?.sendStatement({
1062
809
  });
1063
810
  ```
1064
811
 
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`.
812
+ `useXAPI()` is callable anywhere (setup, handlers, async, `.ts` modules). It returns `null` when no LRS is configured or before adapter init resolves — **null-check and degrade gracefully**. The publisher fills in `actor`, `timestamp`, `id`, `context.contextActivities.grouping`, and (cmi5) `context.registration` + `sessionid`; you supply `verb`, optionally `object` (defaults to the activity), `result`, `context`, `attachments`.
1066
813
 
1067
814
  ### Configure the destination
1068
815
 
1069
- `config.xapi` is one destination or an array. Always declared explicitly — no implicit default.
816
+ `config.xapi` is one destination or an array, always explicit (no implicit default):
1070
817
 
1071
818
  ```js
1072
819
  xapi: {
@@ -1123,7 +870,7 @@ OAuth at the publisher level, statement signing/attachment helpers, offline/Inde
1123
870
 
1124
871
  ## LMS behaviour
1125
872
 
1126
- The runtime translates author intent into adapter calls automatically; you don't write any of it. The author-relevant differences:
873
+ The runtime translates author intent into adapter calls automatically. The author-relevant differences:
1127
874
 
1128
875
  | Concern | SCORM 1.2 | SCORM 2004 4th | cmi5 |
1129
876
  | -------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------- | ------------------------------------ |
@@ -1135,8 +882,8 @@ The runtime translates author intent into adapter calls automatically; you don't
1135
882
  Author-facing consequences:
1136
883
 
1137
884
  - **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`.
885
+ - **SCORM 1.2 shows `incomplete` until a graded quiz produces a result** (no "unknown"); pass/fail uses `scoring.passingScore`, not the LMS's mastery field.
886
+ - **SCORM 2004 / cmi5 honor an LMS-supplied mastery score** at launch, overriding `scoring.passingScore` read it via `useQuiz().passingScore`.
1140
887
  - A failed `adapter.init()` renders a visible "This course can't run here" panel — never a silent degradation.
1141
888
 
1142
889
  ### Local testing
@@ -1154,7 +901,7 @@ Inspect the LMS API call log to confirm `lesson_status` / `completion_status` /
1154
901
 
1155
902
  ## Custom Layouts
1156
903
 
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.
904
+ Drop `layout.svelte` at the project root to replace the default chrome. The contract: it receives a single `page` snippet prop and renders it where the active page goes; use hooks for everything else.
1158
905
 
1159
906
  ```svelte
1160
907
  <!-- layout.svelte -->
@@ -1225,14 +972,7 @@ Emits a `matching` interaction (scored like `<Matching>`); persists partial prog
1225
972
  }
1226
973
  </script>
1227
974
 
1228
- <svg
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>
975
+ <!-- line-drawing UI calls connect(l, r) on drop -->
1236
976
 
1237
977
  {#if q.mode === 'standalone'}
1238
978
  <button onclick={() => q.submit()} disabled={q.submitted}>Check</button>
@@ -1242,94 +982,7 @@ Emits a `matching` interaction (scored like `<Matching>`); persists partial prog
1242
982
  {/if}
1243
983
  ```
1244
984
 
1245
- ### Recipe 2: Custom topbar layout
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`
985
+ ### Recipe 2: Custom quiz shell via `quiz.svelte`
1333
986
 
1334
987
  Drop `quiz.svelte` at the project root. Use only the public `useQuiz()` API; no imports from `tessera-learn/runtime/*`.
1335
988
 
@@ -1373,7 +1026,7 @@ Drop `quiz.svelte` at the project root. Use only the public `useQuiz()` API; no
1373
1026
 
1374
1027
  Always submit through `useQuiz().submit()`.
1375
1028
 
1376
- ### Recipe 4b: Custom question widget for a custom quiz shell
1029
+ ### Recipe 2b: Custom question widget for a custom quiz shell
1377
1030
 
1378
1031
  The widget calls `useQuestion()`, registers a render snippet with `setRender`, pushes answers up with `setAnswer`, calls `commit()` when final, and reads `locked`/`feedbackVisible`/`answer`.
1379
1032
 
@@ -1441,53 +1094,23 @@ The widget calls `useQuestion()`, registers a render snippet with `setRender`, p
1441
1094
 
1442
1095
  Feedback timing: `feedbackMode: 'immediate'` → shell calls `quiz.revealFeedback(q)`, flipping `feedbackVisible` (and `locked`). `'review'` → after `submit()` + `startReview()`. `'never'` → `feedbackVisible` stays false but `locked` still flips on submit.
1443
1096
 
1444
- ### Recipe 5: Graded standalone question
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('');
1097
+ ### Recipe 3: Graded standalone question
1458
1098
 
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>
1099
+ A standalone question (no `<Quiz>`) counts toward course success when built with `graded: true` + a `score()` returning 0–100; omit `correct` to accept any answer. Course success rolls up across all graded items, quizzes and standalones alike.
1473
1100
 
1474
- <h1>Why does this matter to you?</h1>
1475
- <p>At least 50 characters required to pass.</p>
1476
-
1477
- <textarea bind:value={answer} rows="6" disabled={q.submitted}></textarea>
1478
- <button
1479
- onclick={() => q.submit()}
1480
- disabled={q.submitted || answer.trim().length < 50}
1481
- >
1482
- Submit
1483
- </button>
1484
-
1485
- {#if q.submitted}<p>Thanks. Your reflection has been recorded.</p>{/if}
1101
+ ```js
1102
+ const q = useQuestion({
1103
+ id: 'why-it-matters',
1104
+ graded: true,
1105
+ response: () => ({ type: 'long-fill-in', response: answer }),
1106
+ score: () => (answer.trim().length >= 50 ? 100 : 0),
1107
+ reset: () => {
1108
+ answer = '';
1109
+ },
1110
+ });
1486
1111
  ```
1487
1112
 
1488
- Course success rolls up across all graded items: quizzes and standalones alike.
1489
-
1490
- ### Recipe 6: Chunked-reveal page with `markChunk`
1113
+ ### Recipe 4: Chunked-reveal page with `markChunk`
1491
1114
 
1492
1115
  Reveals sections one at a time. `markChunk(pageIndex, chunkIndex)` records the highest revealed chunk so the page resumes mid-scroll on reload.
1493
1116
 
@@ -1525,27 +1148,6 @@ Reveals sections one at a time. `markChunk(pageIndex, chunkIndex)` records the h
1525
1148
  {/if}
1526
1149
  ```
1527
1150
 
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
1151
  ---
1550
1152
 
1551
1153
  ## Constraints