tessera-learn 0.0.13 → 0.2.0
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 +1794 -0
- package/README.md +5 -5
- package/dist/{validation-B-xTvM9B.js → audit-BA5o0ick.js} +605 -269
- package/dist/audit-BA5o0ick.js.map +1 -0
- package/dist/build-commands-C0OnV-Vg.js +27 -0
- package/dist/build-commands-C0OnV-Vg.js.map +1 -0
- package/dist/inline-config-CroQ-_2Y.js +31 -0
- package/dist/inline-config-CroQ-_2Y.js.map +1 -0
- package/dist/plugin/cli.d.ts +9 -1
- package/dist/plugin/cli.d.ts.map +1 -0
- package/dist/plugin/cli.js +326 -17
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +2 -763
- package/dist/plugin-W_rk3Pit.js +731 -0
- package/dist/plugin-W_rk3Pit.js.map +1 -0
- package/package.json +21 -9
- package/src/components/FillInTheBlank.svelte +2 -2
- package/src/components/Matching.svelte +2 -2
- package/src/components/MultipleChoice.svelte +2 -2
- package/src/components/RevealModal.svelte +48 -103
- package/src/components/Sorting.svelte +2 -2
- package/src/components/util.ts +9 -0
- package/src/plugin/a11y/audit.ts +40 -8
- package/src/plugin/a11y-cli.ts +39 -22
- package/src/plugin/ast.ts +276 -0
- package/src/plugin/build-commands.ts +31 -0
- package/src/plugin/cli.ts +96 -21
- package/src/plugin/course-root.ts +98 -0
- package/src/plugin/duplicate-cli.ts +74 -0
- package/src/plugin/index.ts +87 -122
- package/src/plugin/inline-config.ts +54 -0
- package/src/plugin/manifest.ts +103 -136
- package/src/plugin/new-cli.ts +51 -0
- package/src/plugin/package-root.ts +24 -0
- package/src/plugin/project-name.ts +29 -0
- package/src/plugin/quiz.ts +8 -9
- package/src/plugin/template-copy.ts +43 -0
- package/src/plugin/validate-cli.ts +30 -0
- package/src/plugin/validation.ts +152 -244
- package/src/runtime/App.svelte +11 -97
- package/src/runtime/Sidebar.svelte +3 -1
- package/src/runtime/adapters/cmi5.ts +6 -10
- package/src/runtime/adapters/format.ts +6 -0
- package/src/runtime/adapters/retry.ts +1 -1
- package/src/runtime/adapters/scorm2004.ts +2 -4
- package/src/runtime/branding.ts +90 -0
- package/src/runtime/defaults.ts +3 -0
- package/src/runtime/hooks.svelte.ts +16 -53
- package/src/runtime/interaction-format.ts +3 -8
- package/src/runtime/progress.svelte.ts +47 -83
- package/src/runtime/xapi/derive-actor.ts +41 -48
- package/src/runtime/xapi/publisher.ts +14 -14
- package/src/runtime/xapi/setup.ts +39 -46
- package/templates/course/course.config.js +11 -0
- package/templates/course/layout.svelte +116 -0
- package/templates/course/pages/01-getting-started/01-welcome/_meta.js +1 -0
- package/templates/course/pages/01-getting-started/01-welcome/welcome.svelte +19 -0
- package/templates/course/pages/01-getting-started/_meta.js +1 -0
- package/templates/course/styles/custom.css +5 -0
- package/dist/audit-BBJpQGqb.js +0 -204
- package/dist/audit-BBJpQGqb.js.map +0 -1
- package/dist/plugin/a11y-cli.d.ts +0 -1
- package/dist/plugin/a11y-cli.js +0 -36
- package/dist/plugin/a11y-cli.js.map +0 -1
- package/dist/plugin/index.js.map +0 -1
- package/dist/validation-B-xTvM9B.js.map +0 -1
package/src/runtime/App.svelte
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
import DefaultLayout from '../components/DefaultLayout.svelte';
|
|
11
11
|
import { NavigationState } from './navigation.svelte.js';
|
|
12
12
|
import { ProgressState } from './progress.svelte.js';
|
|
13
|
+
import { DEFAULT_PASSING_SCORE } from './defaults.js';
|
|
14
|
+
import { applyBranding } from './branding.js';
|
|
13
15
|
import { DurationTracker } from './duration.js';
|
|
14
16
|
import { createAdapter } from 'virtual:tessera-adapter';
|
|
15
17
|
import { buildXAPIClient } from 'virtual:tessera-xapi-setup';
|
|
@@ -39,7 +41,11 @@
|
|
|
39
41
|
const auditMode =
|
|
40
42
|
typeof window !== 'undefined' &&
|
|
41
43
|
new URLSearchParams(window.location.search).has('__tessera_audit');
|
|
42
|
-
const progress = new ProgressState(
|
|
44
|
+
const progress = new ProgressState(
|
|
45
|
+
gradedQuizIndices,
|
|
46
|
+
config,
|
|
47
|
+
manifest.totalPages,
|
|
48
|
+
);
|
|
43
49
|
const nav = new NavigationState(manifest, progress, config, auditMode);
|
|
44
50
|
nav.setPageModules(pageModules);
|
|
45
51
|
let duration = $state(new DurationTracker(0));
|
|
@@ -62,7 +68,7 @@
|
|
|
62
68
|
// ---- Page context (reactive, read by Quiz in Step 8) ----
|
|
63
69
|
let pageContext = $state({
|
|
64
70
|
quiz: null,
|
|
65
|
-
passingScore: config.scoring?.passingScore ??
|
|
71
|
+
passingScore: config.scoring?.passingScore ?? DEFAULT_PASSING_SCORE,
|
|
66
72
|
});
|
|
67
73
|
setContext(TESSERA_PAGE, pageContext);
|
|
68
74
|
|
|
@@ -141,8 +147,6 @@
|
|
|
141
147
|
) {
|
|
142
148
|
progress.markCompleteManually();
|
|
143
149
|
}
|
|
144
|
-
progress.recalculateCompletion(manifest.totalPages, config);
|
|
145
|
-
progress.recalculateSuccess(config);
|
|
146
150
|
onIdle(() => nav.prefetch(index + 1));
|
|
147
151
|
})
|
|
148
152
|
.catch((err) => {
|
|
@@ -165,96 +169,10 @@
|
|
|
165
169
|
retryKey++;
|
|
166
170
|
}
|
|
167
171
|
|
|
168
|
-
// ---- Branding ----
|
|
169
|
-
// Two sentinels so the validity check doesn't false-positive when the
|
|
170
|
-
// input happens to normalize to the initial fillStyle ("#000000").
|
|
171
|
-
function parseColor(color) {
|
|
172
|
-
if (
|
|
173
|
-
typeof CSS !== 'undefined' &&
|
|
174
|
-
CSS.supports &&
|
|
175
|
-
!CSS.supports('color', color)
|
|
176
|
-
) {
|
|
177
|
-
return null;
|
|
178
|
-
}
|
|
179
|
-
const ctx = document.createElement('canvas').getContext('2d');
|
|
180
|
-
if (!ctx) return null;
|
|
181
|
-
ctx.fillStyle = '#000';
|
|
182
|
-
ctx.fillStyle = color;
|
|
183
|
-
const onBlack = ctx.fillStyle;
|
|
184
|
-
ctx.fillStyle = '#fff';
|
|
185
|
-
ctx.fillStyle = color;
|
|
186
|
-
const onWhite = ctx.fillStyle;
|
|
187
|
-
if (onBlack !== onWhite) return null;
|
|
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
|
-
);
|
|
200
|
-
return rgba ? { r: +rgba[1], g: +rgba[2], b: +rgba[3] } : null;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function rgbToHsl(r, g, b) {
|
|
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;
|
|
212
|
-
if (max !== min) {
|
|
213
|
-
const d = max - min;
|
|
214
|
-
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
215
|
-
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
216
|
-
else if (max === g) h = ((b - r) / d + 2) / 6;
|
|
217
|
-
else h = ((r - g) / d + 4) / 6;
|
|
218
|
-
}
|
|
219
|
-
return {
|
|
220
|
-
h: Math.round(h * 360),
|
|
221
|
-
s: Math.round(s * 100),
|
|
222
|
-
l: Math.round(l * 100),
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function applyBranding(cfg) {
|
|
227
|
-
const el = document.documentElement;
|
|
228
|
-
if (cfg.branding?.primaryColor) {
|
|
229
|
-
el.style.setProperty('--tessera-primary', cfg.branding.primaryColor);
|
|
230
|
-
const rgb = parseColor(cfg.branding.primaryColor);
|
|
231
|
-
if (rgb) {
|
|
232
|
-
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
|
|
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
|
-
);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
if (cfg.branding?.fontFamily) {
|
|
248
|
-
el.style.setProperty('--tessera-font-family', cfg.branding.fontFamily);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
172
|
function handleQuizComplete(e) {
|
|
253
173
|
const { score } = e.detail;
|
|
254
174
|
const pageIndex = nav.currentPageIndex;
|
|
255
175
|
progress.quizCompleted(pageIndex, score);
|
|
256
|
-
progress.recalculateCompletion(manifest.totalPages, config);
|
|
257
|
-
progress.recalculateSuccess(config);
|
|
258
176
|
}
|
|
259
177
|
|
|
260
178
|
// ---- Persistence: serialize / restore ----
|
|
@@ -323,13 +241,9 @@
|
|
|
323
241
|
}
|
|
324
242
|
// Restore duration
|
|
325
243
|
duration = new DurationTracker(saved.d || 0);
|
|
326
|
-
// Must come before recalc so manual-mode branches see the latch.
|
|
327
244
|
if (saved.m === 1) {
|
|
328
245
|
progress.markCompleteManually();
|
|
329
246
|
}
|
|
330
|
-
// Recalculate derived state
|
|
331
|
-
progress.recalculateCompletion(manifest.totalPages, config);
|
|
332
|
-
progress.recalculateSuccess(config);
|
|
333
247
|
// Navigate to bookmark (after state is restored so locking is correct)
|
|
334
248
|
if (saved.b > 0 && saved.b < manifest.totalPages) {
|
|
335
249
|
nav.goToPage(saved.b);
|
|
@@ -445,7 +359,7 @@
|
|
|
445
359
|
|
|
446
360
|
// ---- Lifecycle ----
|
|
447
361
|
onMount(async () => {
|
|
448
|
-
applyBranding(config);
|
|
362
|
+
applyBranding(document.documentElement, config.branding);
|
|
449
363
|
if (config.title) document.title = config.title;
|
|
450
364
|
|
|
451
365
|
// Initialize persistence and restore state. Adapter init() may throw
|
|
@@ -465,8 +379,8 @@
|
|
|
465
379
|
// cmi5 §8: an LMS-supplied masteryScore is the authoritative pass
|
|
466
380
|
// threshold for this launch and overrides the manifest. Mutate the
|
|
467
381
|
// imported config object once before any UI reads it so every
|
|
468
|
-
// downstream consumer (
|
|
469
|
-
// page context) sees the same effective value.
|
|
382
|
+
// downstream consumer (the derived completion/success status, navigation
|
|
383
|
+
// gating, Quiz page context) sees the same effective value.
|
|
470
384
|
const lmsMastery = adapter.getMasteryScore?.();
|
|
471
385
|
if (typeof lmsMastery === 'number') {
|
|
472
386
|
config.scoring.passingScore = lmsMastery * 100;
|
|
@@ -56,7 +56,9 @@
|
|
|
56
56
|
|
|
57
57
|
{#if !collapsedSections.has(section.slug)}
|
|
58
58
|
{#each section.lessons as lesson (lesson.slug)}
|
|
59
|
-
|
|
59
|
+
{#if lesson.title}
|
|
60
|
+
<div class="tessera-nav-lesson-title">{lesson.title}</div>
|
|
61
|
+
{/if}
|
|
60
62
|
{#each lesson.pages as page (page.index)}
|
|
61
63
|
{@const locked = nav.isPageLocked(page.index)}
|
|
62
64
|
<button
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
formatCorrectPattern,
|
|
6
6
|
XAPI_INTERACTION_FORMAT,
|
|
7
7
|
} from '../interaction-format.js';
|
|
8
|
-
import { formatISO8601Duration } from './format.js';
|
|
8
|
+
import { formatISO8601Duration, parseScaled01 } from './format.js';
|
|
9
9
|
import { XAPIPublisher } from '../xapi/publisher.js';
|
|
10
10
|
import { X_API_VERSION } from '../xapi/version.js';
|
|
11
11
|
import type { XAPIAgent } from '../xapi/types.js';
|
|
@@ -151,8 +151,8 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
151
151
|
|
|
152
152
|
const rawMastery = params.get('masteryScore');
|
|
153
153
|
if (rawMastery !== null && rawMastery !== '') {
|
|
154
|
-
const m =
|
|
155
|
-
if (
|
|
154
|
+
const m = parseScaled01(rawMastery);
|
|
155
|
+
if (m !== null) {
|
|
156
156
|
this.#masteryScore = m;
|
|
157
157
|
} else {
|
|
158
158
|
console.warn(
|
|
@@ -272,13 +272,9 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
272
272
|
) {
|
|
273
273
|
this.#returnURL = this.#launchData.returnURL;
|
|
274
274
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
this.#launchData.masteryScore >= 0 &&
|
|
279
|
-
this.#launchData.masteryScore <= 1
|
|
280
|
-
) {
|
|
281
|
-
this.#masteryScore = this.#launchData.masteryScore;
|
|
275
|
+
const launchMastery = parseScaled01(this.#launchData.masteryScore);
|
|
276
|
+
if (launchMastery !== null) {
|
|
277
|
+
this.#masteryScore = launchMastery;
|
|
282
278
|
}
|
|
283
279
|
}
|
|
284
280
|
|
|
@@ -65,3 +65,9 @@ export function formatISO8601Duration(totalSeconds: number): string {
|
|
|
65
65
|
if (seconds > 0 || result === 'PT') result += `${seconds}S`;
|
|
66
66
|
return result;
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
export function parseScaled01(raw: unknown): number | null {
|
|
70
|
+
if (raw === null || raw === undefined || raw === '') return null;
|
|
71
|
+
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
72
|
+
return Number.isFinite(n) && n >= 0 && n <= 1 ? n : null;
|
|
73
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
formatISO8601Duration,
|
|
6
6
|
formatISO8601Timestamp,
|
|
7
7
|
formatReal107,
|
|
8
|
+
parseScaled01,
|
|
8
9
|
} from './format.js';
|
|
9
10
|
|
|
10
11
|
export interface SCORM2004API {
|
|
@@ -92,10 +93,7 @@ export class SCORM2004Adapter extends BaseScormAdapter<SCORM2004API> {
|
|
|
92
93
|
} catch {
|
|
93
94
|
return null;
|
|
94
95
|
}
|
|
95
|
-
|
|
96
|
-
const n = Number(raw);
|
|
97
|
-
if (Number.isFinite(n) && n >= 0 && n <= 1) return n;
|
|
98
|
-
return null;
|
|
96
|
+
return parseScaled01(raw);
|
|
99
97
|
}
|
|
100
98
|
|
|
101
99
|
saveState(state: SavedState): void {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Two sentinels so the validity check doesn't false-positive when the input
|
|
2
|
+
// normalizes to the initial fillStyle.
|
|
3
|
+
export function parseColor(
|
|
4
|
+
color: string,
|
|
5
|
+
): { r: number; g: number; b: number } | null {
|
|
6
|
+
if (
|
|
7
|
+
typeof CSS !== 'undefined' &&
|
|
8
|
+
CSS.supports &&
|
|
9
|
+
!CSS.supports('color', color)
|
|
10
|
+
) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const ctx = document.createElement('canvas').getContext('2d');
|
|
14
|
+
if (!ctx) return null;
|
|
15
|
+
ctx.fillStyle = '#000';
|
|
16
|
+
ctx.fillStyle = color;
|
|
17
|
+
const onBlack = ctx.fillStyle;
|
|
18
|
+
ctx.fillStyle = '#fff';
|
|
19
|
+
ctx.fillStyle = color;
|
|
20
|
+
const onWhite = ctx.fillStyle;
|
|
21
|
+
if (onBlack !== onWhite) return null;
|
|
22
|
+
const hex = String(onBlack).match(
|
|
23
|
+
/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i,
|
|
24
|
+
);
|
|
25
|
+
if (hex)
|
|
26
|
+
return {
|
|
27
|
+
r: parseInt(hex[1], 16),
|
|
28
|
+
g: parseInt(hex[2], 16),
|
|
29
|
+
b: parseInt(hex[3], 16),
|
|
30
|
+
};
|
|
31
|
+
const rgba = String(onBlack).match(
|
|
32
|
+
/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/,
|
|
33
|
+
);
|
|
34
|
+
return rgba ? { r: +rgba[1], g: +rgba[2], b: +rgba[3] } : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function rgbToHsl(
|
|
38
|
+
r: number,
|
|
39
|
+
g: number,
|
|
40
|
+
b: number,
|
|
41
|
+
): { h: number; s: number; l: number } {
|
|
42
|
+
r /= 255;
|
|
43
|
+
g /= 255;
|
|
44
|
+
b /= 255;
|
|
45
|
+
const max = Math.max(r, g, b),
|
|
46
|
+
min = Math.min(r, g, b);
|
|
47
|
+
let h = 0,
|
|
48
|
+
s = 0;
|
|
49
|
+
const l = (max + min) / 2;
|
|
50
|
+
if (max !== min) {
|
|
51
|
+
const d = max - min;
|
|
52
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
53
|
+
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
54
|
+
else if (max === g) h = ((b - r) / d + 2) / 6;
|
|
55
|
+
else h = ((r - g) / d + 4) / 6;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
h: Math.round(h * 360),
|
|
59
|
+
s: Math.round(s * 100),
|
|
60
|
+
l: Math.round(l * 100),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function applyBranding(
|
|
65
|
+
el: HTMLElement,
|
|
66
|
+
branding: { primaryColor?: string; fontFamily?: string } | undefined,
|
|
67
|
+
): void {
|
|
68
|
+
if (branding?.primaryColor) {
|
|
69
|
+
el.style.setProperty('--tessera-primary', branding.primaryColor);
|
|
70
|
+
const rgb = parseColor(branding.primaryColor);
|
|
71
|
+
if (rgb) {
|
|
72
|
+
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
|
|
73
|
+
el.style.setProperty(
|
|
74
|
+
'--tessera-primary-light',
|
|
75
|
+
`hsl(${hsl.h}, ${Math.min(hsl.s + 10, 100)}%, 90%)`,
|
|
76
|
+
);
|
|
77
|
+
el.style.setProperty(
|
|
78
|
+
'--tessera-primary-dark',
|
|
79
|
+
`hsl(${hsl.h}, ${Math.min(hsl.s + 10, 100)}%, ${Math.max(hsl.l - 15, 10)}%)`,
|
|
80
|
+
);
|
|
81
|
+
el.style.setProperty(
|
|
82
|
+
'--tessera-focus-ring',
|
|
83
|
+
`0 0 0 3px rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.4)`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (branding?.fontFamily) {
|
|
88
|
+
el.style.setProperty('--tessera-font-family', branding.fontFamily);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -122,49 +122,16 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
|
|
|
122
122
|
reset: opts.reset,
|
|
123
123
|
interaction: () => opts.response(),
|
|
124
124
|
});
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
},
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
get answer() {
|
|
136
|
-
return q.answer;
|
|
137
|
-
},
|
|
138
|
-
get feedbackVisible() {
|
|
139
|
-
return q.feedbackVisible;
|
|
140
|
-
},
|
|
141
|
-
get locked() {
|
|
142
|
-
return q.locked;
|
|
143
|
-
},
|
|
144
|
-
get isLockedCorrect() {
|
|
145
|
-
return q.isLockedCorrect;
|
|
146
|
-
},
|
|
147
|
-
get render() {
|
|
148
|
-
return q.render;
|
|
149
|
-
},
|
|
150
|
-
setAnswer(a: unknown) {
|
|
151
|
-
q.setAnswer(a);
|
|
152
|
-
},
|
|
153
|
-
commit() {
|
|
154
|
-
q.commit();
|
|
155
|
-
},
|
|
156
|
-
submit() {},
|
|
157
|
-
reset() {
|
|
158
|
-
opts.reset?.();
|
|
159
|
-
},
|
|
160
|
-
retry() {},
|
|
161
|
-
canRetry: false,
|
|
162
|
-
retryCount: 0,
|
|
163
|
-
mode: 'quiz' as const,
|
|
164
|
-
setRender(render: unknown) {
|
|
165
|
-
q.setRender(render);
|
|
166
|
-
},
|
|
167
|
-
};
|
|
125
|
+
const handle = q as UseQuestionHandle;
|
|
126
|
+
handle.submit = () => {};
|
|
127
|
+
handle.reset = () => opts.reset?.();
|
|
128
|
+
handle.retry = () => {};
|
|
129
|
+
Object.defineProperties(handle, {
|
|
130
|
+
canRetry: { value: false },
|
|
131
|
+
retryCount: { value: 0 },
|
|
132
|
+
mode: { value: 'quiz' },
|
|
133
|
+
});
|
|
134
|
+
return handle;
|
|
168
135
|
}
|
|
169
136
|
|
|
170
137
|
const maxRetries = opts.maxRetries ?? Infinity;
|
|
@@ -197,17 +164,14 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
|
|
|
197
164
|
adapterCtx?.adapter.reportInteraction(opts.id, response, correct);
|
|
198
165
|
committed = true;
|
|
199
166
|
}
|
|
200
|
-
if (
|
|
167
|
+
if (navCtx) {
|
|
201
168
|
const pageIndex = navCtx.nav.currentPageIndex;
|
|
202
|
-
navCtx.progress.markStandaloneQuestion(
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
169
|
+
navCtx.progress.markStandaloneQuestion(
|
|
170
|
+
pageIndex,
|
|
171
|
+
opts.id,
|
|
172
|
+
score,
|
|
173
|
+
!!opts.graded,
|
|
206
174
|
);
|
|
207
|
-
navCtx.progress.recalculateSuccess(navCtx.config);
|
|
208
|
-
} else if (navCtx) {
|
|
209
|
-
const pageIndex = navCtx.nav.currentPageIndex;
|
|
210
|
-
navCtx.progress.markStandaloneQuestion(pageIndex, opts.id, score, false);
|
|
211
175
|
}
|
|
212
176
|
|
|
213
177
|
submitted = true;
|
|
@@ -357,7 +321,6 @@ export function useCompletion(): {
|
|
|
357
321
|
return;
|
|
358
322
|
}
|
|
359
323
|
progress.markCompleteManually();
|
|
360
|
-
progress.recalculateSuccess(config);
|
|
361
324
|
},
|
|
362
325
|
get completionStatus() {
|
|
363
326
|
return progress.completionStatus;
|
|
@@ -41,14 +41,9 @@ export const SCORM2004_INTERACTION_FORMAT: InteractionFormat = {
|
|
|
41
41
|
identifier: (v) => v,
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
rangeDelim: '[:]',
|
|
48
|
-
supportsNumericRange: true,
|
|
49
|
-
formatBoolean: (v) => (v ? 'true' : 'false'),
|
|
50
|
-
identifier: (v) => v,
|
|
51
|
-
};
|
|
44
|
+
// xAPI reuses SCORM 2004's delimiters, numeric-range support, and identity
|
|
45
|
+
// identifier verbatim, so it's the same format object.
|
|
46
|
+
export const XAPI_INTERACTION_FORMAT = SCORM2004_INTERACTION_FORMAT;
|
|
52
47
|
|
|
53
48
|
/**
|
|
54
49
|
* SCORM `short_identifier_type` / `CMIIdentifier`: alphanumerics +
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
2
2
|
import type { CourseConfig } from './types.js';
|
|
3
|
+
import { DEFAULT_PERCENTAGE_THRESHOLD } from './defaults.js';
|
|
3
4
|
|
|
4
5
|
export class ProgressState {
|
|
5
6
|
#quizGradedIndices: ReadonlySet<number>;
|
|
7
|
+
#config: CourseConfig;
|
|
8
|
+
#totalPages: number;
|
|
6
9
|
|
|
7
|
-
constructor(
|
|
10
|
+
constructor(
|
|
11
|
+
quizGradedIndices: ReadonlySet<number>,
|
|
12
|
+
config: CourseConfig,
|
|
13
|
+
totalPages: number,
|
|
14
|
+
) {
|
|
8
15
|
this.#quizGradedIndices = quizGradedIndices;
|
|
16
|
+
this.#config = config;
|
|
17
|
+
this.#totalPages = totalPages;
|
|
9
18
|
}
|
|
10
19
|
|
|
11
20
|
visitedPages = $state(new SvelteSet<number>());
|
|
@@ -28,12 +37,16 @@ export class ProgressState {
|
|
|
28
37
|
* Pages in this set contribute to course success status via their standalone average.
|
|
29
38
|
*/
|
|
30
39
|
gradedStandalonePages = $state(new SvelteSet<number>());
|
|
31
|
-
completionStatus = $state<'incomplete' | 'complete'>('incomplete');
|
|
32
|
-
successStatus = $state<'unknown' | 'passed' | 'failed'>('unknown');
|
|
33
40
|
|
|
34
|
-
// Latch for manual completion. Monotonic;
|
|
41
|
+
// Latch for manual completion. Monotonic; only flips forward.
|
|
35
42
|
#manuallyCompleted = $state(false);
|
|
36
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Monotonic counter incremented on every persistable state mutation. App.svelte
|
|
46
|
+
* subscribes to this single signal to schedule a coalesced save.
|
|
47
|
+
*/
|
|
48
|
+
version = $state(0);
|
|
49
|
+
|
|
37
50
|
get manuallyCompleted(): boolean {
|
|
38
51
|
return this.#manuallyCompleted;
|
|
39
52
|
}
|
|
@@ -42,41 +55,21 @@ export class ProgressState {
|
|
|
42
55
|
markCompleteManually(): void {
|
|
43
56
|
if (this.#manuallyCompleted) return;
|
|
44
57
|
this.#manuallyCompleted = true;
|
|
45
|
-
this.completionStatus = 'complete';
|
|
46
58
|
this.version++;
|
|
47
59
|
}
|
|
48
60
|
|
|
49
|
-
/**
|
|
50
|
-
* Monotonic counter incremented on every persistable state mutation
|
|
51
|
-
* (visited/scores/chunks/standalone). Callers that need to react to *any*
|
|
52
|
-
* progress change can subscribe to this single signal instead of iterating
|
|
53
|
-
* each Map/Set themselves.
|
|
54
|
-
*/
|
|
55
|
-
version = $state(0);
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Mark a page as visited. Callers must call recalculateCompletion()
|
|
59
|
-
* afterward to update completionStatus.
|
|
60
|
-
*/
|
|
61
61
|
markVisited(pageIndex: number) {
|
|
62
62
|
if (this.visitedPages.has(pageIndex)) return;
|
|
63
63
|
this.visitedPages.add(pageIndex);
|
|
64
64
|
this.version++;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
/**
|
|
68
|
-
* Record a quiz score. Callers must call recalculateCompletion()
|
|
69
|
-
* and recalculateSuccess() afterward to update status fields.
|
|
70
|
-
*/
|
|
71
67
|
quizCompleted(pageIndex: number, score: number) {
|
|
72
68
|
this.quizScores.set(pageIndex, score);
|
|
73
69
|
this.version++;
|
|
74
70
|
}
|
|
75
71
|
|
|
76
|
-
/**
|
|
77
|
-
* Record the highest chunk index revealed on a page. Idempotent — only
|
|
78
|
-
* advances forward, never backward.
|
|
79
|
-
*/
|
|
72
|
+
/** Record the highest chunk index revealed on a page. Only advances forward. */
|
|
80
73
|
markChunk(pageIndex: number, chunkIndex: number) {
|
|
81
74
|
const current = this.chunkProgress.get(pageIndex) ?? -1;
|
|
82
75
|
if (chunkIndex <= current) return;
|
|
@@ -89,11 +82,6 @@ export class ProgressState {
|
|
|
89
82
|
return this.chunkProgress.get(pageIndex) ?? -1;
|
|
90
83
|
}
|
|
91
84
|
|
|
92
|
-
/**
|
|
93
|
-
* Record the score for a single standalone question (one created via
|
|
94
|
-
* `useQuestion` outside a `<Quiz>`). When `graded`, the page is added to
|
|
95
|
-
* `gradedStandalonePages` so it contributes to course success.
|
|
96
|
-
*/
|
|
97
85
|
markStandaloneQuestion(
|
|
98
86
|
pageIndex: number,
|
|
99
87
|
questionId: string,
|
|
@@ -121,66 +109,48 @@ export class ProgressState {
|
|
|
121
109
|
return sum / pageMap.size;
|
|
122
110
|
}
|
|
123
111
|
|
|
124
|
-
|
|
125
|
-
if (this.#manuallyCompleted) return;
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
|
|
112
|
+
completionStatus = $derived.by<'incomplete' | 'complete'>(() => {
|
|
113
|
+
if (this.#manuallyCompleted) return 'complete';
|
|
114
|
+
const mode = this.#config.completion.mode;
|
|
115
|
+
if (mode === 'manual') return 'incomplete';
|
|
116
|
+
if (mode === 'percentage') {
|
|
117
|
+
const threshold =
|
|
118
|
+
this.#config.completion.percentageThreshold ??
|
|
119
|
+
DEFAULT_PERCENTAGE_THRESHOLD;
|
|
129
120
|
const percent =
|
|
130
|
-
totalPages > 0
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (indices.length === 0) {
|
|
135
|
-
this.completionStatus = 'incomplete';
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
const average = this.#gradedAverage(indices);
|
|
139
|
-
this.completionStatus =
|
|
140
|
-
average >= config.scoring.passingScore ? 'complete' : 'incomplete';
|
|
121
|
+
this.#totalPages > 0
|
|
122
|
+
? (this.visitedPages.size / this.#totalPages) * 100
|
|
123
|
+
: 0;
|
|
124
|
+
return percent >= threshold ? 'complete' : 'incomplete';
|
|
141
125
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
126
|
+
const { indices } = this.#gradedPages();
|
|
127
|
+
if (indices.length === 0) return 'incomplete';
|
|
128
|
+
return this.#gradedAverage(indices) >= this.#config.scoring.passingScore
|
|
129
|
+
? 'complete'
|
|
130
|
+
: 'incomplete';
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
successStatus = $derived.by<'unknown' | 'passed' | 'failed'>(() => {
|
|
134
|
+
if (this.#config.completion.mode === 'manual') {
|
|
135
|
+
const want = this.#config.completion.requireSuccessStatus;
|
|
136
|
+
return this.#manuallyCompleted && want !== undefined ? want : 'unknown';
|
|
152
137
|
}
|
|
153
|
-
|
|
154
138
|
const { indices, attempted } = this.#gradedPages();
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
// Stay unknown until at least one graded score has been recorded
|
|
161
|
-
if (!attempted) {
|
|
162
|
-
this.successStatus = 'unknown';
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
const average = this.#gradedAverage(indices);
|
|
166
|
-
this.successStatus =
|
|
167
|
-
average >= config.scoring.passingScore ? 'passed' : 'failed';
|
|
168
|
-
}
|
|
139
|
+
if (indices.length === 0 || !attempted) return 'unknown';
|
|
140
|
+
return this.#gradedAverage(indices) >= this.#config.scoring.passingScore
|
|
141
|
+
? 'passed'
|
|
142
|
+
: 'failed';
|
|
143
|
+
});
|
|
169
144
|
|
|
170
145
|
/**
|
|
171
146
|
* Effective graded score for LMS reporting — same union and averaging as
|
|
172
|
-
*
|
|
147
|
+
* successStatus, so score and success status can't disagree.
|
|
173
148
|
*/
|
|
174
149
|
gradedScore(): { average: number; attempted: boolean } {
|
|
175
150
|
const { indices, attempted } = this.#gradedPages();
|
|
176
151
|
return { average: this.#gradedAverage(indices), attempted };
|
|
177
152
|
}
|
|
178
153
|
|
|
179
|
-
/**
|
|
180
|
-
* Union of pages that contribute to graded scoring: pageConfig graded quizzes
|
|
181
|
-
* plus pages with at least one graded standalone question (deduped).
|
|
182
|
-
* `attempted` is true if any of those pages has a recorded score.
|
|
183
|
-
*/
|
|
184
154
|
#gradedPages(): { indices: number[]; attempted: boolean } {
|
|
185
155
|
const merged = new Set(this.#quizGradedIndices);
|
|
186
156
|
for (const i of this.gradedStandalonePages) merged.add(i);
|
|
@@ -189,18 +159,12 @@ export class ProgressState {
|
|
|
189
159
|
return { indices, attempted };
|
|
190
160
|
}
|
|
191
161
|
|
|
192
|
-
/** Whether a page has any recorded graded score (quiz or standalone). */
|
|
193
162
|
#hasScore(pageIndex: number): boolean {
|
|
194
163
|
if (this.quizScores.has(pageIndex)) return true;
|
|
195
164
|
const pageMap = this.standaloneQuestionScores.get(pageIndex);
|
|
196
165
|
return !!pageMap && pageMap.size > 0;
|
|
197
166
|
}
|
|
198
167
|
|
|
199
|
-
/**
|
|
200
|
-
* Average across the given page indices. Each page contributes its quiz score
|
|
201
|
-
* if present, otherwise its standalone average. Pages with no recorded score
|
|
202
|
-
* contribute 0 (matching the existing "unattempted graded quiz = 0" rule).
|
|
203
|
-
*/
|
|
204
168
|
#gradedAverage(indices: number[]): number {
|
|
205
169
|
if (indices.length === 0) return 0;
|
|
206
170
|
let sum = 0;
|