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