tessera-learn 0.0.8 → 0.0.10
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/dist/plugin/cli.js +1 -1
- package/dist/plugin/index.js +147 -11
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-B4UhCY5y.js → validation-BxWAMMnJ.js} +4 -7
- package/dist/validation-BxWAMMnJ.js.map +1 -0
- package/package.json +9 -6
- package/src/components/DefaultLayout.svelte +2 -0
- package/src/components/FillInTheBlank.svelte +32 -37
- package/src/components/Matching.svelte +35 -68
- package/src/components/MultipleChoice.svelte +25 -38
- package/src/components/Quiz.svelte +22 -26
- package/src/components/Sorting.svelte +40 -42
- package/src/index.ts +1 -0
- package/src/plugin/index.ts +184 -9
- package/src/plugin/validation.ts +7 -2
- package/src/runtime/App.svelte +53 -39
- package/src/runtime/LoadingBar.svelte +47 -0
- package/src/runtime/Sidebar.svelte +2 -0
- package/src/runtime/adapters/cmi5.ts +44 -14
- package/src/runtime/hooks.svelte.ts +269 -227
- package/src/runtime/interaction-format.ts +40 -8
- package/src/runtime/interaction.ts +3 -3
- package/src/runtime/navigation.svelte.ts +38 -5
- package/src/runtime/persistence.ts +5 -0
- package/src/runtime/progress.svelte.ts +16 -10
- package/src/runtime/quiz-policy.ts +16 -16
- package/src/runtime/types.ts +1 -2
- package/src/virtual.d.ts +13 -0
- package/styles/layout.css +34 -24
- package/dist/validation-B4UhCY5y.js.map +0 -1
- package/src/components/quiz-payload.ts +0 -71
- package/src/runtime/LoadingSkeleton.svelte +0 -26
package/src/plugin/index.ts
CHANGED
|
@@ -27,17 +27,21 @@ function resolveStylesDir(): string {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function tesseraPlugin() {
|
|
30
|
+
const manifestRef: { current: Manifest | null; root: string } = { current: null, root: '' };
|
|
30
31
|
return [
|
|
31
32
|
svelte({
|
|
32
|
-
compilerOptions: { css: '
|
|
33
|
+
compilerOptions: { css: 'external' },
|
|
33
34
|
}),
|
|
34
35
|
tesseraValidationPlugin(),
|
|
35
36
|
tesseraEntryPlugin(),
|
|
36
37
|
tesseraConfigPlugin(),
|
|
37
38
|
tesseraPagesPlugin(),
|
|
38
|
-
tesseraManifestPlugin(),
|
|
39
|
+
tesseraManifestPlugin(manifestRef),
|
|
39
40
|
tesseraLayoutPlugin(),
|
|
40
41
|
tesseraQuizPlugin(),
|
|
42
|
+
tesseraAdapterPlugin(),
|
|
43
|
+
tesseraXAPISetupPlugin(),
|
|
44
|
+
tesseraFirstPagePreloadPlugin(manifestRef),
|
|
41
45
|
tesseraExportPlugin(),
|
|
42
46
|
];
|
|
43
47
|
}
|
|
@@ -207,6 +211,11 @@ function tesseraConfigPlugin(): Plugin {
|
|
|
207
211
|
'$assets': resolve(root, 'assets'),
|
|
208
212
|
},
|
|
209
213
|
},
|
|
214
|
+
// tessera-learn ships .ts/.svelte.ts source; Vite's dep optimizer
|
|
215
|
+
// doesn't run vite-plugin-svelte's preprocessor, so skip pre-bundling.
|
|
216
|
+
optimizeDeps: {
|
|
217
|
+
exclude: ['tessera-learn'],
|
|
218
|
+
},
|
|
210
219
|
};
|
|
211
220
|
},
|
|
212
221
|
|
|
@@ -392,15 +401,15 @@ function tesseraExportPlugin(): Plugin {
|
|
|
392
401
|
const VIRTUAL_MANIFEST_ID = 'virtual:tessera-manifest';
|
|
393
402
|
const RESOLVED_MANIFEST_ID = '\0' + VIRTUAL_MANIFEST_ID;
|
|
394
403
|
|
|
395
|
-
function tesseraManifestPlugin(): Plugin {
|
|
404
|
+
function tesseraManifestPlugin(manifestRef: { current: Manifest | null; root: string }): Plugin {
|
|
396
405
|
let projectRoot: string;
|
|
397
406
|
let pagesDir: string;
|
|
398
|
-
let currentManifest: Manifest | null = null;
|
|
399
407
|
let server: ViteDevServer | null = null;
|
|
400
408
|
|
|
401
409
|
function buildManifest(): Manifest {
|
|
402
|
-
|
|
403
|
-
|
|
410
|
+
const m = generateManifest(pagesDir);
|
|
411
|
+
manifestRef.current = m;
|
|
412
|
+
return m;
|
|
404
413
|
}
|
|
405
414
|
|
|
406
415
|
return {
|
|
@@ -410,6 +419,7 @@ function tesseraManifestPlugin(): Plugin {
|
|
|
410
419
|
configResolved(config: ResolvedConfig) {
|
|
411
420
|
projectRoot = config.root;
|
|
412
421
|
pagesDir = resolve(projectRoot, 'pages');
|
|
422
|
+
manifestRef.root = projectRoot;
|
|
413
423
|
},
|
|
414
424
|
|
|
415
425
|
configureServer(devServer: ViteDevServer) {
|
|
@@ -427,7 +437,7 @@ function tesseraManifestPlugin(): Plugin {
|
|
|
427
437
|
event === 'unlinkDir';
|
|
428
438
|
|
|
429
439
|
if (isRelevant) {
|
|
430
|
-
|
|
440
|
+
manifestRef.current = null; // invalidate cache
|
|
431
441
|
|
|
432
442
|
// Invalidate the virtual module to trigger HMR
|
|
433
443
|
const mod = devServer.moduleGraph.getModuleById(RESOLVED_MANIFEST_ID);
|
|
@@ -452,7 +462,7 @@ function tesseraManifestPlugin(): Plugin {
|
|
|
452
462
|
|
|
453
463
|
load(id) {
|
|
454
464
|
if (id === RESOLVED_MANIFEST_ID) {
|
|
455
|
-
if (!
|
|
465
|
+
if (!manifestRef.current) {
|
|
456
466
|
buildManifest();
|
|
457
467
|
}
|
|
458
468
|
|
|
@@ -463,7 +473,7 @@ function tesseraManifestPlugin(): Plugin {
|
|
|
463
473
|
// Encode as base64 to prevent Vite's import analysis from
|
|
464
474
|
// scanning .svelte importPath strings as module imports.
|
|
465
475
|
// Replace Infinity with 1e9 since JSON.stringify drops it.
|
|
466
|
-
const json = JSON.stringify(
|
|
476
|
+
const json = JSON.stringify(manifestRef.current, (_key, value) =>
|
|
467
477
|
value === Infinity ? 1e9 : value
|
|
468
478
|
);
|
|
469
479
|
const b64 = Buffer.from(json).toString('base64');
|
|
@@ -473,3 +483,168 @@ function tesseraManifestPlugin(): Plugin {
|
|
|
473
483
|
},
|
|
474
484
|
};
|
|
475
485
|
}
|
|
486
|
+
|
|
487
|
+
const VIRTUAL_ADAPTER_ID = 'virtual:tessera-adapter';
|
|
488
|
+
const RESOLVED_ADAPTER_ID = '\0' + VIRTUAL_ADAPTER_ID;
|
|
489
|
+
|
|
490
|
+
function tesseraAdapterPlugin(): Plugin {
|
|
491
|
+
let projectRoot: string;
|
|
492
|
+
let isBuild = false;
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
name: 'tessera:adapter',
|
|
496
|
+
enforce: 'pre',
|
|
497
|
+
|
|
498
|
+
configResolved(config: ResolvedConfig) {
|
|
499
|
+
projectRoot = config.root;
|
|
500
|
+
isBuild = config.command === 'build';
|
|
501
|
+
},
|
|
502
|
+
|
|
503
|
+
resolveId(id) {
|
|
504
|
+
if (id === VIRTUAL_ADAPTER_ID) return RESOLVED_ADAPTER_ID;
|
|
505
|
+
return null;
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
load(id) {
|
|
509
|
+
if (id !== RESOLVED_ADAPTER_ID) return null;
|
|
510
|
+
|
|
511
|
+
// In dev, defer to the runtime selector so its WebAdapter fallback
|
|
512
|
+
// for unreachable LMS APIs keeps working.
|
|
513
|
+
if (!isBuild) {
|
|
514
|
+
return `export { createAdapter } from 'tessera-learn/runtime/adapters/index.js';`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
let standard = 'web';
|
|
518
|
+
const configPath = resolve(projectRoot, 'course.config.js');
|
|
519
|
+
if (existsSync(configPath)) {
|
|
520
|
+
const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));
|
|
521
|
+
if (objectStr) {
|
|
522
|
+
try {
|
|
523
|
+
const parsed = JSON5.parse(objectStr);
|
|
524
|
+
if (typeof parsed?.export?.standard === 'string') standard = parsed.export.standard;
|
|
525
|
+
} catch {}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
switch (standard) {
|
|
530
|
+
case 'scorm12':
|
|
531
|
+
return `
|
|
532
|
+
import { SCORM12Adapter } from 'tessera-learn/runtime/adapters/scorm12.js';
|
|
533
|
+
import { findSCORM12API } from 'tessera-learn/runtime/adapters/discovery.js';
|
|
534
|
+
import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
|
|
535
|
+
export function createAdapter() {
|
|
536
|
+
const api = findSCORM12API();
|
|
537
|
+
if (!api) throw new LMSAdapterError('scorm12', 'Tessera: SCORM 1.2 API not found in window.parent/opener chain. Course must be launched from a SCORM 1.2 LMS.');
|
|
538
|
+
return new SCORM12Adapter(api);
|
|
539
|
+
}
|
|
540
|
+
`;
|
|
541
|
+
case 'scorm2004':
|
|
542
|
+
return `
|
|
543
|
+
import { SCORM2004Adapter } from 'tessera-learn/runtime/adapters/scorm2004.js';
|
|
544
|
+
import { findSCORM2004API } from 'tessera-learn/runtime/adapters/discovery.js';
|
|
545
|
+
import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
|
|
546
|
+
export function createAdapter() {
|
|
547
|
+
const api = findSCORM2004API();
|
|
548
|
+
if (!api) throw new LMSAdapterError('scorm2004', 'Tessera: SCORM 2004 API not found in window.parent/opener chain. Course must be launched from a SCORM 2004 LMS.');
|
|
549
|
+
return new SCORM2004Adapter(api);
|
|
550
|
+
}
|
|
551
|
+
`;
|
|
552
|
+
case 'cmi5':
|
|
553
|
+
return `
|
|
554
|
+
import { CMI5Adapter } from 'tessera-learn/runtime/adapters/cmi5.js';
|
|
555
|
+
import { hasCMI5LaunchParams } from 'tessera-learn/runtime/adapters/discovery.js';
|
|
556
|
+
import { LMSAdapterError } from 'tessera-learn/runtime/adapters/index.js';
|
|
557
|
+
export function createAdapter() {
|
|
558
|
+
if (!hasCMI5LaunchParams()) throw new LMSAdapterError('cmi5', 'Tessera: cmi5 launch parameters not present on URL. Course must be launched from a cmi5-compliant LMS.');
|
|
559
|
+
return new CMI5Adapter();
|
|
560
|
+
}
|
|
561
|
+
`;
|
|
562
|
+
default:
|
|
563
|
+
return `
|
|
564
|
+
import { WebAdapter } from 'tessera-learn/runtime/adapters/web.js';
|
|
565
|
+
export function createAdapter(config) {
|
|
566
|
+
return new WebAdapter(config);
|
|
567
|
+
}
|
|
568
|
+
`;
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const VIRTUAL_XAPI_SETUP_ID = 'virtual:tessera-xapi-setup';
|
|
575
|
+
const RESOLVED_XAPI_SETUP_ID = '\0' + VIRTUAL_XAPI_SETUP_ID;
|
|
576
|
+
|
|
577
|
+
function tesseraXAPISetupPlugin(): Plugin {
|
|
578
|
+
let projectRoot: string;
|
|
579
|
+
let isBuild = false;
|
|
580
|
+
|
|
581
|
+
return {
|
|
582
|
+
name: 'tessera:xapi-setup',
|
|
583
|
+
enforce: 'pre',
|
|
584
|
+
|
|
585
|
+
configResolved(config: ResolvedConfig) {
|
|
586
|
+
projectRoot = config.root;
|
|
587
|
+
isBuild = config.command === 'build';
|
|
588
|
+
},
|
|
589
|
+
|
|
590
|
+
resolveId(id) {
|
|
591
|
+
if (id === VIRTUAL_XAPI_SETUP_ID) return RESOLVED_XAPI_SETUP_ID;
|
|
592
|
+
return null;
|
|
593
|
+
},
|
|
594
|
+
|
|
595
|
+
load(id) {
|
|
596
|
+
if (id !== RESOLVED_XAPI_SETUP_ID) return null;
|
|
597
|
+
|
|
598
|
+
if (!isBuild) {
|
|
599
|
+
return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
let standard = 'web';
|
|
603
|
+
let hasXapi = false;
|
|
604
|
+
const configPath = resolve(projectRoot, 'course.config.js');
|
|
605
|
+
if (existsSync(configPath)) {
|
|
606
|
+
const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));
|
|
607
|
+
if (objectStr) {
|
|
608
|
+
try {
|
|
609
|
+
const parsed = JSON5.parse(objectStr);
|
|
610
|
+
if (typeof parsed?.export?.standard === 'string') standard = parsed.export.standard;
|
|
611
|
+
hasXapi = parsed?.xapi != null;
|
|
612
|
+
} catch {}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// cmi5 needs the publisher regardless of explicit xapi config (cmi5
|
|
617
|
+
// adapter shares the publisher queue for its own LMS-required statements).
|
|
618
|
+
if (hasXapi || standard === 'cmi5') {
|
|
619
|
+
return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return `export async function buildXAPIClient() { return null; }`;
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function tesseraFirstPagePreloadPlugin(manifestRef: { current: Manifest | null; root: string }): Plugin {
|
|
628
|
+
return {
|
|
629
|
+
name: 'tessera:first-page-preload',
|
|
630
|
+
apply: 'build',
|
|
631
|
+
transformIndexHtml: {
|
|
632
|
+
order: 'post',
|
|
633
|
+
handler(_html, ctx) {
|
|
634
|
+
const firstPagePath = manifestRef.current?.pages[0]?.importPath;
|
|
635
|
+
if (!firstPagePath || !ctx.bundle) return;
|
|
636
|
+
const normalized = resolve(manifestRef.root, firstPagePath.replace(/^\//, '')).replace(/\\/g, '/');
|
|
637
|
+
const chunk = Object.values(ctx.bundle).find(
|
|
638
|
+
(c): c is import('vite').Rollup.OutputChunk =>
|
|
639
|
+
c.type === 'chunk' && !!c.facadeModuleId && c.facadeModuleId.replace(/\\/g, '/') === normalized
|
|
640
|
+
);
|
|
641
|
+
if (!chunk) return;
|
|
642
|
+
return [{
|
|
643
|
+
tag: 'link',
|
|
644
|
+
attrs: { rel: 'modulepreload', href: `./${chunk.fileName}` },
|
|
645
|
+
injectTo: 'head',
|
|
646
|
+
}];
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
};
|
|
650
|
+
}
|
package/src/plugin/validation.ts
CHANGED
|
@@ -542,7 +542,8 @@ function validatePageFile(
|
|
|
542
542
|
if (
|
|
543
543
|
pageConfig?.quiz &&
|
|
544
544
|
!HAS_USE_QUESTION_RE.test(content) &&
|
|
545
|
-
!HAS_QUESTION_TAG_RE.test(content)
|
|
545
|
+
!HAS_QUESTION_TAG_RE.test(content) &&
|
|
546
|
+
!HAS_LOCAL_SVELTE_IMPORT_RE.test(content)
|
|
546
547
|
) {
|
|
547
548
|
warnings.push(
|
|
548
549
|
`${fileRel}: quiz page has no question components or useQuestion() calls — ` +
|
|
@@ -810,7 +811,7 @@ function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): v
|
|
|
810
811
|
}
|
|
811
812
|
}
|
|
812
813
|
|
|
813
|
-
for (const field of ['graded', 'gatesProgress'
|
|
814
|
+
for (const field of ['graded', 'gatesProgress']) {
|
|
814
815
|
if (cfg[field] !== undefined && typeof cfg[field] !== 'boolean') {
|
|
815
816
|
errors.push(
|
|
816
817
|
`${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`
|
|
@@ -1041,6 +1042,10 @@ const HAS_USE_QUESTION_RE = /\buseQuestion\s*\(/;
|
|
|
1041
1042
|
const HAS_QUESTION_TAG_RE = new RegExp(
|
|
1042
1043
|
`<(${Object.keys(QUESTION_COMPONENT_REQUIRED).join('|')})(?=[\\s/>])`
|
|
1043
1044
|
);
|
|
1045
|
+
// Custom widget imported from a local `.svelte` file may wrap useQuestion.
|
|
1046
|
+
// Treat its presence as enough to suppress the "no questions" warning —
|
|
1047
|
+
// false negatives are acceptable for a heuristic that's already advisory.
|
|
1048
|
+
const HAS_LOCAL_SVELTE_IMPORT_RE = /from\s+['"][^'"]+\.svelte['"]/;
|
|
1044
1049
|
|
|
1045
1050
|
/**
|
|
1046
1051
|
* Detect ways an author file can bypass the LMS data contract. These check
|
package/src/runtime/App.svelte
CHANGED
|
@@ -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
|
|
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 '
|
|
15
|
-
import { buildXAPIClient } from '
|
|
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
|
|
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(
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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) {
|
|
@@ -177,18 +190,12 @@
|
|
|
177
190
|
}
|
|
178
191
|
}
|
|
179
192
|
|
|
180
|
-
// ---- Quiz completion handler ----
|
|
181
193
|
function handleQuizComplete(e) {
|
|
182
|
-
const { score
|
|
194
|
+
const { score } = e.detail;
|
|
183
195
|
const pageIndex = nav.currentPageIndex;
|
|
184
196
|
progress.quizCompleted(pageIndex, score);
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
progress.recalculateCompletion(manifest, config);
|
|
189
|
-
progress.recalculateSuccess(manifest, config);
|
|
190
|
-
// Persistence is scheduled by the version-tracking effect below; no
|
|
191
|
-
// explicit call needed here.
|
|
197
|
+
progress.recalculateCompletion(manifest.totalPages, config);
|
|
198
|
+
progress.recalculateSuccess(config);
|
|
192
199
|
}
|
|
193
200
|
|
|
194
201
|
// ---- Persistence: serialize / restore ----
|
|
@@ -257,8 +264,8 @@
|
|
|
257
264
|
progress.markCompleteManually();
|
|
258
265
|
}
|
|
259
266
|
// Recalculate derived state
|
|
260
|
-
progress.recalculateCompletion(manifest, config);
|
|
261
|
-
progress.recalculateSuccess(
|
|
267
|
+
progress.recalculateCompletion(manifest.totalPages, config);
|
|
268
|
+
progress.recalculateSuccess(config);
|
|
262
269
|
// Navigate to bookmark (after state is restored so locking is correct)
|
|
263
270
|
if (saved.b > 0 && saved.b < manifest.totalPages) {
|
|
264
271
|
nav.goToPage(saved.b);
|
|
@@ -302,14 +309,21 @@
|
|
|
302
309
|
$effect(() => {
|
|
303
310
|
const scores = progress.quizScores;
|
|
304
311
|
if (!persistenceReady || scores.size === 0) return;
|
|
305
|
-
if (gradedQuizIndices.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
312
|
+
if (gradedQuizIndices.size === 0) return;
|
|
313
|
+
|
|
314
|
+
let sum = 0;
|
|
315
|
+
let attempted = false;
|
|
316
|
+
for (const i of gradedQuizIndices) {
|
|
317
|
+
if (scores.has(i)) {
|
|
318
|
+
sum += scores.get(i) ?? 0;
|
|
319
|
+
attempted = true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (!attempted) return;
|
|
309
323
|
|
|
310
324
|
// Divide by total graded count — incomplete quizzes count as 0, matching
|
|
311
325
|
// the recalculateSuccess logic in progress.svelte.ts.
|
|
312
|
-
const average =
|
|
326
|
+
const average = sum / gradedQuizIndices.size;
|
|
313
327
|
|
|
314
328
|
untrack(() => {
|
|
315
329
|
adapter.setScore(Math.round(average));
|
|
@@ -403,6 +417,7 @@
|
|
|
403
417
|
restoreState(saved);
|
|
404
418
|
prevCompletionStatus = progress.completionStatus;
|
|
405
419
|
prevSuccessStatus = progress.successStatus;
|
|
420
|
+
adapter.seedLifecycle?.(progress.completionStatus, progress.successStatus);
|
|
406
421
|
}
|
|
407
422
|
persistenceReady = true;
|
|
408
423
|
|
|
@@ -468,9 +483,7 @@
|
|
|
468
483
|
</script>
|
|
469
484
|
|
|
470
485
|
{#snippet page()}
|
|
471
|
-
{#if
|
|
472
|
-
<LoadingSkeleton />
|
|
473
|
-
{:else if pageError}
|
|
486
|
+
{#if pageError}
|
|
474
487
|
<ErrorPage error={pageError} onretry={retryPage} />
|
|
475
488
|
{:else if PageComponent}
|
|
476
489
|
{#if pageContext.quiz}
|
|
@@ -484,6 +497,7 @@
|
|
|
484
497
|
{/snippet}
|
|
485
498
|
|
|
486
499
|
<div id="tessera-app" data-chrome={chromeMode}>
|
|
500
|
+
<LoadingBar active={pageLoading} />
|
|
487
501
|
{#if UserLayout}
|
|
488
502
|
<UserLayout {page} />
|
|
489
503
|
{: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">
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { PersistenceAdapter, SavedState } from '../persistence.js';
|
|
2
2
|
import type { Interaction } from '../interaction.js';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
formatResponse,
|
|
5
|
+
formatCorrectPattern,
|
|
6
|
+
XAPI_INTERACTION_FORMAT,
|
|
7
|
+
} from '../interaction-format.js';
|
|
4
8
|
import { formatISO8601Duration } from './retry.js';
|
|
5
9
|
import { XAPIPublisher } from '../xapi/publisher.js';
|
|
6
10
|
import { X_API_VERSION } from '../xapi/version.js';
|
|
@@ -139,8 +143,8 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
139
143
|
#score: number | null = null;
|
|
140
144
|
#durationSeconds = 0;
|
|
141
145
|
#state: SavedState | null = null;
|
|
142
|
-
#
|
|
143
|
-
#
|
|
146
|
+
#completedEmitted = false;
|
|
147
|
+
#lastSuccessEmitted: 'unknown' | 'passed' | 'failed' = 'unknown';
|
|
144
148
|
#terminated = false;
|
|
145
149
|
|
|
146
150
|
// cmi5 §8 launch params. masteryScore (when present) overrides the
|
|
@@ -240,13 +244,28 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
240
244
|
// Basic credential (already base64); we don't re-encode.
|
|
241
245
|
let token = '';
|
|
242
246
|
if (text.startsWith('{')) {
|
|
247
|
+
let parsed: unknown;
|
|
243
248
|
try {
|
|
244
|
-
|
|
245
|
-
if (parsed && typeof parsed['auth-token'] === 'string') {
|
|
246
|
-
token = parsed['auth-token'].trim();
|
|
247
|
-
}
|
|
249
|
+
parsed = JSON.parse(text);
|
|
248
250
|
} catch {
|
|
249
|
-
|
|
251
|
+
parsed = undefined;
|
|
252
|
+
}
|
|
253
|
+
if (parsed && typeof parsed === 'object') {
|
|
254
|
+
const obj = parsed as Record<string, unknown>;
|
|
255
|
+
if (typeof obj['auth-token'] === 'string') {
|
|
256
|
+
token = (obj['auth-token'] as string).trim();
|
|
257
|
+
} else {
|
|
258
|
+
const code = typeof obj['error-code'] === 'string' ? obj['error-code'] : undefined;
|
|
259
|
+
const errText = typeof obj['error-text'] === 'string' ? obj['error-text'] : undefined;
|
|
260
|
+
const detail =
|
|
261
|
+
code !== undefined || errText !== undefined
|
|
262
|
+
? ` (error-code=${code ?? 'unknown'}${errText ? `: ${errText}` : ''})`
|
|
263
|
+
: '';
|
|
264
|
+
throw new Error(
|
|
265
|
+
`Tessera cmi5: fetch URL returned a JSON response without an 'auth-token' field${detail}. ` +
|
|
266
|
+
'The cmi5 fetch URL is single-use (§8.2.3.1); reload from the LMS to obtain a fresh launch.'
|
|
267
|
+
);
|
|
268
|
+
}
|
|
250
269
|
}
|
|
251
270
|
}
|
|
252
271
|
if (!token) {
|
|
@@ -434,11 +453,21 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
434
453
|
this.#score = Math.max(0, Math.min(100, score));
|
|
435
454
|
}
|
|
436
455
|
|
|
456
|
+
seedLifecycle(
|
|
457
|
+
completion: 'incomplete' | 'complete',
|
|
458
|
+
success: 'unknown' | 'passed' | 'failed'
|
|
459
|
+
): void {
|
|
460
|
+
if (completion === 'complete') this.#completedEmitted = true;
|
|
461
|
+
if (success === 'passed' || success === 'failed') {
|
|
462
|
+
this.#lastSuccessEmitted = success;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
437
466
|
setCompletionStatus(status: 'incomplete' | 'complete'): void {
|
|
438
|
-
if (status !== 'complete' || this.#
|
|
467
|
+
if (status !== 'complete' || this.#completedEmitted || !this.#publisher) return;
|
|
439
468
|
// cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Completed.
|
|
440
469
|
if (this.#launchMode !== 'Normal') return;
|
|
441
|
-
this.#
|
|
470
|
+
this.#completedEmitted = true;
|
|
442
471
|
// cmi5 §9.5.1 — `score` MUST NOT appear on Completed (Passed/Failed only).
|
|
443
472
|
const result: Record<string, unknown> = {
|
|
444
473
|
completion: true,
|
|
@@ -457,10 +486,11 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
457
486
|
}
|
|
458
487
|
|
|
459
488
|
setSuccessStatus(status: 'passed' | 'failed' | 'unknown'): void {
|
|
460
|
-
if (status === 'unknown' ||
|
|
489
|
+
if (status === 'unknown' || !this.#publisher) return;
|
|
490
|
+
if (status === this.#lastSuccessEmitted) return;
|
|
461
491
|
// cmi5 §10.2.2 — Browse/Review launches MUST NOT emit Passed/Failed.
|
|
462
492
|
if (this.#launchMode !== 'Normal') return;
|
|
463
|
-
this.#
|
|
493
|
+
this.#lastSuccessEmitted = status;
|
|
464
494
|
|
|
465
495
|
const verb = status === 'passed' ? VERBS.passed : VERBS.failed;
|
|
466
496
|
const verbName = status === 'passed' ? 'passed' : 'failed';
|
|
@@ -518,8 +548,8 @@ export class CMI5Adapter implements PersistenceAdapter {
|
|
|
518
548
|
correct: boolean | null
|
|
519
549
|
): void {
|
|
520
550
|
if (!this.#publisher) return;
|
|
521
|
-
const response = formatResponse(interaction);
|
|
522
|
-
const pattern = formatCorrectPattern(interaction);
|
|
551
|
+
const response = formatResponse(interaction, XAPI_INTERACTION_FORMAT);
|
|
552
|
+
const pattern = formatCorrectPattern(interaction, XAPI_INTERACTION_FORMAT);
|
|
523
553
|
const definition: Record<string, unknown> = {
|
|
524
554
|
type: CMI_INTERACTION_TYPE,
|
|
525
555
|
interactionType: interaction.type,
|