tessera-learn 0.0.9 → 0.0.11

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 (45) hide show
  1. package/dist/plugin/cli.js +5 -3
  2. package/dist/plugin/cli.js.map +1 -1
  3. package/dist/plugin/index.d.ts.map +1 -1
  4. package/dist/plugin/index.js +215 -124
  5. package/dist/plugin/index.js.map +1 -1
  6. package/dist/{validation-BxWAMMnJ.js → validation-D9DXlqNP.js} +77 -65
  7. package/dist/validation-D9DXlqNP.js.map +1 -0
  8. package/package.json +9 -6
  9. package/src/components/Audio.svelte +5 -2
  10. package/src/components/DefaultLayout.svelte +2 -0
  11. package/src/components/FillInTheBlank.svelte +60 -98
  12. package/src/components/Image.svelte +3 -8
  13. package/src/components/LockedBanner.svelte +3 -4
  14. package/src/components/MultipleChoice.svelte +53 -94
  15. package/src/components/Quiz.svelte +2 -1
  16. package/src/components/Video.svelte +4 -2
  17. package/src/components/util.ts +1 -0
  18. package/src/plugin/cli.ts +2 -7
  19. package/src/plugin/export.ts +23 -41
  20. package/src/plugin/index.ts +197 -56
  21. package/src/plugin/layout.ts +6 -51
  22. package/src/plugin/manifest.ts +31 -5
  23. package/src/plugin/override-plugin.ts +68 -0
  24. package/src/plugin/quiz.ts +9 -54
  25. package/src/plugin/validation.ts +38 -67
  26. package/src/runtime/App.svelte +48 -36
  27. package/src/runtime/LoadingBar.svelte +47 -0
  28. package/src/runtime/Sidebar.svelte +2 -0
  29. package/src/runtime/adapters/cmi5.ts +13 -83
  30. package/src/runtime/adapters/format.ts +67 -0
  31. package/src/runtime/adapters/index.ts +28 -29
  32. package/src/runtime/adapters/retry.ts +0 -64
  33. package/src/runtime/adapters/scorm12.ts +1 -1
  34. package/src/runtime/adapters/scorm2004.ts +11 -16
  35. package/src/runtime/hooks.svelte.ts +14 -16
  36. package/src/runtime/navigation.svelte.ts +51 -45
  37. package/src/runtime/progress.svelte.ts +25 -10
  38. package/src/runtime/quiz-policy.ts +21 -178
  39. package/src/runtime/xapi/agent-rules.ts +7 -2
  40. package/src/runtime/xapi/publisher.ts +1 -11
  41. package/src/runtime/xapi/validation.ts +1 -1
  42. package/src/virtual.d.ts +13 -0
  43. package/styles/layout.css +34 -24
  44. package/dist/validation-BxWAMMnJ.js.map +0 -1
  45. package/src/runtime/LoadingSkeleton.svelte +0 -26
@@ -1,6 +1,7 @@
1
1
  import type { Manifest } from '../plugin/manifest.js';
2
2
  import type { CourseConfig } from './types.js';
3
3
  import { ProgressState } from './progress.svelte.js';
4
+ import { resolveAccess } from './access.js';
4
5
 
5
6
  export function isPageComplete(
6
7
  index: number,
@@ -22,10 +23,13 @@ export function isPageComplete(
22
23
  return (progress.quizScores.get(index) ?? 0) >= config.scoring.passingScore;
23
24
  }
24
25
 
26
+ export type PageModuleMap = Record<string, () => Promise<unknown>>;
27
+
25
28
  export class NavigationState {
26
29
  manifest = $state<Manifest>(null!);
27
30
  #progress: ProgressState;
28
31
  #config: CourseConfig;
32
+ #pageModules: PageModuleMap | null = null;
29
33
  currentPageIndex = $state(0);
30
34
 
31
35
  canGoPrev = $derived(this.currentPageIndex > 0);
@@ -36,11 +40,24 @@ export class NavigationState {
36
40
  return !this.isPageLocked(next);
37
41
  });
38
42
 
39
- // Cache locked-page lookup as a single derived Set so the sidebar's
40
- // per-page `isPageLocked` calls stay O(1). Without this, sequential mode
41
- // is O(n²) per render (each `isPageLocked` walks all earlier pages).
42
- // Recomputed once per relevant state change.
43
- #lockedSet = $derived.by<Set<number>>(() => this.#computeLockedSet());
43
+ // Memo cache so the derived can return a stable Set reference when
44
+ // membership is unchanged (two Sets with identical contents are not `===`).
45
+ // Must NOT be `$state` that would make this a reactive-state mutation
46
+ // from inside a derived.
47
+ #prevLockedSet: Set<number> | null = null;
48
+ #lockedSet = $derived.by<Set<number>>(() => {
49
+ const next = this.#computeLockedSet();
50
+ const prev = this.#prevLockedSet;
51
+ if (prev && prev.size === next.size) {
52
+ let same = true;
53
+ for (const i of next) {
54
+ if (!prev.has(i)) { same = false; break; }
55
+ }
56
+ if (same) return prev;
57
+ }
58
+ this.#prevLockedSet = next;
59
+ return next;
60
+ });
44
61
 
45
62
  constructor(manifest: Manifest, progress: ProgressState, config: CourseConfig) {
46
63
  this.manifest = manifest;
@@ -48,6 +65,23 @@ export class NavigationState {
48
65
  this.#config = config;
49
66
  }
50
67
 
68
+ setPageModules(modules: PageModuleMap) {
69
+ this.#pageModules = modules;
70
+ }
71
+
72
+ /**
73
+ * Warm the browser module cache for a page chunk. Idempotent — repeated
74
+ * calls for the same index hit the existing cache. Bails on locked pages
75
+ * so callers don't need to guard.
76
+ */
77
+ prefetch(index: number) {
78
+ if (!this.#pageModules) return;
79
+ if (index < 0 || index >= this.manifest.totalPages) return;
80
+ if (this.isPageLocked(index)) return;
81
+ const page = this.manifest.pages[index];
82
+ this.#pageModules[page.importPath]?.();
83
+ }
84
+
51
85
  goToPage(index: number) {
52
86
  if (index < 0 || index >= this.manifest.totalPages) return;
53
87
  if (this.isPageLocked(index)) return;
@@ -66,50 +100,22 @@ export class NavigationState {
66
100
  return this.#lockedSet.has(index);
67
101
  }
68
102
 
69
- // Compute the locked set in a single forward pass. The built-in modes are
70
- // expressed inline (rather than calling resolveAccess/freeAccess/sequential
71
- // per page) so the whole walk is O(n). Custom predicates fall back to a
72
- // per-page evaluation since their semantics are arbitrary — but it still
73
- // runs once per state change rather than once per page per render.
103
+ // Resolve the access predicate once (custom canAccess, or the free /
104
+ // sequential preset) and evaluate it per page. Runs once per state change
105
+ // the presets are the single source of truth for the gating rules.
74
106
  #computeLockedSet(): Set<number> {
75
107
  const total = this.manifest.totalPages;
76
108
  const locked = new Set<number>();
77
-
78
- if (this.#config.navigation.canAccess) {
79
- const fn = this.#config.navigation.canAccess;
80
- for (let i = 0; i < total; i++) {
81
- if (!fn({
82
- pageIndex: i,
83
- page: this.manifest.pages[i],
84
- manifest: this.manifest,
85
- progress: this.#progress,
86
- config: this.#config,
87
- })) {
88
- locked.add(i);
89
- }
90
- }
91
- return locked;
92
- }
93
-
94
- if (this.#config.navigation.mode === 'sequential') {
95
- // Once any page is incomplete, every later page is locked.
96
- for (let i = 1; i < total; i++) {
97
- if (!isPageComplete(i - 1, this.manifest, this.#progress, this.#config)) {
98
- for (let k = i; k < total; k++) locked.add(k);
99
- return locked;
100
- }
101
- }
102
- return locked;
103
- }
104
-
105
- // Free mode: a page is locked iff its most-recent gating quiz is unmet.
106
- let lastGatingUnmet = false;
109
+ const access = resolveAccess(this.#config);
107
110
  for (let i = 0; i < total; i++) {
108
- if (lastGatingUnmet) locked.add(i);
109
- const page = this.manifest.pages[i];
110
- if (page.quiz?.gatesProgress) {
111
- const score = this.#progress.quizScores.get(i) ?? 0;
112
- lastGatingUnmet = score < this.#config.scoring.passingScore;
111
+ if (!access({
112
+ pageIndex: i,
113
+ page: this.manifest.pages[i],
114
+ manifest: this.manifest,
115
+ progress: this.#progress,
116
+ config: this.#config,
117
+ })) {
118
+ locked.add(i);
113
119
  }
114
120
  }
115
121
  return locked;
@@ -1,8 +1,13 @@
1
1
  import { SvelteMap, SvelteSet } from 'svelte/reactivity';
2
- import type { Manifest } from '../plugin/manifest.js';
3
2
  import type { CourseConfig } from './types.js';
4
3
 
5
4
  export class ProgressState {
5
+ #quizGradedIndices: ReadonlySet<number>;
6
+
7
+ constructor(quizGradedIndices: ReadonlySet<number>) {
8
+ this.#quizGradedIndices = quizGradedIndices;
9
+ }
10
+
6
11
  visitedPages = $state(new SvelteSet<number>());
7
12
  quizScores = $state(new SvelteMap<number, number>());
8
13
  /**
@@ -114,17 +119,17 @@ export class ProgressState {
114
119
  return sum / pageMap.size;
115
120
  }
116
121
 
117
- recalculateCompletion(manifest: Manifest, config: CourseConfig) {
122
+ recalculateCompletion(totalPages: number, config: CourseConfig) {
118
123
  if (this.#manuallyCompleted) return;
119
124
  if (config.completion.mode === 'manual') return;
120
125
  if (config.completion.mode === 'percentage') {
121
126
  const threshold = config.completion.percentageThreshold ?? 100;
122
- const percent = manifest.totalPages > 0
123
- ? (this.visitedPages.size / manifest.totalPages) * 100
127
+ const percent = totalPages > 0
128
+ ? (this.visitedPages.size / totalPages) * 100
124
129
  : 0;
125
130
  this.completionStatus = percent >= threshold ? 'complete' : 'incomplete';
126
131
  } else if (config.completion.mode === 'quiz') {
127
- const { indices } = this.#gradedPages(manifest);
132
+ const { indices } = this.#gradedPages();
128
133
  if (indices.length === 0) {
129
134
  this.completionStatus = 'incomplete';
130
135
  return;
@@ -134,7 +139,7 @@ export class ProgressState {
134
139
  }
135
140
  }
136
141
 
137
- recalculateSuccess(manifest: Manifest, config: CourseConfig) {
142
+ recalculateSuccess(config: CourseConfig) {
138
143
  if (config.completion.mode === 'manual') {
139
144
  const want = config.completion.requireSuccessStatus;
140
145
  // Stay 'unknown' until manual mark fires, so a learner who never
@@ -143,7 +148,7 @@ export class ProgressState {
143
148
  return;
144
149
  }
145
150
 
146
- const { indices, attempted } = this.#gradedPages(manifest);
151
+ const { indices, attempted } = this.#gradedPages();
147
152
 
148
153
  if (indices.length === 0) {
149
154
  this.successStatus = 'unknown';
@@ -158,14 +163,24 @@ export class ProgressState {
158
163
  this.successStatus = average >= config.scoring.passingScore ? 'passed' : 'failed';
159
164
  }
160
165
 
166
+ /**
167
+ * Effective graded score for LMS reporting — same union and averaging as
168
+ * recalculateSuccess, so score and success status can't disagree.
169
+ */
170
+ gradedScore(): { average: number; attempted: boolean } {
171
+ const { indices, attempted } = this.#gradedPages();
172
+ return { average: this.#gradedAverage(indices), attempted };
173
+ }
174
+
161
175
  /**
162
176
  * Union of pages that contribute to graded scoring: pageConfig graded quizzes
163
177
  * plus pages with at least one graded standalone question (deduped).
164
178
  * `attempted` is true if any of those pages has a recorded score.
165
179
  */
166
- #gradedPages(manifest: Manifest): { indices: number[]; attempted: boolean } {
167
- const quizPages = manifest.pages.filter(p => p.quiz?.graded).map(p => p.index);
168
- const indices = [...new Set([...quizPages, ...this.gradedStandalonePages])];
180
+ #gradedPages(): { indices: number[]; attempted: boolean } {
181
+ const merged = new Set(this.#quizGradedIndices);
182
+ for (const i of this.gradedStandalonePages) merged.add(i);
183
+ const indices = [...merged];
169
184
  const attempted = indices.some(i => this.#hasScore(i));
170
185
  return { indices, attempted };
171
186
  }
@@ -1,10 +1,12 @@
1
1
  /**
2
- * Quiz config desugaring. Authors drive feedback / retry / submit-gating /
3
- * scoring with either string enums or predicate functions; this module
4
- * normalizes both forms into predicates so `useQuiz` only ever interacts
5
- * with the predicate API.
2
+ * Quiz config desugaring. Authors pick feedback / retry behavior with string
3
+ * enums in `pageConfig.quiz`; this module normalizes them into predicates so
4
+ * `useQuiz` only ever interacts with the predicate API. Config is extracted
5
+ * from source as a static object literal (JSON5), so only the enum forms are
6
+ * representable — there are no function-valued options.
6
7
  */
7
8
  import type { Interaction } from './interaction.js';
9
+ import type { QuizConfig } from './types.js';
8
10
 
9
11
  export interface QuizQuestionResult {
10
12
  /** The original interaction reported for the question. */
@@ -15,12 +17,7 @@ export interface QuizQuestionResult {
15
17
  weight: number;
16
18
  }
17
19
 
18
- /**
19
- * State the feedback predicate is given so it can decide independently of
20
- * the string-enum branches inside `useQuiz`. The predicate is the single
21
- * source of truth — the enums (`'immediate'` / `'review'`) desugar into
22
- * predicates over this same state.
23
- */
20
+ /** State the feedback predicate decides over. */
24
21
  export interface FeedbackVisibilityState {
25
22
  /** Index of the question being asked about. */
26
23
  questionIndex: number;
@@ -30,11 +27,7 @@ export interface FeedbackVisibilityState {
30
27
  reviewing: boolean;
31
28
  /** Has the question been answered (the shell called `setAnswer`)? */
32
29
  hasAnswered: boolean;
33
- /**
34
- * Has the shell explicitly revealed feedback for this question via
35
- * `revealFeedback(index)`? Lets `'immediate'` flows distinguish "answered
36
- * but not yet revealed" from "Check Answer button pressed."
37
- */
30
+ /** Has the shell revealed feedback for this question via `revealFeedback`? */
38
31
  revealed: boolean;
39
32
  /** Number of times `submit()` has fired for this quiz instance. */
40
33
  attemptCount: number;
@@ -42,102 +35,29 @@ export interface FeedbackVisibilityState {
42
35
 
43
36
  export type FeedbackModePredicate = (state: FeedbackVisibilityState) => boolean;
44
37
  export type RetryStrategyPredicate = (results: QuizQuestionResult[]) => Set<number>;
45
- export type CanSubmitPredicate = (answeredCount: number, totalCount: number) => boolean;
46
- export type ScorePredicate = (results: QuizQuestionResult[]) => number;
47
-
48
- export interface QuizPolicyConfig {
49
- /**
50
- * When feedback for a question should render:
51
- * - `'immediate'` — after the shell calls `revealFeedback(q)` for the question.
52
- * - `'review'` (default) — only while the quiz is in review mode.
53
- * - `'never'` — feedback never renders, no Review button.
54
- * - predicate `(state) => boolean` — full control over visibility.
55
- *
56
- * Predicates receive a `FeedbackVisibilityState` so they can decide
57
- * independently of the enum branches — the enums themselves desugar to
58
- * predicates over the same state.
59
- */
60
- feedbackMode?: 'immediate' | 'review' | 'never' | FeedbackModePredicate;
61
- /**
62
- * On retry, clear every answer (`'full'`), preserve correct answers
63
- * (`'incorrect-only'`), or pass a custom predicate that takes the previous
64
- * attempt's results and returns the set of question indices to keep locked.
65
- */
66
- retryMode?: 'full' | 'incorrect-only' | RetryStrategyPredicate;
67
- /**
68
- * Custom gate for the Submit button. Defaults to "every registered
69
- * question has an answer". Predicates take (answered, total).
70
- */
71
- canSubmit?: CanSubmitPredicate;
72
- /**
73
- * Custom score formula. Defaults to weighted-correct percentage —
74
- * `Σ(weight × correct) / Σ(weight) × 100`. Authors must return a value in
75
- * 0–100; values outside that range warn in dev mode.
76
- */
77
- score?: ScorePredicate;
78
- }
79
38
 
80
39
  /**
81
- * Resolve the configured feedback policy into a single predicate that owns
82
- * the "should this question's feedback be visible right now?" decision.
83
- *
84
- * The shipping enums desugar to:
85
- * - `'immediate'` — visible after the shell calls `revealFeedback(q)` for
86
- * the question, OR while the quiz is in review mode.
87
- * - `'review'` (default) — visible only while the quiz is in review mode.
88
- * - `'never'` — never visible. `useQuiz` short-circuits before calling here.
89
- *
90
- * Predicates receive the full visibility state so they can encode any policy
91
- * — e.g. "only after first wrong attempt": `(s) => s.attemptCount > 0 && s.submitted`.
40
+ * Resolve the configured feedback policy into the "should this question's
41
+ * feedback be visible now?" predicate.
42
+ * - `'immediate'` — visible after the shell calls `revealFeedback(q)`, or in review.
43
+ * - `'review'` (default) visible only while reviewing.
44
+ * - `'never'` — never visible (`useQuiz` short-circuits before calling here).
92
45
  */
93
- export function resolveFeedbackMode(cfg: QuizPolicyConfig | undefined | null): FeedbackModePredicate {
46
+ export function resolveFeedbackMode(cfg: QuizConfig | undefined | null): FeedbackModePredicate {
94
47
  const mode = cfg?.feedbackMode;
95
- if (typeof mode === 'function') return mode;
96
- if (mode === 'immediate') {
97
- return (s) => s.revealed || s.reviewing;
98
- }
99
- if (mode === 'never') {
100
- return () => false;
101
- }
102
- // Default + 'review'
48
+ if (mode === 'immediate') return (s) => s.revealed || s.reviewing;
49
+ if (mode === 'never') return () => false;
103
50
  return (s) => s.reviewing;
104
51
  }
105
52
 
106
- function isDevMode(): boolean {
107
- return import.meta.env?.DEV === true;
108
- }
109
-
110
53
  /**
111
- * Resolve the configured retry strategy into a predicate that returns the
112
- * set of question indices to lock as "already correct" on the next attempt.
113
- *
114
- * - `'full'` (default) — reset everything.
54
+ * Resolve the retry strategy into a predicate returning the set of question
55
+ * indices to lock as "already correct" on the next attempt.
115
56
  * - `'incorrect-only'` — keep questions the learner got right.
116
- * - functionauthor decides per result.
117
- *
118
- * Author predicates are wrapped: a non-Set return turns into "lock nothing"
119
- * in production and throws in dev so the bug stays local. An author returning
120
- * `[0, 1]` instead of `new Set([0, 1])` would otherwise silently no-op the
121
- * lock and quietly break `'incorrect-only'`-style retries.
57
+ * - `'full'` (default) reset everything.
122
58
  */
123
- export function resolveRetryStrategy(cfg: QuizPolicyConfig | undefined | null): RetryStrategyPredicate {
124
- const mode = cfg?.retryMode;
125
- if (typeof mode === 'function') {
126
- return (results) => {
127
- const raw = mode(results);
128
- if (!(raw instanceof Set)) {
129
- if (isDevMode()) {
130
- throw new TypeError(
131
- `[tessera] quiz retryMode predicate returned ${Object.prototype.toString.call(raw)}; ` +
132
- `expected a Set<number> of question indices to lock.`
133
- );
134
- }
135
- return new Set<number>();
136
- }
137
- return raw;
138
- };
139
- }
140
- if (mode === 'incorrect-only') {
59
+ export function resolveRetryStrategy(cfg: QuizConfig | undefined | null): RetryStrategyPredicate {
60
+ if (cfg?.retryMode === 'incorrect-only') {
141
61
  return (results) => {
142
62
  const locked = new Set<number>();
143
63
  results.forEach((r, i) => {
@@ -146,82 +66,5 @@ export function resolveRetryStrategy(cfg: QuizPolicyConfig | undefined | null):
146
66
  return locked;
147
67
  };
148
68
  }
149
- // Default 'full': clear every answer.
150
69
  return () => new Set<number>();
151
70
  }
152
-
153
- /**
154
- * Resolve the Submit gate. Default — all answered.
155
- *
156
- * Author predicates are wrapped: a non-boolean return is coerced with `!!` in
157
- * production and throws in dev. Authors returning `answered` (a number) would
158
- * otherwise enable Submit on `0` answered ↔ disable on a count that happens
159
- * to equal `NaN` — silently wrong gates either way.
160
- */
161
- export function resolveCanSubmit(cfg: QuizPolicyConfig | undefined | null): CanSubmitPredicate {
162
- if (typeof cfg?.canSubmit === 'function') {
163
- const fn = cfg.canSubmit;
164
- return (answered, total) => {
165
- const raw = fn(answered, total);
166
- if (typeof raw !== 'boolean') {
167
- if (isDevMode()) {
168
- throw new TypeError(
169
- `[tessera] quiz canSubmit predicate returned ${typeof raw}; expected a boolean.`
170
- );
171
- }
172
- return !!raw;
173
- }
174
- return raw;
175
- };
176
- }
177
- return (answered, total) => total > 0 && answered >= total;
178
- }
179
-
180
- /**
181
- * Resolve the score formula. Default — weighted-correct percentage. With all
182
- * weights = 1 (the default for every existing course), the output equals the
183
- * pre-Phase-5 unweighted formula.
184
- */
185
- export function resolveScore(cfg: QuizPolicyConfig | undefined | null): ScorePredicate {
186
- if (typeof cfg?.score === 'function') {
187
- return (results) => {
188
- const raw = cfg.score!(results);
189
- const isDev = isDevMode();
190
- if (typeof raw !== 'number' || !Number.isFinite(raw)) {
191
- // NaN/Infinity/non-number can't ride through to setScore(...) — the LMS
192
- // either rejects the cmi write or rolls it up to nonsense. Throw in dev
193
- // so the bug stays local; clamp to 0 in prod so a runaway predicate
194
- // can't crash the learner's session.
195
- if (isDev) {
196
- throw new TypeError(
197
- `[tessera] quiz score predicate returned ${String(raw)}; expected a finite number in 0–100.`
198
- );
199
- }
200
- return 0;
201
- }
202
- if (raw < 0 || raw > 100) {
203
- if (isDev) {
204
- // eslint-disable-next-line no-console
205
- console.warn(
206
- `[tessera] quiz score predicate returned ${raw}; expected a finite number in 0–100. ` +
207
- `Clamping to range — LMSes reject out-of-range cmi.score.raw values.`
208
- );
209
- }
210
- return Math.max(0, Math.min(100, raw));
211
- }
212
- return raw;
213
- };
214
- }
215
- return (results) => {
216
- if (results.length === 0) return 0;
217
- let weighted = 0;
218
- let totalWeight = 0;
219
- for (const r of results) {
220
- const w = r.weight > 0 ? r.weight : 1;
221
- totalWeight += w;
222
- if (r.correct) weighted += w;
223
- }
224
- if (totalWeight === 0) return 0;
225
- return Math.round((weighted / totalWeight) * 100);
226
- };
227
- }
@@ -7,6 +7,11 @@
7
7
  * Keeping the rules in one place prevents the two callsites from drifting.
8
8
  */
9
9
 
10
+ /** Join a field label with a validator suffix: `.foo` chains, others get `: `. */
11
+ export function joinFieldError(label: string, suffix: string): string {
12
+ return suffix.startsWith('.') ? `${label}${suffix}` : `${label}: ${suffix}`;
13
+ }
14
+
10
15
  /**
11
16
  * Validate that a candidate is an Identified Agent per xAPI 1.0.3.
12
17
  * Returns null on success or a human-readable error suffix on failure.
@@ -81,10 +86,10 @@ export function validateAgent(actor: unknown): string | null {
81
86
  */
82
87
  export function validateAuthCredential(auth: string): string | null {
83
88
  if (typeof auth !== 'string' || !auth) {
84
- return 'auth must be a non-empty string';
89
+ return 'must be a non-empty string';
85
90
  }
86
91
  if (/^basic\s/i.test(auth)) {
87
- return "auth must be the Basic credential value only, not the full header. Drop the 'Basic ' prefix.";
92
+ return "must be the Basic credential value only, not the full header. Drop the 'Basic ' prefix.";
88
93
  }
89
94
  if (/^bearer\s/i.test(auth)) {
90
95
  return 'Bearer/OAuth credentials are not supported in v1. Use Basic auth, or wrap your token-exchange in an auth function that returns a Basic credential.';
@@ -12,8 +12,8 @@ import {
12
12
  validatePartialStatement,
13
13
  validateAgent,
14
14
  validateAuthCredential,
15
+ joinFieldError,
15
16
  XAPIConfigError,
16
- XAPIStatementError,
17
17
  } from './validation.js';
18
18
  import { RETRY_ATTEMPTS, backoffMs } from '../adapters/retry.js';
19
19
 
@@ -21,16 +21,6 @@ import { RETRY_ATTEMPTS, backoffMs } from '../adapters/retry.js';
21
21
  const CMI5_SESSIONID_EXT =
22
22
  'https://w3id.org/xapi/cmi5/context/extensions/sessionid';
23
23
 
24
- /**
25
- * Combine a field label (e.g. `xapi.actor`) with the prefix-friendly suffix
26
- * returned by `validateAgent`. Sub-field suffixes start with `.` and chain
27
- * directly (`xapi.actor.mbox …`); top-level messages get a `: ` separator
28
- * (`xapi.actor: must be an object`).
29
- */
30
- function joinFieldError(label: string, suffix: string): string {
31
- return suffix.startsWith('.') ? `${label}${suffix}` : `${label}: ${suffix}`;
32
- }
33
-
34
24
  export interface XAPIPublisherOptions {
35
25
  /** Resolved http(s) endpoint URL. The 'lms' sentinel is a config-layer concept and never reaches the publisher. */
36
26
  endpoint: string;
@@ -1,5 +1,5 @@
1
1
  import type { PartialStatement } from './types.js';
2
- export { validateAgent, validateAuthCredential } from './agent-rules.js';
2
+ export { validateAgent, validateAuthCredential, joinFieldError } from './agent-rules.js';
3
3
 
4
4
  /** Thrown for runtime-validation failures (auth/actor resolver misuse). */
5
5
  export class XAPIConfigError extends Error {
package/src/virtual.d.ts CHANGED
@@ -4,6 +4,19 @@ declare module 'virtual:tessera-layout' {
4
4
  export default layout;
5
5
  }
6
6
 
7
+ declare module 'virtual:tessera-adapter' {
8
+ import type { PersistenceAdapter } from 'tessera-learn/runtime/persistence.js';
9
+ import type { CourseConfig } from 'tessera-learn/runtime/types.js';
10
+ export function createAdapter(config: CourseConfig): PersistenceAdapter;
11
+ }
12
+
13
+ declare module 'virtual:tessera-xapi-setup' {
14
+ import type { CourseConfig } from 'tessera-learn/runtime/types.js';
15
+ import type { PersistenceAdapter } from 'tessera-learn/runtime/persistence.js';
16
+ import type { XAPIClient } from 'tessera-learn/runtime/xapi/client.js';
17
+ export function buildXAPIClient(config: CourseConfig, adapter: PersistenceAdapter): Promise<XAPIClient | null>;
18
+ }
19
+
7
20
  interface ImportMetaEnv {
8
21
  readonly DEV: boolean;
9
22
  readonly PROD: boolean;
package/styles/layout.css CHANGED
@@ -281,38 +281,48 @@
281
281
  z-index: 999;
282
282
  }
283
283
 
284
- /* ---- Loading Skeleton ---- */
285
- .tessera-skeleton {
286
- display: flex;
287
- flex-direction: column;
288
- gap: var(--tessera-spacing-md);
289
- padding: var(--tessera-spacing-md) 0;
284
+ /* ---- Loading Bar ---- */
285
+ .tessera-loading-bar {
286
+ position: fixed;
287
+ top: 0;
288
+ left: 0;
289
+ right: 0;
290
+ height: 2px;
291
+ z-index: 2000;
292
+ pointer-events: none;
293
+ background-color: transparent;
290
294
  }
291
295
 
292
- .tessera-skeleton-line {
293
- height: 16px;
294
- background-color: var(--tessera-border);
295
- border-radius: 4px;
296
- animation: tessera-pulse 1.5s ease-in-out infinite;
296
+ .tessera-loading-bar-fill {
297
+ height: 100%;
298
+ width: 0;
299
+ background-color: var(--tessera-primary);
300
+ box-shadow: 0 0 8px var(--tessera-primary);
301
+ transition: width 12s cubic-bezier(0.1, 0.7, 0.1, 1);
297
302
  }
298
303
 
299
- .tessera-skeleton-line:nth-child(1) { width: 45%; height: 28px; }
300
- .tessera-skeleton-line:nth-child(2) { width: 100%; }
301
- .tessera-skeleton-line:nth-child(3) { width: 92%; }
302
- .tessera-skeleton-line:nth-child(4) { width: 78%; }
303
- .tessera-skeleton-line:nth-child(5) { width: 85%; }
304
- .tessera-skeleton-line:nth-child(6) { width: 60%; }
304
+ .tessera-loading-bar.appear .tessera-loading-bar-fill {
305
+ width: 90%;
306
+ }
305
307
 
306
- @keyframes tessera-pulse {
307
- 0%, 100% { opacity: 1; }
308
- 50% { opacity: 0.4; }
308
+ .tessera-loading-bar.complete .tessera-loading-bar-fill {
309
+ width: 100%;
310
+ transition: width 180ms ease-out;
309
311
  }
310
312
 
311
- .tessera-skeleton-message {
312
- margin-top: var(--tessera-spacing-md);
313
- font-size: 0.875rem;
313
+ .tessera-loading-bar-message {
314
+ position: fixed;
315
+ top: var(--tessera-spacing-md);
316
+ left: 50%;
317
+ transform: translateX(-50%);
318
+ z-index: 2000;
319
+ margin: 0;
320
+ padding: 4px 12px;
321
+ font-size: 0.8125rem;
314
322
  color: var(--tessera-text-light);
315
- text-align: center;
323
+ background-color: var(--tessera-bg-secondary);
324
+ border: 1px solid var(--tessera-border);
325
+ border-radius: 999px;
316
326
  }
317
327
 
318
328
  /* ---- Buttons ---- */