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.
Files changed (68) hide show
  1. package/AGENTS.md +1794 -0
  2. package/README.md +5 -5
  3. package/dist/{validation-B-xTvM9B.js → audit-BA5o0ick.js} +605 -269
  4. package/dist/audit-BA5o0ick.js.map +1 -0
  5. package/dist/build-commands-C0OnV-Vg.js +27 -0
  6. package/dist/build-commands-C0OnV-Vg.js.map +1 -0
  7. package/dist/inline-config-CroQ-_2Y.js +31 -0
  8. package/dist/inline-config-CroQ-_2Y.js.map +1 -0
  9. package/dist/plugin/cli.d.ts +9 -1
  10. package/dist/plugin/cli.d.ts.map +1 -0
  11. package/dist/plugin/cli.js +326 -17
  12. package/dist/plugin/cli.js.map +1 -1
  13. package/dist/plugin/index.d.ts +1 -1
  14. package/dist/plugin/index.d.ts.map +1 -1
  15. package/dist/plugin/index.js +2 -763
  16. package/dist/plugin-W_rk3Pit.js +731 -0
  17. package/dist/plugin-W_rk3Pit.js.map +1 -0
  18. package/package.json +21 -9
  19. package/src/components/FillInTheBlank.svelte +2 -2
  20. package/src/components/Matching.svelte +2 -2
  21. package/src/components/MultipleChoice.svelte +2 -2
  22. package/src/components/RevealModal.svelte +48 -103
  23. package/src/components/Sorting.svelte +2 -2
  24. package/src/components/util.ts +9 -0
  25. package/src/plugin/a11y/audit.ts +40 -8
  26. package/src/plugin/a11y-cli.ts +39 -22
  27. package/src/plugin/ast.ts +276 -0
  28. package/src/plugin/build-commands.ts +31 -0
  29. package/src/plugin/cli.ts +96 -21
  30. package/src/plugin/course-root.ts +98 -0
  31. package/src/plugin/duplicate-cli.ts +74 -0
  32. package/src/plugin/index.ts +87 -122
  33. package/src/plugin/inline-config.ts +54 -0
  34. package/src/plugin/manifest.ts +103 -136
  35. package/src/plugin/new-cli.ts +51 -0
  36. package/src/plugin/package-root.ts +24 -0
  37. package/src/plugin/project-name.ts +29 -0
  38. package/src/plugin/quiz.ts +8 -9
  39. package/src/plugin/template-copy.ts +43 -0
  40. package/src/plugin/validate-cli.ts +30 -0
  41. package/src/plugin/validation.ts +152 -244
  42. package/src/runtime/App.svelte +11 -97
  43. package/src/runtime/Sidebar.svelte +3 -1
  44. package/src/runtime/adapters/cmi5.ts +6 -10
  45. package/src/runtime/adapters/format.ts +6 -0
  46. package/src/runtime/adapters/retry.ts +1 -1
  47. package/src/runtime/adapters/scorm2004.ts +2 -4
  48. package/src/runtime/branding.ts +90 -0
  49. package/src/runtime/defaults.ts +3 -0
  50. package/src/runtime/hooks.svelte.ts +16 -53
  51. package/src/runtime/interaction-format.ts +3 -8
  52. package/src/runtime/progress.svelte.ts +47 -83
  53. package/src/runtime/xapi/derive-actor.ts +41 -48
  54. package/src/runtime/xapi/publisher.ts +14 -14
  55. package/src/runtime/xapi/setup.ts +39 -46
  56. package/templates/course/course.config.js +11 -0
  57. package/templates/course/layout.svelte +116 -0
  58. package/templates/course/pages/01-getting-started/01-welcome/_meta.js +1 -0
  59. package/templates/course/pages/01-getting-started/01-welcome/welcome.svelte +19 -0
  60. package/templates/course/pages/01-getting-started/_meta.js +1 -0
  61. package/templates/course/styles/custom.css +5 -0
  62. package/dist/audit-BBJpQGqb.js +0 -204
  63. package/dist/audit-BBJpQGqb.js.map +0 -1
  64. package/dist/plugin/a11y-cli.d.ts +0 -1
  65. package/dist/plugin/a11y-cli.js +0 -36
  66. package/dist/plugin/a11y-cli.js.map +0 -1
  67. package/dist/plugin/index.js.map +0 -1
  68. package/dist/validation-B-xTvM9B.js.map +0 -1
@@ -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(gradedQuizIndices);
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 ?? 70,
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 (recalculateSuccess, navigation gating, Quiz
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
- <div class="tessera-nav-lesson-title">{lesson.title}</div>
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 = Number(rawMastery);
155
- if (Number.isFinite(m) && m >= 0 && m <= 1) {
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
- if (
276
- typeof this.#launchData.masteryScore === 'number' &&
277
- Number.isFinite(this.#launchData.masteryScore) &&
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
+ }
@@ -42,7 +42,7 @@ function logRetryGiveUp(
42
42
  );
43
43
  }
44
44
 
45
- export function formatLMSErrorDetail(
45
+ function formatLMSErrorDetail(
46
46
  errorReporter: LMSErrorReporter | undefined,
47
47
  code: string,
48
48
  ): string {
@@ -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
- if (!raw) return null;
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
+ }
@@ -0,0 +1,3 @@
1
+ export const DEFAULT_PASSING_SCORE = 70;
2
+
3
+ export const DEFAULT_PERCENTAGE_THRESHOLD = 100;
@@ -122,49 +122,16 @@ export function useQuestion(opts: UseQuestionOptions): UseQuestionHandle {
122
122
  reset: opts.reset,
123
123
  interaction: () => opts.response(),
124
124
  });
125
- return {
126
- get id() {
127
- return q.id;
128
- },
129
- get submitted() {
130
- return q.submitted;
131
- },
132
- get correct() {
133
- return q.correct;
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 (opts.graded && navCtx) {
167
+ if (navCtx) {
201
168
  const pageIndex = navCtx.nav.currentPageIndex;
202
- navCtx.progress.markStandaloneQuestion(pageIndex, opts.id, score, true);
203
- navCtx.progress.recalculateCompletion(
204
- navCtx.manifest.totalPages,
205
- navCtx.config,
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
- export const XAPI_INTERACTION_FORMAT: InteractionFormat = {
45
- itemDelim: '[,]',
46
- pairDelim: '[.]',
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(quizGradedIndices: ReadonlySet<number>) {
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; recalc methods bail when set.
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
- recalculateCompletion(totalPages: number, config: CourseConfig) {
125
- if (this.#manuallyCompleted) return;
126
- if (config.completion.mode === 'manual') return;
127
- if (config.completion.mode === 'percentage') {
128
- const threshold = config.completion.percentageThreshold ?? 100;
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 ? (this.visitedPages.size / totalPages) * 100 : 0;
131
- this.completionStatus = percent >= threshold ? 'complete' : 'incomplete';
132
- } else if (config.completion.mode === 'quiz') {
133
- const { indices } = this.#gradedPages();
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
- recalculateSuccess(config: CourseConfig) {
145
- if (config.completion.mode === 'manual') {
146
- const want = config.completion.requireSuccessStatus;
147
- // Stay 'unknown' until manual mark fires, so a learner who never
148
- // finishes isn't reported as passed.
149
- this.successStatus =
150
- this.#manuallyCompleted && want !== undefined ? want : 'unknown';
151
- return;
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
- if (indices.length === 0) {
157
- this.successStatus = 'unknown';
158
- return;
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
- * recalculateSuccess, so score and success status can't disagree.
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;