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.
- package/AGENTS.md +1228 -0
- package/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/plugin/index.d.ts +7 -0
- package/dist/plugin/index.d.ts.map +1 -0
- package/dist/plugin/index.js +1239 -0
- package/dist/plugin/index.js.map +1 -0
- package/package.json +77 -0
- package/src/archiver.d.ts +27 -0
- package/src/components/Accordion.svelte +32 -0
- package/src/components/AccordionItem.svelte +144 -0
- package/src/components/Audio.svelte +38 -0
- package/src/components/Callout.svelte +81 -0
- package/src/components/Carousel.svelte +194 -0
- package/src/components/CarouselSlide.svelte +32 -0
- package/src/components/DefaultLayout.svelte +108 -0
- package/src/components/FillInTheBlank.svelte +345 -0
- package/src/components/Image.svelte +47 -0
- package/src/components/Matching.svelte +513 -0
- package/src/components/MultipleChoice.svelte +363 -0
- package/src/components/Quiz.svelte +569 -0
- package/src/components/RevealModal.svelte +228 -0
- package/src/components/Sorting.svelte +663 -0
- package/src/components/Video.svelte +118 -0
- package/src/components/index.ts +15 -0
- package/src/components/quiz-payload.ts +71 -0
- package/src/components/util.ts +24 -0
- package/src/index.ts +56 -0
- package/src/plugin/export.ts +264 -0
- package/src/plugin/index.ts +464 -0
- package/src/plugin/layout.ts +55 -0
- package/src/plugin/manifest.ts +330 -0
- package/src/plugin/quiz.ts +65 -0
- package/src/plugin/validation.ts +838 -0
- package/src/runtime/App.svelte +435 -0
- package/src/runtime/ErrorPage.svelte +14 -0
- package/src/runtime/LoadingSkeleton.svelte +26 -0
- package/src/runtime/Sidebar.svelte +76 -0
- package/src/runtime/access.ts +55 -0
- package/src/runtime/adapters/cmi5.ts +341 -0
- package/src/runtime/adapters/discovery.ts +38 -0
- package/src/runtime/adapters/index.ts +99 -0
- package/src/runtime/adapters/retry.ts +284 -0
- package/src/runtime/adapters/scorm12.ts +172 -0
- package/src/runtime/adapters/scorm2004.ts +162 -0
- package/src/runtime/adapters/web.ts +62 -0
- package/src/runtime/contexts.ts +76 -0
- package/src/runtime/duration.ts +29 -0
- package/src/runtime/hooks.svelte.ts +543 -0
- package/src/runtime/interaction-format.ts +132 -0
- package/src/runtime/interaction.ts +96 -0
- package/src/runtime/navigation.svelte.ts +117 -0
- package/src/runtime/persistence.ts +56 -0
- package/src/runtime/progress.svelte.ts +168 -0
- package/src/runtime/quiz-policy.ts +227 -0
- package/src/runtime/slugify.ts +17 -0
- package/src/runtime/types.ts +92 -0
- package/src/runtime/xapi/agent-rules.ts +93 -0
- package/src/runtime/xapi/client.ts +133 -0
- package/src/runtime/xapi/derive-actor.ts +90 -0
- package/src/runtime/xapi/publisher.ts +604 -0
- package/src/runtime/xapi/registry.ts +38 -0
- package/src/runtime/xapi/setup.ts +250 -0
- package/src/runtime/xapi/types.ts +106 -0
- package/src/runtime/xapi/uuid.ts +21 -0
- package/src/runtime/xapi/validation.ts +71 -0
- package/src/runtime/xapi/version.ts +23 -0
- package/src/virtual.d.ts +16 -0
- package/styles/base.css +194 -0
- package/styles/layout.css +408 -0
- 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
|
+
}
|