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,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>