tessera-learn 0.0.10 → 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 (79) 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 +6 -3
  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 +171 -140
  12. package/dist/plugin/index.js.map +1 -1
  13. package/dist/{validation-BxWAMMnJ.js → validation-B-xTvM9B.js} +417 -81
  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 +22 -5
  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 +75 -103
  23. package/src/components/Image.svelte +14 -10
  24. package/src/components/LockedBanner.svelte +5 -5
  25. package/src/components/Matching.svelte +48 -19
  26. package/src/components/MediaTracks.svelte +21 -0
  27. package/src/components/MultipleChoice.svelte +81 -102
  28. package/src/components/Quiz.svelte +63 -21
  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 +25 -20
  34. package/src/components/util.ts +4 -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 +6 -8
  41. package/src/plugin/export.ts +60 -50
  42. package/src/plugin/index.ts +244 -101
  43. package/src/plugin/layout.ts +6 -51
  44. package/src/plugin/manifest.ts +90 -24
  45. package/src/plugin/override-plugin.ts +68 -0
  46. package/src/plugin/quiz.ts +9 -54
  47. package/src/plugin/validation.ts +768 -183
  48. package/src/runtime/App.svelte +128 -64
  49. package/src/runtime/LoadingBar.svelte +12 -3
  50. package/src/runtime/Sidebar.svelte +24 -8
  51. package/src/runtime/access.ts +15 -3
  52. package/src/runtime/adapters/cmi5.ts +68 -116
  53. package/src/runtime/adapters/format.ts +67 -0
  54. package/src/runtime/adapters/index.ts +45 -34
  55. package/src/runtime/adapters/retry.ts +25 -84
  56. package/src/runtime/adapters/scorm-base.ts +19 -15
  57. package/src/runtime/adapters/scorm12.ts +8 -9
  58. package/src/runtime/adapters/scorm2004.ts +22 -30
  59. package/src/runtime/adapters/web.ts +1 -1
  60. package/src/runtime/hooks.svelte.ts +152 -328
  61. package/src/runtime/interaction-format.ts +30 -12
  62. package/src/runtime/interaction.ts +44 -11
  63. package/src/runtime/navigation.svelte.ts +29 -40
  64. package/src/runtime/persistence.ts +2 -2
  65. package/src/runtime/progress.svelte.ts +22 -9
  66. package/src/runtime/quiz-engine.svelte.ts +361 -0
  67. package/src/runtime/quiz-policy.ts +28 -179
  68. package/src/runtime/types.ts +24 -2
  69. package/src/runtime/xapi/agent-rules.ts +11 -3
  70. package/src/runtime/xapi/client.ts +5 -5
  71. package/src/runtime/xapi/derive-actor.ts +2 -2
  72. package/src/runtime/xapi/publisher.ts +33 -40
  73. package/src/runtime/xapi/setup.ts +18 -15
  74. package/src/runtime/xapi/validation.ts +15 -6
  75. package/src/virtual.d.ts +4 -1
  76. package/styles/base.css +32 -11
  77. package/styles/layout.css +39 -18
  78. package/styles/theme.css +15 -3
  79. package/dist/validation-BxWAMMnJ.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
  }
@@ -306,30 +370,25 @@
306
370
 
307
371
  // ---- Persistence: report score/completion/success to adapter ----
308
372
  // These are no-ops for WebAdapter but used by LMS adapters (Step 10)
373
+ let prevReportedScore = $state(null);
309
374
  $effect(() => {
310
- const scores = progress.quizScores;
311
- if (!persistenceReady || scores.size === 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
- }
375
+ void progress.version;
376
+ if (!persistenceReady) return;
377
+
378
+ const { average, attempted } = progress.gradedScore();
322
379
  if (!attempted) return;
323
380
 
324
- // Divide by total graded count — incomplete quizzes count as 0, matching
325
- // the recalculateSuccess logic in progress.svelte.ts.
326
- const average = sum / gradedQuizIndices.size;
381
+ const rounded = Math.round(average);
382
+ if (rounded === prevReportedScore) return;
383
+ prevReportedScore = rounded;
327
384
 
328
385
  untrack(() => {
329
- adapter.setScore(Math.round(average));
386
+ adapter.setScore(rounded);
330
387
  // Under manual mode, success is owned by requireSuccessStatus.
331
388
  if (config.completion.mode !== 'manual') {
332
- adapter.setSuccessStatus(average >= config.scoring.passingScore ? 'passed' : 'failed');
389
+ adapter.setSuccessStatus(
390
+ average >= config.scoring.passingScore ? 'passed' : 'failed',
391
+ );
333
392
  }
334
393
  adapter.setDuration(duration.sessionSeconds);
335
394
  adapter.commit();
@@ -373,7 +432,9 @@
373
432
  // Tell SCORM whether this is a suspend-to-resume close or a normal
374
433
  // exit. cmi5/web adapters no-op. Must come before terminate() so the
375
434
  // value is committed in the same flush.
376
- adapter.setExit(progress.completionStatus === 'complete' ? 'normal' : 'suspend');
435
+ adapter.setExit(
436
+ progress.completionStatus === 'complete' ? 'normal' : 'suspend',
437
+ );
377
438
  adapter.commit();
378
439
  // Stop accepting author-issued statements on independent destinations
379
440
  // before terminate() so a late `useXAPI().sendStatement(...)` from a
@@ -417,7 +478,10 @@
417
478
  restoreState(saved);
418
479
  prevCompletionStatus = progress.completionStatus;
419
480
  prevSuccessStatus = progress.successStatus;
420
- adapter.seedLifecycle?.(progress.completionStatus, progress.successStatus);
481
+ adapter.seedLifecycle?.(
482
+ progress.completionStatus,
483
+ progress.successStatus,
484
+ );
421
485
  }
422
486
  persistenceReady = true;
423
487
 
@@ -460,7 +524,7 @@
460
524
  '[tessera] completion.mode is "manual" but the course has not completed after 60s. ' +
461
525
  'No page declared `pageConfig.completesOn: "view"` was reached, and no component called ' +
462
526
  '`useCompletion().markComplete()`. This is a misconfiguration; set `completion.trigger: "page"` ' +
463
- '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.',
464
528
  );
465
529
  }
466
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
  }