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
@@ -1,6 +1,7 @@
1
1
  // Mock-exam clock: pressing Start on /timer pretends it's the exam's start time
2
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.
3
+ // localStorage so it survives reloads and is shared by every open tab. Pausing
4
+ // freezes the clock; resuming shifts `startedAt` forward by the paused time.
4
5
  // Framework-owned: eject with `qb reveal framework/lib/exam-timer.ts`.
5
6
 
6
7
  import { useEffect, useState, useSyncExternalStore } from 'react';
@@ -12,10 +13,13 @@ export type ExamTimer = {
12
13
  startedAt: number;
13
14
  readingMinutes: number;
14
15
  writingMinutes: number;
16
+ /** Set while paused: when Pause was pressed (epoch ms). The clock is frozen at this moment. */
17
+ pausedAt?: number;
15
18
  };
16
19
 
17
20
  export type ExamState = {
18
21
  phase: 'reading' | 'writing' | 'over';
22
+ paused: boolean;
19
23
  /** Mock clock time, "HH:MM". */
20
24
  clock: string;
21
25
  /** Ms since Start was pressed. */
@@ -28,10 +32,9 @@ const keyFor = (namespace: string) => `${namespace}-question-bank-exam-timer`;
28
32
 
29
33
  const isTimer = (value: unknown): value is ExamTimer => {
30
34
  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
+ const { startMinutes, startedAt, readingMinutes, writingMinutes, pausedAt } = value as Record<string, unknown>;
36
+ const isTime = (n: unknown) => typeof n === 'number' && Number.isFinite(n) && n >= 0;
37
+ return [startMinutes, startedAt, readingMinutes, writingMinutes].every(isTime) && (pausedAt === undefined || isTime(pausedAt));
35
38
  };
36
39
 
37
40
  const parse = (raw: string | null): ExamTimer | null => {
@@ -51,6 +54,19 @@ export function setExamTimer(namespace: string, timer: ExamTimer | null): void {
51
54
  window.dispatchEvent(new Event(CHANGE_EVENT));
52
55
  }
53
56
 
57
+ /** Freeze the clock at `now`. No-op if already paused. */
58
+ export function pauseExamTimer(namespace: string, timer: ExamTimer, now = Date.now()): void {
59
+ if (timer.pausedAt !== undefined) return;
60
+ setExamTimer(namespace, { ...timer, pausedAt: now });
61
+ }
62
+
63
+ /** Carry on from where it was paused, by shifting `startedAt` forward by the time spent paused. */
64
+ export function resumeExamTimer(namespace: string, timer: ExamTimer, now = Date.now()): void {
65
+ if (timer.pausedAt === undefined) return;
66
+ const { pausedAt, ...rest } = timer;
67
+ setExamTimer(namespace, { ...rest, startedAt: rest.startedAt + Math.max(0, now - pausedAt) });
68
+ }
69
+
54
70
  function subscribe(onChange: () => void) {
55
71
  window.addEventListener('storage', onChange);
56
72
  window.addEventListener(CHANGE_EVENT, onChange);
@@ -76,22 +92,25 @@ export const formatClock = (minutesAfterMidnight: number) => {
76
92
  };
77
93
 
78
94
  export function examState(timer: ExamTimer, now: number): ExamState {
79
- const elapsed = Math.max(0, now - timer.startedAt);
95
+ const paused = timer.pausedAt !== undefined;
96
+ const elapsed = Math.max(0, (timer.pausedAt ?? now) - timer.startedAt);
80
97
  const minutes = elapsed / MINUTE;
81
98
  const phase =
82
99
  minutes < timer.readingMinutes ? 'reading' : minutes < timer.readingMinutes + timer.writingMinutes ? 'writing' : 'over';
83
- return { phase, clock: formatClock(timer.startMinutes + minutes), elapsed };
100
+ return { phase, paused, clock: formatClock(timer.startMinutes + minutes), elapsed };
84
101
  }
85
102
 
86
103
  /**
87
104
  * 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.
105
+ * when the tab becomes visible again). Never re-renders more than once a minute,
106
+ * and doesn't tick at all while paused.
89
107
  */
90
108
  export function useExamNow(timer: ExamTimer | null): number {
91
109
  const [now, setNow] = useState(() => Date.now());
92
110
  const startedAt = timer?.startedAt;
111
+ const paused = timer?.pausedAt !== undefined;
93
112
  useEffect(() => {
94
- if (startedAt === undefined) return;
113
+ if (startedAt === undefined || paused) return;
95
114
  let timeout: ReturnType<typeof setTimeout>;
96
115
  const tick = () => {
97
116
  const current = Date.now();
@@ -110,6 +129,6 @@ export function useExamNow(timer: ExamTimer | null): number {
110
129
  clearTimeout(timeout);
111
130
  document.removeEventListener('visibilitychange', onVisible);
112
131
  };
113
- }, [startedAt]);
132
+ }, [startedAt, paused]);
114
133
  return now;
115
134
  }
@@ -1,24 +1,35 @@
1
- /* shadcn-style theme tokens consumed by question-bank-framework UI components.
2
- * Import this from your app stylesheet (after tailwindcss) instead of
3
- * copy-pasting the tokens, so every bank inherits upstream token changes:
1
+ /* Design system for question-bank-framework: shadcn/ui neutral tokens + Inter.
2
+ * Import this from your app stylesheet after tailwindcss:
4
3
  *
5
4
  * @import 'tailwindcss';
6
- * @source '../node_modules/question-bank-framework';
7
5
  * @import 'question-bank-framework/framework/styles/theme.css';
8
6
  */
9
7
 
8
+ /* Generate utilities used by the framework's components (paths resolve
9
+ * relative to this file, so this also works when the package is linked). */
10
+ @source '..';
11
+
12
+ /* Inter variable with both axes (wght 100–900, opsz 14–32), upright and italic.
13
+ * `font-optical-sizing: auto` (below) maps opsz to the font size, so small
14
+ * text gets Inter Text (open spacing) and headings get Inter Display (tight
15
+ * spacing, finer details). Don't add negative tracking on top of it. */
16
+ @import '@fontsource-variable/inter/opsz.css';
17
+ @import '@fontsource-variable/inter/opsz-italic.css';
18
+ @import 'tw-animate-css';
10
19
  /* KaTeX CSS from the framework's own katex, matching the version that renders
11
20
  * math in MarkdownContent. Don't import katex CSS separately in your app. */
12
21
  @import 'katex/dist/katex.min.css';
13
22
 
23
+ @custom-variant dark (&:is(.dark *));
24
+
14
25
  @theme inline {
15
- --font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif;
16
- --font-display: 'Fraunces', Georgia, serif;
17
- --font-mono: 'DM Mono', ui-monospace, monospace;
18
- --radius-sm: calc(var(--radius) - 4px);
19
- --radius-md: calc(var(--radius) - 2px);
26
+ --font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
27
+ --font-mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace;
28
+ --radius-sm: calc(var(--radius) * 0.6);
29
+ --radius-md: calc(var(--radius) * 0.8);
20
30
  --radius-lg: var(--radius);
21
- --radius-xl: calc(var(--radius) + 4px);
31
+ --radius-xl: calc(var(--radius) * 1.4);
32
+ --radius-2xl: calc(var(--radius) * 1.8);
22
33
  --color-background: var(--background);
23
34
  --color-foreground: var(--foreground);
24
35
  --color-card: var(--card);
@@ -34,6 +45,8 @@
34
45
  --color-accent: var(--accent);
35
46
  --color-accent-foreground: var(--accent-foreground);
36
47
  --color-destructive: var(--destructive);
48
+ --color-success: var(--success);
49
+ --color-warning: var(--warning);
37
50
  --color-border: var(--border);
38
51
  --color-input: var(--input);
39
52
  --color-ring: var(--ring);
@@ -41,22 +54,47 @@
41
54
 
42
55
  :root {
43
56
  --radius: 0.625rem;
44
- --background: #ffffff;
45
- --foreground: #111827;
46
- --card: #ffffff;
47
- --card-foreground: #111827;
48
- --popover: #ffffff;
49
- --popover-foreground: #111827;
50
- --primary: #25564c;
51
- --primary-foreground: #ffffff;
52
- --secondary: #f1f5f2;
53
- --secondary-foreground: #263a2f;
54
- --muted: #f3f4f6;
55
- --muted-foreground: #6b7280;
56
- --accent: #eef3ef;
57
- --accent-foreground: #263a2f;
58
- --destructive: #b91c1c;
59
- --border: #d1d5db;
60
- --input: #d1d5db;
61
- --ring: #78a589;
57
+ --background: oklch(1 0 0);
58
+ --foreground: oklch(0.145 0 0);
59
+ --card: oklch(1 0 0);
60
+ --card-foreground: oklch(0.145 0 0);
61
+ --popover: oklch(1 0 0);
62
+ --popover-foreground: oklch(0.145 0 0);
63
+ --primary: oklch(0.205 0 0);
64
+ --primary-foreground: oklch(0.985 0 0);
65
+ --secondary: oklch(0.97 0 0);
66
+ --secondary-foreground: oklch(0.205 0 0);
67
+ --muted: oklch(0.97 0 0);
68
+ --muted-foreground: oklch(0.556 0 0);
69
+ --accent: oklch(0.97 0 0);
70
+ --accent-foreground: oklch(0.205 0 0);
71
+ --destructive: oklch(0.577 0.245 27.325);
72
+ --success: oklch(0.527 0.154 150.069);
73
+ --warning: oklch(0.666 0.179 58.318);
74
+ --border: oklch(0.922 0 0);
75
+ --input: oklch(0.922 0 0);
76
+ --ring: oklch(0.708 0 0);
77
+ }
78
+
79
+ @layer base {
80
+ * {
81
+ @apply border-border outline-ring/50;
82
+ }
83
+ html {
84
+ font-optical-sizing: auto;
85
+ -webkit-font-smoothing: antialiased;
86
+ -moz-osx-font-smoothing: grayscale;
87
+ text-rendering: optimizeLegibility;
88
+ }
89
+ body {
90
+ @apply bg-background font-sans text-foreground;
91
+ }
92
+ h1,
93
+ h2,
94
+ h3 {
95
+ text-wrap: balance;
96
+ }
97
+ p {
98
+ text-wrap: pretty;
99
+ }
62
100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "question-bank-framework",
3
- "version": "0.8.0",
3
+ "version": "0.10.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",
@@ -33,6 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@base-ui/react": "^1.9.0",
36
+ "@fontsource-variable/inter": "^5.3.0",
36
37
  "@tanstack/react-table": "^8.21.3",
37
38
  "@tanstack/react-virtual": "^3.14.19",
38
39
  "class-variance-authority": "^0.7.1",
@@ -44,6 +45,7 @@
44
45
  "remark-gfm": "^4.0.1",
45
46
  "remark-math": "^6.0.0",
46
47
  "tailwind-merge": "^3.7.0",
48
+ "tw-animate-css": "^1.4.0",
47
49
  "zustand": "^5.0.15"
48
50
  },
49
51
  "devDependencies": {
@@ -35,7 +35,7 @@ function RootDocument({ children }: { children: ReactNode }) {
35
35
  <head>
36
36
  <HeadContent />
37
37
  </head>
38
- <body className="m-0 min-h-screen bg-[#f5f7f4] font-sans text-[#27392f] antialiased">
38
+ <body className="min-h-dvh">
39
39
  {children}
40
40
  <Scripts />
41
41
  </body>
@@ -1,6 +1,2 @@
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
1
  @import 'tailwindcss';
3
- @import 'tw-animate-css';
4
2
  @import 'question-bank-framework/framework/styles/theme.css';
5
-
6
- @source '../../node_modules/question-bank-framework';