tessera-learn 0.0.1 → 0.0.2

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.
@@ -1,115 +1,45 @@
1
1
  <script>
2
- import { getContext, setContext, onMount } from 'svelte';
3
- import { SvelteMap, SvelteSet } from 'svelte/reactivity';
4
- import { buildQuizInteractions } from './quiz-payload.js';
2
+ import { getContext } from 'svelte';
3
+ import { useQuiz } from '../runtime/hooks.svelte.js';
5
4
 
6
5
  let { children } = $props();
7
6
  let quizElement = $state(null);
8
7
 
9
- // Read quiz config from page context (set by App.svelte)
8
+ const handle = useQuiz({ element: () => quizElement });
9
+
10
10
  const pageCtx = getContext('tessera-page');
11
- const quizConfig = $derived(pageCtx?.quiz ?? {});
11
+ let quizConfig = $derived(pageCtx?.quiz ?? {});
12
+ let passingScore = $derived(pageCtx?.passingScore ?? 70);
13
+ let showFeedback = $derived(quizConfig.showFeedback ?? true);
14
+ let maxAttempts = $derived(quizConfig.maxAttempts ?? Infinity);
15
+ let isImmediateMode = $derived(showFeedback && quizConfig.feedbackMode === 'immediate');
12
16
 
13
- // State
14
- let questions = $state([]);
15
17
  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
18
  let reviewIndex = $state(0);
23
19
 
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'
20
+ let totalQuestions = $derived(handle.questions.length);
21
+ let correctCount = $derived(
22
+ handle.questions.reduce((sum, q) => sum + (q.correct ? 1 : 0), 0)
39
23
  );
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
24
+ let passed = $derived(handle.score >= passingScore);
25
+
26
+ function isAnswered(i) {
27
+ return handle.getAnswer(i) !== undefined || handle.isLockedCorrect(i);
28
+ }
29
+
30
+ function needsReveal(i) {
31
+ return (
32
+ isImmediateMode &&
33
+ isAnswered(i) &&
34
+ !handle.isLockedCorrect(i) &&
35
+ !handle.feedbackVisible(i)
36
+ );
37
+ }
38
+
106
39
  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);
40
+ // Immediate-mode: first click reveals feedback, second advances.
41
+ if (needsReveal(currentQuestionIndex)) {
42
+ handle.revealFeedback(currentQuestionIndex);
113
43
  return;
114
44
  }
115
45
  if (currentQuestionIndex < totalQuestions - 1) {
@@ -118,143 +48,56 @@
118
48
  }
119
49
 
120
50
  function goPrevQuestion() {
121
- if (currentQuestionIndex > 0) {
122
- currentQuestionIndex--;
123
- }
51
+ if (currentQuestionIndex > 0) currentQuestionIndex--;
124
52
  }
125
53
 
126
54
  function goNextReview() {
127
- if (reviewIndex < totalQuestions - 1) {
128
- reviewIndex++;
129
- }
55
+ if (reviewIndex < totalQuestions - 1) reviewIndex++;
130
56
  }
131
57
 
132
58
  function goPrevReview() {
133
- if (reviewIndex > 0) {
134
- reviewIndex--;
135
- }
59
+ if (reviewIndex > 0) reviewIndex--;
136
60
  }
137
61
 
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
62
  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;
63
+ if (!handle.canSubmit) return;
64
+ handle.submit();
191
65
  }
192
66
 
193
- function exitReview() {
194
- reviewing = false;
67
+ function handleStartReview() {
68
+ reviewIndex = 0;
69
+ handle.startReview();
195
70
  }
196
71
 
197
- // Retry
198
72
  function handleRetry() {
199
- submitted = false;
200
- reviewing = false;
201
- score = 0;
202
- correctCount = 0;
73
+ handle.retry();
203
74
  currentQuestionIndex = 0;
204
75
  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
76
  }
234
-
235
77
  </script>
236
78
 
237
79
  <div class="tessera-quiz" bind:this={quizElement} role="region" aria-label="Quiz">
238
- {#if !submitted}
80
+ {#if handle.state === 'answering'}
239
81
  <!-- Question phase -->
240
82
  <div class="tessera-quiz-progress" aria-live="polite">
241
83
  <span class="tessera-quiz-progress-text">
242
84
  <span class="tessera-quiz-progress-desktop">Question {currentQuestionIndex + 1} of {totalQuestions}</span>
243
85
  <span class="tessera-quiz-progress-mobile">{currentQuestionIndex + 1}/{totalQuestions}</span>
244
86
  </span>
245
- <div class="tessera-quiz-progress-bar">
87
+ <div class="tessera-progress-track">
246
88
  <div
247
- class="tessera-quiz-progress-fill"
89
+ class="tessera-progress-fill"
248
90
  style="width: {totalQuestions > 0 ? ((currentQuestionIndex + 1) / totalQuestions) * 100 : 0}%"
249
91
  ></div>
250
92
  </div>
251
93
  </div>
252
94
 
253
95
  <div class="tessera-quiz-questions">
254
- {#each questions as q, i (q.id)}
96
+ {#each handle.questions as q, i (q.id)}
97
+ {@const render = handle.getRender(i)}
255
98
  <div class="tessera-quiz-question-wrapper" class:active={i === currentQuestionIndex} aria-hidden={i !== currentQuestionIndex}>
256
- {#if q.render}
257
- {@render q.render()}
99
+ {#if render}
100
+ {@render render()}
258
101
  {/if}
259
102
  </div>
260
103
  {/each}
@@ -270,23 +113,23 @@
270
113
  </button>
271
114
  {#if currentQuestionIndex < totalQuestions - 1}
272
115
  <button
273
- class="tessera-quiz-btn tessera-quiz-btn-primary"
274
- disabled={!answers.has(currentQuestionIndex) && !lockedCorrect.has(currentQuestionIndex)}
116
+ class="tessera-quiz-btn tessera-btn-primary"
117
+ disabled={!isAnswered(currentQuestionIndex)}
275
118
  onclick={goNextQuestion}
276
119
  >
277
- {feedbackShown.has(currentQuestionIndex) && feedbackMode === 'immediate' ? 'Continue' : 'Next'}
120
+ {handle.feedbackVisible(currentQuestionIndex) && isImmediateMode ? 'Continue' : 'Next'}
278
121
  </button>
279
- {:else if needsImmediateFeedback()}
122
+ {:else if needsReveal(currentQuestionIndex)}
280
123
  <button
281
- class="tessera-quiz-btn tessera-quiz-btn-primary"
282
- onclick={showImmediateFeedback}
124
+ class="tessera-quiz-btn tessera-btn-primary"
125
+ onclick={() => handle.revealFeedback(currentQuestionIndex)}
283
126
  >
284
127
  Check Answer
285
128
  </button>
286
129
  {:else}
287
130
  <button
288
- class="tessera-quiz-btn tessera-quiz-btn-primary tessera-quiz-btn-submit"
289
- disabled={!allAnswered}
131
+ class="tessera-quiz-btn tessera-btn-primary tessera-quiz-btn-submit"
132
+ disabled={!handle.canSubmit}
290
133
  onclick={handleSubmit}
291
134
  >
292
135
  Submit
@@ -294,7 +137,7 @@
294
137
  {/if}
295
138
  </div>
296
139
 
297
- {:else if reviewing}
140
+ {:else if handle.state === 'reviewing'}
298
141
  <!-- Review phase -->
299
142
  <div class="tessera-quiz-progress" aria-live="polite">
300
143
  <span class="tessera-quiz-progress-text">
@@ -304,10 +147,11 @@
304
147
  </div>
305
148
 
306
149
  <div class="tessera-quiz-questions">
307
- {#each questions as q, i (q.id)}
150
+ {#each handle.questions as q, i (q.id)}
151
+ {@const render = handle.getRender(i)}
308
152
  <div class="tessera-quiz-question-wrapper" class:active={i === reviewIndex} aria-hidden={i !== reviewIndex}>
309
- {#if q.render}
310
- {@render q.render()}
153
+ {#if render}
154
+ {@render render()}
311
155
  {/if}
312
156
  </div>
313
157
  {/each}
@@ -323,15 +167,15 @@
323
167
  </button>
324
168
  {#if reviewIndex < totalQuestions - 1}
325
169
  <button
326
- class="tessera-quiz-btn tessera-quiz-btn-primary"
170
+ class="tessera-quiz-btn tessera-btn-primary"
327
171
  onclick={goNextReview}
328
172
  >
329
173
  Next
330
174
  </button>
331
175
  {:else}
332
176
  <button
333
- class="tessera-quiz-btn tessera-quiz-btn-primary"
334
- onclick={exitReview}
177
+ class="tessera-quiz-btn tessera-btn-primary"
178
+ onclick={() => handle.exitReview()}
335
179
  >
336
180
  Done
337
181
  </button>
@@ -343,7 +187,7 @@
343
187
  <div class="tessera-quiz-results" role="status" aria-live="polite">
344
188
  <h2 class="tessera-quiz-results-title">Quiz Results</h2>
345
189
  <div class="tessera-quiz-score">
346
- <span class="tessera-quiz-score-value">{score}%</span>
190
+ <span class="tessera-quiz-score-value">{handle.score}%</span>
347
191
  <span class="tessera-quiz-score-label" class:passed class:failed={!passed}>
348
192
  {passed ? 'Passed' : 'Not Passed'}
349
193
  </span>
@@ -356,21 +200,21 @@
356
200
  {#if showFeedback}
357
201
  <button
358
202
  class="tessera-quiz-btn tessera-quiz-btn-secondary"
359
- onclick={startReview}
203
+ onclick={handleStartReview}
360
204
  >
361
205
  Review Answers
362
206
  </button>
363
207
  {/if}
364
- {#if canRetry}
208
+ {#if handle.canRetry}
365
209
  <button
366
- class="tessera-quiz-btn tessera-quiz-btn-primary"
210
+ class="tessera-quiz-btn tessera-btn-primary"
367
211
  onclick={handleRetry}
368
212
  >
369
213
  Retry Quiz
370
214
  </button>
371
215
  {/if}
372
- {#if maxAttempts !== Infinity && attemptCount >= maxAttempts}
373
- <p class="tessera-quiz-attempts-exhausted">All attempts used ({attemptCount}/{maxAttempts})</p>
216
+ {#if maxAttempts !== Infinity && handle.attemptCount >= maxAttempts}
217
+ <p class="tessera-quiz-attempts-exhausted">All attempts used ({handle.attemptCount}/{maxAttempts})</p>
374
218
  {/if}
375
219
  </div>
376
220
  </div>
@@ -403,19 +247,8 @@
403
247
  display: none;
404
248
  }
405
249
 
406
- .tessera-quiz-progress-bar {
250
+ .tessera-quiz-progress :global(.tessera-progress-track) {
407
251
  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
252
  }
420
253
 
421
254
  .tessera-quiz-question-wrapper {
@@ -452,15 +285,6 @@
452
285
  cursor: not-allowed;
453
286
  }
454
287
 
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
288
  .tessera-quiz-btn-secondary {
465
289
  background: var(--tessera-bg-secondary);
466
290
  color: var(--tessera-text);
@@ -530,24 +354,6 @@
530
354
  font-style: italic;
531
355
  }
532
356
 
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
357
  /* Mobile */
552
358
  @media (max-width: 640px) {
553
359
  .tessera-quiz-progress-desktop { display: none; }
@@ -0,0 +1,13 @@
1
+ <script>
2
+ let { kind, size = 16 } = $props();
3
+ </script>
4
+
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"/>
8
+ </svg>
9
+ {: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"/>
12
+ </svg>
13
+ {/if}
@@ -0,0 +1,25 @@
1
+ <script>
2
+ let { onclick, label = 'Try again' } = $props();
3
+ </script>
4
+
5
+ <button class="tessera-standalone-retry" {onclick}>{label}</button>
6
+
7
+ <style>
8
+ .tessera-standalone-retry {
9
+ display: inline-block;
10
+ margin-top: var(--tessera-spacing-md);
11
+ padding: 0;
12
+ font-size: 0.875rem;
13
+ font-weight: 600;
14
+ color: var(--tessera-primary);
15
+ background: none;
16
+ border: none;
17
+ cursor: pointer;
18
+ text-decoration: underline;
19
+ text-underline-offset: 2px;
20
+ }
21
+
22
+ .tessera-standalone-retry:hover {
23
+ color: var(--tessera-primary-dark);
24
+ }
25
+ </style>