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.
@@ -13,6 +13,7 @@ export interface ManifestPage {
13
13
  slug: string;
14
14
  importPath: string;
15
15
  quiz: QuizConfig | null;
16
+ completesOn?: 'view';
16
17
  }
17
18
 
18
19
  export interface ManifestLesson {
@@ -127,7 +128,7 @@ export type PageConfigParseResult =
127
128
  /** No module script, or no `pageConfig =` export. Treat as "no config". */
128
129
  | { kind: 'none' }
129
130
  /** Found and successfully parsed. */
130
- | { kind: 'ok'; value: { title?: string; quiz?: QuizConfig } }
131
+ | { kind: 'ok'; value: { title?: string; quiz?: QuizConfig; completesOn?: 'view' } }
131
132
  /** Found but couldn't parse as a static object literal — non-literal RHS or JSON5 failure. */
132
133
  | { kind: 'invalid' };
133
134
 
@@ -160,7 +161,7 @@ export function parsePageConfigFromSource(content: string): PageConfigParseResul
160
161
  }
161
162
 
162
163
  /** Extract pageConfig from a .svelte file. Throws on parse failure. */
163
- export function extractPageConfig(filePath: string): { title?: string; quiz?: QuizConfig } {
164
+ export function extractPageConfig(filePath: string): { title?: string; quiz?: QuizConfig; completesOn?: 'view' } {
164
165
  const result = parsePageConfigFromSource(readSourceFileCached(filePath));
165
166
  if (result.kind === 'ok') return result.value;
166
167
  if (result.kind === 'invalid') {
@@ -300,7 +301,7 @@ export function generateManifest(pagesDir: string): Manifest {
300
301
  const filePath = resolve(lessonPath, fileName);
301
302
  const pageSlug = deriveSlug(fileName, true);
302
303
 
303
- let pageConfig: { title?: string; quiz?: QuizConfig } = {};
304
+ let pageConfig: { title?: string; quiz?: QuizConfig; completesOn?: 'view' } = {};
304
305
  try {
305
306
  pageConfig = extractPageConfig(filePath);
306
307
  } catch (e) {
@@ -317,6 +318,7 @@ export function generateManifest(pagesDir: string): Manifest {
317
318
  slug: pageSlug,
318
319
  importPath: relativePath,
319
320
  quiz: pageConfig.quiz || null,
321
+ ...(pageConfig.completesOn === 'view' ? { completesOn: 'view' as const } : {}),
320
322
  };
321
323
 
322
324
  lesson.pages.push(page);
@@ -32,8 +32,10 @@ const KNOWN_CONFIG_FIELDS = new Set([
32
32
  ]);
33
33
 
34
34
  const VALID_NAV_MODES = ['free', 'sequential'];
35
- const VALID_COMPLETION_MODES = ['quiz', 'percentage'];
35
+ const VALID_COMPLETION_MODES = ['quiz', 'percentage', 'manual'];
36
36
  const VALID_EXPORT_STANDARDS = ['web', 'scorm12', 'scorm2004', 'cmi5'];
37
+ const VALID_MANUAL_TRIGGERS = ['page'];
38
+ const VALID_REQUIRE_SUCCESS_STATUS = ['passed', 'failed'];
37
39
 
38
40
  // ---------- Main ----------
39
41
 
@@ -75,7 +77,12 @@ export function validateProject(projectRoot: string): ValidationResult {
75
77
  interface ParsedConfig {
76
78
  title?: string;
77
79
  navigation?: { mode?: string };
78
- completion?: { mode?: string; percentageThreshold?: number };
80
+ completion?: {
81
+ mode?: string;
82
+ percentageThreshold?: number;
83
+ trigger?: string;
84
+ requireSuccessStatus?: string;
85
+ };
79
86
  scoring?: { passingScore?: number };
80
87
  export?: { standard?: string };
81
88
  [key: string]: unknown;
@@ -126,7 +133,31 @@ function parseConfig(
126
133
  if (config.completion?.mode !== undefined) {
127
134
  if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) {
128
135
  errors.push(
129
- `course.config.js: "completion.mode" must be "quiz" or "percentage", got "${config.completion.mode}"`
136
+ `course.config.js: "completion.mode" must be "quiz", "percentage", or "manual", got "${config.completion.mode}"`
137
+ );
138
+ }
139
+ }
140
+
141
+ if (config.completion?.trigger !== undefined) {
142
+ if (config.completion.mode !== 'manual') {
143
+ warnings.push(
144
+ `course.config.js: "completion.trigger" is ignored unless completion.mode is "manual"`
145
+ );
146
+ } else if (!VALID_MANUAL_TRIGGERS.includes(config.completion.trigger)) {
147
+ errors.push(
148
+ `course.config.js: "completion.trigger" must be "page" or omitted, got "${config.completion.trigger}"`
149
+ );
150
+ }
151
+ }
152
+
153
+ if (config.completion?.requireSuccessStatus !== undefined) {
154
+ if (config.completion.mode !== 'manual') {
155
+ warnings.push(
156
+ `course.config.js: "completion.requireSuccessStatus" is ignored unless completion.mode is "manual"`
157
+ );
158
+ } else if (!VALID_REQUIRE_SUCCESS_STATUS.includes(config.completion.requireSuccessStatus)) {
159
+ errors.push(
160
+ `course.config.js: "completion.requireSuccessStatus" must be "passed" or "failed" (omit for "unknown"), got "${config.completion.requireSuccessStatus}"`
130
161
  );
131
162
  }
132
163
  }
@@ -452,10 +483,19 @@ function validateSingleXAPIEntry(
452
483
 
453
484
  // ---------- Pages Validation ----------
454
485
 
486
+ interface PageInfo {
487
+ fileRel: string;
488
+ navIndex: number;
489
+ hasGradedQuiz: boolean;
490
+ hasQuiz: boolean;
491
+ completesOnView: boolean;
492
+ }
493
+
455
494
  interface PagesValidationResult extends ValidationResult {
456
495
  totalPages: number;
457
496
  totalQuizzes: number;
458
497
  hasGradedQuiz: boolean;
498
+ pages: PageInfo[];
459
499
  }
460
500
 
461
501
  function validatePages(
@@ -465,6 +505,7 @@ function validatePages(
465
505
  ): PagesValidationResult {
466
506
  const errors: string[] = [];
467
507
  const warnings: string[] = [];
508
+ const pages: PageInfo[] = [];
468
509
  let totalPages = 0;
469
510
  let totalQuizzes = 0;
470
511
  let hasGradedQuiz = false;
@@ -475,7 +516,7 @@ function validatePages(
475
516
  errors.push(
476
517
  'No pages found. Create at least one section with a lesson and page in pages/'
477
518
  );
478
- return { errors, warnings, totalPages: 0, totalQuizzes: 0, hasGradedQuiz: false };
519
+ return { errors, warnings, totalPages: 0, totalQuizzes: 0, hasGradedQuiz: false, pages };
479
520
  }
480
521
 
481
522
  const topLevelEntries = readdirSync(pagesDir);
@@ -503,7 +544,7 @@ function validatePages(
503
544
  errors.push(
504
545
  'No pages found. Create at least one section with a lesson and page in pages/'
505
546
  );
506
- return { errors, warnings, totalPages: 0, totalQuizzes: 0, hasGradedQuiz: false };
547
+ return { errors, warnings, totalPages: 0, totalQuizzes: 0, hasGradedQuiz: false, pages };
507
548
  }
508
549
 
509
550
  for (const sectionName of sectionDirs) {
@@ -545,16 +586,28 @@ function validatePages(
545
586
  const content = readSourceFileCached(filePath);
546
587
 
547
588
  const pageConfig = validatePageConfig(content, fileRel, errors);
589
+ const navIndex = totalPages;
548
590
  totalPages++;
549
591
 
592
+ let pageHasGradedQuiz = false;
550
593
  if (pageConfig?.quiz) {
551
594
  totalQuizzes++;
552
595
  validateQuizConfig(pageConfig.quiz, fileRel, errors);
553
596
  if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
554
597
  hasGradedQuiz = true;
598
+ pageHasGradedQuiz = true;
555
599
  }
556
600
  }
557
601
 
602
+ const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
603
+ pages.push({
604
+ fileRel,
605
+ navIndex,
606
+ hasGradedQuiz: pageHasGradedQuiz,
607
+ hasQuiz: !!pageConfig?.quiz,
608
+ completesOnView,
609
+ });
610
+
558
611
  validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
559
612
  }
560
613
 
@@ -615,8 +668,10 @@ function validatePages(
615
668
  const content = readSourceFileCached(filePath);
616
669
 
617
670
  const pageConfig = validatePageConfig(content, fileRel, errors);
671
+ const navIndex = totalPages;
618
672
  totalPages++;
619
673
 
674
+ let pageHasGradedQuiz = false;
620
675
  if (pageConfig?.quiz) {
621
676
  totalQuizzes++;
622
677
 
@@ -625,9 +680,19 @@ function validatePages(
625
680
 
626
681
  if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
627
682
  hasGradedQuiz = true;
683
+ pageHasGradedQuiz = true;
628
684
  }
629
685
  }
630
686
 
687
+ const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
688
+ pages.push({
689
+ fileRel,
690
+ navIndex,
691
+ hasGradedQuiz: pageHasGradedQuiz,
692
+ hasQuiz: !!pageConfig?.quiz,
693
+ completesOnView,
694
+ });
695
+
631
696
  // Check $assets references
632
697
  validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
633
698
  }
@@ -640,7 +705,7 @@ function validatePages(
640
705
  );
641
706
  }
642
707
 
643
- return { errors, warnings, totalPages, totalQuizzes, hasGradedQuiz };
708
+ return { errors, warnings, totalPages, totalQuizzes, hasGradedQuiz, pages };
644
709
  }
645
710
 
646
711
  // ---------- _meta.js Validation ----------
@@ -681,7 +746,7 @@ function validatePageConfig(
681
746
  content: string,
682
747
  fileRel: string,
683
748
  errors: string[]
684
- ): { title?: string; quiz?: unknown } | null {
749
+ ): { title?: string; quiz?: unknown; completesOn?: unknown } | null {
685
750
  const result = parsePageConfigFromSource(content);
686
751
  if (result.kind === 'ok') return result.value;
687
752
  if (result.kind === 'invalid') {
@@ -692,6 +757,19 @@ function validatePageConfig(
692
757
  return null;
693
758
  }
694
759
 
760
+ function validateCompletesOn(
761
+ pageConfig: { completesOn?: unknown } | null,
762
+ fileRel: string,
763
+ errors: string[]
764
+ ): boolean {
765
+ if (!pageConfig || pageConfig.completesOn === undefined) return false;
766
+ if (pageConfig.completesOn === 'view') return true;
767
+ errors.push(
768
+ `${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}`
769
+ );
770
+ return false;
771
+ }
772
+
695
773
  // ---------- Quiz Config Validation ----------
696
774
 
697
775
  function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): void {
@@ -766,6 +844,58 @@ function crossValidate(
766
844
  );
767
845
  }
768
846
 
847
+ const isManual = config.completion?.mode === 'manual';
848
+ const completesOnPages = pageResults.pages.filter((p) => p.completesOnView);
849
+
850
+ if (isManual && config.completion?.trigger === 'page' && completesOnPages.length === 0) {
851
+ errors.push(
852
+ 'completion.mode is "manual" with trigger: "page", but no page declares pageConfig.completesOn: "view". ' +
853
+ 'Either add a completesOn page or remove the trigger field to drop the static check.'
854
+ );
855
+ }
856
+
857
+ if (isManual) {
858
+ for (const page of pageResults.pages) {
859
+ if (page.hasGradedQuiz) {
860
+ warnings.push(
861
+ `${page.fileRel}: quiz.graded is true under completion.mode: "manual". ` +
862
+ 'The score will be reported to the LMS for transcripts, but it will not drive ' +
863
+ 'completion or success status — `markComplete()` / completesOn does. If that\'s ' +
864
+ 'not what you want, set graded: false or change completion.mode.'
865
+ );
866
+ }
867
+ }
868
+ }
869
+
870
+ if (isManual && config.completion?.percentageThreshold !== undefined) {
871
+ warnings.push(
872
+ 'course.config.js: "completion.percentageThreshold" is ignored under completion.mode: "manual"'
873
+ );
874
+ }
875
+ if (!isManual) {
876
+ for (const page of completesOnPages) {
877
+ warnings.push(
878
+ `${page.fileRel}: pageConfig.completesOn is ignored — completion.mode is "${config.completion?.mode ?? 'percentage'}"`
879
+ );
880
+ }
881
+ }
882
+ for (const page of pageResults.pages) {
883
+ if (page.completesOnView && page.hasQuiz) {
884
+ warnings.push(
885
+ `${page.fileRel}: completion fires on view, before the quiz can be answered — likely a mistake`
886
+ );
887
+ }
888
+ }
889
+
890
+ if (isManual) {
891
+ const firstPage = pageResults.pages.find((p) => p.navIndex === 0);
892
+ if (firstPage?.completesOnView) {
893
+ warnings.push(
894
+ `${firstPage.fileRel}: pageConfig.completesOn: "view" is on the first page — the course will complete immediately on launch, before the learner sees any other content.`
895
+ );
896
+ }
897
+ }
898
+
769
899
  // SCORM 1.2 + high page count warning
770
900
  if (config.export?.standard === 'scorm12') {
771
901
  // Estimate worst-case suspend_data size when all pages are visited, all
@@ -102,8 +102,13 @@
102
102
  if (gen !== loadGeneration) return; // stale
103
103
  PageComponent = mod.default;
104
104
  pageLoading = false;
105
- // Mark visited and recalculate
106
105
  progress.markVisited(index);
106
+ if (
107
+ manifest.pages[index].completesOn === 'view' &&
108
+ config.completion.mode === 'manual'
109
+ ) {
110
+ progress.markCompleteManually();
111
+ }
107
112
  progress.recalculateCompletion(manifest, config);
108
113
  progress.recalculateSuccess(manifest, config);
109
114
  }).catch(err => {
@@ -211,6 +216,7 @@
211
216
  s,
212
217
  gs: [...progress.gradedStandalonePages],
213
218
  u: { ...userState },
219
+ ...(progress.manuallyCompleted ? { m: 1 } : {}),
214
220
  };
215
221
  }
216
222
 
@@ -246,6 +252,10 @@
246
252
  }
247
253
  // Restore duration
248
254
  duration = new DurationTracker(saved.d || 0);
255
+ // Must come before recalc so manual-mode branches see the latch.
256
+ if (saved.m === 1) {
257
+ progress.markCompleteManually();
258
+ }
249
259
  // Recalculate derived state
250
260
  progress.recalculateCompletion(manifest, config);
251
261
  progress.recalculateSuccess(manifest, config);
@@ -303,7 +313,10 @@
303
313
 
304
314
  untrack(() => {
305
315
  adapter.setScore(Math.round(average));
306
- adapter.setSuccessStatus(average >= config.scoring.passingScore ? 'passed' : 'failed');
316
+ // Under manual mode, success is owned by requireSuccessStatus.
317
+ if (config.completion.mode !== 'manual') {
318
+ adapter.setSuccessStatus(average >= config.scoring.passingScore ? 'passed' : 'failed');
319
+ }
307
320
  adapter.setDuration(duration.sessionSeconds);
308
321
  adapter.commit();
309
322
  });
@@ -322,8 +335,21 @@
322
335
  });
323
336
  });
324
337
 
338
+ let prevSuccessStatus = $state('unknown');
339
+ $effect(() => {
340
+ const status = progress.successStatus;
341
+ if (!persistenceReady) return;
342
+ if (status === prevSuccessStatus) return;
343
+ prevSuccessStatus = status;
344
+ untrack(() => {
345
+ adapter.setSuccessStatus(status);
346
+ adapter.commit();
347
+ });
348
+ });
349
+
325
350
  // ---- Exit / Terminate lifecycle ----
326
351
  let terminated = false;
352
+ let manualWatchdog = null;
327
353
 
328
354
  function handleExit() {
329
355
  if (terminated) return;
@@ -360,10 +386,23 @@
360
386
  pageLoading = false;
361
387
  return;
362
388
  }
389
+
390
+ // cmi5 §8: an LMS-supplied masteryScore is the authoritative pass
391
+ // threshold for this launch and overrides the manifest. Mutate the
392
+ // imported config object once before any UI reads it so every
393
+ // downstream consumer (recalculateSuccess, navigation gating, Quiz
394
+ // page context) sees the same effective value.
395
+ const lmsMastery = adapter.getMasteryScore?.();
396
+ if (typeof lmsMastery === 'number') {
397
+ config.scoring.passingScore = lmsMastery * 100;
398
+ pageContext.passingScore = lmsMastery * 100;
399
+ }
400
+
363
401
  const saved = adapter.getState();
364
402
  if (saved) {
365
403
  restoreState(saved);
366
404
  prevCompletionStatus = progress.completionStatus;
405
+ prevSuccessStatus = progress.successStatus;
367
406
  }
368
407
  persistenceReady = true;
369
408
 
@@ -390,6 +429,27 @@
390
429
  window.addEventListener('beforeunload', handleExit);
391
430
  const appEl = document.getElementById('tessera-app');
392
431
  appEl?.addEventListener('tessera-quiz-complete', handleQuizComplete);
432
+
433
+ // Dev-only watchdog for `completion.mode: "manual"` without an opt-in
434
+ // trigger check — catches the hook never being called or no completesOn
435
+ // page being reachable.
436
+ if (
437
+ import.meta.env?.DEV &&
438
+ config.completion.mode === 'manual' &&
439
+ config.completion.trigger === undefined &&
440
+ progress.completionStatus === 'incomplete'
441
+ ) {
442
+ manualWatchdog = window.setTimeout(() => {
443
+ if (progress.completionStatus === 'incomplete') {
444
+ console.warn(
445
+ '[tessera] completion.mode is "manual" but the course has not completed after 60s. ' +
446
+ 'No page declared `pageConfig.completesOn: "view"` was reached, and no component called ' +
447
+ '`useCompletion().markComplete()`. This is a misconfiguration; set `completion.trigger: "page"` ' +
448
+ 'in course.config.js to fail the build instead of waiting at runtime.'
449
+ );
450
+ }
451
+ }, 60_000);
452
+ }
393
453
  });
394
454
 
395
455
  onDestroy(() => {
@@ -397,6 +457,10 @@
397
457
  window.removeEventListener('beforeunload', handleExit);
398
458
  const appEl = document.getElementById('tessera-app');
399
459
  appEl?.removeEventListener('tessera-quiz-complete', handleQuizComplete);
460
+ if (manualWatchdog !== null) {
461
+ clearTimeout(manualWatchdog);
462
+ manualWatchdog = null;
463
+ }
400
464
  // Clear the global slot so a stale client from a previous mount
401
465
  // can't leak into a fresh one (matters for tests that re-mount).
402
466
  registerXAPIClient(null);