tessera-learn 0.0.10 → 0.0.13
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/README.md +1 -0
- package/dist/audit-BBJpQGqb.js +204 -0
- package/dist/audit-BBJpQGqb.js.map +1 -0
- package/dist/plugin/a11y-cli.d.ts +1 -0
- package/dist/plugin/a11y-cli.js +36 -0
- package/dist/plugin/a11y-cli.js.map +1 -0
- package/dist/plugin/cli.js +6 -3
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +16 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +171 -140
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-BxWAMMnJ.js → validation-B-xTvM9B.js} +417 -81
- package/dist/validation-B-xTvM9B.js.map +1 -0
- package/package.json +17 -2
- package/src/components/Accordion.svelte +3 -1
- package/src/components/AccordionItem.svelte +1 -5
- package/src/components/Audio.svelte +22 -5
- package/src/components/Callout.svelte +5 -1
- package/src/components/Carousel.svelte +24 -8
- package/src/components/DefaultLayout.svelte +41 -12
- package/src/components/FillInTheBlank.svelte +75 -103
- package/src/components/Image.svelte +14 -10
- package/src/components/LockedBanner.svelte +5 -5
- package/src/components/Matching.svelte +48 -19
- package/src/components/MediaTracks.svelte +21 -0
- package/src/components/MultipleChoice.svelte +81 -102
- package/src/components/Quiz.svelte +63 -21
- package/src/components/ResultIcon.svelte +20 -4
- package/src/components/RevealModal.svelte +25 -22
- package/src/components/Sorting.svelte +61 -26
- package/src/components/Transcript.svelte +37 -0
- package/src/components/Video.svelte +25 -20
- package/src/components/util.ts +4 -1
- package/src/components/video-embed.ts +25 -0
- package/src/index.ts +2 -7
- package/src/plugin/a11y/audit.ts +299 -0
- package/src/plugin/a11y/contrast.ts +67 -0
- package/src/plugin/a11y-cli.ts +35 -0
- package/src/plugin/cli.ts +6 -8
- package/src/plugin/export.ts +60 -50
- package/src/plugin/index.ts +244 -101
- package/src/plugin/layout.ts +6 -51
- package/src/plugin/manifest.ts +90 -24
- package/src/plugin/override-plugin.ts +68 -0
- package/src/plugin/quiz.ts +9 -54
- package/src/plugin/validation.ts +768 -183
- package/src/runtime/App.svelte +128 -64
- package/src/runtime/LoadingBar.svelte +12 -3
- package/src/runtime/Sidebar.svelte +24 -8
- package/src/runtime/access.ts +15 -3
- package/src/runtime/adapters/cmi5.ts +68 -116
- package/src/runtime/adapters/format.ts +67 -0
- package/src/runtime/adapters/index.ts +45 -34
- package/src/runtime/adapters/retry.ts +25 -84
- package/src/runtime/adapters/scorm-base.ts +19 -15
- package/src/runtime/adapters/scorm12.ts +8 -9
- package/src/runtime/adapters/scorm2004.ts +22 -30
- package/src/runtime/adapters/web.ts +1 -1
- package/src/runtime/hooks.svelte.ts +152 -328
- package/src/runtime/interaction-format.ts +30 -12
- package/src/runtime/interaction.ts +44 -11
- package/src/runtime/navigation.svelte.ts +29 -40
- package/src/runtime/persistence.ts +2 -2
- package/src/runtime/progress.svelte.ts +22 -9
- package/src/runtime/quiz-engine.svelte.ts +361 -0
- package/src/runtime/quiz-policy.ts +28 -179
- package/src/runtime/types.ts +24 -2
- package/src/runtime/xapi/agent-rules.ts +11 -3
- package/src/runtime/xapi/client.ts +5 -5
- package/src/runtime/xapi/derive-actor.ts +2 -2
- package/src/runtime/xapi/publisher.ts +33 -40
- package/src/runtime/xapi/setup.ts +18 -15
- package/src/runtime/xapi/validation.ts +15 -6
- package/src/virtual.d.ts +4 -1
- package/styles/base.css +32 -11
- package/styles/layout.css +39 -18
- package/styles/theme.css +15 -3
- package/dist/validation-BxWAMMnJ.js.map +0 -1
package/src/runtime/App.svelte
CHANGED
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
import { createAdapter } from 'virtual:tessera-adapter';
|
|
15
15
|
import { buildXAPIClient } from 'virtual:tessera-xapi-setup';
|
|
16
16
|
import { registerXAPIClient } from './xapi/registry.js';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
TESSERA_PAGE,
|
|
19
|
+
TESSERA_NAV,
|
|
20
|
+
TESSERA_ADAPTER,
|
|
21
|
+
TESSERA_USER_STATE,
|
|
22
|
+
} from './contexts.js';
|
|
18
23
|
|
|
19
24
|
// ---- Persistence ----
|
|
20
25
|
const adapter = createAdapter(config);
|
|
@@ -25,18 +30,28 @@
|
|
|
25
30
|
let xapiClient = null;
|
|
26
31
|
|
|
27
32
|
const gradedQuizIndices = new Set(
|
|
28
|
-
manifest.pages.filter(p => p.quiz?.graded).map(p => p.index)
|
|
33
|
+
manifest.pages.filter((p) => p.quiz?.graded).map((p) => p.index),
|
|
29
34
|
);
|
|
30
35
|
|
|
31
36
|
// ---- State classes ----
|
|
37
|
+
// The Tier-2 auditor appends ?__tessera_audit to unlock navigation so it can
|
|
38
|
+
// scan every page, including ones gated behind a quiz.
|
|
39
|
+
const auditMode =
|
|
40
|
+
typeof window !== 'undefined' &&
|
|
41
|
+
new URLSearchParams(window.location.search).has('__tessera_audit');
|
|
32
42
|
const progress = new ProgressState(gradedQuizIndices);
|
|
33
|
-
const nav = new NavigationState(manifest, progress, config);
|
|
43
|
+
const nav = new NavigationState(manifest, progress, config, auditMode);
|
|
34
44
|
nav.setPageModules(pageModules);
|
|
35
45
|
let duration = $state(new DurationTracker(0));
|
|
36
46
|
|
|
37
|
-
const onIdle =
|
|
38
|
-
|
|
39
|
-
|
|
47
|
+
const onIdle =
|
|
48
|
+
typeof window !== 'undefined' && window.requestIdleCallback
|
|
49
|
+
? window.requestIdleCallback.bind(window)
|
|
50
|
+
: (cb) =>
|
|
51
|
+
setTimeout(
|
|
52
|
+
() => cb({ didTimeout: false, timeRemaining: () => 50 }),
|
|
53
|
+
1,
|
|
54
|
+
);
|
|
40
55
|
|
|
41
56
|
// Page loading state
|
|
42
57
|
let PageComponent = $state(null);
|
|
@@ -45,7 +60,10 @@
|
|
|
45
60
|
let retryKey = $state(0);
|
|
46
61
|
|
|
47
62
|
// ---- Page context (reactive, read by Quiz in Step 8) ----
|
|
48
|
-
let pageContext = $state({
|
|
63
|
+
let pageContext = $state({
|
|
64
|
+
quiz: null,
|
|
65
|
+
passingScore: config.scoring?.passingScore ?? 70,
|
|
66
|
+
});
|
|
49
67
|
setContext(TESSERA_PAGE, pageContext);
|
|
50
68
|
|
|
51
69
|
// ---- Navigation context (read by custom chrome components) ----
|
|
@@ -54,7 +72,11 @@
|
|
|
54
72
|
setContext(TESSERA_NAV, { nav, manifest, progress, config });
|
|
55
73
|
|
|
56
74
|
// ---- Adapter context (read by useQuestion / usePersistence) ----
|
|
57
|
-
setContext(TESSERA_ADAPTER, {
|
|
75
|
+
setContext(TESSERA_ADAPTER, {
|
|
76
|
+
get adapter() {
|
|
77
|
+
return adapter;
|
|
78
|
+
},
|
|
79
|
+
});
|
|
58
80
|
|
|
59
81
|
// ---- User-scoped state (read/written by usePersistence) ----
|
|
60
82
|
// Each call site namespaces under its own key. Persisted to SavedState.u.
|
|
@@ -74,7 +96,9 @@
|
|
|
74
96
|
// Otherwise: "default" renders the built-in DefaultLayout; "custom" hides
|
|
75
97
|
// the chrome entirely so a course-owned shell can take over.
|
|
76
98
|
if (UserLayout && config.chrome === 'custom' && import.meta.env?.DEV) {
|
|
77
|
-
console.warn(
|
|
99
|
+
console.warn(
|
|
100
|
+
'[tessera] Both layout.svelte and chrome: "custom" are set. layout.svelte wins.',
|
|
101
|
+
);
|
|
78
102
|
}
|
|
79
103
|
const chromeMode = UserLayout
|
|
80
104
|
? 'user'
|
|
@@ -94,35 +118,39 @@
|
|
|
94
118
|
|
|
95
119
|
const loader = pageModules[page.importPath];
|
|
96
120
|
if (!loader) {
|
|
97
|
-
console.error(
|
|
121
|
+
console.error(
|
|
122
|
+
`Tessera: No loader for page ${index} at ${page.importPath}`,
|
|
123
|
+
);
|
|
98
124
|
pageError = new Error(`Page not found: ${page.importPath}`);
|
|
99
125
|
PageComponent = null;
|
|
100
126
|
pageLoading = false;
|
|
101
127
|
return;
|
|
102
128
|
}
|
|
103
129
|
|
|
104
|
-
loader()
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
130
|
+
loader()
|
|
131
|
+
.then((mod) => {
|
|
132
|
+
if (gen !== loadGeneration) return; // stale
|
|
133
|
+
pageError = null;
|
|
134
|
+
pageContext.quiz = page.quiz;
|
|
135
|
+
PageComponent = mod.default;
|
|
136
|
+
pageLoading = false;
|
|
137
|
+
progress.markVisited(index);
|
|
138
|
+
if (
|
|
139
|
+
manifest.pages[index].completesOn === 'view' &&
|
|
140
|
+
config.completion.mode === 'manual'
|
|
141
|
+
) {
|
|
142
|
+
progress.markCompleteManually();
|
|
143
|
+
}
|
|
144
|
+
progress.recalculateCompletion(manifest.totalPages, config);
|
|
145
|
+
progress.recalculateSuccess(config);
|
|
146
|
+
onIdle(() => nav.prefetch(index + 1));
|
|
147
|
+
})
|
|
148
|
+
.catch((err) => {
|
|
149
|
+
if (gen !== loadGeneration) return; // stale
|
|
150
|
+
console.error(`Tessera: Failed to load page ${index}`, err);
|
|
151
|
+
pageError = err;
|
|
152
|
+
pageLoading = false;
|
|
153
|
+
});
|
|
126
154
|
}
|
|
127
155
|
|
|
128
156
|
// React to page index changes
|
|
@@ -141,7 +169,11 @@
|
|
|
141
169
|
// Two sentinels so the validity check doesn't false-positive when the
|
|
142
170
|
// input happens to normalize to the initial fillStyle ("#000000").
|
|
143
171
|
function parseColor(color) {
|
|
144
|
-
if (
|
|
172
|
+
if (
|
|
173
|
+
typeof CSS !== 'undefined' &&
|
|
174
|
+
CSS.supports &&
|
|
175
|
+
!CSS.supports('color', color)
|
|
176
|
+
) {
|
|
145
177
|
return null;
|
|
146
178
|
}
|
|
147
179
|
const ctx = document.createElement('canvas').getContext('2d');
|
|
@@ -153,16 +185,30 @@
|
|
|
153
185
|
ctx.fillStyle = color;
|
|
154
186
|
const onWhite = ctx.fillStyle;
|
|
155
187
|
if (onBlack !== onWhite) return null;
|
|
156
|
-
const hex = String(onBlack).match(
|
|
157
|
-
|
|
158
|
-
|
|
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
|
+
);
|
|
159
200
|
return rgba ? { r: +rgba[1], g: +rgba[2], b: +rgba[3] } : null;
|
|
160
201
|
}
|
|
161
202
|
|
|
162
203
|
function rgbToHsl(r, g, b) {
|
|
163
|
-
r /= 255;
|
|
164
|
-
|
|
165
|
-
|
|
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;
|
|
166
212
|
if (max !== min) {
|
|
167
213
|
const d = max - min;
|
|
168
214
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
@@ -170,7 +216,11 @@
|
|
|
170
216
|
else if (max === g) h = ((b - r) / d + 2) / 6;
|
|
171
217
|
else h = ((r - g) / d + 4) / 6;
|
|
172
218
|
}
|
|
173
|
-
return {
|
|
219
|
+
return {
|
|
220
|
+
h: Math.round(h * 360),
|
|
221
|
+
s: Math.round(s * 100),
|
|
222
|
+
l: Math.round(l * 100),
|
|
223
|
+
};
|
|
174
224
|
}
|
|
175
225
|
|
|
176
226
|
function applyBranding(cfg) {
|
|
@@ -180,9 +230,18 @@
|
|
|
180
230
|
const rgb = parseColor(cfg.branding.primaryColor);
|
|
181
231
|
if (rgb) {
|
|
182
232
|
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
|
|
183
|
-
el.style.setProperty(
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
);
|
|
186
245
|
}
|
|
187
246
|
}
|
|
188
247
|
if (cfg.branding?.fontFamily) {
|
|
@@ -249,7 +308,12 @@
|
|
|
249
308
|
for (const [pageKey, questions] of Object.entries(saved.s)) {
|
|
250
309
|
const pageIndex = Number(pageKey);
|
|
251
310
|
for (const [qid, score] of Object.entries(questions)) {
|
|
252
|
-
progress.markStandaloneQuestion(
|
|
311
|
+
progress.markStandaloneQuestion(
|
|
312
|
+
pageIndex,
|
|
313
|
+
qid,
|
|
314
|
+
Number(score),
|
|
315
|
+
gradedSet.has(pageIndex),
|
|
316
|
+
);
|
|
253
317
|
}
|
|
254
318
|
}
|
|
255
319
|
}
|
|
@@ -306,30 +370,25 @@
|
|
|
306
370
|
|
|
307
371
|
// ---- Persistence: report score/completion/success to adapter ----
|
|
308
372
|
// These are no-ops for WebAdapter but used by LMS adapters (Step 10)
|
|
373
|
+
let prevReportedScore = $state(null);
|
|
309
374
|
$effect(() => {
|
|
310
|
-
|
|
311
|
-
if (!persistenceReady
|
|
312
|
-
|
|
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
|
-
}
|
|
375
|
+
void progress.version;
|
|
376
|
+
if (!persistenceReady) return;
|
|
377
|
+
|
|
378
|
+
const { average, attempted } = progress.gradedScore();
|
|
322
379
|
if (!attempted) return;
|
|
323
380
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
381
|
+
const rounded = Math.round(average);
|
|
382
|
+
if (rounded === prevReportedScore) return;
|
|
383
|
+
prevReportedScore = rounded;
|
|
327
384
|
|
|
328
385
|
untrack(() => {
|
|
329
|
-
adapter.setScore(
|
|
386
|
+
adapter.setScore(rounded);
|
|
330
387
|
// Under manual mode, success is owned by requireSuccessStatus.
|
|
331
388
|
if (config.completion.mode !== 'manual') {
|
|
332
|
-
adapter.setSuccessStatus(
|
|
389
|
+
adapter.setSuccessStatus(
|
|
390
|
+
average >= config.scoring.passingScore ? 'passed' : 'failed',
|
|
391
|
+
);
|
|
333
392
|
}
|
|
334
393
|
adapter.setDuration(duration.sessionSeconds);
|
|
335
394
|
adapter.commit();
|
|
@@ -373,7 +432,9 @@
|
|
|
373
432
|
// Tell SCORM whether this is a suspend-to-resume close or a normal
|
|
374
433
|
// exit. cmi5/web adapters no-op. Must come before terminate() so the
|
|
375
434
|
// value is committed in the same flush.
|
|
376
|
-
adapter.setExit(
|
|
435
|
+
adapter.setExit(
|
|
436
|
+
progress.completionStatus === 'complete' ? 'normal' : 'suspend',
|
|
437
|
+
);
|
|
377
438
|
adapter.commit();
|
|
378
439
|
// Stop accepting author-issued statements on independent destinations
|
|
379
440
|
// before terminate() so a late `useXAPI().sendStatement(...)` from a
|
|
@@ -417,7 +478,10 @@
|
|
|
417
478
|
restoreState(saved);
|
|
418
479
|
prevCompletionStatus = progress.completionStatus;
|
|
419
480
|
prevSuccessStatus = progress.successStatus;
|
|
420
|
-
adapter.seedLifecycle?.(
|
|
481
|
+
adapter.seedLifecycle?.(
|
|
482
|
+
progress.completionStatus,
|
|
483
|
+
progress.successStatus,
|
|
484
|
+
);
|
|
421
485
|
}
|
|
422
486
|
persistenceReady = true;
|
|
423
487
|
|
|
@@ -460,7 +524,7 @@
|
|
|
460
524
|
'[tessera] completion.mode is "manual" but the course has not completed after 60s. ' +
|
|
461
525
|
'No page declared `pageConfig.completesOn: "view"` was reached, and no component called ' +
|
|
462
526
|
'`useCompletion().markComplete()`. This is a misconfiguration; set `completion.trigger: "page"` ' +
|
|
463
|
-
'in course.config.js to fail the build instead of waiting at runtime.'
|
|
527
|
+
'in course.config.js to fail the build instead of waiting at runtime.',
|
|
464
528
|
);
|
|
465
529
|
}
|
|
466
530
|
}, 60_000);
|
|
@@ -14,9 +14,13 @@
|
|
|
14
14
|
// next frame so the CSS transition from width:0 → 90% actually fires.
|
|
15
15
|
const appearTimer = setTimeout(() => {
|
|
16
16
|
visible = true;
|
|
17
|
-
requestAnimationFrame(() => {
|
|
17
|
+
requestAnimationFrame(() => {
|
|
18
|
+
appeared = true;
|
|
19
|
+
});
|
|
18
20
|
}, 100);
|
|
19
|
-
const slowTimer = setTimeout(() => {
|
|
21
|
+
const slowTimer = setTimeout(() => {
|
|
22
|
+
showSlowMessage = true;
|
|
23
|
+
}, 5000);
|
|
20
24
|
return () => {
|
|
21
25
|
clearTimeout(appearTimer);
|
|
22
26
|
clearTimeout(slowTimer);
|
|
@@ -38,7 +42,12 @@
|
|
|
38
42
|
</script>
|
|
39
43
|
|
|
40
44
|
{#if visible}
|
|
41
|
-
<div
|
|
45
|
+
<div
|
|
46
|
+
class="tessera-loading-bar"
|
|
47
|
+
class:appear={appeared}
|
|
48
|
+
class:complete
|
|
49
|
+
aria-hidden="true"
|
|
50
|
+
>
|
|
42
51
|
<div class="tessera-loading-bar-fill"></div>
|
|
43
52
|
</div>
|
|
44
53
|
{#if showSlowMessage}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script>
|
|
2
|
-
let { manifest, config, currentPageIndex, nav, onnavigate, onclose } =
|
|
2
|
+
let { manifest, config, currentPageIndex, nav, onnavigate, onclose } =
|
|
3
|
+
$props();
|
|
3
4
|
|
|
4
5
|
// Track which sections are collapsed. All expanded by default.
|
|
5
6
|
let collapsedSections = $state(new Set());
|
|
@@ -22,13 +23,17 @@
|
|
|
22
23
|
|
|
23
24
|
<div class="tessera-sidebar-header">
|
|
24
25
|
{#if config.branding?.logo}
|
|
25
|
-
<img
|
|
26
|
+
<img
|
|
27
|
+
src={config.branding.logo}
|
|
28
|
+
alt={config.title}
|
|
29
|
+
class="tessera-sidebar-logo"
|
|
30
|
+
/>
|
|
26
31
|
{/if}
|
|
27
32
|
<h1 class="tessera-sidebar-title">{config.title || '(no title)'}</h1>
|
|
28
33
|
</div>
|
|
29
34
|
|
|
30
35
|
<nav class="tessera-sidebar-nav" aria-label="Course navigation">
|
|
31
|
-
{#each manifest.sections as section}
|
|
36
|
+
{#each manifest.sections as section (section.slug)}
|
|
32
37
|
<div class="tessera-nav-section">
|
|
33
38
|
<button
|
|
34
39
|
class="tessera-nav-section-title"
|
|
@@ -50,22 +55,33 @@
|
|
|
50
55
|
</button>
|
|
51
56
|
|
|
52
57
|
{#if !collapsedSections.has(section.slug)}
|
|
53
|
-
{#each section.lessons as lesson}
|
|
58
|
+
{#each section.lessons as lesson (lesson.slug)}
|
|
54
59
|
<div class="tessera-nav-lesson-title">{lesson.title}</div>
|
|
55
|
-
{#each lesson.pages as page}
|
|
60
|
+
{#each lesson.pages as page (page.index)}
|
|
56
61
|
{@const locked = nav.isPageLocked(page.index)}
|
|
57
62
|
<button
|
|
58
63
|
class="tessera-nav-page"
|
|
59
64
|
class:locked
|
|
60
|
-
aria-current={page.index === currentPageIndex
|
|
65
|
+
aria-current={page.index === currentPageIndex
|
|
66
|
+
? 'page'
|
|
67
|
+
: undefined}
|
|
61
68
|
aria-disabled={locked ? 'true' : undefined}
|
|
62
69
|
onclick={() => handlePageClick(page.index)}
|
|
63
70
|
onpointerenter={() => !locked && nav.prefetch(page.index)}
|
|
64
71
|
onfocusin={() => !locked && nav.prefetch(page.index)}
|
|
65
72
|
>
|
|
66
73
|
{#if locked}
|
|
67
|
-
<svg
|
|
68
|
-
|
|
74
|
+
<svg
|
|
75
|
+
class="tessera-nav-lock-icon"
|
|
76
|
+
viewBox="0 0 16 16"
|
|
77
|
+
fill="currentColor"
|
|
78
|
+
aria-hidden="true"
|
|
79
|
+
width="12"
|
|
80
|
+
height="12"
|
|
81
|
+
>
|
|
82
|
+
<path
|
|
83
|
+
d="M8 1a4 4 0 0 0-4 4v2H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1h-1V5a4 4 0 0 0-4-4zm-2 4a2 2 0 1 1 4 0v2H6V5z"
|
|
84
|
+
/>
|
|
69
85
|
</svg>
|
|
70
86
|
{/if}
|
|
71
87
|
{page.title}
|
package/src/runtime/access.ts
CHANGED
|
@@ -24,7 +24,12 @@ export type AccessFn = (ctx: AccessContext) => boolean;
|
|
|
24
24
|
* Free-navigation preset. A page is accessible unless a preceding page declares
|
|
25
25
|
* `pageConfig.quiz.gatesProgress` and the learner has not met the passing score.
|
|
26
26
|
*/
|
|
27
|
-
export const freeAccess: AccessFn = ({
|
|
27
|
+
export const freeAccess: AccessFn = ({
|
|
28
|
+
pageIndex,
|
|
29
|
+
manifest,
|
|
30
|
+
progress,
|
|
31
|
+
config,
|
|
32
|
+
}) => {
|
|
28
33
|
for (let i = pageIndex - 1; i >= 0; i--) {
|
|
29
34
|
const page = manifest.pages[i];
|
|
30
35
|
if (page.quiz?.gatesProgress) {
|
|
@@ -38,7 +43,12 @@ export const freeAccess: AccessFn = ({ pageIndex, manifest, progress, config })
|
|
|
38
43
|
* Sequential-navigation preset. A page is accessible only when every preceding
|
|
39
44
|
* page is complete (visited or quiz-passed, per `isPageComplete`).
|
|
40
45
|
*/
|
|
41
|
-
export const sequentialAccess: AccessFn = ({
|
|
46
|
+
export const sequentialAccess: AccessFn = ({
|
|
47
|
+
pageIndex,
|
|
48
|
+
manifest,
|
|
49
|
+
progress,
|
|
50
|
+
config,
|
|
51
|
+
}) => {
|
|
42
52
|
for (let i = 0; i < pageIndex; i++) {
|
|
43
53
|
if (!isPageComplete(i, manifest, progress, config)) return false;
|
|
44
54
|
}
|
|
@@ -51,5 +61,7 @@ export const sequentialAccess: AccessFn = ({ pageIndex, manifest, progress, conf
|
|
|
51
61
|
*/
|
|
52
62
|
export function resolveAccess(config: CourseConfig): AccessFn {
|
|
53
63
|
if (config.navigation.canAccess) return config.navigation.canAccess;
|
|
54
|
-
return config.navigation.mode === 'sequential'
|
|
64
|
+
return config.navigation.mode === 'sequential'
|
|
65
|
+
? sequentialAccess
|
|
66
|
+
: freeAccess;
|
|
55
67
|
}
|