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.
Files changed (32) hide show
  1. package/README.md +14 -8
  2. package/framework/components/app-shell.tsx +11 -7
  3. package/framework/components/exam-timer.tsx +64 -33
  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 +2 -2
  26. package/framework/components/ui/tooltip.tsx +65 -0
  27. package/framework/index.ts +1 -1
  28. package/framework/lib/exam-timer.ts +29 -10
  29. package/framework/styles/theme.css +66 -28
  30. package/package.json +3 -1
  31. package/template/src/routes/__root.tsx +1 -1
  32. package/template/src/routes/styles.css +0 -4
package/README.md CHANGED
@@ -26,19 +26,23 @@ Your app owns:
26
26
  - Thin TanStack Start routes in `src/routes/` (see `template/src/routes/`)
27
27
  wrapping `<BankProvider bank={bank}>` + `<AppShell brand>` + framework pages.
28
28
  - `src/routes/styles.css` (scaffolded by `qb init`, imported as `?url` from
29
- `__root.tsx`). It must list the framework as a Tailwind source — v4 ignores
30
- `node_modules` by default, so without this none of the grid/badge/filter
31
- utilities are generated and the app renders unstyled:
29
+ `__root.tsx`):
32
30
 
33
31
  ```css
34
32
  @import 'tailwindcss';
35
33
  @import 'question-bank-framework/framework/styles/theme.css';
36
- @source '../node_modules/question-bank-framework'; /* relative to this file */
37
34
  ```
38
35
 
39
- `theme.css` also pulls in the KaTeX stylesheet from the framework's own `katex`,
40
- matching the version that renders math. Don't import `katex/dist/katex.min.css`
41
- yourself — a mismatched KaTeX version breaks math layout (0.19 renamed its CSS classes).
36
+ `theme.css` is the design system: shadcn/ui neutral tokens, Inter Variable
37
+ (weight and optical-size axes, with `font-optical-sizing: auto`), tw-animate-css,
38
+ and the KaTeX stylesheet from the framework's own `katex`. It also registers the
39
+ framework's components as a Tailwind source, so you don't need an `@source` line.
40
+ Don't import `katex/dist/katex.min.css` yourself — a mismatched KaTeX version
41
+ breaks math layout (0.19 renamed its CSS classes).
42
+
43
+ UI components in `framework/components/ui/` are shadcn/ui on Base UI
44
+ (base-nova style). Style everything with Tailwind utilities and the theme tokens
45
+ (`bg-background`, `text-muted-foreground`, `border`, …), not raw colours.
42
46
 
43
47
  Text columns sort number-aware ("Q2" before "Q10"). New quizzes keep the
44
48
  table's current sort order, so to get source order add a numeric field (e.g.
@@ -56,7 +60,9 @@ time (e.g. 08:30) and reading/writing minutes, then press Start: the top right
56
60
  of the header shows the mock exam time, starting at 08:30, with a book icon
57
61
  during reading time and a pen during writing time. It updates once a minute.
58
62
  Toasts announce reading time over, 30 and 10 minutes remaining, and exam over;
59
- the clock disappears once the exam is over. The timer lives in localStorage,
63
+ the clock disappears once the exam is over. Pause freezes the clock (the
64
+ header shows a pause icon) and Resume carries on from the same mock time, so
65
+ breaks don't eat into the exam. The timer lives in localStorage,
60
66
  so it survives reloads and shows in every open tab.
61
67
 
62
68
  ## Sync (Cloudflare Durable Objects)
@@ -30,20 +30,24 @@ export function AppShell({
30
30
 
31
31
  return (
32
32
  <Toaster>
33
- <div className={cn('flex flex-col bg-white', isQuestionBank ? 'h-dvh overflow-hidden' : 'min-h-dvh')}>
34
- <header className="sticky top-0 z-20 shrink-0 border-b border-gray-200 bg-white">
35
- <div className="mx-auto flex h-16 w-full max-w-6xl items-center gap-8 px-4">
36
- <Link to="/" className="text-lg font-bold text-gray-900">
33
+ <div className={cn('flex flex-col bg-background', isQuestionBank ? 'h-dvh overflow-hidden' : 'min-h-dvh')}>
34
+ <header className="sticky top-0 z-20 shrink-0 border-b bg-background/80 backdrop-blur-sm">
35
+ <div className="flex h-14 items-center gap-6 px-6">
36
+ <Link to="/" className="text-sm font-semibold text-foreground">
37
37
  {brand}
38
38
  </Link>
39
- <nav className="flex items-center gap-6" aria-label="Main navigation">
39
+ <nav className="flex items-center gap-1" aria-label="Main navigation">
40
40
  {navigation.map(({ label, to }) => {
41
41
  const active = to === '/' ? pathname === '/' : pathname.startsWith(to);
42
42
  return (
43
43
  <Link
44
44
  key={to}
45
45
  to={to}
46
- className={cn('text-sm font-semibold text-gray-600 hover:text-gray-900', active && 'text-gray-900 underline underline-offset-4')}
46
+ aria-current={active ? 'page' : undefined}
47
+ className={cn(
48
+ 'rounded-md px-2.5 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none',
49
+ active && 'bg-muted text-foreground',
50
+ )}
47
51
  >
48
52
  {label}
49
53
  </Link>
@@ -55,7 +59,7 @@ export function AppShell({
55
59
  </div>
56
60
  </div>
57
61
  </header>
58
- <main className={cn('w-full flex-1', isQuestionBank ? 'flex min-h-0 flex-col overflow-hidden' : 'mx-auto max-w-6xl px-4 py-8')}>
62
+ <main className={cn('w-full flex-1', isQuestionBank ? 'flex min-h-0 flex-col overflow-hidden' : 'mx-auto max-w-5xl px-6 py-10')}>
59
63
  {children}
60
64
  </main>
61
65
  </div>
@@ -1,14 +1,27 @@
1
1
  // Mock-exam clock: <ExamTimerBadge> shows the mock time in the header while an
2
- // exam is running; <ExamTimerPage> (the /timer route) is the only place to start it.
2
+ // exam is running; <ExamTimerPage> (the /timer route) is the only place to start,
3
+ // pause, resume or stop it.
3
4
  // Framework-owned: eject with `qb reveal framework/components/exam-timer.tsx`.
4
5
 
5
6
  import { useEffect, useRef, useState, type FormEvent } from 'react';
6
- import { BookOpen, PenLine } from 'lucide-react';
7
+ import { BookOpen, Pause, PenLine } from 'lucide-react';
7
8
  import { useBank } from '../../core/config.tsx';
8
9
  import { Button } from './ui/button';
10
+ import { Card, CardContent, CardFooter } from './ui/card';
11
+ import { Field, FieldGroup, FieldLabel } from './ui/field';
9
12
  import { Input } from './ui/input';
10
13
  import { toast } from './ui/toast';
11
- import { examState, formatClock, MINUTE, setExamTimer, useExamNow, useExamTimer, type ExamTimer } from '../lib/exam-timer.ts';
14
+ import {
15
+ examState,
16
+ formatClock,
17
+ MINUTE,
18
+ pauseExamTimer,
19
+ resumeExamTimer,
20
+ setExamTimer,
21
+ useExamNow,
22
+ useExamTimer,
23
+ type ExamTimer,
24
+ } from '../lib/exam-timer.ts';
12
25
 
13
26
  /** Moments to announce, in minutes after Start. */
14
27
  const milestones = ({ readingMinutes: reading, writingMinutes: writing }: ExamTimer) => {
@@ -41,14 +54,15 @@ export function ExamTimerBadge() {
41
54
  }, [timer?.startedAt, state?.elapsed]);
42
55
 
43
56
  if (!state || state.phase === 'over') return null;
44
- const Icon = state.phase === 'reading' ? BookOpen : PenLine;
57
+ const Icon = state.paused ? Pause : state.phase === 'reading' ? BookOpen : PenLine;
58
+ const label = `${state.phase === 'reading' ? 'Reading time' : 'Writing time'}${state.paused ? ' (paused)' : ''}`;
45
59
  return (
46
60
  <span
47
- className="flex items-center gap-1.5 text-sm font-medium tabular-nums text-gray-700"
48
- title={state.phase === 'reading' ? 'Reading time' : 'Writing time'}
61
+ className={`flex items-center gap-1.5 text-sm font-medium tabular-nums ${state.paused ? 'text-muted-foreground' : 'text-foreground'}`}
62
+ title={label}
49
63
  >
50
- <Icon aria-hidden="true" className="size-4" />
51
- <span className="sr-only">{state.phase === 'reading' ? 'Reading time' : 'Writing time'}, mock exam time </span>
64
+ <Icon aria-hidden="true" className="size-4 text-muted-foreground" />
65
+ <span className="sr-only">{label}, mock exam time </span>
52
66
  {state.clock}
53
67
  </span>
54
68
  );
@@ -72,6 +86,7 @@ export function ExamTimerPage({
72
86
  const timer = useExamTimer(namespace);
73
87
  const now = useExamNow(timer);
74
88
  const running = timer !== null && examState(timer, now).phase !== 'over';
89
+ const paused = running && timer.pausedAt !== undefined;
75
90
  const [start, setStart] = useState(defaultStart);
76
91
  const [reading, setReading] = useState(String(defaultReadingMinutes));
77
92
  const [writing, setWriting] = useState(String(defaultWritingMinutes));
@@ -94,32 +109,48 @@ export function ExamTimerPage({
94
109
  });
95
110
  };
96
111
 
97
- const field = 'grid gap-1.5 text-sm font-medium text-gray-700';
98
112
  return (
99
- <div className="grid max-w-sm gap-6">
100
- <h1 className="m-0 text-3xl font-bold tracking-tight text-gray-900">Timer</h1>
101
- <form onSubmit={submit} className="grid gap-4">
102
- <label className={field}>
103
- Exam start time
104
- <Input type="time" required value={start} onChange={(event) => setStart(event.target.value)} />
105
- </label>
106
- <label className={field}>
107
- Reading minutes
108
- <Input type="number" min={0} required value={reading} onChange={(event) => setReading(event.target.value)} />
109
- </label>
110
- <label className={field}>
111
- Writing minutes
112
- <Input type="number" min={0} required value={writing} onChange={(event) => setWriting(event.target.value)} />
113
- </label>
114
- <div className="flex gap-2">
115
- <Button type="submit">{running ? 'Restart' : 'Start'}</Button>
116
- {running && (
117
- <Button type="button" variant="outline" onClick={() => setExamTimer(namespace, null)}>
118
- Stop
119
- </Button>
120
- )}
121
- </div>
122
- </form>
113
+ <div className="mx-auto grid w-full max-w-md gap-8">
114
+ <h1 className="text-2xl font-semibold">Timer</h1>
115
+ <Card>
116
+ <form onSubmit={submit} className="contents">
117
+ <CardContent>
118
+ <FieldGroup>
119
+ <Field>
120
+ <FieldLabel htmlFor="exam-start">Exam start time</FieldLabel>
121
+ <Input id="exam-start" type="time" required value={start} onChange={(event) => setStart(event.target.value)} />
122
+ </Field>
123
+ <div className="grid grid-cols-2 gap-4">
124
+ <Field>
125
+ <FieldLabel htmlFor="exam-reading">Reading minutes</FieldLabel>
126
+ <Input id="exam-reading" type="number" min={0} required inputMode="numeric" className="tabular-nums" value={reading} onChange={(event) => setReading(event.target.value)} />
127
+ </Field>
128
+ <Field>
129
+ <FieldLabel htmlFor="exam-writing">Writing minutes</FieldLabel>
130
+ <Input id="exam-writing" type="number" min={0} required inputMode="numeric" className="tabular-nums" value={writing} onChange={(event) => setWriting(event.target.value)} />
131
+ </Field>
132
+ </div>
133
+ </FieldGroup>
134
+ </CardContent>
135
+ <CardFooter className="gap-2">
136
+ <Button type="submit">{running ? 'Restart' : 'Start'}</Button>
137
+ {running && (
138
+ <Button
139
+ type="button"
140
+ variant="outline"
141
+ onClick={() => (paused ? resumeExamTimer(namespace, timer) : pauseExamTimer(namespace, timer))}
142
+ >
143
+ {paused ? 'Resume' : 'Pause'}
144
+ </Button>
145
+ )}
146
+ {running && (
147
+ <Button type="button" variant="ghost" onClick={() => setExamTimer(namespace, null)}>
148
+ Stop
149
+ </Button>
150
+ )}
151
+ </CardFooter>
152
+ </form>
153
+ </Card>
123
154
  </div>
124
155
  );
125
156
  }
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react'
2
- import { ChevronDown, Circle, Filter, Hash, Plus, Tags, Type, X } from 'lucide-react'
2
+ import { ChevronDown, Circle, Hash, ListFilter, Plus, Tags, Type, X, type LucideIcon } from 'lucide-react'
3
3
  import { Badge } from './ui/badge'
4
4
  import { Button } from './ui/button'
5
5
  import { Checkbox } from './ui/checkbox'
@@ -31,7 +31,7 @@ import {
31
31
  } from '../lib/question-filter.ts'
32
32
  import { cn } from '../lib/utils.ts'
33
33
 
34
- const fieldIcons: Record<FilterFieldType, typeof Filter> = {
34
+ const fieldIcons: Record<FilterFieldType, LucideIcon> = {
35
35
  text: Type,
36
36
  number: Hash,
37
37
  select: Circle,
@@ -70,26 +70,24 @@ export function FilterBuilder({
70
70
  if (!nextOpen) setAdding(false)
71
71
  }}
72
72
  >
73
- <PopoverTrigger asChild>
74
- <Button variant="outline" className="h-11 gap-2 text-base">
75
- <Filter className="size-4" />
76
- Filter
77
- {filters.length > 0 && (
78
- <Badge variant="secondary" className="h-5 min-w-5 rounded-full px-1.5 text-xs tabular-nums">
79
- {filters.length}
80
- </Badge>
81
- )}
82
- </Button>
73
+ <PopoverTrigger render={<Button variant="outline" />}>
74
+ <ListFilter aria-hidden="true" data-icon="inline-start" className="text-muted-foreground" />
75
+ Filter
76
+ {filters.length > 0 && (
77
+ <Badge variant="secondary" className="min-w-5 px-1.5 tabular-nums">
78
+ {filters.length}
79
+ </Badge>
80
+ )}
83
81
  </PopoverTrigger>
84
- <PopoverContent align="start" className="w-[min(780px,calc(100vw-2rem))] p-0">
85
- <div className="flex items-center justify-between gap-2 border-b px-3 py-2">
86
- <p className="text-sm font-semibold">Filters</p>
82
+ <PopoverContent align="start" className="w-[min(780px,calc(100vw-2rem))] gap-0 p-0">
83
+ <div className="flex h-10 items-center justify-between gap-2 border-b px-3">
84
+ <p className="text-sm font-medium">Filters</p>
87
85
  {filters.length > 0 && (
88
- <Button variant="ghost" size="sm" onClick={onClear}>Clear all</Button>
86
+ <Button variant="ghost" size="sm" className="text-muted-foreground" onClick={onClear}>Clear all</Button>
89
87
  )}
90
88
  </div>
91
89
 
92
- <div className="space-y-1 px-3 py-3">
90
+ <div className="flex flex-col p-2">
93
91
  {filters.length === 0 ? (
94
92
  <PropertyPicker fields={fields} onPick={onAdd} />
95
93
  ) : (
@@ -110,7 +108,7 @@ export function FilterBuilder({
110
108
  {filters.length > 0 && (
111
109
  <div className="border-t p-2">
112
110
  {adding ? (
113
- <div className="space-y-1">
111
+ <div className="flex flex-col gap-1">
114
112
  <PropertyPicker
115
113
  fields={fields}
116
114
  onPick={(field) => {
@@ -131,10 +129,10 @@ export function FilterBuilder({
131
129
  <Button
132
130
  variant="ghost"
133
131
  size="sm"
134
- className="w-full justify-start gap-2"
132
+ className="w-full justify-start text-muted-foreground"
135
133
  onClick={() => setAdding(true)}
136
134
  >
137
- <Plus className="size-4" />
135
+ <Plus aria-hidden="true" />
138
136
  Add filter
139
137
  </Button>
140
138
  )}
@@ -154,8 +152,8 @@ function PropertyPicker({
154
152
  }) {
155
153
  return (
156
154
  <div>
157
- <p className="px-2 pb-1 pt-1 text-xs font-medium text-muted-foreground">Filter by</p>
158
- <div className="grid gap-0.5">
155
+ <p className="px-2 py-1 text-xs font-medium text-muted-foreground">Filter by</p>
156
+ <div className="grid gap-px">
159
157
  {fields.map((field) => {
160
158
  const Icon = fieldIcons[field.type]
161
159
  return (
@@ -163,9 +161,9 @@ function PropertyPicker({
163
161
  key={field.id}
164
162
  type="button"
165
163
  onClick={() => onPick(field.id)}
166
- className="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent"
164
+ className="flex h-8 items-center gap-2 rounded-md px-2 text-left text-sm outline-none transition-colors hover:bg-muted/50 focus-visible:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50"
167
165
  >
168
- <Icon className="size-4 shrink-0 text-muted-foreground" />
166
+ <Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
169
167
  <span className="flex-1 truncate">{field.label}</span>
170
168
  <span className="text-xs text-muted-foreground">{fieldTypeLabels[field.type]}</span>
171
169
  </button>
@@ -185,28 +183,31 @@ function MatchToggle({
185
183
  }) {
186
184
  return (
187
185
  <DropdownMenu>
188
- <DropdownMenuTrigger asChild>
189
- <button
190
- type="button"
191
- aria-label={`Match ${match === 'and' ? 'all' : 'any'} filters`}
192
- className="my-1 ml-2 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
193
- >
194
- {match === 'and' ? 'And' : 'Or'}
195
- <ChevronDown className="size-3" />
196
- </button>
186
+ <DropdownMenuTrigger
187
+ render={
188
+ <Button
189
+ variant="ghost"
190
+ size="xs"
191
+ aria-label={`Match ${match === 'and' ? 'all' : 'any'} filters`}
192
+ className="my-1 ml-1 text-muted-foreground"
193
+ />
194
+ }
195
+ >
196
+ {match === 'and' ? 'And' : 'Or'}
197
+ <ChevronDown aria-hidden="true" data-icon="inline-end" />
197
198
  </DropdownMenuTrigger>
198
- <DropdownMenuContent align="start" className="w-72">
199
+ <DropdownMenuContent align="start" className="w-48">
199
200
  <DropdownMenuRadioGroup
200
201
  value={match}
201
202
  onValueChange={(value) => onChange(value as FilterMatch)}
202
203
  >
203
204
  <DropdownMenuRadioItem value="and">
204
205
  And
205
- <span className="text-muted-foreground">— all filters must match</span>
206
+ <span className="ml-auto text-xs text-muted-foreground">all must match</span>
206
207
  </DropdownMenuRadioItem>
207
208
  <DropdownMenuRadioItem value="or">
208
209
  Or
209
- <span className="text-muted-foreground">— at least one must match</span>
210
+ <span className="ml-auto text-xs text-muted-foreground">any can match</span>
210
211
  </DropdownMenuRadioItem>
211
212
  </DropdownMenuRadioGroup>
212
213
  </DropdownMenuContent>
@@ -231,10 +232,12 @@ function FilterRuleRow({
231
232
  const needsValue = rule.operator !== 'isEmpty' && rule.operator !== 'isNotEmpty'
232
233
 
233
234
  return (
234
- <div className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 p-2">
235
+ <div className="flex flex-wrap items-center gap-2 rounded-lg border p-2">
235
236
  <Select
236
237
  value={rule.field}
238
+ items={fields.map((candidate) => ({ value: candidate.id, label: candidate.label }))}
237
239
  onValueChange={(value) => {
240
+ if (value === null) return
238
241
  const nextField = fields.find((candidate) => candidate.id === value)
239
242
  if (!nextField) return
240
243
  onChange(rule.id, {
@@ -244,7 +247,7 @@ function FilterRuleRow({
244
247
  })
245
248
  }}
246
249
  >
247
- <SelectTrigger size="sm" aria-label="Filter field" className="w-[132px]">
250
+ <SelectTrigger aria-label="Filter field" className="w-32">
248
251
  <SelectValue />
249
252
  </SelectTrigger>
250
253
  <SelectContent>
@@ -256,14 +259,16 @@ function FilterRuleRow({
256
259
 
257
260
  <Select
258
261
  value={rule.operator}
259
- onValueChange={(value) =>
262
+ items={field.operators.map((operator) => ({ value: operator, label: operatorLabels[operator] }))}
263
+ onValueChange={(value) => {
264
+ if (value === null) return
260
265
  onChange(rule.id, {
261
266
  operator: value as FilterOperatorId,
262
267
  value: value === 'between' ? [] : rule.value,
263
268
  })
264
- }
269
+ }}
265
270
  >
266
- <SelectTrigger size="sm" aria-label="Filter operator" className="w-[172px]">
271
+ <SelectTrigger aria-label="Filter operator" className="w-44">
267
272
  <SelectValue />
268
273
  </SelectTrigger>
269
274
  <SelectContent>
@@ -273,7 +278,7 @@ function FilterRuleRow({
273
278
  </SelectContent>
274
279
  </Select>
275
280
 
276
- <div className="min-w-[140px] flex-1">
281
+ <div className="min-w-36 flex-1">
277
282
  {needsValue ? (
278
283
  <FilterValueEditor
279
284
  field={field}
@@ -287,11 +292,12 @@ function FilterRuleRow({
287
292
 
288
293
  <Button
289
294
  variant="ghost"
290
- size="icon-sm"
295
+ size="icon"
291
296
  aria-label="Remove filter"
297
+ className="text-muted-foreground"
292
298
  onClick={() => onRemove(rule.id)}
293
299
  >
294
- <X className="size-4" />
300
+ <X aria-hidden="true" />
295
301
  </Button>
296
302
  </div>
297
303
  )
@@ -315,7 +321,6 @@ function FilterValueEditor({
315
321
  inputMode="decimal"
316
322
  aria-label="Minimum value"
317
323
  placeholder="Min"
318
- className="h-8"
319
324
  value={rule.value[0] ?? ''}
320
325
  onChange={(event) => onChange([event.target.value, rule.value[1] ?? ''])}
321
326
  />
@@ -325,7 +330,6 @@ function FilterValueEditor({
325
330
  inputMode="decimal"
326
331
  aria-label="Maximum value"
327
332
  placeholder="Max"
328
- className="h-8"
329
333
  value={rule.value[1] ?? ''}
330
334
  onChange={(event) => onChange([rule.value[0] ?? '', event.target.value])}
331
335
  />
@@ -339,7 +343,6 @@ function FilterValueEditor({
339
343
  inputMode="decimal"
340
344
  aria-label={`${field.label} value`}
341
345
  placeholder={field.placeholder ?? 'Value'}
342
- className="h-8"
343
346
  value={rule.value[0] ?? ''}
344
347
  onChange={(event) => onChange([event.target.value])}
345
348
  />
@@ -351,7 +354,6 @@ function FilterValueEditor({
351
354
  <Input
352
355
  aria-label={`${field.label} value`}
353
356
  placeholder={field.placeholder ?? 'Type a value…'}
354
- className="h-8"
355
357
  value={rule.value[0] ?? ''}
356
358
  onChange={(event) => onChange([event.target.value])}
357
359
  />
@@ -403,20 +405,21 @@ function ValuePicker({
403
405
  if (!nextOpen) setQuery('')
404
406
  }}
405
407
  >
406
- <PopoverTrigger asChild>
407
- <Button
408
- variant="outline"
409
- size="sm"
410
- className={cn(
411
- 'h-8 w-full justify-between font-normal',
412
- selected.length === 0 && 'text-muted-foreground',
413
- )}
414
- >
415
- <span className="truncate">{label}</span>
416
- <ChevronDown className="size-4 shrink-0 opacity-60" />
417
- </Button>
408
+ <PopoverTrigger
409
+ render={
410
+ <Button
411
+ variant="outline"
412
+ className={cn(
413
+ 'w-full justify-between border-input font-normal',
414
+ selected.length === 0 && 'text-muted-foreground',
415
+ )}
416
+ />
417
+ }
418
+ >
419
+ <span className="truncate">{label}</span>
420
+ <ChevronDown aria-hidden="true" className="text-muted-foreground" />
418
421
  </PopoverTrigger>
419
- <PopoverContent align="start" className="w-64 p-0">
422
+ <PopoverContent align="start" className="w-64 gap-0 p-0">
420
423
  {showSearch && (
421
424
  <div className="border-b p-2">
422
425
  <Input
@@ -425,7 +428,6 @@ function ValuePicker({
425
428
  onChange={(event) => setQuery(event.target.value)}
426
429
  placeholder="Search values…"
427
430
  aria-label={`Search ${field.label} values`}
428
- className="h-8"
429
431
  />
430
432
  </div>
431
433
  )}
@@ -438,7 +440,7 @@ function ValuePicker({
438
440
  return (
439
441
  <div
440
442
  key={option.value}
441
- className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
443
+ className="flex h-8 cursor-pointer items-center gap-2 rounded-md px-2 text-sm hover:bg-muted/50"
442
444
  onClick={() => toggle(option.value)}
443
445
  >
444
446
  <Checkbox
@@ -25,23 +25,23 @@ function MathFormula({ tex, display }: { tex: string; display: boolean }) {
25
25
 
26
26
  // Every renderer drops react-markdown's `node` prop so it isn't forwarded to the DOM.
27
27
  const components: Components = {
28
- a: ({ node: _node, ...props }) => <a className="font-medium text-green-800 underline decoration-green-300 underline-offset-2 hover:text-green-950" target="_blank" rel="noreferrer" {...props} />,
29
- blockquote: ({ node: _node, ...props }) => <blockquote className="my-4 border-l-2 border-gray-300 pl-4 text-gray-700" {...props} />,
28
+ a: ({ node: _node, ...props }) => <a className="font-medium text-foreground underline decoration-muted-foreground/40 underline-offset-2 hover:decoration-foreground" target="_blank" rel="noreferrer" {...props} />,
29
+ blockquote: ({ node: _node, ...props }) => <blockquote className="my-4 border-l-2 pl-4 text-muted-foreground" {...props} />,
30
30
  code: ({ node: _node, className, children, ...props }) => {
31
31
  if (hasClass(className, 'language-math')) {
32
32
  return <MathFormula tex={textOf(children)} display={hasClass(className, 'math-display')} />
33
33
  }
34
34
  // Inline-code styling; fenced blocks reset it via the `pre` renderer below.
35
- return <code className={cn('rounded bg-gray-100 px-1 py-0.5 font-mono text-[0.9em] text-gray-900 break-words', className)} {...props}>{children}</code>
35
+ return <code className={cn('rounded bg-muted px-1 py-0.5 font-mono text-[0.875em] break-words', className)} {...props}>{children}</code>
36
36
  },
37
- h1: ({ node: _node, ...props }) => <h1 className="mb-3 mt-6 text-2xl font-bold tracking-tight text-gray-950 first:mt-0" {...props} />,
38
- h2: ({ node: _node, ...props }) => <h2 className="mb-3 mt-6 text-xl font-bold tracking-tight text-gray-950 first:mt-0" {...props} />,
39
- h3: ({ node: _node, ...props }) => <h3 className="mb-2 mt-5 text-lg font-semibold text-gray-950 first:mt-0" {...props} />,
40
- hr: () => <hr className="my-5 border-gray-200" />,
41
- img: ({ node: _node, ...props }) => <img className="my-4 h-auto max-w-full rounded-md border border-gray-200" loading="lazy" {...props} />,
37
+ h1: ({ node: _node, ...props }) => <h1 className="mb-3 mt-6 text-xl font-semibold first:mt-0" {...props} />,
38
+ h2: ({ node: _node, ...props }) => <h2 className="mb-3 mt-6 text-lg font-semibold first:mt-0" {...props} />,
39
+ h3: ({ node: _node, ...props }) => <h3 className="mb-2 mt-5 text-base font-semibold first:mt-0" {...props} />,
40
+ hr: () => <hr className="my-5" />,
41
+ img: ({ node: _node, ...props }) => <img className="my-4 h-auto max-w-full rounded-lg border" loading="lazy" {...props} />,
42
42
  li: ({ node: _node, ...props }) => <li className="my-1 pl-1" {...props} />,
43
43
  ol: ({ node: _node, ...props }) => <ol className="my-3 list-decimal space-y-1 pl-6" {...props} />,
44
- p: ({ node: _node, ...props }) => <p className="my-3 whitespace-pre-wrap leading-7 first:mt-0 last:mb-0" {...props} />,
44
+ p: ({ node: _node, ...props }) => <p className="my-3 whitespace-pre-wrap first:mt-0 last:mb-0" {...props} />,
45
45
  pre: ({ node, children, ...props }) => {
46
46
  // remark-math emits display math as <pre><code class="language-math math-display">.
47
47
  const first = node?.children.find((child) => child.type === 'element')
@@ -50,17 +50,17 @@ const components: Components = {
50
50
  }
51
51
  return (
52
52
  <pre
53
- className="my-4 overflow-x-auto rounded-lg bg-gray-950 p-4 text-gray-50 [&_code]:rounded-none [&_code]:bg-transparent [&_code]:p-0 [&_code]:text-sm [&_code]:text-inherit [&_code]:break-normal"
53
+ className="my-4 overflow-x-auto rounded-lg bg-muted p-4 font-mono text-xs leading-5 [&_code]:rounded-none [&_code]:bg-transparent [&_code]:p-0 [&_code]:text-xs [&_code]:break-normal"
54
54
  {...props}
55
55
  >
56
56
  {children}
57
57
  </pre>
58
58
  )
59
59
  },
60
- table: ({ node: _node, children, ...props }) => <div className="my-4 overflow-x-auto"><table className="w-full border-collapse text-left text-sm" {...props}>{children}</table></div>,
61
- tbody: ({ node: _node, ...props }) => <tbody className="divide-y divide-gray-200" {...props} />,
62
- td: ({ node: _node, ...props }) => <td className="border border-gray-200 px-3 py-2 align-top" {...props} />,
63
- th: ({ node: _node, ...props }) => <th className="border border-gray-200 bg-gray-50 px-3 py-2 text-left font-semibold" {...props} />,
60
+ table: ({ node: _node, children, ...props }) => <div className="my-4 overflow-x-auto rounded-lg border"><table className="w-full border-collapse text-left text-sm [&_tr>*:first-child]:border-l-0 [&_tr>*:last-child]:border-r-0 [&_tr:first-child>*]:border-t-0 [&_tbody_tr:last-child>*]:border-b-0" {...props}>{children}</table></div>,
61
+ tbody: ({ node: _node, ...props }) => <tbody className="divide-y" {...props} />,
62
+ td: ({ node: _node, ...props }) => <td className="border px-3 py-2 align-top" {...props} />,
63
+ th: ({ node: _node, ...props }) => <th className="border bg-muted/50 px-3 py-2 text-left font-medium" {...props} />,
64
64
  ul: ({ node: _node, ...props }) => <ul className="my-3 list-disc space-y-1 pl-6" {...props} />,
65
65
  }
66
66
 
@@ -153,5 +153,5 @@ export function MarkdownContent({ content, className, inline = false }: { conten
153
153
  </ReactMarkdown>
154
154
  )
155
155
  if (inline) return <span className={cn('min-w-0', className)}>{markdown}</span>
156
- return <div className={cn(blockClassName, 'text-base leading-7 text-gray-800', className)}>{markdown}</div>
156
+ return <div className={cn(blockClassName, 'text-[15px] leading-7 text-foreground', className)}>{markdown}</div>
157
157
  }