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
@@ -4,17 +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
13
  import { resolveAsset } from './util.js';
11
14
 
12
- let { src, alt, caption = '' } = $props();
15
+ let { src, alt, decorative = false, caption = '' } = $props();
13
16
  let resolvedSrc = $derived(resolveAsset(src));
14
17
  </script>
15
18
 
16
19
  <figure class="tessera-image">
17
- <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
+ />
18
27
  {#if caption}
19
28
  <figcaption class="tessera-image-caption">{caption}</figcaption>
20
29
  {/if}
@@ -1,6 +1,7 @@
1
1
  <script>
2
2
  import ResultIcon from './ResultIcon.svelte';
3
- let { message = 'You already got this one right — click Next to continue.' } = $props();
3
+ let { message = 'You already got this one right — click Next to continue.' } =
4
+ $props();
4
5
  </script>
5
6
 
6
7
  <div class="tessera-quiz-locked-banner">
@@ -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}
@@ -23,15 +23,23 @@
23
23
  const groupId = `mc-${componentId}`;
24
24
 
25
25
  const q = useQuestion({
26
- get id() { return id ?? `mc-${slugFromQuestion(question)}`; },
27
- get weight() { return weight; },
28
- get maxRetries() { return maxRetries; },
26
+ get id() {
27
+ return id ?? `mc-${slugFromQuestion(question)}`;
28
+ },
29
+ get weight() {
30
+ return weight;
31
+ },
32
+ get maxRetries() {
33
+ return maxRetries;
34
+ },
29
35
  response: () => ({
30
36
  type: 'choice',
31
37
  response: selectedOption !== null ? [String(selectedOption)] : [],
32
38
  correct: [String(correct)],
33
39
  }),
34
- reset: () => { selectedOption = null; },
40
+ reset: () => {
41
+ selectedOption = null;
42
+ },
35
43
  });
36
44
 
37
45
  // `q.mode` is fixed for the lifetime of the widget; capture once.
@@ -59,7 +67,8 @@
59
67
  function getOptionClass(optIndex) {
60
68
  if (!q.feedbackVisible) return '';
61
69
  if (isCorrectOption(optIndex)) return 'correct';
62
- if (optIndex === selectedOption && !isCorrectOption(optIndex)) return 'incorrect';
70
+ if (optIndex === selectedOption && !isCorrectOption(optIndex))
71
+ return 'incorrect';
63
72
  return '';
64
73
  }
65
74
  </script>
@@ -68,7 +77,7 @@
68
77
  <p class="tessera-mc-question" id="{groupId}-label">{question}</p>
69
78
 
70
79
  <div class="tessera-mc-options">
71
- {#each options as option, i}
80
+ {#each options as option, i (i)}
72
81
  {@const optionId = `${groupId}-opt-${i}`}
73
82
  {@const isSelected = selectedOption === i}
74
83
  {@const stateClass = getOptionClass(i)}
@@ -91,9 +100,13 @@
91
100
 
92
101
  {#if q.feedbackVisible}
93
102
  {#if stateClass === 'correct' && (correctFeedback || optionFeedback[i])}
94
- <span class="tessera-mc-feedback correct">{optionFeedback[i] || correctFeedback}</span>
103
+ <span class="tessera-mc-feedback correct"
104
+ >{optionFeedback[i] || correctFeedback}</span
105
+ >
95
106
  {:else if stateClass === 'incorrect' && (incorrectFeedback || optionFeedback[i])}
96
- <span class="tessera-mc-feedback incorrect">{optionFeedback[i] || incorrectFeedback}</span>
107
+ <span class="tessera-mc-feedback incorrect"
108
+ >{optionFeedback[i] || incorrectFeedback}</span
109
+ >
97
110
  {:else if optionFeedback[i]}
98
111
  <span class="tessera-mc-feedback">{optionFeedback[i]}</span>
99
112
  {/if}
@@ -106,7 +119,9 @@
106
119
  {#if selectedOption === correct && correctFeedback && !optionFeedback[selectedOption]}
107
120
  <div class="tessera-mc-overall-feedback correct">{correctFeedback}</div>
108
121
  {:else if selectedOption !== correct && incorrectFeedback && !optionFeedback[selectedOption]}
109
- <div class="tessera-mc-overall-feedback incorrect">{incorrectFeedback}</div>
122
+ <div class="tessera-mc-overall-feedback incorrect">
123
+ {incorrectFeedback}
124
+ </div>
110
125
  {/if}
111
126
  {#if !inQuiz && q.canRetry}
112
127
  <RetryButton onclick={() => q.retry()} />
@@ -155,7 +170,9 @@
155
170
  border: 2px solid var(--tessera-border);
156
171
  border-radius: 8px;
157
172
  cursor: pointer;
158
- transition: border-color 0.2s, background 0.2s;
173
+ transition:
174
+ border-color 0.2s,
175
+ background 0.2s;
159
176
  flex-wrap: wrap;
160
177
  min-height: 44px;
161
178
  }
@@ -180,7 +197,7 @@
180
197
  background: var(--tessera-error-bg);
181
198
  }
182
199
 
183
- .tessera-mc-option input[type="radio"] {
200
+ .tessera-mc-option input[type='radio'] {
184
201
  position: absolute;
185
202
  opacity: 0;
186
203
  width: 0;
@@ -194,7 +211,9 @@
194
211
  border: 2px solid var(--tessera-border);
195
212
  border-radius: 50%;
196
213
  margin-top: 2px;
197
- transition: border-color 0.2s, background 0.2s;
214
+ transition:
215
+ border-color 0.2s,
216
+ background 0.2s;
198
217
  position: relative;
199
218
  }
200
219
 
@@ -238,7 +257,8 @@
238
257
  .tessera-mc-feedback {
239
258
  width: 100%;
240
259
  font-size: 0.875rem;
241
- padding: var(--tessera-spacing-sm) 0 0 calc(20px + var(--tessera-spacing-md));
260
+ padding: var(--tessera-spacing-sm) 0 0
261
+ calc(20px + var(--tessera-spacing-md));
242
262
  line-height: 1.4;
243
263
  }
244
264
 
@@ -12,16 +12,17 @@
12
12
  let quizConfig = $derived(pageCtx?.quiz ?? {});
13
13
  let feedbackDisabled = $derived(quizConfig.feedbackMode === 'never');
14
14
  let maxAttempts = $derived(quizConfig.maxAttempts ?? Infinity);
15
- let isImmediateMode = $derived(!feedbackDisabled && quizConfig.feedbackMode === 'immediate');
15
+ let isImmediateMode = $derived(
16
+ !feedbackDisabled && quizConfig.feedbackMode === 'immediate',
17
+ );
16
18
 
17
19
  let currentQuestionIndex = $state(0);
18
20
  let reviewIndex = $state(0);
19
21
 
20
22
  let totalQuestions = $derived(handle.questions.length);
21
23
  let currentQuestion = $derived(handle.questions[currentQuestionIndex]);
22
- let reviewQuestion = $derived(handle.questions[reviewIndex]);
23
24
  let correctCount = $derived(
24
- handle.questions.reduce((sum, q) => sum + (q.correct ? 1 : 0), 0)
25
+ handle.questions.reduce((sum, q) => sum + (q.correct ? 1 : 0), 0),
25
26
  );
26
27
  let passed = $derived(handle.score >= handle.passingScore);
27
28
 
@@ -32,7 +33,12 @@
32
33
 
33
34
  function needsReveal(q) {
34
35
  if (!q) return false;
35
- return isImmediateMode && isAnswered(q) && !q.isLockedCorrect && !q.feedbackVisible;
36
+ return (
37
+ isImmediateMode &&
38
+ isAnswered(q) &&
39
+ !q.isLockedCorrect &&
40
+ !q.feedbackVisible
41
+ );
36
42
  }
37
43
 
38
44
  function goNextQuestion() {
@@ -75,25 +81,40 @@
75
81
  }
76
82
  </script>
77
83
 
78
- <div class="tessera-quiz" bind:this={quizElement} role="region" aria-label="Quiz">
84
+ <div
85
+ class="tessera-quiz"
86
+ bind:this={quizElement}
87
+ role="region"
88
+ aria-label="Quiz"
89
+ >
79
90
  {#if handle.state === 'answering'}
80
91
  <!-- Question phase -->
81
92
  <div class="tessera-quiz-progress" aria-live="polite">
82
93
  <span class="tessera-quiz-progress-text">
83
- <span class="tessera-quiz-progress-desktop">Question {currentQuestionIndex + 1} of {totalQuestions}</span>
84
- <span class="tessera-quiz-progress-mobile">{currentQuestionIndex + 1}/{totalQuestions}</span>
94
+ <span class="tessera-quiz-progress-desktop"
95
+ >Question {currentQuestionIndex + 1} of {totalQuestions}</span
96
+ >
97
+ <span class="tessera-quiz-progress-mobile"
98
+ >{currentQuestionIndex + 1}/{totalQuestions}</span
99
+ >
85
100
  </span>
86
101
  <div class="tessera-progress-track">
87
102
  <div
88
103
  class="tessera-progress-fill"
89
- style="width: {totalQuestions > 0 ? ((currentQuestionIndex + 1) / totalQuestions) * 100 : 0}%"
104
+ style="width: {totalQuestions > 0
105
+ ? ((currentQuestionIndex + 1) / totalQuestions) * 100
106
+ : 0}%"
90
107
  ></div>
91
108
  </div>
92
109
  </div>
93
110
 
94
111
  <div class="tessera-quiz-questions">
95
112
  {#each handle.questions as q, i (q.id)}
96
- <div class="tessera-quiz-question-wrapper" class:active={i === currentQuestionIndex} aria-hidden={i !== currentQuestionIndex}>
113
+ <div
114
+ class="tessera-quiz-question-wrapper"
115
+ class:active={i === currentQuestionIndex}
116
+ aria-hidden={i !== currentQuestionIndex}
117
+ >
97
118
  {#if q.render}
98
119
  {@render q.render()}
99
120
  {/if}
@@ -115,7 +136,9 @@
115
136
  disabled={!isAnswered(currentQuestion)}
116
137
  onclick={goNextQuestion}
117
138
  >
118
- {currentQuestion?.feedbackVisible && isImmediateMode ? 'Continue' : 'Next'}
139
+ {currentQuestion?.feedbackVisible && isImmediateMode
140
+ ? 'Continue'
141
+ : 'Next'}
119
142
  </button>
120
143
  {:else if needsReveal(currentQuestion)}
121
144
  <button
@@ -134,19 +157,26 @@
134
157
  </button>
135
158
  {/if}
136
159
  </div>
137
-
138
160
  {:else if handle.state === 'reviewing'}
139
161
  <!-- Review phase -->
140
162
  <div class="tessera-quiz-progress" aria-live="polite">
141
163
  <span class="tessera-quiz-progress-text">
142
- <span class="tessera-quiz-progress-desktop">Review: Question {reviewIndex + 1} of {totalQuestions}</span>
143
- <span class="tessera-quiz-progress-mobile">Review: {reviewIndex + 1}/{totalQuestions}</span>
164
+ <span class="tessera-quiz-progress-desktop"
165
+ >Review: Question {reviewIndex + 1} of {totalQuestions}</span
166
+ >
167
+ <span class="tessera-quiz-progress-mobile"
168
+ >Review: {reviewIndex + 1}/{totalQuestions}</span
169
+ >
144
170
  </span>
145
171
  </div>
146
172
 
147
173
  <div class="tessera-quiz-questions">
148
174
  {#each handle.questions as q, i (q.id)}
149
- <div class="tessera-quiz-question-wrapper" class:active={i === reviewIndex} aria-hidden={i !== reviewIndex}>
175
+ <div
176
+ class="tessera-quiz-question-wrapper"
177
+ class:active={i === reviewIndex}
178
+ aria-hidden={i !== reviewIndex}
179
+ >
150
180
  {#if q.render}
151
181
  {@render q.render()}
152
182
  {/if}
@@ -178,14 +208,17 @@
178
208
  </button>
179
209
  {/if}
180
210
  </div>
181
-
182
211
  {:else}
183
212
  <!-- Results phase -->
184
213
  <div class="tessera-quiz-results" role="status" aria-live="polite">
185
214
  <h2 class="tessera-quiz-results-title">Quiz Results</h2>
186
215
  <div class="tessera-quiz-score">
187
216
  <span class="tessera-quiz-score-value">{handle.score}%</span>
188
- <span class="tessera-quiz-score-label" class:passed class:failed={!passed}>
217
+ <span
218
+ class="tessera-quiz-score-label"
219
+ class:passed
220
+ class:failed={!passed}
221
+ >
189
222
  {passed ? 'Passed' : 'Not Passed'}
190
223
  </span>
191
224
  </div>
@@ -211,7 +244,9 @@
211
244
  </button>
212
245
  {/if}
213
246
  {#if maxAttempts !== Infinity && handle.attemptCount >= maxAttempts}
214
- <p class="tessera-quiz-attempts-exhausted">All attempts used ({handle.attemptCount}/{maxAttempts})</p>
247
+ <p class="tessera-quiz-attempts-exhausted">
248
+ All attempts used ({handle.attemptCount}/{maxAttempts})
249
+ </p>
215
250
  {/if}
216
251
  </div>
217
252
  </div>
@@ -272,7 +307,9 @@
272
307
  font-size: 0.9375rem;
273
308
  font-weight: 500;
274
309
  cursor: pointer;
275
- transition: background 0.2s, opacity 0.2s;
310
+ transition:
311
+ background 0.2s,
312
+ opacity 0.2s;
276
313
  min-height: 44px;
277
314
  min-width: 44px;
278
315
  }
@@ -353,8 +390,12 @@
353
390
 
354
391
  /* Mobile */
355
392
  @media (max-width: 640px) {
356
- .tessera-quiz-progress-desktop { display: none; }
357
- .tessera-quiz-progress-mobile { display: inline; }
393
+ .tessera-quiz-progress-desktop {
394
+ display: none;
395
+ }
396
+ .tessera-quiz-progress-mobile {
397
+ display: inline;
398
+ }
358
399
 
359
400
  .tessera-quiz-nav {
360
401
  position: sticky;
@@ -3,11 +3,27 @@
3
3
  </script>
4
4
 
5
5
  {#if kind === 'correct'}
6
- <svg viewBox="0 0 16 16" fill="currentColor" width={size} height={size} 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"/>
6
+ <svg
7
+ viewBox="0 0 16 16"
8
+ fill="currentColor"
9
+ width={size}
10
+ height={size}
11
+ aria-hidden="true"
12
+ >
13
+ <path
14
+ 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"
15
+ />
8
16
  </svg>
9
17
  {:else}
10
- <svg viewBox="0 0 16 16" fill="currentColor" width={size} height={size} aria-hidden="true">
11
- <path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"/>
18
+ <svg
19
+ viewBox="0 0 16 16"
20
+ fill="currentColor"
21
+ width={size}
22
+ height={size}
23
+ aria-hidden="true"
24
+ >
25
+ <path
26
+ d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z"
27
+ />
12
28
  </svg>
13
29
  {/if}
@@ -7,8 +7,6 @@
7
7
  * @prop {import('svelte').Snippet} trigger - Trigger content snippet
8
8
  * @prop {import('svelte').Snippet} content - Modal body snippet
9
9
  */
10
- import { onMount } from 'svelte';
11
-
12
10
  let { trigger, content, title = '' } = $props();
13
11
  let open = $state(false);
14
12
  let modalRef = $state(null);
@@ -26,13 +24,6 @@
26
24
  }
27
25
  }
28
26
 
29
- function handleTriggerKey(e) {
30
- if (e.key === 'Enter' || e.key === ' ') {
31
- e.preventDefault();
32
- openModal();
33
- }
34
- }
35
-
36
27
  function handleOverlayClick(e) {
37
28
  if (e.target === e.currentTarget) {
38
29
  closeModal();
@@ -48,7 +39,7 @@
48
39
  // Focus trap
49
40
  if (e.key === 'Tab' && modalRef) {
50
41
  const focusable = modalRef.querySelectorAll(
51
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
42
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
52
43
  );
53
44
  if (focusable.length === 0) return;
54
45
 
@@ -76,7 +67,7 @@
76
67
  queueMicrotask(() => {
77
68
  if (modalRef) {
78
69
  const firstFocusable = modalRef.querySelector(
79
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
70
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
80
71
  );
81
72
  if (firstFocusable) firstFocusable.focus();
82
73
  else modalRef.focus();
@@ -90,10 +81,7 @@
90
81
 
91
82
  <!-- svelte-ignore a11y_click_events_have_key_events -->
92
83
  <!-- svelte-ignore a11y_no_static_element_interactions -->
93
- <div
94
- class="tessera-reveal-trigger"
95
- onclick={openModal}
96
- >
84
+ <div class="tessera-reveal-trigger" onclick={openModal}>
97
85
  {@render trigger()}
98
86
  </div>
99
87
 
@@ -118,7 +106,11 @@
118
106
  <div class="tessera-modal-body">
119
107
  {@render content()}
120
108
  </div>
121
- <button class="tessera-modal-close" onclick={closeModal} aria-label="Close modal">
109
+ <button
110
+ class="tessera-modal-close"
111
+ onclick={closeModal}
112
+ aria-label="Close modal"
113
+ >
122
114
 
123
115
  </button>
124
116
  </div>
@@ -194,8 +186,9 @@
194
186
  color: var(--tessera-text-light);
195
187
  cursor: pointer;
196
188
  border-radius: 6px;
197
- transition: background-color var(--tessera-transition-fast),
198
- color var(--tessera-transition-fast);
189
+ transition:
190
+ background-color var(--tessera-transition-fast),
191
+ color var(--tessera-transition-fast);
199
192
  }
200
193
 
201
194
  .tessera-modal-close:hover {
@@ -209,13 +202,23 @@
209
202
  }
210
203
 
211
204
  @keyframes tessera-modal-fade-in {
212
- from { opacity: 0; }
213
- to { opacity: 1; }
205
+ from {
206
+ opacity: 0;
207
+ }
208
+ to {
209
+ opacity: 1;
210
+ }
214
211
  }
215
212
 
216
213
  @keyframes tessera-modal-slide-in {
217
- from { transform: translateY(10px); opacity: 0; }
218
- to { transform: translateY(0); opacity: 1; }
214
+ from {
215
+ transform: translateY(10px);
216
+ opacity: 0;
217
+ }
218
+ to {
219
+ transform: translateY(0);
220
+ opacity: 1;
221
+ }
219
222
  }
220
223
 
221
224
  @media (max-width: 640px) {