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.
- package/README.md +1 -0
- package/dist/audit-BBJpQGqb.js +204 -0
- package/dist/audit-BBJpQGqb.js.map +1 -0
- package/dist/plugin/a11y-cli.d.ts +1 -0
- package/dist/plugin/a11y-cli.js +36 -0
- package/dist/plugin/a11y-cli.js.map +1 -0
- package/dist/plugin/cli.js +2 -1
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +16 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +85 -10
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-D9DXlqNP.js → validation-B-xTvM9B.js} +342 -18
- package/dist/validation-B-xTvM9B.js.map +1 -0
- package/package.json +17 -2
- package/src/components/Accordion.svelte +3 -1
- package/src/components/AccordionItem.svelte +1 -5
- package/src/components/Audio.svelte +17 -3
- package/src/components/Callout.svelte +5 -1
- package/src/components/Carousel.svelte +24 -8
- package/src/components/DefaultLayout.svelte +41 -12
- package/src/components/FillInTheBlank.svelte +16 -6
- package/src/components/Image.svelte +12 -3
- package/src/components/LockedBanner.svelte +2 -1
- package/src/components/Matching.svelte +48 -19
- package/src/components/MediaTracks.svelte +21 -0
- package/src/components/MultipleChoice.svelte +33 -13
- package/src/components/Quiz.svelte +61 -20
- package/src/components/ResultIcon.svelte +20 -4
- package/src/components/RevealModal.svelte +25 -22
- package/src/components/Sorting.svelte +61 -26
- package/src/components/Transcript.svelte +37 -0
- package/src/components/Video.svelte +21 -18
- package/src/components/util.ts +3 -1
- package/src/components/video-embed.ts +25 -0
- package/src/index.ts +2 -7
- package/src/plugin/a11y/audit.ts +299 -0
- package/src/plugin/a11y/contrast.ts +67 -0
- package/src/plugin/a11y-cli.ts +35 -0
- package/src/plugin/cli.ts +4 -1
- package/src/plugin/export.ts +42 -14
- package/src/plugin/index.ts +216 -44
- package/src/plugin/manifest.ts +62 -22
- package/src/plugin/validation.ts +736 -122
- package/src/runtime/App.svelte +119 -48
- package/src/runtime/LoadingBar.svelte +12 -3
- package/src/runtime/Sidebar.svelte +24 -8
- package/src/runtime/access.ts +15 -3
- package/src/runtime/adapters/cmi5.ts +55 -33
- package/src/runtime/adapters/index.ts +22 -10
- package/src/runtime/adapters/retry.ts +25 -20
- package/src/runtime/adapters/scorm-base.ts +19 -15
- package/src/runtime/adapters/scorm12.ts +7 -8
- package/src/runtime/adapters/scorm2004.ts +11 -14
- package/src/runtime/adapters/web.ts +1 -1
- package/src/runtime/hooks.svelte.ts +152 -326
- package/src/runtime/interaction-format.ts +30 -12
- package/src/runtime/interaction.ts +44 -11
- package/src/runtime/navigation.svelte.ts +27 -11
- package/src/runtime/persistence.ts +2 -2
- package/src/runtime/progress.svelte.ts +13 -9
- package/src/runtime/quiz-engine.svelte.ts +361 -0
- package/src/runtime/quiz-policy.ts +9 -3
- package/src/runtime/types.ts +24 -2
- package/src/runtime/xapi/agent-rules.ts +4 -1
- package/src/runtime/xapi/client.ts +5 -5
- package/src/runtime/xapi/derive-actor.ts +2 -2
- package/src/runtime/xapi/publisher.ts +32 -29
- package/src/runtime/xapi/setup.ts +18 -15
- package/src/runtime/xapi/validation.ts +15 -6
- package/src/virtual.d.ts +4 -1
- package/styles/base.css +32 -11
- package/styles/layout.css +39 -18
- package/styles/theme.css +15 -3
- package/dist/validation-D9DXlqNP.js.map +0 -1
package/src/runtime/App.svelte
CHANGED
|
@@ -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 {
|
|
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 =
|
|
38
|
-
|
|
39
|
-
|
|
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({
|
|
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, {
|
|
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(
|
|
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(
|
|
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()
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 (
|
|
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(
|
|
157
|
-
|
|
158
|
-
|
|
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;
|
|
164
|
-
|
|
165
|
-
|
|
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 {
|
|
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(
|
|
184
|
-
|
|
185
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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?.(
|
|
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(() => {
|
|
17
|
+
requestAnimationFrame(() => {
|
|
18
|
+
appeared = true;
|
|
19
|
+
});
|
|
18
20
|
}, 100);
|
|
19
|
-
const slowTimer = setTimeout(() => {
|
|
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
|
|
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 } =
|
|
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
|
|
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
|
|
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
|
|
68
|
-
|
|
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}
|
package/src/runtime/access.ts
CHANGED
|
@@ -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 = ({
|
|
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 = ({
|
|
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'
|
|
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 =
|
|
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: {
|
|
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 =
|
|
218
|
-
|
|
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)
|
|
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 =
|
|
439
|
-
|
|
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<
|
|
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(
|
|
586
|
-
|
|
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
|
}
|