tessera-learn 0.0.1

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 (71) hide show
  1. package/AGENTS.md +1228 -0
  2. package/LICENSE +21 -0
  3. package/README.md +21 -0
  4. package/dist/plugin/index.d.ts +7 -0
  5. package/dist/plugin/index.d.ts.map +1 -0
  6. package/dist/plugin/index.js +1239 -0
  7. package/dist/plugin/index.js.map +1 -0
  8. package/package.json +77 -0
  9. package/src/archiver.d.ts +27 -0
  10. package/src/components/Accordion.svelte +32 -0
  11. package/src/components/AccordionItem.svelte +144 -0
  12. package/src/components/Audio.svelte +38 -0
  13. package/src/components/Callout.svelte +81 -0
  14. package/src/components/Carousel.svelte +194 -0
  15. package/src/components/CarouselSlide.svelte +32 -0
  16. package/src/components/DefaultLayout.svelte +108 -0
  17. package/src/components/FillInTheBlank.svelte +345 -0
  18. package/src/components/Image.svelte +47 -0
  19. package/src/components/Matching.svelte +513 -0
  20. package/src/components/MultipleChoice.svelte +363 -0
  21. package/src/components/Quiz.svelte +569 -0
  22. package/src/components/RevealModal.svelte +228 -0
  23. package/src/components/Sorting.svelte +663 -0
  24. package/src/components/Video.svelte +118 -0
  25. package/src/components/index.ts +15 -0
  26. package/src/components/quiz-payload.ts +71 -0
  27. package/src/components/util.ts +24 -0
  28. package/src/index.ts +56 -0
  29. package/src/plugin/export.ts +264 -0
  30. package/src/plugin/index.ts +464 -0
  31. package/src/plugin/layout.ts +55 -0
  32. package/src/plugin/manifest.ts +330 -0
  33. package/src/plugin/quiz.ts +65 -0
  34. package/src/plugin/validation.ts +838 -0
  35. package/src/runtime/App.svelte +435 -0
  36. package/src/runtime/ErrorPage.svelte +14 -0
  37. package/src/runtime/LoadingSkeleton.svelte +26 -0
  38. package/src/runtime/Sidebar.svelte +76 -0
  39. package/src/runtime/access.ts +55 -0
  40. package/src/runtime/adapters/cmi5.ts +341 -0
  41. package/src/runtime/adapters/discovery.ts +38 -0
  42. package/src/runtime/adapters/index.ts +99 -0
  43. package/src/runtime/adapters/retry.ts +284 -0
  44. package/src/runtime/adapters/scorm12.ts +172 -0
  45. package/src/runtime/adapters/scorm2004.ts +162 -0
  46. package/src/runtime/adapters/web.ts +62 -0
  47. package/src/runtime/contexts.ts +76 -0
  48. package/src/runtime/duration.ts +29 -0
  49. package/src/runtime/hooks.svelte.ts +543 -0
  50. package/src/runtime/interaction-format.ts +132 -0
  51. package/src/runtime/interaction.ts +96 -0
  52. package/src/runtime/navigation.svelte.ts +117 -0
  53. package/src/runtime/persistence.ts +56 -0
  54. package/src/runtime/progress.svelte.ts +168 -0
  55. package/src/runtime/quiz-policy.ts +227 -0
  56. package/src/runtime/slugify.ts +17 -0
  57. package/src/runtime/types.ts +92 -0
  58. package/src/runtime/xapi/agent-rules.ts +93 -0
  59. package/src/runtime/xapi/client.ts +133 -0
  60. package/src/runtime/xapi/derive-actor.ts +90 -0
  61. package/src/runtime/xapi/publisher.ts +604 -0
  62. package/src/runtime/xapi/registry.ts +38 -0
  63. package/src/runtime/xapi/setup.ts +250 -0
  64. package/src/runtime/xapi/types.ts +106 -0
  65. package/src/runtime/xapi/uuid.ts +21 -0
  66. package/src/runtime/xapi/validation.ts +71 -0
  67. package/src/runtime/xapi/version.ts +23 -0
  68. package/src/virtual.d.ts +16 -0
  69. package/styles/base.css +194 -0
  70. package/styles/layout.css +408 -0
  71. package/styles/theme.css +36 -0
@@ -0,0 +1,435 @@
1
+ <script>
2
+ import config from 'virtual:tessera-config';
3
+ import manifest from 'virtual:tessera-manifest';
4
+ import pageModules from 'virtual:tessera-pages';
5
+ import UserLayout from 'virtual:tessera-layout';
6
+ import Quiz from 'virtual:tessera-quiz';
7
+ import { onMount, onDestroy, setContext, untrack } from 'svelte';
8
+ import LoadingSkeleton from './LoadingSkeleton.svelte';
9
+ import ErrorPage from './ErrorPage.svelte';
10
+ import DefaultLayout from '../components/DefaultLayout.svelte';
11
+ import { NavigationState } from './navigation.svelte.js';
12
+ import { ProgressState } from './progress.svelte.js';
13
+ import { DurationTracker } from './duration.js';
14
+ import { createAdapter } from './adapters/index.js';
15
+ import { buildXAPIClient } from './xapi/setup.js';
16
+ import { registerXAPIClient } from './xapi/registry.js';
17
+ import { TESSERA_PAGE, TESSERA_NAV, TESSERA_ADAPTER, TESSERA_USER_STATE } from './contexts.js';
18
+
19
+ // ---- Persistence ----
20
+ const adapter = createAdapter(config);
21
+ let persistenceReady = $state(false);
22
+ // Holds the resolved xAPI client for unload-time markUnloading. Set
23
+ // after adapter.init() resolves and registered globally so useXAPI()
24
+ // can reach it.
25
+ let xapiClient = null;
26
+
27
+ // ---- State classes ----
28
+ const progress = new ProgressState();
29
+ const nav = new NavigationState(manifest, progress, config);
30
+ let duration = $state(new DurationTracker(0));
31
+
32
+ // Page loading state
33
+ let PageComponent = $state(null);
34
+ let pageLoading = $state(true);
35
+ let pageError = $state(null);
36
+ let retryKey = $state(0);
37
+
38
+ // ---- Page context (reactive, read by Quiz in Step 8) ----
39
+ let pageContext = $state({ quiz: null, passingScore: config.scoring?.passingScore ?? 70 });
40
+ setContext(TESSERA_PAGE, pageContext);
41
+
42
+ // ---- Navigation context (read by custom chrome components) ----
43
+ // Exposes nav/manifest/progress/config so courses can build custom top bars,
44
+ // menus, tables of contents, etc. that can navigate to specific pages.
45
+ setContext(TESSERA_NAV, { nav, manifest, progress, config });
46
+
47
+ // ---- Adapter context (read by useQuestion / usePersistence) ----
48
+ setContext(TESSERA_ADAPTER, { get adapter() { return adapter; } });
49
+
50
+ // ---- User-scoped state (read/written by usePersistence) ----
51
+ // Each call site namespaces under its own key. Persisted to SavedState.u.
52
+ let userState = $state({});
53
+ setContext(TESSERA_USER_STATE, {
54
+ get(key) {
55
+ return key in userState ? userState[key] : null;
56
+ },
57
+ set(key, value) {
58
+ userState[key] = value;
59
+ requestPersist();
60
+ },
61
+ });
62
+
63
+ // ---- Chrome mode ----
64
+ // A project-supplied layout.svelte at the project root takes precedence.
65
+ // Otherwise: "default" renders the built-in DefaultLayout; "custom" hides
66
+ // the chrome entirely so a course-owned shell can take over.
67
+ if (UserLayout && config.chrome === 'custom' && import.meta.env?.DEV) {
68
+ console.warn('[tessera] Both layout.svelte and chrome: "custom" are set. layout.svelte wins.');
69
+ }
70
+ const chromeMode = UserLayout
71
+ ? 'user'
72
+ : config.chrome === 'custom'
73
+ ? 'custom'
74
+ : 'default';
75
+
76
+ // ---- Page loading ----
77
+ let loadGeneration = 0;
78
+
79
+ function loadPage(index) {
80
+ const page = manifest.pages[index];
81
+ if (!page) return;
82
+
83
+ const gen = ++loadGeneration;
84
+ pageLoading = true;
85
+ pageError = null;
86
+ PageComponent = null;
87
+
88
+ // Update context for the new page
89
+ pageContext.quiz = page.quiz;
90
+
91
+ const loader = pageModules[page.importPath];
92
+ if (!loader) {
93
+ console.error(`Tessera: No loader for page ${index} at ${page.importPath}`);
94
+ pageError = new Error(`Page not found: ${page.importPath}`);
95
+ pageLoading = false;
96
+ return;
97
+ }
98
+
99
+ loader().then(mod => {
100
+ if (gen !== loadGeneration) return; // stale
101
+ PageComponent = mod.default;
102
+ pageLoading = false;
103
+ // Mark visited and recalculate
104
+ progress.markVisited(index);
105
+ progress.recalculateCompletion(manifest, config);
106
+ progress.recalculateSuccess(manifest, config);
107
+ }).catch(err => {
108
+ if (gen !== loadGeneration) return; // stale
109
+ console.error(`Tessera: Failed to load page ${index}`, err);
110
+ pageError = err;
111
+ pageLoading = false;
112
+ });
113
+ }
114
+
115
+ // React to page index changes
116
+ $effect(() => {
117
+ const index = nav.currentPageIndex;
118
+ const _retry = retryKey;
119
+ untrack(() => loadPage(index));
120
+ });
121
+
122
+ // ---- Retry ----
123
+ function retryPage() {
124
+ retryKey++;
125
+ }
126
+
127
+ // ---- Branding ----
128
+ function parseColor(ctx, color) {
129
+ ctx.fillStyle = '#000';
130
+ ctx.fillStyle = color;
131
+ if (ctx.fillStyle === '#000000'
132
+ && color.trim().toLowerCase() !== '#000000'
133
+ && color.trim().toLowerCase() !== '#000'
134
+ && color.trim().toLowerCase() !== 'black') {
135
+ return null;
136
+ }
137
+ const hex = ctx.fillStyle;
138
+ return {
139
+ r: parseInt(hex.slice(1, 3), 16),
140
+ g: parseInt(hex.slice(3, 5), 16),
141
+ b: parseInt(hex.slice(5, 7), 16),
142
+ };
143
+ }
144
+
145
+ function rgbToHsl(r, g, b) {
146
+ r /= 255; g /= 255; b /= 255;
147
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
148
+ let h = 0, s = 0, l = (max + min) / 2;
149
+ if (max !== min) {
150
+ const d = max - min;
151
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
152
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
153
+ else if (max === g) h = ((b - r) / d + 2) / 6;
154
+ else h = ((r - g) / d + 4) / 6;
155
+ }
156
+ return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
157
+ }
158
+
159
+ function applyBranding(cfg) {
160
+ const el = document.documentElement;
161
+ if (cfg.branding?.primaryColor) {
162
+ el.style.setProperty('--tessera-primary', cfg.branding.primaryColor);
163
+ // Create the canvas once here rather than inside parseColor to avoid
164
+ // allocating a new element for every color resolved.
165
+ const canvas = document.createElement('canvas');
166
+ const ctx = canvas.getContext('2d');
167
+ const rgb = ctx ? parseColor(ctx, cfg.branding.primaryColor) : null;
168
+ if (rgb) {
169
+ const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
170
+ el.style.setProperty('--tessera-primary-light', `hsl(${hsl.h}, ${Math.min(hsl.s + 10, 100)}%, 90%)`);
171
+ el.style.setProperty('--tessera-primary-dark', `hsl(${hsl.h}, ${Math.min(hsl.s + 10, 100)}%, ${Math.max(hsl.l - 15, 10)}%)`);
172
+ el.style.setProperty('--tessera-focus-ring', `0 0 0 3px rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.4)`);
173
+ }
174
+ }
175
+ if (cfg.branding?.fontFamily) {
176
+ el.style.setProperty('--tessera-font-family', cfg.branding.fontFamily);
177
+ }
178
+ }
179
+
180
+ // ---- Quiz completion handler ----
181
+ function handleQuizComplete(e) {
182
+ const { score, interactions = [] } = e.detail;
183
+ const pageIndex = nav.currentPageIndex;
184
+ 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.
192
+ }
193
+
194
+ // ---- Persistence: serialize / restore ----
195
+ function serializeState() {
196
+ const q = {};
197
+ for (const [pageIndex, score] of progress.quizScores) {
198
+ q[String(pageIndex)] = score;
199
+ }
200
+ const c = {};
201
+ for (const [pageIndex, chunkIndex] of progress.chunkProgress) {
202
+ c[String(pageIndex)] = chunkIndex;
203
+ }
204
+ const s = {};
205
+ for (const [pageIndex, questionMap] of progress.standaloneQuestionScores) {
206
+ const obj = {};
207
+ for (const [qid, score] of questionMap) obj[qid] = score;
208
+ s[String(pageIndex)] = obj;
209
+ }
210
+ return {
211
+ b: nav.currentPageIndex,
212
+ v: [...progress.visitedPages],
213
+ q,
214
+ d: duration.totalSeconds,
215
+ c,
216
+ s,
217
+ gs: [...progress.gradedStandalonePages],
218
+ u: { ...userState },
219
+ };
220
+ }
221
+
222
+ function restoreState(saved) {
223
+ if (!saved) return;
224
+ // Restore visited pages
225
+ for (const idx of saved.v) {
226
+ progress.markVisited(idx);
227
+ }
228
+ // Restore quiz scores
229
+ for (const [key, score] of Object.entries(saved.q)) {
230
+ progress.quizCompleted(Number(key), score);
231
+ }
232
+ // Restore chunk progress (may be absent on state saved before this field existed)
233
+ if (saved.c) {
234
+ for (const [key, chunkIndex] of Object.entries(saved.c)) {
235
+ progress.markChunk(Number(key), Number(chunkIndex));
236
+ }
237
+ }
238
+ // Restore standalone question scores (absent on state saved before useQuestion existed)
239
+ if (saved.s) {
240
+ const gradedSet = new Set((saved.gs ?? []).map(Number));
241
+ for (const [pageKey, questions] of Object.entries(saved.s)) {
242
+ const pageIndex = Number(pageKey);
243
+ for (const [qid, score] of Object.entries(questions)) {
244
+ progress.markStandaloneQuestion(pageIndex, qid, Number(score), gradedSet.has(pageIndex));
245
+ }
246
+ }
247
+ }
248
+ // Restore user-scoped state from usePersistence (absent on older saves)
249
+ if (saved.u && typeof saved.u === 'object') {
250
+ userState = { ...saved.u };
251
+ }
252
+ // Restore duration
253
+ duration = new DurationTracker(saved.d || 0);
254
+ // Recalculate derived state
255
+ progress.recalculateCompletion(manifest, config);
256
+ progress.recalculateSuccess(manifest, config);
257
+ // Navigate to bookmark (after state is restored so locking is correct)
258
+ if (saved.b > 0 && saved.b < manifest.totalPages) {
259
+ nav.goToPage(saved.b);
260
+ }
261
+ }
262
+
263
+ function persistState() {
264
+ if (!persistenceReady) return;
265
+ adapter.saveState(serializeState());
266
+ }
267
+
268
+ // ---- Persistence: coalesced save on state changes ----
269
+ // A single microtask-batched scheduler. Multiple state mutations within one
270
+ // tick collapse to one persistState() call (and one LMS commit). Replaces
271
+ // four independent $effects, each of which used to fire its own write.
272
+ let persistScheduled = false;
273
+
274
+ function requestPersist() {
275
+ if (persistScheduled) return;
276
+ if (!persistenceReady) return;
277
+ persistScheduled = true;
278
+ queueMicrotask(() => {
279
+ persistScheduled = false;
280
+ persistState();
281
+ });
282
+ }
283
+
284
+ $effect(() => {
285
+ // Subscribe to every signal that influences serializeState():
286
+ // - currentPageIndex (bookmark)
287
+ // - progress.version (bumped by markVisited / quizCompleted /
288
+ // markChunk / markStandaloneQuestion)
289
+ // userState writes go through requestPersist() directly from the setter.
290
+ void nav.currentPageIndex;
291
+ void progress.version;
292
+ untrack(requestPersist);
293
+ });
294
+
295
+ // ---- Persistence: report score/completion/success to adapter ----
296
+ // These are no-ops for WebAdapter but used by LMS adapters (Step 10)
297
+ $effect(() => {
298
+ const scores = progress.quizScores;
299
+ if (!persistenceReady || scores.size === 0) return;
300
+
301
+ const gradedQuizIndices = manifest.pages.filter(p => p.quiz?.graded).map(p => p.index);
302
+ const completedGraded = gradedQuizIndices.filter(i => scores.has(i));
303
+ if (completedGraded.length === 0) return;
304
+
305
+ // Divide by total graded count — incomplete quizzes count as 0, matching
306
+ // the recalculateSuccess logic in progress.svelte.ts.
307
+ const average = completedGraded.reduce((sum, i) => sum + scores.get(i), 0) / gradedQuizIndices.length;
308
+
309
+ untrack(() => {
310
+ adapter.setScore(Math.round(average));
311
+ adapter.setSuccessStatus(average >= config.scoring.passingScore ? 'passed' : 'failed');
312
+ adapter.setDuration(duration.sessionSeconds);
313
+ adapter.commit();
314
+ });
315
+ });
316
+
317
+ let prevCompletionStatus = $state('incomplete');
318
+ $effect(() => {
319
+ const status = progress.completionStatus;
320
+ if (!persistenceReady) return;
321
+ if (status === prevCompletionStatus) return;
322
+ prevCompletionStatus = status;
323
+ untrack(() => {
324
+ adapter.setCompletionStatus(status);
325
+ adapter.setDuration(duration.sessionSeconds);
326
+ adapter.commit();
327
+ });
328
+ });
329
+
330
+ // ---- Exit / Terminate lifecycle ----
331
+ let terminated = false;
332
+
333
+ function handleExit() {
334
+ if (terminated) return;
335
+ terminated = true;
336
+ adapter.saveState(serializeState());
337
+ adapter.setDuration(duration.sessionSeconds);
338
+ // Tell SCORM whether this is a suspend-to-resume close or a normal
339
+ // exit. cmi5/web adapters no-op. Must come before terminate() so the
340
+ // value is committed in the same flush.
341
+ adapter.setExit(progress.completionStatus === 'complete' ? 'normal' : 'suspend');
342
+ adapter.commit();
343
+ // Stop accepting author-issued statements on independent destinations
344
+ // before terminate() so a late `useXAPI().sendStatement(...)` from a
345
+ // beforeunload handler can't slip in after Terminated.
346
+ xapiClient?.markUnloading();
347
+ adapter.terminate();
348
+ }
349
+
350
+ // ---- Lifecycle ----
351
+ onMount(async () => {
352
+ applyBranding(config);
353
+ if (config.title) document.title = config.title;
354
+
355
+ // Initialize persistence and restore state. Adapter init() may throw
356
+ // for malformed launch params (cmi5 actor JSON, missing fetch URL,
357
+ // failed token request). Surface that to the UI rather than crashing
358
+ // silently — a launch-time error means the LMS context is wrong and
359
+ // the user can't continue regardless.
360
+ try {
361
+ await adapter.init();
362
+ } catch (err) {
363
+ console.error('Tessera: adapter init failed', err);
364
+ pageError = err instanceof Error ? err : new Error(String(err));
365
+ pageLoading = false;
366
+ return;
367
+ }
368
+ const saved = adapter.getState();
369
+ if (saved) {
370
+ restoreState(saved);
371
+ prevCompletionStatus = progress.completionStatus;
372
+ }
373
+ persistenceReady = true;
374
+
375
+ // Build the xAPI client (custom destinations + cmi5 'lms' shared
376
+ // queue) once the adapter has resolved its launch context. Failure
377
+ // here is non-fatal — courses with no `xapi:` config get null, which
378
+ // is what `useXAPI()` is documented to return when nothing is wired.
379
+ try {
380
+ xapiClient = await buildXAPIClient(config, adapter);
381
+ } catch (err) {
382
+ console.warn('Tessera: xAPI client setup failed', err);
383
+ xapiClient = null;
384
+ }
385
+ registerXAPIClient(xapiClient);
386
+
387
+ // Push initial completion + success status to the adapter so LMSes never
388
+ // see the SCORM default ("unknown") on Terminate — SCORM Cloud rolls that
389
+ // up to "completed"/"passed" during status rollup.
390
+ adapter.setCompletionStatus(progress.completionStatus);
391
+ adapter.setSuccessStatus(progress.successStatus);
392
+ adapter.commit();
393
+
394
+ window.addEventListener('pagehide', handleExit);
395
+ window.addEventListener('beforeunload', handleExit);
396
+ const appEl = document.getElementById('tessera-app');
397
+ appEl?.addEventListener('tessera-quiz-complete', handleQuizComplete);
398
+ });
399
+
400
+ onDestroy(() => {
401
+ window.removeEventListener('pagehide', handleExit);
402
+ window.removeEventListener('beforeunload', handleExit);
403
+ const appEl = document.getElementById('tessera-app');
404
+ appEl?.removeEventListener('tessera-quiz-complete', handleQuizComplete);
405
+ // Clear the global slot so a stale client from a previous mount
406
+ // can't leak into a fresh one (matters for tests that re-mount).
407
+ registerXAPIClient(null);
408
+ });
409
+ </script>
410
+
411
+ {#snippet page()}
412
+ {#if pageLoading}
413
+ <LoadingSkeleton />
414
+ {:else if pageError}
415
+ <ErrorPage error={pageError} onretry={retryPage} />
416
+ {:else if PageComponent}
417
+ {#if pageContext.quiz}
418
+ <Quiz>
419
+ <PageComponent />
420
+ </Quiz>
421
+ {:else}
422
+ <PageComponent />
423
+ {/if}
424
+ {/if}
425
+ {/snippet}
426
+
427
+ <div id="tessera-app" data-chrome={chromeMode}>
428
+ {#if UserLayout}
429
+ <UserLayout {page} />
430
+ {:else if chromeMode === 'custom'}
431
+ {@render page()}
432
+ {:else}
433
+ <DefaultLayout {page} />
434
+ {/if}
435
+ </div>
@@ -0,0 +1,14 @@
1
+ <script>
2
+ let { error, onretry } = $props();
3
+ </script>
4
+
5
+ <div class="tessera-error" role="alert">
6
+ <h2>This page failed to load</h2>
7
+ <p>Try navigating to another page or refreshing the browser.</p>
8
+ {#if error?.message}
9
+ <p><small>{error.message}</small></p>
10
+ {/if}
11
+ <button class="tessera-error-retry" onclick={onretry}>
12
+ Retry
13
+ </button>
14
+ </div>
@@ -0,0 +1,26 @@
1
+ <script>
2
+ import { onMount } from 'svelte';
3
+
4
+ let showSlowMessage = $state(false);
5
+ let timer;
6
+
7
+ onMount(() => {
8
+ timer = setTimeout(() => {
9
+ showSlowMessage = true;
10
+ }, 5000);
11
+
12
+ return () => clearTimeout(timer);
13
+ });
14
+ </script>
15
+
16
+ <div class="tessera-skeleton" aria-busy="true" aria-label="Loading page content">
17
+ <div class="tessera-skeleton-line"></div>
18
+ <div class="tessera-skeleton-line"></div>
19
+ <div class="tessera-skeleton-line"></div>
20
+ <div class="tessera-skeleton-line"></div>
21
+ <div class="tessera-skeleton-line"></div>
22
+ <div class="tessera-skeleton-line"></div>
23
+ {#if showSlowMessage}
24
+ <p class="tessera-skeleton-message">Still loading…</p>
25
+ {/if}
26
+ </div>
@@ -0,0 +1,76 @@
1
+ <script>
2
+ let { manifest, config, currentPageIndex, nav, onnavigate, onclose } = $props();
3
+
4
+ // Track which sections are collapsed. All expanded by default.
5
+ let collapsedSections = $state(new Set());
6
+
7
+ function toggleSection(slug) {
8
+ if (collapsedSections.has(slug)) {
9
+ collapsedSections.delete(slug);
10
+ } else {
11
+ collapsedSections.add(slug);
12
+ }
13
+ }
14
+
15
+ function handlePageClick(pageIndex) {
16
+ if (nav.isPageLocked(pageIndex)) return;
17
+ onnavigate(pageIndex);
18
+ // Close sidebar on mobile
19
+ if (onclose) onclose();
20
+ }
21
+ </script>
22
+
23
+ <div class="tessera-sidebar-header">
24
+ {#if config.branding?.logo}
25
+ <img src={config.branding.logo} alt={config.title} class="tessera-sidebar-logo" />
26
+ {/if}
27
+ <h1 class="tessera-sidebar-title">{config.title || '(no title)'}</h1>
28
+ </div>
29
+
30
+ <nav class="tessera-sidebar-nav" aria-label="Course navigation">
31
+ {#each manifest.sections as section}
32
+ <div class="tessera-nav-section">
33
+ <button
34
+ class="tessera-nav-section-title"
35
+ onclick={() => toggleSection(section.slug)}
36
+ aria-expanded={!collapsedSections.has(section.slug)}
37
+ >
38
+ <span>{section.title}</span>
39
+ <svg
40
+ class="tessera-nav-section-chevron"
41
+ class:collapsed={collapsedSections.has(section.slug)}
42
+ viewBox="0 0 16 16"
43
+ fill="none"
44
+ stroke="currentColor"
45
+ stroke-width="2"
46
+ aria-hidden="true"
47
+ >
48
+ <path d="M4 6l4 4 4-4" />
49
+ </svg>
50
+ </button>
51
+
52
+ {#if !collapsedSections.has(section.slug)}
53
+ {#each section.lessons as lesson}
54
+ <div class="tessera-nav-lesson-title">{lesson.title}</div>
55
+ {#each lesson.pages as page}
56
+ {@const locked = nav.isPageLocked(page.index)}
57
+ <button
58
+ class="tessera-nav-page"
59
+ class:locked
60
+ aria-current={page.index === currentPageIndex ? 'page' : undefined}
61
+ aria-disabled={locked ? 'true' : undefined}
62
+ onclick={() => handlePageClick(page.index)}
63
+ >
64
+ {#if locked}
65
+ <svg class="tessera-nav-lock-icon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" width="12" height="12">
66
+ <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"/>
67
+ </svg>
68
+ {/if}
69
+ {page.title}
70
+ </button>
71
+ {/each}
72
+ {/each}
73
+ {/if}
74
+ </div>
75
+ {/each}
76
+ </nav>
@@ -0,0 +1,55 @@
1
+ import type { Manifest, ManifestPage } from '../plugin/manifest.js';
2
+ import type { CourseConfig } from './types.js';
3
+ import type { ProgressState } from './progress.svelte.js';
4
+ import { isPageComplete } from './navigation.svelte.js';
5
+
6
+ export interface AccessContext {
7
+ pageIndex: number;
8
+ page: ManifestPage;
9
+ manifest: Manifest;
10
+ progress: ProgressState;
11
+ config: CourseConfig;
12
+ }
13
+
14
+ /**
15
+ * Predicate deciding whether a page is accessible to the learner.
16
+ *
17
+ * Runs synchronously on every derived re-evaluation — keep it cheap. It is a
18
+ * runtime-side check only: the LMS does not enforce these rules. Authors who
19
+ * need true sequencing must rely on the LMS standard's own activity rules.
20
+ */
21
+ export type AccessFn = (ctx: AccessContext) => boolean;
22
+
23
+ /**
24
+ * Free-navigation preset. A page is accessible unless a preceding page declares
25
+ * `pageConfig.quiz.gatesProgress` and the learner has not met the passing score.
26
+ */
27
+ export const freeAccess: AccessFn = ({ pageIndex, manifest, progress, config }) => {
28
+ for (let i = pageIndex - 1; i >= 0; i--) {
29
+ const page = manifest.pages[i];
30
+ if (page.quiz?.gatesProgress) {
31
+ return (progress.quizScores.get(i) ?? 0) >= config.scoring.passingScore;
32
+ }
33
+ }
34
+ return true;
35
+ };
36
+
37
+ /**
38
+ * Sequential-navigation preset. A page is accessible only when every preceding
39
+ * page is complete (visited or quiz-passed, per `isPageComplete`).
40
+ */
41
+ export const sequentialAccess: AccessFn = ({ pageIndex, manifest, progress, config }) => {
42
+ for (let i = 0; i < pageIndex; i++) {
43
+ if (!isPageComplete(i, manifest, progress, config)) return false;
44
+ }
45
+ return true;
46
+ };
47
+
48
+ /**
49
+ * Resolve the access predicate for a course. Custom `config.navigation.canAccess`
50
+ * wins; otherwise the preset matching `config.navigation.mode` is returned.
51
+ */
52
+ export function resolveAccess(config: CourseConfig): AccessFn {
53
+ if (config.navigation.canAccess) return config.navigation.canAccess;
54
+ return config.navigation.mode === 'sequential' ? sequentialAccess : freeAccess;
55
+ }