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