question-bank-framework 0.7.1 → 0.9.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.
Files changed (33) hide show
  1. package/README.md +22 -7
  2. package/framework/components/app-shell.tsx +40 -26
  3. package/framework/components/exam-timer.tsx +134 -0
  4. package/framework/components/filter-builder.tsx +65 -63
  5. package/framework/components/markdown-content.tsx +15 -15
  6. package/framework/components/question-bank.tsx +150 -227
  7. package/framework/components/question-response.tsx +41 -31
  8. package/framework/components/quiz-list.tsx +42 -37
  9. package/framework/components/quiz-page.tsx +63 -62
  10. package/framework/components/quiz-question-editor.tsx +11 -11
  11. package/framework/components/ui/badge.tsx +29 -23
  12. package/framework/components/ui/button.tsx +24 -28
  13. package/framework/components/ui/card.tsx +104 -0
  14. package/framework/components/ui/checkbox.tsx +9 -10
  15. package/framework/components/ui/dropdown-menu.tsx +139 -123
  16. package/framework/components/ui/field.tsx +238 -0
  17. package/framework/components/ui/input.tsx +6 -5
  18. package/framework/components/ui/kbd.tsx +28 -0
  19. package/framework/components/ui/label.tsx +19 -0
  20. package/framework/components/ui/popover.tsx +35 -32
  21. package/framework/components/ui/select.tsx +80 -65
  22. package/framework/components/ui/separator.tsx +24 -0
  23. package/framework/components/ui/sheet.tsx +36 -39
  24. package/framework/components/ui/table.tsx +4 -4
  25. package/framework/components/ui/toast.tsx +234 -0
  26. package/framework/components/ui/tooltip.tsx +65 -0
  27. package/framework/index.ts +4 -0
  28. package/framework/lib/exam-timer.ts +115 -0
  29. package/framework/styles/theme.css +66 -28
  30. package/package.json +4 -1
  31. package/template/src/routes/__root.tsx +1 -1
  32. package/template/src/routes/styles.css +0 -4
  33. package/template/src/routes/timer.tsx +4 -0
@@ -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-semibold text-gray-900">
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
- <input
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
- 'h-10 w-48 rounded-lg border bg-white px-3 font-mono text-base tabular-nums text-gray-900 outline-none placeholder:font-sans placeholder:text-gray-400 focus-visible:ring-2 focus-visible:ring-gray-900/20',
57
- checked && correct === true && 'border-green-600 bg-green-50',
58
- checked && correct === false && 'border-red-600 bg-red-50',
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-gray-600">{question.numeric.unit}</span>}
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-sm text-amber-800">
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 value={value ?? ''} onValueChange={onChange}>
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-9 w-full min-w-48 whitespace-normal bg-white text-left sm:w-72',
97
- state === 'correct' && 'border-green-600 bg-green-50',
98
- (state === 'incorrect' || state === 'missing') && 'border-red-600 bg-red-50',
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 position="popper" className="max-w-[min(36rem,calc(100vw-2rem))]">
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
- {lettered && <span className="font-semibold text-gray-500">{optionLetter(index)}.</span>}
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-semibold text-gray-900">
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-gray-500">
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 border-gray-200 bg-gray-50 p-3">
144
- <p className="m-0 text-xs font-semibold uppercase tracking-wide text-gray-500">Options</p>
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 text-gray-800">
148
- <span className="w-5 shrink-0 font-semibold text-gray-500">{optionLetter(index)}.</span>
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 === 'idle' && 'border-gray-200 bg-white',
173
- state === 'correct' && 'border-green-300 bg-green-50/50',
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-semibold tabular-nums text-gray-500">{index + 1}.</span>
179
- <label htmlFor={selectId} className="min-w-0 flex-1 overflow-x-auto overflow-y-hidden text-base leading-7 text-gray-900">
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-5 shrink-0 text-green-700" />}
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-5 shrink-0 text-red-700" />
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-sm text-red-900">
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-semibold">{optionLetter(correctIndex)}.</span>}
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 grid-cols-[minmax(0,1fr)] gap-8">
53
- <div className="flex flex-wrap items-end justify-between gap-4">
54
- <div>
55
- <h1 className="m-0 text-3xl font-bold tracking-tight text-gray-900">My quizzes</h1>
56
- <p className="mt-2 text-base text-gray-600">Pick up where you left off. Your answers are saved as you go.</p>
57
- </div>
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
- <div className="py-16 text-center text-sm text-gray-600">Loading your quizzes...</div>
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="mb-3 text-sm text-red-700">
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
- <div className="grid grid-cols-[minmax(0,1fr)] gap-4 sm:grid-cols-[repeat(2,minmax(0,1fr))]">
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
- <div key={quiz.id} className="group relative">
82
+ <li key={quiz.id} className="group relative">
83
83
  <Link
84
84
  to="/quiz/$quizId"
85
85
  params={{ quizId: quiz.id }}
86
- className="block rounded-xl border border-gray-200 bg-white p-5 transition-colors hover:border-gray-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500"
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
- <h2 className="m-0 break-words pr-16 text-lg font-semibold text-gray-900">{quiz.title}</h2>
89
- <p className="mt-2 text-sm text-gray-700">
90
- {quiz.questionIds.length} {quiz.questionIds.length === 1 ? 'question' : 'questions'}
91
- </p>
92
- <p className="mt-1 text-sm text-gray-600">
93
- {answeredCount} of {quiz.questionIds.length} answered
94
- {score.gradeable > 0 && ` • ${score.correct}/${score.gradeable} correct`}
95
- </p>
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 right-3 top-3 flex gap-1.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100">
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="outline"
101
- size="icon-xs"
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="outline"
112
- size="icon-xs"
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="rounded-full border-red-200 bg-white text-red-600 shadow-sm hover:border-red-300 hover:bg-red-50 hover:text-red-700"
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
- </div>
128
+ </li>
123
129
  );
124
130
  })}
125
- </div>
131
+ </ul>
126
132
  </div>
127
133
  ) : (
128
- <div className="mx-auto max-w-xl py-16 text-center">
129
- <h2 className="m-0 text-xl font-semibold text-gray-900">No quizzes yet</h2>
130
- <p className="mt-2 text-base text-gray-600">Choose questions from the bank and turn them into a quiz that remembers your progress.</p>
131
- <Button asChild className="mt-6">
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-gray-300 bg-gray-50 text-gray-700',
34
- revisit: 'border-amber-300 bg-amber-50 text-amber-900',
35
- complete: 'border-green-300 bg-green-50 text-green-800',
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-gray-300 bg-white text-gray-600 hover:border-gray-500',
40
- revisit: 'border-amber-400 bg-amber-100 text-amber-900 hover:border-amber-600',
41
- complete: 'border-green-500 bg-green-100 text-green-900 hover:border-green-700',
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-gray-600">Opening your quiz...</div>;
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-bold text-gray-900">Quiz not found</h1>
186
- <p className="mt-2 text-base text-gray-600">This practice set may have been removed from this browser.</p>
187
- <Button asChild className="mt-6">
188
- <Link to="/quizzes">Back to My quizzes</Link>
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-semibold text-gray-600 hover:text-gray-900">
212
- Back to My quizzes
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-auto min-w-0 border-gray-300 px-2 py-1 text-2xl font-bold tracking-tight md:text-3xl"
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-3xl font-bold tracking-tight text-gray-900">{session.title}</h1>
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-sm text-gray-600">{saving ? 'Saving...' : 'Saved'}</span>
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-base font-semibold text-gray-900">
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-green-300 bg-green-50 text-green-800">
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-md border text-xs font-semibold tabular-nums transition-colors',
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-gray-900 ring-offset-2',
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 border-gray-200 bg-gray-50 p-5">
299
- <h2 className="m-0 text-lg font-semibold text-gray-900">
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-gray-100"
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-bold',
328
- result === true && 'bg-green-600 text-white',
329
- result === false && 'bg-red-600 text-white',
330
- result === null && 'bg-gray-200 text-gray-700',
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 text-gray-900">{question.title}</span>
336
- <span className="shrink-0 text-xs text-gray-500">{resultLabel}</span>
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 border-gray-200 bg-white p-5">
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-xl font-semibold text-gray-900">{activeQuestion.title}</h2>
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-gray-500">{activeQuestion.id}</p>
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-base leading-relaxed text-gray-800" />
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-semibold text-gray-900">
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-gray-900 bg-gray-50',
381
- !showCorrectness && !selected && 'border-gray-300 bg-white hover:border-gray-500',
382
- showCorrectness && isCorrectOption && 'border-green-600 bg-green-50',
383
- showCorrectness && !isCorrectOption && selected && 'border-red-600 bg-red-50',
384
- showCorrectness && !isCorrectOption && !selected && 'border-gray-200 bg-white opacity-70',
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-gray-900"
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-bold',
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-green-600 bg-green-600 text-white'
401
+ ? 'border-success/30 bg-success/10 text-success'
401
402
  : showCorrectness && selected
402
- ? 'border-red-600 bg-red-600 text-white'
403
+ ? 'border-destructive/30 bg-destructive/10 text-destructive'
403
404
  : selected
404
- ? 'border-gray-900 bg-gray-900 text-white'
405
- : 'border-gray-300 bg-white text-gray-600',
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-base leading-7" />
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-green-600 bg-white text-green-800">
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-red-600 bg-white text-red-800">
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-semibold text-gray-900">
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-gray-300 bg-white p-3 text-base leading-relaxed text-gray-900 outline-none placeholder:text-gray-400"
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 p-4 text-sm font-medium',
464
- correctness ? 'border-green-300 bg-green-50 text-green-900' : 'border-red-300 bg-red-50 text-red-900',
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 border-gray-200 bg-gray-50 p-4">
514
- <h3 className="m-0 text-base font-semibold text-gray-900">
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-3 text-sm leading-relaxed text-gray-800" />
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-3">
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-amber-400 text-gray-950 hover:bg-amber-500">
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}>