tessera-learn 0.0.8 → 0.0.9

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,5 +1,5 @@
1
1
  <script>
2
- import { getContext, onMount } from 'svelte';
2
+ import { onMount } from 'svelte';
3
3
  import { useQuestion } from '../runtime/hooks.svelte.js';
4
4
  import { slugFromQuestion } from './util.js';
5
5
  import LockedBanner from './LockedBanner.svelte';
@@ -17,9 +17,6 @@
17
17
  weight = 1,
18
18
  } = $props();
19
19
 
20
- const quiz = getContext('tessera-quiz');
21
- const standalone = !quiz;
22
-
23
20
  let inputValue = $state('');
24
21
 
25
22
  const componentId = $props.id();
@@ -35,7 +32,7 @@
35
32
  });
36
33
  }
37
34
 
38
- const handle = useQuestion({
35
+ const q = useQuestion({
39
36
  get id() { return id ?? `fitb-${slugFromQuestion(question)}`; },
40
37
  get weight() { return weight; },
41
38
  get maxRetries() { return maxRetries; },
@@ -48,36 +45,33 @@
48
45
  reset: () => { inputValue = ''; },
49
46
  });
50
47
 
51
- const myIndex = $derived(handle.quizIndex ?? -1);
48
+ // `q.mode` is fixed for the lifetime of the widget; capture once.
49
+ const inQuiz = q.mode === 'quiz';
52
50
 
53
51
  onMount(() => {
54
- if (!standalone) quiz.setRender(myIndex, renderQuestion);
52
+ if (inQuiz) q.setRender(renderQuestion);
55
53
  });
56
54
 
57
- let isLocked = $derived(standalone ? false : quiz.isLockedCorrect(myIndex));
58
- let quizLocked = $derived(standalone ? handle.submitted : quiz.isAnswerLocked(myIndex));
59
-
60
55
  function handleInput(e) {
61
- if (standalone) {
62
- if (handle.submitted) return;
63
- inputValue = e.target.value;
64
- } else {
65
- if (quizLocked) return;
66
- inputValue = e.target.value;
67
- quiz.setAnswer(myIndex, inputValue);
68
- }
56
+ if (q.locked) return;
57
+ inputValue = e.target.value;
58
+ if (inQuiz) q.setAnswer(inputValue);
59
+ }
60
+
61
+ function handleBlur() {
62
+ if (!inQuiz || q.locked) return;
63
+ if (inputValue.trim()) q.commit();
69
64
  }
70
65
 
71
66
  function handleKeydown(e) {
72
- if (!standalone || handle.submitted) return;
67
+ if (inQuiz || q.submitted) return;
73
68
  if (e.key === 'Enter' && inputValue.trim()) {
74
- handle.submit();
69
+ q.submit();
75
70
  }
76
71
  }
77
-
78
72
  </script>
79
73
 
80
- {#if standalone}
74
+ {#if !inQuiz}
81
75
  <div class="tessera-fitb">
82
76
  <label class="tessera-fitb-question" for={inputId}>{question}</label>
83
77
 
@@ -86,27 +80,27 @@
86
80
  type="text"
87
81
  id={inputId}
88
82
  class="tessera-fitb-input"
89
- class:correct={handle.submitted && checkAnswer(inputValue)}
90
- class:incorrect={handle.submitted && !checkAnswer(inputValue)}
83
+ class:correct={q.submitted && checkAnswer(inputValue)}
84
+ class:incorrect={q.submitted && !checkAnswer(inputValue)}
91
85
  value={inputValue}
92
86
  oninput={handleInput}
93
87
  onkeydown={handleKeydown}
94
- disabled={handle.submitted}
88
+ disabled={q.submitted}
95
89
  placeholder="Type your answer..."
96
90
  autocomplete="off"
97
91
  />
98
- {#if !handle.submitted}
92
+ {#if !q.submitted}
99
93
  <button
100
94
  class="tessera-btn-primary tessera-fitb-check-btn"
101
95
  disabled={!inputValue.trim()}
102
- onclick={() => { handle.submit(); }}
96
+ onclick={() => { q.submit(); }}
103
97
  >
104
98
  Check
105
99
  </button>
106
100
  {/if}
107
101
  </div>
108
102
 
109
- {#if handle.submitted}
103
+ {#if q.submitted}
110
104
  {@const isCorrect = checkAnswer(inputValue)}
111
105
  <div class="tessera-fitb-review">
112
106
  {#if isCorrect}
@@ -129,8 +123,8 @@
129
123
  <p class="tessera-fitb-feedback incorrect">{incorrectFeedback}</p>
130
124
  {/if}
131
125
  {/if}
132
- {#if handle.canRetry}
133
- <RetryButton onclick={() => handle.retry()} />
126
+ {#if q.canRetry}
127
+ <RetryButton onclick={() => q.retry()} />
134
128
  {/if}
135
129
  </div>
136
130
  {/if}
@@ -139,7 +133,7 @@
139
133
 
140
134
  {#snippet renderQuestion()}
141
135
  <div class="tessera-fitb">
142
- {#if isLocked}
136
+ {#if q.isLockedCorrect}
143
137
  <LockedBanner />
144
138
  {/if}
145
139
  <label class="tessera-fitb-question" for={inputId}>{question}</label>
@@ -149,18 +143,19 @@
149
143
  type="text"
150
144
  id={inputId}
151
145
  class="tessera-fitb-input"
152
- class:correct={quiz.feedbackVisible(myIndex) && checkAnswer(quiz.getAnswer(myIndex))}
153
- class:incorrect={quiz.feedbackVisible(myIndex) && !checkAnswer(quiz.getAnswer(myIndex))}
154
- value={quizLocked ? (quiz.getAnswer(myIndex) ?? '') : inputValue}
146
+ class:correct={q.feedbackVisible && checkAnswer(q.answer)}
147
+ class:incorrect={q.feedbackVisible && !checkAnswer(q.answer)}
148
+ value={q.locked ? (q.answer ?? '') : inputValue}
155
149
  oninput={handleInput}
156
- disabled={quizLocked}
150
+ onblur={handleBlur}
151
+ disabled={q.locked}
157
152
  placeholder="Type your answer..."
158
153
  autocomplete="off"
159
154
  />
160
155
  </div>
161
156
 
162
- {#if quiz.feedbackVisible(myIndex)}
163
- {@const userAnswer = quiz.getAnswer(myIndex)}
157
+ {#if q.feedbackVisible}
158
+ {@const userAnswer = q.answer}
164
159
  {@const isCorrect = checkAnswer(userAnswer)}
165
160
  <div class="tessera-fitb-review">
166
161
  {#if isCorrect}
@@ -1,5 +1,5 @@
1
1
  <script>
2
- import { getContext, onMount } from 'svelte';
2
+ import { onMount } from 'svelte';
3
3
  import { SvelteMap } from 'svelte/reactivity';
4
4
  import { useQuestion } from '../runtime/hooks.svelte.js';
5
5
  import { slugFromQuestion, shuffle } from './util.js';
@@ -17,9 +17,6 @@
17
17
  weight = 1,
18
18
  } = $props();
19
19
 
20
- const quiz = getContext('tessera-quiz');
21
- const standalone = !quiz;
22
-
23
20
  let shuffledRight = $state([]);
24
21
  let matches = $state(new SvelteMap());
25
22
  // Reverse index (right.originalIndex → left index) for O(1) right-column lookups.
@@ -27,7 +24,6 @@
27
24
  let selectedLeft = $state(null);
28
25
  let selectedRight = $state(null);
29
26
 
30
-
31
27
  const pairColors = [
32
28
  '#2563eb', '#9333ea', '#0891b2', '#c2410c', '#4f46e5',
33
29
  '#0d9488', '#b91c1c', '#7c3aed', '#0369a1', '#a16207',
@@ -37,15 +33,6 @@
37
33
  shuffledRight = shuffle(pairs.map((p, i) => ({ text: p.right, originalIndex: i })));
38
34
  }
39
35
 
40
- if (standalone) {
41
- initShuffle();
42
- } else {
43
- onMount(() => {
44
- initShuffle();
45
- quiz.setRender(myIndex, renderQuestion);
46
- });
47
- }
48
-
49
36
  function checkAnswer(answer) {
50
37
  if (!answer || !(answer instanceof Map)) return false;
51
38
  if (answer.size !== pairs.length) return false;
@@ -63,7 +50,7 @@
63
50
  initShuffle();
64
51
  }
65
52
 
66
- const handle = useQuestion({
53
+ const q = useQuestion({
67
54
  get id() { return id ?? `matching-${slugFromQuestion(question)}`; },
68
55
  get weight() { return weight; },
69
56
  get maxRetries() { return maxRetries; },
@@ -75,47 +62,37 @@
75
62
  reset: resetState,
76
63
  });
77
64
 
78
- const myIndex = $derived(handle.quizIndex ?? -1);
65
+ // `q.mode` is fixed for the lifetime of the widget; capture it once so
66
+ // setup-time branches don't trip Svelte's "state_referenced_locally" warning.
67
+ const inQuiz = q.mode === 'quiz';
79
68
 
80
- let isLocked = $derived(standalone ? false : quiz.isLockedCorrect(myIndex));
81
- let quizLocked = $derived(standalone ? handle.submitted : quiz.isAnswerLocked(myIndex));
69
+ if (!inQuiz) {
70
+ initShuffle();
71
+ } else {
72
+ onMount(() => {
73
+ initShuffle();
74
+ q.setRender(renderQuestion);
75
+ });
76
+ }
82
77
 
83
78
  function handleLeftClick(leftIndex) {
84
- if (standalone) {
85
- if (handle.submitted) return;
86
- } else {
87
- if (quizLocked) return;
88
- }
89
-
79
+ if (q.locked) return;
90
80
  if (selectedLeft === leftIndex) {
91
81
  selectedLeft = null;
92
82
  return;
93
83
  }
94
-
95
84
  selectedLeft = leftIndex;
96
-
97
- if (selectedRight !== null) {
98
- createMatch(leftIndex, selectedRight);
99
- }
85
+ if (selectedRight !== null) createMatch(leftIndex, selectedRight);
100
86
  }
101
87
 
102
88
  function handleRightClick(rightOriginalIndex) {
103
- if (standalone) {
104
- if (handle.submitted) return;
105
- } else {
106
- if (quizLocked) return;
107
- }
108
-
89
+ if (q.locked) return;
109
90
  if (selectedRight === rightOriginalIndex) {
110
91
  selectedRight = null;
111
92
  return;
112
93
  }
113
-
114
94
  selectedRight = rightOriginalIndex;
115
-
116
- if (selectedLeft !== null) {
117
- createMatch(selectedLeft, selectedRight);
118
- }
95
+ if (selectedLeft !== null) createMatch(selectedLeft, selectedRight);
119
96
  }
120
97
 
121
98
  function createMatch(leftIndex, rightOriginalIndex) {
@@ -134,27 +111,20 @@
134
111
  selectedLeft = null;
135
112
  selectedRight = null;
136
113
 
137
- if (standalone) {
138
- if (matches.size === pairs.length && !handle.submitted) {
139
- handle.submit();
140
- }
141
- } else {
142
- quiz.setAnswer(myIndex, new Map(matches));
114
+ if (inQuiz) {
115
+ q.setAnswer(new Map(matches));
116
+ if (matches.size === pairs.length) q.commit();
117
+ } else if (matches.size === pairs.length && !q.submitted) {
118
+ q.submit();
143
119
  }
144
120
  }
145
121
 
146
122
  function removeMatch(leftIndex) {
147
- if (standalone) {
148
- if (handle.submitted) return;
149
- } else {
150
- if (quizLocked) return;
151
- }
123
+ if (q.locked) return;
152
124
  const right = matches.get(leftIndex);
153
125
  matches.delete(leftIndex);
154
126
  if (right !== undefined) rightToLeft.delete(right);
155
- if (!standalone) {
156
- quiz.setAnswer(myIndex, new Map(matches));
157
- }
127
+ if (inQuiz) q.setAnswer(new Map(matches));
158
128
  }
159
129
 
160
130
  function getMatchColor(leftIndex) {
@@ -178,9 +148,6 @@
178
148
  function isMatchCorrect(leftIndex) {
179
149
  return matches.get(leftIndex) === leftIndex;
180
150
  }
181
-
182
- let showFeedback = $derived(standalone ? handle.submitted : quiz.feedbackVisible(myIndex));
183
- let isDisabled = $derived(standalone ? handle.submitted : quizLocked);
184
151
  </script>
185
152
 
186
153
  {#snippet matchingContent()}
@@ -194,8 +161,8 @@
194
161
  {@const color = getMatchColor(i)}
195
162
  {@const isSelected = selectedLeft === i}
196
163
  {@const matched = matches.has(i)}
197
- {@const correctMatch = showFeedback && matched && isMatchCorrect(i)}
198
- {@const wrongMatch = showFeedback && matched && !isMatchCorrect(i)}
164
+ {@const correctMatch = q.feedbackVisible && matched && isMatchCorrect(i)}
165
+ {@const wrongMatch = q.feedbackVisible && matched && !isMatchCorrect(i)}
199
166
  <button
200
167
  class="tessera-matching-item left"
201
168
  class:selected={isSelected}
@@ -203,8 +170,8 @@
203
170
  class:correct={correctMatch}
204
171
  class:incorrect={wrongMatch}
205
172
  style={color ? `border-color: ${color}; --match-color: ${color}` : ''}
206
- onclick={() => matched && !isDisabled ? removeMatch(i) : handleLeftClick(i)}
207
- disabled={isDisabled}
173
+ onclick={() => matched && !q.locked ? removeMatch(i) : handleLeftClick(i)}
174
+ disabled={q.locked}
208
175
  aria-label="{pair.left}{matched ? ' (matched, activate to unmatch)' : ''}"
209
176
  >
210
177
  {#if matched}
@@ -213,7 +180,7 @@
213
180
  </span>
214
181
  {/if}
215
182
  <span>{pair.left}</span>
216
- {#if matched && !isDisabled}
183
+ {#if matched && !q.locked}
217
184
  <span class="tessera-matching-unmatch" aria-hidden="true">×</span>
218
185
  {/if}
219
186
  </button>
@@ -233,7 +200,7 @@
233
200
  class:matched
234
201
  style={color ? `border-color: ${color}; --match-color: ${color}` : ''}
235
202
  onclick={() => handleRightClick(item.originalIndex)}
236
- disabled={isDisabled}
203
+ disabled={q.locked}
237
204
  aria-label="{item.text}{matched ? ' (matched)' : ''}"
238
205
  >
239
206
  {#if matched}
@@ -248,7 +215,7 @@
248
215
  </div>
249
216
  </div>
250
217
 
251
- {#if showFeedback}
218
+ {#if q.feedbackVisible}
252
219
  {@const isCorrect = checkAnswer(matches)}
253
220
  <div class="tessera-matching-review">
254
221
  {#if isCorrect}
@@ -274,14 +241,14 @@
274
241
  <p class="tessera-matching-feedback incorrect">{incorrectFeedback}</p>
275
242
  {/if}
276
243
  {/if}
277
- {#if standalone && handle.canRetry}
278
- <RetryButton onclick={() => handle.retry()} />
244
+ {#if !inQuiz && q.canRetry}
245
+ <RetryButton onclick={() => q.retry()} />
279
246
  {/if}
280
247
  </div>
281
248
  {/if}
282
249
  {/snippet}
283
250
 
284
- {#if standalone}
251
+ {#if !inQuiz}
285
252
  <div class="tessera-matching" aria-label={question}>
286
253
  {@render matchingContent()}
287
254
  </div>
@@ -289,7 +256,7 @@
289
256
 
290
257
  {#snippet renderQuestion()}
291
258
  <div class="tessera-matching" aria-label={question}>
292
- {#if isLocked}
259
+ {#if q.isLockedCorrect}
293
260
  <LockedBanner />
294
261
  {/if}
295
262
  {@render matchingContent()}
@@ -1,5 +1,5 @@
1
1
  <script>
2
- import { getContext, onMount } from 'svelte';
2
+ import { onMount } from 'svelte';
3
3
  import { useQuestion } from '../runtime/hooks.svelte.js';
4
4
  import { slugFromQuestion } from './util.js';
5
5
  import LockedBanner from './LockedBanner.svelte';
@@ -17,15 +17,12 @@
17
17
  weight = 1,
18
18
  } = $props();
19
19
 
20
- const quiz = getContext('tessera-quiz');
21
- const standalone = !quiz;
22
-
23
20
  let selectedOption = $state(null);
24
21
 
25
22
  const componentId = $props.id();
26
23
  const groupId = `mc-${componentId}`;
27
24
 
28
- const handle = useQuestion({
25
+ const q = useQuestion({
29
26
  get id() { return id ?? `mc-${slugFromQuestion(question)}`; },
30
27
  get weight() { return weight; },
31
28
  get maxRetries() { return maxRetries; },
@@ -37,21 +34,21 @@
37
34
  reset: () => { selectedOption = null; },
38
35
  });
39
36
 
40
- const myIndex = $derived(handle.quizIndex ?? -1);
37
+ // `q.mode` is fixed for the lifetime of the widget; capture once.
38
+ const inQuiz = q.mode === 'quiz';
41
39
 
42
40
  onMount(() => {
43
- if (!standalone) quiz.setRender(myIndex, renderQuestion);
41
+ if (inQuiz) q.setRender(renderQuestion);
44
42
  });
45
43
 
46
44
  function handleSelect(optIndex) {
47
- if (standalone) {
48
- if (handle.submitted) return;
49
- selectedOption = optIndex;
50
- handle.submit();
45
+ if (q.locked) return;
46
+ selectedOption = optIndex;
47
+ if (inQuiz) {
48
+ q.setAnswer(optIndex);
49
+ q.commit();
51
50
  } else {
52
- if (quizLocked) return;
53
- selectedOption = optIndex;
54
- quiz.setAnswer(myIndex, optIndex);
51
+ q.submit();
55
52
  }
56
53
  }
57
54
 
@@ -59,26 +56,16 @@
59
56
  return optIndex === correct;
60
57
  }
61
58
 
62
- // Quiz-mode helpers
63
59
  function getOptionClass(optIndex) {
64
- if (standalone) {
65
- if (!handle.submitted) return '';
66
- if (isCorrectOption(optIndex)) return 'correct';
67
- if (optIndex === selectedOption && !isCorrectOption(optIndex)) return 'incorrect';
68
- return '';
69
- }
70
- if (!quiz.feedbackVisible(myIndex)) return '';
71
- const answer = quiz.getAnswer(myIndex);
60
+ if (!q.feedbackVisible) return '';
61
+ const answer = inQuiz ? q.answer : selectedOption;
72
62
  if (isCorrectOption(optIndex)) return 'correct';
73
63
  if (optIndex === answer && !isCorrectOption(optIndex)) return 'incorrect';
74
64
  return '';
75
65
  }
76
-
77
- let isLocked = $derived(standalone ? false : quiz.isLockedCorrect(myIndex));
78
- let quizLocked = $derived(standalone ? handle.submitted : quiz.isAnswerLocked(myIndex));
79
66
  </script>
80
67
 
81
- {#if standalone}
68
+ {#if !inQuiz}
82
69
  <div class="tessera-mc" role="radiogroup" aria-labelledby="{groupId}-label">
83
70
  <p class="tessera-mc-question" id="{groupId}-label">{question}</p>
84
71
 
@@ -98,13 +85,13 @@
98
85
  name={groupId}
99
86
  value={i}
100
87
  checked={isSelected}
101
- disabled={handle.submitted}
88
+ disabled={q.submitted}
102
89
  onchange={() => handleSelect(i)}
103
90
  />
104
91
  <span class="tessera-mc-radio-custom"></span>
105
92
  <span class="tessera-mc-option-text">{option}</span>
106
93
 
107
- {#if handle.submitted}
94
+ {#if q.submitted}
108
95
  {#if stateClass === 'correct' && (correctFeedback || optionFeedback[i])}
109
96
  <span class="tessera-mc-feedback correct">{optionFeedback[i] || correctFeedback}</span>
110
97
  {:else if stateClass === 'incorrect' && (incorrectFeedback || optionFeedback[i])}
@@ -117,14 +104,14 @@
117
104
  {/each}
118
105
  </div>
119
106
 
120
- {#if handle.submitted}
107
+ {#if q.submitted}
121
108
  {#if selectedOption === correct && correctFeedback && !optionFeedback[selectedOption]}
122
109
  <div class="tessera-mc-overall-feedback correct">{correctFeedback}</div>
123
110
  {:else if selectedOption !== correct && incorrectFeedback && !optionFeedback[selectedOption]}
124
111
  <div class="tessera-mc-overall-feedback incorrect">{incorrectFeedback}</div>
125
112
  {/if}
126
- {#if handle.canRetry}
127
- <RetryButton onclick={() => handle.retry()} />
113
+ {#if q.canRetry}
114
+ <RetryButton onclick={() => q.retry()} />
128
115
  {/if}
129
116
  {/if}
130
117
  </div>
@@ -132,7 +119,7 @@
132
119
 
133
120
  {#snippet renderQuestion()}
134
121
  <div class="tessera-mc" role="radiogroup" aria-labelledby="{groupId}-label">
135
- {#if isLocked}
122
+ {#if q.isLockedCorrect}
136
123
  <LockedBanner />
137
124
  {/if}
138
125
  <p class="tessera-mc-question" id="{groupId}-label">{question}</p>
@@ -140,7 +127,7 @@
140
127
  <div class="tessera-mc-options">
141
128
  {#each options as option, i}
142
129
  {@const optionId = `${groupId}-opt-${i}`}
143
- {@const isSelected = (quizLocked ? quiz.getAnswer(myIndex) : selectedOption) === i}
130
+ {@const isSelected = (q.locked ? q.answer : selectedOption) === i}
144
131
  {@const stateClass = getOptionClass(i)}
145
132
  <label
146
133
  class="tessera-mc-option {stateClass}"
@@ -153,13 +140,13 @@
153
140
  name={groupId}
154
141
  value={i}
155
142
  checked={isSelected}
156
- disabled={quizLocked}
143
+ disabled={q.locked}
157
144
  onchange={() => handleSelect(i)}
158
145
  />
159
146
  <span class="tessera-mc-radio-custom"></span>
160
147
  <span class="tessera-mc-option-text">{option}</span>
161
148
 
162
- {#if quiz.feedbackVisible(myIndex)}
149
+ {#if q.feedbackVisible}
163
150
  {#if stateClass === 'correct' && (correctFeedback || optionFeedback[i])}
164
151
  <span class="tessera-mc-feedback correct">{optionFeedback[i] || correctFeedback}</span>
165
152
  {:else if stateClass === 'incorrect' && (incorrectFeedback || optionFeedback[i])}
@@ -172,8 +159,8 @@
172
159
  {/each}
173
160
  </div>
174
161
 
175
- {#if quiz.feedbackVisible(myIndex)}
176
- {@const answer = quiz.getAnswer(myIndex)}
162
+ {#if q.feedbackVisible}
163
+ {@const answer = q.answer}
177
164
  {#if answer === correct && correctFeedback && !optionFeedback[answer]}
178
165
  <div class="tessera-mc-overall-feedback correct">{correctFeedback}</div>
179
166
  {:else if answer !== correct && incorrectFeedback && !optionFeedback[answer]}
@@ -9,37 +9,35 @@
9
9
 
10
10
  const pageCtx = getContext('tessera-page');
11
11
  let quizConfig = $derived(pageCtx?.quiz ?? {});
12
- let passingScore = $derived(pageCtx?.passingScore ?? 70);
13
- let showFeedback = $derived(quizConfig.showFeedback ?? true);
12
+ let feedbackDisabled = $derived(quizConfig.feedbackMode === 'never');
14
13
  let maxAttempts = $derived(quizConfig.maxAttempts ?? Infinity);
15
- let isImmediateMode = $derived(showFeedback && quizConfig.feedbackMode === 'immediate');
14
+ let isImmediateMode = $derived(!feedbackDisabled && quizConfig.feedbackMode === 'immediate');
16
15
 
17
16
  let currentQuestionIndex = $state(0);
18
17
  let reviewIndex = $state(0);
19
18
 
20
19
  let totalQuestions = $derived(handle.questions.length);
20
+ let currentQuestion = $derived(handle.questions[currentQuestionIndex]);
21
+ let reviewQuestion = $derived(handle.questions[reviewIndex]);
21
22
  let correctCount = $derived(
22
23
  handle.questions.reduce((sum, q) => sum + (q.correct ? 1 : 0), 0)
23
24
  );
24
- let passed = $derived(handle.score >= passingScore);
25
+ let passed = $derived(handle.score >= handle.passingScore);
25
26
 
26
- function isAnswered(i) {
27
- return handle.getAnswer(i) !== undefined || handle.isLockedCorrect(i);
27
+ function isAnswered(q) {
28
+ if (!q) return false;
29
+ return q.answer !== undefined || q.isLockedCorrect;
28
30
  }
29
31
 
30
- function needsReveal(i) {
31
- return (
32
- isImmediateMode &&
33
- isAnswered(i) &&
34
- !handle.isLockedCorrect(i) &&
35
- !handle.feedbackVisible(i)
36
- );
32
+ function needsReveal(q) {
33
+ if (!q) return false;
34
+ return isImmediateMode && isAnswered(q) && !q.isLockedCorrect && !q.feedbackVisible;
37
35
  }
38
36
 
39
37
  function goNextQuestion() {
40
38
  // Immediate-mode: first click reveals feedback, second advances.
41
- if (needsReveal(currentQuestionIndex)) {
42
- handle.revealFeedback(currentQuestionIndex);
39
+ if (needsReveal(currentQuestion)) {
40
+ handle.revealFeedback(currentQuestion);
43
41
  return;
44
42
  }
45
43
  if (currentQuestionIndex < totalQuestions - 1) {
@@ -94,10 +92,9 @@
94
92
 
95
93
  <div class="tessera-quiz-questions">
96
94
  {#each handle.questions as q, i (q.id)}
97
- {@const render = handle.getRender(i)}
98
95
  <div class="tessera-quiz-question-wrapper" class:active={i === currentQuestionIndex} aria-hidden={i !== currentQuestionIndex}>
99
- {#if render}
100
- {@render render()}
96
+ {#if q.render}
97
+ {@render q.render()}
101
98
  {/if}
102
99
  </div>
103
100
  {/each}
@@ -114,15 +111,15 @@
114
111
  {#if currentQuestionIndex < totalQuestions - 1}
115
112
  <button
116
113
  class="tessera-quiz-btn tessera-btn-primary"
117
- disabled={!isAnswered(currentQuestionIndex)}
114
+ disabled={!isAnswered(currentQuestion)}
118
115
  onclick={goNextQuestion}
119
116
  >
120
- {handle.feedbackVisible(currentQuestionIndex) && isImmediateMode ? 'Continue' : 'Next'}
117
+ {currentQuestion?.feedbackVisible && isImmediateMode ? 'Continue' : 'Next'}
121
118
  </button>
122
- {:else if needsReveal(currentQuestionIndex)}
119
+ {:else if needsReveal(currentQuestion)}
123
120
  <button
124
121
  class="tessera-quiz-btn tessera-btn-primary"
125
- onclick={() => handle.revealFeedback(currentQuestionIndex)}
122
+ onclick={() => handle.revealFeedback(currentQuestion)}
126
123
  >
127
124
  Check Answer
128
125
  </button>
@@ -148,10 +145,9 @@
148
145
 
149
146
  <div class="tessera-quiz-questions">
150
147
  {#each handle.questions as q, i (q.id)}
151
- {@const render = handle.getRender(i)}
152
148
  <div class="tessera-quiz-question-wrapper" class:active={i === reviewIndex} aria-hidden={i !== reviewIndex}>
153
- {#if render}
154
- {@render render()}
149
+ {#if q.render}
150
+ {@render q.render()}
155
151
  {/if}
156
152
  </div>
157
153
  {/each}
@@ -197,7 +193,7 @@
197
193
  </p>
198
194
 
199
195
  <div class="tessera-quiz-results-actions">
200
- {#if showFeedback}
196
+ {#if !feedbackDisabled}
201
197
  <button
202
198
  class="tessera-quiz-btn tessera-quiz-btn-secondary"
203
199
  onclick={handleStartReview}