tessera-learn 0.0.1

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 (71) hide show
  1. package/AGENTS.md +1228 -0
  2. package/LICENSE +21 -0
  3. package/README.md +21 -0
  4. package/dist/plugin/index.d.ts +7 -0
  5. package/dist/plugin/index.d.ts.map +1 -0
  6. package/dist/plugin/index.js +1239 -0
  7. package/dist/plugin/index.js.map +1 -0
  8. package/package.json +77 -0
  9. package/src/archiver.d.ts +27 -0
  10. package/src/components/Accordion.svelte +32 -0
  11. package/src/components/AccordionItem.svelte +144 -0
  12. package/src/components/Audio.svelte +38 -0
  13. package/src/components/Callout.svelte +81 -0
  14. package/src/components/Carousel.svelte +194 -0
  15. package/src/components/CarouselSlide.svelte +32 -0
  16. package/src/components/DefaultLayout.svelte +108 -0
  17. package/src/components/FillInTheBlank.svelte +345 -0
  18. package/src/components/Image.svelte +47 -0
  19. package/src/components/Matching.svelte +513 -0
  20. package/src/components/MultipleChoice.svelte +363 -0
  21. package/src/components/Quiz.svelte +569 -0
  22. package/src/components/RevealModal.svelte +228 -0
  23. package/src/components/Sorting.svelte +663 -0
  24. package/src/components/Video.svelte +118 -0
  25. package/src/components/index.ts +15 -0
  26. package/src/components/quiz-payload.ts +71 -0
  27. package/src/components/util.ts +24 -0
  28. package/src/index.ts +56 -0
  29. package/src/plugin/export.ts +264 -0
  30. package/src/plugin/index.ts +464 -0
  31. package/src/plugin/layout.ts +55 -0
  32. package/src/plugin/manifest.ts +330 -0
  33. package/src/plugin/quiz.ts +65 -0
  34. package/src/plugin/validation.ts +838 -0
  35. package/src/runtime/App.svelte +435 -0
  36. package/src/runtime/ErrorPage.svelte +14 -0
  37. package/src/runtime/LoadingSkeleton.svelte +26 -0
  38. package/src/runtime/Sidebar.svelte +76 -0
  39. package/src/runtime/access.ts +55 -0
  40. package/src/runtime/adapters/cmi5.ts +341 -0
  41. package/src/runtime/adapters/discovery.ts +38 -0
  42. package/src/runtime/adapters/index.ts +99 -0
  43. package/src/runtime/adapters/retry.ts +284 -0
  44. package/src/runtime/adapters/scorm12.ts +172 -0
  45. package/src/runtime/adapters/scorm2004.ts +162 -0
  46. package/src/runtime/adapters/web.ts +62 -0
  47. package/src/runtime/contexts.ts +76 -0
  48. package/src/runtime/duration.ts +29 -0
  49. package/src/runtime/hooks.svelte.ts +543 -0
  50. package/src/runtime/interaction-format.ts +132 -0
  51. package/src/runtime/interaction.ts +96 -0
  52. package/src/runtime/navigation.svelte.ts +117 -0
  53. package/src/runtime/persistence.ts +56 -0
  54. package/src/runtime/progress.svelte.ts +168 -0
  55. package/src/runtime/quiz-policy.ts +227 -0
  56. package/src/runtime/slugify.ts +17 -0
  57. package/src/runtime/types.ts +92 -0
  58. package/src/runtime/xapi/agent-rules.ts +93 -0
  59. package/src/runtime/xapi/client.ts +133 -0
  60. package/src/runtime/xapi/derive-actor.ts +90 -0
  61. package/src/runtime/xapi/publisher.ts +604 -0
  62. package/src/runtime/xapi/registry.ts +38 -0
  63. package/src/runtime/xapi/setup.ts +250 -0
  64. package/src/runtime/xapi/types.ts +106 -0
  65. package/src/runtime/xapi/uuid.ts +21 -0
  66. package/src/runtime/xapi/validation.ts +71 -0
  67. package/src/runtime/xapi/version.ts +23 -0
  68. package/src/virtual.d.ts +16 -0
  69. package/styles/base.css +194 -0
  70. package/styles/layout.css +408 -0
  71. package/styles/theme.css +36 -0
@@ -0,0 +1,194 @@
1
+ <script>
2
+ /**
3
+ * @component Carousel
4
+ * Container for CarouselSlide components with arrow navigation and dot indicators.
5
+ *
6
+ * @prop {import('svelte').Snippet} [children] - CarouselSlide children
7
+ */
8
+ import { setContext } from 'svelte';
9
+
10
+ let { children } = $props();
11
+ let currentSlide = $state(0);
12
+ let totalSlides = $state(0);
13
+ let touchStartX = 0;
14
+ let touchEndX = 0;
15
+
16
+ const ctx = {
17
+ get currentSlide() { return currentSlide; },
18
+ register() {
19
+ const index = totalSlides;
20
+ totalSlides++;
21
+ return index;
22
+ },
23
+ };
24
+
25
+ setContext('tessera-carousel', ctx);
26
+
27
+ function prev() {
28
+ if (currentSlide > 0) currentSlide--;
29
+ }
30
+
31
+ function next() {
32
+ if (currentSlide < totalSlides - 1) currentSlide++;
33
+ }
34
+
35
+ function goTo(index) {
36
+ currentSlide = index;
37
+ }
38
+
39
+ function handleKeydown(e) {
40
+ if (e.key === 'ArrowLeft') { prev(); e.preventDefault(); }
41
+ if (e.key === 'ArrowRight') { next(); e.preventDefault(); }
42
+ }
43
+
44
+ function handleTouchStart(e) {
45
+ touchStartX = e.changedTouches[0].screenX;
46
+ }
47
+
48
+ function handleTouchEnd(e) {
49
+ touchEndX = e.changedTouches[0].screenX;
50
+ const diff = touchStartX - touchEndX;
51
+ if (Math.abs(diff) > 50) {
52
+ if (diff > 0) next();
53
+ else prev();
54
+ }
55
+ }
56
+
57
+ // Build dots array reactively
58
+ let dots = $derived(Array.from({ length: totalSlides }, (_, i) => i));
59
+ </script>
60
+
61
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
62
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
63
+ <div
64
+ class="tessera-carousel"
65
+ role="region"
66
+ aria-roledescription="carousel"
67
+ aria-label="Slide carousel"
68
+ onkeydown={handleKeydown}
69
+ ontouchstart={handleTouchStart}
70
+ ontouchend={handleTouchEnd}
71
+ tabindex="0"
72
+ >
73
+ <div class="tessera-carousel-viewport">
74
+ <div class="tessera-carousel-track" style="transform: translateX(-{currentSlide * 100}%)">
75
+ {@render children?.()}
76
+ </div>
77
+ </div>
78
+
79
+ <div class="tessera-carousel-controls">
80
+ <button
81
+ class="tessera-carousel-arrow tessera-carousel-prev"
82
+ onclick={prev}
83
+ disabled={currentSlide === 0}
84
+ aria-label="Previous slide"
85
+ >
86
+
87
+ </button>
88
+
89
+ <div class="tessera-carousel-dots" role="tablist" aria-label="Slide indicators">
90
+ {#each dots as dot}
91
+ <button
92
+ class="tessera-carousel-dot"
93
+ class:active={dot === currentSlide}
94
+ role="tab"
95
+ aria-selected={dot === currentSlide}
96
+ aria-label="Go to slide {dot + 1}"
97
+ onclick={() => goTo(dot)}
98
+ ></button>
99
+ {/each}
100
+ </div>
101
+
102
+ <button
103
+ class="tessera-carousel-arrow tessera-carousel-next"
104
+ onclick={next}
105
+ disabled={currentSlide >= totalSlides - 1}
106
+ aria-label="Next slide"
107
+ >
108
+
109
+ </button>
110
+ </div>
111
+ </div>
112
+
113
+ <style>
114
+ .tessera-carousel {
115
+ margin-bottom: var(--tessera-spacing-lg);
116
+ position: relative;
117
+ }
118
+
119
+ .tessera-carousel-viewport {
120
+ overflow: hidden;
121
+ border-radius: 8px;
122
+ border: 1px solid var(--tessera-border);
123
+ }
124
+
125
+ .tessera-carousel-track {
126
+ display: flex;
127
+ transition: transform var(--tessera-transition-normal);
128
+ }
129
+
130
+ .tessera-carousel-controls {
131
+ display: flex;
132
+ align-items: center;
133
+ justify-content: center;
134
+ gap: var(--tessera-spacing-md);
135
+ margin-top: var(--tessera-spacing-md);
136
+ }
137
+
138
+ .tessera-carousel-arrow {
139
+ display: inline-flex;
140
+ align-items: center;
141
+ justify-content: center;
142
+ width: 36px;
143
+ height: 36px;
144
+ font-size: 1.5rem;
145
+ line-height: 1;
146
+ color: var(--tessera-text);
147
+ background: var(--tessera-bg);
148
+ border: 1px solid var(--tessera-border);
149
+ border-radius: 50%;
150
+ cursor: pointer;
151
+ transition: background-color var(--tessera-transition-fast),
152
+ color var(--tessera-transition-fast);
153
+ }
154
+
155
+ .tessera-carousel-arrow:hover:not(:disabled) {
156
+ background-color: var(--tessera-bg-secondary);
157
+ }
158
+
159
+ .tessera-carousel-arrow:disabled {
160
+ opacity: 0.35;
161
+ cursor: not-allowed;
162
+ }
163
+
164
+ .tessera-carousel-arrow:focus-visible {
165
+ box-shadow: var(--tessera-focus-ring);
166
+ outline: none;
167
+ }
168
+
169
+ .tessera-carousel-dots {
170
+ display: flex;
171
+ gap: 8px;
172
+ }
173
+
174
+ .tessera-carousel-dot {
175
+ width: 10px;
176
+ height: 10px;
177
+ border-radius: 50%;
178
+ border: 1px solid var(--tessera-border);
179
+ background: var(--tessera-bg);
180
+ cursor: pointer;
181
+ padding: 0;
182
+ transition: background-color var(--tessera-transition-fast);
183
+ }
184
+
185
+ .tessera-carousel-dot.active {
186
+ background-color: var(--tessera-primary);
187
+ border-color: var(--tessera-primary);
188
+ }
189
+
190
+ .tessera-carousel-dot:focus-visible {
191
+ box-shadow: var(--tessera-focus-ring);
192
+ outline: none;
193
+ }
194
+ </style>
@@ -0,0 +1,32 @@
1
+ <script>
2
+ /**
3
+ * @component CarouselSlide
4
+ * Single slide within a Carousel.
5
+ *
6
+ * @prop {import('svelte').Snippet} [children] - Slide content
7
+ */
8
+ import { getContext } from 'svelte';
9
+
10
+ let { children } = $props();
11
+ const carousel = getContext('tessera-carousel');
12
+ const index = carousel.register();
13
+
14
+ let isCurrent = $derived(carousel.currentSlide === index);
15
+ </script>
16
+
17
+ <div
18
+ class="tessera-carousel-slide"
19
+ role="tabpanel"
20
+ aria-hidden={!isCurrent}
21
+ aria-label="Slide {index + 1}"
22
+ >
23
+ {@render children?.()}
24
+ </div>
25
+
26
+ <style>
27
+ .tessera-carousel-slide {
28
+ flex: 0 0 100%;
29
+ min-width: 0;
30
+ padding: var(--tessera-spacing-lg);
31
+ }
32
+ </style>
@@ -0,0 +1,108 @@
1
+ <script>
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import Sidebar from '../runtime/Sidebar.svelte';
4
+ import { requireNavContext } from '../runtime/contexts.js';
5
+
6
+ let { page } = $props();
7
+ const { nav, manifest, config, progress } = requireNavContext('DefaultLayout');
8
+
9
+ let sidebarOpen = $state(false);
10
+
11
+ let progressPercent = $derived(
12
+ manifest.totalPages > 0
13
+ ? Math.round((progress.visitedPages.size / manifest.totalPages) * 100)
14
+ : 0
15
+ );
16
+
17
+ function toggleSidebar() {
18
+ sidebarOpen = !sidebarOpen;
19
+ }
20
+
21
+ function closeSidebar() {
22
+ sidebarOpen = false;
23
+ }
24
+
25
+ function handleKeyNav(e) {
26
+ const tag = e.target?.tagName;
27
+ if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return;
28
+ if (e.target?.closest('[role="radiogroup"], [role="dialog"], .tessera-accordion, .tessera-carousel, .tessera-quiz')) return;
29
+
30
+ if (e.key === 'ArrowLeft') { nav.goPrev(); e.preventDefault(); }
31
+ if (e.key === 'ArrowRight') { nav.goNext(); e.preventDefault(); }
32
+ if (e.key === 'Escape' && sidebarOpen) { closeSidebar(); e.preventDefault(); }
33
+ }
34
+
35
+ onMount(() => {
36
+ window.addEventListener('keydown', handleKeyNav);
37
+ });
38
+
39
+ onDestroy(() => {
40
+ window.removeEventListener('keydown', handleKeyNav);
41
+ });
42
+ </script>
43
+
44
+ <button
45
+ class="tessera-hamburger"
46
+ aria-label={sidebarOpen ? 'Close navigation' : 'Open navigation'}
47
+ aria-expanded={sidebarOpen}
48
+ onclick={toggleSidebar}
49
+ >
50
+ <span class="tessera-hamburger-lines">
51
+ <span class="tessera-hamburger-line"></span>
52
+ <span class="tessera-hamburger-line"></span>
53
+ <span class="tessera-hamburger-line"></span>
54
+ </span>
55
+ </button>
56
+
57
+ {#if sidebarOpen}
58
+ <div
59
+ class="tessera-sidebar-overlay visible"
60
+ role="presentation"
61
+ onclick={closeSidebar}
62
+ ></div>
63
+ {/if}
64
+
65
+ <div class="tessera-app" data-chrome="default">
66
+ <aside class="tessera-sidebar" class:open={sidebarOpen} aria-label="Course sidebar">
67
+ <Sidebar
68
+ {manifest}
69
+ {config}
70
+ currentPageIndex={nav.currentPageIndex}
71
+ {nav}
72
+ onnavigate={(index) => nav.goToPage(index)}
73
+ onclose={closeSidebar}
74
+ />
75
+ </aside>
76
+
77
+ <main class="tessera-main">
78
+ <div class="tessera-content">
79
+ {@render page()}
80
+ </div>
81
+
82
+ <div class="tessera-page-nav">
83
+ <button
84
+ class="tessera-page-nav-btn"
85
+ disabled={!nav.canGoPrev}
86
+ onclick={() => nav.goPrev()}
87
+ >
88
+ ← Previous
89
+ </button>
90
+ <button
91
+ class="tessera-page-nav-btn"
92
+ disabled={!nav.canGoNext}
93
+ onclick={() => nav.goNext()}
94
+ >
95
+ Next →
96
+ </button>
97
+ </div>
98
+ </main>
99
+
100
+ <footer class="tessera-progress" aria-label="Course progress">
101
+ <div class="tessera-progress-track" role="progressbar"
102
+ aria-valuenow={progressPercent} aria-valuemin={0} aria-valuemax={100}
103
+ aria-label="Course progress">
104
+ <div class="tessera-progress-fill" style="width: {progressPercent}%"></div>
105
+ </div>
106
+ <div class="tessera-progress-label">{progress.visitedPages.size} of {manifest.totalPages} pages</div>
107
+ </footer>
108
+ </div>
@@ -0,0 +1,345 @@
1
+ <script>
2
+ import { getContext, onMount } from 'svelte';
3
+ import { useQuestion } from '../runtime/hooks.svelte.js';
4
+ import { slugFromQuestion } from './util.js';
5
+
6
+ let {
7
+ id,
8
+ question,
9
+ answers,
10
+ caseSensitive = false,
11
+ correctFeedback = '',
12
+ incorrectFeedback = '',
13
+ maxRetries = Infinity,
14
+ weight = 1,
15
+ } = $props();
16
+
17
+ const quiz = getContext('tessera-quiz');
18
+ const standalone = !quiz;
19
+
20
+ let inputValue = $state('');
21
+ let saRetryCount = $state(0);
22
+ let saCanRetry = $derived(saRetryCount < maxRetries);
23
+
24
+ const inputId = `fitb-${Math.random().toString(36).slice(2, 9)}`;
25
+ const defaultId = `fitb-${slugFromQuestion(question)}`;
26
+
27
+ function checkAnswer(userAnswer) {
28
+ if (!userAnswer || typeof userAnswer !== 'string') return false;
29
+ const trimmed = userAnswer.trim();
30
+ return answers.some(acceptable => {
31
+ const a = acceptable.trim();
32
+ if (caseSensitive) return trimmed === a;
33
+ return trimmed.toLowerCase() === a.toLowerCase();
34
+ });
35
+ }
36
+
37
+ const handle = useQuestion({
38
+ id: id ?? defaultId,
39
+ weight,
40
+ response: () => ({
41
+ type: 'fill-in',
42
+ response: inputValue,
43
+ correct: Array.isArray(answers) ? answers : [answers],
44
+ caseMatters: !!caseSensitive,
45
+ }),
46
+ reset: () => { inputValue = ''; },
47
+ });
48
+
49
+ const myIndex = $derived(handle.quizIndex ?? -1);
50
+
51
+ onMount(() => {
52
+ if (!standalone) quiz.setRender(myIndex, renderQuestion);
53
+ });
54
+
55
+ let isLocked = $derived(standalone ? false : quiz.isLockedCorrect(myIndex));
56
+ let quizLocked = $derived(standalone ? handle.submitted : quiz.isAnswerLocked(myIndex));
57
+
58
+ function handleInput(e) {
59
+ if (standalone) {
60
+ if (handle.submitted) return;
61
+ inputValue = e.target.value;
62
+ } else {
63
+ if (quizLocked) return;
64
+ inputValue = e.target.value;
65
+ quiz.setAnswer(myIndex, inputValue);
66
+ }
67
+ }
68
+
69
+ function handleKeydown(e) {
70
+ if (!standalone || handle.submitted) return;
71
+ if (e.key === 'Enter' && inputValue.trim()) {
72
+ handle.submit();
73
+ }
74
+ }
75
+
76
+ function handleRetry() {
77
+ saRetryCount++;
78
+ inputValue = '';
79
+ handle.reset();
80
+ }
81
+ </script>
82
+
83
+ {#if standalone}
84
+ <div class="tessera-fitb">
85
+ <label class="tessera-fitb-question" for={inputId}>{question}</label>
86
+
87
+ <div class="tessera-fitb-input-wrapper">
88
+ <input
89
+ type="text"
90
+ id={inputId}
91
+ class="tessera-fitb-input"
92
+ class:correct={handle.submitted && checkAnswer(inputValue)}
93
+ class:incorrect={handle.submitted && !checkAnswer(inputValue)}
94
+ value={inputValue}
95
+ oninput={handleInput}
96
+ onkeydown={handleKeydown}
97
+ disabled={handle.submitted}
98
+ placeholder="Type your answer..."
99
+ autocomplete="off"
100
+ />
101
+ {#if !handle.submitted}
102
+ <button
103
+ class="tessera-fitb-check-btn"
104
+ disabled={!inputValue.trim()}
105
+ onclick={() => { handle.submit(); }}
106
+ >
107
+ Check
108
+ </button>
109
+ {/if}
110
+ </div>
111
+
112
+ {#if handle.submitted}
113
+ {@const isCorrect = checkAnswer(inputValue)}
114
+ <div class="tessera-fitb-review">
115
+ {#if isCorrect}
116
+ <div class="tessera-fitb-result correct">
117
+ <svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16" aria-hidden="true">
118
+ <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"/>
119
+ </svg>
120
+ Correct
121
+ </div>
122
+ {#if correctFeedback}
123
+ <p class="tessera-fitb-feedback correct">{correctFeedback}</p>
124
+ {/if}
125
+ {:else}
126
+ <div class="tessera-fitb-result incorrect">
127
+ <svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16" aria-hidden="true">
128
+ <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"/>
129
+ </svg>
130
+ Incorrect
131
+ </div>
132
+ <p class="tessera-fitb-correct-answer">
133
+ Correct answer{answers.length > 1 ? 's' : ''}: {answers.join(', ')}
134
+ </p>
135
+ {#if incorrectFeedback}
136
+ <p class="tessera-fitb-feedback incorrect">{incorrectFeedback}</p>
137
+ {/if}
138
+ {/if}
139
+ {#if saCanRetry}
140
+ <button class="tessera-standalone-retry" onclick={handleRetry}>Try again</button>
141
+ {/if}
142
+ </div>
143
+ {/if}
144
+ </div>
145
+ {/if}
146
+
147
+ {#snippet renderQuestion()}
148
+ <div class="tessera-fitb">
149
+ {#if isLocked}
150
+ <div class="tessera-quiz-locked-banner">
151
+ <svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16" aria-hidden="true">
152
+ <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"/>
153
+ </svg>
154
+ You already got this one right — click Next to continue.
155
+ </div>
156
+ {/if}
157
+ <label class="tessera-fitb-question" for={inputId}>{question}</label>
158
+
159
+ <div class="tessera-fitb-input-wrapper">
160
+ <input
161
+ type="text"
162
+ id={inputId}
163
+ class="tessera-fitb-input"
164
+ class:correct={quiz.feedbackVisible(myIndex) && checkAnswer(quiz.getAnswer(myIndex))}
165
+ class:incorrect={quiz.feedbackVisible(myIndex) && !checkAnswer(quiz.getAnswer(myIndex))}
166
+ value={quizLocked ? (quiz.getAnswer(myIndex) ?? '') : inputValue}
167
+ oninput={handleInput}
168
+ disabled={quizLocked}
169
+ placeholder="Type your answer..."
170
+ autocomplete="off"
171
+ />
172
+ </div>
173
+
174
+ {#if quiz.feedbackVisible(myIndex)}
175
+ {@const userAnswer = quiz.getAnswer(myIndex)}
176
+ {@const isCorrect = checkAnswer(userAnswer)}
177
+ <div class="tessera-fitb-review">
178
+ {#if isCorrect}
179
+ <div class="tessera-fitb-result correct">
180
+ <svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16" aria-hidden="true">
181
+ <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"/>
182
+ </svg>
183
+ Correct
184
+ </div>
185
+ {#if correctFeedback}
186
+ <p class="tessera-fitb-feedback correct">{correctFeedback}</p>
187
+ {/if}
188
+ {:else}
189
+ <div class="tessera-fitb-result incorrect">
190
+ <svg viewBox="0 0 16 16" fill="currentColor" width="16" height="16" aria-hidden="true">
191
+ <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"/>
192
+ </svg>
193
+ Incorrect
194
+ </div>
195
+ <p class="tessera-fitb-correct-answer">
196
+ Correct answer{answers.length > 1 ? 's' : ''}: {answers.join(', ')}
197
+ </p>
198
+ {#if incorrectFeedback}
199
+ <p class="tessera-fitb-feedback incorrect">{incorrectFeedback}</p>
200
+ {/if}
201
+ {/if}
202
+ </div>
203
+ {/if}
204
+ </div>
205
+ {/snippet}
206
+
207
+ <style>
208
+ .tessera-fitb {
209
+ padding: var(--tessera-spacing-md) 0;
210
+ }
211
+
212
+ .tessera-fitb-question {
213
+ display: block;
214
+ font-size: 1.125rem;
215
+ font-weight: 600;
216
+ margin-bottom: var(--tessera-spacing-lg);
217
+ color: var(--tessera-text);
218
+ }
219
+
220
+ .tessera-fitb-input-wrapper {
221
+ max-width: 400px;
222
+ }
223
+
224
+ .tessera-fitb-input {
225
+ width: 100%;
226
+ padding: 0.625rem 0.875rem;
227
+ border: 2px solid var(--tessera-border);
228
+ border-radius: 6px;
229
+ font-size: 1rem;
230
+ font-family: var(--tessera-font-family);
231
+ color: var(--tessera-text);
232
+ background: var(--tessera-bg);
233
+ transition: border-color 0.2s;
234
+ min-height: 44px;
235
+ }
236
+
237
+ .tessera-fitb-input:focus {
238
+ outline: none;
239
+ border-color: var(--tessera-primary);
240
+ box-shadow: var(--tessera-focus-ring, 0 0 0 3px rgba(37, 99, 235, 0.4));
241
+ }
242
+
243
+ .tessera-fitb-input:disabled {
244
+ background: var(--tessera-bg-secondary);
245
+ cursor: not-allowed;
246
+ }
247
+
248
+ .tessera-fitb-input.correct {
249
+ border-color: var(--tessera-success);
250
+ }
251
+
252
+ .tessera-fitb-input.incorrect {
253
+ border-color: var(--tessera-error);
254
+ }
255
+
256
+ .tessera-fitb-review {
257
+ margin-top: var(--tessera-spacing-md);
258
+ }
259
+
260
+ .tessera-fitb-result {
261
+ display: flex;
262
+ align-items: center;
263
+ gap: var(--tessera-spacing-sm);
264
+ font-weight: 600;
265
+ font-size: 0.9375rem;
266
+ margin-bottom: var(--tessera-spacing-sm);
267
+ }
268
+
269
+ .tessera-fitb-result.correct {
270
+ color: var(--tessera-success);
271
+ }
272
+
273
+ .tessera-fitb-result.incorrect {
274
+ color: var(--tessera-error);
275
+ }
276
+
277
+ .tessera-fitb-correct-answer {
278
+ font-size: 0.875rem;
279
+ color: var(--tessera-text-light);
280
+ margin-bottom: var(--tessera-spacing-sm);
281
+ }
282
+
283
+ .tessera-fitb-feedback {
284
+ font-size: 0.875rem;
285
+ padding: var(--tessera-spacing-sm) var(--tessera-spacing-md);
286
+ border-radius: 4px;
287
+ }
288
+
289
+ .tessera-fitb-feedback.correct {
290
+ color: var(--tessera-success);
291
+ background: color-mix(in srgb, var(--tessera-success) 8%, transparent);
292
+ }
293
+
294
+ .tessera-fitb-feedback.incorrect {
295
+ color: var(--tessera-error);
296
+ background: color-mix(in srgb, var(--tessera-error) 8%, transparent);
297
+ }
298
+
299
+ .tessera-fitb-check-btn {
300
+ margin-top: var(--tessera-spacing-sm);
301
+ padding: 0.5rem 1rem;
302
+ font-size: 0.875rem;
303
+ font-weight: 600;
304
+ color: #fff;
305
+ background: var(--tessera-primary);
306
+ border: none;
307
+ border-radius: 6px;
308
+ cursor: pointer;
309
+ min-height: 44px;
310
+ transition: background 0.2s, opacity 0.2s;
311
+ }
312
+
313
+ .tessera-fitb-check-btn:hover:not(:disabled) {
314
+ background: var(--tessera-primary-dark);
315
+ }
316
+
317
+ .tessera-fitb-check-btn:disabled {
318
+ opacity: 0.4;
319
+ cursor: not-allowed;
320
+ }
321
+
322
+ .tessera-standalone-retry {
323
+ display: inline-block;
324
+ margin-top: var(--tessera-spacing-md);
325
+ padding: 0;
326
+ font-size: 0.875rem;
327
+ font-weight: 600;
328
+ color: var(--tessera-primary);
329
+ background: none;
330
+ border: none;
331
+ cursor: pointer;
332
+ text-decoration: underline;
333
+ text-underline-offset: 2px;
334
+ }
335
+
336
+ .tessera-standalone-retry:hover {
337
+ color: var(--tessera-primary-dark);
338
+ }
339
+
340
+ @media (max-width: 640px) {
341
+ .tessera-fitb-input-wrapper {
342
+ max-width: 100%;
343
+ }
344
+ }
345
+ </style>