tessera-learn 0.0.6 → 0.0.8
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/dist/plugin/cli.d.ts +1 -0
- package/dist/plugin/cli.js +18 -0
- package/dist/plugin/cli.js.map +1 -0
- package/dist/plugin/index.js +2 -727
- package/dist/plugin/index.js.map +1 -1
- package/dist/validation-B4UhCY5y.js +911 -0
- package/dist/validation-B4UhCY5y.js.map +1 -0
- package/package.json +4 -2
- package/src/plugin/cli.ts +30 -0
- package/src/plugin/validation.ts +336 -62
- package/src/runtime/adapters/index.ts +1 -1
- package/src/runtime/hooks.svelte.ts +22 -1
- package/AGENTS.md +0 -1376
package/AGENTS.md
DELETED
|
@@ -1,1376 +0,0 @@
|
|
|
1
|
-
# AGENTS.md: Tessera Course Authoring Guide
|
|
2
|
-
|
|
3
|
-
Tessera is an **LMS tracking runtime** for interactive learning content. It handles SCORM 1.2 / SCORM 2004 / cmi5 / xAPI statements, progress state, completion and success rollup, persistence, and navigation gating, and gets out of the way for the presentation layer.
|
|
4
|
-
|
|
5
|
-
**Lock the data contract. Free the presentation.** Build a course with built-in components, your own (via the hooks), or any mix. This file is the canonical reference for any agent or human author working in a Tessera project. Read it before generating or editing course code.
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## Running the project
|
|
10
|
-
|
|
11
|
-
From the project root:
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
npm install # first time only
|
|
15
|
-
npm run preview # dev server at http://localhost:5173 (Ctrl+C to stop)
|
|
16
|
-
npm run export # build + package for the LMS standard configured in course.config.js
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
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`.
|
|
20
|
-
|
|
21
|
-
---
|
|
22
|
-
|
|
23
|
-
## Project Structure
|
|
24
|
-
|
|
25
|
-
The framework imposes the **minimum** structure it needs to discover content. Everything else is convention you can opt into.
|
|
26
|
-
|
|
27
|
-
### Required
|
|
28
|
-
|
|
29
|
-
```
|
|
30
|
-
my-course/
|
|
31
|
-
├── course.config.js # Course configuration
|
|
32
|
-
├── vite.config.js # Vite config (do not modify)
|
|
33
|
-
├── package.json
|
|
34
|
-
└── pages/ # Course content (at least one section dir with .svelte files)
|
|
35
|
-
└── intro/
|
|
36
|
-
└── welcome.svelte
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
That's it. `pages/` exists, contains one or more **section directories**, each containing one or more `.svelte` files (directly or inside lesson subdirectories). The runtime works with that alone.
|
|
40
|
-
|
|
41
|
-
### Optional
|
|
42
|
-
|
|
43
|
-
```
|
|
44
|
-
my-course/
|
|
45
|
-
├── layout.svelte # Custom chrome (replaces default sidebar/topbar)
|
|
46
|
-
├── quiz.svelte # Custom quiz shell (replaces built-in <Quiz>)
|
|
47
|
-
├── assets/ # Images, audio, video files (referenced via $assets/)
|
|
48
|
-
├── styles/ # Custom CSS overrides
|
|
49
|
-
├── AGENTS.md # This file (written by the scaffolder)
|
|
50
|
-
└── pages/
|
|
51
|
-
└── 01-intro/ # Numeric prefix → controls order
|
|
52
|
-
├── _meta.js # Override section title; control page order
|
|
53
|
-
├── welcome.svelte # Page directly in the section ("flat" shape)
|
|
54
|
-
└── 01-getting-started/ # Lesson subdirectory ("nested" shape)
|
|
55
|
-
├── _meta.js
|
|
56
|
-
└── overview.svelte
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
### Hierarchy and ordering
|
|
60
|
-
|
|
61
|
-
The manifest is always **section → lesson → page**. Files directly in a section folder are flattened into one implicit lesson with the section's title; lesson subdirectories nest as expected. Both shapes can coexist.
|
|
62
|
-
|
|
63
|
-
Sorting is alphabetical by directory / filename. Numeric prefixes on directories (`01-`, `02-`, …) give explicit ordering without renaming the files inside, and are stripped from slugs and titles (`01-getting-started/` → slug `getting-started`, title "Getting Started"). Use `_meta.js` to control page order within a lesson rather than prefixing page filenames.
|
|
64
|
-
|
|
65
|
-
### `_meta.js` files
|
|
66
|
-
|
|
67
|
-
**Optional everywhere.** When absent, titles fall back to the title-cased slug.
|
|
68
|
-
|
|
69
|
-
```js
|
|
70
|
-
// section or lesson _meta.js: title override
|
|
71
|
-
export default { title: "Getting Started" };
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
```js
|
|
75
|
-
// lesson _meta.js: explicit page order
|
|
76
|
-
export default {
|
|
77
|
-
title: "Welcome",
|
|
78
|
-
pages: ["welcome", "objectives"],
|
|
79
|
-
};
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
Pages listed in `pages` come first in listed order; any unlisted `.svelte` files are appended alphabetically.
|
|
83
|
-
|
|
84
|
-
---
|
|
85
|
-
|
|
86
|
-
## Authoring Surfaces
|
|
87
|
-
|
|
88
|
-
There are five:
|
|
89
|
-
|
|
90
|
-
1. **Built-in components**: `Callout`, `Image`, `MultipleChoice`, `FillInTheBlank`, `Matching`, `Sorting`, etc., from `tessera-learn`. Use, compose, or skip.
|
|
91
|
-
2. **Hooks**: `useQuestion`, `useQuiz`, `useNavigation`, `useProgress`, `useCompletion`, `usePersistence`. The stable contract between custom widgets and the runtime. Anything the built-ins do, you can do.
|
|
92
|
-
3. **Custom layout**: drop `layout.svelte` at the project root to replace the default chrome.
|
|
93
|
-
4. **Custom quiz shell**: drop `quiz.svelte` at the project root to replace the built-in quiz UI for every page that has `pageConfig.quiz`. Authors call `useQuiz()` for state and dispatch; question widgets continue to register through `useQuestion`.
|
|
94
|
-
5. **Custom xAPI**: `useXAPI()` returns a publisher for emitting your own xAPI verbs to one or more LRSes. See [Custom xAPI statements](#custom-xapi-statements).
|
|
95
|
-
|
|
96
|
-
The built-ins are reference implementations of the hooks. A custom widget that calls `useQuestion` and emits an `Interaction` is treated identically to `<MultipleChoice>`, with the same scoring, LMS reporting, and persistence.
|
|
97
|
-
|
|
98
|
-
---
|
|
99
|
-
|
|
100
|
-
## Creating Pages
|
|
101
|
-
|
|
102
|
-
Each page is a `.svelte` file inside a lesson folder.
|
|
103
|
-
|
|
104
|
-
### Basic page
|
|
105
|
-
|
|
106
|
-
```svelte
|
|
107
|
-
<h1>Welcome</h1>
|
|
108
|
-
<p>Standard HTML works as-is.</p>
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
### Page configuration
|
|
112
|
-
|
|
113
|
-
`pageConfig` sets the page title and configures quizzes. It must be a **static object literal** in a module script block. No variables, function calls, or computed values.
|
|
114
|
-
|
|
115
|
-
Both `<script module>` (Svelte 5) and `<script context="module">` (legacy) are accepted by the manifest parser.
|
|
116
|
-
|
|
117
|
-
```svelte
|
|
118
|
-
<script module>
|
|
119
|
-
export const pageConfig = {
|
|
120
|
-
title: "Introduction to the Topic",
|
|
121
|
-
};
|
|
122
|
-
</script>
|
|
123
|
-
|
|
124
|
-
<h1>Introduction to the Topic</h1>
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
If `pageConfig.title` is omitted, the title is derived from the filename: `my-page.svelte` → "My Page".
|
|
128
|
-
|
|
129
|
-
### Importing components
|
|
130
|
-
|
|
131
|
-
```svelte
|
|
132
|
-
<script>
|
|
133
|
-
import { Callout, Image } from 'tessera-learn';
|
|
134
|
-
</script>
|
|
135
|
-
|
|
136
|
-
<Callout type="info">
|
|
137
|
-
<p>Helpful information.</p>
|
|
138
|
-
</Callout>
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
---
|
|
142
|
-
|
|
143
|
-
## Component Reference
|
|
144
|
-
|
|
145
|
-
All components import from `tessera-learn`. Nothing is loaded automatically; import only what you use.
|
|
146
|
-
|
|
147
|
-
### Callout
|
|
148
|
-
|
|
149
|
-
Styled box for highlighting information.
|
|
150
|
-
|
|
151
|
-
| Prop | Type | Default |
|
|
152
|
-
|------|------|---------|
|
|
153
|
-
| `type` | `"info" \| "warning" \| "tip" \| "important"` | `"info"` |
|
|
154
|
-
|
|
155
|
-
Children become the body. A11y: `role="note"` with type-appropriate `aria-label`.
|
|
156
|
-
|
|
157
|
-
```svelte
|
|
158
|
-
<Callout type="warning"><p>Be careful.</p></Callout>
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
### Image
|
|
162
|
-
|
|
163
|
-
Lazy-loaded image with optional caption. Renders as `<figure>`/`<figcaption>`.
|
|
164
|
-
|
|
165
|
-
| Prop | Type | Description |
|
|
166
|
-
|------|------|-------------|
|
|
167
|
-
| `src` | `string` | Image URL. `$assets/` prefix supported |
|
|
168
|
-
| `alt` | `string` | **Required.** Alt text |
|
|
169
|
-
| `caption` | `string` | Optional caption |
|
|
170
|
-
|
|
171
|
-
```svelte
|
|
172
|
-
<Image src="$assets/diagram.png" alt="System architecture diagram" caption="Figure 1" />
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
### Accordion / AccordionItem
|
|
176
|
-
|
|
177
|
-
Expandable panels. Only one open at a time. A11y: `aria-expanded`, `aria-controls`, `role="region"`, keyboard Enter/Space.
|
|
178
|
-
|
|
179
|
-
```svelte
|
|
180
|
-
<Accordion>
|
|
181
|
-
<AccordionItem title="What is Tessera?">
|
|
182
|
-
<p>An LMS tracking runtime for interactive learning content.</p>
|
|
183
|
-
</AccordionItem>
|
|
184
|
-
<AccordionItem title="How do I start?">
|
|
185
|
-
<p>Add pages in <code>pages/</code> and import components.</p>
|
|
186
|
-
</AccordionItem>
|
|
187
|
-
</Accordion>
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
### Carousel / CarouselSlide
|
|
191
|
-
|
|
192
|
-
Slide-based viewer. A11y: `role="region"`, `aria-roledescription="carousel"`, arrow keys, mobile swipe.
|
|
193
|
-
|
|
194
|
-
```svelte
|
|
195
|
-
<Carousel>
|
|
196
|
-
<CarouselSlide><h3>Step 1</h3><p>Plan.</p></CarouselSlide>
|
|
197
|
-
<CarouselSlide><h3>Step 2</h3><p>Build.</p></CarouselSlide>
|
|
198
|
-
<CarouselSlide><h3>Step 3</h3><p>Deploy.</p></CarouselSlide>
|
|
199
|
-
</Carousel>
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
### RevealModal
|
|
203
|
-
|
|
204
|
-
Modal triggered by user interaction. Uses Svelte 5 snippets for `trigger` and `content`. A11y: `role="dialog"`, `aria-modal="true"`, focus trap, Escape to close.
|
|
205
|
-
|
|
206
|
-
| Prop | Type | Description |
|
|
207
|
-
|------|------|-------------|
|
|
208
|
-
| `title` | `string` | Modal label for screen readers |
|
|
209
|
-
| `trigger` | `snippet` | Click target that opens the modal |
|
|
210
|
-
| `content` | `snippet` | Modal body |
|
|
211
|
-
|
|
212
|
-
```svelte
|
|
213
|
-
<RevealModal title="Details">
|
|
214
|
-
{#snippet trigger()}<button>More info</button>{/snippet}
|
|
215
|
-
{#snippet content()}
|
|
216
|
-
<h3>Additional Information</h3>
|
|
217
|
-
<p>Press Escape or click outside to close.</p>
|
|
218
|
-
{/snippet}
|
|
219
|
-
</RevealModal>
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
### Video
|
|
223
|
-
|
|
224
|
-
YouTube/Vimeo iframe (auto-detected, responsive 16:9) or native `<video>` for direct files. Lazy-loads on scroll.
|
|
225
|
-
|
|
226
|
-
| Prop | Type | Description |
|
|
227
|
-
|------|------|-------------|
|
|
228
|
-
| `src` | `string` | Video URL or `$assets/` path |
|
|
229
|
-
| `title` | `string` | Accessible label |
|
|
230
|
-
|
|
231
|
-
```svelte
|
|
232
|
-
<Video src="https://www.youtube.com/watch?v=dQw4w9WgXcQ" title="Intro" />
|
|
233
|
-
<Video src="$assets/demo.mp4" title="Demo" />
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
### Audio
|
|
237
|
-
|
|
238
|
-
Native player. A11y: `aria-label` from title.
|
|
239
|
-
|
|
240
|
-
```svelte
|
|
241
|
-
<Audio src="$assets/lecture-01.mp3" title="Lecture 1" />
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
---
|
|
245
|
-
|
|
246
|
-
## Quizzes
|
|
247
|
-
|
|
248
|
-
A quiz page is a normal page with `pageConfig.quiz` set. The runtime wraps the page in the resolved quiz shell (built-in `<Quiz>` by default; a project-supplied `quiz.svelte` if one exists at the project root). Page authors no longer need their own `<Quiz>` wrapper. Drop question components directly at the page root.
|
|
249
|
-
|
|
250
|
-
### Setup
|
|
251
|
-
|
|
252
|
-
```svelte
|
|
253
|
-
<script module>
|
|
254
|
-
export const pageConfig = {
|
|
255
|
-
title: "Module 1 Quiz",
|
|
256
|
-
quiz: { graded: true, maxAttempts: 3 },
|
|
257
|
-
};
|
|
258
|
-
</script>
|
|
259
|
-
|
|
260
|
-
<script>
|
|
261
|
-
import { MultipleChoice } from 'tessera-learn';
|
|
262
|
-
</script>
|
|
263
|
-
|
|
264
|
-
<MultipleChoice
|
|
265
|
-
question="Which planet is closest to the Sun?"
|
|
266
|
-
options={["Venus", "Mercury", "Earth", "Mars"]}
|
|
267
|
-
correct={1}
|
|
268
|
-
/>
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
### Data contract: what the LMS sees
|
|
272
|
-
|
|
273
|
-
Whatever quiz UI you build, the LMS sees the same `cmi.interactions` it would from the built-in: every question registered through `useQuestion` flows through `useQuiz().submit()` → `tessera-quiz-complete` → the persistence adapter. Bypass the hook and the quiz reports nothing.
|
|
274
|
-
|
|
275
|
-
### `pageConfig.quiz` fields
|
|
276
|
-
|
|
277
|
-
| Field | Type | Default | Description |
|
|
278
|
-
|-------|------|---------|-------------|
|
|
279
|
-
| `graded` | `boolean` | `false` | Whether the score counts toward course success |
|
|
280
|
-
| `gatesProgress` | `boolean` | `false` | Whether passing is required to access the next page |
|
|
281
|
-
| `maxAttempts` | `number` | `Infinity` | Max attempts |
|
|
282
|
-
| `showFeedback` | `boolean` | `true` | Master gate. When `false`, feedback never renders regardless of `feedbackMode`. |
|
|
283
|
-
| `feedbackMode` | `"review" \| "immediate" \| (qIndex, attempt) => boolean` | `"review"` | When feedback renders (only consulted if `showFeedback` is true). `"immediate"` shows feedback after each answer; `"review"` after submit. Predicates have full control. |
|
|
284
|
-
| `retryMode` | `"full" \| "incorrect-only" \| (results) => Set<number>` | `"full"` | Enum sugar or a predicate that returns the set of question indices to lock as "already correct" on retry. |
|
|
285
|
-
| `canSubmit` | `(answered, total) => boolean` | all-answered | Custom Submit gate. Default requires every question to have an answer. |
|
|
286
|
-
| `score` | `(results) => number` | weighted-correct % | Returns 0–100. Default: `Σ(weight × correct) / Σ(weight) × 100`. With every weight = 1 (the default), this matches the unweighted mean. |
|
|
287
|
-
|
|
288
|
-
Enum sugar and predicate forms are equally first-class; pick whichever fits the course. `gatesProgress: true` blocks navigation to the next page until the learner passes. Works in both `free` and `sequential` navigation modes.
|
|
289
|
-
|
|
290
|
-
### Per-question weighting
|
|
291
|
-
|
|
292
|
-
Pass `weight` to `useQuestion` (and through built-in widget props) to change how much a question pulls on the page-level score. Defaults to 1.
|
|
293
|
-
|
|
294
|
-
```svelte
|
|
295
|
-
<MultipleChoice id="q-easy" weight={1} ... />
|
|
296
|
-
<MultipleChoice id="q-hard" weight={3} ... />
|
|
297
|
-
```
|
|
298
|
-
|
|
299
|
-
Weights apply identically inside a `<Quiz>` and to standalone questions on a plain page. Both paths roll up using `Σ(weight × score) / Σ(weight)`. The same widget answered the same way produces the same page score whether it's wrapped in a quiz or scattered across the page. Non-positive weights are treated as 1.
|
|
300
|
-
|
|
301
|
-
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.*`.
|
|
302
|
-
|
|
303
|
-
### Question types
|
|
304
|
-
|
|
305
|
-
#### MultipleChoice
|
|
306
|
-
|
|
307
|
-
| Prop | Type | Description |
|
|
308
|
-
|------|------|-------------|
|
|
309
|
-
| `question` | `string` | Prompt |
|
|
310
|
-
| `options` | `string[]` | Answer options |
|
|
311
|
-
| `correct` | `number` | Index of correct option (0-based) |
|
|
312
|
-
| `correctFeedback` | `string` | Optional |
|
|
313
|
-
| `incorrectFeedback` | `string` | Optional |
|
|
314
|
-
| `optionFeedback` | `string[]` | Optional per-option feedback |
|
|
315
|
-
| `weight` | `number` | Page-level rollup weight (default `1`). See [Per-question weighting](#per-question-weighting). |
|
|
316
|
-
|
|
317
|
-
```svelte
|
|
318
|
-
<MultipleChoice
|
|
319
|
-
question="What is the capital of France?"
|
|
320
|
-
options={["London", "Berlin", "Paris", "Madrid"]}
|
|
321
|
-
correct={2}
|
|
322
|
-
/>
|
|
323
|
-
```
|
|
324
|
-
|
|
325
|
-
#### FillInTheBlank
|
|
326
|
-
|
|
327
|
-
| Prop | Type | Default | Description |
|
|
328
|
-
|------|------|---------|-------------|
|
|
329
|
-
| `question` | `string` | | Prompt |
|
|
330
|
-
| `answers` | `string[]` | | Acceptable answers |
|
|
331
|
-
| `caseSensitive` | `boolean` | `false` | Comparison casing |
|
|
332
|
-
| `weight` | `number` | `1` | Page-level rollup weight |
|
|
333
|
-
|
|
334
|
-
`answers` only needs distinct spellings; `caseSensitive: false` already handles case variants.
|
|
335
|
-
|
|
336
|
-
```svelte
|
|
337
|
-
<FillInTheBlank
|
|
338
|
-
question="What element has the symbol 'O'?"
|
|
339
|
-
answers={["Oxygen"]}
|
|
340
|
-
/>
|
|
341
|
-
```
|
|
342
|
-
|
|
343
|
-
#### Matching
|
|
344
|
-
|
|
345
|
-
| Prop | Type | Description |
|
|
346
|
-
|------|------|-------------|
|
|
347
|
-
| `question` | `string` | Prompt |
|
|
348
|
-
| `pairs` | `{left: string, right: string}[]` | Correct pairs |
|
|
349
|
-
| `weight` | `number` | Page-level rollup weight (default `1`) |
|
|
350
|
-
|
|
351
|
-
The right column is auto-shuffled. Click left then right to match (tap on mobile). Click a matched pair to unmatch. All pairs must be correct.
|
|
352
|
-
|
|
353
|
-
```svelte
|
|
354
|
-
<Matching
|
|
355
|
-
question="Match country to capital:"
|
|
356
|
-
pairs={[
|
|
357
|
-
{ left: "France", right: "Paris" },
|
|
358
|
-
{ left: "Germany", right: "Berlin" },
|
|
359
|
-
{ left: "Japan", right: "Tokyo" },
|
|
360
|
-
]}
|
|
361
|
-
/>
|
|
362
|
-
```
|
|
363
|
-
|
|
364
|
-
#### Sorting
|
|
365
|
-
|
|
366
|
-
Drag-and-drop (or click-to-place) into labelled categories.
|
|
367
|
-
|
|
368
|
-
| Prop | Type | Description |
|
|
369
|
-
|------|------|-------------|
|
|
370
|
-
| `question` | `string` | Prompt |
|
|
371
|
-
| `items` | `string[]` | Items to sort |
|
|
372
|
-
| `targets` | `string[]` | Category labels |
|
|
373
|
-
| `correct` | `number[]` | For each item, the index of its correct target (parallel array) |
|
|
374
|
-
| `weight` | `number` | Page-level rollup weight (default `1`) |
|
|
375
|
-
|
|
376
|
-
```svelte
|
|
377
|
-
<Sorting
|
|
378
|
-
question="Sort each animal:"
|
|
379
|
-
items={["Dog", "Eagle", "Salmon", "Cat", "Robin", "Trout"]}
|
|
380
|
-
targets={["Mammals", "Birds", "Fish"]}
|
|
381
|
-
correct={[0, 1, 2, 0, 1, 2]}
|
|
382
|
-
/>
|
|
383
|
-
```
|
|
384
|
-
|
|
385
|
-
### Standalone questions
|
|
386
|
-
|
|
387
|
-
All four question components also work outside `<Quiz>` for inline practice. Standalone widgets render their own Check / Retry buttons.
|
|
388
|
-
|
|
389
|
-
| Prop | Type | Default | Description |
|
|
390
|
-
|------|------|---------|-------------|
|
|
391
|
-
| `maxRetries` | `number` | `Infinity` | Max retries for standalone widgets |
|
|
392
|
-
| `weight` | `number` | `1` | Per-question weight for page-level rollup |
|
|
393
|
-
|
|
394
|
-
```svelte
|
|
395
|
-
<MultipleChoice
|
|
396
|
-
question="What color is the sky on a clear day?"
|
|
397
|
-
options={["Red", "Blue", "Green"]}
|
|
398
|
-
correct={1}
|
|
399
|
-
maxRetries={2}
|
|
400
|
-
/>
|
|
401
|
-
```
|
|
402
|
-
|
|
403
|
-
Standalone questions are not graded by default. To grade one (e.g., a required reflection that affects course success), build it with the `useQuestion` hook directly. See [Recipe 5](#recipe-5-graded-standalone-question).
|
|
404
|
-
|
|
405
|
-
---
|
|
406
|
-
|
|
407
|
-
## Manual completion
|
|
408
|
-
|
|
409
|
-
`completion.mode: "manual"` is for courses where the author — not a quiz score or a page-visit ratio — owns the moment of completion. Two examples:
|
|
410
|
-
|
|
411
|
-
- A short policy briefing where reading the final page **is** the proof of completion.
|
|
412
|
-
- A compliance "click to acknowledge" button at the end of a module.
|
|
413
|
-
|
|
414
|
-
Under manual mode, **both** triggers below are always active. First-to-fire wins; subsequent calls are idempotent.
|
|
415
|
-
|
|
416
|
-
### Trigger A: page frontmatter
|
|
417
|
-
|
|
418
|
-
Declare `completesOn: "view"` on any page. Completion fires the moment that page renders.
|
|
419
|
-
|
|
420
|
-
```svelte
|
|
421
|
-
<!-- pages/05-summary/finale.svelte -->
|
|
422
|
-
<script module>
|
|
423
|
-
export const pageConfig = {
|
|
424
|
-
title: "You're done",
|
|
425
|
-
completesOn: "view",
|
|
426
|
-
};
|
|
427
|
-
</script>
|
|
428
|
-
|
|
429
|
-
<h1>Thanks for completing the briefing.</h1>
|
|
430
|
-
```
|
|
431
|
-
|
|
432
|
-
`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.
|
|
433
|
-
|
|
434
|
-
### Trigger B: runtime hook
|
|
435
|
-
|
|
436
|
-
```svelte
|
|
437
|
-
<script>
|
|
438
|
-
import { useCompletion } from 'tessera-learn';
|
|
439
|
-
|
|
440
|
-
const { markComplete, completionStatus } = useCompletion();
|
|
441
|
-
</script>
|
|
442
|
-
|
|
443
|
-
<button
|
|
444
|
-
onclick={() => markComplete()}
|
|
445
|
-
disabled={completionStatus === 'complete'}
|
|
446
|
-
>
|
|
447
|
-
I acknowledge
|
|
448
|
-
</button>
|
|
449
|
-
|
|
450
|
-
{#if completionStatus === 'complete'}
|
|
451
|
-
<p>Recorded. You may now close this window.</p>
|
|
452
|
-
{/if}
|
|
453
|
-
```
|
|
454
|
-
|
|
455
|
-
Composes cleanly with custom widgets, modal close handlers, video-ended events, timer expirations, etc. Calling `markComplete()` outside `completion.mode: "manual"` is a no-op with a one-shot dev warning per session — safe to leave in shared components.
|
|
456
|
-
|
|
457
|
-
### `completion.trigger` (build-time check)
|
|
458
|
-
|
|
459
|
-
Optional. Set to `"page"` to fail the build when no page declares `completesOn: "view"`. Useful when the page-view path is load-bearing and a typo should fail the build, not the launch. Both triggers still work either way; the field only adds a static check.
|
|
460
|
-
|
|
461
|
-
```js
|
|
462
|
-
completion: { mode: "manual", trigger: "page" }
|
|
463
|
-
```
|
|
464
|
-
|
|
465
|
-
When omitted, the dev runtime warns once after 60 s if completion has not fired — a safety net that covers both "no `completesOn` page exists" and "the hook is never called" cases.
|
|
466
|
-
|
|
467
|
-
### Success status
|
|
468
|
-
|
|
469
|
-
By default `successStatus` stays `"unknown"` under manual — the LMS sees completion without a pass/fail verdict. If you want completion **and** an automatic pass (typical for "acknowledge" flows):
|
|
470
|
-
|
|
471
|
-
```js
|
|
472
|
-
completion: { mode: "manual", requireSuccessStatus: "passed" } // or "failed"
|
|
473
|
-
```
|
|
474
|
-
|
|
475
|
-
| Adapter | What the LMS sees on `markComplete()` (no `requireSuccessStatus`) |
|
|
476
|
-
| -------------- | ------------------------------------------------------------------ |
|
|
477
|
-
| SCORM 1.2 | `cmi.core.lesson_status = "completed"` |
|
|
478
|
-
| SCORM 2004 4th | `cmi.completion_status = "completed"`, `cmi.success_status = "unknown"` |
|
|
479
|
-
| cmi5 | **Completed** statement (no Passed / Failed) |
|
|
480
|
-
| web | `localStorage` only |
|
|
481
|
-
|
|
482
|
-
With `requireSuccessStatus: "passed"`, SCORM 1.2 writes `lesson_status = "passed"`, SCORM 2004 writes `success_status = "passed"`, and cmi5 emits a **Passed** statement alongside **Completed**.
|
|
483
|
-
|
|
484
|
-
### Quizzes under manual mode
|
|
485
|
-
|
|
486
|
-
A graded quiz under `mode: "manual"` reports its score to the LMS gradebook but does **not** drive completion or success — `markComplete()` / `completesOn` does. The build emits a warning to make this explicit. Set `graded: false` (or remove the quiz) if that's not what you want.
|
|
487
|
-
|
|
488
|
-
### Non-goals
|
|
489
|
-
|
|
490
|
-
- Combining manual + quiz/percentage rules ("complete when X **and** quiz passed"). Use a `useCompletion()` call inside a custom `$effect` if you need conditional logic.
|
|
491
|
-
- Per-learner conditional completion expressed in config — same answer: do it in a component with `useCompletion()`.
|
|
492
|
-
- Marking a course **incomplete** after it has been completed. Completion is monotonic in every spec we target. The runtime ignores re-marks.
|
|
493
|
-
|
|
494
|
-
---
|
|
495
|
-
|
|
496
|
-
## Assets
|
|
497
|
-
|
|
498
|
-
Drop files into `assets/`. Reference them with `$assets/` in component props:
|
|
499
|
-
|
|
500
|
-
```svelte
|
|
501
|
-
<Image src="$assets/photo.png" alt="Photo" />
|
|
502
|
-
<Video src="$assets/demo.mp4" title="Demo" />
|
|
503
|
-
<Audio src="$assets/lecture.mp3" title="Lecture" />
|
|
504
|
-
```
|
|
505
|
-
|
|
506
|
-
In CSS, use a relative path from `styles/`:
|
|
507
|
-
|
|
508
|
-
```css
|
|
509
|
-
.bg { background-image: url('../assets/bg.png'); }
|
|
510
|
-
```
|
|
511
|
-
|
|
512
|
-
External URLs work too: `<Image src="https://example.com/img.jpg" alt="..." />`.
|
|
513
|
-
|
|
514
|
-
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.
|
|
515
|
-
|
|
516
|
-
---
|
|
517
|
-
|
|
518
|
-
## Styling
|
|
519
|
-
|
|
520
|
-
Add `.css` files to `styles/`. They load after framework styles and override them.
|
|
521
|
-
|
|
522
|
-
### CSS custom properties
|
|
523
|
-
|
|
524
|
-
Override these to theme globally:
|
|
525
|
-
|
|
526
|
-
| Property | Default |
|
|
527
|
-
|----------|---------|
|
|
528
|
-
| `--tessera-primary` | `#2563eb` |
|
|
529
|
-
| `--tessera-primary-light` | `#dbeafe` |
|
|
530
|
-
| `--tessera-primary-dark` | `#1e40af` |
|
|
531
|
-
| `--tessera-text` | `#1f2937` |
|
|
532
|
-
| `--tessera-text-light` | `#6b7280` |
|
|
533
|
-
| `--tessera-bg` | `#ffffff` |
|
|
534
|
-
| `--tessera-bg-secondary` | `#f9fafb` |
|
|
535
|
-
| `--tessera-border` | `#e5e7eb` |
|
|
536
|
-
| `--tessera-success` | `#16a34a` |
|
|
537
|
-
| `--tessera-error` | `#dc2626` |
|
|
538
|
-
| `--tessera-warning` | `#d97706` |
|
|
539
|
-
| `--tessera-font-family` | `'Inter', system-ui, sans-serif` |
|
|
540
|
-
| `--tessera-font-size-base` | `1rem` |
|
|
541
|
-
| `--tessera-line-height` | `1.6` |
|
|
542
|
-
| `--tessera-spacing-sm` / `-md` / `-lg` / `-xl` | `0.5rem` / `1rem` / `1.5rem` / `2rem` |
|
|
543
|
-
| `--tessera-sidebar-width` | `280px` |
|
|
544
|
-
| `--tessera-content-max-width` | `800px` |
|
|
545
|
-
|
|
546
|
-
```css
|
|
547
|
-
:root {
|
|
548
|
-
--tessera-primary: #9333ea;
|
|
549
|
-
--tessera-font-family: 'Georgia', serif;
|
|
550
|
-
}
|
|
551
|
-
```
|
|
552
|
-
|
|
553
|
-
`branding.primaryColor` and `branding.fontFamily` in `course.config.js` cover the common overrides without writing CSS.
|
|
554
|
-
|
|
555
|
-
---
|
|
556
|
-
|
|
557
|
-
## `course.config.js`
|
|
558
|
-
|
|
559
|
-
```js
|
|
560
|
-
export default {
|
|
561
|
-
// Metadata
|
|
562
|
-
title: "My Course", // required
|
|
563
|
-
description: "",
|
|
564
|
-
author: "",
|
|
565
|
-
version: "1.0.0",
|
|
566
|
-
|
|
567
|
-
branding: {
|
|
568
|
-
logo: "", // e.g., "$assets/logo.png"
|
|
569
|
-
primaryColor: "#2563eb",
|
|
570
|
-
fontFamily: "Inter, sans-serif",
|
|
571
|
-
},
|
|
572
|
-
|
|
573
|
-
navigation: {
|
|
574
|
-
mode: "free", // "free" or "sequential"
|
|
575
|
-
},
|
|
576
|
-
|
|
577
|
-
completion: {
|
|
578
|
-
mode: "percentage", // "percentage" | "quiz" | "manual"
|
|
579
|
-
percentageThreshold: 100, // 0–100 (percentage mode)
|
|
580
|
-
// trigger: "page", // (manual only) opt into build-time check
|
|
581
|
-
// requireSuccessStatus: "passed", // (manual only) "passed" | "failed"
|
|
582
|
-
},
|
|
583
|
-
|
|
584
|
-
scoring: {
|
|
585
|
-
passingScore: 70, // optional under "manual" (defaults to 0)
|
|
586
|
-
},
|
|
587
|
-
|
|
588
|
-
export: {
|
|
589
|
-
standard: "web", // "web" | "scorm12" | "scorm2004" | "cmi5"
|
|
590
|
-
},
|
|
591
|
-
};
|
|
592
|
-
```
|
|
593
|
-
|
|
594
|
-
- `navigation.mode: "free"` → all pages accessible except those blocked by gating quizzes.
|
|
595
|
-
- `navigation.mode: "sequential"` → pages unlock one at a time as each is completed.
|
|
596
|
-
- `completion.mode: "percentage"` → course completes when `visitedPages / totalPages * 100 >= percentageThreshold`.
|
|
597
|
-
- `completion.mode: "quiz"` → course completes when graded quiz average >= `scoring.passingScore`.
|
|
598
|
-
- `completion.mode: "manual"` → course completes when an author-declared trigger fires: a page declares `pageConfig.completesOn: "view"`, or any component calls `useCompletion().markComplete()`. First-to-fire wins. `scoring.passingScore` is optional (defaults to 0). See [Manual completion](#manual-completion).
|
|
599
|
-
|
|
600
|
-
### Minimum config
|
|
601
|
-
|
|
602
|
-
Every field except `title` has a default. The build merges yours over:
|
|
603
|
-
|
|
604
|
-
```js
|
|
605
|
-
// effective defaults
|
|
606
|
-
{
|
|
607
|
-
title: "Untitled Course",
|
|
608
|
-
navigation: { mode: "free" },
|
|
609
|
-
completion: { mode: "percentage", percentageThreshold: 100 },
|
|
610
|
-
scoring: { passingScore: 70 },
|
|
611
|
-
export: { standard: "web" },
|
|
612
|
-
}
|
|
613
|
-
```
|
|
614
|
-
|
|
615
|
-
So `export default { title: "My Course" }` is a complete config: free navigation, full-percentage completion, web export.
|
|
616
|
-
|
|
617
|
-
### Custom access rules
|
|
618
|
-
|
|
619
|
-
For anything beyond the two presets (prereqs, instructor approval, time gating), supply `navigation.canAccess`. It runs synchronously on every navigation evaluation. Keep it cheap.
|
|
620
|
-
|
|
621
|
-
```js
|
|
622
|
-
import { sequentialAccess } from 'tessera-learn';
|
|
623
|
-
|
|
624
|
-
export default {
|
|
625
|
-
// ...
|
|
626
|
-
navigation: {
|
|
627
|
-
mode: 'sequential',
|
|
628
|
-
canAccess: (ctx) => {
|
|
629
|
-
if (!sequentialAccess(ctx)) return false;
|
|
630
|
-
if (ctx.page.slug === 'lesson-5') {
|
|
631
|
-
const i = ctx.manifest.pages.findIndex(p => p.slug === 'lesson-2-quiz');
|
|
632
|
-
return (ctx.progress.quizScores.get(i) ?? 0) >= ctx.config.scoring.passingScore;
|
|
633
|
-
}
|
|
634
|
-
return true;
|
|
635
|
-
},
|
|
636
|
-
},
|
|
637
|
-
};
|
|
638
|
-
```
|
|
639
|
-
|
|
640
|
-
`AccessContext` exposes `pageIndex`, `page`, `manifest`, `progress`, and `config`. The presets `freeAccess` and `sequentialAccess` are re-exported from `tessera-learn` for composition. `resolveAccess(config)` is also exported. It returns the predicate the runtime would use (custom `canAccess` if set, otherwise the matching preset). Useful when you want to wrap rather than replace.
|
|
641
|
-
|
|
642
|
-
### Build output
|
|
643
|
-
|
|
644
|
-
`npm run export` (which wraps `vite build`) writes:
|
|
645
|
-
|
|
646
|
-
| `export.standard` | What ships | Where |
|
|
647
|
-
|-------------------|------------|-------|
|
|
648
|
-
| `web` | Static site (HTML/CSS/JS + `assets/`) | `dist/` (host on any static file server) |
|
|
649
|
-
| `scorm12` | SCORM 1.2 package | `dist/<course>-scorm12.zip` |
|
|
650
|
-
| `scorm2004` | SCORM 2004 4th Edition package | `dist/<course>-scorm2004.zip` |
|
|
651
|
-
| `cmi5` | cmi5 package (AU + manifest) | `dist/<course>-cmi5.zip` |
|
|
652
|
-
|
|
653
|
-
For LMS exports, upload the zip via your LMS's import flow. For web export, the bundle is a self-contained static site. Drop `dist/` on Netlify, GitHub Pages, S3, or any static host.
|
|
654
|
-
|
|
655
|
-
### Validation
|
|
656
|
-
|
|
657
|
-
The Vite plugin runs project validation on every dev start and build (manifest shape, `pageConfig` parseability, asset references, etc.). Errors abort the build and print as `[tessera error] ...`; warnings print as `[tessera warning] ...` and don't block. The npm scripts in a scaffolded project are `npm run preview` (wraps `vite dev`, local dev server with HMR) and `npm run export` (wraps `vite build`, full validation + bundle + adapter packaging). Names diverge from Vite's defaults because they describe the authoring intent ("preview the course", "export for an LMS") rather than the underlying tool.
|
|
658
|
-
|
|
659
|
-
---
|
|
660
|
-
|
|
661
|
-
## Hooks Reference
|
|
662
|
-
|
|
663
|
-
Six hooks plus one helper make up the stable contract between widgets and the runtime.
|
|
664
|
-
|
|
665
|
-
```js
|
|
666
|
-
import {
|
|
667
|
-
useQuestion,
|
|
668
|
-
useQuiz,
|
|
669
|
-
useNavigation,
|
|
670
|
-
useProgress,
|
|
671
|
-
useCompletion,
|
|
672
|
-
usePersistence,
|
|
673
|
-
isCorrect,
|
|
674
|
-
} from 'tessera-learn';
|
|
675
|
-
import type { Interaction } from 'tessera-learn';
|
|
676
|
-
```
|
|
677
|
-
|
|
678
|
-
Each hook is synchronous and must be called during component setup, inside a Tessera course. Calling them outside the runtime throws.
|
|
679
|
-
|
|
680
|
-
### `useQuestion`
|
|
681
|
-
|
|
682
|
-
Register a question widget so the runtime can submit, score, persist, and report it.
|
|
683
|
-
|
|
684
|
-
- **Inside `<Quiz>`**: the parent Quiz drives submission. The widget renders the prompt + answer UI; nothing else.
|
|
685
|
-
- **Standalone**: the widget owns its own Check/Retry. Set `graded: true` to count toward course success.
|
|
686
|
-
|
|
687
|
-
```ts
|
|
688
|
-
function useQuestion(opts: {
|
|
689
|
-
id: string; // unique on the page; LMS interaction id
|
|
690
|
-
graded?: boolean; // standalone only
|
|
691
|
-
response: () => Interaction; // current learner answer; called on submit
|
|
692
|
-
score?: () => number; // standalone-only override (0–100)
|
|
693
|
-
weight?: number; // page-level rollup weight (default 1)
|
|
694
|
-
maxRetries?: number; // standalone retry cap (default Infinity); ignored inside <Quiz>
|
|
695
|
-
reset?: () => void;
|
|
696
|
-
}): {
|
|
697
|
-
submit(): void;
|
|
698
|
-
reset(): void;
|
|
699
|
-
retry(): void; // standalone-only; no-op once maxRetries hit or inside <Quiz>
|
|
700
|
-
readonly submitted: boolean;
|
|
701
|
-
readonly correct: boolean | null;
|
|
702
|
-
readonly canRetry: boolean;
|
|
703
|
-
readonly retryCount: number;
|
|
704
|
-
readonly mode: 'standalone' | 'quiz';
|
|
705
|
-
readonly quizIndex: number | undefined;
|
|
706
|
-
};
|
|
707
|
-
```
|
|
708
|
-
|
|
709
|
-
`Interaction` follows SCORM 2004 4th Edition vocabulary verbatim: `choice`, `true-false`, `fill-in`, `long-fill-in`, `matching`, `sequencing`, `numeric`, `likert`, `performance`, `other`. Each is `{ type, response, correct? }`. Omit `correct` if the runtime should not auto-judge; `useQuestion` reports a `null` correctness flag and your widget renders its own UI.
|
|
710
|
-
|
|
711
|
-
```svelte
|
|
712
|
-
<script>
|
|
713
|
-
import { useQuestion } from 'tessera-learn';
|
|
714
|
-
|
|
715
|
-
let order = $state(['Mercury', 'Venus', 'Earth', 'Mars']);
|
|
716
|
-
|
|
717
|
-
const q = useQuestion({
|
|
718
|
-
id: 'planet-rank',
|
|
719
|
-
response: () => ({
|
|
720
|
-
type: 'sequencing',
|
|
721
|
-
response: order,
|
|
722
|
-
correct: ['Mercury', 'Venus', 'Earth', 'Mars'],
|
|
723
|
-
}),
|
|
724
|
-
reset: () => { order = ['Mercury', 'Venus', 'Earth', 'Mars']; },
|
|
725
|
-
});
|
|
726
|
-
</script>
|
|
727
|
-
|
|
728
|
-
<!-- drag-to-reorder UI bound to `order` -->
|
|
729
|
-
{#if q.mode === 'standalone'}
|
|
730
|
-
<button onclick={() => q.submit()} disabled={q.submitted}>Check</button>
|
|
731
|
-
{/if}
|
|
732
|
-
```
|
|
733
|
-
|
|
734
|
-
### `useQuiz`
|
|
735
|
-
|
|
736
|
-
Quiz orchestration hook used by both the built-in `<Quiz>` and any project-supplied `quiz.svelte`. A custom shell calls `useQuiz` to drive submission/retry/review; **`submit()` is the only sanctioned dispatcher of `tessera-quiz-complete`**: bypassing it means the quiz reports nothing to the LMS.
|
|
737
|
-
|
|
738
|
-
```ts
|
|
739
|
-
function useQuiz(opts: { element: () => HTMLElement | null }): {
|
|
740
|
-
registerQuestion(api: {
|
|
741
|
-
id: string;
|
|
742
|
-
weight?: number;
|
|
743
|
-
checkAnswer: () => boolean;
|
|
744
|
-
reset?: () => void;
|
|
745
|
-
interaction: () => Interaction;
|
|
746
|
-
}): number;
|
|
747
|
-
setRender(index: number, render: unknown): void;
|
|
748
|
-
setAnswer(index: number, answer: unknown): void;
|
|
749
|
-
submit(): void; // dispatches tessera-quiz-complete; runtime forwards interactions to the adapter
|
|
750
|
-
retry(): void;
|
|
751
|
-
startReview(): void;
|
|
752
|
-
exitReview(): void;
|
|
753
|
-
revealFeedback(index: number): void; // immediate-feedback flow
|
|
754
|
-
getAnswer(index: number): unknown;
|
|
755
|
-
getRender(index: number): unknown;
|
|
756
|
-
feedbackVisible(index: number): boolean;
|
|
757
|
-
isLockedCorrect(index: number): boolean;
|
|
758
|
-
readonly questions: ReadonlyArray<{ id: string; submitted: boolean; correct: boolean | null }>;
|
|
759
|
-
readonly state: 'answering' | 'submitted' | 'reviewing';
|
|
760
|
-
readonly score: number;
|
|
761
|
-
readonly attemptCount: number;
|
|
762
|
-
readonly canSubmit: boolean;
|
|
763
|
-
readonly canRetry: boolean;
|
|
764
|
-
};
|
|
765
|
-
```
|
|
766
|
-
|
|
767
|
-
Throws when called on a page without `pageConfig.quiz`. Three telemetry-only DOM events also fire (`tessera-quiz-question-answered`, `tessera-quiz-before-submit`, `tessera-quiz-retry`); none of them write to the adapter.
|
|
768
|
-
|
|
769
|
-
### `useNavigation`
|
|
770
|
-
|
|
771
|
-
```ts
|
|
772
|
-
function useNavigation(): {
|
|
773
|
-
readonly currentPage: ManifestPage;
|
|
774
|
-
readonly currentPageIndex: number;
|
|
775
|
-
readonly pages: ManifestPage[];
|
|
776
|
-
goTo(slug: string): void;
|
|
777
|
-
goToIndex(index: number): void;
|
|
778
|
-
next(): void;
|
|
779
|
-
prev(): void;
|
|
780
|
-
readonly canGoNext: boolean;
|
|
781
|
-
readonly canGoPrev: boolean;
|
|
782
|
-
canAccess(slug: string): boolean;
|
|
783
|
-
};
|
|
784
|
-
```
|
|
785
|
-
|
|
786
|
-
### `useProgress`
|
|
787
|
-
|
|
788
|
-
```ts
|
|
789
|
-
function useProgress(): {
|
|
790
|
-
readonly visitedPages: Set<number>;
|
|
791
|
-
readonly quizScores: Map<number, number>; // pageIndex → score 0–100
|
|
792
|
-
readonly chunkProgress: Map<number, number>; // pageIndex → highest revealed chunk index
|
|
793
|
-
readonly completionStatus: 'incomplete' | 'complete';
|
|
794
|
-
readonly successStatus: 'unknown' | 'passed' | 'failed';
|
|
795
|
-
markVisited(pageIndex: number): void;
|
|
796
|
-
markChunk(pageIndex: number, chunkIndex: number): void;
|
|
797
|
-
};
|
|
798
|
-
```
|
|
799
|
-
|
|
800
|
-
### `useCompletion`
|
|
801
|
-
|
|
802
|
-
Trigger course completion from any component, and reactively read the current completion status. 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).
|
|
803
|
-
|
|
804
|
-
```ts
|
|
805
|
-
function useCompletion(): {
|
|
806
|
-
/** Idempotent — only the first call per session has an effect. */
|
|
807
|
-
markComplete(): void;
|
|
808
|
-
readonly completionStatus: 'incomplete' | 'complete';
|
|
809
|
-
};
|
|
810
|
-
```
|
|
811
|
-
|
|
812
|
-
### `usePersistence<T>(key)`
|
|
813
|
-
|
|
814
|
-
Per-widget persistent state. Survives reload on every adapter: `localStorage` for web, SCORM `cmi.suspend_data` for SCORM 1.2/2004, xAPI State API for cmi5. Reads sync; writes batched by the adapter. JSON-serializable values only.
|
|
815
|
-
|
|
816
|
-
```ts
|
|
817
|
-
function usePersistence<T>(key: string): {
|
|
818
|
-
get(): T | null;
|
|
819
|
-
set(value: T): void;
|
|
820
|
-
};
|
|
821
|
-
```
|
|
822
|
-
|
|
823
|
-
```svelte
|
|
824
|
-
<script>
|
|
825
|
-
import { usePersistence } from 'tessera-learn';
|
|
826
|
-
|
|
827
|
-
const store = usePersistence('whiteboard');
|
|
828
|
-
let state = $state(store.get() ?? { strokes: [] });
|
|
829
|
-
$effect(() => store.set(state));
|
|
830
|
-
</script>
|
|
831
|
-
```
|
|
832
|
-
|
|
833
|
-
### `isCorrect(interaction)`
|
|
834
|
-
|
|
835
|
-
Pure helper. Returns `true`, `false`, or `null` (when the interaction has no `correct` field).
|
|
836
|
-
|
|
837
|
-
```ts
|
|
838
|
-
function isCorrect(i: Interaction): boolean | null;
|
|
839
|
-
```
|
|
840
|
-
|
|
841
|
-
---
|
|
842
|
-
|
|
843
|
-
## Custom xAPI statements
|
|
844
|
-
|
|
845
|
-
The lifecycle stream (Initialized / Completed / Passed / Failed / Terminated under cmi5; `cmi.*` writes under SCORM) is sent automatically. See [LMS Adapter Reference](#lms-adapter-reference). To emit your own xAPI verbs, use `useXAPI()`:
|
|
846
|
-
|
|
847
|
-
```ts
|
|
848
|
-
import { useXAPI } from 'tessera-learn';
|
|
849
|
-
|
|
850
|
-
const xapi = useXAPI(); // XAPIClient | null
|
|
851
|
-
xapi?.sendStatement({
|
|
852
|
-
verb: { id: 'http://adlnet.gov/expapi/verbs/experienced' },
|
|
853
|
-
object: { id: `${xapi.getActivityId()}#diagram-1` },
|
|
854
|
-
});
|
|
855
|
-
```
|
|
856
|
-
|
|
857
|
-
`useXAPI()` is a plain function (not a Svelte context hook), callable from anywhere: component setup, event handlers, async callbacks, plain `.ts` modules. Returns `null` when no LRS is configured or before adapter init resolves; null-check and degrade gracefully.
|
|
858
|
-
|
|
859
|
-
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`.
|
|
860
|
-
|
|
861
|
-
### Configure the destination: `course.config.js`
|
|
862
|
-
|
|
863
|
-
`config.xapi` is one destination, or an array of them. The destination is always declared explicitly. There is no implicit default.
|
|
864
|
-
|
|
865
|
-
```js
|
|
866
|
-
xapi: {
|
|
867
|
-
endpoint: 'https://lrs.example.com/xapi/',
|
|
868
|
-
auth: () => fetch('/api/lrs-token').then(r => r.text()),
|
|
869
|
-
actor: () => getCurrentUser(), // or a static Agent object
|
|
870
|
-
activityId: 'https://example.com/courses/intro-to-x',
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
// cmi5 only: inherit the LMS launch LRS (endpoint+auth+actor+activityId+registration):
|
|
874
|
-
xapi: { endpoint: 'lms' }
|
|
875
|
-
|
|
876
|
-
// Fan out (at most one 'lms' entry):
|
|
877
|
-
xapi: [
|
|
878
|
-
{ endpoint: 'lms' },
|
|
879
|
-
{ endpoint: 'https://analytics.example.com/xapi/', auth, actor, activityId },
|
|
880
|
-
]
|
|
881
|
-
```
|
|
882
|
-
|
|
883
|
-
Each destination has its own queue, auth resolver, and retry loop. One UUID is minted per `sendStatement` and reused across destinations, so all LRSes see the same statement id (idempotent dedupe works).
|
|
884
|
-
|
|
885
|
-
### Per-mode behaviour
|
|
886
|
-
|
|
887
|
-
| Mode | `xapi` not set | `xapi.endpoint: 'lms'` | `xapi: {endpoint, ...}` (explicit) |
|
|
888
|
-
|------|---------------|------------------------|-----------------------------------|
|
|
889
|
-
| **cmi5** | `useXAPI()` → null | Inherits launch LRS; shares queue with lifecycle stream | Independent publisher; `actor` defaults to launch actor |
|
|
890
|
-
| **scorm12** | `useXAPI()` → null | **Config error** | Independent publisher; `actor` derived from `cmi.core.student_id` |
|
|
891
|
-
| **scorm2004** | `useXAPI()` → null | **Config error** | Independent publisher; `actor` derived from `cmi.learner_id` |
|
|
892
|
-
| **web** | `useXAPI()` → null | **Config error** | Independent publisher; `actor` **required** in config |
|
|
893
|
-
|
|
894
|
-
### Actor resolution
|
|
895
|
-
|
|
896
|
-
Priority order (top wins):
|
|
897
|
-
|
|
898
|
-
1. **Author-supplied `xapi.actor`**: always wins.
|
|
899
|
-
2. **cmi5 launch actor**: under cmi5, the publisher uses the same Agent the LMS handed us at launch.
|
|
900
|
-
3. **SCORM-derived actor**: under scorm12/scorm2004, the publisher synthesizes:
|
|
901
|
-
```ts
|
|
902
|
-
{
|
|
903
|
-
account: {
|
|
904
|
-
homePage: xapi.actorAccountHomePage ?? originOf(xapi.activityId),
|
|
905
|
-
name: <cmi.core.student_id | cmi.learner_id>,
|
|
906
|
-
},
|
|
907
|
-
name: <cmi.core.student_name | cmi.learner_name>,
|
|
908
|
-
objectType: 'Agent',
|
|
909
|
-
}
|
|
910
|
-
```
|
|
911
|
-
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.
|
|
912
|
-
4. **Fallback: error.** Web export with no `actor` fails at config time.
|
|
913
|
-
|
|
914
|
-
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.
|
|
915
|
-
|
|
916
|
-
### Auth
|
|
917
|
-
|
|
918
|
-
v1 supports **Basic auth only**. The publisher prepends `Basic ` to whatever your `auth` value resolves to; pass the credential value, not the full header.
|
|
919
|
-
|
|
920
|
-
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).
|
|
921
|
-
|
|
922
|
-
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.
|
|
923
|
-
|
|
924
|
-
**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.
|
|
925
|
-
|
|
926
|
-
### Retry policy
|
|
927
|
-
|
|
928
|
-
- **Default:** 3 attempts with exponential backoff (100ms, 200ms, 400ms).
|
|
929
|
-
- **5xx / network errors** retry. **4xx** short-circuits; retrying won't help.
|
|
930
|
-
- **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).
|
|
931
|
-
- **Per-statement opt-out:** `sendStatement(stmt, { retry: false })` for fire-and-forget telemetry where the author would rather drop than block.
|
|
932
|
-
|
|
933
|
-
### `sendStatement` return shape
|
|
934
|
-
|
|
935
|
-
```ts
|
|
936
|
-
const result = await xapi.sendStatement({ verb, object });
|
|
937
|
-
// result: {
|
|
938
|
-
// statementId: string,
|
|
939
|
-
// statement: Statement, // fully resolved: actor, context, timestamp filled in
|
|
940
|
-
// destinations: [{ endpoint, ok, status?, error? }, ...]
|
|
941
|
-
// }
|
|
942
|
-
```
|
|
943
|
-
|
|
944
|
-
`destinations[]` lets you act on partial failures under fan-out: one LRS can be down without affecting the others.
|
|
945
|
-
|
|
946
|
-
### Validation
|
|
947
|
-
|
|
948
|
-
The publisher checks three things before sending:
|
|
949
|
-
|
|
950
|
-
1. `verb.id`: present, non-empty string.
|
|
951
|
-
2. `object.id`: non-empty string when `object` is supplied.
|
|
952
|
-
3. `result.score.scaled`: number in `[-1, 1]` when supplied.
|
|
953
|
-
|
|
954
|
-
Everything else passes through. The LRS gives clearer errors for IRI / extension / attachment shape issues than we can; failures surface via `destinations[].error`.
|
|
955
|
-
|
|
956
|
-
### Mode-specific caveats
|
|
957
|
-
|
|
958
|
-
**SCORM (1.2 / 2004).** Actor is auto-derived from the LMS data model; supply `actor` explicitly to use a different IFI (`mbox`, `openid`). **CORS** is the painful one: the LRS must allow the LMS-served origin, and many don't by default. cmi5's `sessionid` extension does not exist here; attach your own extension if you need to group statements by session. In dev (WebAdapter fallback), an explicit `xapi` destination with no author actor cannot synthesize an Agent, so `sendStatement` rejects with an explicit error.
|
|
959
|
-
|
|
960
|
-
**Web.** The bundle is public, so static `auth: 'Basic abc123'` leaks. Always use a function that fetches a server-brokered short-lived token. CORS matters for the token endpoint too. Three actor patterns: hardcoded anonymous, author-wired (`actor: () => getCurrentUser()`), or query-string `?actor=...` mirroring cmi5.
|
|
961
|
-
|
|
962
|
-
**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.
|
|
963
|
-
|
|
964
|
-
**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`.
|
|
965
|
-
|
|
966
|
-
### Non-goals (v1)
|
|
967
|
-
|
|
968
|
-
- Bearer / OAuth credentials at the publisher level (wrap in your `auth` function).
|
|
969
|
-
- Statement signing / attachments helpers (the publisher accepts attachments but doesn't help build them).
|
|
970
|
-
- Offline queue / IndexedDB durability.
|
|
971
|
-
- LRS State API access for non-cmi5 modes.
|
|
972
|
-
- Voiding statements.
|
|
973
|
-
- Mid-session actor refresh (`refreshActor()`).
|
|
974
|
-
- Group actors (Agent only).
|
|
975
|
-
|
|
976
|
-
---
|
|
977
|
-
|
|
978
|
-
## LMS Adapter Reference
|
|
979
|
-
|
|
980
|
-
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.
|
|
981
|
-
|
|
982
|
-
### Cross-mode rollup
|
|
983
|
-
|
|
984
|
-
| Runtime event | SCORM 1.2 | SCORM 2004 4th | cmi5 |
|
|
985
|
-
|---------------|-----------|----------------|------|
|
|
986
|
-
| 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 |
|
|
987
|
-
| 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 |
|
|
988
|
-
| 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) |
|
|
989
|
-
| 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. |
|
|
990
|
-
| 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 |
|
|
991
|
-
| 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` |
|
|
992
|
-
| 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 |
|
|
993
|
-
| 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. |
|
|
994
|
-
| 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) |
|
|
995
|
-
| 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) |
|
|
996
|
-
| Score scale exposed to LMS | `score.raw` only (0–100) | `score.raw` (0–100) **and** `score.scaled` (0–1) | `result.score.scaled` (0–1) |
|
|
997
|
-
|
|
998
|
-
`commit()` is microtask-coalesced. Multiple state mutations within one tick collapse to a single `LMSCommit` / `Commit`. cmi5 statements are individual (no batched commit).
|
|
999
|
-
|
|
1000
|
-
### SCORM 1.2 notes
|
|
1001
|
-
|
|
1002
|
-
API discovery: walks `window.parent` / `window.opener` up to 10 levels looking for `API`.
|
|
1003
|
-
|
|
1004
|
-
**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`.
|
|
1005
|
-
|
|
1006
|
-
**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.
|
|
1007
|
-
|
|
1008
|
-
**Interaction encoding (§3.4.7).** Plain `,` items, `.` pairs, `:` ranges (not the bracketed `[,]` 2004 form). Response/correct identifiers are slugged to `CMIIdentifier` (alphanumeric + underscore, max 250 chars) — raw option text like `"88 Earth days"` becomes `88_Earth_days` 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).
|
|
1009
|
-
|
|
1010
|
-
**Bookmark.** `cmi.core.lesson_location` is written from `SavedState.b` on every `saveState` to surface "Resume from page N" in LMS UIs.
|
|
1011
|
-
|
|
1012
|
-
**Not implemented.** No `cmi.objectives.*` writes. No SCORM 1.2 sequencing; `navigation.canAccess` is the only gating layer, and the LMS sees one SCO. SCORM 1.2 `time-out` / `logout` exit values are not emitted.
|
|
1013
|
-
|
|
1014
|
-
**Local testing.** Upload `dist/*-scorm12.zip` to [SCORM Cloud](https://cloud.scorm.com) (free tier) or [Reload SCORM Player](https://github.com/reload/reload). Inspect the LMS API call log to confirm `lesson_status` and `cmi.interactions.*` look right.
|
|
1015
|
-
|
|
1016
|
-
### SCORM 2004 4th notes
|
|
1017
|
-
|
|
1018
|
-
API discovery: `API_1484_11` via the same parent/opener walk.
|
|
1019
|
-
|
|
1020
|
-
**Two status fields, both written.** `cmi.completion_status` and `cmi.success_status` are independent. `unknown` is written *explicitly* when no graded result exists; leaving it null causes some LMSes (notably SCORM Cloud) to roll a null up to `passed` during status rollup.
|
|
1021
|
-
|
|
1022
|
-
**LMS-supplied thresholds.** `cmi.scaled_passing_score` (§4.2.4.3) and `cmi.completion_threshold` (§4.2.4.4) are read on init and exposed via `adapter.getMasteryScore()` / `getCompletionThreshold()`. `App.svelte` picks up `masteryScore` and overrides `scoring.passingScore` for the launch — parity with cmi5's launch-time mastery.
|
|
1023
|
-
|
|
1024
|
-
**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()`.
|
|
1025
|
-
|
|
1026
|
-
**Interaction encoding (§4.2.7 / Appendix A).** Bracketed delimiters `[,]` / `[.]` / `[:]` (literal text, not regex). Response/correct identifiers must be `short_identifier_type` (same alphanumeric + underscore rules as 1.2 — raw option labels are slugged to dodge `406 Data Model Element Type Mismatch`). `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.
|
|
1027
|
-
|
|
1028
|
-
**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%.
|
|
1029
|
-
|
|
1030
|
-
**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.
|
|
1031
|
-
|
|
1032
|
-
**Not implemented.** `imsss:sequencing` rules are omitted from `imsmanifest.xml` by design. No `cmi.objectives.*`, no `cmi.adl.nav.*` writes.
|
|
1033
|
-
|
|
1034
|
-
**Local testing.** SCORM Cloud is the easiest end-to-end check. Moodle, Cornerstone, SuccessFactors, and Canvas (via Rustici Engine) accept `dist/*-scorm2004.zip` directly.
|
|
1035
|
-
|
|
1036
|
-
### cmi5 notes
|
|
1037
|
-
|
|
1038
|
-
**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`.
|
|
1039
|
-
|
|
1040
|
-
**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.
|
|
1041
|
-
|
|
1042
|
-
**Lifecycle order.** **Initialized** → **Answered** (per question on submit) → **Completed** → **Passed** / **Failed** → **Terminated** (always last, cmi5 §9.3.6). Completed / Passed / Failed are one-shot per session; once dispatched, the corresponding setter no-ops. A reloaded session may re-dispatch them, which is intended: each session sends its lifecycle exactly once. **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).
|
|
1043
|
-
|
|
1044
|
-
**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.
|
|
1045
|
-
|
|
1046
|
-
**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).
|
|
1047
|
-
|
|
1048
|
-
**`LMS.LaunchData` (§10).** Fetched once at init from the State API under `stateId='LMS.LaunchData'`. The AU reads `contextTemplate`, `launchMode`, `returnURL`, `launchParameters`, `masteryScore`, and `moveOn` 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.
|
|
1049
|
-
|
|
1050
|
-
**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. Exposed to author code via `adapter.getLearnerPreferences()`.
|
|
1051
|
-
|
|
1052
|
-
**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()`.
|
|
1053
|
-
|
|
1054
|
-
**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). Bare `getReturnURL()` is available for authors who want to inspect the URL without triggering exit.
|
|
1055
|
-
|
|
1056
|
-
**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.
|
|
1057
|
-
|
|
1058
|
-
**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.
|
|
1059
|
-
|
|
1060
|
-
**Not implemented.** No multi-AU courses (one course = one AU in v1). No **Waived** or **Abandoned** verbs (LMS-only). No mid-session actor refresh.
|
|
1061
|
-
|
|
1062
|
-
**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.
|
|
1063
|
-
|
|
1064
|
-
### Common adapter behaviour
|
|
1065
|
-
|
|
1066
|
-
**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)`.
|
|
1067
|
-
|
|
1068
|
-
**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.
|
|
1069
|
-
|
|
1070
|
-
**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.
|
|
1071
|
-
|
|
1072
|
-
**Interaction encoding.** Split by dialect: SCORM 1.2 (§3.4.7) uses plain `,` / `.` / `:` and slugs identifiers to alphanumeric+underscore; SCORM 2004 (§4.2.7) + cmi5 use bracketed `[,]` / `[.]` / `[:]` and apply the same identifier slugging for short_identifier_type fields. Both dialects share `formatResponse` / `formatCorrectPattern` parameterised over an `InteractionFormat` record; cmi5 embeds the 2004 encoding in `result.response` / `definition.correctResponsesPattern`.
|
|
1073
|
-
|
|
1074
|
-
**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.
|
|
1075
|
-
|
|
1076
|
-
---
|
|
1077
|
-
|
|
1078
|
-
## Custom Layouts
|
|
1079
|
-
|
|
1080
|
-
Drop `layout.svelte` at the project root to replace the default sidebar/topbar/prev-next chrome. The runtime uses it whenever it exists.
|
|
1081
|
-
|
|
1082
|
-
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.
|
|
1083
|
-
|
|
1084
|
-
```svelte
|
|
1085
|
-
<!-- layout.svelte -->
|
|
1086
|
-
<script>
|
|
1087
|
-
import { useNavigation, useProgress } from 'tessera-learn';
|
|
1088
|
-
|
|
1089
|
-
let { page } = $props();
|
|
1090
|
-
const nav = useNavigation();
|
|
1091
|
-
const progress = useProgress();
|
|
1092
|
-
</script>
|
|
1093
|
-
|
|
1094
|
-
<header>
|
|
1095
|
-
<h1>{nav.currentPage.title}</h1>
|
|
1096
|
-
<span>{progress.visitedPages.size} / {nav.pages.length} visited</span>
|
|
1097
|
-
</header>
|
|
1098
|
-
|
|
1099
|
-
<main>{@render page()}</main>
|
|
1100
|
-
|
|
1101
|
-
<footer>
|
|
1102
|
-
<button disabled={!nav.canGoPrev} onclick={() => nav.prev()}>Prev</button>
|
|
1103
|
-
<button disabled={!nav.canGoNext} onclick={() => nav.next()}>Next</button>
|
|
1104
|
-
</footer>
|
|
1105
|
-
```
|
|
1106
|
-
|
|
1107
|
-
To keep most of the default chrome and swap one piece, import `DefaultLayout` from `tessera-learn` and compose around it.
|
|
1108
|
-
|
|
1109
|
-
---
|
|
1110
|
-
|
|
1111
|
-
## Cookbook
|
|
1112
|
-
|
|
1113
|
-
End-to-end recipes that exercise the full hooks API. Adapt to taste.
|
|
1114
|
-
|
|
1115
|
-
### Recipe 1: Custom "draw a line" question
|
|
1116
|
-
|
|
1117
|
-
Learner connects a left-side label to a right-side label by drawing a line. Emits a `matching` interaction so the runtime scores it identically to `<Matching>`. Persists partial progress so an interrupted session resumes cleanly.
|
|
1118
|
-
|
|
1119
|
-
```svelte
|
|
1120
|
-
<!-- pages/05-pairs/01-pairs/draw-pairs.svelte -->
|
|
1121
|
-
<script module>
|
|
1122
|
-
export const pageConfig = { title: "Match the elements" };
|
|
1123
|
-
</script>
|
|
1124
|
-
|
|
1125
|
-
<script>
|
|
1126
|
-
import { useQuestion, usePersistence } from 'tessera-learn';
|
|
1127
|
-
|
|
1128
|
-
const store = usePersistence('draw-pairs:v1');
|
|
1129
|
-
let pairs = $state(store.get() ?? []);
|
|
1130
|
-
$effect(() => store.set(pairs));
|
|
1131
|
-
|
|
1132
|
-
const q = useQuestion({
|
|
1133
|
-
id: 'draw-pairs-1',
|
|
1134
|
-
response: () => ({
|
|
1135
|
-
type: 'matching',
|
|
1136
|
-
response: pairs,
|
|
1137
|
-
correct: [['Hydrogen', 'H'], ['Helium', 'He'], ['Lithium', 'Li']],
|
|
1138
|
-
}),
|
|
1139
|
-
reset: () => { pairs = []; },
|
|
1140
|
-
});
|
|
1141
|
-
|
|
1142
|
-
function connect(l, r) {
|
|
1143
|
-
pairs = [...pairs.filter(([a]) => a !== l), [l, r]];
|
|
1144
|
-
}
|
|
1145
|
-
</script>
|
|
1146
|
-
|
|
1147
|
-
<svg width="400" height="200" role="img" aria-label="Drag to match elements to their symbols">
|
|
1148
|
-
<!-- canvas + line-drawing UI calls connect(l, r) on drop -->
|
|
1149
|
-
</svg>
|
|
1150
|
-
|
|
1151
|
-
{#if q.mode === 'standalone'}
|
|
1152
|
-
<button onclick={() => q.submit()} disabled={q.submitted}>Check</button>
|
|
1153
|
-
{#if q.correct === true}<p>Correct.</p>{/if}
|
|
1154
|
-
{#if q.correct === false}<button onclick={() => q.reset()}>Try again</button>{/if}
|
|
1155
|
-
{/if}
|
|
1156
|
-
```
|
|
1157
|
-
|
|
1158
|
-
### Recipe 2: Custom topbar layout
|
|
1159
|
-
|
|
1160
|
-
Replace the default sidebar with a horizontal topbar showing breadcrumb + progress %. Drop `layout.svelte` at the project root; no other changes needed.
|
|
1161
|
-
|
|
1162
|
-
```svelte
|
|
1163
|
-
<!-- layout.svelte -->
|
|
1164
|
-
<script>
|
|
1165
|
-
import { useNavigation, useProgress } from 'tessera-learn';
|
|
1166
|
-
|
|
1167
|
-
let { page } = $props();
|
|
1168
|
-
const nav = useNavigation();
|
|
1169
|
-
const progress = useProgress();
|
|
1170
|
-
|
|
1171
|
-
const percent = $derived(
|
|
1172
|
-
Math.round((progress.visitedPages.size / nav.pages.length) * 100)
|
|
1173
|
-
);
|
|
1174
|
-
</script>
|
|
1175
|
-
|
|
1176
|
-
<header class="topbar">
|
|
1177
|
-
<span class="brand">My Course</span>
|
|
1178
|
-
<span class="crumb">{nav.currentPage.section} › {nav.currentPage.title}</span>
|
|
1179
|
-
<span class="progress" aria-live="polite">{percent}% complete</span>
|
|
1180
|
-
</header>
|
|
1181
|
-
|
|
1182
|
-
<main class="content">{@render page()}</main>
|
|
1183
|
-
|
|
1184
|
-
<nav class="footer">
|
|
1185
|
-
<button disabled={!nav.canGoPrev} onclick={() => nav.prev()}>← Back</button>
|
|
1186
|
-
<select onchange={(e) => nav.goTo(e.currentTarget.value)} value={nav.currentPage.slug}>
|
|
1187
|
-
{#each nav.pages as p}<option value={p.slug}>{p.title}</option>{/each}
|
|
1188
|
-
</select>
|
|
1189
|
-
<button disabled={!nav.canGoNext} onclick={() => nav.next()}>Next →</button>
|
|
1190
|
-
</nav>
|
|
1191
|
-
|
|
1192
|
-
<style>
|
|
1193
|
-
.topbar { display: flex; gap: 1rem; padding: 0.75rem 1.5rem; border-bottom: 1px solid var(--tessera-border); }
|
|
1194
|
-
.content { max-width: var(--tessera-content-max-width); margin: 0 auto; padding: 2rem; }
|
|
1195
|
-
.footer { display: flex; gap: 1rem; padding: 1rem 1.5rem; border-top: 1px solid var(--tessera-border); }
|
|
1196
|
-
</style>
|
|
1197
|
-
```
|
|
1198
|
-
|
|
1199
|
-
### Recipe 3: Prerequisite-based access
|
|
1200
|
-
|
|
1201
|
-
Lock lesson 5 until lessons 1–3 are visited. Composes with `sequentialAccess` instead of re-implementing it.
|
|
1202
|
-
|
|
1203
|
-
```js
|
|
1204
|
-
// course.config.js
|
|
1205
|
-
import { sequentialAccess } from 'tessera-learn';
|
|
1206
|
-
|
|
1207
|
-
const PREREQS = ['lesson-1', 'lesson-2', 'lesson-3'];
|
|
1208
|
-
|
|
1209
|
-
export default {
|
|
1210
|
-
title: 'My Course',
|
|
1211
|
-
navigation: {
|
|
1212
|
-
mode: 'sequential',
|
|
1213
|
-
canAccess: (ctx) => {
|
|
1214
|
-
if (!sequentialAccess(ctx)) return false;
|
|
1215
|
-
if (ctx.page.slug !== 'lesson-5') return true;
|
|
1216
|
-
return PREREQS.every((slug) => {
|
|
1217
|
-
const i = ctx.manifest.pages.findIndex((p) => p.slug === slug);
|
|
1218
|
-
return i >= 0 && ctx.progress.visitedPages.has(i);
|
|
1219
|
-
});
|
|
1220
|
-
},
|
|
1221
|
-
},
|
|
1222
|
-
completion: { mode: 'percentage', percentageThreshold: 100 },
|
|
1223
|
-
scoring: { passingScore: 70 },
|
|
1224
|
-
export: { standard: 'web' },
|
|
1225
|
-
};
|
|
1226
|
-
```
|
|
1227
|
-
|
|
1228
|
-
### Recipe 4: Custom quiz shell via `quiz.svelte`
|
|
1229
|
-
|
|
1230
|
-
Drop `quiz.svelte` at the project root to replace the built-in `<Quiz>`. The runtime wraps every page with `pageConfig.quiz` in your shell instead of the carousel default. The shell uses only the public `useQuiz()` API; no imports from `tessera/runtime/*`.
|
|
1231
|
-
|
|
1232
|
-
```svelte
|
|
1233
|
-
<!-- quiz.svelte -->
|
|
1234
|
-
<script>
|
|
1235
|
-
import { useQuiz } from 'tessera-learn';
|
|
1236
|
-
|
|
1237
|
-
let { children } = $props();
|
|
1238
|
-
let host;
|
|
1239
|
-
|
|
1240
|
-
// useQuiz owns submission, retry, review, score, and dispatching
|
|
1241
|
-
// tessera-quiz-complete. The shell only drives the UI on top of it.
|
|
1242
|
-
const quiz = useQuiz({ element: () => host });
|
|
1243
|
-
</script>
|
|
1244
|
-
|
|
1245
|
-
<div bind:this={host} class="my-quiz">
|
|
1246
|
-
<p>Question {quiz.questions.findIndex((q) => !q.submitted) + 1} of {quiz.questions.length}</p>
|
|
1247
|
-
|
|
1248
|
-
{#each quiz.questions as q, i}
|
|
1249
|
-
{@const renderFn = quiz.getRender(i)}
|
|
1250
|
-
<section data-question-id={q.id}>{#if renderFn}{@render renderFn()}{/if}</section>
|
|
1251
|
-
{/each}
|
|
1252
|
-
|
|
1253
|
-
{#if quiz.state === 'answering'}
|
|
1254
|
-
<button disabled={!quiz.canSubmit} onclick={() => quiz.submit()}>Submit</button>
|
|
1255
|
-
{:else if quiz.state === 'submitted'}
|
|
1256
|
-
{#if quiz.canRetry}<button onclick={() => quiz.retry()}>Retry</button>{/if}
|
|
1257
|
-
<button onclick={() => quiz.startReview()}>Review</button>
|
|
1258
|
-
{/if}
|
|
1259
|
-
|
|
1260
|
-
<!-- Children render hidden so widget state survives submit/review. -->
|
|
1261
|
-
<div style="display:none">{@render children?.()}</div>
|
|
1262
|
-
</div>
|
|
1263
|
-
```
|
|
1264
|
-
|
|
1265
|
-
Always submit through `useQuiz().submit()`. See [Data contract](#data-contract--what-the-lms-sees).
|
|
1266
|
-
|
|
1267
|
-
### Recipe 5: Graded standalone question
|
|
1268
|
-
|
|
1269
|
-
A single inline reflection, not in a `<Quiz>` but `graded: true`, so it counts toward course success. Useful for "must answer to pass" gates without the quiz wrapper.
|
|
1270
|
-
|
|
1271
|
-
```svelte
|
|
1272
|
-
<!-- pages/04-reflection/01-reflect/reflect.svelte -->
|
|
1273
|
-
<script module>
|
|
1274
|
-
export const pageConfig = { title: "Reflection" };
|
|
1275
|
-
</script>
|
|
1276
|
-
|
|
1277
|
-
<script>
|
|
1278
|
-
import { useQuestion } from 'tessera-learn';
|
|
1279
|
-
|
|
1280
|
-
let answer = $state('');
|
|
1281
|
-
|
|
1282
|
-
const q = useQuestion({
|
|
1283
|
-
id: 'why-it-matters',
|
|
1284
|
-
graded: true,
|
|
1285
|
-
response: () => ({
|
|
1286
|
-
type: 'long-fill-in',
|
|
1287
|
-
response: answer,
|
|
1288
|
-
// No `correct`: any answer accepted; we just want completion.
|
|
1289
|
-
}),
|
|
1290
|
-
score: () => answer.trim().length >= 50 ? 100 : 0,
|
|
1291
|
-
reset: () => { answer = ''; },
|
|
1292
|
-
});
|
|
1293
|
-
</script>
|
|
1294
|
-
|
|
1295
|
-
<h1>Why does this matter to you?</h1>
|
|
1296
|
-
<p>At least 50 characters required to pass.</p>
|
|
1297
|
-
|
|
1298
|
-
<textarea bind:value={answer} rows="6" disabled={q.submitted}></textarea>
|
|
1299
|
-
<button onclick={() => q.submit()} disabled={q.submitted || answer.trim().length < 50}>
|
|
1300
|
-
Submit
|
|
1301
|
-
</button>
|
|
1302
|
-
|
|
1303
|
-
{#if q.submitted}<p>Thanks. Your reflection has been recorded.</p>{/if}
|
|
1304
|
-
```
|
|
1305
|
-
|
|
1306
|
-
The LMS sees a graded `long-fill-in` interaction. Course success rolls up across all graded items: quizzes and standalones alike.
|
|
1307
|
-
|
|
1308
|
-
### Recipe 6: Chunked-reveal page with `markChunk`
|
|
1309
|
-
|
|
1310
|
-
A page that reveals sections one at a time as the learner advances. `markChunk(pageIndex, chunkIndex)` records the highest revealed chunk so the page resumes mid-scroll on reload. `chunkProgress` is the page-keyed map of those highs.
|
|
1311
|
-
|
|
1312
|
-
```svelte
|
|
1313
|
-
<!-- pages/02-deep-dive/01-concepts/long-read.svelte -->
|
|
1314
|
-
<script module>
|
|
1315
|
-
export const pageConfig = { title: "How it works" };
|
|
1316
|
-
</script>
|
|
1317
|
-
|
|
1318
|
-
<script>
|
|
1319
|
-
import { useNavigation, useProgress } from 'tessera-learn';
|
|
1320
|
-
|
|
1321
|
-
const nav = useNavigation();
|
|
1322
|
-
const progress = useProgress();
|
|
1323
|
-
const pageIndex = $derived(nav.currentPageIndex);
|
|
1324
|
-
|
|
1325
|
-
const TOTAL_CHUNKS = 4;
|
|
1326
|
-
let revealed = $state(progress.chunkProgress.get(pageIndex) ?? 0);
|
|
1327
|
-
|
|
1328
|
-
function reveal() {
|
|
1329
|
-
revealed = Math.min(revealed + 1, TOTAL_CHUNKS - 1);
|
|
1330
|
-
progress.markChunk(pageIndex, revealed);
|
|
1331
|
-
}
|
|
1332
|
-
</script>
|
|
1333
|
-
|
|
1334
|
-
{#each Array(revealed + 1) as _, i}
|
|
1335
|
-
<section>
|
|
1336
|
-
<h2>Step {i + 1}</h2>
|
|
1337
|
-
<p>Content for step {i + 1}.</p>
|
|
1338
|
-
</section>
|
|
1339
|
-
{/each}
|
|
1340
|
-
|
|
1341
|
-
{#if revealed < TOTAL_CHUNKS - 1}
|
|
1342
|
-
<button onclick={reveal}>Show next</button>
|
|
1343
|
-
{/if}
|
|
1344
|
-
```
|
|
1345
|
-
|
|
1346
|
-
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.
|
|
1347
|
-
|
|
1348
|
-
### Recipe 7: Persisted UI state with `usePersistence`
|
|
1349
|
-
|
|
1350
|
-
`usePersistence` is not just for question state. Any JSON-serialisable value the learner produces can survive reload through it. Here, a sidebar collapsed/expanded toggle that the learner expects to stay set across sessions.
|
|
1351
|
-
|
|
1352
|
-
```svelte
|
|
1353
|
-
<!-- in any page component, layout.svelte, or a custom widget -->
|
|
1354
|
-
<script>
|
|
1355
|
-
import { usePersistence } from 'tessera-learn';
|
|
1356
|
-
|
|
1357
|
-
const ui = usePersistence('sidebar-prefs');
|
|
1358
|
-
let collapsed = $state(ui.get()?.collapsed ?? false);
|
|
1359
|
-
$effect(() => ui.set({ collapsed }));
|
|
1360
|
-
</script>
|
|
1361
|
-
|
|
1362
|
-
<button onclick={() => (collapsed = !collapsed)}>
|
|
1363
|
-
{collapsed ? 'Expand' : 'Collapse'}
|
|
1364
|
-
</button>
|
|
1365
|
-
```
|
|
1366
|
-
|
|
1367
|
-
Keys are namespaced per course, so two courses on the same LMS don't collide. Under SCORM the value rides in `cmi.suspend_data`; under cmi5 in the xAPI State API; under web in `localStorage`.
|
|
1368
|
-
|
|
1369
|
-
---
|
|
1370
|
-
|
|
1371
|
-
## Constraints
|
|
1372
|
-
|
|
1373
|
-
- **No runtime data fetching in pages.** Page content is static; no `fetch()` or dynamic loaders in page components.
|
|
1374
|
-
- **Public API only.** Import from `tessera-learn`. Do **not** import from `tessera-learn/runtime/*`; those paths are internal and may change.
|
|
1375
|
-
- **`pageConfig` is JSON5-parseable.** Trailing commas, unquoted keys, single quotes are fine; variables, function calls, template literals, and computed values are not.
|
|
1376
|
-
- **Third-party libraries** must be project dependencies in `package.json`.
|