tessera-learn 0.0.3 → 0.0.4

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.
@@ -882,6 +987,7 @@ The runtime translates author intent (page visits, quiz scores, completion, pers
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
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`, `result.duration`, `result.score?` (one-shot per session) |
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
993
  | 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) |
@@ -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;
@@ -1049,6 +1113,19 @@ mount(App, {
1049
1113
  }
1050
1114
  const VIRTUAL_CONFIG_ID = "virtual:tessera-config";
1051
1115
  const RESOLVED_CONFIG_ID = "\0" + VIRTUAL_CONFIG_ID;
1116
+ function completionDefaults(mode) {
1117
+ if (mode === "manual") return {
1118
+ completion: { mode: "manual" },
1119
+ passingScore: 0
1120
+ };
1121
+ return {
1122
+ completion: {
1123
+ mode: "percentage",
1124
+ percentageThreshold: 100
1125
+ },
1126
+ passingScore: 70
1127
+ };
1128
+ }
1052
1129
  function tesseraConfigPlugin() {
1053
1130
  let projectRoot;
1054
1131
  return {
@@ -1078,6 +1155,7 @@ function tesseraConfigPlugin() {
1078
1155
  userConfig = JSON5.parse(objectStr);
1079
1156
  } catch {}
1080
1157
  }
1158
+ const { completion, passingScore } = completionDefaults(userConfig.completion?.mode);
1081
1159
  const merged = {
1082
1160
  title: userConfig.title || "Untitled Course",
1083
1161
  ...userConfig,
@@ -1086,12 +1164,11 @@ function tesseraConfigPlugin() {
1086
1164
  ...userConfig.navigation
1087
1165
  },
1088
1166
  completion: {
1089
- mode: "percentage",
1090
- percentageThreshold: 100,
1167
+ ...completion,
1091
1168
  ...userConfig.completion
1092
1169
  },
1093
1170
  scoring: {
1094
- passingScore: 70,
1171
+ passingScore,
1095
1172
  ...userConfig.scoring
1096
1173
  },
1097
1174
  export: {