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 +117 -515
- package/dist/{audit-B9VHgVjk.js → audit--fSWIOgK.js} +10 -13
- package/dist/{audit-B9VHgVjk.js.map → audit--fSWIOgK.js.map} +1 -1
- package/dist/{build-commands-D127jw0J.js → build-commands-Qyrlsp3n.js} +2 -2
- package/dist/{build-commands-D127jw0J.js.map → build-commands-Qyrlsp3n.js.map} +1 -1
- package/dist/{inline-config-eHjv9XuA.js → inline-config-DqAKsCNl.js} +2 -2
- package/dist/{inline-config-eHjv9XuA.js.map → inline-config-DqAKsCNl.js.map} +1 -1
- package/dist/plugin/cli.d.ts.map +1 -1
- package/dist/plugin/cli.js +9 -12
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +0 -2
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +2 -2
- package/dist/{plugin--8H9xQIl.js → plugin-B-aiL9-V.js} +2 -2
- package/dist/{plugin--8H9xQIl.js.map → plugin-B-aiL9-V.js.map} +1 -1
- package/package.json +11 -8
- package/src/plugin/a11y/audit.ts +8 -13
- package/src/plugin/a11y-cli.ts +1 -4
- package/src/plugin/cli.ts +2 -3
- package/src/plugin/validate-cli.ts +10 -4
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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"`
|
|
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
|
-
|
|
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
|
-
###
|
|
435
|
+
### Rules and non-goals
|
|
611
436
|
|
|
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.
|
|
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
|
|
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;
|
|
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
|
|
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",
|
|
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"
|
|
750
|
-
standard: 'wcag2aa', // "wcag2a" | "wcag2aa" (default) | "wcag21aa"
|
|
751
|
-
ignore: [], // rule IDs to suppress, e.g. ["tessera/heading-order"
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
},
|
|
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`
|
|
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
|
|
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.
|
|
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()`
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
1139
|
-
- **SCORM 2004 / cmi5 honor an LMS-supplied mastery score** at launch, overriding `scoring.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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
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
|
-
|
|
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
|