question-bank-framework 0.8.0 → 0.10.0
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/README.md +14 -8
- package/framework/components/app-shell.tsx +11 -7
- package/framework/components/exam-timer.tsx +64 -33
- package/framework/components/filter-builder.tsx +65 -63
- package/framework/components/markdown-content.tsx +15 -15
- package/framework/components/question-bank.tsx +150 -227
- package/framework/components/question-response.tsx +41 -31
- package/framework/components/quiz-list.tsx +42 -37
- package/framework/components/quiz-page.tsx +63 -62
- package/framework/components/quiz-question-editor.tsx +11 -11
- package/framework/components/ui/badge.tsx +29 -23
- package/framework/components/ui/button.tsx +24 -28
- package/framework/components/ui/card.tsx +104 -0
- package/framework/components/ui/checkbox.tsx +9 -10
- package/framework/components/ui/dropdown-menu.tsx +139 -123
- package/framework/components/ui/field.tsx +238 -0
- package/framework/components/ui/input.tsx +6 -5
- package/framework/components/ui/kbd.tsx +28 -0
- package/framework/components/ui/label.tsx +19 -0
- package/framework/components/ui/popover.tsx +35 -32
- package/framework/components/ui/select.tsx +80 -65
- package/framework/components/ui/separator.tsx +24 -0
- package/framework/components/ui/sheet.tsx +36 -39
- package/framework/components/ui/table.tsx +4 -4
- package/framework/components/ui/toast.tsx +2 -2
- package/framework/components/ui/tooltip.tsx +65 -0
- package/framework/index.ts +1 -1
- package/framework/lib/exam-timer.ts +29 -10
- package/framework/styles/theme.css +66 -28
- package/package.json +3 -1
- package/template/src/routes/__root.tsx +1 -1
- package/template/src/routes/styles.css +0 -4
|
@@ -6,6 +6,7 @@ import { Check, X } from 'lucide-react';
|
|
|
6
6
|
import type { BaseQuestion, QuestionOption, QuizAnswer } from '../../core/types.ts';
|
|
7
7
|
import { getPairingSlots, parseNumericAnswer, questionKind, toPairings } from '../../core/types.ts';
|
|
8
8
|
import { MarkdownContent } from './markdown-content';
|
|
9
|
+
import { Input } from './ui/input';
|
|
9
10
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
|
10
11
|
import { cn } from '../lib/utils.ts';
|
|
11
12
|
|
|
@@ -38,11 +39,11 @@ export function NumericalResponse({
|
|
|
38
39
|
const inputId = `quiz-numeric-${question.id}`;
|
|
39
40
|
return (
|
|
40
41
|
<div className="mt-6">
|
|
41
|
-
<label htmlFor={inputId} className="text-sm font-
|
|
42
|
+
<label htmlFor={inputId} className="text-sm font-medium">
|
|
42
43
|
Your answer
|
|
43
44
|
</label>
|
|
44
45
|
<div className="mt-2 flex items-center gap-2">
|
|
45
|
-
<
|
|
46
|
+
<Input
|
|
46
47
|
id={inputId}
|
|
47
48
|
type="text"
|
|
48
49
|
inputMode="decimal"
|
|
@@ -53,16 +54,15 @@ export function NumericalResponse({
|
|
|
53
54
|
aria-invalid={invalid || (checked && correct === false) || undefined}
|
|
54
55
|
aria-describedby={invalid ? `${inputId}-hint` : undefined}
|
|
55
56
|
className={cn(
|
|
56
|
-
'
|
|
57
|
-
checked && correct === true && 'border-
|
|
58
|
-
checked && correct === false && 'border-
|
|
59
|
-
!checked && 'border-gray-300',
|
|
57
|
+
'w-48 font-mono tabular-nums placeholder:font-sans',
|
|
58
|
+
checked && correct === true && 'border-success/30 bg-success/10',
|
|
59
|
+
checked && correct === false && 'border-destructive/30 bg-destructive/10',
|
|
60
60
|
)}
|
|
61
61
|
/>
|
|
62
|
-
{question.numeric?.unit && <span className="text-sm text-
|
|
62
|
+
{question.numeric?.unit && <span className="text-sm text-muted-foreground">{question.numeric.unit}</span>}
|
|
63
63
|
</div>
|
|
64
64
|
{invalid && (
|
|
65
|
-
<p id={`${inputId}-hint`} className="mt-1.5 text-
|
|
65
|
+
<p id={`${inputId}-hint`} className="mt-1.5 text-xs text-warning">
|
|
66
66
|
That doesn't look like a number — it will be marked incorrect.
|
|
67
67
|
</p>
|
|
68
68
|
)}
|
|
@@ -87,24 +87,35 @@ function OptionSelect({
|
|
|
87
87
|
ariaLabel: string;
|
|
88
88
|
state: 'idle' | 'correct' | 'incorrect' | 'missing';
|
|
89
89
|
}) {
|
|
90
|
+
const renderOptionLabel = (option: QuestionOption, index: number) => (
|
|
91
|
+
<>
|
|
92
|
+
{lettered && <span className="font-medium text-muted-foreground">{optionLetter(index)}.</span>}
|
|
93
|
+
<MarkdownContent inline content={option.text} />
|
|
94
|
+
</>
|
|
95
|
+
);
|
|
90
96
|
return (
|
|
91
|
-
<Select
|
|
97
|
+
<Select
|
|
98
|
+
value={value ?? null}
|
|
99
|
+
onValueChange={(next) => {
|
|
100
|
+
if (next !== null) onChange(next);
|
|
101
|
+
}}
|
|
102
|
+
items={options.map((option, index) => ({ value: option.id, label: renderOptionLabel(option, index) }))}
|
|
103
|
+
>
|
|
92
104
|
<SelectTrigger
|
|
93
105
|
id={id}
|
|
94
106
|
aria-label={ariaLabel}
|
|
95
107
|
className={cn(
|
|
96
|
-
'h-auto min-h-
|
|
97
|
-
state === 'correct' && 'border-
|
|
98
|
-
(state === 'incorrect' || state === 'missing') && 'border-
|
|
108
|
+
'h-auto min-h-8 w-full min-w-48 whitespace-normal text-left sm:w-72 *:data-[slot=select-value]:line-clamp-none',
|
|
109
|
+
state === 'correct' && 'border-success/30 bg-success/10',
|
|
110
|
+
(state === 'incorrect' || state === 'missing') && 'border-destructive/30 bg-destructive/10',
|
|
99
111
|
)}
|
|
100
112
|
>
|
|
101
113
|
<SelectValue placeholder="Choose…" />
|
|
102
114
|
</SelectTrigger>
|
|
103
|
-
<SelectContent
|
|
115
|
+
<SelectContent alignItemWithTrigger={false} className="w-auto min-w-(--anchor-width) max-w-[min(36rem,calc(100vw-2rem))]">
|
|
104
116
|
{options.map((option, index) => (
|
|
105
|
-
<SelectItem key={option.id} value={option.id} className="whitespace-normal">
|
|
106
|
-
{
|
|
107
|
-
<MarkdownContent inline content={option.text} />
|
|
117
|
+
<SelectItem key={option.id} value={option.id} className="*:min-w-0 *:whitespace-normal">
|
|
118
|
+
{renderOptionLabel(option, index)}
|
|
108
119
|
</SelectItem>
|
|
109
120
|
))}
|
|
110
121
|
</SelectContent>
|
|
@@ -132,20 +143,20 @@ export function PairingResponse({
|
|
|
132
143
|
|
|
133
144
|
return (
|
|
134
145
|
<div className="mt-6">
|
|
135
|
-
<p id={`quiz-pairing-label-${question.id}`} className="text-sm font-
|
|
146
|
+
<p id={`quiz-pairing-label-${question.id}`} className="text-sm font-medium">
|
|
136
147
|
{matching ? 'Match each item to an option' : 'Fill in each blank'}
|
|
137
|
-
<span className="ml-2 font-normal text-
|
|
148
|
+
<span className="ml-2 text-xs font-normal tabular-nums text-muted-foreground">
|
|
138
149
|
{answeredCount}/{slots.length} answered
|
|
139
150
|
</span>
|
|
140
151
|
</p>
|
|
141
152
|
|
|
142
153
|
{matching && (
|
|
143
|
-
<div className="mt-3 rounded-lg border
|
|
144
|
-
<p className="m-0 text-xs font-
|
|
154
|
+
<div className="mt-3 rounded-lg border bg-muted/50 p-3">
|
|
155
|
+
<p className="m-0 text-xs font-medium text-muted-foreground">Options</p>
|
|
145
156
|
<ol className="m-0 mt-2 grid list-none grid-cols-[minmax(0,1fr)] gap-x-6 gap-y-1 p-0 sm:grid-cols-[repeat(2,minmax(0,1fr))]">
|
|
146
157
|
{(question.options ?? []).map((option, index) => (
|
|
147
|
-
<li key={option.id} className="flex items-start gap-2 text-sm
|
|
148
|
-
<span className="w-5 shrink-0 font-
|
|
158
|
+
<li key={option.id} className="flex items-start gap-2 text-sm leading-6">
|
|
159
|
+
<span className="w-5 shrink-0 font-medium text-muted-foreground">{optionLetter(index)}.</span>
|
|
149
160
|
<MarkdownContent inline content={option.text} className="min-w-0 flex-1 overflow-x-auto" />
|
|
150
161
|
</li>
|
|
151
162
|
))}
|
|
@@ -169,14 +180,13 @@ export function PairingResponse({
|
|
|
169
180
|
// Wraps instead of squeezing: short items sit beside their select, while
|
|
170
181
|
// wide items (long formulas) take the full row and the select drops below.
|
|
171
182
|
'flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border p-3',
|
|
172
|
-
state === '
|
|
173
|
-
state === '
|
|
174
|
-
(state === 'incorrect' || state === 'missing') && 'border-red-300 bg-red-50/50',
|
|
183
|
+
state === 'correct' && 'border-success/30 bg-success/10',
|
|
184
|
+
(state === 'incorrect' || state === 'missing') && 'border-destructive/30 bg-destructive/10',
|
|
175
185
|
)}
|
|
176
186
|
>
|
|
177
187
|
<div className="flex min-w-0 max-w-full flex-[1_1_auto] items-start gap-2">
|
|
178
|
-
<span className="mt-0.5 w-6 shrink-0 text-sm font-
|
|
179
|
-
<label htmlFor={selectId} className="min-w-0 flex-1 overflow-x-auto overflow-y-hidden text-
|
|
188
|
+
<span className="mt-0.5 w-6 shrink-0 text-sm font-medium tabular-nums text-muted-foreground">{index + 1}.</span>
|
|
189
|
+
<label htmlFor={selectId} className="min-w-0 flex-1 overflow-x-auto overflow-y-hidden text-[15px] leading-7">
|
|
180
190
|
<MarkdownContent inline content={label} />
|
|
181
191
|
</label>
|
|
182
192
|
</div>
|
|
@@ -191,15 +201,15 @@ export function PairingResponse({
|
|
|
191
201
|
state={state}
|
|
192
202
|
onChange={(value) => onChange({ ...pairings, [slot.id]: value })}
|
|
193
203
|
/>
|
|
194
|
-
{state === 'correct' && <Check aria-label="Correct" className="size-
|
|
204
|
+
{state === 'correct' && <Check aria-label="Correct" className="size-4 shrink-0 text-success" />}
|
|
195
205
|
{(state === 'incorrect' || state === 'missing') && (
|
|
196
|
-
<X aria-label={state === 'missing' ? 'Not answered' : 'Incorrect'} className="size-
|
|
206
|
+
<X aria-label={state === 'missing' ? 'Not answered' : 'Incorrect'} className="size-4 shrink-0 text-destructive" />
|
|
197
207
|
)}
|
|
198
208
|
</div>
|
|
199
209
|
{(state === 'incorrect' || state === 'missing') && correctOption && (
|
|
200
|
-
<p className="m-0 flex items-baseline gap-1 text-
|
|
210
|
+
<p className="m-0 flex items-baseline gap-1 text-xs text-destructive">
|
|
201
211
|
<span className="shrink-0">Correct:</span>
|
|
202
|
-
{matching && <span className="font-
|
|
212
|
+
{matching && <span className="font-medium">{optionLetter(correctIndex)}.</span>}
|
|
203
213
|
<MarkdownContent inline content={correctOption.text} />
|
|
204
214
|
</p>
|
|
205
215
|
)}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from 'react';
|
|
4
4
|
import { Link } from '@tanstack/react-router';
|
|
5
|
-
import { ListOrdered, Trash2 } from 'lucide-react';
|
|
5
|
+
import { ListOrdered, Plus, Trash2 } from 'lucide-react';
|
|
6
6
|
import { useBank } from '../../core/config.tsx';
|
|
7
7
|
import type { QuizSession } from '../../core/types.ts';
|
|
8
8
|
import { isAnswerProvided, scoreAnswers } from '../../core/types.ts';
|
|
@@ -49,27 +49,25 @@ export function QuizList() {
|
|
|
49
49
|
};
|
|
50
50
|
|
|
51
51
|
return (
|
|
52
|
-
<div className="grid
|
|
53
|
-
<div className="flex flex-wrap items-
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
<Button asChild>
|
|
59
|
-
<Link to="/">Build a quiz</Link>
|
|
52
|
+
<div className="grid gap-8">
|
|
53
|
+
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
54
|
+
<h1 className="text-2xl font-semibold">My quizzes</h1>
|
|
55
|
+
<Button render={<Link to="/" />} nativeButton={false}>
|
|
56
|
+
<Plus data-icon="inline-start" aria-hidden="true" />
|
|
57
|
+
New quiz
|
|
60
58
|
</Button>
|
|
61
59
|
</div>
|
|
62
60
|
|
|
63
61
|
{loading ? (
|
|
64
|
-
<
|
|
62
|
+
<p className="py-16 text-center text-sm text-muted-foreground">Loading your quizzes…</p>
|
|
65
63
|
) : quizzes.length > 0 ? (
|
|
66
|
-
<div>
|
|
64
|
+
<div className="grid gap-3">
|
|
67
65
|
{deleteError && (
|
|
68
|
-
<p role="alert" className="
|
|
66
|
+
<p role="alert" className="text-sm text-destructive">
|
|
69
67
|
Could not delete that quiz. Please try again.
|
|
70
68
|
</p>
|
|
71
69
|
)}
|
|
72
|
-
<
|
|
70
|
+
<ul className="grid gap-3 sm:grid-cols-2">
|
|
73
71
|
{quizzes.map((quiz) => {
|
|
74
72
|
const questions = quiz.questionIds
|
|
75
73
|
.map((id) => bank.questionById.get(id))
|
|
@@ -78,58 +76,65 @@ export function QuizList() {
|
|
|
78
76
|
isAnswerProvided(question, quiz.answers[question.id]),
|
|
79
77
|
).length;
|
|
80
78
|
const score = scoreAnswers(questions, quiz.answers);
|
|
79
|
+
const total = quiz.questionIds.length;
|
|
80
|
+
const progress = total > 0 ? Math.round((answeredCount / total) * 100) : 0;
|
|
81
81
|
return (
|
|
82
|
-
<
|
|
82
|
+
<li key={quiz.id} className="group relative">
|
|
83
83
|
<Link
|
|
84
84
|
to="/quiz/$quizId"
|
|
85
85
|
params={{ quizId: quiz.id }}
|
|
86
|
-
className="
|
|
86
|
+
className="flex h-full flex-col gap-4 rounded-xl border bg-card p-4 transition-colors hover:bg-muted/50 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
|
87
87
|
>
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
88
|
+
<div className="pr-16">
|
|
89
|
+
<h2 className="text-sm font-semibold break-words">{quiz.title}</h2>
|
|
90
|
+
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
|
|
91
|
+
{total} {total === 1 ? 'question' : 'questions'}
|
|
92
|
+
{score.gradeable > 0 && ` · ${score.correct}/${score.gradeable} correct`}
|
|
93
|
+
</p>
|
|
94
|
+
</div>
|
|
95
|
+
<div className="mt-auto grid gap-1.5">
|
|
96
|
+
<div className="h-1 overflow-hidden rounded-full bg-muted" aria-hidden="true">
|
|
97
|
+
<div className="h-full rounded-full bg-primary transition-[width]" style={{ width: `${progress}%` }} />
|
|
98
|
+
</div>
|
|
99
|
+
<p className="text-xs text-muted-foreground tabular-nums">
|
|
100
|
+
{answeredCount} of {total} answered
|
|
101
|
+
</p>
|
|
102
|
+
</div>
|
|
96
103
|
</Link>
|
|
97
|
-
<div className="absolute
|
|
104
|
+
<div className="absolute top-3 right-3 flex gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-within:opacity-100">
|
|
98
105
|
<Button
|
|
99
106
|
type="button"
|
|
100
|
-
variant="
|
|
101
|
-
size="icon-
|
|
107
|
+
variant="ghost"
|
|
108
|
+
size="icon-sm"
|
|
102
109
|
aria-label={`Reorder questions in ${quiz.title}`}
|
|
103
110
|
title="Reorder or remove questions"
|
|
104
111
|
onClick={() => setEditingQuiz(quiz)}
|
|
105
|
-
className="rounded-full bg-white shadow-sm"
|
|
106
112
|
>
|
|
107
113
|
<ListOrdered aria-hidden="true" />
|
|
108
114
|
</Button>
|
|
109
115
|
<Button
|
|
110
116
|
type="button"
|
|
111
|
-
variant="
|
|
112
|
-
size="icon-
|
|
117
|
+
variant="ghost"
|
|
118
|
+
size="icon-sm"
|
|
113
119
|
aria-label={`Delete ${quiz.title}`}
|
|
114
120
|
title="Delete quiz"
|
|
115
121
|
disabled={deletingId === quiz.id}
|
|
116
122
|
onClick={() => removeQuiz(quiz)}
|
|
117
|
-
className="
|
|
123
|
+
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
|
118
124
|
>
|
|
119
125
|
<Trash2 aria-hidden="true" />
|
|
120
126
|
</Button>
|
|
121
127
|
</div>
|
|
122
|
-
</
|
|
128
|
+
</li>
|
|
123
129
|
);
|
|
124
130
|
})}
|
|
125
|
-
</
|
|
131
|
+
</ul>
|
|
126
132
|
</div>
|
|
127
133
|
) : (
|
|
128
|
-
<div className="
|
|
129
|
-
<
|
|
130
|
-
<
|
|
131
|
-
|
|
132
|
-
<Link to="/">Browse questions</Link>
|
|
134
|
+
<div className="flex flex-col items-center rounded-xl border border-dashed px-6 py-16 text-center">
|
|
135
|
+
<p className="text-sm text-muted-foreground">No quizzes yet</p>
|
|
136
|
+
<Button render={<Link to="/" />} nativeButton={false} className="mt-4">
|
|
137
|
+
Browse questions
|
|
133
138
|
</Button>
|
|
134
139
|
</div>
|
|
135
140
|
)}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import { Link, useParams } from '@tanstack/react-router';
|
|
5
|
-
import { Check, Pencil, X } from 'lucide-react';
|
|
5
|
+
import { Check, ChevronLeft, Pencil, X } from 'lucide-react';
|
|
6
6
|
import { useBank } from '../../core/config.tsx';
|
|
7
7
|
import type { QuestionStatus, QuestionStatusMap, QuizAnswer, QuizSession } from '../../core/types.ts';
|
|
8
8
|
import {
|
|
@@ -30,15 +30,15 @@ import { getQuiz, saveQuizLocally, syncQuiz } from '../lib/quiz-storage.ts';
|
|
|
30
30
|
import { cn } from '../lib/utils.ts';
|
|
31
31
|
|
|
32
32
|
const questionStatusStyles: Record<QuestionStatus, string> = {
|
|
33
|
-
unanswered: 'border-
|
|
34
|
-
revisit: 'border-
|
|
35
|
-
complete: 'border-
|
|
33
|
+
unanswered: 'border-border bg-muted text-muted-foreground',
|
|
34
|
+
revisit: 'border-warning/30 bg-warning/10 text-warning',
|
|
35
|
+
complete: 'border-success/30 bg-success/10 text-success',
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
const questionStatusDotStyles: Record<QuestionStatus, string> = {
|
|
39
|
-
unanswered: 'border-
|
|
40
|
-
revisit: 'border-
|
|
41
|
-
complete: 'border-
|
|
39
|
+
unanswered: 'border-border bg-background text-muted-foreground hover:bg-muted',
|
|
40
|
+
revisit: 'border-warning/30 bg-warning/10 text-warning hover:bg-warning/20',
|
|
41
|
+
complete: 'border-success/30 bg-success/10 text-success hover:bg-success/20',
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
export function QuizPage() {
|
|
@@ -177,15 +177,15 @@ export function QuizPage() {
|
|
|
177
177
|
setEditingTitle(false);
|
|
178
178
|
};
|
|
179
179
|
|
|
180
|
-
if (loading) return <div className="py-16 text-center text-sm text-
|
|
180
|
+
if (loading) return <div className="py-16 text-center text-sm text-muted-foreground">Opening your quiz...</div>;
|
|
181
181
|
|
|
182
182
|
if (!session || !activeQuestion || quizQuestions.length === 0) {
|
|
183
183
|
return (
|
|
184
|
-
<div className="mx-auto max-w-xl py-16 text-center">
|
|
185
|
-
<h1 className="text-2xl font-
|
|
186
|
-
<p className="mt-
|
|
187
|
-
<Button
|
|
188
|
-
|
|
184
|
+
<div className="mx-auto flex w-full max-w-xl flex-col items-center py-16 text-center">
|
|
185
|
+
<h1 className="text-2xl font-semibold">Quiz not found</h1>
|
|
186
|
+
<p className="mt-1 text-sm text-muted-foreground">This practice set may have been removed from this browser.</p>
|
|
187
|
+
<Button render={<Link to="/quizzes" />} nativeButton={false} className="mt-6">
|
|
188
|
+
Back to my quizzes
|
|
189
189
|
</Button>
|
|
190
190
|
</div>
|
|
191
191
|
);
|
|
@@ -208,8 +208,9 @@ export function QuizPage() {
|
|
|
208
208
|
|
|
209
209
|
return (
|
|
210
210
|
<div className="mx-auto grid w-full max-w-3xl grid-cols-[minmax(0,1fr)] gap-6">
|
|
211
|
-
<Link to="/quizzes" className="text-sm font-
|
|
212
|
-
|
|
211
|
+
<Link to="/quizzes" className="inline-flex w-fit items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground">
|
|
212
|
+
<ChevronLeft aria-hidden="true" className="size-4" />
|
|
213
|
+
Back to my quizzes
|
|
213
214
|
</Link>
|
|
214
215
|
|
|
215
216
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
@@ -229,7 +230,7 @@ export function QuizPage() {
|
|
|
229
230
|
onKeyDown={(event) => {
|
|
230
231
|
if (event.key === 'Escape') setEditingTitle(false);
|
|
231
232
|
}}
|
|
232
|
-
className="h-
|
|
233
|
+
className="h-9 min-w-0 px-2 text-2xl font-semibold md:text-2xl"
|
|
233
234
|
/>
|
|
234
235
|
<Button type="submit" size="icon-sm" aria-label="Save title" title="Save title" disabled={!titleDraft.trim()}>
|
|
235
236
|
<Check aria-hidden="true" />
|
|
@@ -240,26 +241,26 @@ export function QuizPage() {
|
|
|
240
241
|
</form>
|
|
241
242
|
) : (
|
|
242
243
|
<div className="flex min-w-0 items-center gap-2">
|
|
243
|
-
<h1 className="m-0 min-w-0 break-words text-
|
|
244
|
+
<h1 className="m-0 min-w-0 break-words text-2xl font-semibold">{session.title}</h1>
|
|
244
245
|
<Button type="button" variant="ghost" size="icon-xs" aria-label="Edit quiz title" title="Edit quiz title" onClick={startEditingTitle}>
|
|
245
|
-
<Pencil aria-hidden="true" />
|
|
246
|
+
<Pencil aria-hidden="true" className="text-muted-foreground" />
|
|
246
247
|
</Button>
|
|
247
248
|
</div>
|
|
248
249
|
)}
|
|
249
|
-
<span className="text-
|
|
250
|
+
<span className="text-xs text-muted-foreground">{saving ? 'Saving...' : 'Saved'}</span>
|
|
250
251
|
</div>
|
|
251
252
|
|
|
252
253
|
<div className="flex flex-wrap items-center gap-3">
|
|
253
|
-
<p className="m-0 text-
|
|
254
|
+
<p className="m-0 text-sm font-medium tabular-nums">
|
|
254
255
|
Question {activeIndex + 1} of {quizQuestions.length}
|
|
255
256
|
</p>
|
|
256
257
|
{score.gradeable > 0 && (
|
|
257
|
-
<Badge variant="secondary" aria-label={`Quiz score: ${score.correct} of ${score.gradeable} correct`}>
|
|
258
|
+
<Badge variant="secondary" className="tabular-nums" aria-label={`Quiz score: ${score.correct} of ${score.gradeable} correct`}>
|
|
258
259
|
{score.correct}/{score.gradeable} correct
|
|
259
260
|
</Badge>
|
|
260
261
|
)}
|
|
261
262
|
{session.completed && (
|
|
262
|
-
<Badge variant="outline" className="border-
|
|
263
|
+
<Badge variant="outline" className="border-success/30 bg-success/10 text-success">
|
|
263
264
|
Quiz complete
|
|
264
265
|
</Badge>
|
|
265
266
|
)}
|
|
@@ -280,9 +281,9 @@ export function QuizPage() {
|
|
|
280
281
|
aria-current={isActive ? 'true' : undefined}
|
|
281
282
|
title={`${question.title} — ${questionStatusLabels[status]}`}
|
|
282
283
|
className={cn(
|
|
283
|
-
'flex size-8 items-center justify-center rounded-
|
|
284
|
+
'flex size-8 items-center justify-center rounded-lg border text-xs font-medium tabular-nums transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/50',
|
|
284
285
|
questionStatusDotStyles[status],
|
|
285
|
-
isActive && 'ring-2 ring-
|
|
286
|
+
isActive && 'ring-2 ring-ring ring-offset-2 ring-offset-background',
|
|
286
287
|
)}
|
|
287
288
|
>
|
|
288
289
|
{index + 1}
|
|
@@ -295,8 +296,8 @@ export function QuizPage() {
|
|
|
295
296
|
)}
|
|
296
297
|
|
|
297
298
|
{session.completed && score.gradeable > 0 && (
|
|
298
|
-
<section aria-label="Quiz results" className="rounded-xl border
|
|
299
|
-
<h2 className="m-0 text-
|
|
299
|
+
<section aria-label="Quiz results" className="rounded-xl border bg-card p-5">
|
|
300
|
+
<h2 className="m-0 text-base font-semibold tabular-nums">
|
|
300
301
|
You scored {score.correct} of {score.gradeable}
|
|
301
302
|
</h2>
|
|
302
303
|
<ol className="mt-3 grid gap-1.5">
|
|
@@ -318,22 +319,22 @@ export function QuizPage() {
|
|
|
318
319
|
<button
|
|
319
320
|
type="button"
|
|
320
321
|
onClick={() => goToQuestion(index)}
|
|
321
|
-
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm hover:bg-
|
|
322
|
+
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm hover:bg-muted/50"
|
|
322
323
|
aria-label={`Review question ${index + 1}: ${question.title} — ${resultLabel.toLowerCase()}`}
|
|
323
324
|
>
|
|
324
325
|
<span
|
|
325
326
|
aria-hidden="true"
|
|
326
327
|
className={cn(
|
|
327
|
-
'flex size-5 shrink-0 items-center justify-center rounded-full text-[11px] font-
|
|
328
|
-
result === true && 'bg-
|
|
329
|
-
result === false && 'bg-
|
|
330
|
-
result === null && 'bg-
|
|
328
|
+
'flex size-5 shrink-0 items-center justify-center rounded-full text-[11px] font-medium tabular-nums',
|
|
329
|
+
result === true && 'bg-success/10 text-success',
|
|
330
|
+
result === false && 'bg-destructive/10 text-destructive',
|
|
331
|
+
result === null && 'bg-muted text-muted-foreground',
|
|
331
332
|
)}
|
|
332
333
|
>
|
|
333
334
|
{result === true ? <Check className="size-3" /> : result === false ? <X className="size-3" /> : index + 1}
|
|
334
335
|
</span>
|
|
335
|
-
<span className="min-w-0 flex-1 truncate
|
|
336
|
-
<span className="shrink-0 text-xs text-
|
|
336
|
+
<span className="min-w-0 flex-1 truncate">{question.title}</span>
|
|
337
|
+
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">{resultLabel}</span>
|
|
337
338
|
</button>
|
|
338
339
|
</li>
|
|
339
340
|
);
|
|
@@ -342,15 +343,15 @@ export function QuizPage() {
|
|
|
342
343
|
</section>
|
|
343
344
|
)}
|
|
344
345
|
|
|
345
|
-
<section className="min-w-0 rounded-xl border
|
|
346
|
+
<section className="min-w-0 rounded-xl border bg-card p-6">
|
|
346
347
|
<div className="flex flex-wrap items-center gap-2">
|
|
347
|
-
<h2 className="m-0 min-w-0 flex-1 break-words text-
|
|
348
|
+
<h2 className="m-0 min-w-0 flex-1 break-words text-base font-semibold">{activeQuestion.title}</h2>
|
|
348
349
|
<Badge variant="outline">{questionKindLabels[kind]}</Badge>
|
|
349
350
|
<Badge variant="outline" className={questionStatusStyles[activeStatus]}>
|
|
350
351
|
{questionStatusLabels[activeStatus]}
|
|
351
352
|
</Badge>
|
|
352
353
|
</div>
|
|
353
|
-
<p className="mt-1 break-all font-mono text-xs text-
|
|
354
|
+
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">{activeQuestion.id}</p>
|
|
354
355
|
{activeQuestion.tags.length > 0 && (
|
|
355
356
|
<div className="mt-2 flex flex-wrap gap-1">
|
|
356
357
|
{activeQuestion.tags.map((tag) => (
|
|
@@ -360,11 +361,11 @@ export function QuizPage() {
|
|
|
360
361
|
))}
|
|
361
362
|
</div>
|
|
362
363
|
)}
|
|
363
|
-
<MarkdownContent content={activeQuestion.prompt} className="mt-4 text-
|
|
364
|
+
<MarkdownContent content={activeQuestion.prompt} className="mt-4 text-[15px] leading-7" />
|
|
364
365
|
|
|
365
366
|
{choice ? (
|
|
366
367
|
<div className="mt-6">
|
|
367
|
-
<p id="quiz-choices-label" className="text-sm font-
|
|
368
|
+
<p id="quiz-choices-label" className="text-sm font-medium">
|
|
368
369
|
{kind === 'single' ? 'Choose one answer' : 'Choose all that apply'}
|
|
369
370
|
</p>
|
|
370
371
|
<div role={kind === 'single' ? 'radiogroup' : 'group'} aria-labelledby="quiz-choices-label" className="mt-2 grid grid-cols-[minmax(0,1fr)] gap-2">
|
|
@@ -376,12 +377,12 @@ export function QuizPage() {
|
|
|
376
377
|
<label
|
|
377
378
|
key={option.id}
|
|
378
379
|
className={cn(
|
|
379
|
-
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
|
|
380
|
-
!showCorrectness && selected && 'border-
|
|
381
|
-
!showCorrectness && !selected && '
|
|
382
|
-
showCorrectness && isCorrectOption && 'border-
|
|
383
|
-
showCorrectness && !isCorrectOption && selected && 'border-
|
|
384
|
-
showCorrectness && !isCorrectOption && !selected && '
|
|
380
|
+
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors has-focus-visible:ring-3 has-focus-visible:ring-ring/50',
|
|
381
|
+
!showCorrectness && selected && 'border-ring bg-muted',
|
|
382
|
+
!showCorrectness && !selected && 'hover:bg-muted/50',
|
|
383
|
+
showCorrectness && isCorrectOption && 'border-success/30 bg-success/10',
|
|
384
|
+
showCorrectness && !isCorrectOption && selected && 'border-destructive/30 bg-destructive/10',
|
|
385
|
+
showCorrectness && !isCorrectOption && !selected && 'opacity-60',
|
|
385
386
|
)}
|
|
386
387
|
>
|
|
387
388
|
<input
|
|
@@ -389,32 +390,32 @@ export function QuizPage() {
|
|
|
389
390
|
name={`quiz-choice-${activeQuestion.id}`}
|
|
390
391
|
checked={selected}
|
|
391
392
|
onChange={() => toggleOption(activeQuestion.id, option.id, kind === 'single')}
|
|
392
|
-
className="mt-1 size-4 shrink-0 accent-
|
|
393
|
+
className="mt-1.5 size-4 shrink-0 accent-primary"
|
|
393
394
|
aria-label={`Option ${optionLetter(index)}`}
|
|
394
395
|
/>
|
|
395
396
|
<span
|
|
396
397
|
aria-hidden="true"
|
|
397
398
|
className={cn(
|
|
398
|
-
'mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-
|
|
399
|
+
'mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-medium',
|
|
399
400
|
showCorrectness && isCorrectOption
|
|
400
|
-
? 'border-
|
|
401
|
+
? 'border-success/30 bg-success/10 text-success'
|
|
401
402
|
: showCorrectness && selected
|
|
402
|
-
? 'border-
|
|
403
|
+
? 'border-destructive/30 bg-destructive/10 text-destructive'
|
|
403
404
|
: selected
|
|
404
|
-
? 'border-
|
|
405
|
-
: '
|
|
405
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
406
|
+
: 'bg-background text-muted-foreground',
|
|
406
407
|
)}
|
|
407
408
|
>
|
|
408
409
|
{optionLetter(index)}
|
|
409
410
|
</span>
|
|
410
|
-
<MarkdownContent content={option.text} className="min-w-0 flex-1 overflow-x-auto text-
|
|
411
|
+
<MarkdownContent content={option.text} className="min-w-0 flex-1 overflow-x-auto text-[15px] leading-7" />
|
|
411
412
|
{showCorrectness && isCorrectOption && (
|
|
412
|
-
<Badge variant="outline" className="shrink-0 border-
|
|
413
|
+
<Badge variant="outline" className="mt-1 shrink-0 border-success/30 bg-success/10 text-success">
|
|
413
414
|
Correct
|
|
414
415
|
</Badge>
|
|
415
416
|
)}
|
|
416
417
|
{showCorrectness && !isCorrectOption && selected && (
|
|
417
|
-
<Badge variant="outline" className="shrink-0 border-
|
|
418
|
+
<Badge variant="outline" className="mt-1 shrink-0 border-destructive/30 bg-destructive/10 text-destructive">
|
|
418
419
|
Yours
|
|
419
420
|
</Badge>
|
|
420
421
|
)}
|
|
@@ -441,7 +442,7 @@ export function QuizPage() {
|
|
|
441
442
|
/>
|
|
442
443
|
) : (
|
|
443
444
|
<div className="mt-6">
|
|
444
|
-
<label htmlFor="quiz-answer" className="text-sm font-
|
|
445
|
+
<label htmlFor="quiz-answer" className="text-sm font-medium">
|
|
445
446
|
Your answer
|
|
446
447
|
</label>
|
|
447
448
|
<textarea
|
|
@@ -449,7 +450,7 @@ export function QuizPage() {
|
|
|
449
450
|
value={typeof answer === 'string' ? answer : ''}
|
|
450
451
|
onChange={(event) => updateWrittenAnswer(event.target.value)}
|
|
451
452
|
placeholder="Write your working or notes here..."
|
|
452
|
-
className="mt-2 block min-h-40 w-full resize-y rounded-lg border border-
|
|
453
|
+
className="mt-2 block min-h-40 w-full resize-y rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm leading-6 transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 dark:bg-input/30"
|
|
453
454
|
/>
|
|
454
455
|
</div>
|
|
455
456
|
)}
|
|
@@ -460,8 +461,8 @@ export function QuizPage() {
|
|
|
460
461
|
<div
|
|
461
462
|
role="status"
|
|
462
463
|
className={cn(
|
|
463
|
-
'mt-4 rounded-lg border
|
|
464
|
-
correctness ? 'border-
|
|
464
|
+
'mt-4 rounded-lg border px-4 py-3 text-sm font-medium',
|
|
465
|
+
correctness ? 'border-success/30 bg-success/10 text-success' : 'border-destructive/30 bg-destructive/10 text-destructive',
|
|
465
466
|
)}
|
|
466
467
|
>
|
|
467
468
|
{correctness
|
|
@@ -510,17 +511,17 @@ export function QuizPage() {
|
|
|
510
511
|
</Button>
|
|
511
512
|
)}
|
|
512
513
|
{isSuggestedAnswerShown && (
|
|
513
|
-
<section id="suggested-answer" className="mt-4 rounded-lg border
|
|
514
|
-
<h3 className="m-0 text-
|
|
514
|
+
<section id="suggested-answer" className="mt-4 rounded-lg border bg-muted/50 p-4">
|
|
515
|
+
<h3 className="m-0 text-sm font-medium">
|
|
515
516
|
{gradeable ? 'Explanation' : 'Suggested answer'}
|
|
516
517
|
</h3>
|
|
517
|
-
<MarkdownContent content={suggestedAnswer} className="mt-
|
|
518
|
+
<MarkdownContent content={suggestedAnswer} className="mt-2 text-sm leading-6" />
|
|
518
519
|
</section>
|
|
519
520
|
)}
|
|
520
521
|
</div>
|
|
521
522
|
)}
|
|
522
523
|
|
|
523
|
-
<div className="mt-6 flex flex-wrap items-center justify-between gap-
|
|
524
|
+
<div className="mt-6 flex flex-wrap items-center justify-between gap-2 border-t pt-4">
|
|
524
525
|
<div className="flex gap-2">
|
|
525
526
|
<Button variant="outline" onClick={() => goToQuestion(Math.max(activeIndex - 1, 0))} disabled={activeIndex === 0}>
|
|
526
527
|
Previous
|
|
@@ -537,7 +538,7 @@ export function QuizPage() {
|
|
|
537
538
|
<Button variant="outline" onClick={() => updateQuestionStatus('unanswered')} disabled={session.completed}>
|
|
538
539
|
Skip
|
|
539
540
|
</Button>
|
|
540
|
-
<Button onClick={() => updateQuestionStatus('revisit')} disabled={session.completed} className="bg-
|
|
541
|
+
<Button onClick={() => updateQuestionStatus('revisit')} disabled={session.completed} variant="outline" className="border-warning/30 bg-warning/10 text-warning hover:bg-warning/20 hover:text-warning">
|
|
541
542
|
Revisit
|
|
542
543
|
</Button>
|
|
543
544
|
<Button onClick={() => updateQuestionStatus('complete')} disabled={session.completed}>
|