tessera-learn 0.0.11 → 0.0.13

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.
Files changed (75) hide show
  1. package/README.md +1 -0
  2. package/dist/audit-BBJpQGqb.js +204 -0
  3. package/dist/audit-BBJpQGqb.js.map +1 -0
  4. package/dist/plugin/a11y-cli.d.ts +1 -0
  5. package/dist/plugin/a11y-cli.js +36 -0
  6. package/dist/plugin/a11y-cli.js.map +1 -0
  7. package/dist/plugin/cli.js +2 -1
  8. package/dist/plugin/cli.js.map +1 -1
  9. package/dist/plugin/index.d.ts +16 -1
  10. package/dist/plugin/index.d.ts.map +1 -1
  11. package/dist/plugin/index.js +85 -10
  12. package/dist/plugin/index.js.map +1 -1
  13. package/dist/{validation-D9DXlqNP.js → validation-B-xTvM9B.js} +342 -18
  14. package/dist/validation-B-xTvM9B.js.map +1 -0
  15. package/package.json +17 -2
  16. package/src/components/Accordion.svelte +3 -1
  17. package/src/components/AccordionItem.svelte +1 -5
  18. package/src/components/Audio.svelte +17 -3
  19. package/src/components/Callout.svelte +5 -1
  20. package/src/components/Carousel.svelte +24 -8
  21. package/src/components/DefaultLayout.svelte +41 -12
  22. package/src/components/FillInTheBlank.svelte +16 -6
  23. package/src/components/Image.svelte +12 -3
  24. package/src/components/LockedBanner.svelte +2 -1
  25. package/src/components/Matching.svelte +48 -19
  26. package/src/components/MediaTracks.svelte +21 -0
  27. package/src/components/MultipleChoice.svelte +33 -13
  28. package/src/components/Quiz.svelte +61 -20
  29. package/src/components/ResultIcon.svelte +20 -4
  30. package/src/components/RevealModal.svelte +25 -22
  31. package/src/components/Sorting.svelte +61 -26
  32. package/src/components/Transcript.svelte +37 -0
  33. package/src/components/Video.svelte +21 -18
  34. package/src/components/util.ts +3 -1
  35. package/src/components/video-embed.ts +25 -0
  36. package/src/index.ts +2 -7
  37. package/src/plugin/a11y/audit.ts +299 -0
  38. package/src/plugin/a11y/contrast.ts +67 -0
  39. package/src/plugin/a11y-cli.ts +35 -0
  40. package/src/plugin/cli.ts +4 -1
  41. package/src/plugin/export.ts +42 -14
  42. package/src/plugin/index.ts +216 -44
  43. package/src/plugin/manifest.ts +62 -22
  44. package/src/plugin/validation.ts +736 -122
  45. package/src/runtime/App.svelte +119 -48
  46. package/src/runtime/LoadingBar.svelte +12 -3
  47. package/src/runtime/Sidebar.svelte +24 -8
  48. package/src/runtime/access.ts +15 -3
  49. package/src/runtime/adapters/cmi5.ts +55 -33
  50. package/src/runtime/adapters/index.ts +22 -10
  51. package/src/runtime/adapters/retry.ts +25 -20
  52. package/src/runtime/adapters/scorm-base.ts +19 -15
  53. package/src/runtime/adapters/scorm12.ts +7 -8
  54. package/src/runtime/adapters/scorm2004.ts +11 -14
  55. package/src/runtime/adapters/web.ts +1 -1
  56. package/src/runtime/hooks.svelte.ts +152 -326
  57. package/src/runtime/interaction-format.ts +30 -12
  58. package/src/runtime/interaction.ts +44 -11
  59. package/src/runtime/navigation.svelte.ts +27 -11
  60. package/src/runtime/persistence.ts +2 -2
  61. package/src/runtime/progress.svelte.ts +13 -9
  62. package/src/runtime/quiz-engine.svelte.ts +361 -0
  63. package/src/runtime/quiz-policy.ts +9 -3
  64. package/src/runtime/types.ts +24 -2
  65. package/src/runtime/xapi/agent-rules.ts +4 -1
  66. package/src/runtime/xapi/client.ts +5 -5
  67. package/src/runtime/xapi/derive-actor.ts +2 -2
  68. package/src/runtime/xapi/publisher.ts +32 -29
  69. package/src/runtime/xapi/setup.ts +18 -15
  70. package/src/runtime/xapi/validation.ts +15 -6
  71. package/src/virtual.d.ts +4 -1
  72. package/styles/base.css +32 -11
  73. package/styles/layout.css +39 -18
  74. package/styles/theme.css +15 -3
  75. package/dist/validation-D9DXlqNP.js.map +0 -1
@@ -14,7 +14,12 @@
14
14
  import { createAdapter } from 'virtual:tessera-adapter';
15
15
  import { buildXAPIClient } from 'virtual:tessera-xapi-setup';
16
16
  import { registerXAPIClient } from './xapi/registry.js';
17
- import { TESSERA_PAGE, TESSERA_NAV, TESSERA_ADAPTER, TESSERA_USER_STATE } from './contexts.js';
17
+ import {
18
+ TESSERA_PAGE,
19
+ TESSERA_NAV,
20
+ TESSERA_ADAPTER,
21
+ TESSERA_USER_STATE,
22
+ } from './contexts.js';
18
23
 
19
24
  // ---- Persistence ----
20
25
  const adapter = createAdapter(config);
@@ -25,18 +30,28 @@
25
30
  let xapiClient = null;
26
31
 
27
32
  const gradedQuizIndices = new Set(
28
- manifest.pages.filter(p => p.quiz?.graded).map(p => p.index)
33
+ manifest.pages.filter((p) => p.quiz?.graded).map((p) => p.index),
29
34
  );
30
35
 
31
36
  // ---- State classes ----
37
+ // The Tier-2 auditor appends ?__tessera_audit to unlock navigation so it can
38
+ // scan every page, including ones gated behind a quiz.
39
+ const auditMode =
40
+ typeof window !== 'undefined' &&
41
+ new URLSearchParams(window.location.search).has('__tessera_audit');
32
42
  const progress = new ProgressState(gradedQuizIndices);
33
- const nav = new NavigationState(manifest, progress, config);
43
+ const nav = new NavigationState(manifest, progress, config, auditMode);
34
44
  nav.setPageModules(pageModules);
35
45
  let duration = $state(new DurationTracker(0));
36
46
 
37
- const onIdle = typeof window !== 'undefined' && window.requestIdleCallback
38
- ? window.requestIdleCallback.bind(window)
39
- : (cb) => setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 1);
47
+ const onIdle =
48
+ typeof window !== 'undefined' && window.requestIdleCallback
49
+ ? window.requestIdleCallback.bind(window)
50
+ : (cb) =>
51
+ setTimeout(
52
+ () => cb({ didTimeout: false, timeRemaining: () => 50 }),
53
+ 1,
54
+ );
40
55
 
41
56
  // Page loading state
42
57
  let PageComponent = $state(null);
@@ -45,7 +60,10 @@
45
60
  let retryKey = $state(0);
46
61
 
47
62
  // ---- Page context (reactive, read by Quiz in Step 8) ----
48
- let pageContext = $state({ quiz: null, passingScore: config.scoring?.passingScore ?? 70 });
63
+ let pageContext = $state({
64
+ quiz: null,
65
+ passingScore: config.scoring?.passingScore ?? 70,
66
+ });
49
67
  setContext(TESSERA_PAGE, pageContext);
50
68
 
51
69
  // ---- Navigation context (read by custom chrome components) ----
@@ -54,7 +72,11 @@
54
72
  setContext(TESSERA_NAV, { nav, manifest, progress, config });
55
73
 
56
74
  // ---- Adapter context (read by useQuestion / usePersistence) ----
57
- setContext(TESSERA_ADAPTER, { get adapter() { return adapter; } });
75
+ setContext(TESSERA_ADAPTER, {
76
+ get adapter() {
77
+ return adapter;
78
+ },
79
+ });
58
80
 
59
81
  // ---- User-scoped state (read/written by usePersistence) ----
60
82
  // Each call site namespaces under its own key. Persisted to SavedState.u.
@@ -74,7 +96,9 @@
74
96
  // Otherwise: "default" renders the built-in DefaultLayout; "custom" hides
75
97
  // the chrome entirely so a course-owned shell can take over.
76
98
  if (UserLayout && config.chrome === 'custom' && import.meta.env?.DEV) {
77
- console.warn('[tessera] Both layout.svelte and chrome: "custom" are set. layout.svelte wins.');
99
+ console.warn(
100
+ '[tessera] Both layout.svelte and chrome: "custom" are set. layout.svelte wins.',
101
+ );
78
102
  }
79
103
  const chromeMode = UserLayout
80
104
  ? 'user'
@@ -94,35 +118,39 @@
94
118
 
95
119
  const loader = pageModules[page.importPath];
96
120
  if (!loader) {
97
- console.error(`Tessera: No loader for page ${index} at ${page.importPath}`);
121
+ console.error(
122
+ `Tessera: No loader for page ${index} at ${page.importPath}`,
123
+ );
98
124
  pageError = new Error(`Page not found: ${page.importPath}`);
99
125
  PageComponent = null;
100
126
  pageLoading = false;
101
127
  return;
102
128
  }
103
129
 
104
- loader().then(mod => {
105
- if (gen !== loadGeneration) return; // stale
106
- pageError = null;
107
- pageContext.quiz = page.quiz;
108
- PageComponent = mod.default;
109
- pageLoading = false;
110
- progress.markVisited(index);
111
- if (
112
- manifest.pages[index].completesOn === 'view' &&
113
- config.completion.mode === 'manual'
114
- ) {
115
- progress.markCompleteManually();
116
- }
117
- progress.recalculateCompletion(manifest.totalPages, config);
118
- progress.recalculateSuccess(config);
119
- onIdle(() => nav.prefetch(index + 1));
120
- }).catch(err => {
121
- if (gen !== loadGeneration) return; // stale
122
- console.error(`Tessera: Failed to load page ${index}`, err);
123
- pageError = err;
124
- pageLoading = false;
125
- });
130
+ loader()
131
+ .then((mod) => {
132
+ if (gen !== loadGeneration) return; // stale
133
+ pageError = null;
134
+ pageContext.quiz = page.quiz;
135
+ PageComponent = mod.default;
136
+ pageLoading = false;
137
+ progress.markVisited(index);
138
+ if (
139
+ manifest.pages[index].completesOn === 'view' &&
140
+ config.completion.mode === 'manual'
141
+ ) {
142
+ progress.markCompleteManually();
143
+ }
144
+ progress.recalculateCompletion(manifest.totalPages, config);
145
+ progress.recalculateSuccess(config);
146
+ onIdle(() => nav.prefetch(index + 1));
147
+ })
148
+ .catch((err) => {
149
+ if (gen !== loadGeneration) return; // stale
150
+ console.error(`Tessera: Failed to load page ${index}`, err);
151
+ pageError = err;
152
+ pageLoading = false;
153
+ });
126
154
  }
127
155
 
128
156
  // React to page index changes
@@ -141,7 +169,11 @@
141
169
  // Two sentinels so the validity check doesn't false-positive when the
142
170
  // input happens to normalize to the initial fillStyle ("#000000").
143
171
  function parseColor(color) {
144
- if (typeof CSS !== 'undefined' && CSS.supports && !CSS.supports('color', color)) {
172
+ if (
173
+ typeof CSS !== 'undefined' &&
174
+ CSS.supports &&
175
+ !CSS.supports('color', color)
176
+ ) {
145
177
  return null;
146
178
  }
147
179
  const ctx = document.createElement('canvas').getContext('2d');
@@ -153,16 +185,30 @@
153
185
  ctx.fillStyle = color;
154
186
  const onWhite = ctx.fillStyle;
155
187
  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+)/);
188
+ const hex = String(onBlack).match(
189
+ /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i,
190
+ );
191
+ if (hex)
192
+ return {
193
+ r: parseInt(hex[1], 16),
194
+ g: parseInt(hex[2], 16),
195
+ b: parseInt(hex[3], 16),
196
+ };
197
+ const rgba = String(onBlack).match(
198
+ /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/,
199
+ );
159
200
  return rgba ? { r: +rgba[1], g: +rgba[2], b: +rgba[3] } : null;
160
201
  }
161
202
 
162
203
  function rgbToHsl(r, g, b) {
163
- r /= 255; g /= 255; b /= 255;
164
- const max = Math.max(r, g, b), min = Math.min(r, g, b);
165
- let h = 0, s = 0, l = (max + min) / 2;
204
+ r /= 255;
205
+ g /= 255;
206
+ b /= 255;
207
+ const max = Math.max(r, g, b),
208
+ min = Math.min(r, g, b);
209
+ let h = 0,
210
+ s = 0,
211
+ l = (max + min) / 2;
166
212
  if (max !== min) {
167
213
  const d = max - min;
168
214
  s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
@@ -170,7 +216,11 @@
170
216
  else if (max === g) h = ((b - r) / d + 2) / 6;
171
217
  else h = ((r - g) / d + 4) / 6;
172
218
  }
173
- return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
219
+ return {
220
+ h: Math.round(h * 360),
221
+ s: Math.round(s * 100),
222
+ l: Math.round(l * 100),
223
+ };
174
224
  }
175
225
 
176
226
  function applyBranding(cfg) {
@@ -180,9 +230,18 @@
180
230
  const rgb = parseColor(cfg.branding.primaryColor);
181
231
  if (rgb) {
182
232
  const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
183
- el.style.setProperty('--tessera-primary-light', `hsl(${hsl.h}, ${Math.min(hsl.s + 10, 100)}%, 90%)`);
184
- el.style.setProperty('--tessera-primary-dark', `hsl(${hsl.h}, ${Math.min(hsl.s + 10, 100)}%, ${Math.max(hsl.l - 15, 10)}%)`);
185
- el.style.setProperty('--tessera-focus-ring', `0 0 0 3px rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.4)`);
233
+ el.style.setProperty(
234
+ '--tessera-primary-light',
235
+ `hsl(${hsl.h}, ${Math.min(hsl.s + 10, 100)}%, 90%)`,
236
+ );
237
+ el.style.setProperty(
238
+ '--tessera-primary-dark',
239
+ `hsl(${hsl.h}, ${Math.min(hsl.s + 10, 100)}%, ${Math.max(hsl.l - 15, 10)}%)`,
240
+ );
241
+ el.style.setProperty(
242
+ '--tessera-focus-ring',
243
+ `0 0 0 3px rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.4)`,
244
+ );
186
245
  }
187
246
  }
188
247
  if (cfg.branding?.fontFamily) {
@@ -249,7 +308,12 @@
249
308
  for (const [pageKey, questions] of Object.entries(saved.s)) {
250
309
  const pageIndex = Number(pageKey);
251
310
  for (const [qid, score] of Object.entries(questions)) {
252
- progress.markStandaloneQuestion(pageIndex, qid, Number(score), gradedSet.has(pageIndex));
311
+ progress.markStandaloneQuestion(
312
+ pageIndex,
313
+ qid,
314
+ Number(score),
315
+ gradedSet.has(pageIndex),
316
+ );
253
317
  }
254
318
  }
255
319
  }
@@ -322,7 +386,9 @@
322
386
  adapter.setScore(rounded);
323
387
  // Under manual mode, success is owned by requireSuccessStatus.
324
388
  if (config.completion.mode !== 'manual') {
325
- adapter.setSuccessStatus(average >= config.scoring.passingScore ? 'passed' : 'failed');
389
+ adapter.setSuccessStatus(
390
+ average >= config.scoring.passingScore ? 'passed' : 'failed',
391
+ );
326
392
  }
327
393
  adapter.setDuration(duration.sessionSeconds);
328
394
  adapter.commit();
@@ -366,7 +432,9 @@
366
432
  // Tell SCORM whether this is a suspend-to-resume close or a normal
367
433
  // exit. cmi5/web adapters no-op. Must come before terminate() so the
368
434
  // value is committed in the same flush.
369
- adapter.setExit(progress.completionStatus === 'complete' ? 'normal' : 'suspend');
435
+ adapter.setExit(
436
+ progress.completionStatus === 'complete' ? 'normal' : 'suspend',
437
+ );
370
438
  adapter.commit();
371
439
  // Stop accepting author-issued statements on independent destinations
372
440
  // before terminate() so a late `useXAPI().sendStatement(...)` from a
@@ -410,7 +478,10 @@
410
478
  restoreState(saved);
411
479
  prevCompletionStatus = progress.completionStatus;
412
480
  prevSuccessStatus = progress.successStatus;
413
- adapter.seedLifecycle?.(progress.completionStatus, progress.successStatus);
481
+ adapter.seedLifecycle?.(
482
+ progress.completionStatus,
483
+ progress.successStatus,
484
+ );
414
485
  }
415
486
  persistenceReady = true;
416
487
 
@@ -453,7 +524,7 @@
453
524
  '[tessera] completion.mode is "manual" but the course has not completed after 60s. ' +
454
525
  'No page declared `pageConfig.completesOn: "view"` was reached, and no component called ' +
455
526
  '`useCompletion().markComplete()`. This is a misconfiguration; set `completion.trigger: "page"` ' +
456
- 'in course.config.js to fail the build instead of waiting at runtime.'
527
+ 'in course.config.js to fail the build instead of waiting at runtime.',
457
528
  );
458
529
  }
459
530
  }, 60_000);
@@ -14,9 +14,13 @@
14
14
  // next frame so the CSS transition from width:0 → 90% actually fires.
15
15
  const appearTimer = setTimeout(() => {
16
16
  visible = true;
17
- requestAnimationFrame(() => { appeared = true; });
17
+ requestAnimationFrame(() => {
18
+ appeared = true;
19
+ });
18
20
  }, 100);
19
- const slowTimer = setTimeout(() => { showSlowMessage = true; }, 5000);
21
+ const slowTimer = setTimeout(() => {
22
+ showSlowMessage = true;
23
+ }, 5000);
20
24
  return () => {
21
25
  clearTimeout(appearTimer);
22
26
  clearTimeout(slowTimer);
@@ -38,7 +42,12 @@
38
42
  </script>
39
43
 
40
44
  {#if visible}
41
- <div class="tessera-loading-bar" class:appear={appeared} class:complete aria-hidden="true">
45
+ <div
46
+ class="tessera-loading-bar"
47
+ class:appear={appeared}
48
+ class:complete
49
+ aria-hidden="true"
50
+ >
42
51
  <div class="tessera-loading-bar-fill"></div>
43
52
  </div>
44
53
  {#if showSlowMessage}
@@ -1,5 +1,6 @@
1
1
  <script>
2
- let { manifest, config, currentPageIndex, nav, onnavigate, onclose } = $props();
2
+ let { manifest, config, currentPageIndex, nav, onnavigate, onclose } =
3
+ $props();
3
4
 
4
5
  // Track which sections are collapsed. All expanded by default.
5
6
  let collapsedSections = $state(new Set());
@@ -22,13 +23,17 @@
22
23
 
23
24
  <div class="tessera-sidebar-header">
24
25
  {#if config.branding?.logo}
25
- <img src={config.branding.logo} alt={config.title} class="tessera-sidebar-logo" />
26
+ <img
27
+ src={config.branding.logo}
28
+ alt={config.title}
29
+ class="tessera-sidebar-logo"
30
+ />
26
31
  {/if}
27
32
  <h1 class="tessera-sidebar-title">{config.title || '(no title)'}</h1>
28
33
  </div>
29
34
 
30
35
  <nav class="tessera-sidebar-nav" aria-label="Course navigation">
31
- {#each manifest.sections as section}
36
+ {#each manifest.sections as section (section.slug)}
32
37
  <div class="tessera-nav-section">
33
38
  <button
34
39
  class="tessera-nav-section-title"
@@ -50,22 +55,33 @@
50
55
  </button>
51
56
 
52
57
  {#if !collapsedSections.has(section.slug)}
53
- {#each section.lessons as lesson}
58
+ {#each section.lessons as lesson (lesson.slug)}
54
59
  <div class="tessera-nav-lesson-title">{lesson.title}</div>
55
- {#each lesson.pages as page}
60
+ {#each lesson.pages as page (page.index)}
56
61
  {@const locked = nav.isPageLocked(page.index)}
57
62
  <button
58
63
  class="tessera-nav-page"
59
64
  class:locked
60
- aria-current={page.index === currentPageIndex ? 'page' : undefined}
65
+ aria-current={page.index === currentPageIndex
66
+ ? 'page'
67
+ : undefined}
61
68
  aria-disabled={locked ? 'true' : undefined}
62
69
  onclick={() => handlePageClick(page.index)}
63
70
  onpointerenter={() => !locked && nav.prefetch(page.index)}
64
71
  onfocusin={() => !locked && nav.prefetch(page.index)}
65
72
  >
66
73
  {#if locked}
67
- <svg class="tessera-nav-lock-icon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" width="12" height="12">
68
- <path d="M8 1a4 4 0 0 0-4 4v2H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1h-1V5a4 4 0 0 0-4-4zm-2 4a2 2 0 1 1 4 0v2H6V5z"/>
74
+ <svg
75
+ class="tessera-nav-lock-icon"
76
+ viewBox="0 0 16 16"
77
+ fill="currentColor"
78
+ aria-hidden="true"
79
+ width="12"
80
+ height="12"
81
+ >
82
+ <path
83
+ d="M8 1a4 4 0 0 0-4 4v2H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1h-1V5a4 4 0 0 0-4-4zm-2 4a2 2 0 1 1 4 0v2H6V5z"
84
+ />
69
85
  </svg>
70
86
  {/if}
71
87
  {page.title}
@@ -24,7 +24,12 @@ export type AccessFn = (ctx: AccessContext) => boolean;
24
24
  * Free-navigation preset. A page is accessible unless a preceding page declares
25
25
  * `pageConfig.quiz.gatesProgress` and the learner has not met the passing score.
26
26
  */
27
- export const freeAccess: AccessFn = ({ pageIndex, manifest, progress, config }) => {
27
+ export const freeAccess: AccessFn = ({
28
+ pageIndex,
29
+ manifest,
30
+ progress,
31
+ config,
32
+ }) => {
28
33
  for (let i = pageIndex - 1; i >= 0; i--) {
29
34
  const page = manifest.pages[i];
30
35
  if (page.quiz?.gatesProgress) {
@@ -38,7 +43,12 @@ export const freeAccess: AccessFn = ({ pageIndex, manifest, progress, config })
38
43
  * Sequential-navigation preset. A page is accessible only when every preceding
39
44
  * page is complete (visited or quiz-passed, per `isPageComplete`).
40
45
  */
41
- export const sequentialAccess: AccessFn = ({ pageIndex, manifest, progress, config }) => {
46
+ export const sequentialAccess: AccessFn = ({
47
+ pageIndex,
48
+ manifest,
49
+ progress,
50
+ config,
51
+ }) => {
42
52
  for (let i = 0; i < pageIndex; i++) {
43
53
  if (!isPageComplete(i, manifest, progress, config)) return false;
44
54
  }
@@ -51,5 +61,7 @@ export const sequentialAccess: AccessFn = ({ pageIndex, manifest, progress, conf
51
61
  */
52
62
  export function resolveAccess(config: CourseConfig): AccessFn {
53
63
  if (config.navigation.canAccess) return config.navigation.canAccess;
54
- return config.navigation.mode === 'sequential' ? sequentialAccess : freeAccess;
64
+ return config.navigation.mode === 'sequential'
65
+ ? sequentialAccess
66
+ : freeAccess;
55
67
  }
@@ -26,7 +26,8 @@ const VERBS = {
26
26
  // registration state when Terminated lands without Completed.
27
27
  } as const;
28
28
 
29
- const CMI_INTERACTION_TYPE = 'http://adlnet.gov/expapi/activities/cmi.interaction';
29
+ const CMI_INTERACTION_TYPE =
30
+ 'http://adlnet.gov/expapi/activities/cmi.interaction';
30
31
 
31
32
  const CMI5_MASTERYSCORE_EXT =
32
33
  'https://w3id.org/xapi/cmi5/context/extensions/masteryscore';
@@ -36,8 +37,7 @@ const CMI5_MASTERYSCORE_EXT =
36
37
  // "failed" MUST additionally carry the "moveOn" Category. Without these, an
37
38
  // LRS will accept the statement as an arbitrary xAPI verb but won't roll it
38
39
  // up into cmi5 lifecycle state — the LMS never sees the AU as completed.
39
- const CMI5_CATEGORY_CMI5 =
40
- 'https://w3id.org/xapi/cmi5/context/categories/cmi5';
40
+ const CMI5_CATEGORY_CMI5 = 'https://w3id.org/xapi/cmi5/context/categories/cmi5';
41
41
  const CMI5_CATEGORY_MOVEON =
42
42
  'https://w3id.org/xapi/cmi5/context/categories/moveon';
43
43
 
@@ -80,14 +80,16 @@ interface CMI5LaunchData {
80
80
 
81
81
  /** `.then` handler that warns on LRS non-2xx. Publisher resolves successfully on 4xx/5xx (failure is in the outcome), so `.catch` alone misses them. */
82
82
  function warnOnLRSReject(
83
- verbName: string
84
- ): (res: { destinations?: Array<{ ok?: boolean; status?: number; error?: Error }> }) => void {
83
+ verbName: string,
84
+ ): (res: {
85
+ destinations?: Array<{ ok?: boolean; status?: number; error?: Error }>;
86
+ }) => void {
85
87
  return (res) => {
86
88
  const dest = res.destinations?.[0];
87
89
  if (dest && !dest.ok) {
88
90
  console.warn(
89
91
  `Tessera cmi5: ${verbName} statement rejected by LRS (${dest.status ?? 'network error'})`,
90
- dest.error
92
+ dest.error,
91
93
  );
92
94
  }
93
95
  };
@@ -154,7 +156,7 @@ export class CMI5Adapter implements PersistenceAdapter {
154
156
  this.#masteryScore = m;
155
157
  } else {
156
158
  console.warn(
157
- `Tessera cmi5: launch parameter 'masteryScore' is not a decimal in [0,1] (got "${rawMastery}"); ignoring.`
159
+ `Tessera cmi5: launch parameter 'masteryScore' is not a decimal in [0,1] (got "${rawMastery}"); ignoring.`,
158
160
  );
159
161
  }
160
162
  }
@@ -171,7 +173,8 @@ export class CMI5Adapter implements PersistenceAdapter {
171
173
  this.#actor = parsed as XAPIAgent;
172
174
  } catch (err) {
173
175
  throw new Error(
174
- `Tessera cmi5: launch parameter 'actor' is malformed (${err instanceof Error ? err.message : String(err)}). The LMS did not send a valid Identified Agent JSON.`
176
+ `Tessera cmi5: launch parameter 'actor' is malformed (${err instanceof Error ? err.message : String(err)}). The LMS did not send a valid Identified Agent JSON.`,
177
+ { cause: err },
175
178
  );
176
179
  }
177
180
 
@@ -180,7 +183,7 @@ export class CMI5Adapter implements PersistenceAdapter {
180
183
  // Fail loud at launch instead of dribbling errors per statement.
181
184
  if (!fetchUrl) {
182
185
  throw new Error(
183
- "Tessera cmi5: launch parameter 'fetch' is missing. Cannot acquire LMS auth token."
186
+ "Tessera cmi5: launch parameter 'fetch' is missing. Cannot acquire LMS auth token.",
184
187
  );
185
188
  }
186
189
  let resp: Response;
@@ -188,12 +191,13 @@ export class CMI5Adapter implements PersistenceAdapter {
188
191
  resp = await fetch(fetchUrl, { method: 'POST' });
189
192
  } catch (err) {
190
193
  throw new Error(
191
- `Tessera cmi5: fetch token request failed (${err instanceof Error ? err.message : String(err)}). The cmi5 launch fetch URL is single-use; reload from the LMS to retry.`
194
+ `Tessera cmi5: fetch token request failed (${err instanceof Error ? err.message : String(err)}). The cmi5 launch fetch URL is single-use; reload from the LMS to retry.`,
195
+ { cause: err },
192
196
  );
193
197
  }
194
198
  if (!resp.ok) {
195
199
  throw new Error(
196
- `Tessera cmi5: fetch token request returned ${resp.status}. The cmi5 launch fetch URL is single-use; reload from the LMS to retry.`
200
+ `Tessera cmi5: fetch token request returned ${resp.status}. The cmi5 launch fetch URL is single-use; reload from the LMS to retry.`,
197
201
  );
198
202
  }
199
203
  const text = (await resp.text()).trim();
@@ -214,15 +218,21 @@ export class CMI5Adapter implements PersistenceAdapter {
214
218
  if (typeof obj['auth-token'] === 'string') {
215
219
  token = (obj['auth-token'] as string).trim();
216
220
  } else {
217
- const code = typeof obj['error-code'] === 'string' ? obj['error-code'] : undefined;
218
- const errText = typeof obj['error-text'] === 'string' ? obj['error-text'] : undefined;
221
+ const code =
222
+ typeof obj['error-code'] === 'string'
223
+ ? obj['error-code']
224
+ : undefined;
225
+ const errText =
226
+ typeof obj['error-text'] === 'string'
227
+ ? obj['error-text']
228
+ : undefined;
219
229
  const detail =
220
230
  code !== undefined || errText !== undefined
221
231
  ? ` (error-code=${code ?? 'unknown'}${errText ? `: ${errText}` : ''})`
222
232
  : '';
223
233
  throw new Error(
224
234
  `Tessera cmi5: fetch URL returned a JSON response without an 'auth-token' field${detail}. ` +
225
- 'The cmi5 fetch URL is single-use (§8.2.3.1); reload from the LMS to obtain a fresh launch.'
235
+ 'The cmi5 fetch URL is single-use (§8.2.3.1); reload from the LMS to obtain a fresh launch.',
226
236
  );
227
237
  }
228
238
  }
@@ -233,7 +243,7 @@ export class CMI5Adapter implements PersistenceAdapter {
233
243
  this.#authToken = token;
234
244
  if (!this.#authToken) {
235
245
  throw new Error(
236
- 'Tessera cmi5: fetch token request returned an empty token. Expected a JSON body of the form {"auth-token": "..."}.'
246
+ 'Tessera cmi5: fetch token request returned an empty token. Expected a JSON body of the form {"auth-token": "..."}.',
237
247
  );
238
248
  }
239
249
 
@@ -311,12 +321,12 @@ export class CMI5Adapter implements PersistenceAdapter {
311
321
  this.#state = await resp.json();
312
322
  } else if (resp.status !== 404) {
313
323
  console.warn(
314
- `Tessera cmi5: State API GET returned ${resp.status}; resume disabled for this launch.`
324
+ `Tessera cmi5: State API GET returned ${resp.status}; resume disabled for this launch.`,
315
325
  );
316
326
  }
317
327
  } catch (err) {
318
328
  console.warn(
319
- `Tessera cmi5: State API GET failed (${err instanceof Error ? err.message : String(err)}); resume disabled for this launch.`
329
+ `Tessera cmi5: State API GET failed (${err instanceof Error ? err.message : String(err)}); resume disabled for this launch.`,
320
330
  );
321
331
  this.#state = null;
322
332
  }
@@ -355,7 +365,7 @@ export class CMI5Adapter implements PersistenceAdapter {
355
365
  if (!this.#publisher) return;
356
366
  // Chain the State PUT onto the publisher's queue so it lands before
357
367
  // Terminated. We can't use sendStatement here (different URL/verb).
358
- this.#publisher.chainTask(async () => {
368
+ void this.#publisher.chainTask(async () => {
359
369
  try {
360
370
  const resp = await this.#xapiFetch(this.#buildStateUrl(), {
361
371
  method: 'PUT',
@@ -364,7 +374,7 @@ export class CMI5Adapter implements PersistenceAdapter {
364
374
  });
365
375
  if (!resp.ok) {
366
376
  console.warn(
367
- `Tessera cmi5: State API PUT returned ${resp.status}; learner progress did not persist.`
377
+ `Tessera cmi5: State API PUT returned ${resp.status}; learner progress did not persist.`,
368
378
  );
369
379
  }
370
380
  } catch (err) {
@@ -385,7 +395,7 @@ export class CMI5Adapter implements PersistenceAdapter {
385
395
 
386
396
  seedLifecycle(
387
397
  completion: 'incomplete' | 'complete',
388
- success: 'unknown' | 'passed' | 'failed'
398
+ success: 'unknown' | 'passed' | 'failed',
389
399
  ): void {
390
400
  if (completion === 'complete') this.#completedEmitted = true;
391
401
  if (success === 'passed' || success === 'failed') {
@@ -394,7 +404,8 @@ export class CMI5Adapter implements PersistenceAdapter {
394
404
  }
395
405
 
396
406
  setCompletionStatus(status: 'incomplete' | 'complete'): void {
397
- if (status !== 'complete' || this.#completedEmitted || !this.#publisher) return;
407
+ if (status !== 'complete' || this.#completedEmitted || !this.#publisher)
408
+ return;
398
409
  // cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Completed.
399
410
  if (this.#launchMode !== 'Normal') return;
400
411
  this.#completedEmitted = true;
@@ -435,14 +446,16 @@ export class CMI5Adapter implements PersistenceAdapter {
435
446
  // The author asserted the verb, so on contradiction we keep the
436
447
  // verb and drop the score (and warn).
437
448
  if (this.#masteryScore !== null) {
438
- const violatesPassed = status === 'passed' && scaled < this.#masteryScore;
439
- const violatesFailed = status === 'failed' && scaled >= this.#masteryScore;
449
+ const violatesPassed =
450
+ status === 'passed' && scaled < this.#masteryScore;
451
+ const violatesFailed =
452
+ status === 'failed' && scaled >= this.#masteryScore;
440
453
  if (violatesPassed || violatesFailed) {
441
454
  console.warn(
442
455
  `Tessera cmi5: refusing to attach scaled score ${scaled.toFixed(3)} to ` +
443
456
  `${status === 'passed' ? 'Passed' : 'Failed'} (masteryScore=${this.#masteryScore}); ` +
444
457
  `per cmi5 §9.3.${status === 'passed' ? '4' : '5'} the score would contradict the verb. ` +
445
- `Statement will be sent without a score.`
458
+ `Statement will be sent without a score.`,
446
459
  );
447
460
  } else {
448
461
  result.score = { scaled };
@@ -475,7 +488,7 @@ export class CMI5Adapter implements PersistenceAdapter {
475
488
  reportInteraction(
476
489
  questionId: string,
477
490
  interaction: Interaction,
478
- correct: boolean | null
491
+ correct: boolean | null,
479
492
  ): void {
480
493
  if (!this.#publisher) return;
481
494
  const response = formatResponse(interaction, XAPI_INTERACTION_FORMAT);
@@ -567,10 +580,13 @@ export class CMI5Adapter implements PersistenceAdapter {
567
580
  * for Passed/Failed (§9.6.3.2).
568
581
  */
569
582
  #cmi5Context(
570
- opts: { moveOn?: boolean; mastery?: boolean } = {}
583
+ opts: { moveOn?: boolean; mastery?: boolean } = {},
571
584
  ): Record<string, unknown> {
572
585
  const tmpl = this.#launchData?.contextTemplate ?? {};
573
- const tmplActivities = (tmpl.contextActivities ?? {}) as Record<string, unknown>;
586
+ const tmplActivities = (tmpl.contextActivities ?? {}) as Record<
587
+ string,
588
+ unknown
589
+ >;
574
590
 
575
591
  // Concat-dedupe category to preserve any template-supplied entries
576
592
  // (§10.2.1 forbids overwriting them).
@@ -582,8 +598,14 @@ export class CMI5Adapter implements PersistenceAdapter {
582
598
  category.push({ id, objectType: 'Activity' });
583
599
  }
584
600
  };
585
- const templateCategory = Array.isArray((tmplActivities as { category?: unknown }).category)
586
- ? ((tmplActivities as { category: Array<{ id: string; objectType?: string }> }).category)
601
+ const templateCategory = Array.isArray(
602
+ (tmplActivities as { category?: unknown }).category,
603
+ )
604
+ ? (
605
+ tmplActivities as {
606
+ category: Array<{ id: string; objectType?: string }>;
607
+ }
608
+ ).category
587
609
  : [];
588
610
  for (const c of templateCategory) {
589
611
  if (c && typeof c.id === 'string') push(c.id);
@@ -645,11 +667,11 @@ export class CMI5Adapter implements PersistenceAdapter {
645
667
  console.warn(
646
668
  `Tessera cmi5: LMS.LaunchData State GET returned ${resp.status}; ` +
647
669
  'cmi5 Defined Statements may be rejected by strict LRSes ' +
648
- '(missing Publisher Activity / session id).'
670
+ '(missing Publisher Activity / session id).',
649
671
  );
650
672
  } catch (err) {
651
673
  console.warn(
652
- `Tessera cmi5: LMS.LaunchData State GET failed (${err instanceof Error ? err.message : String(err)}).`
674
+ `Tessera cmi5: LMS.LaunchData State GET failed (${err instanceof Error ? err.message : String(err)}).`,
653
675
  );
654
676
  }
655
677
  return null;
@@ -666,12 +688,12 @@ export class CMI5Adapter implements PersistenceAdapter {
666
688
  const resp = await this.#xapiFetch(url, { method: 'GET' });
667
689
  if (!resp.ok && resp.status !== 404) {
668
690
  console.warn(
669
- `Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) returned ${resp.status}.`
691
+ `Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) returned ${resp.status}.`,
670
692
  );
671
693
  }
672
694
  } catch (err) {
673
695
  console.warn(
674
- `Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) failed (${err instanceof Error ? err.message : String(err)}).`
696
+ `Tessera cmi5: Agent Profile GET (cmi5LearnerPreferences) failed (${err instanceof Error ? err.message : String(err)}).`,
675
697
  );
676
698
  }
677
699
  }