tessera-learn 0.0.1 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +93 -75
- package/README.md +11 -0
- package/dist/plugin/index.js +79 -78
- package/dist/plugin/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/FillInTheBlank.svelte +19 -69
- package/src/components/LockedBanner.svelte +30 -0
- package/src/components/Matching.svelte +44 -80
- package/src/components/MultipleChoice.svelte +14 -43
- package/src/components/Quiz.svelte +69 -263
- package/src/components/ResultIcon.svelte +13 -0
- package/src/components/RetryButton.svelte +25 -0
- package/src/components/Sorting.svelte +33 -76
- package/src/components/util.ts +10 -0
- package/src/plugin/export.ts +39 -33
- package/src/plugin/manifest.ts +38 -12
- package/src/plugin/validation.ts +36 -69
- package/src/runtime/App.svelte +15 -20
- package/src/runtime/ErrorPage.svelte +1 -1
- package/src/runtime/adapters/retry.ts +48 -41
- package/src/runtime/adapters/scorm-base.ts +143 -0
- package/src/runtime/adapters/scorm12.ts +37 -117
- package/src/runtime/adapters/scorm2004.ts +34 -115
- package/src/runtime/hooks.svelte.ts +63 -29
- package/src/runtime/xapi/client.ts +2 -2
- package/src/runtime/xapi/publisher.ts +15 -6
- package/src/runtime/xapi/setup.ts +8 -15
- package/styles/layout.css +21 -10
- package/styles/theme.css +4 -0
package/AGENTS.md
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
|
-
# AGENTS.md
|
|
1
|
+
# AGENTS.md: Tessera Course Authoring Guide
|
|
2
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
|
|
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
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
|
|
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`.
|
|
6
20
|
|
|
7
21
|
---
|
|
8
22
|
|
|
@@ -53,12 +67,12 @@ Sorting is alphabetical by directory / filename. Numeric prefixes on directories
|
|
|
53
67
|
**Optional everywhere.** When absent, titles fall back to the title-cased slug.
|
|
54
68
|
|
|
55
69
|
```js
|
|
56
|
-
// section or lesson _meta.js
|
|
70
|
+
// section or lesson _meta.js: title override
|
|
57
71
|
export default { title: "Getting Started" };
|
|
58
72
|
```
|
|
59
73
|
|
|
60
74
|
```js
|
|
61
|
-
// lesson _meta.js
|
|
75
|
+
// lesson _meta.js: explicit page order
|
|
62
76
|
export default {
|
|
63
77
|
title: "Welcome",
|
|
64
78
|
pages: ["welcome", "objectives"],
|
|
@@ -73,13 +87,13 @@ Pages listed in `pages` come first in listed order; any unlisted `.svelte` files
|
|
|
73
87
|
|
|
74
88
|
There are five:
|
|
75
89
|
|
|
76
|
-
1. **Built-in components
|
|
77
|
-
2. **Hooks
|
|
78
|
-
3. **Custom layout
|
|
79
|
-
4. **Custom quiz shell
|
|
80
|
-
5. **Custom xAPI
|
|
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`, `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).
|
|
81
95
|
|
|
82
|
-
The built-ins are reference implementations of the hooks. A custom widget that calls `useQuestion` and emits an `Interaction` is treated identically to `<MultipleChoice
|
|
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.
|
|
83
97
|
|
|
84
98
|
---
|
|
85
99
|
|
|
@@ -96,7 +110,7 @@ Each page is a `.svelte` file inside a lesson folder.
|
|
|
96
110
|
|
|
97
111
|
### Page configuration
|
|
98
112
|
|
|
99
|
-
`pageConfig` sets the page title and configures quizzes. It must be a **static object literal** in a module script block
|
|
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.
|
|
100
114
|
|
|
101
115
|
Both `<script module>` (Svelte 5) and `<script context="module">` (legacy) are accepted by the manifest parser.
|
|
102
116
|
|
|
@@ -231,7 +245,7 @@ Native player. A11y: `aria-label` from title.
|
|
|
231
245
|
|
|
232
246
|
## Quizzes
|
|
233
247
|
|
|
234
|
-
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)
|
|
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.
|
|
235
249
|
|
|
236
250
|
### Setup
|
|
237
251
|
|
|
@@ -254,7 +268,7 @@ A quiz page is a normal page with `pageConfig.quiz` set. The runtime wraps the p
|
|
|
254
268
|
/>
|
|
255
269
|
```
|
|
256
270
|
|
|
257
|
-
### Data contract
|
|
271
|
+
### Data contract: what the LMS sees
|
|
258
272
|
|
|
259
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.
|
|
260
274
|
|
|
@@ -282,9 +296,9 @@ Pass `weight` to `useQuestion` (and through built-in widget props) to change how
|
|
|
282
296
|
<MultipleChoice id="q-hard" weight={3} ... />
|
|
283
297
|
```
|
|
284
298
|
|
|
285
|
-
Weights apply identically inside a `<Quiz>` and to standalone questions on a plain page. Both paths roll up using `Σ(weight × score) / Σ(weight)
|
|
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.
|
|
286
300
|
|
|
287
|
-
The LMS still sees each question as a single pass/fail interaction
|
|
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.*`.
|
|
288
302
|
|
|
289
303
|
### Question types
|
|
290
304
|
|
|
@@ -312,8 +326,8 @@ The LMS still sees each question as a single pass/fail interaction — weights o
|
|
|
312
326
|
|
|
313
327
|
| Prop | Type | Default | Description |
|
|
314
328
|
|------|------|---------|-------------|
|
|
315
|
-
| `question` | `string` |
|
|
316
|
-
| `answers` | `string[]` |
|
|
329
|
+
| `question` | `string` | | Prompt |
|
|
330
|
+
| `answers` | `string[]` | | Acceptable answers |
|
|
317
331
|
| `caseSensitive` | `boolean` | `false` | Comparison casing |
|
|
318
332
|
| `weight` | `number` | `1` | Page-level rollup weight |
|
|
319
333
|
|
|
@@ -386,7 +400,7 @@ All four question components also work outside `<Quiz>` for inline practice. Sta
|
|
|
386
400
|
/>
|
|
387
401
|
```
|
|
388
402
|
|
|
389
|
-
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
|
|
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).
|
|
390
404
|
|
|
391
405
|
---
|
|
392
406
|
|
|
@@ -506,11 +520,11 @@ Every field except `title` has a default. The build merges yours over:
|
|
|
506
520
|
}
|
|
507
521
|
```
|
|
508
522
|
|
|
509
|
-
So `export default { title: "My Course" }` is a complete config
|
|
523
|
+
So `export default { title: "My Course" }` is a complete config: free navigation, full-percentage completion, web export.
|
|
510
524
|
|
|
511
525
|
### Custom access rules
|
|
512
526
|
|
|
513
|
-
For anything beyond the two presets (prereqs, instructor approval, time gating), supply `navigation.canAccess`. It runs synchronously on every navigation evaluation
|
|
527
|
+
For anything beyond the two presets (prereqs, instructor approval, time gating), supply `navigation.canAccess`. It runs synchronously on every navigation evaluation. Keep it cheap.
|
|
514
528
|
|
|
515
529
|
```js
|
|
516
530
|
import { sequentialAccess } from 'tessera-learn';
|
|
@@ -531,7 +545,7 @@ export default {
|
|
|
531
545
|
};
|
|
532
546
|
```
|
|
533
547
|
|
|
534
|
-
`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
|
|
548
|
+
`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.
|
|
535
549
|
|
|
536
550
|
### Build output
|
|
537
551
|
|
|
@@ -539,16 +553,16 @@ export default {
|
|
|
539
553
|
|
|
540
554
|
| `export.standard` | What ships | Where |
|
|
541
555
|
|-------------------|------------|-------|
|
|
542
|
-
| `web` | Static site (HTML/CSS/JS + `assets/`) | `dist/`
|
|
556
|
+
| `web` | Static site (HTML/CSS/JS + `assets/`) | `dist/` (host on any static file server) |
|
|
543
557
|
| `scorm12` | SCORM 1.2 package | `dist/<course>-scorm12.zip` |
|
|
544
558
|
| `scorm2004` | SCORM 2004 4th Edition package | `dist/<course>-scorm2004.zip` |
|
|
545
559
|
| `cmi5` | cmi5 package (AU + manifest) | `dist/<course>-cmi5.zip` |
|
|
546
560
|
|
|
547
|
-
For LMS exports, upload the zip via your LMS's import flow. For web export, the bundle is a self-contained static site
|
|
561
|
+
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.
|
|
548
562
|
|
|
549
563
|
### Validation
|
|
550
564
|
|
|
551
|
-
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
|
|
565
|
+
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.
|
|
552
566
|
|
|
553
567
|
---
|
|
554
568
|
|
|
@@ -574,8 +588,8 @@ Each hook is synchronous and must be called during component setup, inside a Tes
|
|
|
574
588
|
|
|
575
589
|
Register a question widget so the runtime can submit, score, persist, and report it.
|
|
576
590
|
|
|
577
|
-
- **Inside `<Quiz
|
|
578
|
-
- **Standalone
|
|
591
|
+
- **Inside `<Quiz>`**: the parent Quiz drives submission. The widget renders the prompt + answer UI; nothing else.
|
|
592
|
+
- **Standalone**: the widget owns its own Check/Retry. Set `graded: true` to count toward course success.
|
|
579
593
|
|
|
580
594
|
```ts
|
|
581
595
|
function useQuestion(opts: {
|
|
@@ -584,18 +598,22 @@ function useQuestion(opts: {
|
|
|
584
598
|
response: () => Interaction; // current learner answer; called on submit
|
|
585
599
|
score?: () => number; // standalone-only override (0–100)
|
|
586
600
|
weight?: number; // page-level rollup weight (default 1)
|
|
601
|
+
maxRetries?: number; // standalone retry cap (default Infinity); ignored inside <Quiz>
|
|
587
602
|
reset?: () => void;
|
|
588
603
|
}): {
|
|
589
604
|
submit(): void;
|
|
590
605
|
reset(): void;
|
|
606
|
+
retry(): void; // standalone-only; no-op once maxRetries hit or inside <Quiz>
|
|
591
607
|
readonly submitted: boolean;
|
|
592
608
|
readonly correct: boolean | null;
|
|
609
|
+
readonly canRetry: boolean;
|
|
610
|
+
readonly retryCount: number;
|
|
593
611
|
readonly mode: 'standalone' | 'quiz';
|
|
594
612
|
readonly quizIndex: number | undefined;
|
|
595
613
|
};
|
|
596
614
|
```
|
|
597
615
|
|
|
598
|
-
`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
|
|
616
|
+
`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.
|
|
599
617
|
|
|
600
618
|
```svelte
|
|
601
619
|
<script>
|
|
@@ -622,7 +640,7 @@ function useQuestion(opts: {
|
|
|
622
640
|
|
|
623
641
|
### `useQuiz`
|
|
624
642
|
|
|
625
|
-
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
|
|
643
|
+
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.
|
|
626
644
|
|
|
627
645
|
```ts
|
|
628
646
|
function useQuiz(opts: { element: () => HTMLElement | null }): {
|
|
@@ -653,7 +671,7 @@ function useQuiz(opts: { element: () => HTMLElement | null }): {
|
|
|
653
671
|
};
|
|
654
672
|
```
|
|
655
673
|
|
|
656
|
-
Throws when called on a page without `pageConfig.quiz`. Three telemetry-only DOM events also fire
|
|
674
|
+
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.
|
|
657
675
|
|
|
658
676
|
### `useNavigation`
|
|
659
677
|
|
|
@@ -688,7 +706,7 @@ function useProgress(): {
|
|
|
688
706
|
|
|
689
707
|
### `usePersistence<T>(key)`
|
|
690
708
|
|
|
691
|
-
Per-widget persistent state. Survives reload on every adapter
|
|
709
|
+
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.
|
|
692
710
|
|
|
693
711
|
```ts
|
|
694
712
|
function usePersistence<T>(key: string): {
|
|
@@ -719,7 +737,7 @@ function isCorrect(i: Interaction): boolean | null;
|
|
|
719
737
|
|
|
720
738
|
## Custom xAPI statements
|
|
721
739
|
|
|
722
|
-
The lifecycle stream (Initialized / Completed / Passed / Failed / Terminated under cmi5; `cmi.*` writes under SCORM) is sent automatically
|
|
740
|
+
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()`:
|
|
723
741
|
|
|
724
742
|
```ts
|
|
725
743
|
import { useXAPI } from 'tessera-learn';
|
|
@@ -731,13 +749,13 @@ xapi?.sendStatement({
|
|
|
731
749
|
});
|
|
732
750
|
```
|
|
733
751
|
|
|
734
|
-
`useXAPI()` is a plain function (not a Svelte context hook), callable from anywhere
|
|
752
|
+
`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.
|
|
735
753
|
|
|
736
754
|
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`.
|
|
737
755
|
|
|
738
|
-
### Configure the destination
|
|
756
|
+
### Configure the destination: `course.config.js`
|
|
739
757
|
|
|
740
|
-
`config.xapi` is one destination, or an array of them. The destination is always declared explicitly
|
|
758
|
+
`config.xapi` is one destination, or an array of them. The destination is always declared explicitly. There is no implicit default.
|
|
741
759
|
|
|
742
760
|
```js
|
|
743
761
|
xapi: {
|
|
@@ -747,7 +765,7 @@ xapi: {
|
|
|
747
765
|
activityId: 'https://example.com/courses/intro-to-x',
|
|
748
766
|
}
|
|
749
767
|
|
|
750
|
-
// cmi5 only
|
|
768
|
+
// cmi5 only: inherit the LMS launch LRS (endpoint+auth+actor+activityId+registration):
|
|
751
769
|
xapi: { endpoint: 'lms' }
|
|
752
770
|
|
|
753
771
|
// Fan out (at most one 'lms' entry):
|
|
@@ -772,9 +790,9 @@ Each destination has its own queue, auth resolver, and retry loop. One UUID is m
|
|
|
772
790
|
|
|
773
791
|
Priority order (top wins):
|
|
774
792
|
|
|
775
|
-
1. **Author-supplied `xapi.actor
|
|
776
|
-
2. **cmi5 launch actor
|
|
777
|
-
3. **SCORM-derived actor
|
|
793
|
+
1. **Author-supplied `xapi.actor`**: always wins.
|
|
794
|
+
2. **cmi5 launch actor**: under cmi5, the publisher uses the same Agent the LMS handed us at launch.
|
|
795
|
+
3. **SCORM-derived actor**: under scorm12/scorm2004, the publisher synthesizes:
|
|
778
796
|
```ts
|
|
779
797
|
{
|
|
780
798
|
account: {
|
|
@@ -788,22 +806,22 @@ Priority order (top wins):
|
|
|
788
806
|
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.
|
|
789
807
|
4. **Fallback: error.** Web export with no `actor` fails at config time.
|
|
790
808
|
|
|
791
|
-
Mid-session identity change (e.g., learner logs in/out without reloading) is **not supported in v1
|
|
809
|
+
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.
|
|
792
810
|
|
|
793
811
|
### Auth
|
|
794
812
|
|
|
795
|
-
v1 supports **Basic auth only**. The publisher prepends `Basic ` to whatever your `auth` value resolves to
|
|
813
|
+
v1 supports **Basic auth only**. The publisher prepends `Basic ` to whatever your `auth` value resolves to; pass the credential value, not the full header.
|
|
796
814
|
|
|
797
815
|
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).
|
|
798
816
|
|
|
799
|
-
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
|
|
817
|
+
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.
|
|
800
818
|
|
|
801
|
-
**Static-string `auth` ships in your bundle
|
|
819
|
+
**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.
|
|
802
820
|
|
|
803
821
|
### Retry policy
|
|
804
822
|
|
|
805
823
|
- **Default:** 3 attempts with exponential backoff (100ms, 200ms, 400ms).
|
|
806
|
-
- **5xx / network errors** retry. **4xx** short-circuits
|
|
824
|
+
- **5xx / network errors** retry. **4xx** short-circuits; retrying won't help.
|
|
807
825
|
- **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).
|
|
808
826
|
- **Per-statement opt-out:** `sendStatement(stmt, { retry: false })` for fire-and-forget telemetry where the author would rather drop than block.
|
|
809
827
|
|
|
@@ -818,27 +836,27 @@ const result = await xapi.sendStatement({ verb, object });
|
|
|
818
836
|
// }
|
|
819
837
|
```
|
|
820
838
|
|
|
821
|
-
`destinations[]` lets you act on partial failures under fan-out
|
|
839
|
+
`destinations[]` lets you act on partial failures under fan-out: one LRS can be down without affecting the others.
|
|
822
840
|
|
|
823
841
|
### Validation
|
|
824
842
|
|
|
825
843
|
The publisher checks three things before sending:
|
|
826
844
|
|
|
827
|
-
1. `verb.id
|
|
828
|
-
2. `object.id
|
|
829
|
-
3. `result.score.scaled
|
|
845
|
+
1. `verb.id`: present, non-empty string.
|
|
846
|
+
2. `object.id`: non-empty string when `object` is supplied.
|
|
847
|
+
3. `result.score.scaled`: number in `[-1, 1]` when supplied.
|
|
830
848
|
|
|
831
849
|
Everything else passes through. The LRS gives clearer errors for IRI / extension / attachment shape issues than we can; failures surface via `destinations[].error`.
|
|
832
850
|
|
|
833
851
|
### Mode-specific caveats
|
|
834
852
|
|
|
835
|
-
**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
|
|
853
|
+
**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.
|
|
836
854
|
|
|
837
855
|
**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.
|
|
838
856
|
|
|
839
|
-
**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
|
|
857
|
+
**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.
|
|
840
858
|
|
|
841
|
-
**Page unload.** Once unload begins, every publisher is marked unloading and `useXAPI()?.sendStatement(...)` calls reject
|
|
859
|
+
**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`.
|
|
842
860
|
|
|
843
861
|
### Non-goals (v1)
|
|
844
862
|
|
|
@@ -854,7 +872,7 @@ Everything else passes through. The LRS gives clearer errors for IRI / extension
|
|
|
854
872
|
|
|
855
873
|
## LMS Adapter Reference
|
|
856
874
|
|
|
857
|
-
The runtime translates author intent
|
|
875
|
+
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.
|
|
858
876
|
|
|
859
877
|
### Cross-mode rollup
|
|
860
878
|
|
|
@@ -866,30 +884,30 @@ The runtime translates author intent — page visits, quiz scores, completion, p
|
|
|
866
884
|
| 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`, `result.duration`, `result.score?` (one-shot per session) |
|
|
867
885
|
| 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` |
|
|
868
886
|
| 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 |
|
|
869
|
-
| Author exit / unload | `LMSSetValue("cmi.core.exit", "suspend"\|"")`, `LMSCommit("")`, `LMSFinish("")` (queue drained synchronously) | `SetValue("cmi.exit", "suspend"\|"normal"\|...)`, `Commit("")`, `Terminate("")` (queue drained synchronously) | If course not yet **Completed**, send **Suspended**; then **Terminated** (always last on the wire
|
|
887
|
+
| Author exit / unload | `LMSSetValue("cmi.core.exit", "suspend"\|"")`, `LMSCommit("")`, `LMSFinish("")` (queue drained synchronously) | `SetValue("cmi.exit", "suspend"\|"normal"\|...)`, `Commit("")`, `Terminate("")` (queue drained synchronously) | If course not yet **Completed**, send **Suspended**; then **Terminated** (always last on the wire, cmi5 §9.3.6) |
|
|
870
888
|
| 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) |
|
|
871
889
|
| 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) |
|
|
872
890
|
| Score scale exposed to LMS | `score.raw` only (0–100) | `score.raw` (0–100) **and** `score.scaled` (0–1) | `result.score.scaled` (0–1) |
|
|
873
891
|
|
|
874
892
|
`commit()` is microtask-coalesced. Multiple state mutations within one tick collapse to a single `LMSCommit` / `Commit`. cmi5 statements are individual (no batched commit).
|
|
875
893
|
|
|
876
|
-
### SCORM 1.2
|
|
894
|
+
### SCORM 1.2 notes
|
|
877
895
|
|
|
878
896
|
API discovery: walks `window.parent` / `window.opener` up to 10 levels looking for `API`.
|
|
879
897
|
|
|
880
|
-
**One status field.** `cmi.core.lesson_status` collapses completion and pass/fail. The runtime resolves them by priority
|
|
898
|
+
**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`.
|
|
881
899
|
|
|
882
900
|
**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.
|
|
883
901
|
|
|
884
|
-
**Not implemented.** No `cmi.objectives.*` writes. No SCORM 1.2 sequencing
|
|
902
|
+
**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.
|
|
885
903
|
|
|
886
904
|
**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.
|
|
887
905
|
|
|
888
|
-
### SCORM 2004 4th
|
|
906
|
+
### SCORM 2004 4th notes
|
|
889
907
|
|
|
890
908
|
API discovery: `API_1484_11` via the same parent/opener walk.
|
|
891
909
|
|
|
892
|
-
**Two status fields, both written.** `cmi.completion_status` and `cmi.success_status` are independent. `unknown` is written *explicitly* when no graded result exists
|
|
910
|
+
**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.
|
|
893
911
|
|
|
894
912
|
**LMS-side fields untouched.** `cmi.completion_threshold` and `cmi.scaled_passing_score` are LMS-owned; Tessera owns the threshold via `scoring.passingScore`.
|
|
895
913
|
|
|
@@ -897,13 +915,13 @@ API discovery: `API_1484_11` via the same parent/opener walk.
|
|
|
897
915
|
|
|
898
916
|
**Local testing.** SCORM Cloud is the easiest end-to-end check. Moodle, Cornerstone, SuccessFactors, and Canvas (via Rustici Engine) accept `dist/*-scorm2004.zip` directly.
|
|
899
917
|
|
|
900
|
-
### cmi5
|
|
918
|
+
### cmi5 notes
|
|
901
919
|
|
|
902
920
|
**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`.
|
|
903
921
|
|
|
904
922
|
**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.
|
|
905
923
|
|
|
906
|
-
**Lifecycle order.** **Initialized** → **Answered** (per question on submit) → **Completed** → **Passed** / **Failed** → **Suspended** (only if not Completed) → **Terminated** (always last, cmi5 §9.3.6). Completed / Passed / Failed are one-shot per session
|
|
924
|
+
**Lifecycle order.** **Initialized** → **Answered** (per question on submit) → **Completed** → **Passed** / **Failed** → **Suspended** (only if not Completed) → **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.
|
|
907
925
|
|
|
908
926
|
**Required result fields.** Completed: `completion: true`, `duration`. Passed/Failed: `success`, `duration`. Terminated: `duration` (§9.5.4.1). All include `result.score.scaled` when a score is known.
|
|
909
927
|
|
|
@@ -911,19 +929,19 @@ API discovery: `API_1484_11` via the same parent/opener walk.
|
|
|
911
929
|
|
|
912
930
|
**State persistence.** `tessera-state` document via the State API, keyed by `activityId` + `agent` + `registration?` + `stateId='tessera-state'`. Writes chain onto the publisher's queue so the suspend payload lands before Terminated.
|
|
913
931
|
|
|
914
|
-
**Not implemented.** No multi-AU courses (one course = one AU in v1). No **Waived** or **Abandoned** verbs. No mid-session actor refresh. No `MoveOn` criterion in `cmi5.xml
|
|
932
|
+
**Not implemented.** No multi-AU courses (one course = one AU in v1). No **Waived** or **Abandoned** verbs. No mid-session actor refresh. No `MoveOn` criterion in `cmi5.xml`: completion is decided runtime-side; the LMS evaluates MoveOn against the verbs the runtime *does* emit.
|
|
915
933
|
|
|
916
|
-
**Local testing.** Upload `dist/*-cmi5.zip` to SCORM Cloud and use the cmi5 dispatch URL it generates
|
|
934
|
+
**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.
|
|
917
935
|
|
|
918
936
|
### Common adapter behaviour
|
|
919
937
|
|
|
920
|
-
**Queue + retry.** SCORM adapters serialize every `LMSSetValue` / `LMSCommit` through a sequential queue with exponential-backoff retry on transient errors. Retry warnings include the real LMS error code (`GetLastError`)
|
|
938
|
+
**Queue + retry.** SCORM adapters serialize every `LMSSetValue` / `LMSCommit` through a sequential queue with exponential-backoff retry on transient errors. Retry warnings include the real LMS error code (`GetLastError`), e.g. `405 Incorrect Data Type` rather than a generic "LMS call failed".
|
|
921
939
|
|
|
922
|
-
**Unload.** `terminate()` cannot run async retries
|
|
940
|
+
**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.
|
|
923
941
|
|
|
924
|
-
**Interaction encoding.** `formatResponse` / `formatCorrectPattern` follow SCORM 2004 4th RTE §4.2.7 delimiters
|
|
942
|
+
**Interaction encoding.** `formatResponse` / `formatCorrectPattern` follow SCORM 2004 4th RTE §4.2.7 delimiters: `[,]` items, `[.]` pairs, `[:]` ranges. SCORM 1.2 and cmi5 reuse the encoding (cmi5 embeds it in `result.response` / `definition.correctResponsesPattern`).
|
|
925
943
|
|
|
926
|
-
**Failure surface.** Anything thrown from `adapter.init()` is caught by `App.svelte` and rendered as a visible "This course can't run here" panel
|
|
944
|
+
**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.
|
|
927
945
|
|
|
928
946
|
---
|
|
929
947
|
|
|
@@ -1079,7 +1097,7 @@ export default {
|
|
|
1079
1097
|
|
|
1080
1098
|
### Recipe 4: Custom quiz shell via `quiz.svelte`
|
|
1081
1099
|
|
|
1082
|
-
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
|
|
1100
|
+
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/*`.
|
|
1083
1101
|
|
|
1084
1102
|
```svelte
|
|
1085
1103
|
<!-- quiz.svelte -->
|
|
@@ -1114,11 +1132,11 @@ Drop `quiz.svelte` at the project root to replace the built-in `<Quiz>`. The run
|
|
|
1114
1132
|
</div>
|
|
1115
1133
|
```
|
|
1116
1134
|
|
|
1117
|
-
Always submit through `useQuiz().submit()
|
|
1135
|
+
Always submit through `useQuiz().submit()`. See [Data contract](#data-contract--what-the-lms-sees).
|
|
1118
1136
|
|
|
1119
1137
|
### Recipe 5: Graded standalone question
|
|
1120
1138
|
|
|
1121
|
-
A single inline reflection
|
|
1139
|
+
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.
|
|
1122
1140
|
|
|
1123
1141
|
```svelte
|
|
1124
1142
|
<!-- pages/04-reflection/01-reflect/reflect.svelte -->
|
|
@@ -1137,7 +1155,7 @@ A single inline reflection — not in a `<Quiz>` but `graded: true`, so it count
|
|
|
1137
1155
|
response: () => ({
|
|
1138
1156
|
type: 'long-fill-in',
|
|
1139
1157
|
response: answer,
|
|
1140
|
-
// No `correct
|
|
1158
|
+
// No `correct`: any answer accepted; we just want completion.
|
|
1141
1159
|
}),
|
|
1142
1160
|
score: () => answer.trim().length >= 50 ? 100 : 0,
|
|
1143
1161
|
reset: () => { answer = ''; },
|
|
@@ -1152,10 +1170,10 @@ A single inline reflection — not in a `<Quiz>` but `graded: true`, so it count
|
|
|
1152
1170
|
Submit
|
|
1153
1171
|
</button>
|
|
1154
1172
|
|
|
1155
|
-
{#if q.submitted}<p>Thanks
|
|
1173
|
+
{#if q.submitted}<p>Thanks. Your reflection has been recorded.</p>{/if}
|
|
1156
1174
|
```
|
|
1157
1175
|
|
|
1158
|
-
The LMS sees a graded `long-fill-in` interaction. Course success rolls up across all graded items
|
|
1176
|
+
The LMS sees a graded `long-fill-in` interaction. Course success rolls up across all graded items: quizzes and standalones alike.
|
|
1159
1177
|
|
|
1160
1178
|
### Recipe 6: Chunked-reveal page with `markChunk`
|
|
1161
1179
|
|
|
@@ -1199,7 +1217,7 @@ Use this when a page is long enough that "fully visited" is a meaningful state s
|
|
|
1199
1217
|
|
|
1200
1218
|
### Recipe 7: Persisted UI state with `usePersistence`
|
|
1201
1219
|
|
|
1202
|
-
`usePersistence` is not just for question state
|
|
1220
|
+
`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.
|
|
1203
1221
|
|
|
1204
1222
|
```svelte
|
|
1205
1223
|
<!-- in any page component, layout.svelte, or a custom widget -->
|
|
@@ -1222,7 +1240,7 @@ Keys are namespaced per course, so two courses on the same LMS don't collide. Un
|
|
|
1222
1240
|
|
|
1223
1241
|
## Constraints
|
|
1224
1242
|
|
|
1225
|
-
- **No runtime data fetching in pages.** Page content is static
|
|
1226
|
-
- **Public API only.** Import from `tessera-learn`. Do **not** import from `tessera-learn/runtime
|
|
1243
|
+
- **No runtime data fetching in pages.** Page content is static; no `fetch()` or dynamic loaders in page components.
|
|
1244
|
+
- **Public API only.** Import from `tessera-learn`. Do **not** import from `tessera-learn/runtime/*`; those paths are internal and may change.
|
|
1227
1245
|
- **`pageConfig` is JSON5-parseable.** Trailing commas, unquoted keys, single quotes are fine; variables, function calls, template literals, and computed values are not.
|
|
1228
1246
|
- **Third-party libraries** must be project dependencies in `package.json`.
|
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
LMS tracking runtime for interactive learning content. One adapter layer (SCORM 1.2, SCORM 2004 4th Edition, cmi5, static Web), your choice of components.
|
|
4
4
|
|
|
5
|
+
Tessera is a toolkit for building interactive online courses, designed for AI-assisted authoring: pages are `.svelte` files, the runtime locks the LMS data contract (tracking, completion, scoring, persistence), and an AI agent working in the project follows the conventions in `AGENTS.md` to write pages and components. This package is the runtime; you typically don't depend on it directly — `create-tessera` scaffolds a project that pins it for you.
|
|
6
|
+
|
|
5
7
|
## Install
|
|
6
8
|
|
|
7
9
|
You probably don't want to install this package directly. Use the scaffolder:
|
|
@@ -12,6 +14,15 @@ npm create tessera@latest my-course
|
|
|
12
14
|
|
|
13
15
|
That creates a project with Tessera wired up, a starter page structure, and the authoring guide (`AGENTS.md`) at the project root. Prefer hooks-only? `npm create tessera@latest -- --template=bare my-course` scaffolds without the built-in components.
|
|
14
16
|
|
|
17
|
+
## What's included
|
|
18
|
+
|
|
19
|
+
- **Hooks** (`tessera-learn`): `useQuestion`, `useQuiz`, `useNavigation`, `useProgress`, `usePersistence`, `useXAPI`.
|
|
20
|
+
- **Vite plugin** (`tessera-learn/plugin`): `tesseraPlugin()` — wires page/layout discovery, the LMS adapter, and the export pipeline. Used in your project's `vite.config.js`.
|
|
21
|
+
- **Built-in components** (`tessera-learn`): `Callout`, `Image`, `Audio`, `Video`, `Accordion` / `AccordionItem`, `Carousel` / `CarouselSlide`, `RevealModal`, `Quiz`, `MultipleChoice`, `FillInTheBlank`, `Matching`, `Sorting`, `DefaultLayout`.
|
|
22
|
+
- **LMS adapters**: SCORM 1.2, SCORM 2004 4th Edition, cmi5, static Web — selected via `course.config.js` `export.standard`.
|
|
23
|
+
|
|
24
|
+
See `AGENTS.md` for usage, signatures, and authoring conventions.
|
|
25
|
+
|
|
15
26
|
## Documentation
|
|
16
27
|
|
|
17
28
|
The full authoring guide ships with this package at `node_modules/tessera-learn/AGENTS.md`, at the root of any scaffolded project, and on [GitHub](https://github.com/redmodd/tessera/blob/main/AGENTS.md).
|