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.
Files changed (75) hide show
  1. package/README.md +1 -0
  2. package/dist/audit-BBJpQGqb.js +204 -0
  3. package/dist/audit-BBJpQGqb.js.map +1 -0
  4. package/dist/plugin/a11y-cli.d.ts +1 -0
  5. package/dist/plugin/a11y-cli.js +36 -0
  6. package/dist/plugin/a11y-cli.js.map +1 -0
  7. package/dist/plugin/cli.js +2 -1
  8. package/dist/plugin/cli.js.map +1 -1
  9. package/dist/plugin/index.d.ts +16 -1
  10. package/dist/plugin/index.d.ts.map +1 -1
  11. package/dist/plugin/index.js +85 -10
  12. package/dist/plugin/index.js.map +1 -1
  13. package/dist/{validation-D9DXlqNP.js → validation-B-xTvM9B.js} +342 -18
  14. package/dist/validation-B-xTvM9B.js.map +1 -0
  15. package/package.json +17 -2
  16. package/src/components/Accordion.svelte +3 -1
  17. package/src/components/AccordionItem.svelte +1 -5
  18. package/src/components/Audio.svelte +17 -3
  19. package/src/components/Callout.svelte +5 -1
  20. package/src/components/Carousel.svelte +24 -8
  21. package/src/components/DefaultLayout.svelte +41 -12
  22. package/src/components/FillInTheBlank.svelte +16 -6
  23. package/src/components/Image.svelte +12 -3
  24. package/src/components/LockedBanner.svelte +2 -1
  25. package/src/components/Matching.svelte +48 -19
  26. package/src/components/MediaTracks.svelte +21 -0
  27. package/src/components/MultipleChoice.svelte +33 -13
  28. package/src/components/Quiz.svelte +61 -20
  29. package/src/components/ResultIcon.svelte +20 -4
  30. package/src/components/RevealModal.svelte +25 -22
  31. package/src/components/Sorting.svelte +61 -26
  32. package/src/components/Transcript.svelte +37 -0
  33. package/src/components/Video.svelte +21 -18
  34. package/src/components/util.ts +3 -1
  35. package/src/components/video-embed.ts +25 -0
  36. package/src/index.ts +2 -7
  37. package/src/plugin/a11y/audit.ts +299 -0
  38. package/src/plugin/a11y/contrast.ts +67 -0
  39. package/src/plugin/a11y-cli.ts +35 -0
  40. package/src/plugin/cli.ts +4 -1
  41. package/src/plugin/export.ts +42 -14
  42. package/src/plugin/index.ts +216 -44
  43. package/src/plugin/manifest.ts +62 -22
  44. package/src/plugin/validation.ts +736 -122
  45. package/src/runtime/App.svelte +119 -48
  46. package/src/runtime/LoadingBar.svelte +12 -3
  47. package/src/runtime/Sidebar.svelte +24 -8
  48. package/src/runtime/access.ts +15 -3
  49. package/src/runtime/adapters/cmi5.ts +55 -33
  50. package/src/runtime/adapters/index.ts +22 -10
  51. package/src/runtime/adapters/retry.ts +25 -20
  52. package/src/runtime/adapters/scorm-base.ts +19 -15
  53. package/src/runtime/adapters/scorm12.ts +7 -8
  54. package/src/runtime/adapters/scorm2004.ts +11 -14
  55. package/src/runtime/adapters/web.ts +1 -1
  56. package/src/runtime/hooks.svelte.ts +152 -326
  57. package/src/runtime/interaction-format.ts +30 -12
  58. package/src/runtime/interaction.ts +44 -11
  59. package/src/runtime/navigation.svelte.ts +27 -11
  60. package/src/runtime/persistence.ts +2 -2
  61. package/src/runtime/progress.svelte.ts +13 -9
  62. package/src/runtime/quiz-engine.svelte.ts +361 -0
  63. package/src/runtime/quiz-policy.ts +9 -3
  64. package/src/runtime/types.ts +24 -2
  65. package/src/runtime/xapi/agent-rules.ts +4 -1
  66. package/src/runtime/xapi/client.ts +5 -5
  67. package/src/runtime/xapi/derive-actor.ts +2 -2
  68. package/src/runtime/xapi/publisher.ts +32 -29
  69. package/src/runtime/xapi/setup.ts +18 -15
  70. package/src/runtime/xapi/validation.ts +15 -6
  71. package/src/virtual.d.ts +4 -1
  72. package/styles/base.css +32 -11
  73. package/styles/layout.css +39 -18
  74. package/styles/theme.css +15 -3
  75. 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(options: string[] | undefined, value: string): string | null {
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(value: string, options: string[] | undefined, fmt: InteractionFormat): string {
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.map((v) => encodeListItem(v, i.options, fmt)).join(fmt.itemDelim);
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(([s, v]) => `${fmt.identifier(s)}${fmt.pairDelim}${fmt.identifier(String(v))}`)
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(([s, v]) => `${fmt.identifier(s)}${fmt.pairDelim}${fmt.identifier(String(v))}`)
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([`${prefix}.${spec.responseField}`, formatResponse(interaction, spec.format)]);
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
- | { type: 'choice'; response: string[]; correct?: string[]; options?: string[] }
11
- | { type: 'true-false'; response: boolean; correct?: boolean }
12
- | { type: 'fill-in'; response: string; correct?: string[]; caseMatters?: boolean }
13
- | { type: 'long-fill-in'; response: string; correct?: string[]; caseMatters?: boolean }
14
- | { type: 'matching'; response: Array<[string, string]>; correct?: Array<[string, string]>; optionPairs?: { left: string[]; right: string[] } }
15
- | { type: 'sequencing'; response: string[]; correct?: string[]; options?: string[] }
16
- | { type: 'numeric'; response: number; correct?: { min?: number; max?: number } }
17
- | { type: 'likert'; response: string; correct?: string }
18
- | { type: 'performance'; response: Array<[string, string | number]>; correct?: Array<[string, string | number]> }
19
- | { type: 'other'; response: string; correct?: string };
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)) { same = false; break; }
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(manifest: Manifest, progress: ProgressState, config: CourseConfig) {
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 (!access({
112
- pageIndex: i,
113
- page: this.manifest.pages[i],
114
- manifest: this.manifest,
115
- progress: this.#progress,
116
- config: this.#config,
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(new SvelteMap<number, Map<string, number>>());
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 = totalPages > 0
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 = average >= config.scoring.passingScore ? 'complete' : 'incomplete';
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 = this.#manuallyCompleted && want !== undefined ? want : 'unknown';
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 = average >= config.scoring.passingScore ? 'passed' : 'failed';
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