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,55 +1,10 @@
1
- import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
2
- import { existsSync } from 'node:fs';
3
- import { resolve } from 'node:path';
4
-
5
- const VIRTUAL_LAYOUT_ID = 'virtual:tessera-layout';
6
- const RESOLVED_LAYOUT_ID = '\0' + VIRTUAL_LAYOUT_ID;
1
+ import type { Plugin } from 'vite';
2
+ import { createOverridePlugin } from './override-plugin.js';
7
3
 
8
4
  export function tesseraLayoutPlugin(): Plugin {
9
- let projectRoot: string;
10
-
11
- return {
5
+ return createOverridePlugin({
12
6
  name: 'tessera:layout',
13
- enforce: 'pre',
14
-
15
- configResolved(config: ResolvedConfig) {
16
- projectRoot = config.root;
17
- },
18
-
19
- resolveId(id) {
20
- if (id === VIRTUAL_LAYOUT_ID) return RESOLVED_LAYOUT_ID;
21
- return null;
22
- },
23
-
24
- load(id) {
25
- if (id !== RESOLVED_LAYOUT_ID) return null;
26
- const layoutPath = resolve(projectRoot, 'layout.svelte');
27
- if (existsSync(layoutPath)) {
28
- // Register the file with Vite so edits trigger HMR / build --watch
29
- // re-runs. Only add when the file actually exists — calling
30
- // addWatchFile on a non-existent path makes Vite's importAnalysis
31
- // try to resolve it as a real import.
32
- this.addWatchFile(layoutPath);
33
- const normalized = layoutPath.replace(/\\/g, '/');
34
- return `export { default } from '${normalized}';`;
35
- }
36
- return `export default null;`;
37
- },
38
-
39
- configureServer(server: ViteDevServer) {
40
- const layoutPath = resolve(projectRoot, 'layout.svelte');
41
- // Only react to add/unlink: those flip the virtual module's load() output
42
- // between `export default null` and `export { default } from '...'`. A
43
- // `change` event leaves that output identical and is handled by Svelte's
44
- // own HMR for the underlying file — full-reloading on every edit would
45
- // wipe in-page state for no reason.
46
- server.watcher.on('all', (event, filePath) => {
47
- if (filePath !== layoutPath) return;
48
- if (event !== 'add' && event !== 'unlink') return;
49
- const mod = server.moduleGraph.getModuleById(RESOLVED_LAYOUT_ID);
50
- if (mod) server.moduleGraph.invalidateModule(mod);
51
- server.ws.send({ type: 'full-reload' });
52
- });
53
- },
54
- };
7
+ virtualId: 'virtual:tessera-layout',
8
+ projectFile: 'layout.svelte',
9
+ });
55
10
  }
@@ -105,6 +105,29 @@ export function extractDefaultExportObjectLiteral(source: string): string | null
105
105
  return extractObjectLiteral(source, startIndex);
106
106
  }
107
107
 
108
+ export type CourseConfigRead =
109
+ | { ok: true; config: Record<string, any> }
110
+ | { ok: false; reason: 'missing' | 'no-export' | 'parse-error'; error?: unknown };
111
+
112
+ /**
113
+ * Read and JSON5-parse the `export default { ... }` literal from a project's
114
+ * course.config.js. Shared by the build plugin and the validator so the read,
115
+ * cache, and parse rules live in one place. The discriminated `reason` lets
116
+ * callers that care (export, validation) emit precise errors while callers
117
+ * that just need a value can fall back on `!ok`.
118
+ */
119
+ export function readCourseConfig(projectRoot: string): CourseConfigRead {
120
+ const configPath = resolve(projectRoot, 'course.config.js');
121
+ if (!existsSync(configPath)) return { ok: false, reason: 'missing' };
122
+ const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(configPath));
123
+ if (!objectStr) return { ok: false, reason: 'no-export' };
124
+ try {
125
+ return { ok: true, config: JSON5.parse(objectStr) };
126
+ } catch (error) {
127
+ return { ok: false, reason: 'parse-error', error };
128
+ }
129
+ }
130
+
108
131
  /**
109
132
  * Read a _meta.js file and extract its default export object.
110
133
  * Uses the same JSON5 approach as pageConfig extraction — find the object literal
@@ -173,11 +196,14 @@ export function extractPageConfig(filePath: string): { title?: string; quiz?: Qu
173
196
  }
174
197
 
175
198
  /**
176
- * Extract an object literal from source starting at the opening brace.
177
- * Tracks brace depth to find the matching closing brace.
199
+ * Extract a balanced `{...}` or `[...]` span starting at the opening bracket,
200
+ * skipping strings and comments. Returns the substring (inclusive) or null if
201
+ * the open char is wrong or no matching close is found. Shared by manifest
202
+ * extraction, _meta/pageConfig parsing, and the validator's tag-prop parser.
178
203
  */
179
204
  export function extractObjectLiteral(source: string, startIndex: number): string | null {
180
- if (source[startIndex] !== '{') return null;
205
+ const open = source[startIndex];
206
+ if (open !== '{' && open !== '[') return null;
181
207
 
182
208
  let depth = 0;
183
209
  let inString: string | null = null;
@@ -222,8 +248,8 @@ export function extractObjectLiteral(source: string, startIndex: number): string
222
248
  continue;
223
249
  }
224
250
 
225
- if (char === '{') depth++;
226
- if (char === '}') {
251
+ if (char === '{' || char === '[') depth++;
252
+ if (char === '}' || char === ']') {
227
253
  depth--;
228
254
  if (depth === 0) {
229
255
  return source.slice(startIndex, i + 1);
@@ -0,0 +1,68 @@
1
+ import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
2
+ import { normalizePath } from 'vite';
3
+ import { existsSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+
6
+ export interface OverridePluginOptions {
7
+ name: string;
8
+ virtualId: string;
9
+ projectFile: string;
10
+ /** Built-in re-exported when the project file is absent; null export otherwise. */
11
+ builtinFile?: string;
12
+ }
13
+
14
+ /**
15
+ * A virtual module that resolves to a project-root override file when present,
16
+ * and to the built-in (or a null export) otherwise. Shared by the layout and
17
+ * quiz plugins — they differ only in the virtual id, file name, and built-in.
18
+ */
19
+ export function createOverridePlugin({
20
+ name,
21
+ virtualId,
22
+ projectFile,
23
+ builtinFile,
24
+ }: OverridePluginOptions): Plugin {
25
+ const resolvedId = '\0' + virtualId;
26
+ const fallback = builtinFile
27
+ ? `export { default } from '${normalizePath(builtinFile)}';`
28
+ : 'export default null;';
29
+ let filePath: string;
30
+
31
+ return {
32
+ name,
33
+ enforce: 'pre',
34
+
35
+ configResolved(config: ResolvedConfig) {
36
+ filePath = resolve(config.root, projectFile);
37
+ },
38
+
39
+ resolveId(id) {
40
+ if (id === virtualId) return resolvedId;
41
+ return null;
42
+ },
43
+
44
+ load(id) {
45
+ if (id !== resolvedId) return null;
46
+ if (existsSync(filePath)) {
47
+ // Only watch when it exists — addWatchFile on a missing path makes
48
+ // Vite's importAnalysis try to resolve it as a real import.
49
+ this.addWatchFile(filePath);
50
+ return `export { default } from '${normalizePath(filePath)}';`;
51
+ }
52
+ return fallback;
53
+ },
54
+
55
+ configureServer(server: ViteDevServer) {
56
+ // Only add/unlink flips load()'s output between the override and the
57
+ // fallback; a `change` leaves it identical and Svelte's own HMR handles
58
+ // the underlying file.
59
+ server.watcher.on('all', (event, changed) => {
60
+ if (changed !== filePath) return;
61
+ if (event !== 'add' && event !== 'unlink') return;
62
+ const mod = server.moduleGraph.getModuleById(resolvedId);
63
+ if (mod) server.moduleGraph.invalidateModule(mod);
64
+ server.ws.send({ type: 'full-reload' });
65
+ });
66
+ },
67
+ };
68
+ }
@@ -1,65 +1,20 @@
1
- import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
2
- import { existsSync } from 'node:fs';
1
+ import type { Plugin } from 'vite';
3
2
  import { resolve, dirname } from 'node:path';
4
3
  import { fileURLToPath } from 'node:url';
5
-
6
- const VIRTUAL_QUIZ_ID = 'virtual:tessera-quiz';
7
- const RESOLVED_QUIZ_ID = '\0' + VIRTUAL_QUIZ_ID;
4
+ import { createOverridePlugin } from './override-plugin.js';
8
5
 
9
6
  const __filename = fileURLToPath(import.meta.url);
10
7
  const __dirname = dirname(__filename);
11
8
 
12
- /**
13
- * Resolve the project's quiz shell.
14
- * `projectRoot/quiz.svelte` overrides the built-in `<Quiz>` if it exists,
15
- * otherwise the built-in is used. Mirrors `tesseraLayoutPlugin` (Phase 3A).
16
- */
17
9
  export function tesseraQuizPlugin(): Plugin {
18
- let projectRoot: string;
19
- // Resolve the built-in Quiz.svelte once. The plugin lives in
20
- // `dist/plugin/quiz.js` after build and `src/plugin/quiz.ts` in source —
21
- // both layouts put `Quiz.svelte` two levels up under `src/components/`.
10
+ // The plugin lives in `dist/plugin/quiz.js` after build and `src/plugin/quiz.ts`
11
+ // in source both put `Quiz.svelte` two levels up under `src/components/`.
22
12
  const packageRoot = resolve(__dirname, '..', '..');
23
13
  const builtinQuiz = resolve(packageRoot, 'src', 'components', 'Quiz.svelte');
24
-
25
- return {
14
+ return createOverridePlugin({
26
15
  name: 'tessera:quiz',
27
- enforce: 'pre',
28
-
29
- configResolved(config: ResolvedConfig) {
30
- projectRoot = config.root;
31
- },
32
-
33
- resolveId(id) {
34
- if (id === VIRTUAL_QUIZ_ID) return RESOLVED_QUIZ_ID;
35
- return null;
36
- },
37
-
38
- load(id) {
39
- if (id !== RESOLVED_QUIZ_ID) return null;
40
- const userQuizPath = resolve(projectRoot, 'quiz.svelte');
41
- if (existsSync(userQuizPath)) {
42
- // Watch the user file so add/remove flips through HMR (see below).
43
- this.addWatchFile(userQuizPath);
44
- const normalized = userQuizPath.replace(/\\/g, '/');
45
- return `export { default } from '${normalized}';`;
46
- }
47
- const normalized = builtinQuiz.replace(/\\/g, '/');
48
- return `export { default } from '${normalized}';`;
49
- },
50
-
51
- configureServer(server: ViteDevServer) {
52
- const userQuizPath = resolve(projectRoot, 'quiz.svelte');
53
- // Only react to add/unlink — those flip the load() output between the
54
- // user quiz and the built-in. A `change` event leaves the resolved
55
- // module identical and is handled by Svelte's own HMR.
56
- server.watcher.on('all', (event, filePath) => {
57
- if (filePath !== userQuizPath) return;
58
- if (event !== 'add' && event !== 'unlink') return;
59
- const mod = server.moduleGraph.getModuleById(RESOLVED_QUIZ_ID);
60
- if (mod) server.moduleGraph.invalidateModule(mod);
61
- server.ws.send({ type: 'full-reload' });
62
- });
63
- },
64
- };
16
+ virtualId: 'virtual:tessera-quiz',
17
+ projectFile: 'quiz.svelte',
18
+ builtinFile: builtinQuiz,
19
+ });
65
20
  }
@@ -3,11 +3,17 @@ import { resolve, relative } from 'node:path';
3
3
  import JSON5 from 'json5';
4
4
  import {
5
5
  extractDefaultExportObjectLiteral,
6
+ extractObjectLiteral,
6
7
  parsePageConfigFromSource,
7
8
  readSourceFileCached,
8
9
  ensureSvelteSuffix,
10
+ readCourseConfig,
9
11
  } from './manifest.js';
10
- import { validateAgent } from '../runtime/xapi/agent-rules.js';
12
+ import {
13
+ validateAgent,
14
+ validateAuthCredential,
15
+ joinFieldError,
16
+ } from '../runtime/xapi/agent-rules.js';
11
17
 
12
18
  // ---------- Types ----------
13
19
 
@@ -16,6 +22,16 @@ export interface ValidationResult {
16
22
  warnings: string[];
17
23
  }
18
24
 
25
+ /** Print validation warnings (yellow) then errors (red). Shared by the dev/build plugin and the CLI. */
26
+ export function reportValidationIssues({ errors, warnings }: ValidationResult): void {
27
+ for (const warning of warnings) {
28
+ console.warn(`\x1b[33m[tessera warning]\x1b[0m ${warning}`);
29
+ }
30
+ for (const error of errors) {
31
+ console.error(`\x1b[31m[tessera error]\x1b[0m ${error}`);
32
+ }
33
+ }
34
+
19
35
  // Known top-level config fields
20
36
  const KNOWN_CONFIG_FIELDS = new Set([
21
37
  'title',
@@ -55,7 +71,7 @@ export function validateProject(projectRoot: string): ValidationResult {
55
71
  }
56
72
 
57
73
  // 2. Parse and validate config
58
- const config = parseConfig(configPath, errors, warnings);
74
+ const config = parseConfig(projectRoot, errors, warnings);
59
75
 
60
76
  // 3. Validate pages directory
61
77
  const pagesDir = resolve(projectRoot, 'pages');
@@ -97,27 +113,25 @@ interface ParsedConfig {
97
113
  }
98
114
 
99
115
  function parseConfig(
100
- configPath: string,
116
+ projectRoot: string,
101
117
  errors: string[],
102
118
  warnings: string[]
103
119
  ): ParsedConfig | null {
104
- const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(configPath));
105
- if (!objectStr) {
106
- errors.push(
107
- 'course.config.js: could not parse — must use `export default { ... }` syntax'
108
- );
109
- return null;
110
- }
111
-
112
- let config: ParsedConfig;
113
- try {
114
- config = JSON5.parse(objectStr);
115
- } catch {
116
- errors.push(
117
- 'course.config.js: syntax error — must export a static object literal'
118
- );
120
+ const read = readCourseConfig(projectRoot);
121
+ if (!read.ok) {
122
+ // 'missing' can't occur — validateProject checks existsSync first.
123
+ if (read.reason === 'no-export') {
124
+ errors.push(
125
+ 'course.config.js: could not parse — must use `export default { ... }` syntax'
126
+ );
127
+ } else if (read.reason === 'parse-error') {
128
+ errors.push(
129
+ 'course.config.js: syntax error — must export a static object literal'
130
+ );
131
+ }
119
132
  return null;
120
133
  }
134
+ const config = read.config as ParsedConfig;
121
135
 
122
136
  // Check for unknown fields
123
137
  for (const key of Object.keys(config)) {
@@ -360,16 +374,9 @@ function validateSingleXAPIEntry(
360
374
  if (auth === undefined) {
361
375
  errors.push(`course.config.js: ${label}.auth is required`);
362
376
  } else if (typeof auth === 'string') {
363
- if (!auth) {
364
- errors.push(`course.config.js: ${label}.auth must be a non-empty string`);
365
- } else if (/^basic\s/i.test(auth)) {
366
- errors.push(
367
- `course.config.js: ${label}.auth must be the Basic credential value only, not the full header. Drop the 'Basic ' prefix.`
368
- );
369
- } else if (/^bearer\s/i.test(auth)) {
370
- errors.push(
371
- `course.config.js: ${label}.auth: 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.`
372
- );
377
+ const authErr = validateAuthCredential(auth);
378
+ if (authErr) {
379
+ errors.push(`course.config.js: ${joinFieldError(`${label}.auth`, authErr)}`);
373
380
  } else {
374
381
  warnings.push(
375
382
  `course.config.js: ${label}.auth is a static string and will be embedded in the bundle. ` +
@@ -411,10 +418,7 @@ function validateSingleXAPIEntry(
411
418
  } else if (typeof actor === 'object' && actor !== null) {
412
419
  const err = validateAgent(actor);
413
420
  if (err) {
414
- const joined = err.startsWith('.')
415
- ? `${label}.actor${err}`
416
- : `${label}.actor ${err}`;
417
- errors.push(`course.config.js: ${joined}`);
421
+ errors.push(`course.config.js: ${joinFieldError(`${label}.actor`, err)}`);
418
422
  }
419
423
  } else if (typeof actor !== 'function') {
420
424
  errors.push(
@@ -834,39 +838,6 @@ type PropValue =
834
838
  | { kind: 'expr'; raw: string }
835
839
  | { kind: 'bool' };
836
840
 
837
- /** Extract a balanced {...} or [...] span starting at startIndex, or null. */
838
- function extractBalanced(source: string, startIndex: number): string | null {
839
- const open = source[startIndex];
840
- if (open !== '{' && open !== '[') return null;
841
- let depth = 0;
842
- let inString: string | null = null;
843
- let escaped = false;
844
- for (let i = startIndex; i < source.length; i++) {
845
- const char = source[i];
846
- if (escaped) {
847
- escaped = false;
848
- continue;
849
- }
850
- if (char === '\\' && inString) {
851
- escaped = true;
852
- continue;
853
- }
854
- if (inString) {
855
- if (char === inString) inString = null;
856
- continue;
857
- }
858
- if (char === '"' || char === "'" || char === '`') {
859
- inString = char;
860
- continue;
861
- }
862
- if (char === '{' || char === '[') depth++;
863
- if (char === '}' || char === ']') {
864
- depth--;
865
- if (depth === 0) return source.slice(startIndex, i + 1);
866
- }
867
- }
868
- return null;
869
- }
870
841
 
871
842
  /**
872
843
  * Parse the props of an opening tag starting just after the component name.
@@ -884,7 +855,7 @@ function parseTagProps(content: string, start: number): Map<string, PropValue> |
884
855
  if (c === '/' && content[i + 1] === '>') return props;
885
856
  // Spread / shorthand expression — skip the whole {...} block.
886
857
  if (c === '{') {
887
- const block = extractBalanced(content, i);
858
+ const block = extractObjectLiteral(content, i);
888
859
  if (!block) return null;
889
860
  i += block.length;
890
861
  continue;
@@ -907,7 +878,7 @@ function parseTagProps(content: string, start: number): Map<string, PropValue> |
907
878
  props.set(propName, { kind: 'string', value: content.slice(i + 1, end) });
908
879
  i = end + 1;
909
880
  } else if (v === '{') {
910
- const block = extractBalanced(content, i);
881
+ const block = extractObjectLiteral(content, i);
911
882
  if (!block) return null;
912
883
  props.set(propName, { kind: 'expr', raw: block.slice(1, -1).trim() });
913
884
  i += block.length;
@@ -5,14 +5,14 @@
5
5
  import UserLayout from 'virtual:tessera-layout';
6
6
  import Quiz from 'virtual:tessera-quiz';
7
7
  import { onMount, onDestroy, setContext, untrack } from 'svelte';
8
- import LoadingSkeleton from './LoadingSkeleton.svelte';
8
+ import LoadingBar from './LoadingBar.svelte';
9
9
  import ErrorPage from './ErrorPage.svelte';
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
13
  import { DurationTracker } from './duration.js';
14
- import { createAdapter } from './adapters/index.js';
15
- import { buildXAPIClient } from './xapi/setup.js';
14
+ import { createAdapter } from 'virtual:tessera-adapter';
15
+ import { buildXAPIClient } from 'virtual:tessera-xapi-setup';
16
16
  import { registerXAPIClient } from './xapi/registry.js';
17
17
  import { TESSERA_PAGE, TESSERA_NAV, TESSERA_ADAPTER, TESSERA_USER_STATE } from './contexts.js';
18
18
 
@@ -24,12 +24,19 @@
24
24
  // can reach it.
25
25
  let xapiClient = null;
26
26
 
27
+ const gradedQuizIndices = new Set(
28
+ manifest.pages.filter(p => p.quiz?.graded).map(p => p.index)
29
+ );
30
+
27
31
  // ---- State classes ----
28
- const progress = new ProgressState();
32
+ const progress = new ProgressState(gradedQuizIndices);
29
33
  const nav = new NavigationState(manifest, progress, config);
34
+ nav.setPageModules(pageModules);
30
35
  let duration = $state(new DurationTracker(0));
31
36
 
32
- const gradedQuizIndices = manifest.pages.filter(p => p.quiz?.graded).map(p => p.index);
37
+ const onIdle = typeof window !== 'undefined' && window.requestIdleCallback
38
+ ? window.requestIdleCallback.bind(window)
39
+ : (cb) => setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 1);
33
40
 
34
41
  // Page loading state
35
42
  let PageComponent = $state(null);
@@ -84,22 +91,20 @@
84
91
 
85
92
  const gen = ++loadGeneration;
86
93
  pageLoading = true;
87
- pageError = null;
88
- PageComponent = null;
89
-
90
- // Update context for the new page
91
- pageContext.quiz = page.quiz;
92
94
 
93
95
  const loader = pageModules[page.importPath];
94
96
  if (!loader) {
95
97
  console.error(`Tessera: No loader for page ${index} at ${page.importPath}`);
96
98
  pageError = new Error(`Page not found: ${page.importPath}`);
99
+ PageComponent = null;
97
100
  pageLoading = false;
98
101
  return;
99
102
  }
100
103
 
101
104
  loader().then(mod => {
102
105
  if (gen !== loadGeneration) return; // stale
106
+ pageError = null;
107
+ pageContext.quiz = page.quiz;
103
108
  PageComponent = mod.default;
104
109
  pageLoading = false;
105
110
  progress.markVisited(index);
@@ -109,8 +114,9 @@
109
114
  ) {
110
115
  progress.markCompleteManually();
111
116
  }
112
- progress.recalculateCompletion(manifest, config);
113
- progress.recalculateSuccess(manifest, config);
117
+ progress.recalculateCompletion(manifest.totalPages, config);
118
+ progress.recalculateSuccess(config);
119
+ onIdle(() => nav.prefetch(index + 1));
114
120
  }).catch(err => {
115
121
  if (gen !== loadGeneration) return; // stale
116
122
  console.error(`Tessera: Failed to load page ${index}`, err);
@@ -132,18 +138,25 @@
132
138
  }
133
139
 
134
140
  // ---- Branding ----
141
+ // Two sentinels so the validity check doesn't false-positive when the
142
+ // input happens to normalize to the initial fillStyle ("#000000").
135
143
  function parseColor(color) {
136
144
  if (typeof CSS !== 'undefined' && CSS.supports && !CSS.supports('color', color)) {
137
145
  return null;
138
146
  }
139
- const el = document.createElement('span');
140
- el.style.color = color;
141
- document.documentElement.appendChild(el);
142
- const computed = getComputedStyle(el).color;
143
- el.remove();
144
- const match = computed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
145
- if (!match) return null;
146
- return { r: +match[1], g: +match[2], b: +match[3] };
147
+ const ctx = document.createElement('canvas').getContext('2d');
148
+ if (!ctx) return null;
149
+ ctx.fillStyle = '#000';
150
+ ctx.fillStyle = color;
151
+ const onBlack = ctx.fillStyle;
152
+ ctx.fillStyle = '#fff';
153
+ ctx.fillStyle = color;
154
+ const onWhite = ctx.fillStyle;
155
+ if (onBlack !== onWhite) return null;
156
+ const hex = String(onBlack).match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
157
+ if (hex) return { r: parseInt(hex[1], 16), g: parseInt(hex[2], 16), b: parseInt(hex[3], 16) };
158
+ const rgba = String(onBlack).match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
159
+ return rgba ? { r: +rgba[1], g: +rgba[2], b: +rgba[3] } : null;
147
160
  }
148
161
 
149
162
  function rgbToHsl(r, g, b) {
@@ -181,8 +194,8 @@
181
194
  const { score } = e.detail;
182
195
  const pageIndex = nav.currentPageIndex;
183
196
  progress.quizCompleted(pageIndex, score);
184
- progress.recalculateCompletion(manifest, config);
185
- progress.recalculateSuccess(manifest, config);
197
+ progress.recalculateCompletion(manifest.totalPages, config);
198
+ progress.recalculateSuccess(config);
186
199
  }
187
200
 
188
201
  // ---- Persistence: serialize / restore ----
@@ -251,8 +264,8 @@
251
264
  progress.markCompleteManually();
252
265
  }
253
266
  // Recalculate derived state
254
- progress.recalculateCompletion(manifest, config);
255
- progress.recalculateSuccess(manifest, config);
267
+ progress.recalculateCompletion(manifest.totalPages, config);
268
+ progress.recalculateSuccess(config);
256
269
  // Navigate to bookmark (after state is restored so locking is correct)
257
270
  if (saved.b > 0 && saved.b < manifest.totalPages) {
258
271
  nav.goToPage(saved.b);
@@ -293,20 +306,20 @@
293
306
 
294
307
  // ---- Persistence: report score/completion/success to adapter ----
295
308
  // These are no-ops for WebAdapter but used by LMS adapters (Step 10)
309
+ let prevReportedScore = $state(null);
296
310
  $effect(() => {
297
- const scores = progress.quizScores;
298
- if (!persistenceReady || scores.size === 0) return;
299
- if (gradedQuizIndices.length === 0) return;
311
+ void progress.version;
312
+ if (!persistenceReady) return;
300
313
 
301
- const completedGraded = gradedQuizIndices.filter(i => scores.has(i));
302
- if (completedGraded.length === 0) return;
314
+ const { average, attempted } = progress.gradedScore();
315
+ if (!attempted) return;
303
316
 
304
- // Divide by total graded count — incomplete quizzes count as 0, matching
305
- // the recalculateSuccess logic in progress.svelte.ts.
306
- const average = completedGraded.reduce((sum, i) => sum + (scores.get(i) ?? 0), 0) / gradedQuizIndices.length;
317
+ const rounded = Math.round(average);
318
+ if (rounded === prevReportedScore) return;
319
+ prevReportedScore = rounded;
307
320
 
308
321
  untrack(() => {
309
- adapter.setScore(Math.round(average));
322
+ adapter.setScore(rounded);
310
323
  // Under manual mode, success is owned by requireSuccessStatus.
311
324
  if (config.completion.mode !== 'manual') {
312
325
  adapter.setSuccessStatus(average >= config.scoring.passingScore ? 'passed' : 'failed');
@@ -463,9 +476,7 @@
463
476
  </script>
464
477
 
465
478
  {#snippet page()}
466
- {#if pageLoading}
467
- <LoadingSkeleton />
468
- {:else if pageError}
479
+ {#if pageError}
469
480
  <ErrorPage error={pageError} onretry={retryPage} />
470
481
  {:else if PageComponent}
471
482
  {#if pageContext.quiz}
@@ -479,6 +490,7 @@
479
490
  {/snippet}
480
491
 
481
492
  <div id="tessera-app" data-chrome={chromeMode}>
493
+ <LoadingBar active={pageLoading} />
482
494
  {#if UserLayout}
483
495
  <UserLayout {page} />
484
496
  {:else if chromeMode === 'custom'}
@@ -0,0 +1,47 @@
1
+ <script>
2
+ import { untrack } from 'svelte';
3
+
4
+ let { active = false } = $props();
5
+
6
+ let visible = $state(false);
7
+ let appeared = $state(false);
8
+ let complete = $state(false);
9
+ let showSlowMessage = $state(false);
10
+
11
+ $effect(() => {
12
+ if (active) {
13
+ // Defer the bar so sub-100ms loads never flash. Add `.appear` on the
14
+ // next frame so the CSS transition from width:0 → 90% actually fires.
15
+ const appearTimer = setTimeout(() => {
16
+ visible = true;
17
+ requestAnimationFrame(() => { appeared = true; });
18
+ }, 100);
19
+ const slowTimer = setTimeout(() => { showSlowMessage = true; }, 5000);
20
+ return () => {
21
+ clearTimeout(appearTimer);
22
+ clearTimeout(slowTimer);
23
+ };
24
+ }
25
+
26
+ // Completing. If the bar never appeared we have nothing to finish.
27
+ // untrack so flipping `visible` doesn't re-trigger this effect.
28
+ if (!untrack(() => visible)) return;
29
+ complete = true;
30
+ const hideTimer = setTimeout(() => {
31
+ visible = false;
32
+ appeared = false;
33
+ complete = false;
34
+ showSlowMessage = false;
35
+ }, 220);
36
+ return () => clearTimeout(hideTimer);
37
+ });
38
+ </script>
39
+
40
+ {#if visible}
41
+ <div class="tessera-loading-bar" class:appear={appeared} class:complete aria-hidden="true">
42
+ <div class="tessera-loading-bar-fill"></div>
43
+ </div>
44
+ {#if showSlowMessage}
45
+ <p class="tessera-loading-bar-message" role="status">Still loading…</p>
46
+ {/if}
47
+ {/if}
@@ -60,6 +60,8 @@
60
60
  aria-current={page.index === currentPageIndex ? 'page' : undefined}
61
61
  aria-disabled={locked ? 'true' : undefined}
62
62
  onclick={() => handlePageClick(page.index)}
63
+ onpointerenter={() => !locked && nav.prefetch(page.index)}
64
+ onfocusin={() => !locked && nav.prefetch(page.index)}
63
65
  >
64
66
  {#if locked}
65
67
  <svg class="tessera-nav-lock-icon" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" width="12" height="12">