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
@@ -25,7 +25,7 @@
25
25
  function checkAnswer(userAnswer) {
26
26
  if (!userAnswer || typeof userAnswer !== 'string') return false;
27
27
  const trimmed = userAnswer.trim();
28
- return answers.some(acceptable => {
28
+ return answers.some((acceptable) => {
29
29
  const a = acceptable.trim();
30
30
  if (caseSensitive) return trimmed === a;
31
31
  return trimmed.toLowerCase() === a.toLowerCase();
@@ -33,16 +33,24 @@
33
33
  }
34
34
 
35
35
  const q = useQuestion({
36
- get id() { return id ?? `fitb-${slugFromQuestion(question)}`; },
37
- get weight() { return weight; },
38
- get maxRetries() { return maxRetries; },
36
+ get id() {
37
+ return id ?? `fitb-${slugFromQuestion(question)}`;
38
+ },
39
+ get weight() {
40
+ return weight;
41
+ },
42
+ get maxRetries() {
43
+ return maxRetries;
44
+ },
39
45
  response: () => ({
40
46
  type: 'fill-in',
41
47
  response: inputValue,
42
48
  correct: Array.isArray(answers) ? answers : [answers],
43
49
  caseMatters: !!caseSensitive,
44
50
  }),
45
- reset: () => { inputValue = ''; },
51
+ reset: () => {
52
+ inputValue = '';
53
+ },
46
54
  });
47
55
 
48
56
  // `q.mode` is fixed for the lifetime of the widget; capture once.
@@ -71,63 +79,70 @@
71
79
  }
72
80
  </script>
73
81
 
74
- {#if !inQuiz}
75
- <div class="tessera-fitb">
76
- <label class="tessera-fitb-question" for={inputId}>{question}</label>
77
-
78
- <div class="tessera-fitb-input-wrapper">
79
- <input
80
- type="text"
81
- id={inputId}
82
- class="tessera-fitb-input"
83
- class:correct={q.submitted && checkAnswer(inputValue)}
84
- class:incorrect={q.submitted && !checkAnswer(inputValue)}
85
- value={inputValue}
86
- oninput={handleInput}
87
- onkeydown={handleKeydown}
88
- disabled={q.submitted}
89
- placeholder="Type your answer..."
90
- autocomplete="off"
91
- />
92
- {#if !q.submitted}
93
- <button
94
- class="tessera-btn-primary tessera-fitb-check-btn"
95
- disabled={!inputValue.trim()}
96
- onclick={() => { q.submit(); }}
97
- >
98
- Check
99
- </button>
100
- {/if}
101
- </div>
82
+ {#snippet fitbContent()}
83
+ <label class="tessera-fitb-question" for={inputId}>{question}</label>
84
+
85
+ <div class="tessera-fitb-input-wrapper">
86
+ <input
87
+ type="text"
88
+ id={inputId}
89
+ class="tessera-fitb-input"
90
+ class:correct={q.feedbackVisible && checkAnswer(inputValue)}
91
+ class:incorrect={q.feedbackVisible && !checkAnswer(inputValue)}
92
+ value={inputValue}
93
+ oninput={handleInput}
94
+ onkeydown={handleKeydown}
95
+ onblur={handleBlur}
96
+ disabled={q.locked}
97
+ placeholder="Type your answer..."
98
+ autocomplete="off"
99
+ />
100
+ {#if !inQuiz && !q.submitted}
101
+ <button
102
+ class="tessera-btn-primary tessera-fitb-check-btn"
103
+ disabled={!inputValue.trim()}
104
+ onclick={() => {
105
+ q.submit();
106
+ }}
107
+ >
108
+ Check
109
+ </button>
110
+ {/if}
111
+ </div>
102
112
 
103
- {#if q.submitted}
104
- {@const isCorrect = checkAnswer(inputValue)}
105
- <div class="tessera-fitb-review">
106
- {#if isCorrect}
107
- <div class="tessera-fitb-result correct">
108
- <ResultIcon kind="correct" />
109
- Correct
110
- </div>
111
- {#if correctFeedback}
112
- <p class="tessera-fitb-feedback correct">{correctFeedback}</p>
113
- {/if}
114
- {:else}
115
- <div class="tessera-fitb-result incorrect">
116
- <ResultIcon kind="incorrect" />
117
- Incorrect
118
- </div>
119
- <p class="tessera-fitb-correct-answer">
120
- Correct answer{answers.length > 1 ? 's' : ''}: {answers.join(', ')}
121
- </p>
122
- {#if incorrectFeedback}
123
- <p class="tessera-fitb-feedback incorrect">{incorrectFeedback}</p>
124
- {/if}
113
+ {#if q.feedbackVisible}
114
+ {@const isCorrect = checkAnswer(inputValue)}
115
+ <div class="tessera-fitb-review">
116
+ {#if isCorrect}
117
+ <div class="tessera-fitb-result correct">
118
+ <ResultIcon kind="correct" />
119
+ Correct
120
+ </div>
121
+ {#if correctFeedback}
122
+ <p class="tessera-fitb-feedback correct">{correctFeedback}</p>
125
123
  {/if}
126
- {#if q.canRetry}
127
- <RetryButton onclick={() => q.retry()} />
124
+ {:else}
125
+ <div class="tessera-fitb-result incorrect">
126
+ <ResultIcon kind="incorrect" />
127
+ Incorrect
128
+ </div>
129
+ <p class="tessera-fitb-correct-answer">
130
+ Correct answer{answers.length > 1 ? 's' : ''}: {answers.join(', ')}
131
+ </p>
132
+ {#if incorrectFeedback}
133
+ <p class="tessera-fitb-feedback incorrect">{incorrectFeedback}</p>
128
134
  {/if}
129
- </div>
130
- {/if}
135
+ {/if}
136
+ {#if !inQuiz && q.canRetry}
137
+ <RetryButton onclick={() => q.retry()} />
138
+ {/if}
139
+ </div>
140
+ {/if}
141
+ {/snippet}
142
+
143
+ {#if !inQuiz}
144
+ <div class="tessera-fitb">
145
+ {@render fitbContent()}
131
146
  </div>
132
147
  {/if}
133
148
 
@@ -136,50 +151,7 @@
136
151
  {#if q.isLockedCorrect}
137
152
  <LockedBanner />
138
153
  {/if}
139
- <label class="tessera-fitb-question" for={inputId}>{question}</label>
140
-
141
- <div class="tessera-fitb-input-wrapper">
142
- <input
143
- type="text"
144
- id={inputId}
145
- class="tessera-fitb-input"
146
- class:correct={q.feedbackVisible && checkAnswer(q.answer)}
147
- class:incorrect={q.feedbackVisible && !checkAnswer(q.answer)}
148
- value={q.locked ? (q.answer ?? '') : inputValue}
149
- oninput={handleInput}
150
- onblur={handleBlur}
151
- disabled={q.locked}
152
- placeholder="Type your answer..."
153
- autocomplete="off"
154
- />
155
- </div>
156
-
157
- {#if q.feedbackVisible}
158
- {@const userAnswer = q.answer}
159
- {@const isCorrect = checkAnswer(userAnswer)}
160
- <div class="tessera-fitb-review">
161
- {#if isCorrect}
162
- <div class="tessera-fitb-result correct">
163
- <ResultIcon kind="correct" />
164
- Correct
165
- </div>
166
- {#if correctFeedback}
167
- <p class="tessera-fitb-feedback correct">{correctFeedback}</p>
168
- {/if}
169
- {:else}
170
- <div class="tessera-fitb-result incorrect">
171
- <ResultIcon kind="incorrect" />
172
- Incorrect
173
- </div>
174
- <p class="tessera-fitb-correct-answer">
175
- Correct answer{answers.length > 1 ? 's' : ''}: {answers.join(', ')}
176
- </p>
177
- {#if incorrectFeedback}
178
- <p class="tessera-fitb-feedback incorrect">{incorrectFeedback}</p>
179
- {/if}
180
- {/if}
181
- </div>
182
- {/if}
154
+ {@render fitbContent()}
183
155
  </div>
184
156
  {/snippet}
185
157
 
@@ -4,22 +4,26 @@
4
4
  * Lazy-loaded image with optional caption, rendered as <figure>.
5
5
  *
6
6
  * @prop {string} src - Image source URL (supports $assets/ paths)
7
- * @prop {string} alt - Alt text (required for accessibility)
7
+ * @prop {string} alt - Alt text. Required unless `decorative` is set; the
8
+ * linter (rule 1.3) enforces exactly one of {non-empty alt, decorative}.
9
+ * @prop {boolean} [decorative] - Mark a purely ornamental image: renders an
10
+ * empty alt and aria-hidden so assistive tech skips it.
8
11
  * @prop {string} [caption] - Optional caption below image
9
12
  */
10
- let { src, alt, caption = '' } = $props();
13
+ import { resolveAsset } from './util.js';
11
14
 
12
- // Resolve $assets/ prefix to the assets directory.
13
- // In dev, Vite serves from project root so /assets/ works.
14
- // In build, the Vite alias handles JS imports but not HTML attrs,
15
- // so we rewrite to a root-relative path that Vite can serve.
16
- let resolvedSrc = $derived(
17
- src.startsWith('$assets/') ? src.replace('$assets/', '/assets/') : src
18
- );
15
+ let { src, alt, decorative = false, caption = '' } = $props();
16
+ let resolvedSrc = $derived(resolveAsset(src));
19
17
  </script>
20
18
 
21
19
  <figure class="tessera-image">
22
- <img src={resolvedSrc} {alt} loading="lazy" class="tessera-image-img" />
20
+ <img
21
+ src={resolvedSrc}
22
+ alt={decorative ? '' : alt}
23
+ aria-hidden={decorative ? 'true' : undefined}
24
+ loading="lazy"
25
+ class="tessera-image-img"
26
+ />
23
27
  {#if caption}
24
28
  <figcaption class="tessera-image-caption">{caption}</figcaption>
25
29
  {/if}
@@ -1,11 +1,11 @@
1
1
  <script>
2
- let { message = 'You already got this one right — click Next to continue.' } = $props();
2
+ import ResultIcon from './ResultIcon.svelte';
3
+ let { message = 'You already got this one right — click Next to continue.' } =
4
+ $props();
3
5
  </script>
4
6
 
5
7
  <div class="tessera-quiz-locked-banner">
6
- <svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16" aria-hidden="true">
7
- <path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
8
- </svg>
8
+ <ResultIcon kind="correct" />
9
9
  {message}
10
10
  </div>
11
11
 
@@ -24,7 +24,7 @@
24
24
  font-weight: 500;
25
25
  }
26
26
 
27
- .tessera-quiz-locked-banner svg {
27
+ .tessera-quiz-locked-banner :global(svg) {
28
28
  flex-shrink: 0;
29
29
  }
30
30
  </style>
@@ -18,19 +18,29 @@
18
18
  } = $props();
19
19
 
20
20
  let shuffledRight = $state([]);
21
- let matches = $state(new SvelteMap());
21
+ const matches = new SvelteMap();
22
22
  // Reverse index (right.originalIndex → left index) for O(1) right-column lookups.
23
- let rightToLeft = $state(new SvelteMap());
23
+ const rightToLeft = new SvelteMap();
24
24
  let selectedLeft = $state(null);
25
25
  let selectedRight = $state(null);
26
26
 
27
27
  const pairColors = [
28
- '#2563eb', '#9333ea', '#0891b2', '#c2410c', '#4f46e5',
29
- '#0d9488', '#b91c1c', '#7c3aed', '#0369a1', '#a16207',
28
+ '#2563eb',
29
+ '#9333ea',
30
+ '#0891b2',
31
+ '#c2410c',
32
+ '#4f46e5',
33
+ '#0d9488',
34
+ '#b91c1c',
35
+ '#7c3aed',
36
+ '#0369a1',
37
+ '#a16207',
30
38
  ];
31
39
 
32
40
  function initShuffle() {
33
- shuffledRight = shuffle(pairs.map((p, i) => ({ text: p.right, originalIndex: i })));
41
+ shuffledRight = shuffle(
42
+ pairs.map((p, i) => ({ text: p.right, originalIndex: i })),
43
+ );
34
44
  }
35
45
 
36
46
  function checkAnswer(answer) {
@@ -43,17 +53,23 @@
43
53
  }
44
54
 
45
55
  function resetState() {
46
- matches = new SvelteMap();
47
- rightToLeft = new SvelteMap();
56
+ matches.clear();
57
+ rightToLeft.clear();
48
58
  selectedLeft = null;
49
59
  selectedRight = null;
50
60
  initShuffle();
51
61
  }
52
62
 
53
63
  const q = useQuestion({
54
- get id() { return id ?? `matching-${slugFromQuestion(question)}`; },
55
- get weight() { return weight; },
56
- get maxRetries() { return maxRetries; },
64
+ get id() {
65
+ return id ?? `matching-${slugFromQuestion(question)}`;
66
+ },
67
+ get weight() {
68
+ return weight;
69
+ },
70
+ get maxRetries() {
71
+ return maxRetries;
72
+ },
57
73
  response: () => ({
58
74
  type: 'matching',
59
75
  response: [...matches.entries()].map(([l, r]) => [String(l), String(r)]),
@@ -157,11 +173,12 @@
157
173
  <!-- Left column -->
158
174
  <div class="tessera-matching-column">
159
175
  <div class="tessera-matching-column-header">Match from</div>
160
- {#each pairs as pair, i}
176
+ {#each pairs as pair, i (i)}
161
177
  {@const color = getMatchColor(i)}
162
178
  {@const isSelected = selectedLeft === i}
163
179
  {@const matched = matches.has(i)}
164
- {@const correctMatch = q.feedbackVisible && matched && isMatchCorrect(i)}
180
+ {@const correctMatch =
181
+ q.feedbackVisible && matched && isMatchCorrect(i)}
165
182
  {@const wrongMatch = q.feedbackVisible && matched && !isMatchCorrect(i)}
166
183
  <button
167
184
  class="tessera-matching-item left"
@@ -170,9 +187,12 @@
170
187
  class:correct={correctMatch}
171
188
  class:incorrect={wrongMatch}
172
189
  style={color ? `border-color: ${color}; --match-color: ${color}` : ''}
173
- onclick={() => matched && !q.locked ? removeMatch(i) : handleLeftClick(i)}
190
+ onclick={() =>
191
+ matched && !q.locked ? removeMatch(i) : handleLeftClick(i)}
174
192
  disabled={q.locked}
175
- aria-label="{pair.left}{matched ? ' (matched, activate to unmatch)' : ''}"
193
+ aria-label="{pair.left}{matched
194
+ ? ' (matched, activate to unmatch)'
195
+ : ''}"
176
196
  >
177
197
  {#if matched}
178
198
  <span class="tessera-matching-badge" style="background: {color}">
@@ -190,7 +210,7 @@
190
210
  <!-- Right column -->
191
211
  <div class="tessera-matching-column">
192
212
  <div class="tessera-matching-column-header">Match to</div>
193
- {#each shuffledRight as item}
213
+ {#each shuffledRight as item (item.originalIndex)}
194
214
  {@const color = getRightMatchColor(item.originalIndex)}
195
215
  {@const isSelected = selectedRight === item.originalIndex}
196
216
  {@const matched = isRightMatched(item.originalIndex)}
@@ -233,8 +253,10 @@
233
253
  </div>
234
254
  <div class="tessera-matching-correct-pairs">
235
255
  <p class="tessera-matching-correct-pairs-title">Correct pairs:</p>
236
- {#each pairs as pair}
237
- <p class="tessera-matching-correct-pair">{pair.left} → {pair.right}</p>
256
+ {#each pairs as pair, i (i)}
257
+ <p class="tessera-matching-correct-pair">
258
+ {pair.left} → {pair.right}
259
+ </p>
238
260
  {/each}
239
261
  </div>
240
262
  {#if incorrectFeedback}
@@ -306,7 +328,10 @@
306
328
  border-radius: 8px;
307
329
  background: var(--tessera-bg);
308
330
  cursor: pointer;
309
- transition: border-color 0.2s, background 0.2s, transform 0.1s;
331
+ transition:
332
+ border-color 0.2s,
333
+ background 0.2s,
334
+ transform 0.1s;
310
335
  font-size: 0.9375rem;
311
336
  font-family: var(--tessera-font-family);
312
337
  color: var(--tessera-text);
@@ -326,7 +351,11 @@
326
351
  }
327
352
 
328
353
  .tessera-matching-item.matched {
329
- background: color-mix(in srgb, var(--match-color, var(--tessera-primary)) 8%, transparent);
354
+ background: color-mix(
355
+ in srgb,
356
+ var(--match-color, var(--tessera-primary)) 8%,
357
+ transparent
358
+ );
330
359
  }
331
360
 
332
361
  .tessera-matching-item.correct {
@@ -0,0 +1,21 @@
1
+ <script>
2
+ /**
3
+ * @component MediaTracks
4
+ * Renders caption/subtitle <track> elements for a native <audio>/<video>.
5
+ * Used inline as a child of the media element so the tracks attach to it.
6
+ *
7
+ * @prop {Array<{ src: string, kind?: 'captions'|'subtitles', srclang?: string, label?: string }>} [tracks]
8
+ */
9
+ import { resolveAsset } from './util.js';
10
+
11
+ let { tracks = [] } = $props();
12
+ </script>
13
+
14
+ {#each tracks as track, i (i)}
15
+ <track
16
+ src={resolveAsset(track.src)}
17
+ kind={track.kind ?? 'captions'}
18
+ srclang={track.srclang}
19
+ label={track.label}
20
+ />
21
+ {/each}