tessera-learn 0.0.8 → 0.0.10

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.
@@ -27,17 +27,21 @@ function resolveStylesDir(): string {
27
27
  }
28
28
 
29
29
  export function tesseraPlugin() {
30
+ const manifestRef: { current: Manifest | null; root: string } = { current: null, root: '' };
30
31
  return [
31
32
  svelte({
32
- compilerOptions: { css: 'injected' },
33
+ compilerOptions: { css: 'external' },
33
34
  }),
34
35
  tesseraValidationPlugin(),
35
36
  tesseraEntryPlugin(),
36
37
  tesseraConfigPlugin(),
37
38
  tesseraPagesPlugin(),
38
- tesseraManifestPlugin(),
39
+ tesseraManifestPlugin(manifestRef),
39
40
  tesseraLayoutPlugin(),
40
41
  tesseraQuizPlugin(),
42
+ tesseraAdapterPlugin(),
43
+ tesseraXAPISetupPlugin(),
44
+ tesseraFirstPagePreloadPlugin(manifestRef),
41
45
  tesseraExportPlugin(),
42
46
  ];
43
47
  }
@@ -207,6 +211,11 @@ function tesseraConfigPlugin(): Plugin {
207
211
  '$assets': resolve(root, 'assets'),
208
212
  },
209
213
  },
214
+ // tessera-learn ships .ts/.svelte.ts source; Vite's dep optimizer
215
+ // doesn't run vite-plugin-svelte's preprocessor, so skip pre-bundling.
216
+ optimizeDeps: {
217
+ exclude: ['tessera-learn'],
218
+ },
210
219
  };
211
220
  },
212
221
 
@@ -392,15 +401,15 @@ function tesseraExportPlugin(): Plugin {
392
401
  const VIRTUAL_MANIFEST_ID = 'virtual:tessera-manifest';
393
402
  const RESOLVED_MANIFEST_ID = '\0' + VIRTUAL_MANIFEST_ID;
394
403
 
395
- function tesseraManifestPlugin(): Plugin {
404
+ function tesseraManifestPlugin(manifestRef: { current: Manifest | null; root: string }): Plugin {
396
405
  let projectRoot: string;
397
406
  let pagesDir: string;
398
- let currentManifest: Manifest | null = null;
399
407
  let server: ViteDevServer | null = null;
400
408
 
401
409
  function buildManifest(): Manifest {
402
- currentManifest = generateManifest(pagesDir);
403
- return currentManifest;
410
+ const m = generateManifest(pagesDir);
411
+ manifestRef.current = m;
412
+ return m;
404
413
  }
405
414
 
406
415
  return {
@@ -410,6 +419,7 @@ function tesseraManifestPlugin(): Plugin {
410
419
  configResolved(config: ResolvedConfig) {
411
420
  projectRoot = config.root;
412
421
  pagesDir = resolve(projectRoot, 'pages');
422
+ manifestRef.root = projectRoot;
413
423
  },
414
424
 
415
425
  configureServer(devServer: ViteDevServer) {
@@ -427,7 +437,7 @@ function tesseraManifestPlugin(): Plugin {
427
437
  event === 'unlinkDir';
428
438
 
429
439
  if (isRelevant) {
430
- currentManifest = null; // invalidate cache
440
+ manifestRef.current = null; // invalidate cache
431
441
 
432
442
  // Invalidate the virtual module to trigger HMR
433
443
  const mod = devServer.moduleGraph.getModuleById(RESOLVED_MANIFEST_ID);
@@ -452,7 +462,7 @@ function tesseraManifestPlugin(): Plugin {
452
462
 
453
463
  load(id) {
454
464
  if (id === RESOLVED_MANIFEST_ID) {
455
- if (!currentManifest) {
465
+ if (!manifestRef.current) {
456
466
  buildManifest();
457
467
  }
458
468
 
@@ -463,7 +473,7 @@ function tesseraManifestPlugin(): Plugin {
463
473
  // Encode as base64 to prevent Vite's import analysis from
464
474
  // scanning .svelte importPath strings as module imports.
465
475
  // Replace Infinity with 1e9 since JSON.stringify drops it.
466
- const json = JSON.stringify(currentManifest, (_key, value) =>
476
+ const json = JSON.stringify(manifestRef.current, (_key, value) =>
467
477
  value === Infinity ? 1e9 : value
468
478
  );
469
479
  const b64 = Buffer.from(json).toString('base64');
@@ -473,3 +483,168 @@ function tesseraManifestPlugin(): Plugin {
473
483
  },
474
484
  };
475
485
  }
486
+
487
+ const VIRTUAL_ADAPTER_ID = 'virtual:tessera-adapter';
488
+ const RESOLVED_ADAPTER_ID = '\0' + VIRTUAL_ADAPTER_ID;
489
+
490
+ function tesseraAdapterPlugin(): Plugin {
491
+ let projectRoot: string;
492
+ let isBuild = false;
493
+
494
+ return {
495
+ name: 'tessera:adapter',
496
+ enforce: 'pre',
497
+
498
+ configResolved(config: ResolvedConfig) {
499
+ projectRoot = config.root;
500
+ isBuild = config.command === 'build';
501
+ },
502
+
503
+ resolveId(id) {
504
+ if (id === VIRTUAL_ADAPTER_ID) return RESOLVED_ADAPTER_ID;
505
+ return null;
506
+ },
507
+
508
+ load(id) {
509
+ if (id !== RESOLVED_ADAPTER_ID) return null;
510
+
511
+ // In dev, defer to the runtime selector so its WebAdapter fallback
512
+ // for unreachable LMS APIs keeps working.
513
+ if (!isBuild) {
514
+ return `export { createAdapter } from 'tessera-learn/runtime/adapters/index.js';`;
515
+ }
516
+
517
+ let standard = 'web';
518
+ const configPath = resolve(projectRoot, 'course.config.js');
519
+ if (existsSync(configPath)) {
520
+ const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));
521
+ if (objectStr) {
522
+ try {
523
+ const parsed = JSON5.parse(objectStr);
524
+ if (typeof parsed?.export?.standard === 'string') standard = parsed.export.standard;
525
+ } catch {}
526
+ }
527
+ }
528
+
529
+ switch (standard) {
530
+ case 'scorm12':
531
+ return `
532
+ import { SCORM12Adapter } from 'tessera-learn/runtime/adapters/scorm12.js';
533
+ import { findSCORM12API } from 'tessera-learn/runtime/adapters/discovery.js';
534
+ import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
535
+ export function createAdapter() {
536
+ const api = findSCORM12API();
537
+ if (!api) throw new LMSAdapterError('scorm12', 'Tessera: SCORM 1.2 API not found in window.parent/opener chain. Course must be launched from a SCORM 1.2 LMS.');
538
+ return new SCORM12Adapter(api);
539
+ }
540
+ `;
541
+ case 'scorm2004':
542
+ return `
543
+ import { SCORM2004Adapter } from 'tessera-learn/runtime/adapters/scorm2004.js';
544
+ import { findSCORM2004API } from 'tessera-learn/runtime/adapters/discovery.js';
545
+ import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
546
+ export function createAdapter() {
547
+ const api = findSCORM2004API();
548
+ if (!api) throw new LMSAdapterError('scorm2004', 'Tessera: SCORM 2004 API not found in window.parent/opener chain. Course must be launched from a SCORM 2004 LMS.');
549
+ return new SCORM2004Adapter(api);
550
+ }
551
+ `;
552
+ case 'cmi5':
553
+ return `
554
+ import { CMI5Adapter } from 'tessera-learn/runtime/adapters/cmi5.js';
555
+ import { hasCMI5LaunchParams } from 'tessera-learn/runtime/adapters/discovery.js';
556
+ import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
557
+ export function createAdapter() {
558
+ if (!hasCMI5LaunchParams()) throw new LMSAdapterError('cmi5', 'Tessera: cmi5 launch parameters not present on URL. Course must be launched from a cmi5-compliant LMS.');
559
+ return new CMI5Adapter();
560
+ }
561
+ `;
562
+ default:
563
+ return `
564
+ import { WebAdapter } from 'tessera-learn/runtime/adapters/web.js';
565
+ export function createAdapter(config) {
566
+ return new WebAdapter(config);
567
+ }
568
+ `;
569
+ }
570
+ },
571
+ };
572
+ }
573
+
574
+ const VIRTUAL_XAPI_SETUP_ID = 'virtual:tessera-xapi-setup';
575
+ const RESOLVED_XAPI_SETUP_ID = '\0' + VIRTUAL_XAPI_SETUP_ID;
576
+
577
+ function tesseraXAPISetupPlugin(): Plugin {
578
+ let projectRoot: string;
579
+ let isBuild = false;
580
+
581
+ return {
582
+ name: 'tessera:xapi-setup',
583
+ enforce: 'pre',
584
+
585
+ configResolved(config: ResolvedConfig) {
586
+ projectRoot = config.root;
587
+ isBuild = config.command === 'build';
588
+ },
589
+
590
+ resolveId(id) {
591
+ if (id === VIRTUAL_XAPI_SETUP_ID) return RESOLVED_XAPI_SETUP_ID;
592
+ return null;
593
+ },
594
+
595
+ load(id) {
596
+ if (id !== RESOLVED_XAPI_SETUP_ID) return null;
597
+
598
+ if (!isBuild) {
599
+ return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;
600
+ }
601
+
602
+ let standard = 'web';
603
+ let hasXapi = false;
604
+ const configPath = resolve(projectRoot, 'course.config.js');
605
+ if (existsSync(configPath)) {
606
+ const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));
607
+ if (objectStr) {
608
+ try {
609
+ const parsed = JSON5.parse(objectStr);
610
+ if (typeof parsed?.export?.standard === 'string') standard = parsed.export.standard;
611
+ hasXapi = parsed?.xapi != null;
612
+ } catch {}
613
+ }
614
+ }
615
+
616
+ // cmi5 needs the publisher regardless of explicit xapi config (cmi5
617
+ // adapter shares the publisher queue for its own LMS-required statements).
618
+ if (hasXapi || standard === 'cmi5') {
619
+ return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;
620
+ }
621
+
622
+ return `export async function buildXAPIClient() { return null; }`;
623
+ },
624
+ };
625
+ }
626
+
627
+ function tesseraFirstPagePreloadPlugin(manifestRef: { current: Manifest | null; root: string }): Plugin {
628
+ return {
629
+ name: 'tessera:first-page-preload',
630
+ apply: 'build',
631
+ transformIndexHtml: {
632
+ order: 'post',
633
+ handler(_html, ctx) {
634
+ const firstPagePath = manifestRef.current?.pages[0]?.importPath;
635
+ if (!firstPagePath || !ctx.bundle) return;
636
+ const normalized = resolve(manifestRef.root, firstPagePath.replace(/^\//, '')).replace(/\\/g, '/');
637
+ const chunk = Object.values(ctx.bundle).find(
638
+ (c): c is import('vite').Rollup.OutputChunk =>
639
+ c.type === 'chunk' && !!c.facadeModuleId && c.facadeModuleId.replace(/\\/g, '/') === normalized
640
+ );
641
+ if (!chunk) return;
642
+ return [{
643
+ tag: 'link',
644
+ attrs: { rel: 'modulepreload', href: `./${chunk.fileName}` },
645
+ injectTo: 'head',
646
+ }];
647
+ },
648
+ },
649
+ };
650
+ }
@@ -542,7 +542,8 @@ function validatePageFile(
542
542
  if (
543
543
  pageConfig?.quiz &&
544
544
  !HAS_USE_QUESTION_RE.test(content) &&
545
- !HAS_QUESTION_TAG_RE.test(content)
545
+ !HAS_QUESTION_TAG_RE.test(content) &&
546
+ !HAS_LOCAL_SVELTE_IMPORT_RE.test(content)
546
547
  ) {
547
548
  warnings.push(
548
549
  `${fileRel}: quiz page has no question components or useQuestion() calls — ` +
@@ -810,7 +811,7 @@ function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): v
810
811
  }
811
812
  }
812
813
 
813
- for (const field of ['graded', 'gatesProgress', 'showFeedback']) {
814
+ for (const field of ['graded', 'gatesProgress']) {
814
815
  if (cfg[field] !== undefined && typeof cfg[field] !== 'boolean') {
815
816
  errors.push(
816
817
  `${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`
@@ -1041,6 +1042,10 @@ const HAS_USE_QUESTION_RE = /\buseQuestion\s*\(/;
1041
1042
  const HAS_QUESTION_TAG_RE = new RegExp(
1042
1043
  `<(${Object.keys(QUESTION_COMPONENT_REQUIRED).join('|')})(?=[\\s/>])`
1043
1044
  );
1045
+ // Custom widget imported from a local `.svelte` file may wrap useQuestion.
1046
+ // Treat its presence as enough to suppress the "no questions" warning —
1047
+ // false negatives are acceptable for a heuristic that's already advisory.
1048
+ const HAS_LOCAL_SVELTE_IMPORT_RE = /from\s+['"][^'"]+\.svelte['"]/;
1044
1049
 
1045
1050
  /**
1046
1051
  * Detect ways an author file can bypass the LMS data contract. These check
@@ -5,14 +5,14 @@
5
5
  import UserLayout from 'virtual:tessera-layout';
6
6
  import Quiz from 'virtual:tessera-quiz';
7
7
  import { onMount, onDestroy, setContext, untrack } from 'svelte';
8
- import LoadingSkeleton from './LoadingSkeleton.svelte';
8
+ import LoadingBar from './LoadingBar.svelte';
9
9
  import ErrorPage from './ErrorPage.svelte';
10
10
  import DefaultLayout from '../components/DefaultLayout.svelte';
11
11
  import { NavigationState } from './navigation.svelte.js';
12
12
  import { ProgressState } from './progress.svelte.js';
13
13
  import { DurationTracker } from './duration.js';
14
- import { createAdapter } from './adapters/index.js';
15
- import { buildXAPIClient } from './xapi/setup.js';
14
+ import { createAdapter } from 'virtual:tessera-adapter';
15
+ import { buildXAPIClient } from 'virtual:tessera-xapi-setup';
16
16
  import { registerXAPIClient } from './xapi/registry.js';
17
17
  import { TESSERA_PAGE, TESSERA_NAV, TESSERA_ADAPTER, TESSERA_USER_STATE } from './contexts.js';
18
18
 
@@ -24,12 +24,19 @@
24
24
  // can reach it.
25
25
  let xapiClient = null;
26
26
 
27
+ const gradedQuizIndices = new Set(
28
+ manifest.pages.filter(p => p.quiz?.graded).map(p => p.index)
29
+ );
30
+
27
31
  // ---- State classes ----
28
- const progress = new ProgressState();
32
+ const progress = new ProgressState(gradedQuizIndices);
29
33
  const nav = new NavigationState(manifest, progress, config);
34
+ nav.setPageModules(pageModules);
30
35
  let duration = $state(new DurationTracker(0));
31
36
 
32
- const gradedQuizIndices = manifest.pages.filter(p => p.quiz?.graded).map(p => p.index);
37
+ const onIdle = typeof window !== 'undefined' && window.requestIdleCallback
38
+ ? window.requestIdleCallback.bind(window)
39
+ : (cb) => setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 1);
33
40
 
34
41
  // Page loading state
35
42
  let PageComponent = $state(null);
@@ -84,22 +91,20 @@
84
91
 
85
92
  const gen = ++loadGeneration;
86
93
  pageLoading = true;
87
- pageError = null;
88
- PageComponent = null;
89
-
90
- // Update context for the new page
91
- pageContext.quiz = page.quiz;
92
94
 
93
95
  const loader = pageModules[page.importPath];
94
96
  if (!loader) {
95
97
  console.error(`Tessera: No loader for page ${index} at ${page.importPath}`);
96
98
  pageError = new Error(`Page not found: ${page.importPath}`);
99
+ PageComponent = null;
97
100
  pageLoading = false;
98
101
  return;
99
102
  }
100
103
 
101
104
  loader().then(mod => {
102
105
  if (gen !== loadGeneration) return; // stale
106
+ pageError = null;
107
+ pageContext.quiz = page.quiz;
103
108
  PageComponent = mod.default;
104
109
  pageLoading = false;
105
110
  progress.markVisited(index);
@@ -109,8 +114,9 @@
109
114
  ) {
110
115
  progress.markCompleteManually();
111
116
  }
112
- progress.recalculateCompletion(manifest, config);
113
- progress.recalculateSuccess(manifest, config);
117
+ progress.recalculateCompletion(manifest.totalPages, config);
118
+ progress.recalculateSuccess(config);
119
+ onIdle(() => nav.prefetch(index + 1));
114
120
  }).catch(err => {
115
121
  if (gen !== loadGeneration) return; // stale
116
122
  console.error(`Tessera: Failed to load page ${index}`, err);
@@ -132,18 +138,25 @@
132
138
  }
133
139
 
134
140
  // ---- Branding ----
141
+ // Two sentinels so the validity check doesn't false-positive when the
142
+ // input happens to normalize to the initial fillStyle ("#000000").
135
143
  function parseColor(color) {
136
144
  if (typeof CSS !== 'undefined' && CSS.supports && !CSS.supports('color', color)) {
137
145
  return null;
138
146
  }
139
- const el = document.createElement('span');
140
- el.style.color = color;
141
- document.documentElement.appendChild(el);
142
- const computed = getComputedStyle(el).color;
143
- el.remove();
144
- const match = computed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
145
- if (!match) return null;
146
- return { r: +match[1], g: +match[2], b: +match[3] };
147
+ const ctx = document.createElement('canvas').getContext('2d');
148
+ if (!ctx) return null;
149
+ ctx.fillStyle = '#000';
150
+ ctx.fillStyle = color;
151
+ const onBlack = ctx.fillStyle;
152
+ ctx.fillStyle = '#fff';
153
+ ctx.fillStyle = color;
154
+ const onWhite = ctx.fillStyle;
155
+ if (onBlack !== onWhite) return null;
156
+ const hex = String(onBlack).match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
157
+ if (hex) return { r: parseInt(hex[1], 16), g: parseInt(hex[2], 16), b: parseInt(hex[3], 16) };
158
+ const rgba = String(onBlack).match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
159
+ return rgba ? { r: +rgba[1], g: +rgba[2], b: +rgba[3] } : null;
147
160
  }
148
161
 
149
162
  function rgbToHsl(r, g, b) {
@@ -177,18 +190,12 @@
177
190
  }
178
191
  }
179
192
 
180
- // ---- Quiz completion handler ----
181
193
  function handleQuizComplete(e) {
182
- const { score, interactions = [] } = e.detail;
194
+ const { score } = e.detail;
183
195
  const pageIndex = nav.currentPageIndex;
184
196
  progress.quizCompleted(pageIndex, score);
185
- for (const { id, interaction, correct } of interactions) {
186
- adapter.reportInteraction(id, interaction, correct);
187
- }
188
- progress.recalculateCompletion(manifest, config);
189
- progress.recalculateSuccess(manifest, config);
190
- // Persistence is scheduled by the version-tracking effect below; no
191
- // explicit call needed here.
197
+ progress.recalculateCompletion(manifest.totalPages, config);
198
+ progress.recalculateSuccess(config);
192
199
  }
193
200
 
194
201
  // ---- Persistence: serialize / restore ----
@@ -257,8 +264,8 @@
257
264
  progress.markCompleteManually();
258
265
  }
259
266
  // Recalculate derived state
260
- progress.recalculateCompletion(manifest, config);
261
- progress.recalculateSuccess(manifest, config);
267
+ progress.recalculateCompletion(manifest.totalPages, config);
268
+ progress.recalculateSuccess(config);
262
269
  // Navigate to bookmark (after state is restored so locking is correct)
263
270
  if (saved.b > 0 && saved.b < manifest.totalPages) {
264
271
  nav.goToPage(saved.b);
@@ -302,14 +309,21 @@
302
309
  $effect(() => {
303
310
  const scores = progress.quizScores;
304
311
  if (!persistenceReady || scores.size === 0) return;
305
- if (gradedQuizIndices.length === 0) return;
306
-
307
- const completedGraded = gradedQuizIndices.filter(i => scores.has(i));
308
- if (completedGraded.length === 0) return;
312
+ if (gradedQuizIndices.size === 0) return;
313
+
314
+ let sum = 0;
315
+ let attempted = false;
316
+ for (const i of gradedQuizIndices) {
317
+ if (scores.has(i)) {
318
+ sum += scores.get(i) ?? 0;
319
+ attempted = true;
320
+ }
321
+ }
322
+ if (!attempted) return;
309
323
 
310
324
  // Divide by total graded count — incomplete quizzes count as 0, matching
311
325
  // the recalculateSuccess logic in progress.svelte.ts.
312
- const average = completedGraded.reduce((sum, i) => sum + (scores.get(i) ?? 0), 0) / gradedQuizIndices.length;
326
+ const average = sum / gradedQuizIndices.size;
313
327
 
314
328
  untrack(() => {
315
329
  adapter.setScore(Math.round(average));
@@ -403,6 +417,7 @@
403
417
  restoreState(saved);
404
418
  prevCompletionStatus = progress.completionStatus;
405
419
  prevSuccessStatus = progress.successStatus;
420
+ adapter.seedLifecycle?.(progress.completionStatus, progress.successStatus);
406
421
  }
407
422
  persistenceReady = true;
408
423
 
@@ -468,9 +483,7 @@
468
483
  </script>
469
484
 
470
485
  {#snippet page()}
471
- {#if pageLoading}
472
- <LoadingSkeleton />
473
- {:else if pageError}
486
+ {#if pageError}
474
487
  <ErrorPage error={pageError} onretry={retryPage} />
475
488
  {:else if PageComponent}
476
489
  {#if pageContext.quiz}
@@ -484,6 +497,7 @@
484
497
  {/snippet}
485
498
 
486
499
  <div id="tessera-app" data-chrome={chromeMode}>
500
+ <LoadingBar active={pageLoading} />
487
501
  {#if UserLayout}
488
502
  <UserLayout {page} />
489
503
  {:else if chromeMode === 'custom'}
@@ -0,0 +1,47 @@
1
+ <script>
2
+ import { untrack } from 'svelte';
3
+
4
+ let { active = false } = $props();
5
+
6
+ let visible = $state(false);
7
+ let appeared = $state(false);
8
+ let complete = $state(false);
9
+ let showSlowMessage = $state(false);
10
+
11
+ $effect(() => {
12
+ if (active) {
13
+ // Defer the bar so sub-100ms loads never flash. Add `.appear` on the
14
+ // next frame so the CSS transition from width:0 → 90% actually fires.
15
+ const appearTimer = setTimeout(() => {
16
+ visible = true;
17
+ requestAnimationFrame(() => { appeared = true; });
18
+ }, 100);
19
+ const slowTimer = setTimeout(() => { showSlowMessage = true; }, 5000);
20
+ return () => {
21
+ clearTimeout(appearTimer);
22
+ clearTimeout(slowTimer);
23
+ };
24
+ }
25
+
26
+ // Completing. If the bar never appeared we have nothing to finish.
27
+ // untrack so flipping `visible` doesn't re-trigger this effect.
28
+ if (!untrack(() => visible)) return;
29
+ complete = true;
30
+ const hideTimer = setTimeout(() => {
31
+ visible = false;
32
+ appeared = false;
33
+ complete = false;
34
+ showSlowMessage = false;
35
+ }, 220);
36
+ return () => clearTimeout(hideTimer);
37
+ });
38
+ </script>
39
+
40
+ {#if visible}
41
+ <div class="tessera-loading-bar" class:appear={appeared} class:complete aria-hidden="true">
42
+ <div class="tessera-loading-bar-fill"></div>
43
+ </div>
44
+ {#if showSlowMessage}
45
+ <p class="tessera-loading-bar-message" role="status">Still loading…</p>
46
+ {/if}
47
+ {/if}
@@ -60,6 +60,8 @@
60
60
  aria-current={page.index === currentPageIndex ? 'page' : undefined}
61
61
  aria-disabled={locked ? 'true' : undefined}
62
62
  onclick={() => handlePageClick(page.index)}
63
+ onpointerenter={() => !locked && nav.prefetch(page.index)}
64
+ onfocusin={() => !locked && nav.prefetch(page.index)}
63
65
  >
64
66
  {#if locked}
65
67
  <svg class="tessera-nav-lock-icon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" width="12" height="12">
@@ -1,6 +1,10 @@
1
1
  import type { PersistenceAdapter, SavedState } from '../persistence.js';
2
2
  import type { Interaction } from '../interaction.js';
3
- import { formatResponse, formatCorrectPattern } from '../interaction-format.js';
3
+ import {
4
+ formatResponse,
5
+ formatCorrectPattern,
6
+ XAPI_INTERACTION_FORMAT,
7
+ } from '../interaction-format.js';
4
8
  import { formatISO8601Duration } from './retry.js';
5
9
  import { XAPIPublisher } from '../xapi/publisher.js';
6
10
  import { X_API_VERSION } from '../xapi/version.js';
@@ -139,8 +143,8 @@ export class CMI5Adapter implements PersistenceAdapter {
139
143
  #score: number | null = null;
140
144
  #durationSeconds = 0;
141
145
  #state: SavedState | null = null;
142
- #completedSent = false;
143
- #successSent = false;
146
+ #completedEmitted = false;
147
+ #lastSuccessEmitted: 'unknown' | 'passed' | 'failed' = 'unknown';
144
148
  #terminated = false;
145
149
 
146
150
  // cmi5 §8 launch params. masteryScore (when present) overrides the
@@ -240,13 +244,28 @@ export class CMI5Adapter implements PersistenceAdapter {
240
244
  // Basic credential (already base64); we don't re-encode.
241
245
  let token = '';
242
246
  if (text.startsWith('{')) {
247
+ let parsed: unknown;
243
248
  try {
244
- const parsed = JSON.parse(text);
245
- if (parsed && typeof parsed['auth-token'] === 'string') {
246
- token = parsed['auth-token'].trim();
247
- }
249
+ parsed = JSON.parse(text);
248
250
  } catch {
249
- // fall through to legacy parsing
251
+ parsed = undefined;
252
+ }
253
+ if (parsed && typeof parsed === 'object') {
254
+ const obj = parsed as Record<string, unknown>;
255
+ if (typeof obj['auth-token'] === 'string') {
256
+ token = (obj['auth-token'] as string).trim();
257
+ } else {
258
+ const code = typeof obj['error-code'] === 'string' ? obj['error-code'] : undefined;
259
+ const errText = typeof obj['error-text'] === 'string' ? obj['error-text'] : undefined;
260
+ const detail =
261
+ code !== undefined || errText !== undefined
262
+ ? ` (error-code=${code ?? 'unknown'}${errText ? `: ${errText}` : ''})`
263
+ : '';
264
+ throw new Error(
265
+ `Tessera cmi5: fetch URL returned a JSON response without an 'auth-token' field${detail}. ` +
266
+ 'The cmi5 fetch URL is single-use (§8.2.3.1); reload from the LMS to obtain a fresh launch.'
267
+ );
268
+ }
250
269
  }
251
270
  }
252
271
  if (!token) {
@@ -434,11 +453,21 @@ export class CMI5Adapter implements PersistenceAdapter {
434
453
  this.#score = Math.max(0, Math.min(100, score));
435
454
  }
436
455
 
456
+ seedLifecycle(
457
+ completion: 'incomplete' | 'complete',
458
+ success: 'unknown' | 'passed' | 'failed'
459
+ ): void {
460
+ if (completion === 'complete') this.#completedEmitted = true;
461
+ if (success === 'passed' || success === 'failed') {
462
+ this.#lastSuccessEmitted = success;
463
+ }
464
+ }
465
+
437
466
  setCompletionStatus(status: 'incomplete' | 'complete'): void {
438
- if (status !== 'complete' || this.#completedSent || !this.#publisher) return;
467
+ if (status !== 'complete' || this.#completedEmitted || !this.#publisher) return;
439
468
  // cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Completed.
440
469
  if (this.#launchMode !== 'Normal') return;
441
- this.#completedSent = true;
470
+ this.#completedEmitted = true;
442
471
  // cmi5 §9.5.1 — `score` MUST NOT appear on Completed (Passed/Failed only).
443
472
  const result: Record<string, unknown> = {
444
473
  completion: true,
@@ -457,10 +486,11 @@ export class CMI5Adapter implements PersistenceAdapter {
457
486
  }
458
487
 
459
488
  setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
460
- if (status === 'unknown' || this.#successSent || !this.#publisher) return;
489
+ if (status === 'unknown' || !this.#publisher) return;
490
+ if (status === this.#lastSuccessEmitted) return;
461
491
  // cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Passed/Failed.
462
492
  if (this.#launchMode !== 'Normal') return;
463
- this.#successSent = true;
493
+ this.#lastSuccessEmitted = status;
464
494
 
465
495
  const verb = status === 'passed' ? VERBS.passed : VERBS.failed;
466
496
  const verbName = status === 'passed' ? 'passed' : 'failed';
@@ -518,8 +548,8 @@ export class CMI5Adapter implements PersistenceAdapter {
518
548
  correct: boolean | null
519
549
  ): void {
520
550
  if (!this.#publisher) return;
521
- const response = formatResponse(interaction);
522
- const pattern = formatCorrectPattern(interaction);
551
+ const response = formatResponse(interaction, XAPI_INTERACTION_FORMAT);
552
+ const pattern = formatCorrectPattern(interaction, XAPI_INTERACTION_FORMAT);
523
553
  const definition: Record<string, unknown> = {
524
554
  type: CMI_INTERACTION_TYPE,
525
555
  interactionType: interaction.type,