tessera-learn 0.0.6 → 0.0.7

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 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`.