tessera-learn 0.0.11 → 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 +2 -1
- 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 +85 -10
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-D9DXlqNP.js → validation-B-xTvM9B.js} +342 -18
- 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 +17 -3
- 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 +16 -6
- package/src/components/Image.svelte +12 -3
- package/src/components/LockedBanner.svelte +2 -1
- package/src/components/Matching.svelte +48 -19
- package/src/components/MediaTracks.svelte +21 -0
- package/src/components/MultipleChoice.svelte +33 -13
- package/src/components/Quiz.svelte +61 -20
- 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 +21 -18
- package/src/components/util.ts +3 -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 +4 -1
- package/src/plugin/export.ts +42 -14
- package/src/plugin/index.ts +216 -44
- package/src/plugin/manifest.ts +62 -22
- package/src/plugin/validation.ts +736 -122
- package/src/runtime/App.svelte +119 -48
- 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 +55 -33
- package/src/runtime/adapters/index.ts +22 -10
- package/src/runtime/adapters/retry.ts +25 -20
- package/src/runtime/adapters/scorm-base.ts +19 -15
- package/src/runtime/adapters/scorm12.ts +7 -8
- package/src/runtime/adapters/scorm2004.ts +11 -14
- package/src/runtime/adapters/web.ts +1 -1
- package/src/runtime/hooks.svelte.ts +152 -326
- package/src/runtime/interaction-format.ts +30 -12
- package/src/runtime/interaction.ts +44 -11
- package/src/runtime/navigation.svelte.ts +27 -11
- package/src/runtime/persistence.ts +2 -2
- package/src/runtime/progress.svelte.ts +13 -9
- package/src/runtime/quiz-engine.svelte.ts +361 -0
- package/src/runtime/quiz-policy.ts +9 -3
- package/src/runtime/types.ts +24 -2
- package/src/runtime/xapi/agent-rules.ts +4 -1
- package/src/runtime/xapi/client.ts +5 -5
- package/src/runtime/xapi/derive-actor.ts +2 -2
- package/src/runtime/xapi/publisher.ts +32 -29
- 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-D9DXlqNP.js.map +0 -1
|
@@ -55,19 +55,26 @@ export const XAPI_INTERACTION_FORMAT: InteractionFormat = {
|
|
|
55
55
|
* underscore, max 250 chars. Strict validators (SCORM Cloud) reject raw
|
|
56
56
|
* option labels with spaces or punctuation with error 405/406.
|
|
57
57
|
*/
|
|
58
|
-
function shortIdentifier(value: string): string {
|
|
58
|
+
export function shortIdentifier(value: string): string {
|
|
59
59
|
const cleaned = value.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
60
60
|
const trimmed = cleaned.slice(0, 250);
|
|
61
61
|
return trimmed || '_';
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
function indexLookup(
|
|
64
|
+
function indexLookup(
|
|
65
|
+
options: string[] | undefined,
|
|
66
|
+
value: string,
|
|
67
|
+
): string | null {
|
|
65
68
|
if (!options) return null;
|
|
66
69
|
const idx = options.indexOf(value);
|
|
67
70
|
return idx >= 0 ? String(idx) : null;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
function encodeListItem(
|
|
73
|
+
function encodeListItem(
|
|
74
|
+
value: string,
|
|
75
|
+
options: string[] | undefined,
|
|
76
|
+
fmt: InteractionFormat,
|
|
77
|
+
): string {
|
|
71
78
|
if (fmt === SCORM12_INTERACTION_FORMAT) {
|
|
72
79
|
const idx = indexLookup(options, value);
|
|
73
80
|
if (idx !== null) return idx;
|
|
@@ -77,12 +84,14 @@ function encodeListItem(value: string, options: string[] | undefined, fmt: Inter
|
|
|
77
84
|
|
|
78
85
|
export function formatResponse(
|
|
79
86
|
i: Interaction,
|
|
80
|
-
fmt: InteractionFormat = SCORM2004_INTERACTION_FORMAT
|
|
87
|
+
fmt: InteractionFormat = SCORM2004_INTERACTION_FORMAT,
|
|
81
88
|
): string {
|
|
82
89
|
switch (i.type) {
|
|
83
90
|
case 'choice':
|
|
84
91
|
case 'sequencing':
|
|
85
|
-
return i.response
|
|
92
|
+
return i.response
|
|
93
|
+
.map((v) => encodeListItem(v, i.options, fmt))
|
|
94
|
+
.join(fmt.itemDelim);
|
|
86
95
|
case 'true-false':
|
|
87
96
|
return fmt.formatBoolean(i.response);
|
|
88
97
|
case 'fill-in':
|
|
@@ -94,14 +103,17 @@ export function formatResponse(
|
|
|
94
103
|
return i.response
|
|
95
104
|
.map(
|
|
96
105
|
([l, r]) =>
|
|
97
|
-
`${encodeListItem(l, i.optionPairs?.left, fmt)}${fmt.pairDelim}${encodeListItem(r, i.optionPairs?.right, fmt)}
|
|
106
|
+
`${encodeListItem(l, i.optionPairs?.left, fmt)}${fmt.pairDelim}${encodeListItem(r, i.optionPairs?.right, fmt)}`,
|
|
98
107
|
)
|
|
99
108
|
.join(fmt.itemDelim);
|
|
100
109
|
case 'numeric':
|
|
101
110
|
return String(i.response);
|
|
102
111
|
case 'performance':
|
|
103
112
|
return i.response
|
|
104
|
-
.map(
|
|
113
|
+
.map(
|
|
114
|
+
([s, v]) =>
|
|
115
|
+
`${fmt.identifier(s)}${fmt.pairDelim}${fmt.identifier(String(v))}`,
|
|
116
|
+
)
|
|
105
117
|
.join(fmt.itemDelim);
|
|
106
118
|
}
|
|
107
119
|
}
|
|
@@ -109,7 +121,7 @@ export function formatResponse(
|
|
|
109
121
|
/** Returns null when no correct pattern was provided. */
|
|
110
122
|
export function formatCorrectPattern(
|
|
111
123
|
i: Interaction,
|
|
112
|
-
fmt: InteractionFormat = SCORM2004_INTERACTION_FORMAT
|
|
124
|
+
fmt: InteractionFormat = SCORM2004_INTERACTION_FORMAT,
|
|
113
125
|
): string | null {
|
|
114
126
|
if (i.correct === undefined) return null;
|
|
115
127
|
switch (i.type) {
|
|
@@ -127,7 +139,7 @@ export function formatCorrectPattern(
|
|
|
127
139
|
return (i.correct as Array<[string, string]>)
|
|
128
140
|
.map(
|
|
129
141
|
([l, r]) =>
|
|
130
|
-
`${encodeListItem(l, i.optionPairs?.left, fmt)}${fmt.pairDelim}${encodeListItem(r, i.optionPairs?.right, fmt)}
|
|
142
|
+
`${encodeListItem(l, i.optionPairs?.left, fmt)}${fmt.pairDelim}${encodeListItem(r, i.optionPairs?.right, fmt)}`,
|
|
131
143
|
)
|
|
132
144
|
.join(fmt.itemDelim);
|
|
133
145
|
case 'numeric': {
|
|
@@ -146,7 +158,10 @@ export function formatCorrectPattern(
|
|
|
146
158
|
return i.correct as string;
|
|
147
159
|
case 'performance':
|
|
148
160
|
return (i.correct as Array<[string, string | number]>)
|
|
149
|
-
.map(
|
|
161
|
+
.map(
|
|
162
|
+
([s, v]) =>
|
|
163
|
+
`${fmt.identifier(s)}${fmt.pairDelim}${fmt.identifier(String(v))}`,
|
|
164
|
+
)
|
|
150
165
|
.join(fmt.itemDelim);
|
|
151
166
|
}
|
|
152
167
|
}
|
|
@@ -177,7 +192,7 @@ export function buildScormInteractionFields(
|
|
|
177
192
|
questionId: string,
|
|
178
193
|
interaction: Interaction,
|
|
179
194
|
correct: boolean | null,
|
|
180
|
-
spec: ScormInteractionSpec
|
|
195
|
+
spec: ScormInteractionSpec,
|
|
181
196
|
): Array<[string, string]> {
|
|
182
197
|
const fields: Array<[string, string]> = [
|
|
183
198
|
[`${prefix}.id`, spec.format.identifier(questionId)],
|
|
@@ -187,7 +202,10 @@ export function buildScormInteractionFields(
|
|
|
187
202
|
if (pattern !== null) {
|
|
188
203
|
fields.push([`${prefix}.correct_responses.0.pattern`, pattern]);
|
|
189
204
|
}
|
|
190
|
-
fields.push([
|
|
205
|
+
fields.push([
|
|
206
|
+
`${prefix}.${spec.responseField}`,
|
|
207
|
+
formatResponse(interaction, spec.format),
|
|
208
|
+
]);
|
|
191
209
|
if (correct !== null) {
|
|
192
210
|
fields.push([
|
|
193
211
|
`${prefix}.result`,
|
|
@@ -7,16 +7,49 @@
|
|
|
7
7
|
* so there is no impedance mismatch when writing to an LMS.
|
|
8
8
|
*/
|
|
9
9
|
export type Interaction =
|
|
10
|
-
| {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
| { type: '
|
|
17
|
-
| {
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
| {
|
|
11
|
+
type: 'choice';
|
|
12
|
+
response: string[];
|
|
13
|
+
correct?: string[];
|
|
14
|
+
options?: string[];
|
|
15
|
+
}
|
|
16
|
+
| { type: 'true-false'; response: boolean; correct?: boolean }
|
|
17
|
+
| {
|
|
18
|
+
type: 'fill-in';
|
|
19
|
+
response: string;
|
|
20
|
+
correct?: string[];
|
|
21
|
+
caseMatters?: boolean;
|
|
22
|
+
}
|
|
23
|
+
| {
|
|
24
|
+
type: 'long-fill-in';
|
|
25
|
+
response: string;
|
|
26
|
+
correct?: string[];
|
|
27
|
+
caseMatters?: boolean;
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
type: 'matching';
|
|
31
|
+
response: Array<[string, string]>;
|
|
32
|
+
correct?: Array<[string, string]>;
|
|
33
|
+
optionPairs?: { left: string[]; right: string[] };
|
|
34
|
+
}
|
|
35
|
+
| {
|
|
36
|
+
type: 'sequencing';
|
|
37
|
+
response: string[];
|
|
38
|
+
correct?: string[];
|
|
39
|
+
options?: string[];
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
type: 'numeric';
|
|
43
|
+
response: number;
|
|
44
|
+
correct?: { min?: number; max?: number };
|
|
45
|
+
}
|
|
46
|
+
| { type: 'likert'; response: string; correct?: string }
|
|
47
|
+
| {
|
|
48
|
+
type: 'performance';
|
|
49
|
+
response: Array<[string, string | number]>;
|
|
50
|
+
correct?: Array<[string, string | number]>;
|
|
51
|
+
}
|
|
52
|
+
| { type: 'other'; response: string; correct?: string };
|
|
20
53
|
|
|
21
54
|
/**
|
|
22
55
|
* Decide whether a learner response is correct. Returns:
|
|
@@ -86,7 +119,7 @@ function setEqual(a: string[], b: string[]): boolean {
|
|
|
86
119
|
|
|
87
120
|
function pairSetEqual(
|
|
88
121
|
a: Array<[string, string]>,
|
|
89
|
-
b: Array<[string, string]
|
|
122
|
+
b: Array<[string, string]>,
|
|
90
123
|
): boolean {
|
|
91
124
|
if (a.length !== b.length) return false;
|
|
92
125
|
const key = ([l, r]: [string, string]) => `${l}\u241F${r}`;
|
|
@@ -7,7 +7,7 @@ export function isPageComplete(
|
|
|
7
7
|
index: number,
|
|
8
8
|
manifest: Manifest,
|
|
9
9
|
progress: ProgressState,
|
|
10
|
-
config: CourseConfig
|
|
10
|
+
config: CourseConfig,
|
|
11
11
|
): boolean {
|
|
12
12
|
const page = manifest.pages[index];
|
|
13
13
|
if (!page) return false;
|
|
@@ -30,6 +30,10 @@ export class NavigationState {
|
|
|
30
30
|
#progress: ProgressState;
|
|
31
31
|
#config: CourseConfig;
|
|
32
32
|
#pageModules: PageModuleMap | null = null;
|
|
33
|
+
// Audit mode unlocks every page so the Tier-2 auditor can render and scan
|
|
34
|
+
// each one's DOM. Safe because gating is a runtime-only UX affordance — the
|
|
35
|
+
// whole course already ships client-side (see access.ts).
|
|
36
|
+
#auditMode: boolean;
|
|
33
37
|
currentPageIndex = $state(0);
|
|
34
38
|
|
|
35
39
|
canGoPrev = $derived(this.currentPageIndex > 0);
|
|
@@ -51,7 +55,10 @@ export class NavigationState {
|
|
|
51
55
|
if (prev && prev.size === next.size) {
|
|
52
56
|
let same = true;
|
|
53
57
|
for (const i of next) {
|
|
54
|
-
if (!prev.has(i)) {
|
|
58
|
+
if (!prev.has(i)) {
|
|
59
|
+
same = false;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
55
62
|
}
|
|
56
63
|
if (same) return prev;
|
|
57
64
|
}
|
|
@@ -59,10 +66,16 @@ export class NavigationState {
|
|
|
59
66
|
return next;
|
|
60
67
|
});
|
|
61
68
|
|
|
62
|
-
constructor(
|
|
69
|
+
constructor(
|
|
70
|
+
manifest: Manifest,
|
|
71
|
+
progress: ProgressState,
|
|
72
|
+
config: CourseConfig,
|
|
73
|
+
auditMode = false,
|
|
74
|
+
) {
|
|
63
75
|
this.manifest = manifest;
|
|
64
76
|
this.#progress = progress;
|
|
65
77
|
this.#config = config;
|
|
78
|
+
this.#auditMode = auditMode;
|
|
66
79
|
}
|
|
67
80
|
|
|
68
81
|
setPageModules(modules: PageModuleMap) {
|
|
@@ -79,7 +92,7 @@ export class NavigationState {
|
|
|
79
92
|
if (index < 0 || index >= this.manifest.totalPages) return;
|
|
80
93
|
if (this.isPageLocked(index)) return;
|
|
81
94
|
const page = this.manifest.pages[index];
|
|
82
|
-
this.#pageModules[page.importPath]?.();
|
|
95
|
+
void this.#pageModules[page.importPath]?.();
|
|
83
96
|
}
|
|
84
97
|
|
|
85
98
|
goToPage(index: number) {
|
|
@@ -97,6 +110,7 @@ export class NavigationState {
|
|
|
97
110
|
}
|
|
98
111
|
|
|
99
112
|
isPageLocked(index: number): boolean {
|
|
113
|
+
if (this.#auditMode) return false;
|
|
100
114
|
return this.#lockedSet.has(index);
|
|
101
115
|
}
|
|
102
116
|
|
|
@@ -108,13 +122,15 @@ export class NavigationState {
|
|
|
108
122
|
const locked = new Set<number>();
|
|
109
123
|
const access = resolveAccess(this.#config);
|
|
110
124
|
for (let i = 0; i < total; i++) {
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
125
|
+
if (
|
|
126
|
+
!access({
|
|
127
|
+
pageIndex: i,
|
|
128
|
+
page: this.manifest.pages[i],
|
|
129
|
+
manifest: this.manifest,
|
|
130
|
+
progress: this.#progress,
|
|
131
|
+
config: this.#config,
|
|
132
|
+
})
|
|
133
|
+
) {
|
|
118
134
|
locked.add(i);
|
|
119
135
|
}
|
|
120
136
|
}
|
|
@@ -14,7 +14,7 @@ export interface PersistenceAdapter {
|
|
|
14
14
|
/** Tell the adapter what was already emitted in prior sessions, so it skips re-emitting on resume. */
|
|
15
15
|
seedLifecycle?(
|
|
16
16
|
completion: 'incomplete' | 'complete',
|
|
17
|
-
success: 'unknown' | 'passed' | 'failed'
|
|
17
|
+
success: 'unknown' | 'passed' | 'failed',
|
|
18
18
|
): void;
|
|
19
19
|
setDuration(seconds: number): void;
|
|
20
20
|
/**
|
|
@@ -31,7 +31,7 @@ export interface PersistenceAdapter {
|
|
|
31
31
|
reportInteraction(
|
|
32
32
|
questionId: string,
|
|
33
33
|
interaction: Interaction,
|
|
34
|
-
correct: boolean | null
|
|
34
|
+
correct: boolean | null,
|
|
35
35
|
): void;
|
|
36
36
|
commit(): void;
|
|
37
37
|
terminate(): void;
|
|
@@ -20,7 +20,9 @@ export class ProgressState {
|
|
|
20
20
|
* Tracked separately from `quizScores` because <Quiz> blocks score as a unit
|
|
21
21
|
* while standalone questions score individually and average per page.
|
|
22
22
|
*/
|
|
23
|
-
standaloneQuestionScores = $state(
|
|
23
|
+
standaloneQuestionScores = $state(
|
|
24
|
+
new SvelteMap<number, Map<string, number>>(),
|
|
25
|
+
);
|
|
24
26
|
/**
|
|
25
27
|
* Set of page indices that have at least one graded standalone question.
|
|
26
28
|
* Pages in this set contribute to course success status via their standalone average.
|
|
@@ -96,7 +98,7 @@ export class ProgressState {
|
|
|
96
98
|
pageIndex: number,
|
|
97
99
|
questionId: string,
|
|
98
100
|
score: number,
|
|
99
|
-
graded: boolean
|
|
101
|
+
graded: boolean,
|
|
100
102
|
) {
|
|
101
103
|
let pageMap = this.standaloneQuestionScores.get(pageIndex);
|
|
102
104
|
if (!pageMap) {
|
|
@@ -124,9 +126,8 @@ export class ProgressState {
|
|
|
124
126
|
if (config.completion.mode === 'manual') return;
|
|
125
127
|
if (config.completion.mode === 'percentage') {
|
|
126
128
|
const threshold = config.completion.percentageThreshold ?? 100;
|
|
127
|
-
const percent =
|
|
128
|
-
? (this.visitedPages.size / totalPages) * 100
|
|
129
|
-
: 0;
|
|
129
|
+
const percent =
|
|
130
|
+
totalPages > 0 ? (this.visitedPages.size / totalPages) * 100 : 0;
|
|
130
131
|
this.completionStatus = percent >= threshold ? 'complete' : 'incomplete';
|
|
131
132
|
} else if (config.completion.mode === 'quiz') {
|
|
132
133
|
const { indices } = this.#gradedPages();
|
|
@@ -135,7 +136,8 @@ export class ProgressState {
|
|
|
135
136
|
return;
|
|
136
137
|
}
|
|
137
138
|
const average = this.#gradedAverage(indices);
|
|
138
|
-
this.completionStatus =
|
|
139
|
+
this.completionStatus =
|
|
140
|
+
average >= config.scoring.passingScore ? 'complete' : 'incomplete';
|
|
139
141
|
}
|
|
140
142
|
}
|
|
141
143
|
|
|
@@ -144,7 +146,8 @@ export class ProgressState {
|
|
|
144
146
|
const want = config.completion.requireSuccessStatus;
|
|
145
147
|
// Stay 'unknown' until manual mark fires, so a learner who never
|
|
146
148
|
// finishes isn't reported as passed.
|
|
147
|
-
this.successStatus =
|
|
149
|
+
this.successStatus =
|
|
150
|
+
this.#manuallyCompleted && want !== undefined ? want : 'unknown';
|
|
148
151
|
return;
|
|
149
152
|
}
|
|
150
153
|
|
|
@@ -160,7 +163,8 @@ export class ProgressState {
|
|
|
160
163
|
return;
|
|
161
164
|
}
|
|
162
165
|
const average = this.#gradedAverage(indices);
|
|
163
|
-
this.successStatus =
|
|
166
|
+
this.successStatus =
|
|
167
|
+
average >= config.scoring.passingScore ? 'passed' : 'failed';
|
|
164
168
|
}
|
|
165
169
|
|
|
166
170
|
/**
|
|
@@ -181,7 +185,7 @@ export class ProgressState {
|
|
|
181
185
|
const merged = new Set(this.#quizGradedIndices);
|
|
182
186
|
for (const i of this.gradedStandalonePages) merged.add(i);
|
|
183
187
|
const indices = [...merged];
|
|
184
|
-
const attempted = indices.some(i => this.#hasScore(i));
|
|
188
|
+
const attempted = indices.some((i) => this.#hasScore(i));
|
|
185
189
|
return { indices, attempted };
|
|
186
190
|
}
|
|
187
191
|
|