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.
- package/AGENTS.md +1228 -0
- package/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/plugin/index.d.ts +7 -0
- package/dist/plugin/index.d.ts.map +1 -0
- package/dist/plugin/index.js +1239 -0
- package/dist/plugin/index.js.map +1 -0
- package/package.json +77 -0
- package/src/archiver.d.ts +27 -0
- package/src/components/Accordion.svelte +32 -0
- package/src/components/AccordionItem.svelte +144 -0
- package/src/components/Audio.svelte +38 -0
- package/src/components/Callout.svelte +81 -0
- package/src/components/Carousel.svelte +194 -0
- package/src/components/CarouselSlide.svelte +32 -0
- package/src/components/DefaultLayout.svelte +108 -0
- package/src/components/FillInTheBlank.svelte +345 -0
- package/src/components/Image.svelte +47 -0
- package/src/components/Matching.svelte +513 -0
- package/src/components/MultipleChoice.svelte +363 -0
- package/src/components/Quiz.svelte +569 -0
- package/src/components/RevealModal.svelte +228 -0
- package/src/components/Sorting.svelte +663 -0
- package/src/components/Video.svelte +118 -0
- package/src/components/index.ts +15 -0
- package/src/components/quiz-payload.ts +71 -0
- package/src/components/util.ts +24 -0
- package/src/index.ts +56 -0
- package/src/plugin/export.ts +264 -0
- package/src/plugin/index.ts +464 -0
- package/src/plugin/layout.ts +55 -0
- package/src/plugin/manifest.ts +330 -0
- package/src/plugin/quiz.ts +65 -0
- package/src/plugin/validation.ts +838 -0
- package/src/runtime/App.svelte +435 -0
- package/src/runtime/ErrorPage.svelte +14 -0
- package/src/runtime/LoadingSkeleton.svelte +26 -0
- package/src/runtime/Sidebar.svelte +76 -0
- package/src/runtime/access.ts +55 -0
- package/src/runtime/adapters/cmi5.ts +341 -0
- package/src/runtime/adapters/discovery.ts +38 -0
- package/src/runtime/adapters/index.ts +99 -0
- package/src/runtime/adapters/retry.ts +284 -0
- package/src/runtime/adapters/scorm12.ts +172 -0
- package/src/runtime/adapters/scorm2004.ts +162 -0
- package/src/runtime/adapters/web.ts +62 -0
- package/src/runtime/contexts.ts +76 -0
- package/src/runtime/duration.ts +29 -0
- package/src/runtime/hooks.svelte.ts +543 -0
- package/src/runtime/interaction-format.ts +132 -0
- package/src/runtime/interaction.ts +96 -0
- package/src/runtime/navigation.svelte.ts +117 -0
- package/src/runtime/persistence.ts +56 -0
- package/src/runtime/progress.svelte.ts +168 -0
- package/src/runtime/quiz-policy.ts +227 -0
- package/src/runtime/slugify.ts +17 -0
- package/src/runtime/types.ts +92 -0
- package/src/runtime/xapi/agent-rules.ts +93 -0
- package/src/runtime/xapi/client.ts +133 -0
- package/src/runtime/xapi/derive-actor.ts +90 -0
- package/src/runtime/xapi/publisher.ts +604 -0
- package/src/runtime/xapi/registry.ts +38 -0
- package/src/runtime/xapi/setup.ts +250 -0
- package/src/runtime/xapi/types.ts +106 -0
- package/src/runtime/xapi/uuid.ts +21 -0
- package/src/runtime/xapi/validation.ts +71 -0
- package/src/runtime/xapi/version.ts +23 -0
- package/src/virtual.d.ts +16 -0
- package/styles/base.css +194 -0
- package/styles/layout.css +408 -0
- package/styles/theme.css +36 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { getContext, setContext, onMount } from 'svelte';
|
|
3
|
+
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
|
4
|
+
import { buildQuizInteractions } from './quiz-payload.js';
|
|
5
|
+
|
|
6
|
+
let { children } = $props();
|
|
7
|
+
let quizElement = $state(null);
|
|
8
|
+
|
|
9
|
+
// Read quiz config from page context (set by App.svelte)
|
|
10
|
+
const pageCtx = getContext('tessera-page');
|
|
11
|
+
const quizConfig = $derived(pageCtx?.quiz ?? {});
|
|
12
|
+
|
|
13
|
+
// State
|
|
14
|
+
let questions = $state([]);
|
|
15
|
+
let currentQuestionIndex = $state(0);
|
|
16
|
+
let answers = $state(new SvelteMap());
|
|
17
|
+
let submitted = $state(false);
|
|
18
|
+
let score = $state(0);
|
|
19
|
+
let correctCount = $state(0);
|
|
20
|
+
let attemptCount = $state(0);
|
|
21
|
+
let reviewing = $state(false);
|
|
22
|
+
let reviewIndex = $state(0);
|
|
23
|
+
|
|
24
|
+
// Immediate feedback state
|
|
25
|
+
let feedbackShown = $state(new SvelteSet());
|
|
26
|
+
// Retry mode: locked correct questions from prior attempt
|
|
27
|
+
let lockedCorrect = $state(new SvelteSet());
|
|
28
|
+
|
|
29
|
+
// Derived
|
|
30
|
+
let totalQuestions = $derived(questions.length);
|
|
31
|
+
let maxAttempts = $derived(quizConfig.maxAttempts ?? Infinity);
|
|
32
|
+
let showFeedback = $derived(quizConfig.showFeedback ?? true);
|
|
33
|
+
let passingScore = $derived(pageCtx?.passingScore ?? 70);
|
|
34
|
+
let canRetry = $derived(attemptCount < maxAttempts);
|
|
35
|
+
let passed = $derived(score >= passingScore);
|
|
36
|
+
let allAnswered = $derived(totalQuestions > 0 && answers.size >= totalQuestions);
|
|
37
|
+
let feedbackMode = $derived(
|
|
38
|
+
(quizConfig.showFeedback && quizConfig.feedbackMode === 'immediate') ? 'immediate' : 'review'
|
|
39
|
+
);
|
|
40
|
+
let retryMode = $derived(quizConfig.retryMode ?? 'full');
|
|
41
|
+
|
|
42
|
+
// Register question API (children call this on mount).
|
|
43
|
+
// The returned integer is the child's quizIndex — position in the `questions`
|
|
44
|
+
// array — and is used to address the same question in setRender / setAnswer /
|
|
45
|
+
// getAnswer / feedbackVisible / isAnswerLocked. Keep it distinct from the
|
|
46
|
+
// API-level `id` the question passes in (a stable slug used for LMS
|
|
47
|
+
// interaction reporting).
|
|
48
|
+
function registerQuestion(questionApi) {
|
|
49
|
+
const index = questions.length;
|
|
50
|
+
const weight = typeof questionApi.weight === 'number' && questionApi.weight > 0
|
|
51
|
+
? questionApi.weight
|
|
52
|
+
: 1;
|
|
53
|
+
questions.push({ ...questionApi, weight });
|
|
54
|
+
return index;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Hand the Quiz the render snippet for a registered question.
|
|
58
|
+
// Snippets aren't available at a child's script-top (they live in the template
|
|
59
|
+
// block), so built-ins call this from onMount once the snippet has compiled.
|
|
60
|
+
function setRender(index, render) {
|
|
61
|
+
questions[index].render = render;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function setAnswer(questionIndex, answer) {
|
|
65
|
+
answers.set(questionIndex, answer);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getAnswer(questionIndex) {
|
|
69
|
+
return answers.get(questionIndex);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Provide context to child question components
|
|
73
|
+
setContext('tessera-quiz', {
|
|
74
|
+
get registerQuestion() { return registerQuestion; },
|
|
75
|
+
get setRender() { return setRender; },
|
|
76
|
+
get setAnswer() { return setAnswer; },
|
|
77
|
+
get getAnswer() { return getAnswer; },
|
|
78
|
+
get submitted() { return submitted; },
|
|
79
|
+
get reviewing() { return reviewing; },
|
|
80
|
+
get showFeedback() { return showFeedback; },
|
|
81
|
+
get currentQuestionIndex() { return reviewing ? reviewIndex : currentQuestionIndex; },
|
|
82
|
+
get feedbackVisible() {
|
|
83
|
+
return (index) => {
|
|
84
|
+
if (feedbackMode === 'immediate' && showFeedback && feedbackShown.has(index)) return true;
|
|
85
|
+
if (submitted && reviewing && showFeedback) return true;
|
|
86
|
+
return false;
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
get isAnswerLocked() {
|
|
90
|
+
return (index) => {
|
|
91
|
+
// Locked during immediate feedback (answer already revealed)
|
|
92
|
+
if (feedbackMode === 'immediate' && feedbackShown.has(index)) return true;
|
|
93
|
+
// Locked after submission
|
|
94
|
+
if (submitted) return true;
|
|
95
|
+
// Locked from incorrect-only retry
|
|
96
|
+
if (lockedCorrect.has(index)) return true;
|
|
97
|
+
return false;
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
get isLockedCorrect() {
|
|
101
|
+
return (index) => lockedCorrect.has(index);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Navigation
|
|
106
|
+
function goNextQuestion() {
|
|
107
|
+
// Immediate feedback: first Next shows feedback, second advances
|
|
108
|
+
if (feedbackMode === 'immediate'
|
|
109
|
+
&& answers.has(currentQuestionIndex)
|
|
110
|
+
&& !feedbackShown.has(currentQuestionIndex)
|
|
111
|
+
&& !lockedCorrect.has(currentQuestionIndex)) {
|
|
112
|
+
feedbackShown.add(currentQuestionIndex);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (currentQuestionIndex < totalQuestions - 1) {
|
|
116
|
+
currentQuestionIndex++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function goPrevQuestion() {
|
|
121
|
+
if (currentQuestionIndex > 0) {
|
|
122
|
+
currentQuestionIndex--;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function goNextReview() {
|
|
127
|
+
if (reviewIndex < totalQuestions - 1) {
|
|
128
|
+
reviewIndex++;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function goPrevReview() {
|
|
133
|
+
if (reviewIndex > 0) {
|
|
134
|
+
reviewIndex--;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Check if the current question needs immediate feedback before advancing/submitting
|
|
139
|
+
function needsImmediateFeedback() {
|
|
140
|
+
return feedbackMode === 'immediate'
|
|
141
|
+
&& !feedbackShown.has(currentQuestionIndex)
|
|
142
|
+
&& !lockedCorrect.has(currentQuestionIndex)
|
|
143
|
+
&& answers.has(currentQuestionIndex);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function showImmediateFeedback() {
|
|
147
|
+
feedbackShown.add(currentQuestionIndex);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Submission
|
|
151
|
+
function handleSubmit() {
|
|
152
|
+
if (!allAnswered) return;
|
|
153
|
+
|
|
154
|
+
// Weighted rollup: Σ(w·correct)/Σ(w)·100. With every weight = 1 (the
|
|
155
|
+
// default) this collapses to the unweighted mean, so existing courses
|
|
156
|
+
// that never set a `weight` prop see no change.
|
|
157
|
+
let count = 0;
|
|
158
|
+
let weighted = 0;
|
|
159
|
+
let totalWeight = 0;
|
|
160
|
+
for (let i = 0; i < questions.length; i++) {
|
|
161
|
+
const q = questions[i];
|
|
162
|
+
const answer = answers.get(i);
|
|
163
|
+
const ok = q.checkAnswer(answer);
|
|
164
|
+
totalWeight += q.weight;
|
|
165
|
+
if (ok) {
|
|
166
|
+
weighted += q.weight;
|
|
167
|
+
count++;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
correctCount = count;
|
|
172
|
+
score = totalWeight === 0 ? 0 : Math.round((weighted / totalWeight) * 100);
|
|
173
|
+
submitted = true;
|
|
174
|
+
attemptCount++;
|
|
175
|
+
|
|
176
|
+
// Report score and per-question interactions via custom event.
|
|
177
|
+
// `interactions` is empty for built-in components until Phase 2 migrates
|
|
178
|
+
// them onto the new useQuestion API — additive and back-compat.
|
|
179
|
+
const interactions = buildQuizInteractions(questions, answers);
|
|
180
|
+
const event = new CustomEvent('tessera-quiz-complete', {
|
|
181
|
+
detail: { score, interactions },
|
|
182
|
+
bubbles: true,
|
|
183
|
+
});
|
|
184
|
+
quizElement?.dispatchEvent(event);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Review
|
|
188
|
+
function startReview() {
|
|
189
|
+
reviewing = true;
|
|
190
|
+
reviewIndex = 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function exitReview() {
|
|
194
|
+
reviewing = false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Retry
|
|
198
|
+
function handleRetry() {
|
|
199
|
+
submitted = false;
|
|
200
|
+
reviewing = false;
|
|
201
|
+
score = 0;
|
|
202
|
+
correctCount = 0;
|
|
203
|
+
currentQuestionIndex = 0;
|
|
204
|
+
reviewIndex = 0;
|
|
205
|
+
feedbackShown = new SvelteSet();
|
|
206
|
+
|
|
207
|
+
if (retryMode === 'incorrect-only') {
|
|
208
|
+
// Identify correct questions
|
|
209
|
+
const newLockedCorrect = new SvelteSet();
|
|
210
|
+
const preservedAnswers = new SvelteMap();
|
|
211
|
+
for (let i = 0; i < questions.length; i++) {
|
|
212
|
+
const answer = answers.get(i);
|
|
213
|
+
if (questions[i].checkAnswer(answer)) {
|
|
214
|
+
newLockedCorrect.add(i);
|
|
215
|
+
preservedAnswers.set(i, answer);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
lockedCorrect = newLockedCorrect;
|
|
219
|
+
answers = preservedAnswers;
|
|
220
|
+
// Only reset incorrect questions
|
|
221
|
+
for (let i = 0; i < questions.length; i++) {
|
|
222
|
+
if (!newLockedCorrect.has(i) && questions[i].reset) {
|
|
223
|
+
questions[i].reset();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} else {
|
|
227
|
+
lockedCorrect = new SvelteSet();
|
|
228
|
+
answers = new SvelteMap();
|
|
229
|
+
for (const q of questions) {
|
|
230
|
+
if (q.reset) q.reset();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
</script>
|
|
236
|
+
|
|
237
|
+
<div class="tessera-quiz" bind:this={quizElement} role="region" aria-label="Quiz">
|
|
238
|
+
{#if !submitted}
|
|
239
|
+
<!-- Question phase -->
|
|
240
|
+
<div class="tessera-quiz-progress" aria-live="polite">
|
|
241
|
+
<span class="tessera-quiz-progress-text">
|
|
242
|
+
<span class="tessera-quiz-progress-desktop">Question {currentQuestionIndex + 1} of {totalQuestions}</span>
|
|
243
|
+
<span class="tessera-quiz-progress-mobile">{currentQuestionIndex + 1}/{totalQuestions}</span>
|
|
244
|
+
</span>
|
|
245
|
+
<div class="tessera-quiz-progress-bar">
|
|
246
|
+
<div
|
|
247
|
+
class="tessera-quiz-progress-fill"
|
|
248
|
+
style="width: {totalQuestions > 0 ? ((currentQuestionIndex + 1) / totalQuestions) * 100 : 0}%"
|
|
249
|
+
></div>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<div class="tessera-quiz-questions">
|
|
254
|
+
{#each questions as q, i (q.id)}
|
|
255
|
+
<div class="tessera-quiz-question-wrapper" class:active={i === currentQuestionIndex} aria-hidden={i !== currentQuestionIndex}>
|
|
256
|
+
{#if q.render}
|
|
257
|
+
{@render q.render()}
|
|
258
|
+
{/if}
|
|
259
|
+
</div>
|
|
260
|
+
{/each}
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<div class="tessera-quiz-nav">
|
|
264
|
+
<button
|
|
265
|
+
class="tessera-quiz-btn tessera-quiz-btn-secondary"
|
|
266
|
+
disabled={currentQuestionIndex === 0}
|
|
267
|
+
onclick={goPrevQuestion}
|
|
268
|
+
>
|
|
269
|
+
Back
|
|
270
|
+
</button>
|
|
271
|
+
{#if currentQuestionIndex < totalQuestions - 1}
|
|
272
|
+
<button
|
|
273
|
+
class="tessera-quiz-btn tessera-quiz-btn-primary"
|
|
274
|
+
disabled={!answers.has(currentQuestionIndex) && !lockedCorrect.has(currentQuestionIndex)}
|
|
275
|
+
onclick={goNextQuestion}
|
|
276
|
+
>
|
|
277
|
+
{feedbackShown.has(currentQuestionIndex) && feedbackMode === 'immediate' ? 'Continue' : 'Next'}
|
|
278
|
+
</button>
|
|
279
|
+
{:else if needsImmediateFeedback()}
|
|
280
|
+
<button
|
|
281
|
+
class="tessera-quiz-btn tessera-quiz-btn-primary"
|
|
282
|
+
onclick={showImmediateFeedback}
|
|
283
|
+
>
|
|
284
|
+
Check Answer
|
|
285
|
+
</button>
|
|
286
|
+
{:else}
|
|
287
|
+
<button
|
|
288
|
+
class="tessera-quiz-btn tessera-quiz-btn-primary tessera-quiz-btn-submit"
|
|
289
|
+
disabled={!allAnswered}
|
|
290
|
+
onclick={handleSubmit}
|
|
291
|
+
>
|
|
292
|
+
Submit
|
|
293
|
+
</button>
|
|
294
|
+
{/if}
|
|
295
|
+
</div>
|
|
296
|
+
|
|
297
|
+
{:else if reviewing}
|
|
298
|
+
<!-- Review phase -->
|
|
299
|
+
<div class="tessera-quiz-progress" aria-live="polite">
|
|
300
|
+
<span class="tessera-quiz-progress-text">
|
|
301
|
+
<span class="tessera-quiz-progress-desktop">Review: Question {reviewIndex + 1} of {totalQuestions}</span>
|
|
302
|
+
<span class="tessera-quiz-progress-mobile">Review: {reviewIndex + 1}/{totalQuestions}</span>
|
|
303
|
+
</span>
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
<div class="tessera-quiz-questions">
|
|
307
|
+
{#each questions as q, i (q.id)}
|
|
308
|
+
<div class="tessera-quiz-question-wrapper" class:active={i === reviewIndex} aria-hidden={i !== reviewIndex}>
|
|
309
|
+
{#if q.render}
|
|
310
|
+
{@render q.render()}
|
|
311
|
+
{/if}
|
|
312
|
+
</div>
|
|
313
|
+
{/each}
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
<div class="tessera-quiz-nav">
|
|
317
|
+
<button
|
|
318
|
+
class="tessera-quiz-btn tessera-quiz-btn-secondary"
|
|
319
|
+
disabled={reviewIndex === 0}
|
|
320
|
+
onclick={goPrevReview}
|
|
321
|
+
>
|
|
322
|
+
Back
|
|
323
|
+
</button>
|
|
324
|
+
{#if reviewIndex < totalQuestions - 1}
|
|
325
|
+
<button
|
|
326
|
+
class="tessera-quiz-btn tessera-quiz-btn-primary"
|
|
327
|
+
onclick={goNextReview}
|
|
328
|
+
>
|
|
329
|
+
Next
|
|
330
|
+
</button>
|
|
331
|
+
{:else}
|
|
332
|
+
<button
|
|
333
|
+
class="tessera-quiz-btn tessera-quiz-btn-primary"
|
|
334
|
+
onclick={exitReview}
|
|
335
|
+
>
|
|
336
|
+
Done
|
|
337
|
+
</button>
|
|
338
|
+
{/if}
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
{:else}
|
|
342
|
+
<!-- Results phase -->
|
|
343
|
+
<div class="tessera-quiz-results" role="status" aria-live="polite">
|
|
344
|
+
<h2 class="tessera-quiz-results-title">Quiz Results</h2>
|
|
345
|
+
<div class="tessera-quiz-score">
|
|
346
|
+
<span class="tessera-quiz-score-value">{score}%</span>
|
|
347
|
+
<span class="tessera-quiz-score-label" class:passed class:failed={!passed}>
|
|
348
|
+
{passed ? 'Passed' : 'Not Passed'}
|
|
349
|
+
</span>
|
|
350
|
+
</div>
|
|
351
|
+
<p class="tessera-quiz-results-detail">
|
|
352
|
+
You answered {correctCount} of {totalQuestions} questions correctly.
|
|
353
|
+
</p>
|
|
354
|
+
|
|
355
|
+
<div class="tessera-quiz-results-actions">
|
|
356
|
+
{#if showFeedback}
|
|
357
|
+
<button
|
|
358
|
+
class="tessera-quiz-btn tessera-quiz-btn-secondary"
|
|
359
|
+
onclick={startReview}
|
|
360
|
+
>
|
|
361
|
+
Review Answers
|
|
362
|
+
</button>
|
|
363
|
+
{/if}
|
|
364
|
+
{#if canRetry}
|
|
365
|
+
<button
|
|
366
|
+
class="tessera-quiz-btn tessera-quiz-btn-primary"
|
|
367
|
+
onclick={handleRetry}
|
|
368
|
+
>
|
|
369
|
+
Retry Quiz
|
|
370
|
+
</button>
|
|
371
|
+
{/if}
|
|
372
|
+
{#if maxAttempts !== Infinity && attemptCount >= maxAttempts}
|
|
373
|
+
<p class="tessera-quiz-attempts-exhausted">All attempts used ({attemptCount}/{maxAttempts})</p>
|
|
374
|
+
{/if}
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
{/if}
|
|
378
|
+
<!-- Children always mounted so snippets survive submit/review phases -->
|
|
379
|
+
<div style="display:none">
|
|
380
|
+
{@render children?.()}
|
|
381
|
+
</div>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
<style>
|
|
385
|
+
.tessera-quiz {
|
|
386
|
+
margin: var(--tessera-spacing-xl) 0;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.tessera-quiz-progress {
|
|
390
|
+
display: flex;
|
|
391
|
+
align-items: center;
|
|
392
|
+
gap: var(--tessera-spacing-md);
|
|
393
|
+
margin-bottom: var(--tessera-spacing-lg);
|
|
394
|
+
font-size: 0.875rem;
|
|
395
|
+
color: var(--tessera-text-light);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
.tessera-quiz-progress-text {
|
|
399
|
+
white-space: nowrap;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.tessera-quiz-progress-mobile {
|
|
403
|
+
display: none;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
.tessera-quiz-progress-bar {
|
|
407
|
+
flex: 1;
|
|
408
|
+
height: 4px;
|
|
409
|
+
background: var(--tessera-border);
|
|
410
|
+
border-radius: 2px;
|
|
411
|
+
overflow: hidden;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
.tessera-quiz-progress-fill {
|
|
415
|
+
height: 100%;
|
|
416
|
+
background: var(--tessera-primary);
|
|
417
|
+
border-radius: 2px;
|
|
418
|
+
transition: width 0.3s ease;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
.tessera-quiz-question-wrapper {
|
|
422
|
+
display: none;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
.tessera-quiz-question-wrapper.active {
|
|
426
|
+
display: block;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.tessera-quiz-nav {
|
|
430
|
+
display: flex;
|
|
431
|
+
justify-content: space-between;
|
|
432
|
+
gap: var(--tessera-spacing-md);
|
|
433
|
+
margin-top: var(--tessera-spacing-lg);
|
|
434
|
+
padding-top: var(--tessera-spacing-lg);
|
|
435
|
+
border-top: 1px solid var(--tessera-border);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
.tessera-quiz-btn {
|
|
439
|
+
padding: 0.625rem 1.25rem;
|
|
440
|
+
border: none;
|
|
441
|
+
border-radius: 6px;
|
|
442
|
+
font-size: 0.9375rem;
|
|
443
|
+
font-weight: 500;
|
|
444
|
+
cursor: pointer;
|
|
445
|
+
transition: background 0.2s, opacity 0.2s;
|
|
446
|
+
min-height: 44px;
|
|
447
|
+
min-width: 44px;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
.tessera-quiz-btn:disabled {
|
|
451
|
+
opacity: 0.4;
|
|
452
|
+
cursor: not-allowed;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.tessera-quiz-btn-primary {
|
|
456
|
+
background: var(--tessera-primary);
|
|
457
|
+
color: #fff;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.tessera-quiz-btn-primary:hover:not(:disabled) {
|
|
461
|
+
background: var(--tessera-primary-dark);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
.tessera-quiz-btn-secondary {
|
|
465
|
+
background: var(--tessera-bg-secondary);
|
|
466
|
+
color: var(--tessera-text);
|
|
467
|
+
border: 1px solid var(--tessera-border);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
.tessera-quiz-btn-secondary:hover:not(:disabled) {
|
|
471
|
+
background: var(--tessera-border);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
.tessera-quiz-results {
|
|
475
|
+
text-align: center;
|
|
476
|
+
padding: var(--tessera-spacing-xl);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
.tessera-quiz-results-title {
|
|
480
|
+
font-size: 1.5rem;
|
|
481
|
+
margin-bottom: var(--tessera-spacing-lg);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.tessera-quiz-score {
|
|
485
|
+
display: flex;
|
|
486
|
+
flex-direction: column;
|
|
487
|
+
align-items: center;
|
|
488
|
+
gap: var(--tessera-spacing-sm);
|
|
489
|
+
margin-bottom: var(--tessera-spacing-lg);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.tessera-quiz-score-value {
|
|
493
|
+
font-size: 3rem;
|
|
494
|
+
font-weight: 700;
|
|
495
|
+
color: var(--tessera-text);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
.tessera-quiz-score-label {
|
|
499
|
+
font-size: 1.125rem;
|
|
500
|
+
font-weight: 600;
|
|
501
|
+
padding: 0.25rem 0.75rem;
|
|
502
|
+
border-radius: 4px;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
.tessera-quiz-score-label.passed {
|
|
506
|
+
color: color-mix(in srgb, var(--tessera-success) 55%, black);
|
|
507
|
+
background: color-mix(in srgb, var(--tessera-success) 12%, white);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
.tessera-quiz-score-label.failed {
|
|
511
|
+
color: color-mix(in srgb, var(--tessera-error) 55%, black);
|
|
512
|
+
background: color-mix(in srgb, var(--tessera-error) 12%, white);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
.tessera-quiz-results-detail {
|
|
516
|
+
color: var(--tessera-text-light);
|
|
517
|
+
margin-bottom: var(--tessera-spacing-lg);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
.tessera-quiz-results-actions {
|
|
521
|
+
display: flex;
|
|
522
|
+
gap: var(--tessera-spacing-md);
|
|
523
|
+
justify-content: center;
|
|
524
|
+
flex-wrap: wrap;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
.tessera-quiz-attempts-exhausted {
|
|
528
|
+
color: var(--tessera-text-light);
|
|
529
|
+
font-size: 0.875rem;
|
|
530
|
+
font-style: italic;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
.tessera-quiz-locked-banner {
|
|
534
|
+
display: flex;
|
|
535
|
+
align-items: center;
|
|
536
|
+
gap: var(--tessera-spacing-sm);
|
|
537
|
+
padding: var(--tessera-spacing-md);
|
|
538
|
+
margin-bottom: var(--tessera-spacing-md);
|
|
539
|
+
background: color-mix(in srgb, var(--tessera-success) 8%, transparent);
|
|
540
|
+
border: 1px solid color-mix(in srgb, var(--tessera-success) 25%, transparent);
|
|
541
|
+
border-radius: 6px;
|
|
542
|
+
color: var(--tessera-success);
|
|
543
|
+
font-size: 0.9375rem;
|
|
544
|
+
font-weight: 500;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
.tessera-quiz-locked-banner svg {
|
|
548
|
+
flex-shrink: 0;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/* Mobile */
|
|
552
|
+
@media (max-width: 640px) {
|
|
553
|
+
.tessera-quiz-progress-desktop { display: none; }
|
|
554
|
+
.tessera-quiz-progress-mobile { display: inline; }
|
|
555
|
+
|
|
556
|
+
.tessera-quiz-nav {
|
|
557
|
+
position: sticky;
|
|
558
|
+
bottom: 0;
|
|
559
|
+
background: var(--tessera-bg);
|
|
560
|
+
padding: var(--tessera-spacing-md);
|
|
561
|
+
margin: 0 calc(-1 * var(--tessera-spacing-md));
|
|
562
|
+
border-top: 1px solid var(--tessera-border);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
.tessera-quiz-btn {
|
|
566
|
+
flex: 1;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
</style>
|