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.
Files changed (79) 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 +6 -3
  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 +171 -140
  12. package/dist/plugin/index.js.map +1 -1
  13. package/dist/{validation-BxWAMMnJ.js → validation-B-xTvM9B.js} +417 -81
  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 +22 -5
  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 +75 -103
  23. package/src/components/Image.svelte +14 -10
  24. package/src/components/LockedBanner.svelte +5 -5
  25. package/src/components/Matching.svelte +48 -19
  26. package/src/components/MediaTracks.svelte +21 -0
  27. package/src/components/MultipleChoice.svelte +81 -102
  28. package/src/components/Quiz.svelte +63 -21
  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 +25 -20
  34. package/src/components/util.ts +4 -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 +6 -8
  41. package/src/plugin/export.ts +60 -50
  42. package/src/plugin/index.ts +244 -101
  43. package/src/plugin/layout.ts +6 -51
  44. package/src/plugin/manifest.ts +90 -24
  45. package/src/plugin/override-plugin.ts +68 -0
  46. package/src/plugin/quiz.ts +9 -54
  47. package/src/plugin/validation.ts +768 -183
  48. package/src/runtime/App.svelte +128 -64
  49. package/src/runtime/LoadingBar.svelte +12 -3
  50. package/src/runtime/Sidebar.svelte +24 -8
  51. package/src/runtime/access.ts +15 -3
  52. package/src/runtime/adapters/cmi5.ts +68 -116
  53. package/src/runtime/adapters/format.ts +67 -0
  54. package/src/runtime/adapters/index.ts +45 -34
  55. package/src/runtime/adapters/retry.ts +25 -84
  56. package/src/runtime/adapters/scorm-base.ts +19 -15
  57. package/src/runtime/adapters/scorm12.ts +8 -9
  58. package/src/runtime/adapters/scorm2004.ts +22 -30
  59. package/src/runtime/adapters/web.ts +1 -1
  60. package/src/runtime/hooks.svelte.ts +152 -328
  61. package/src/runtime/interaction-format.ts +30 -12
  62. package/src/runtime/interaction.ts +44 -11
  63. package/src/runtime/navigation.svelte.ts +29 -40
  64. package/src/runtime/persistence.ts +2 -2
  65. package/src/runtime/progress.svelte.ts +22 -9
  66. package/src/runtime/quiz-engine.svelte.ts +361 -0
  67. package/src/runtime/quiz-policy.ts +28 -179
  68. package/src/runtime/types.ts +24 -2
  69. package/src/runtime/xapi/agent-rules.ts +11 -3
  70. package/src/runtime/xapi/client.ts +5 -5
  71. package/src/runtime/xapi/derive-actor.ts +2 -2
  72. package/src/runtime/xapi/publisher.ts +33 -40
  73. package/src/runtime/xapi/setup.ts +18 -15
  74. package/src/runtime/xapi/validation.ts +15 -6
  75. package/src/virtual.d.ts +4 -1
  76. package/styles/base.css +32 -11
  77. package/styles/layout.css +39 -18
  78. package/styles/theme.css +15 -3
  79. package/dist/validation-BxWAMMnJ.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}`;
@@ -1,12 +1,13 @@
1
1
  import type { Manifest } from '../plugin/manifest.js';
2
2
  import type { CourseConfig } from './types.js';
3
3
  import { ProgressState } from './progress.svelte.js';
4
+ import { resolveAccess } from './access.js';
4
5
 
5
6
  export function isPageComplete(
6
7
  index: number,
7
8
  manifest: Manifest,
8
9
  progress: ProgressState,
9
- config: CourseConfig
10
+ config: CourseConfig,
10
11
  ): boolean {
11
12
  const page = manifest.pages[index];
12
13
  if (!page) return false;
@@ -29,6 +30,10 @@ export class NavigationState {
29
30
  #progress: ProgressState;
30
31
  #config: CourseConfig;
31
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;
32
37
  currentPageIndex = $state(0);
33
38
 
34
39
  canGoPrev = $derived(this.currentPageIndex > 0);
@@ -50,7 +55,10 @@ export class NavigationState {
50
55
  if (prev && prev.size === next.size) {
51
56
  let same = true;
52
57
  for (const i of next) {
53
- if (!prev.has(i)) { same = false; break; }
58
+ if (!prev.has(i)) {
59
+ same = false;
60
+ break;
61
+ }
54
62
  }
55
63
  if (same) return prev;
56
64
  }
@@ -58,10 +66,16 @@ export class NavigationState {
58
66
  return next;
59
67
  });
60
68
 
61
- constructor(manifest: Manifest, progress: ProgressState, config: CourseConfig) {
69
+ constructor(
70
+ manifest: Manifest,
71
+ progress: ProgressState,
72
+ config: CourseConfig,
73
+ auditMode = false,
74
+ ) {
62
75
  this.manifest = manifest;
63
76
  this.#progress = progress;
64
77
  this.#config = config;
78
+ this.#auditMode = auditMode;
65
79
  }
66
80
 
67
81
  setPageModules(modules: PageModuleMap) {
@@ -78,7 +92,7 @@ export class NavigationState {
78
92
  if (index < 0 || index >= this.manifest.totalPages) return;
79
93
  if (this.isPageLocked(index)) return;
80
94
  const page = this.manifest.pages[index];
81
- this.#pageModules[page.importPath]?.();
95
+ void this.#pageModules[page.importPath]?.();
82
96
  }
83
97
 
84
98
  goToPage(index: number) {
@@ -96,53 +110,28 @@ export class NavigationState {
96
110
  }
97
111
 
98
112
  isPageLocked(index: number): boolean {
113
+ if (this.#auditMode) return false;
99
114
  return this.#lockedSet.has(index);
100
115
  }
101
116
 
102
- // Compute the locked set in a single forward pass. The built-in modes are
103
- // expressed inline (rather than calling resolveAccess/freeAccess/sequential
104
- // per page) so the whole walk is O(n). Custom predicates fall back to a
105
- // per-page evaluation since their semantics are arbitrary — but it still
106
- // runs once per state change rather than once per page per render.
117
+ // Resolve the access predicate once (custom canAccess, or the free /
118
+ // sequential preset) and evaluate it per page. Runs once per state change
119
+ // the presets are the single source of truth for the gating rules.
107
120
  #computeLockedSet(): Set<number> {
108
121
  const total = this.manifest.totalPages;
109
122
  const locked = new Set<number>();
110
-
111
- if (this.#config.navigation.canAccess) {
112
- const fn = this.#config.navigation.canAccess;
113
- for (let i = 0; i < total; i++) {
114
- if (!fn({
123
+ const access = resolveAccess(this.#config);
124
+ for (let i = 0; i < total; i++) {
125
+ if (
126
+ !access({
115
127
  pageIndex: i,
116
128
  page: this.manifest.pages[i],
117
129
  manifest: this.manifest,
118
130
  progress: this.#progress,
119
131
  config: this.#config,
120
- })) {
121
- locked.add(i);
122
- }
123
- }
124
- return locked;
125
- }
126
-
127
- if (this.#config.navigation.mode === 'sequential') {
128
- // Once any page is incomplete, every later page is locked.
129
- for (let i = 1; i < total; i++) {
130
- if (!isPageComplete(i - 1, this.manifest, this.#progress, this.#config)) {
131
- for (let k = i; k < total; k++) locked.add(k);
132
- return locked;
133
- }
134
- }
135
- return locked;
136
- }
137
-
138
- // Free mode: a page is locked iff its most-recent gating quiz is unmet.
139
- let lastGatingUnmet = false;
140
- for (let i = 0; i < total; i++) {
141
- if (lastGatingUnmet) locked.add(i);
142
- const page = this.manifest.pages[i];
143
- if (page.quiz?.gatesProgress) {
144
- const score = this.#progress.quizScores.get(i) ?? 0;
145
- lastGatingUnmet = score < this.#config.scoring.passingScore;
132
+ })
133
+ ) {
134
+ locked.add(i);
146
135
  }
147
136
  }
148
137
  return locked;
@@ -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,17 @@ 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';
168
+ }
169
+
170
+ /**
171
+ * Effective graded score for LMS reporting — same union and averaging as
172
+ * recalculateSuccess, so score and success status can't disagree.
173
+ */
174
+ gradedScore(): { average: number; attempted: boolean } {
175
+ const { indices, attempted } = this.#gradedPages();
176
+ return { average: this.#gradedAverage(indices), attempted };
164
177
  }
165
178
 
166
179
  /**
@@ -172,7 +185,7 @@ export class ProgressState {
172
185
  const merged = new Set(this.#quizGradedIndices);
173
186
  for (const i of this.gradedStandalonePages) merged.add(i);
174
187
  const indices = [...merged];
175
- const attempted = indices.some(i => this.#hasScore(i));
188
+ const attempted = indices.some((i) => this.#hasScore(i));
176
189
  return { indices, attempted };
177
190
  }
178
191