question-bank-framework 0.7.0 → 0.8.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 CHANGED
@@ -36,6 +36,10 @@ Your app owns:
36
36
  @source '../node_modules/question-bank-framework'; /* relative to this file */
37
37
  ```
38
38
 
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).
42
+
39
43
  Text columns sort number-aware ("Q2" before "Q10"). New quizzes keep the
40
44
  table's current sort order, so to get source order add a numeric field (e.g.
41
45
  `order`) and sort by it, or set it in `defaultSorting`.
@@ -44,6 +48,17 @@ Pass `hideTagsColumn: true` to `defineBank` when tags merely duplicate
44
48
  dedicated fields (every tag equals a topic/type value already in its own
45
49
  column). Tags still feed free-text search.
46
50
 
51
+ ## Mock-exam timer
52
+
53
+ The header has a **Timer** link to `/timer` (add `src/routes/timer.tsx`
54
+ rendering `<ExamTimerPage />`, as in the template). Enter the exam's start
55
+ time (e.g. 08:30) and reading/writing minutes, then press Start: the top right
56
+ of the header shows the mock exam time, starting at 08:30, with a book icon
57
+ during reading time and a pen during writing time. It updates once a minute.
58
+ 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,
60
+ so it survives reloads and shows in every open tab.
61
+
47
62
  ## Sync (Cloudflare Durable Objects)
48
63
 
49
64
  Without `sync`, quizzes and question statuses live only in the browser's
@@ -4,51 +4,61 @@
4
4
  import { Link, useRouterState } from '@tanstack/react-router';
5
5
  import type { ReactNode } from 'react';
6
6
  import { cn } from '../lib/utils.ts';
7
+ import { ExamTimerBadge } from './exam-timer.tsx';
8
+ import { Toaster } from './ui/toast';
7
9
 
8
10
  export function AppShell({
9
11
  children,
10
12
  brand,
11
13
  homeLabel = 'Question bank',
12
14
  quizzesLabel = 'My quizzes',
15
+ timerLabel = 'Timer',
13
16
  }: {
14
17
  children: ReactNode;
15
18
  brand: string;
16
19
  homeLabel?: string;
17
20
  quizzesLabel?: string;
21
+ timerLabel?: string;
18
22
  }) {
19
23
  const pathname = useRouterState({ select: (state) => state.location.pathname });
20
24
  const navigation = [
21
25
  { label: homeLabel, to: '/' },
22
26
  { label: quizzesLabel, to: '/quizzes' },
27
+ { label: timerLabel, to: '/timer' },
23
28
  ] as const;
24
29
  const isQuestionBank = pathname === '/';
25
30
 
26
31
  return (
27
- <div className={cn('flex flex-col bg-white', isQuestionBank ? 'h-dvh overflow-hidden' : 'min-h-dvh')}>
28
- <header className="sticky top-0 z-20 shrink-0 border-b border-gray-200 bg-white">
29
- <div className="mx-auto flex h-16 w-full max-w-6xl items-center gap-8 px-4">
30
- <Link to="/" className="text-lg font-bold text-gray-900">
31
- {brand}
32
- </Link>
33
- <nav className="flex items-center gap-6" aria-label="Main navigation">
34
- {navigation.map(({ label, to }) => {
35
- const active = to === '/' ? pathname === '/' : pathname.startsWith(to);
36
- return (
37
- <Link
38
- key={to}
39
- to={to}
40
- className={cn('text-sm font-semibold text-gray-600 hover:text-gray-900', active && 'text-gray-900 underline underline-offset-4')}
41
- >
42
- {label}
43
- </Link>
44
- );
45
- })}
46
- </nav>
47
- </div>
48
- </header>
49
- <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')}>
50
- {children}
51
- </main>
52
- </div>
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">
37
+ {brand}
38
+ </Link>
39
+ <nav className="flex items-center gap-6" aria-label="Main navigation">
40
+ {navigation.map(({ label, to }) => {
41
+ const active = to === '/' ? pathname === '/' : pathname.startsWith(to);
42
+ return (
43
+ <Link
44
+ key={to}
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')}
47
+ >
48
+ {label}
49
+ </Link>
50
+ );
51
+ })}
52
+ </nav>
53
+ <div className="ml-auto">
54
+ <ExamTimerBadge />
55
+ </div>
56
+ </div>
57
+ </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')}>
59
+ {children}
60
+ </main>
61
+ </div>
62
+ </Toaster>
53
63
  );
54
64
  }
@@ -0,0 +1,125 @@
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.
3
+ // Framework-owned: eject with `qb reveal framework/components/exam-timer.tsx`.
4
+
5
+ import { useEffect, useRef, useState, type FormEvent } from 'react';
6
+ import { BookOpen, PenLine } from 'lucide-react';
7
+ import { useBank } from '../../core/config.tsx';
8
+ import { Button } from './ui/button';
9
+ import { Input } from './ui/input';
10
+ import { toast } from './ui/toast';
11
+ import { examState, formatClock, MINUTE, setExamTimer, useExamNow, useExamTimer, type ExamTimer } from '../lib/exam-timer.ts';
12
+
13
+ /** Moments to announce, in minutes after Start. */
14
+ const milestones = ({ readingMinutes: reading, writingMinutes: writing }: ExamTimer) => {
15
+ const end = reading + writing;
16
+ return [
17
+ ...(reading > 0 ? [{ at: reading, title: 'Reading time over' }] : []),
18
+ ...(writing > 30 ? [{ at: end - 30, title: '30 minutes remaining' }] : []),
19
+ ...(writing > 10 ? [{ at: end - 10, title: '10 minutes remaining' }] : []),
20
+ { at: end, title: 'Exam over' },
21
+ ];
22
+ };
23
+
24
+ export function ExamTimerBadge() {
25
+ const { namespace } = useBank();
26
+ const timer = useExamTimer(namespace);
27
+ const now = useExamNow(timer);
28
+ const state = timer ? examState(timer, now) : null;
29
+
30
+ // Toast each milestone crossed since the last tick (not ones already past on load).
31
+ const last = useRef<{ startedAt: number; elapsed: number } | null>(null);
32
+ useEffect(() => {
33
+ if (!timer || !state) return;
34
+ const previous = last.current;
35
+ if (previous?.startedAt === timer.startedAt) {
36
+ for (const { at, title } of milestones(timer)) {
37
+ if (previous.elapsed < at * MINUTE && at * MINUTE <= state.elapsed) toast.add({ title });
38
+ }
39
+ }
40
+ last.current = { startedAt: timer.startedAt, elapsed: state.elapsed };
41
+ }, [timer?.startedAt, state?.elapsed]);
42
+
43
+ if (!state || state.phase === 'over') return null;
44
+ const Icon = state.phase === 'reading' ? BookOpen : PenLine;
45
+ return (
46
+ <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'}
49
+ >
50
+ <Icon aria-hidden="true" className="size-4" />
51
+ <span className="sr-only">{state.phase === 'reading' ? 'Reading time' : 'Writing time'}, mock exam time </span>
52
+ {state.clock}
53
+ </span>
54
+ );
55
+ }
56
+
57
+ const parseClock = (time: string) => {
58
+ const [hours, minutes] = time.split(':').map(Number);
59
+ return (hours ?? 0) * 60 + (minutes ?? 0);
60
+ };
61
+
62
+ export function ExamTimerPage({
63
+ defaultStart = '08:30',
64
+ defaultReadingMinutes = 15,
65
+ defaultWritingMinutes = 180,
66
+ }: {
67
+ defaultStart?: string;
68
+ defaultReadingMinutes?: number;
69
+ defaultWritingMinutes?: number;
70
+ }) {
71
+ const { namespace } = useBank();
72
+ const timer = useExamTimer(namespace);
73
+ const now = useExamNow(timer);
74
+ const running = timer !== null && examState(timer, now).phase !== 'over';
75
+ const [start, setStart] = useState(defaultStart);
76
+ const [reading, setReading] = useState(String(defaultReadingMinutes));
77
+ const [writing, setWriting] = useState(String(defaultWritingMinutes));
78
+
79
+ // Show the saved settings once they're read (after hydration, or when another tab changes them).
80
+ useEffect(() => {
81
+ if (!timer) return;
82
+ setStart(formatClock(timer.startMinutes));
83
+ setReading(String(timer.readingMinutes));
84
+ setWriting(String(timer.writingMinutes));
85
+ }, [timer?.startedAt]);
86
+
87
+ const submit = (event: FormEvent) => {
88
+ event.preventDefault();
89
+ setExamTimer(namespace, {
90
+ startMinutes: parseClock(start),
91
+ startedAt: Date.now(),
92
+ readingMinutes: Math.max(0, Number(reading) || 0),
93
+ writingMinutes: Math.max(0, Number(writing) || 0),
94
+ });
95
+ };
96
+
97
+ const field = 'grid gap-1.5 text-sm font-medium text-gray-700';
98
+ 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>
123
+ </div>
124
+ );
125
+ }
@@ -1,30 +1,67 @@
1
+ import katex from 'katex'
2
+ import type { ReactNode } from 'react'
1
3
  import ReactMarkdown, { type Components } from 'react-markdown'
2
- import rehypeKatex from 'rehype-katex'
3
4
  import remarkGfm from 'remark-gfm'
4
5
  import remarkMath from 'remark-math'
5
6
  import { cn } from '../lib/utils.ts'
6
7
 
8
+ // Math is rendered with the framework's own `katex` dependency rather than
9
+ // rehype-katex, which pins katex@^0.16. KaTeX 0.19 renamed its CSS classes
10
+ // (`.base` → `.katex-base`, …), so 0.16 markup styled by 0.19 CSS loses its
11
+ // layout (subscripts wrap and overlap). theme.css imports the CSS from the
12
+ // same katex package, so markup and stylesheet always agree.
13
+ const textOf = (children: ReactNode): string =>
14
+ Array.isArray(children) ? children.map(textOf).join('') : typeof children === 'string' || typeof children === 'number' ? String(children) : ''
15
+
16
+ const hasClass = (className: unknown, name: string) =>
17
+ (Array.isArray(className) ? className.map(String) : String(className ?? '').split(/\s+/)).includes(name)
18
+
19
+ function MathFormula({ tex, display }: { tex: string; display: boolean }) {
20
+ const html = katex.renderToString(tex, { displayMode: display, throwOnError: false, strict: 'ignore' })
21
+ return display
22
+ ? <div dangerouslySetInnerHTML={{ __html: html }} />
23
+ : <span dangerouslySetInnerHTML={{ __html: html }} />
24
+ }
25
+
26
+ // Every renderer drops react-markdown's `node` prop so it isn't forwarded to the DOM.
7
27
  const components: Components = {
8
- a: ({ ...props }) => <a className="font-medium text-green-800 underline decoration-green-300 underline-offset-2 hover:text-green-950" target="_blank" rel="noreferrer" {...props} />,
9
- blockquote: ({ ...props }) => <blockquote className="my-4 border-l-2 border-gray-300 pl-4 text-gray-700" {...props} />,
10
- code: ({ className, children, ...props }) => {
11
- const isBlock = className?.startsWith('language-') ?? false
12
- return <code className={cn(isBlock ? 'font-mono text-sm' : 'rounded bg-gray-100 px-1 py-0.5 font-mono text-[0.9em] text-gray-900 break-words', className)} {...props}>{children}</code>
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} />,
30
+ code: ({ node: _node, className, children, ...props }) => {
31
+ if (hasClass(className, 'language-math')) {
32
+ return <MathFormula tex={textOf(children)} display={hasClass(className, 'math-display')} />
33
+ }
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>
13
36
  },
14
- h1: ({ ...props }) => <h1 className="mb-3 mt-6 text-2xl font-bold tracking-tight text-gray-950 first:mt-0" {...props} />,
15
- h2: ({ ...props }) => <h2 className="mb-3 mt-6 text-xl font-bold tracking-tight text-gray-950 first:mt-0" {...props} />,
16
- h3: ({ ...props }) => <h3 className="mb-2 mt-5 text-lg font-semibold text-gray-950 first:mt-0" {...props} />,
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} />,
17
40
  hr: () => <hr className="my-5 border-gray-200" />,
18
- img: ({ ...props }) => <img className="my-4 h-auto max-w-full rounded-md border border-gray-200" loading="lazy" {...props} />,
19
- li: ({ ...props }) => <li className="my-1 pl-1" {...props} />,
20
- ol: ({ ...props }) => <ol className="my-3 list-decimal space-y-1 pl-6" {...props} />,
21
- p: ({ ...props }) => <p className="my-3 whitespace-pre-wrap leading-7 first:mt-0 last:mb-0" {...props} />,
22
- pre: ({ children, ...props }) => <pre className="my-4 overflow-x-auto rounded-lg bg-gray-950 p-4 text-gray-50" {...props}>{children}</pre>,
23
- table: ({ children, ...props }) => <div className="my-4 overflow-x-auto"><table className="w-full border-collapse text-left text-sm" {...props}>{children}</table></div>,
24
- tbody: ({ ...props }) => <tbody className="divide-y divide-gray-200" {...props} />,
25
- td: ({ ...props }) => <td className="border border-gray-200 px-3 py-2 align-top" {...props} />,
26
- th: ({ ...props }) => <th className="border border-gray-200 bg-gray-50 px-3 py-2 text-left font-semibold" {...props} />,
27
- ul: ({ ...props }) => <ul className="my-3 list-disc space-y-1 pl-6" {...props} />,
41
+ img: ({ node: _node, ...props }) => <img className="my-4 h-auto max-w-full rounded-md border border-gray-200" loading="lazy" {...props} />,
42
+ li: ({ node: _node, ...props }) => <li className="my-1 pl-1" {...props} />,
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} />,
45
+ pre: ({ node, children, ...props }) => {
46
+ // remark-math emits display math as <pre><code class="language-math math-display">.
47
+ const first = node?.children.find((child) => child.type === 'element')
48
+ if (first?.type === 'element' && hasClass(first.properties.className, 'math-display')) {
49
+ return <div className="my-4 overflow-x-auto overflow-y-hidden py-1">{children}</div>
50
+ }
51
+ return (
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"
54
+ {...props}
55
+ >
56
+ {children}
57
+ </pre>
58
+ )
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} />,
64
+ ul: ({ node: _node, ...props }) => <ul className="my-3 list-disc space-y-1 pl-6" {...props} />,
28
65
  }
29
66
 
30
67
  const markdownCode = /```[\s\S]*?```|~~~[\s\S]*?~~~|(`+)(?!`)[\s\S]*?\1(?!`)/g
@@ -102,16 +139,16 @@ const normalizeGfmTables = (content: string) => {
102
139
  // blank labels) can sit inside buttons, select triggers and flex rows.
103
140
  const inlineComponents: Components = {
104
141
  ...components,
105
- p: ({ ...props }) => <span className="whitespace-pre-wrap" {...props} />,
142
+ p: ({ node: _node, ...props }) => <span className="whitespace-pre-wrap" {...props} />,
106
143
  }
107
144
 
108
145
  // min-w-0 + break-words keep long tokens inside the column; display math
109
- // and wide tables/code scroll horizontally instead of widening the page.
110
- const blockClassName = 'min-w-0 max-w-full break-words [&_.katex-display]:overflow-x-auto [&_.katex-display]:overflow-y-hidden [&_.katex-display]:py-1'
146
+ // and wide tables/code scroll horizontally (see the `pre`/`table` renderers).
147
+ const blockClassName = 'min-w-0 max-w-full break-words'
111
148
 
112
149
  export function MarkdownContent({ content, className, inline = false }: { content: string; className?: string; inline?: boolean }) {
113
150
  const markdown = (
114
- <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={inline ? inlineComponents : components}>
151
+ <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} components={inline ? inlineComponents : components}>
115
152
  {normalizeGfmTables(normalizeMathDelimiters(content))}
116
153
  </ReactMarkdown>
117
154
  )
@@ -166,19 +166,21 @@ export function PairingResponse({
166
166
  <li
167
167
  key={slot.id}
168
168
  className={cn(
169
- 'flex flex-col gap-2 rounded-lg border p-3 sm:flex-row sm:items-center',
169
+ // Wraps instead of squeezing: short items sit beside their select, while
170
+ // wide items (long formulas) take the full row and the select drops below.
171
+ 'flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border p-3',
170
172
  state === 'idle' && 'border-gray-200 bg-white',
171
173
  state === 'correct' && 'border-green-300 bg-green-50/50',
172
174
  (state === 'incorrect' || state === 'missing') && 'border-red-300 bg-red-50/50',
173
175
  )}
174
176
  >
175
- <div className="flex min-w-0 flex-1 items-start gap-2">
177
+ <div className="flex min-w-0 max-w-full flex-[1_1_auto] items-start gap-2">
176
178
  <span className="mt-0.5 w-6 shrink-0 text-sm font-semibold tabular-nums text-gray-500">{index + 1}.</span>
177
- <label htmlFor={selectId} className="min-w-0 flex-1 overflow-x-auto text-base leading-7 text-gray-900">
179
+ <label htmlFor={selectId} className="min-w-0 flex-1 overflow-x-auto overflow-y-hidden text-base leading-7 text-gray-900">
178
180
  <MarkdownContent inline content={label} />
179
181
  </label>
180
182
  </div>
181
- <div className="flex shrink-0 flex-col gap-1 pl-8 sm:pl-0">
183
+ <div className="ml-auto flex shrink-0 flex-col gap-1 pl-8 sm:pl-0">
182
184
  <div className="flex items-center gap-2">
183
185
  <OptionSelect
184
186
  id={selectId}
@@ -0,0 +1,234 @@
1
+ // shadcn/ui Toast (Base UI, base-nova style). Framework-owned: eject with `qb reveal framework/components/ui/toast.tsx`.
2
+
3
+ import * as React from "react"
4
+ import { Toast as ToastPrimitive } from "@base-ui/react/toast"
5
+ import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, XIcon } from "lucide-react"
6
+ import { cn } from "../../lib/utils.ts"
7
+
8
+ import { Button } from "./button"
9
+
10
+ const toast = ToastPrimitive.createToastManager()
11
+
12
+ function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) {
13
+ return <ToastPrimitive.Provider {...props} />
14
+ }
15
+
16
+ function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) {
17
+ return <ToastPrimitive.Portal data-slot="toast-portal" {...props} />
18
+ }
19
+
20
+ function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
21
+ return (
22
+ <ToastPrimitive.Viewport
23
+ data-slot="toast-viewport"
24
+ className={cn(
25
+ "pointer-events-none fixed inset-x-4 bottom-4 z-50 mx-auto w-auto max-w-sm outline-none sm:right-4 sm:left-auto sm:mx-0 sm:w-full",
26
+ className
27
+ )}
28
+ {...props}
29
+ />
30
+ )
31
+ }
32
+
33
+ function Toast({ className, ...props }: ToastPrimitive.Root.Props) {
34
+ return (
35
+ <ToastPrimitive.Root
36
+ data-slot="toast"
37
+ className={cn(
38
+ "group/toast pointer-events-auto absolute right-0 bottom-0 z-[calc(1000-var(--toast-index))] w-full origin-bottom rounded-2xl border border-gray-200 bg-popover text-popover-foreground shadow-lg will-change-transform outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
39
+ "[--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]",
40
+ "h-(--height) [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms]",
41
+ "after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
42
+ "data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]",
43
+ "data-limited:opacity-0 data-starting-style:[transform:translateY(150%)]",
44
+ "[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]",
45
+ "data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
46
+ "data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
47
+ "data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
48
+ "data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
49
+ "data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
50
+ "data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
51
+ "data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
52
+ "data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
53
+ className
54
+ )}
55
+ {...props}
56
+ />
57
+ )
58
+ }
59
+
60
+ function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) {
61
+ return (
62
+ <ToastPrimitive.Content
63
+ data-slot="toast-content"
64
+ className={cn(
65
+ "flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-behind:opacity-0 data-expanded:opacity-100",
66
+ className
67
+ )}
68
+ {...props}
69
+ />
70
+ )
71
+ }
72
+
73
+ function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) {
74
+ return (
75
+ <ToastPrimitive.Title
76
+ data-slot="toast-title"
77
+ className={cn("text-sm font-medium", className)}
78
+ {...props}
79
+ />
80
+ )
81
+ }
82
+
83
+ function ToastDescription({
84
+ className,
85
+ ...props
86
+ }: ToastPrimitive.Description.Props) {
87
+ return (
88
+ <ToastPrimitive.Description
89
+ data-slot="toast-description"
90
+ className={cn("text-sm text-muted-foreground", className)}
91
+ {...props}
92
+ />
93
+ )
94
+ }
95
+
96
+ function ToastAction({
97
+ className,
98
+ render = <Button variant="outline" size="sm" />,
99
+ ...props
100
+ }: ToastPrimitive.Action.Props) {
101
+ return (
102
+ <ToastPrimitive.Action
103
+ data-slot="toast-action"
104
+ render={render}
105
+ className={cn("shrink-0", className)}
106
+ {...props}
107
+ />
108
+ )
109
+ }
110
+
111
+ function ToastClose({
112
+ className,
113
+ children,
114
+ render = <Button variant="ghost" size="icon-sm" />,
115
+ ...props
116
+ }: ToastPrimitive.Close.Props) {
117
+ return (
118
+ <ToastPrimitive.Close
119
+ data-slot="toast-close"
120
+ aria-label="Close toast"
121
+ render={render}
122
+ className={cn(
123
+ "relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground",
124
+ className
125
+ )}
126
+ {...props}
127
+ >
128
+ {children ?? (
129
+ <XIcon aria-hidden="true" />
130
+ )}
131
+ </ToastPrimitive.Close>
132
+ )
133
+ }
134
+
135
+ function ToastIcon({ type }: { type: string | undefined }) {
136
+ let icon: React.ReactNode = null
137
+
138
+ if (type === "success") {
139
+ icon = (
140
+ <CircleCheckIcon aria-hidden="true" />
141
+ )
142
+ }
143
+
144
+ if (type === "info") {
145
+ icon = (
146
+ <InfoIcon aria-hidden="true" />
147
+ )
148
+ }
149
+
150
+ if (type === "warning") {
151
+ icon = (
152
+ <TriangleAlertIcon aria-hidden="true" />
153
+ )
154
+ }
155
+
156
+ if (type === "error") {
157
+ icon = (
158
+ <OctagonXIcon className="text-destructive" aria-hidden="true" />
159
+ )
160
+ }
161
+
162
+ if (type === "loading") {
163
+ icon = (
164
+ <Loader2Icon className="animate-spin" aria-hidden="true" />
165
+ )
166
+ }
167
+
168
+ if (!icon) {
169
+ return null
170
+ }
171
+
172
+ return (
173
+ <span
174
+ data-slot="toast-icon"
175
+ className="shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"
176
+ >
177
+ {icon}
178
+ </span>
179
+ )
180
+ }
181
+
182
+ function ToastList() {
183
+ const { toasts } = ToastPrimitive.useToastManager()
184
+
185
+ return toasts.map((toastItem) => (
186
+ <Toast key={toastItem.id} toast={toastItem}>
187
+ <ToastContent>
188
+ <ToastIcon type={toastItem.type} />
189
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
190
+ <ToastTitle />
191
+ <ToastDescription />
192
+ </div>
193
+ <ToastAction />
194
+ <ToastClose />
195
+ </ToastContent>
196
+ </Toast>
197
+ ))
198
+ }
199
+
200
+ function Toaster({
201
+ children,
202
+ toastManager = toast,
203
+ ...props
204
+ }: ToastPrimitive.Provider.Props) {
205
+ return (
206
+ <ToastProvider toastManager={toastManager} {...props}>
207
+ {children}
208
+ <ToastPortal>
209
+ <ToastViewport>
210
+ <ToastList />
211
+ </ToastViewport>
212
+ </ToastPortal>
213
+ </ToastProvider>
214
+ )
215
+ }
216
+
217
+ const createToastManager = ToastPrimitive.createToastManager
218
+ const useToastManager = ToastPrimitive.useToastManager
219
+
220
+ export {
221
+ Toaster,
222
+ Toast,
223
+ ToastAction,
224
+ ToastClose,
225
+ ToastContent,
226
+ ToastDescription,
227
+ ToastPortal,
228
+ ToastProvider,
229
+ ToastTitle,
230
+ ToastViewport,
231
+ createToastManager,
232
+ toast,
233
+ useToastManager,
234
+ }
@@ -5,6 +5,7 @@ export { QuestionBank } from './components/question-bank.tsx';
5
5
  export { QuizPage } from './components/quiz-page.tsx';
6
6
  export { QuizList } from './components/quiz-list.tsx';
7
7
  export { AppShell } from './components/app-shell.tsx';
8
+ export { ExamTimerBadge, ExamTimerPage } from './components/exam-timer.tsx';
8
9
  export { FilterBuilder } from './components/filter-builder.tsx';
9
10
  export { MarkdownContent } from './components/markdown-content.tsx';
10
11
  export { NumericalResponse, PairingResponse, formatNumericAnswer } from './components/question-response.tsx';
@@ -32,5 +33,8 @@ export {
32
33
  syncQuiz,
33
34
  } from './lib/quiz-storage.ts';
34
35
  export { getQuestionStatuses, setQuestionStatus } from './lib/question-status-storage.ts';
36
+ export { examState, setExamTimer, useExamTimer } from './lib/exam-timer.ts';
37
+ export type { ExamState, ExamTimer } from './lib/exam-timer.ts';
38
+ export { Toaster, toast } from './components/ui/toast.tsx';
35
39
  export type { StorageTarget } from './lib/sync-client.ts';
36
40
  export { cn } from './lib/utils.ts';
@@ -0,0 +1,115 @@
1
+ // Mock-exam clock: pressing Start on /timer pretends it's the exam's start time
2
+ // (e.g. 08:30) right now, then the clock runs forward from there. Kept in
3
+ // localStorage so it survives reloads and is shared by every open tab.
4
+ // Framework-owned: eject with `qb reveal framework/lib/exam-timer.ts`.
5
+
6
+ import { useEffect, useState, useSyncExternalStore } from 'react';
7
+
8
+ export type ExamTimer = {
9
+ /** Exam start on the mock clock, in minutes after midnight (08:30 → 510). */
10
+ startMinutes: number;
11
+ /** When Start was pressed (epoch ms). */
12
+ startedAt: number;
13
+ readingMinutes: number;
14
+ writingMinutes: number;
15
+ };
16
+
17
+ export type ExamState = {
18
+ phase: 'reading' | 'writing' | 'over';
19
+ /** Mock clock time, "HH:MM". */
20
+ clock: string;
21
+ /** Ms since Start was pressed. */
22
+ elapsed: number;
23
+ };
24
+
25
+ export const MINUTE = 60_000;
26
+ const CHANGE_EVENT = 'qb-exam-timer-change';
27
+ const keyFor = (namespace: string) => `${namespace}-question-bank-exam-timer`;
28
+
29
+ const isTimer = (value: unknown): value is ExamTimer => {
30
+ if (!value || typeof value !== 'object') return false;
31
+ const { startMinutes, startedAt, readingMinutes, writingMinutes } = value as Record<string, unknown>;
32
+ return [startMinutes, startedAt, readingMinutes, writingMinutes].every(
33
+ (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0,
34
+ );
35
+ };
36
+
37
+ const parse = (raw: string | null): ExamTimer | null => {
38
+ try {
39
+ const value: unknown = JSON.parse(raw ?? 'null');
40
+ return isTimer(value) ? value : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ };
45
+
46
+ export function setExamTimer(namespace: string, timer: ExamTimer | null): void {
47
+ if (typeof window === 'undefined') return;
48
+ if (timer) window.localStorage.setItem(keyFor(namespace), JSON.stringify(timer));
49
+ else window.localStorage.removeItem(keyFor(namespace));
50
+ // `storage` only fires in other tabs; tell this one too.
51
+ window.dispatchEvent(new Event(CHANGE_EVENT));
52
+ }
53
+
54
+ function subscribe(onChange: () => void) {
55
+ window.addEventListener('storage', onChange);
56
+ window.addEventListener(CHANGE_EVENT, onChange);
57
+ return () => {
58
+ window.removeEventListener('storage', onChange);
59
+ window.removeEventListener(CHANGE_EVENT, onChange);
60
+ };
61
+ }
62
+
63
+ /** The saved timer, or null. Always null during SSR. */
64
+ export function useExamTimer(namespace: string): ExamTimer | null {
65
+ const raw = useSyncExternalStore(
66
+ subscribe,
67
+ () => window.localStorage.getItem(keyFor(namespace)),
68
+ () => null,
69
+ );
70
+ return parse(raw);
71
+ }
72
+
73
+ export const formatClock = (minutesAfterMidnight: number) => {
74
+ const minutes = Math.floor(minutesAfterMidnight) % (24 * 60);
75
+ return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
76
+ };
77
+
78
+ export function examState(timer: ExamTimer, now: number): ExamState {
79
+ const elapsed = Math.max(0, now - timer.startedAt);
80
+ const minutes = elapsed / MINUTE;
81
+ const phase =
82
+ minutes < timer.readingMinutes ? 'reading' : minutes < timer.readingMinutes + timer.writingMinutes ? 'writing' : 'over';
83
+ return { phase, clock: formatClock(timer.startMinutes + minutes), elapsed };
84
+ }
85
+
86
+ /**
87
+ * Current time, re-read each time the mock clock ticks over a minute (and
88
+ * when the tab becomes visible again). Never re-renders more than once a minute.
89
+ */
90
+ export function useExamNow(timer: ExamTimer | null): number {
91
+ const [now, setNow] = useState(() => Date.now());
92
+ const startedAt = timer?.startedAt;
93
+ useEffect(() => {
94
+ if (startedAt === undefined) return;
95
+ let timeout: ReturnType<typeof setTimeout>;
96
+ const tick = () => {
97
+ const current = Date.now();
98
+ setNow(current);
99
+ const intoMinute = (((current - startedAt) % MINUTE) + MINUTE) % MINUTE;
100
+ timeout = setTimeout(tick, MINUTE - intoMinute + 50);
101
+ };
102
+ const onVisible = () => {
103
+ if (document.visibilityState !== 'visible') return;
104
+ clearTimeout(timeout);
105
+ tick();
106
+ };
107
+ tick();
108
+ document.addEventListener('visibilitychange', onVisible);
109
+ return () => {
110
+ clearTimeout(timeout);
111
+ document.removeEventListener('visibilitychange', onVisible);
112
+ };
113
+ }, [startedAt]);
114
+ return now;
115
+ }
@@ -7,6 +7,10 @@
7
7
  * @import 'question-bank-framework/framework/styles/theme.css';
8
8
  */
9
9
 
10
+ /* KaTeX CSS from the framework's own katex, matching the version that renders
11
+ * math in MarkdownContent. Don't import katex CSS separately in your app. */
12
+ @import 'katex/dist/katex.min.css';
13
+
10
14
  @theme inline {
11
15
  --font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif;
12
16
  --font-display: 'Fraunces', Georgia, serif;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "question-bank-framework",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Framework-style question bank: TanStack Start + TanStack Table grid, quizzes, and filter DSL. Bring your own bank.config.ts + content/. Eject any framework/ file with `qb reveal` (Vike-style).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,6 +32,7 @@
32
32
  "react-dom": "^19.2.0"
33
33
  },
34
34
  "dependencies": {
35
+ "@base-ui/react": "^1.9.0",
35
36
  "@tanstack/react-table": "^8.21.3",
36
37
  "@tanstack/react-virtual": "^3.14.19",
37
38
  "class-variance-authority": "^0.7.1",
@@ -40,7 +41,6 @@
40
41
  "lucide-react": "^1.57.0",
41
42
  "radix-ui": "^1.8.1",
42
43
  "react-markdown": "^10.1.0",
43
- "rehype-katex": "^7.0.1",
44
44
  "remark-gfm": "^4.0.1",
45
45
  "remark-math": "^6.0.0",
46
46
  "tailwind-merge": "^3.7.0",
@@ -1,5 +1,4 @@
1
1
  @import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,600;9..144,700&display=swap');
2
- @import 'katex/dist/katex.min.css';
3
2
  @import 'tailwindcss';
4
3
  @import 'tw-animate-css';
5
4
  @import 'question-bank-framework/framework/styles/theme.css';
@@ -0,0 +1,4 @@
1
+ import { createFileRoute } from '@tanstack/react-router';
2
+ import { ExamTimerPage } from 'question-bank-framework/framework';
3
+
4
+ export const Route = createFileRoute('/timer')({ component: () => <ExamTimerPage /> });