tessera-learn 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -1,8 +1,22 @@
1
- # AGENTS.md Tessera Course Authoring Guide
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 and gets out of the way for the presentation layer.
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 read it before generating or editing course code.
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 title override
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 explicit page order
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** `Callout`, `Image`, `MultipleChoice`, `FillInTheBlank`, `Matching`, `Sorting`, etc., from `tessera-learn`. Use, compose, or skip.
77
- 2. **Hooks** `useQuestion`, `useQuiz`, `useNavigation`, `useProgress`, `usePersistence`. The stable contract between custom widgets and the runtime. Anything the built-ins do, you can do.
78
- 3. **Custom layout** drop `layout.svelte` at the project root to replace the default chrome.
79
- 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`.
80
- 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).
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>` same scoring, same LMS reporting, same persistence.
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 no variables, function calls, or computed values.
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) page authors no longer need their own `<Quiz>` wrapper. Drop question components directly at the page 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 what the LMS sees
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)` 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.
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 weights only affect the page-level `cmi.core.score.raw` rollup, not `cmi.interactions.*`.
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` || Prompt |
316
- | `answers` | `string[]` || Acceptable answers |
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 see [Recipe 5](#recipe-5-graded-standalone-question).
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 free navigation, full-percentage completion, web export.
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 keep it cheap.
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 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.
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/` host on any static file server |
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 drop `dist/` on Netlify, GitHub Pages, S3, or any static host.
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` 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.
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>`** the parent Quiz drives submission. The widget renders the prompt + answer UI; nothing else.
578
- - **Standalone** the widget owns its own Check/Retry. Set `graded: true` to count toward course success.
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 `useQuestion` reports a `null` correctness flag and your widget renders its own UI.
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`** bypassing it means the quiz reports nothing to the LMS.
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 `tessera-quiz-question-answered`, `tessera-quiz-before-submit`, `tessera-quiz-retry` none of them write to the adapter.
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 `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.
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 see [LMS Adapter Reference](#lms-adapter-reference). To emit your own xAPI verbs, use `useXAPI()`:
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 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.
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 `course.config.js`
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 there is no implicit default.
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 inherit the LMS launch LRS (endpoint+auth+actor+activityId+registration):
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`** always wins.
776
- 2. **cmi5 launch actor** under cmi5, the publisher uses the same Agent the LMS handed us at launch.
777
- 3. **SCORM-derived actor** under scorm12/scorm2004, the publisher synthesizes:
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** actor is resolved once per page-load and cached. Reload the runtime on identity change.
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 pass the credential value, not the full header.
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 every subsequent send fails fast without hitting the LRS. Reload the runtime to retry.
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** fine for demos, never for production. Use a function that fetches a server-brokered short-lived token instead.
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 retrying won't help.
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 one LRS can be down without affecting the others.
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` present, non-empty string.
828
- 2. `object.id` non-empty string when `object` is supplied.
829
- 3. `result.score.scaled` number in `[-1, 1]` when supplied.
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 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 `sendStatement` rejects with an explicit error.
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 no silent fallback. Point `endpoint` at a local LRS for dev.
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 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`.
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 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.
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 cmi5 §9.3.6) |
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 notes
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 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`.
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 `navigation.canAccess` is the only gating layer; the LMS sees one SCO. SCORM 1.2 `time-out` / `logout` exit values are not emitted.
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 notes
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 leaving it null causes some LMSes (notably SCORM Cloud) to roll a null up to `passed` during status rollup.
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 notes
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 once dispatched, the corresponding setter no-ops. A reloaded session may re-dispatch them, which is intended: each session sends its lifecycle exactly once.
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` completion is decided runtime-side; the LMS evaluates MoveOn against the verbs the runtime *does* emit.
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 closest free equivalent to a real LMS launch.
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`) e.g. `405 Incorrect Data Type` rather than a generic "LMS call failed".
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 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.
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 `[,]` items, `[.]` pairs, `[:]` ranges. SCORM 1.2 and cmi5 reuse the encoding (cmi5 embeds it in `result.response` / `definition.correctResponsesPattern`).
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 never a silent degradation.
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 no imports from `tessera/runtime/*`.
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()` see [Data contract](#data-contract--what-the-lms-sees).
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 not in a `<Quiz>` but `graded: true`, so it counts toward course success. Useful for "must answer to pass" gates without the quiz wrapper.
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` any answer accepted; we just want completion.
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 your reflection has been recorded.</p>{/if}
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 quizzes and standalones alike.
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 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.
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 no `fetch()` or dynamic loaders in page components.
1226
- - **Public API only.** Import from `tessera-learn`. Do **not** import from `tessera-learn/runtime/*` those paths are internal and may change.
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).