tessera-learn 0.0.3 → 0.0.5

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
@@ -88,7 +88,7 @@ Pages listed in `pages` come first in listed order; any unlisted `.svelte` files
88
88
  There are five:
89
89
 
90
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.
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
92
  3. **Custom layout**: drop `layout.svelte` at the project root to replace the default chrome.
93
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
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).
@@ -404,6 +404,95 @@ Standalone questions are not graded by default. To grade one (e.g., a required r
404
404
 
405
405
  ---
406
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
+
407
496
  ## Assets
408
497
 
409
498
  Drop files into `assets/`. Reference them with `$assets/` in component props:
@@ -486,12 +575,14 @@ export default {
486
575
  },
487
576
 
488
577
  completion: {
489
- mode: "percentage", // "percentage" or "quiz"
578
+ mode: "percentage", // "percentage" | "quiz" | "manual"
490
579
  percentageThreshold: 100, // 0–100 (percentage mode)
580
+ // trigger: "page", // (manual only) opt into build-time check
581
+ // requireSuccessStatus: "passed", // (manual only) "passed" | "failed"
491
582
  },
492
583
 
493
584
  scoring: {
494
- passingScore: 70,
585
+ passingScore: 70, // optional under "manual" (defaults to 0)
495
586
  },
496
587
 
497
588
  export: {
@@ -504,6 +595,7 @@ export default {
504
595
  - `navigation.mode: "sequential"` → pages unlock one at a time as each is completed.
505
596
  - `completion.mode: "percentage"` → course completes when `visitedPages / totalPages * 100 >= percentageThreshold`.
506
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).
507
599
 
508
600
  ### Minimum config
509
601
 
@@ -568,7 +660,7 @@ The Vite plugin runs project validation on every dev start and build (manifest s
568
660
 
569
661
  ## Hooks Reference
570
662
 
571
- Five hooks plus one helper make up the stable contract between widgets and the runtime.
663
+ Six hooks plus one helper make up the stable contract between widgets and the runtime.
572
664
 
573
665
  ```js
574
666
  import {
@@ -576,6 +668,7 @@ import {
576
668
  useQuiz,
577
669
  useNavigation,
578
670
  useProgress,
671
+ useCompletion,
579
672
  usePersistence,
580
673
  isCorrect,
581
674
  } from 'tessera-learn';
@@ -704,6 +797,18 @@ function useProgress(): {
704
797
  };
705
798
  ```
706
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
+
707
812
  ### `usePersistence<T>(key)`
708
813
 
709
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.
@@ -878,13 +983,14 @@ The runtime translates author intent (page visits, quiz scores, completion, pers
878
983
 
879
984
  | Runtime event | SCORM 1.2 | SCORM 2004 4th | cmi5 |
880
985
  |---------------|-----------|----------------|------|
881
- | 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; build publisher; `GET` State API; send **Initialized** statement |
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 |
882
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 |
883
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) |
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) |
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 |
885
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` |
886
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 |
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) |
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. |
888
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) |
889
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) |
890
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) |
@@ -921,15 +1027,25 @@ API discovery: `API_1484_11` via the same parent/opener walk.
921
1027
 
922
1028
  **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.
923
1029
 
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.
1030
+ **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).
1031
+
1032
+ **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.
1033
+
1034
+ **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).
1035
+
1036
+ **`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.
1037
+
1038
+ **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()`.
1039
+
1040
+ **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()`.
925
1041
 
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.
1042
+ **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.
927
1043
 
928
- **Sessionid extension.** `cmi5Mode` injects the spec-required `sessionid` context extension on every statement.
1044
+ **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.
929
1045
 
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.
1046
+ **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.
931
1047
 
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.
1048
+ **Not implemented.** No multi-AU courses (one course = one AU in v1). No **Waived** or **Abandoned** verbs (LMS-only). No mid-session actor refresh.
933
1049
 
934
1050
  **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.
935
1051
 
@@ -207,7 +207,8 @@ function generateManifest(pagesDir) {
207
207
  title: pageConfig.title || titleCase(pageSlug),
208
208
  slug: pageSlug,
209
209
  importPath: relativePath,
210
- quiz: pageConfig.quiz || null
210
+ quiz: pageConfig.quiz || null,
211
+ ...pageConfig.completesOn === "view" ? { completesOn: "view" } : {}
211
212
  };
212
213
  lesson.pages.push(page);
213
214
  flatPages.push(page);
@@ -305,13 +306,19 @@ const KNOWN_CONFIG_FIELDS = new Set([
305
306
  "xapi"
306
307
  ]);
307
308
  const VALID_NAV_MODES = ["free", "sequential"];
308
- const VALID_COMPLETION_MODES = ["quiz", "percentage"];
309
+ const VALID_COMPLETION_MODES = [
310
+ "quiz",
311
+ "percentage",
312
+ "manual"
313
+ ];
309
314
  const VALID_EXPORT_STANDARDS = [
310
315
  "web",
311
316
  "scorm12",
312
317
  "scorm2004",
313
318
  "cmi5"
314
319
  ];
320
+ const VALID_MANUAL_TRIGGERS = ["page"];
321
+ const VALID_REQUIRE_SUCCESS_STATUS = ["passed", "failed"];
315
322
  /**
316
323
  * Validate a Tessera project at the given root.
317
324
  * Returns errors (block build) and warnings (informational).
@@ -355,7 +362,15 @@ function parseConfig(configPath, errors, warnings) {
355
362
  if (!VALID_NAV_MODES.includes(config.navigation.mode)) errors.push(`course.config.js: "navigation.mode" must be "free" or "sequential", got "${config.navigation.mode}"`);
356
363
  }
357
364
  if (config.completion?.mode !== void 0) {
358
- if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) errors.push(`course.config.js: "completion.mode" must be "quiz" or "percentage", got "${config.completion.mode}"`);
365
+ if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) errors.push(`course.config.js: "completion.mode" must be "quiz", "percentage", or "manual", got "${config.completion.mode}"`);
366
+ }
367
+ if (config.completion?.trigger !== void 0) {
368
+ if (config.completion.mode !== "manual") warnings.push(`course.config.js: "completion.trigger" is ignored unless completion.mode is "manual"`);
369
+ else if (!VALID_MANUAL_TRIGGERS.includes(config.completion.trigger)) errors.push(`course.config.js: "completion.trigger" must be "page" or omitted, got "${config.completion.trigger}"`);
370
+ }
371
+ if (config.completion?.requireSuccessStatus !== void 0) {
372
+ if (config.completion.mode !== "manual") warnings.push(`course.config.js: "completion.requireSuccessStatus" is ignored unless completion.mode is "manual"`);
373
+ else if (!VALID_REQUIRE_SUCCESS_STATUS.includes(config.completion.requireSuccessStatus)) errors.push(`course.config.js: "completion.requireSuccessStatus" must be "passed" or "failed" (omit for "unknown"), got "${config.completion.requireSuccessStatus}"`);
359
374
  }
360
375
  if (config.export?.standard !== void 0) {
361
376
  if (!VALID_EXPORT_STANDARDS.includes(config.export.standard)) errors.push(`course.config.js: "export.standard" must be "web", "scorm12", "scorm2004", or "cmi5", got "${config.export.standard}"`);
@@ -490,6 +505,7 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
490
505
  function validatePages(pagesDir, assetsDir, projectRoot) {
491
506
  const errors = [];
492
507
  const warnings = [];
508
+ const pages = [];
493
509
  let totalPages = 0;
494
510
  let totalQuizzes = 0;
495
511
  let hasGradedQuiz = false;
@@ -501,7 +517,8 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
501
517
  warnings,
502
518
  totalPages: 0,
503
519
  totalQuizzes: 0,
504
- hasGradedQuiz: false
520
+ hasGradedQuiz: false,
521
+ pages
505
522
  };
506
523
  }
507
524
  const topLevelEntries = readdirSync(pagesDir);
@@ -522,7 +539,8 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
522
539
  warnings,
523
540
  totalPages: 0,
524
541
  totalQuizzes: 0,
525
- hasGradedQuiz: false
542
+ hasGradedQuiz: false,
543
+ pages
526
544
  };
527
545
  }
528
546
  for (const sectionName of sectionDirs) {
@@ -546,12 +564,25 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
546
564
  const fileRel = relative(projectRoot, filePath);
547
565
  const content = readSourceFileCached(filePath);
548
566
  const pageConfig = validatePageConfig(content, fileRel, errors);
567
+ const navIndex = totalPages;
549
568
  totalPages++;
569
+ let pageHasGradedQuiz = false;
550
570
  if (pageConfig?.quiz) {
551
571
  totalQuizzes++;
552
572
  validateQuizConfig(pageConfig.quiz, fileRel, errors);
553
- if (pageConfig.quiz.graded === true) hasGradedQuiz = true;
573
+ if (pageConfig.quiz.graded === true) {
574
+ hasGradedQuiz = true;
575
+ pageHasGradedQuiz = true;
576
+ }
554
577
  }
578
+ const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
579
+ pages.push({
580
+ fileRel,
581
+ navIndex,
582
+ hasGradedQuiz: pageHasGradedQuiz,
583
+ hasQuiz: !!pageConfig?.quiz,
584
+ completesOnView
585
+ });
555
586
  validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
556
587
  }
557
588
  const lessonDirs = sectionEntries.filter((name) => {
@@ -581,12 +612,25 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
581
612
  const fileRel = relative(projectRoot, filePath);
582
613
  const content = readSourceFileCached(filePath);
583
614
  const pageConfig = validatePageConfig(content, fileRel, errors);
615
+ const navIndex = totalPages;
584
616
  totalPages++;
617
+ let pageHasGradedQuiz = false;
585
618
  if (pageConfig?.quiz) {
586
619
  totalQuizzes++;
587
620
  validateQuizConfig(pageConfig.quiz, fileRel, errors);
588
- if (pageConfig.quiz.graded === true) hasGradedQuiz = true;
621
+ if (pageConfig.quiz.graded === true) {
622
+ hasGradedQuiz = true;
623
+ pageHasGradedQuiz = true;
624
+ }
589
625
  }
626
+ const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
627
+ pages.push({
628
+ fileRel,
629
+ navIndex,
630
+ hasGradedQuiz: pageHasGradedQuiz,
631
+ hasQuiz: !!pageConfig?.quiz,
632
+ completesOnView
633
+ });
590
634
  validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
591
635
  }
592
636
  }
@@ -597,7 +641,8 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
597
641
  warnings,
598
642
  totalPages,
599
643
  totalQuizzes,
600
- hasGradedQuiz
644
+ hasGradedQuiz,
645
+ pages
601
646
  };
602
647
  }
603
648
  function validateMetaFile(metaPath, parentRel, errors) {
@@ -624,6 +669,12 @@ function validatePageConfig(content, fileRel, errors) {
624
669
  if (result.kind === "invalid") errors.push(`${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`);
625
670
  return null;
626
671
  }
672
+ function validateCompletesOn(pageConfig, fileRel, errors) {
673
+ if (!pageConfig || pageConfig.completesOn === void 0) return false;
674
+ if (pageConfig.completesOn === "view") return true;
675
+ errors.push(`${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}`);
676
+ return false;
677
+ }
627
678
  function validateQuizConfig(quiz, fileRel, errors) {
628
679
  if (!quiz || typeof quiz !== "object") return;
629
680
  const cfg = quiz;
@@ -655,6 +706,19 @@ function validateAssetRefs(content, fileRel, assetsDir, warnings, existsCache) {
655
706
  }
656
707
  function crossValidate(config, pageResults, errors, warnings) {
657
708
  if (config.completion?.mode === "quiz" && !pageResults.hasGradedQuiz) errors.push("completion.mode is \"quiz\" but no pages have quiz config with graded: true");
709
+ const isManual = config.completion?.mode === "manual";
710
+ const completesOnPages = pageResults.pages.filter((p) => p.completesOnView);
711
+ if (isManual && config.completion?.trigger === "page" && completesOnPages.length === 0) errors.push("completion.mode is \"manual\" with trigger: \"page\", but no page declares pageConfig.completesOn: \"view\". Either add a completesOn page or remove the trigger field to drop the static check.");
712
+ if (isManual) {
713
+ for (const page of pageResults.pages) if (page.hasGradedQuiz) warnings.push(`${page.fileRel}: quiz.graded is true under completion.mode: "manual". The score will be reported to the LMS for transcripts, but it will not drive completion or success status — \`markComplete()\` / completesOn does. If that's not what you want, set graded: false or change completion.mode.`);
714
+ }
715
+ if (isManual && config.completion?.percentageThreshold !== void 0) warnings.push("course.config.js: \"completion.percentageThreshold\" is ignored under completion.mode: \"manual\"");
716
+ if (!isManual) for (const page of completesOnPages) warnings.push(`${page.fileRel}: pageConfig.completesOn is ignored — completion.mode is "${config.completion?.mode ?? "percentage"}"`);
717
+ for (const page of pageResults.pages) if (page.completesOnView && page.hasQuiz) warnings.push(`${page.fileRel}: completion fires on view, before the quiz can be answered — likely a mistake`);
718
+ if (isManual) {
719
+ const firstPage = pageResults.pages.find((p) => p.navIndex === 0);
720
+ if (firstPage?.completesOnView) warnings.push(`${firstPage.fileRel}: pageConfig.completesOn: "view" is on the first page — the course will complete immediately on launch, before the learner sees any other content.`);
721
+ }
658
722
  if (config.export?.standard === "scorm12") {
659
723
  let visitedChars = 0;
660
724
  for (let i = 0; i < pageResults.totalPages; i++) visitedChars += String(i).length + 1;
@@ -768,16 +832,17 @@ function generateCMI5Xml(config) {
768
832
  const description = escapeXml(config.description || "");
769
833
  const courseId = stableUrn("course", `tessera-course:${config.title || ""}`);
770
834
  const auId = stableUrn("au", `tessera-au:${config.title || ""}`);
771
- const masteryScore = (config.scoring?.passingScore ?? 70) / 100;
835
+ const masteryScore = Number(((config.scoring?.passingScore ?? 70) / 100).toFixed(4));
772
836
  return `<?xml version="1.0" encoding="UTF-8"?>
773
837
  <courseStructure xmlns="https://w3id.org/xapi/profiles/cmi5/v1/CourseStructure.xsd">
774
838
  <course id="${courseId}">
775
839
  <title><langstring lang="en-US">${title}</langstring></title>
776
840
  <description><langstring lang="en-US">${description}</langstring></description>
777
841
  </course>
778
- <au id="${auId}" url="index.html" moveOn="${config.completion?.mode === "quiz" ? "CompletedAndPassed" : "Completed"}" masteryScore="${masteryScore}">
842
+ <au id="${auId}" launchMethod="AnyWindow" moveOn="${config.completion?.mode === "quiz" ? "CompletedAndPassed" : "Completed"}" masteryScore="${masteryScore}">
779
843
  <title><langstring lang="en-US">${title}</langstring></title>
780
844
  <description><langstring lang="en-US">${description}</langstring></description>
845
+ <url>index.html</url>
781
846
  </au>
782
847
  </courseStructure>`;
783
848
  }
@@ -1049,6 +1114,19 @@ mount(App, {
1049
1114
  }
1050
1115
  const VIRTUAL_CONFIG_ID = "virtual:tessera-config";
1051
1116
  const RESOLVED_CONFIG_ID = "\0" + VIRTUAL_CONFIG_ID;
1117
+ function completionDefaults(mode) {
1118
+ if (mode === "manual") return {
1119
+ completion: { mode: "manual" },
1120
+ passingScore: 0
1121
+ };
1122
+ return {
1123
+ completion: {
1124
+ mode: "percentage",
1125
+ percentageThreshold: 100
1126
+ },
1127
+ passingScore: 70
1128
+ };
1129
+ }
1052
1130
  function tesseraConfigPlugin() {
1053
1131
  let projectRoot;
1054
1132
  return {
@@ -1078,6 +1156,7 @@ function tesseraConfigPlugin() {
1078
1156
  userConfig = JSON5.parse(objectStr);
1079
1157
  } catch {}
1080
1158
  }
1159
+ const { completion, passingScore } = completionDefaults(userConfig.completion?.mode);
1081
1160
  const merged = {
1082
1161
  title: userConfig.title || "Untitled Course",
1083
1162
  ...userConfig,
@@ -1086,12 +1165,11 @@ function tesseraConfigPlugin() {
1086
1165
  ...userConfig.navigation
1087
1166
  },
1088
1167
  completion: {
1089
- mode: "percentage",
1090
- percentageThreshold: 100,
1168
+ ...completion,
1091
1169
  ...userConfig.completion
1092
1170
  },
1093
1171
  scoring: {
1094
- passingScore: 70,
1172
+ passingScore,
1095
1173
  ...userConfig.scoring
1096
1174
  },
1097
1175
  export: {