tessera-learn 0.0.8 → 0.0.10
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/dist/plugin/cli.js +1 -1
- package/dist/plugin/index.js +147 -11
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-B4UhCY5y.js → validation-BxWAMMnJ.js} +4 -7
- package/dist/validation-BxWAMMnJ.js.map +1 -0
- package/package.json +9 -6
- package/src/components/DefaultLayout.svelte +2 -0
- package/src/components/FillInTheBlank.svelte +32 -37
- package/src/components/Matching.svelte +35 -68
- package/src/components/MultipleChoice.svelte +25 -38
- package/src/components/Quiz.svelte +22 -26
- package/src/components/Sorting.svelte +40 -42
- package/src/index.ts +1 -0
- package/src/plugin/index.ts +184 -9
- package/src/plugin/validation.ts +7 -2
- package/src/runtime/App.svelte +53 -39
- package/src/runtime/LoadingBar.svelte +47 -0
- package/src/runtime/Sidebar.svelte +2 -0
- package/src/runtime/adapters/cmi5.ts +44 -14
- package/src/runtime/hooks.svelte.ts +269 -227
- package/src/runtime/interaction-format.ts +40 -8
- package/src/runtime/interaction.ts +3 -3
- package/src/runtime/navigation.svelte.ts +38 -5
- package/src/runtime/persistence.ts +5 -0
- package/src/runtime/progress.svelte.ts +16 -10
- package/src/runtime/quiz-policy.ts +16 -16
- package/src/runtime/types.ts +1 -2
- package/src/virtual.d.ts +13 -0
- package/styles/layout.css +34 -24
- package/dist/validation-B4UhCY5y.js.map +0 -1
- package/src/components/quiz-payload.ts +0 -71
- package/src/runtime/LoadingSkeleton.svelte +0 -26
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script>
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
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 (
|
|
41
|
+
if (inQuiz) q.setRender(renderQuestion);
|
|
44
42
|
});
|
|
45
43
|
|
|
46
44
|
function handleSelect(optIndex) {
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
if (q.locked) return;
|
|
46
|
+
selectedOption = optIndex;
|
|
47
|
+
if (inQuiz) {
|
|
48
|
+
q.setAnswer(optIndex);
|
|
49
|
+
q.commit();
|
|
51
50
|
} else {
|
|
52
|
-
|
|
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 (
|
|
65
|
-
|
|
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
|
|
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={
|
|
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
|
|
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
|
|
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
|
|
127
|
-
<RetryButton onclick={() =>
|
|
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
|
|
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 = (
|
|
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={
|
|
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
|
|
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
|
|
176
|
-
{@const answer =
|
|
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
|
|
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(
|
|
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(
|
|
27
|
-
|
|
27
|
+
function isAnswered(q) {
|
|
28
|
+
if (!q) return false;
|
|
29
|
+
return q.answer !== undefined || q.isLockedCorrect;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
function needsReveal(
|
|
31
|
-
return
|
|
32
|
-
|
|
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(
|
|
42
|
-
handle.revealFeedback(
|
|
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(
|
|
114
|
+
disabled={!isAnswered(currentQuestion)}
|
|
118
115
|
onclick={goNextQuestion}
|
|
119
116
|
>
|
|
120
|
-
{
|
|
117
|
+
{currentQuestion?.feedbackVisible && isImmediateMode ? 'Continue' : 'Next'}
|
|
121
118
|
</button>
|
|
122
|
-
{:else if needsReveal(
|
|
119
|
+
{:else if needsReveal(currentQuestion)}
|
|
123
120
|
<button
|
|
124
121
|
class="tessera-quiz-btn tessera-btn-primary"
|
|
125
|
-
onclick={() => handle.revealFeedback(
|
|
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
|
|
196
|
+
{#if !feedbackDisabled}
|
|
201
197
|
<button
|
|
202
198
|
class="tessera-quiz-btn tessera-quiz-btn-secondary"
|
|
203
199
|
onclick={handleStartReview}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script>
|
|
2
|
-
import {
|
|
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';
|
|
@@ -19,9 +19,6 @@
|
|
|
19
19
|
weight = 1,
|
|
20
20
|
} = $props();
|
|
21
21
|
|
|
22
|
-
const quiz = getContext('tessera-quiz');
|
|
23
|
-
const standalone = !quiz;
|
|
24
|
-
|
|
25
22
|
let queue = $state([]); // item indices not yet placed; queue[0] is current
|
|
26
23
|
let placements = $state(new SvelteMap()); // itemIdx → targetIdx
|
|
27
24
|
let dragOver = $state(null); // target index highlighted during drag
|
|
@@ -36,15 +33,6 @@
|
|
|
36
33
|
isDragging = false;
|
|
37
34
|
}
|
|
38
35
|
|
|
39
|
-
if (standalone) {
|
|
40
|
-
initQueue();
|
|
41
|
-
} else {
|
|
42
|
-
onMount(() => {
|
|
43
|
-
initQueue();
|
|
44
|
-
quiz.setRender(myIndex, renderQuestion);
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
36
|
function checkAnswer(answer) {
|
|
49
37
|
if (!answer || !(answer instanceof Map)) return false;
|
|
50
38
|
if (answer.size !== items.length) return false;
|
|
@@ -61,7 +49,7 @@
|
|
|
61
49
|
// Sorting is semantically a categorization (each item → one target) and maps
|
|
62
50
|
// cleanly to SCORM 2004's `matching` interaction. We emit [itemIdx, targetIdx]
|
|
63
51
|
// pairs as stringified ids.
|
|
64
|
-
const
|
|
52
|
+
const q = useQuestion({
|
|
65
53
|
get id() { return id ?? `sorting-${slugFromQuestion(question)}`; },
|
|
66
54
|
get weight() { return weight; },
|
|
67
55
|
get maxRetries() { return maxRetries; },
|
|
@@ -73,13 +61,20 @@
|
|
|
73
61
|
reset: resetState,
|
|
74
62
|
});
|
|
75
63
|
|
|
76
|
-
|
|
64
|
+
// `q.mode` is fixed for the lifetime of the widget; capture it once so
|
|
65
|
+
// setup-time branches don't trip Svelte's "state_referenced_locally" warning.
|
|
66
|
+
const inQuiz = q.mode === 'quiz';
|
|
77
67
|
|
|
78
|
-
|
|
68
|
+
if (!inQuiz) {
|
|
69
|
+
initQueue();
|
|
70
|
+
} else {
|
|
71
|
+
onMount(() => {
|
|
72
|
+
initQueue();
|
|
73
|
+
q.setRender(renderQuestion);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
79
76
|
|
|
80
|
-
let
|
|
81
|
-
let isDisabled = $derived(standalone ? handle.submitted : quiz.isAnswerLocked(myIndex));
|
|
82
|
-
let showFeedback = $derived(standalone ? handle.submitted : quiz.feedbackVisible(myIndex));
|
|
77
|
+
let currentItemIdx = $derived(queue.length > 0 ? queue[0] : null);
|
|
83
78
|
|
|
84
79
|
function getItemsForTarget(targetIdx) {
|
|
85
80
|
const result = [];
|
|
@@ -94,19 +89,22 @@
|
|
|
94
89
|
}
|
|
95
90
|
|
|
96
91
|
function placeCard(targetIdx) {
|
|
97
|
-
if (
|
|
92
|
+
if (q.locked || currentItemIdx === null) return;
|
|
98
93
|
const itemIdx = queue[0];
|
|
99
94
|
placements.set(itemIdx, targetIdx);
|
|
100
95
|
queue = queue.slice(1);
|
|
101
96
|
cardSelected = false;
|
|
102
|
-
if (
|
|
97
|
+
if (inQuiz) {
|
|
98
|
+
q.setAnswer(new Map(placements));
|
|
99
|
+
if (placements.size === items.length) q.commit();
|
|
100
|
+
}
|
|
103
101
|
}
|
|
104
102
|
|
|
105
103
|
function returnCard(itemIdx) {
|
|
106
|
-
if (
|
|
104
|
+
if (q.locked) return;
|
|
107
105
|
placements.delete(itemIdx);
|
|
108
106
|
queue = [itemIdx, ...queue];
|
|
109
|
-
if (
|
|
107
|
+
if (inQuiz) q.setAnswer(new Map(placements));
|
|
110
108
|
}
|
|
111
109
|
|
|
112
110
|
// --- Drag handlers ---
|
|
@@ -122,7 +120,7 @@
|
|
|
122
120
|
}
|
|
123
121
|
|
|
124
122
|
function onDragOver(e, targetIdx) {
|
|
125
|
-
if (
|
|
123
|
+
if (q.locked) return;
|
|
126
124
|
e.preventDefault();
|
|
127
125
|
e.dataTransfer.dropEffect = 'move';
|
|
128
126
|
dragOver = targetIdx;
|
|
@@ -145,7 +143,7 @@
|
|
|
145
143
|
// --- Click / tap handlers ---
|
|
146
144
|
|
|
147
145
|
function onCardClick() {
|
|
148
|
-
if (
|
|
146
|
+
if (q.locked || currentItemIdx === null) return;
|
|
149
147
|
cardSelected = !cardSelected;
|
|
150
148
|
}
|
|
151
149
|
|
|
@@ -157,7 +155,7 @@
|
|
|
157
155
|
}
|
|
158
156
|
|
|
159
157
|
function onTargetClick(targetIdx) {
|
|
160
|
-
if (
|
|
158
|
+
if (q.locked || !cardSelected) return;
|
|
161
159
|
placeCard(targetIdx);
|
|
162
160
|
}
|
|
163
161
|
|
|
@@ -174,7 +172,7 @@
|
|
|
174
172
|
<p class="tessera-sorting-question">{question}</p>
|
|
175
173
|
|
|
176
174
|
<!-- Card deck: shows the current card to be placed -->
|
|
177
|
-
{#if !
|
|
175
|
+
{#if !q.locked}
|
|
178
176
|
<div class="tessera-sorting-deck" aria-live="polite" aria-atomic="false">
|
|
179
177
|
{#if currentItemIdx !== null}
|
|
180
178
|
<div class="tessera-sorting-deck-inner">
|
|
@@ -212,17 +210,17 @@
|
|
|
212
210
|
{/if}
|
|
213
211
|
|
|
214
212
|
<!-- Drop targets -->
|
|
215
|
-
<div class="tessera-sorting-targets" class:targets-active={cardSelected && !
|
|
213
|
+
<div class="tessera-sorting-targets" class:targets-active={cardSelected && !q.locked}>
|
|
216
214
|
{#each targets as targetLabel, targetIdx}
|
|
217
215
|
{@const targetItems = getItemsForTarget(targetIdx)}
|
|
218
216
|
<div
|
|
219
217
|
class="tessera-sorting-target"
|
|
220
218
|
class:drag-over={dragOver === targetIdx}
|
|
221
|
-
class:clickable={cardSelected && !
|
|
219
|
+
class:clickable={cardSelected && !q.locked}
|
|
222
220
|
role="button"
|
|
223
221
|
tabindex="0"
|
|
224
|
-
aria-disabled={!(cardSelected && !
|
|
225
|
-
aria-label="Target: {targetLabel}{cardSelected && !
|
|
222
|
+
aria-disabled={!(cardSelected && !q.locked)}
|
|
223
|
+
aria-label="Target: {targetLabel}{cardSelected && !q.locked ? ` (activate to place ${items[currentItemIdx]})` : ''}"
|
|
226
224
|
ondragover={(e) => onDragOver(e, targetIdx)}
|
|
227
225
|
ondragleave={onDragLeave}
|
|
228
226
|
ondrop={(e) => onDrop(e, targetIdx)}
|
|
@@ -235,17 +233,17 @@
|
|
|
235
233
|
{#each targetItems as itemIdx}
|
|
236
234
|
<div
|
|
237
235
|
class="tessera-sorting-placed-item"
|
|
238
|
-
class:correct={
|
|
239
|
-
class:incorrect={
|
|
236
|
+
class:correct={q.feedbackVisible && isCorrectPlacement(itemIdx)}
|
|
237
|
+
class:incorrect={q.feedbackVisible && !isCorrectPlacement(itemIdx)}
|
|
240
238
|
>
|
|
241
239
|
<span class="tessera-sorting-item-text">{items[itemIdx]}</span>
|
|
242
|
-
{#if !
|
|
240
|
+
{#if !q.locked}
|
|
243
241
|
<button
|
|
244
242
|
class="tessera-sorting-remove"
|
|
245
243
|
aria-label="Return '{items[itemIdx]}' to deck"
|
|
246
244
|
onclick={(e) => { e.stopPropagation(); returnCard(itemIdx); }}
|
|
247
245
|
>×</button>
|
|
248
|
-
{:else if
|
|
246
|
+
{:else if q.feedbackVisible}
|
|
249
247
|
<span class="tessera-sorting-item-icon" aria-hidden="true">
|
|
250
248
|
{isCorrectPlacement(itemIdx) ? '✓' : '✗'}
|
|
251
249
|
</span>
|
|
@@ -259,7 +257,7 @@
|
|
|
259
257
|
</div>
|
|
260
258
|
|
|
261
259
|
<!-- Feedback (shown after standalone submit or quiz feedbackVisible) -->
|
|
262
|
-
{#if
|
|
260
|
+
{#if q.feedbackVisible}
|
|
263
261
|
{@const isCorrect = checkAnswer(placements)}
|
|
264
262
|
<div class="tessera-sorting-review">
|
|
265
263
|
{#if isCorrect}
|
|
@@ -285,23 +283,23 @@
|
|
|
285
283
|
<p class="tessera-sorting-feedback incorrect">{incorrectFeedback}</p>
|
|
286
284
|
{/if}
|
|
287
285
|
{/if}
|
|
288
|
-
{#if
|
|
289
|
-
<RetryButton onclick={() =>
|
|
286
|
+
{#if !inQuiz && q.canRetry}
|
|
287
|
+
<RetryButton onclick={() => q.retry()} />
|
|
290
288
|
{/if}
|
|
291
289
|
</div>
|
|
292
290
|
{/if}
|
|
293
291
|
|
|
294
292
|
<!-- Standalone Check button (shown once all cards are placed) -->
|
|
295
|
-
{#if
|
|
293
|
+
{#if !inQuiz && !q.submitted && placements.size === items.length}
|
|
296
294
|
<div class="tessera-sorting-actions">
|
|
297
|
-
<button class="tessera-btn-primary tessera-sorting-check" onclick={() =>
|
|
295
|
+
<button class="tessera-btn-primary tessera-sorting-check" onclick={() => q.submit()}>
|
|
298
296
|
Check Answer
|
|
299
297
|
</button>
|
|
300
298
|
</div>
|
|
301
299
|
{/if}
|
|
302
300
|
{/snippet}
|
|
303
301
|
|
|
304
|
-
{#if
|
|
302
|
+
{#if !inQuiz}
|
|
305
303
|
<div class="tessera-sorting" aria-label={question}>
|
|
306
304
|
{@render sortingContent()}
|
|
307
305
|
</div>
|
|
@@ -309,7 +307,7 @@
|
|
|
309
307
|
|
|
310
308
|
{#snippet renderQuestion()}
|
|
311
309
|
<div class="tessera-sorting" aria-label={question}>
|
|
312
|
-
{#if
|
|
310
|
+
{#if q.isLockedCorrect}
|
|
313
311
|
<LockedBanner />
|
|
314
312
|
{/if}
|
|
315
313
|
{@render sortingContent()}
|