@exxatdesignux/ui 0.2.16 → 0.2.18

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 (111) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  6. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
  7. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +19 -0
  9. package/consumer-extras/patterns/data-views-pattern.md +2 -0
  10. package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
  11. package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -0
  12. package/package.json +3 -3
  13. package/src/components/ui/banner.tsx +2 -0
  14. package/src/components/ui/chart.tsx +57 -2
  15. package/src/components/ui/sidebar.tsx +3 -2
  16. package/src/globals.css +65 -14
  17. package/src/theme.css +3 -3
  18. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  19. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
  20. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  21. package/template/AGENTS.md +27 -17
  22. package/template/app/(app)/data-list/page.tsx +2 -2
  23. package/template/app/(app)/error.tsx +22 -6
  24. package/template/app/(app)/layout.tsx +13 -6
  25. package/template/app/(app)/question-bank/layout.tsx +18 -5
  26. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  27. package/template/app/global-error.tsx +63 -0
  28. package/template/app/globals.css +151 -14
  29. package/template/app/layout.tsx +43 -5
  30. package/template/components/app-sidebar.tsx +68 -33
  31. package/template/components/ask-leo-sidebar.tsx +0 -2
  32. package/template/components/brand-color-picker.tsx +344 -0
  33. package/template/components/compliance-list-view.tsx +33 -51
  34. package/template/components/compliance-table.tsx +4 -0
  35. package/template/components/data-table/index.tsx +99 -91
  36. package/template/components/data-table/pagination.tsx +0 -1
  37. package/template/components/data-table/types.ts +4 -1
  38. package/template/components/data-table/use-table-state.ts +276 -100
  39. package/template/components/data-views/data-row-list.tsx +183 -0
  40. package/template/components/data-views/index.ts +7 -3
  41. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  42. package/template/components/dev-chunk-load-recovery.tsx +41 -0
  43. package/template/components/export-drawer.tsx +1 -1
  44. package/template/components/exxat-product-logo.tsx +168 -317
  45. package/template/components/invite-collaborators-drawer.tsx +5 -3
  46. package/template/components/key-metrics.tsx +122 -62
  47. package/template/components/new-placement-form.tsx +4 -2
  48. package/template/components/new-question-composer.tsx +2208 -0
  49. package/template/components/page-breadcrumb-trail.tsx +131 -0
  50. package/template/components/page-header.tsx +2 -1
  51. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +2 -2
  52. package/template/components/placement-detail.tsx +1 -1
  53. package/template/components/placements-board-view.tsx +1 -1
  54. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  55. package/template/components/placements-list-view.tsx +19 -133
  56. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  57. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  58. package/template/components/placements-table-columns.tsx +2 -2
  59. package/template/components/{data-list-table.tsx → placements-table.tsx} +42 -66
  60. package/template/components/product-switcher.tsx +24 -7
  61. package/template/components/product-wordmark.tsx +282 -0
  62. package/template/components/question-bank-client.tsx +20 -2
  63. package/template/components/question-bank-hub-client.tsx +105 -115
  64. package/template/components/question-bank-list-view.tsx +30 -54
  65. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  66. package/template/components/question-bank-secondary-nav.tsx +0 -3
  67. package/template/components/question-bank-table.tsx +19 -6
  68. package/template/components/rotations-empty-state.tsx +3 -0
  69. package/template/components/secondary-panel.tsx +23 -3
  70. package/template/components/settings-appearance-card.tsx +584 -141
  71. package/template/components/sidebar-shell.tsx +2 -1
  72. package/template/components/site-header.tsx +36 -31
  73. package/template/components/sites-list-view.tsx +31 -36
  74. package/template/components/sites-table.tsx +4 -0
  75. package/template/components/table-properties/drawer-button.tsx +38 -20
  76. package/template/components/table-properties/drawer.tsx +17 -14
  77. package/template/components/team-client.tsx +1 -1
  78. package/template/components/team-list-view.tsx +34 -50
  79. package/template/components/team-table.tsx +8 -3
  80. package/template/components/templates/list-page.tsx +12 -9
  81. package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
  82. package/template/components/ui/dot-pattern.tsx +50 -26
  83. package/template/components/ui/leo-icon.tsx +23 -3
  84. package/template/contexts/product-context.tsx +70 -7
  85. package/template/contexts/system-banner-context.tsx +112 -4
  86. package/template/docs/data-views-pattern.md +2 -0
  87. package/template/docs/kpi-flat-band-pattern.md +57 -0
  88. package/template/docs/kpi-strip-max-four-pattern.md +1 -0
  89. package/template/docs/shell-surface-elevation-pattern.md +52 -0
  90. package/template/eslint.config.mjs +18 -0
  91. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  92. package/template/lib/chunk-load-error.ts +13 -0
  93. package/template/lib/conditional-rule-match.ts +87 -22
  94. package/template/lib/data-list-persistence.ts +57 -257
  95. package/template/lib/data-list-view.ts +6 -0
  96. package/template/lib/dev-log.test.ts +6 -5
  97. package/template/lib/exxat-palette.json +1462 -0
  98. package/template/lib/exxat-palette.ts +136 -0
  99. package/template/lib/list-page-table-properties.ts +1 -1
  100. package/template/lib/list-status-badges.ts +1 -1
  101. package/template/lib/mailto.ts +29 -0
  102. package/template/lib/placement-board-card-layout.ts +1 -1
  103. package/template/lib/product-brand.ts +268 -0
  104. package/template/lib/question-bank-authoring.ts +308 -0
  105. package/template/lib/question-bank-nav.ts +44 -0
  106. package/template/lib/raf-throttle.ts +45 -0
  107. package/template/lib/sidebar-state-cookie.ts +9 -0
  108. package/template/lib/table-state-lifecycle.ts +521 -0
  109. package/template/next.config.mjs +156 -0
  110. package/template/package.json +3 -3
  111. package/template/stores/app-store.ts +46 -1
@@ -0,0 +1,2208 @@
1
+ "use client"
2
+
3
+ /**
4
+ * NewQuestionComposer — single-page authoring for the question bank.
5
+ *
6
+ * IA (matches the rest of the question bank surfaces):
7
+ *
8
+ * ├─ PageHeader (title + actions; parent trail is in `SiteHeader`)
9
+ * │ · "New question" + "V1 · Last updated …" subtitle
10
+ * │ · single primary CTA — Save as draft (⏎)
11
+ * │ · ⋯ overflow menu (⌘⌥M) for inspector toggle + discard
12
+ * ├─ 2-column layout (lg+): page scrolls as one; inspector card `sticky` on lg+
13
+ * │ ┌─ Builder (left, no card chrome)
14
+ * │ │ · Question prompt (h1-style Textarea — type-aware)
15
+ * │ │ · Answer block — varies by question type
16
+ * │ │ · Explanation / rubric / model answer
17
+ * │ │ · References (repeatable list)
18
+ * │ └─ Inspector (right, bg-card panel)
19
+ * │ · Question format (SelectionTileGrid → compact)
20
+ * │ · Location (folder SelectionTileGrid)
21
+ * │ · Difficulty / Bloom / NBME (chips)
22
+ * │ · Tags (Input + Badge list)
23
+ * │ Sidebar-style collapse (⌘⌥]) — collapsed rail mimics
24
+ * │ `NestedSecondaryPanelShell` icon mode.
25
+ *
26
+ * Composes existing primitives — `PageHeader`, `Form`/`FormField`,
27
+ * `Input`, `Textarea`, `Checkbox`, `Badge`, `Button`, `Tip`, `Kbd`,
28
+ * `SelectionTileGrid`, `DropdownMenu` + react-hook-form + Zod (same
29
+ * stack as `new-placement-form.tsx`).
30
+ *
31
+ * Local helpers (`OptionRow`, `BuilderSection`,
32
+ * `InspectorSection`) live inside this file — they are not new shared
33
+ * primitives, so they don't need a design-system review per
34
+ * `exxat-reuse-before-custom.mdc`.
35
+ */
36
+
37
+ import * as React from "react"
38
+ import { useRouter } from "next/navigation"
39
+ import { useForm, useWatch, type Resolver } from "react-hook-form"
40
+ import { zodResolver } from "@hookform/resolvers/zod"
41
+ import { z } from "zod"
42
+
43
+ import { cn } from "@/lib/utils"
44
+ import { devLog } from "@/lib/dev-log"
45
+
46
+ import {
47
+ Form,
48
+ FormControl,
49
+ FormDescription,
50
+ FormField,
51
+ FormItem,
52
+ FormMessage,
53
+ } from "@/components/ui/form"
54
+ import { Input } from "@/components/ui/input"
55
+ import { Textarea } from "@/components/ui/textarea"
56
+ import { Button } from "@/components/ui/button"
57
+ import { Badge } from "@/components/ui/badge"
58
+ import { Checkbox } from "@/components/ui/checkbox"
59
+ import { Label } from "@/components/ui/label"
60
+ import { Kbd, KbdGroup } from "@/components/ui/kbd"
61
+ import {
62
+ DropdownMenu,
63
+ DropdownMenuContent,
64
+ DropdownMenuItem,
65
+ DropdownMenuSeparator,
66
+ DropdownMenuTrigger,
67
+ Shortcut,
68
+ } from "@/components/ui/dropdown-menu"
69
+ import { Tip } from "@/components/ui/tip"
70
+ import { PageHeader } from "@/components/page-header"
71
+ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
72
+ import {
73
+ SelectionTileGrid,
74
+ type SelectionTileOption,
75
+ } from "@/components/ui/selection-tile-grid"
76
+ import {
77
+ Popover,
78
+ PopoverContent,
79
+ PopoverTrigger,
80
+ } from "@/components/ui/popover"
81
+ import {
82
+ Command,
83
+ CommandEmpty,
84
+ CommandGroup,
85
+ CommandInput,
86
+ CommandItem,
87
+ CommandList,
88
+ CommandSeparator,
89
+ } from "@/components/ui/command"
90
+ import { QuestionBankNewFolderSheet } from "@/components/question-bank-new-folder-sheet"
91
+
92
+ import {
93
+ AUTHORING_QUESTION_TYPES,
94
+ AUTHORING_BLOOM_OPTIONS,
95
+ AUTHORING_COG_LEVEL_OPTIONS,
96
+ AUTHORING_DIFFICULTY_OPTIONS,
97
+ AUTHORING_STATUS_OPTIONS,
98
+ AUTHORING_DEFAULT_OPTION_COUNT,
99
+ AUTHORING_MIN_OPTION_COUNT,
100
+ AUTHORING_MAX_OPTION_COUNT,
101
+ AUTHORING_LEAD_IN_PLACEHOLDER,
102
+ AUTHORING_RATIONALE_PLACEHOLDER,
103
+ authoringQuestionType,
104
+ type AuthoringQuestionType,
105
+ } from "@/lib/question-bank-authoring"
106
+ import {
107
+ DEFAULT_QUESTION_BANK_FOLDERS,
108
+ newFolderId,
109
+ type QuestionBankFolder,
110
+ type QuestionBankFolderColorKey,
111
+ } from "@/lib/mock/question-bank-folders"
112
+
113
+ // ─────────────────────────────────────────────────────────────────────────────
114
+ // Schema
115
+ // ─────────────────────────────────────────────────────────────────────────────
116
+
117
+ const QUESTION_TYPES = AUTHORING_QUESTION_TYPES.map(t => t.value) as [
118
+ AuthoringQuestionType,
119
+ ...AuthoringQuestionType[],
120
+ ]
121
+
122
+ /** Tiles for the export-drawer-style "File format" pattern (radio + outline + pop). */
123
+ const QUESTION_TYPE_TILES: SelectionTileOption<AuthoringQuestionType>[] =
124
+ AUTHORING_QUESTION_TYPES.map(t => ({
125
+ value: t.value,
126
+ label: t.shortLabel,
127
+ icon: t.icon,
128
+ }))
129
+ const STATUSES = AUTHORING_STATUS_OPTIONS.map(s => s.value) as [
130
+ (typeof AUTHORING_STATUS_OPTIONS)[number]["value"],
131
+ ...(typeof AUTHORING_STATUS_OPTIONS)[number]["value"][],
132
+ ]
133
+
134
+ const optionSchema = z.object({
135
+ id: z.string(),
136
+ text: z.string(),
137
+ isCorrect: z.boolean(),
138
+ rationale: z.string(),
139
+ })
140
+
141
+ const referenceSchema = z.object({
142
+ id: z.string(),
143
+ citation: z.string().min(1, "Add a citation or remove the row."),
144
+ })
145
+
146
+ const matchingPairSchema = z.object({
147
+ id: z.string(),
148
+ left: z.string(),
149
+ right: z.string(),
150
+ })
151
+
152
+ const orderedItemSchema = z.object({
153
+ id: z.string(),
154
+ text: z.string(),
155
+ })
156
+
157
+ const fillBlankAnswerSchema = z.object({
158
+ id: z.string(),
159
+ accepted: z.string(),
160
+ })
161
+
162
+ const questionSchema = z
163
+ .object({
164
+ type: z.enum(QUESTION_TYPES),
165
+ status: z.enum(STATUSES),
166
+ folderId: z.string().min(1, "Pick a location."),
167
+ /* The lead-in IS the question (rendered as the page heading). All
168
+ question types share this one field; the schema requires a real
169
+ sentence so reviewers can read it on its own. */
170
+ leadIn: z.string().min(8, "Add the question prompt (at least a sentence)."),
171
+ options: z.array(optionSchema),
172
+ rationale: z.string(),
173
+ references: z.array(referenceSchema),
174
+ /* Type-specific authoring fields — populated only by the matching builder. */
175
+ numericValue: z.string(),
176
+ numericTolerance: z.string(),
177
+ numericUnits: z.string(),
178
+ pairs: z.array(matchingPairSchema),
179
+ orderedItems: z.array(orderedItemSchema),
180
+ fillBlankAnswers: z.array(fillBlankAnswerSchema),
181
+ difficulty: z.enum(["easy", "medium", "hard"]),
182
+ bloom: z.string(),
183
+ cogLevel: z.string(),
184
+ tags: z.array(z.string()),
185
+ })
186
+ .superRefine((data, ctx) => {
187
+ const isMcq = data.type === "mcq_single" || data.type === "mcq_multiple"
188
+ if (isMcq) {
189
+ const filled = data.options.filter(o => o.text.trim()).length
190
+ if (filled < AUTHORING_MIN_OPTION_COUNT) {
191
+ ctx.addIssue({
192
+ code: z.ZodIssueCode.custom,
193
+ path: ["options"],
194
+ message: `Provide at least ${AUTHORING_MIN_OPTION_COUNT} answer options.`,
195
+ })
196
+ }
197
+ if (!data.options.some(o => o.isCorrect)) {
198
+ ctx.addIssue({
199
+ code: z.ZodIssueCode.custom,
200
+ path: ["options"],
201
+ message: "Mark at least one option as correct.",
202
+ })
203
+ }
204
+ }
205
+ if (data.type === "true_false" && !data.options.some(o => o.isCorrect)) {
206
+ ctx.addIssue({
207
+ code: z.ZodIssueCode.custom,
208
+ path: ["options"],
209
+ message: "Pick whether the statement is True or False.",
210
+ })
211
+ }
212
+ if (data.type === "short_answer" && data.rationale.trim().length < 1) {
213
+ ctx.addIssue({
214
+ code: z.ZodIssueCode.custom,
215
+ path: ["rationale"],
216
+ message: "Add the model answer and any acceptable variants.",
217
+ })
218
+ }
219
+ if (data.type === "essay" && data.rationale.trim().length < 1) {
220
+ ctx.addIssue({
221
+ code: z.ZodIssueCode.custom,
222
+ path: ["rationale"],
223
+ message: "Add a grading rubric so reviewers know how to score.",
224
+ })
225
+ }
226
+ if (data.type === "numeric" && data.numericValue.trim().length === 0) {
227
+ ctx.addIssue({
228
+ code: z.ZodIssueCode.custom,
229
+ path: ["numericValue"],
230
+ message: "Enter the correct numeric value.",
231
+ })
232
+ }
233
+ if (data.type === "matching") {
234
+ const filled = data.pairs.filter(p => p.left.trim() && p.right.trim()).length
235
+ if (filled < 2) {
236
+ ctx.addIssue({
237
+ code: z.ZodIssueCode.custom,
238
+ path: ["pairs"],
239
+ message: "Add at least two matching pairs.",
240
+ })
241
+ }
242
+ }
243
+ if (data.type === "ordering") {
244
+ const filled = data.orderedItems.filter(i => i.text.trim()).length
245
+ if (filled < 2) {
246
+ ctx.addIssue({
247
+ code: z.ZodIssueCode.custom,
248
+ path: ["orderedItems"],
249
+ message: "Add at least two items to order.",
250
+ })
251
+ }
252
+ }
253
+ if (data.type === "fill_blank") {
254
+ const filled = data.fillBlankAnswers.filter(a => a.accepted.trim()).length
255
+ if (filled < 1) {
256
+ ctx.addIssue({
257
+ code: z.ZodIssueCode.custom,
258
+ path: ["fillBlankAnswers"],
259
+ message: "Add at least one accepted answer.",
260
+ })
261
+ }
262
+ }
263
+ })
264
+
265
+ type QuestionFormValues = z.infer<typeof questionSchema>
266
+
267
+ // ─────────────────────────────────────────────────────────────────────────────
268
+ // Helpers
269
+ // ─────────────────────────────────────────────────────────────────────────────
270
+
271
+ const OPTION_LETTERS = ["A", "B", "C", "D", "E", "F", "G", "H"] as const
272
+
273
+ function newOptionId() {
274
+ return `opt-${Math.random().toString(36).slice(2, 9)}`
275
+ }
276
+ function newReferenceId() {
277
+ return `ref-${Math.random().toString(36).slice(2, 9)}`
278
+ }
279
+ function newPairId() {
280
+ return `pair-${Math.random().toString(36).slice(2, 9)}`
281
+ }
282
+ function newOrderedId() {
283
+ return `ord-${Math.random().toString(36).slice(2, 9)}`
284
+ }
285
+ function newBlankId() {
286
+ return `blk-${Math.random().toString(36).slice(2, 9)}`
287
+ }
288
+
289
+ function buildInitialOptions(type: AuthoringQuestionType): QuestionFormValues["options"] {
290
+ if (type === "true_false") {
291
+ return [
292
+ { id: newOptionId(), text: "True", isCorrect: false, rationale: "" },
293
+ { id: newOptionId(), text: "False", isCorrect: false, rationale: "" },
294
+ ]
295
+ }
296
+ if (type === "mcq_single" || type === "mcq_multiple") {
297
+ return Array.from({ length: AUTHORING_DEFAULT_OPTION_COUNT }, () => ({
298
+ id: newOptionId(),
299
+ text: "",
300
+ isCorrect: false,
301
+ rationale: "",
302
+ }))
303
+ }
304
+ return []
305
+ }
306
+
307
+ function buildInitialPairs(): QuestionFormValues["pairs"] {
308
+ return Array.from({ length: 3 }, () => ({ id: newPairId(), left: "", right: "" }))
309
+ }
310
+ function buildInitialOrderedItems(): QuestionFormValues["orderedItems"] {
311
+ return Array.from({ length: 4 }, () => ({ id: newOrderedId(), text: "" }))
312
+ }
313
+ function buildInitialFillBlankAnswers(): QuestionFormValues["fillBlankAnswers"] {
314
+ return [{ id: newBlankId(), accepted: "" }]
315
+ }
316
+
317
+ function folderBreadcrumb(folderId: string, folders: QuestionBankFolder[]): string {
318
+ const f = folders.find(x => x.id === folderId)
319
+ if (!f) return ""
320
+ if (f.parentId == null) return f.name
321
+ const parent = folders.find(x => x.id === f.parentId)
322
+ return parent ? `${parent.name} / ${f.name}` : f.name
323
+ }
324
+
325
+ /** 0–100 percentage of the difficulty meter for the given level. */
326
+ function difficultyToPercent(value: "easy" | "medium" | "hard"): number {
327
+ if (value === "easy") return 18
328
+ if (value === "hard") return 82
329
+ return 50
330
+ }
331
+
332
+ /**
333
+ * Folder-aware difficulty insight — derived deterministically from the
334
+ * folder id so the same folder always returns the same numbers. In a real
335
+ * build this comes from analytics; for the mock it is a stable seed so
336
+ * the inspector reads as if the AI had crunched historical data.
337
+ */
338
+ function difficultyInsightForFolder(folder: QuestionBankFolder | undefined): {
339
+ /** Predicted level based on the content the author is writing. */
340
+ recommendation: "easy" | "medium" | "hard"
341
+ /** Contextual note shown under the meter (e.g. folder distribution). */
342
+ note: string
343
+ /** Point-Biserial Index (0–1) — predicted question quality. */
344
+ pbi: number
345
+ /** Average folder difficulty (0–100, same scale as the meter). */
346
+ averagePercent: number
347
+ } {
348
+ if (!folder) {
349
+ return {
350
+ recommendation: "medium",
351
+ note: "Pick a location to see how your question compares to this folder.",
352
+ pbi: 0.3,
353
+ averagePercent: 50,
354
+ }
355
+ }
356
+ // Deterministic hash of folder id → stable mock numbers per folder.
357
+ let h = 0
358
+ for (let i = 0; i < folder.id.length; i++) h = (h * 31 + folder.id.charCodeAt(i)) >>> 0
359
+ const tilt = (h % 100) / 100 // 0..1
360
+ const mediumShare = 45 + Math.floor(tilt * 30) // 45..75 %
361
+ const pbi = 0.22 + (tilt * 0.22) // 0.22..0.44
362
+ const averagePercent = 35 + Math.floor(tilt * 35) // 35..70
363
+ const recommendation: "easy" | "medium" | "hard" =
364
+ averagePercent < 40 ? "easy" : averagePercent > 65 ? "hard" : "medium"
365
+ return {
366
+ recommendation,
367
+ note: `Based on your content, this question is predicted ${recommendation}. ${mediumShare}% of items in ${folder.name} are Medium (avg PBI ${pbi.toFixed(2)}).`,
368
+ pbi,
369
+ averagePercent,
370
+ }
371
+ }
372
+
373
+ // ─────────────────────────────────────────────────────────────────────────────
374
+ // Local helpers (this file only — not new shared primitives)
375
+ // ─────────────────────────────────────────────────────────────────────────────
376
+
377
+
378
+ function InspectorSection({
379
+ title,
380
+ htmlFor,
381
+ children,
382
+ description,
383
+ }: {
384
+ title: string
385
+ htmlFor?: string
386
+ children: React.ReactNode
387
+ description?: React.ReactNode
388
+ }) {
389
+ return (
390
+ <section className="flex flex-col gap-2">
391
+ <Label
392
+ htmlFor={htmlFor}
393
+ className="text-xs font-medium text-muted-foreground"
394
+ >
395
+ {title}
396
+ </Label>
397
+ {children}
398
+ {description ? (
399
+ <p className="text-[11px] leading-snug text-muted-foreground">{description}</p>
400
+ ) : null}
401
+ </section>
402
+ )
403
+ }
404
+
405
+ function BuilderSection({
406
+ title,
407
+ required,
408
+ hint,
409
+ children,
410
+ }: {
411
+ title: string
412
+ required?: boolean
413
+ hint?: React.ReactNode
414
+ children: React.ReactNode
415
+ }) {
416
+ return (
417
+ <section className="flex flex-col gap-2">
418
+ <div className="flex items-baseline justify-between gap-2">
419
+ <h2 className="text-sm font-semibold text-foreground">
420
+ {title}
421
+ {required ? (
422
+ <span className="ml-1 text-destructive" aria-hidden="true">
423
+ *
424
+ </span>
425
+ ) : null}
426
+ </h2>
427
+ {hint ? <span className="text-xs text-muted-foreground">{hint}</span> : null}
428
+ </div>
429
+ {children}
430
+ </section>
431
+ )
432
+ }
433
+
434
+ // ─── Folder picker (Popover + Command) ──────────────────────────────────────
435
+ //
436
+ // Compact selected tile — same visual rhythm as the collapsed
437
+ // "Question format" card. Clicking the tile opens a Command popover with
438
+ // search, full folder list, and an "Add new folder" footer that bridges to
439
+ // `QuestionBankNewFolderSheet`. Visuals reuse the folder-color tokens from
440
+ // `lib/mock/question-bank-folders.ts` so the inspector reads the same as
441
+ // the rest of the question bank.
442
+
443
+ const FOLDER_TINT_BG: Record<QuestionBankFolderColorKey, string> = {
444
+ brand: "bg-[var(--icon-disc-brand-bg)] text-[var(--icon-disc-brand-fg)]",
445
+ success: "bg-[var(--icon-disc-chart-2-bg)] text-[var(--icon-disc-chart-2-fg)]",
446
+ warning: "bg-[var(--icon-disc-chart-4-bg)] text-[var(--icon-disc-chart-4-fg)]",
447
+ destructive: "bg-destructive/15 text-destructive",
448
+ muted: "bg-muted text-muted-foreground",
449
+ chart1: "bg-[color-mix(in_oklch,var(--color-chart-1)_15%,transparent)] text-[var(--color-chart-1)]",
450
+ chart2: "bg-[var(--icon-disc-chart-2-bg)] text-[var(--icon-disc-chart-2-fg)]",
451
+ chart3: "bg-[color-mix(in_oklch,var(--color-chart-3)_15%,transparent)] text-[var(--color-chart-3)]",
452
+ }
453
+
454
+ interface FolderPickerControlProps {
455
+ folders: QuestionBankFolder[]
456
+ value: string
457
+ onChange: (id: string) => void
458
+ open: boolean
459
+ onOpenChange: (open: boolean) => void
460
+ onRequestNewFolder: () => void
461
+ }
462
+
463
+ /** Build a tree-ordered array: parents first, then their children
464
+ indented beneath. Each entry carries a `depth` for left-padding. */
465
+ function buildFolderTree(
466
+ folders: QuestionBankFolder[],
467
+ ): Array<{ folder: QuestionBankFolder; depth: number }> {
468
+ const roots = folders.filter(f => f.parentId == null)
469
+ const childrenOf = (parentId: string) =>
470
+ folders.filter(f => f.parentId === parentId)
471
+
472
+ const out: Array<{ folder: QuestionBankFolder; depth: number }> = []
473
+ function walk(parent: QuestionBankFolder, depth: number) {
474
+ out.push({ folder: parent, depth })
475
+ for (const child of childrenOf(parent.id)) {
476
+ walk(child, depth + 1)
477
+ }
478
+ }
479
+ for (const root of roots) walk(root, 0)
480
+ return out
481
+ }
482
+
483
+ function FolderPickerControl({
484
+ folders,
485
+ value,
486
+ onChange,
487
+ open,
488
+ onOpenChange,
489
+ onRequestNewFolder,
490
+ }: FolderPickerControlProps) {
491
+ const selected = folders.find(f => f.id === value)
492
+ const tint = selected ? FOLDER_TINT_BG[selected.colorKey] : FOLDER_TINT_BG.muted
493
+ const tree = React.useMemo(() => buildFolderTree(folders), [folders])
494
+
495
+ return (
496
+ <Popover open={open} onOpenChange={onOpenChange}>
497
+ <PopoverTrigger asChild>
498
+ <button
499
+ type="button"
500
+ aria-haspopup="listbox"
501
+ aria-expanded={open}
502
+ aria-label={
503
+ selected
504
+ ? `Change location — currently ${folderBreadcrumb(selected.id, folders)}`
505
+ : "Pick a location"
506
+ }
507
+ className={cn(
508
+ "group flex w-full items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 text-left transition-colors",
509
+ "hover:border-foreground/30 hover:bg-muted/40",
510
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
511
+ )}
512
+ >
513
+ <span
514
+ className={cn(
515
+ "inline-flex size-9 shrink-0 items-center justify-center rounded-md",
516
+ tint,
517
+ )}
518
+ aria-hidden="true"
519
+ >
520
+ <i className={cn("fa-light text-base", selected?.icon ?? "fa-folder")} />
521
+ </span>
522
+ <span className="flex min-w-0 flex-1 flex-col">
523
+ <span className="truncate text-sm font-medium text-foreground">
524
+ {selected ? selected.name : "Pick a location"}
525
+ </span>
526
+ <span className="truncate text-xs text-muted-foreground">
527
+ {selected
528
+ ? selected.parentId
529
+ ? `in ${folders.find(p => p.id === selected.parentId)?.name}`
530
+ : "Top-level folder"
531
+ : "Required"}
532
+ </span>
533
+ </span>
534
+ <span
535
+ className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors group-hover:bg-muted group-hover:text-foreground"
536
+ aria-hidden="true"
537
+ >
538
+ <i className="fa-light fa-chevron-down text-xs" />
539
+ </span>
540
+ </button>
541
+ </PopoverTrigger>
542
+ <PopoverContent
543
+ align="start"
544
+ sideOffset={6}
545
+ className="w-[var(--radix-popover-trigger-width)] min-w-[260px] p-0"
546
+ >
547
+ <Command>
548
+ <CommandInput placeholder="Search folders…" />
549
+ <CommandList>
550
+ <CommandEmpty>No folders match that search.</CommandEmpty>
551
+ <CommandGroup heading="Folders">
552
+ {tree.map(({ folder: f, depth }) => {
553
+ const itemTint = FOLDER_TINT_BG[f.colorKey]
554
+ return (
555
+ <CommandItem
556
+ key={f.id}
557
+ value={f.name}
558
+ data-checked={f.id === value}
559
+ onSelect={() => {
560
+ onChange(f.id)
561
+ onOpenChange(false)
562
+ }}
563
+ style={{ paddingLeft: `${8 + depth * 20}px` }}
564
+ >
565
+ {depth > 0 ? (
566
+ <span
567
+ className="text-muted-foreground/40"
568
+ aria-hidden="true"
569
+ >
570
+ <i className="fa-light fa-turn-down-right text-[11px]" />
571
+ </span>
572
+ ) : null}
573
+ <span
574
+ className={cn(
575
+ "inline-flex size-7 shrink-0 items-center justify-center rounded-md",
576
+ itemTint,
577
+ )}
578
+ aria-hidden="true"
579
+ >
580
+ <i className={cn("fa-light text-sm", f.icon)} />
581
+ </span>
582
+ <span className="truncate text-sm text-foreground">
583
+ {f.name}
584
+ </span>
585
+ </CommandItem>
586
+ )
587
+ })}
588
+ </CommandGroup>
589
+ <CommandSeparator />
590
+ <CommandGroup>
591
+ <CommandItem
592
+ value="__new-folder__"
593
+ onSelect={onRequestNewFolder}
594
+ className="text-foreground"
595
+ >
596
+ <span
597
+ className="inline-flex size-7 shrink-0 items-center justify-center rounded-md bg-[var(--icon-disc-brand-bg)] text-[var(--icon-disc-brand-fg)]"
598
+ aria-hidden="true"
599
+ >
600
+ <i className="fa-light fa-folder-plus text-sm" />
601
+ </span>
602
+ <span className="text-sm font-medium">New folder…</span>
603
+ </CommandItem>
604
+ </CommandGroup>
605
+ </CommandList>
606
+ </Command>
607
+ </PopoverContent>
608
+ </Popover>
609
+ )
610
+ }
611
+
612
+ // ─── Difficulty meter (predicted from content + PBI + folder note) ───────────
613
+ //
614
+ // AI analyses the question content (stem length, option count, Bloom's level,
615
+ // vocabulary complexity) to **predict** how difficult examinees will find this
616
+ // item. The author can override by flipping to Manual mode.
617
+
618
+ interface DifficultyMeterProps {
619
+ value: "easy" | "medium" | "hard"
620
+ onChange: (next: "easy" | "medium" | "hard") => void
621
+ mode: "auto" | "manual"
622
+ onModeChange: (next: "auto" | "manual") => void
623
+ insight: ReturnType<typeof difficultyInsightForFolder>
624
+ }
625
+
626
+ function DifficultyMeter({
627
+ value,
628
+ onChange,
629
+ mode,
630
+ onModeChange,
631
+ insight,
632
+ }: DifficultyMeterProps) {
633
+ const pbiPercent = Math.min(100, Math.max(0, insight.pbi * 100))
634
+ const pbiTone =
635
+ insight.pbi >= 0.3
636
+ ? "bg-[var(--color-chart-2)]"
637
+ : insight.pbi >= 0.2
638
+ ? "bg-[var(--color-chart-4)]"
639
+ : "bg-destructive"
640
+
641
+ const levelLabel =
642
+ value === "easy" ? "Easy" : value === "hard" ? "Hard" : "Medium"
643
+
644
+ return (
645
+ <section className="flex flex-col gap-3">
646
+ <Label className="text-xs font-medium text-muted-foreground">
647
+ Predicted difficulty
648
+ </Label>
649
+
650
+ <div className="flex flex-col gap-3 rounded-lg border border-border bg-muted/20 p-3">
651
+ {/* Level heading + AI / Manual toggle */}
652
+ <div className="flex items-center justify-between gap-2">
653
+ <h3 className="text-base font-semibold text-foreground">
654
+ {levelLabel}
655
+ </h3>
656
+ <Tip
657
+ side="top"
658
+ label={
659
+ mode === "auto"
660
+ ? "Override the predicted difficulty"
661
+ : "Let AI predict difficulty from your content"
662
+ }
663
+ >
664
+ <Button
665
+ type="button"
666
+ variant="ghost"
667
+ size="sm"
668
+ className="h-6 gap-1 px-2 text-[11px] font-medium"
669
+ onClick={() => onModeChange(mode === "auto" ? "manual" : "auto")}
670
+ aria-pressed={mode === "manual"}
671
+ >
672
+ {mode === "auto" ? (
673
+ <>
674
+ <i className="fa-light fa-sparkles text-[10px]" aria-hidden="true" />
675
+ Predicted
676
+ </>
677
+ ) : (
678
+ <>
679
+ <i className="fa-light fa-hand-pointer text-[10px]" aria-hidden="true" />
680
+ Manual
681
+ </>
682
+ )}
683
+ </Button>
684
+ </Tip>
685
+ </div>
686
+
687
+ {/* Manual chips — only shown when the author has overridden. */}
688
+ {mode === "manual" ? (
689
+ <ToggleGroup
690
+ type="single"
691
+ variant="outline"
692
+ size="sm"
693
+ spacing={1}
694
+ value={value}
695
+ onValueChange={v => { if (v) onChange(v as "easy" | "medium" | "hard") }}
696
+ className="flex-wrap"
697
+ >
698
+ {AUTHORING_DIFFICULTY_OPTIONS.map(d => (
699
+ <ToggleGroupItem
700
+ key={d.value}
701
+ value={d.value}
702
+ title={d.description}
703
+ className="rounded-full px-3"
704
+ >
705
+ {d.label}
706
+ </ToggleGroupItem>
707
+ ))}
708
+ </ToggleGroup>
709
+ ) : null}
710
+
711
+ {/* PBI score bar — the only progress indicator, so the section
712
+ reads as a single unified card instead of two separate ones. */}
713
+ <div className="flex flex-col gap-1">
714
+ <div className="flex items-center justify-between">
715
+ <Tip
716
+ side="top"
717
+ label="Point-Biserial Index — correlation between getting this question right and total score. Above 0.30 is good; below 0.20 suggests the item needs review."
718
+ >
719
+ <span
720
+ tabIndex={0}
721
+ role="img"
722
+ aria-label="PBI score — Point-Biserial Index, a measure of question quality"
723
+ className="inline-flex items-center gap-1 text-xs font-medium text-muted-foreground"
724
+ >
725
+ PBI score
726
+ <i
727
+ className="fa-light fa-circle-info text-[10px] text-muted-foreground/70"
728
+ aria-hidden="true"
729
+ />
730
+ </span>
731
+ </Tip>
732
+ <span className="font-mono tabular-nums text-xs font-medium text-foreground">
733
+ {insight.pbi.toFixed(2)}
734
+ </span>
735
+ </div>
736
+ <div className="h-1.5 rounded-full bg-muted">
737
+ <div
738
+ className={cn("h-full rounded-full transition-[width] duration-200", pbiTone)}
739
+ style={{ width: `${pbiPercent}%` }}
740
+ />
741
+ </div>
742
+ </div>
743
+
744
+ {/* Folder note */}
745
+ <p className="flex items-start gap-1.5 text-[11px] leading-snug text-muted-foreground">
746
+ <i
747
+ className="fa-light fa-circle-info mt-0.5 text-[10px]"
748
+ aria-hidden="true"
749
+ />
750
+ <span>{insight.note}</span>
751
+ </p>
752
+ </div>
753
+ </section>
754
+ )
755
+ }
756
+
757
+ // ─────────────────────────────────────────────────────────────────────────────
758
+ // Composer
759
+ // ─────────────────────────────────────────────────────────────────────────────
760
+
761
+ interface NewQuestionComposerProps {
762
+ /** Assigned on the server — keeps SSR and hydration in sync. */
763
+ draftQuestionId: string
764
+ defaultFolderId?: string
765
+ /** Where to send the user when they cancel or save. */
766
+ backHref: string
767
+ folders?: QuestionBankFolder[]
768
+ }
769
+
770
+ export function NewQuestionComposer({
771
+ draftQuestionId,
772
+ defaultFolderId,
773
+ backHref,
774
+ folders = DEFAULT_QUESTION_BANK_FOLDERS,
775
+ }: NewQuestionComposerProps) {
776
+ const router = useRouter()
777
+ const [submitting, setSubmitting] = React.useState(false)
778
+ const [tagDraft, setTagDraft] = React.useState("")
779
+ const [inspectorOpen, setInspectorOpen] = React.useState(true)
780
+ const [moreOpen, setMoreOpen] = React.useState(false)
781
+ /** Question-type chooser visibility — collapses into a single
782
+ "selected type" tile once the author picks the first time so the
783
+ inspector stays compact for the rest of the authoring flow. */
784
+ const [typeChooserOpen, setTypeChooserOpen] = React.useState(true)
785
+ /** Local folder list — extended in-place when the author adds one
786
+ from the location picker so the new entry is selectable without
787
+ a page navigation. */
788
+ const [localFolders, setLocalFolders] = React.useState<QuestionBankFolder[]>(folders)
789
+ React.useEffect(() => {
790
+ setLocalFolders(prev =>
791
+ prev.length === folders.length && prev.every((f, i) => f.id === folders[i]?.id)
792
+ ? prev
793
+ : folders,
794
+ )
795
+ }, [folders])
796
+ const [folderPickerOpen, setFolderPickerOpen] = React.useState(false)
797
+ const [newFolderOpen, setNewFolderOpen] = React.useState(false)
798
+ /** "auto" → the meter follows the AI recommendation derived from the
799
+ selected folder's history; "manual" → the author has overridden
800
+ the level via the chip row. */
801
+ const [difficultyMode, setDifficultyMode] = React.useState<"auto" | "manual">("auto")
802
+
803
+ const initialFolderId =
804
+ defaultFolderId && folders.some(f => f.id === defaultFolderId)
805
+ ? defaultFolderId
806
+ : folders.find(f => f.id === "fld-favorites")?.id ?? folders[0]?.id ?? "fld-favorites"
807
+
808
+ const form = useForm<QuestionFormValues>({
809
+ resolver: zodResolver(questionSchema) as Resolver<QuestionFormValues>,
810
+ mode: "onTouched",
811
+ defaultValues: {
812
+ type: "mcq_single",
813
+ status: "draft",
814
+ folderId: initialFolderId,
815
+ leadIn: "",
816
+ options: buildInitialOptions("mcq_single"),
817
+ rationale: "",
818
+ references: [],
819
+ numericValue: "",
820
+ numericTolerance: "",
821
+ numericUnits: "",
822
+ pairs: buildInitialPairs(),
823
+ orderedItems: buildInitialOrderedItems(),
824
+ fillBlankAnswers: buildInitialFillBlankAnswers(),
825
+ difficulty: "medium",
826
+ bloom: "",
827
+ cogLevel: "",
828
+ tags: [],
829
+ },
830
+ })
831
+
832
+ // `useWatch` is React-Compiler-safe; `form.watch()` is not. Same fix as
833
+ // `new-placement-form.tsx`.
834
+ const watchedType = useWatch({ control: form.control, name: "type" })
835
+ const watchedOptions = useWatch({ control: form.control, name: "options" })
836
+ const watchedPairs = useWatch({ control: form.control, name: "pairs" })
837
+ const watchedOrderedItems = useWatch({ control: form.control, name: "orderedItems" })
838
+ const watchedFillBlankAnswers = useWatch({
839
+ control: form.control,
840
+ name: "fillBlankAnswers",
841
+ })
842
+ const watchedTags = useWatch({ control: form.control, name: "tags" })
843
+ const watchedFolderId = useWatch({ control: form.control, name: "folderId" })
844
+ const watchedDifficulty = useWatch({ control: form.control, name: "difficulty" })
845
+
846
+ const difficultyInsight = React.useMemo(
847
+ () => difficultyInsightForFolder(localFolders.find(f => f.id === watchedFolderId)),
848
+ [localFolders, watchedFolderId],
849
+ )
850
+ // When the AI is in charge, mirror its recommendation into the form.
851
+ // Avoids race conditions where the meter shows one value but submit
852
+ // ships another.
853
+ React.useEffect(() => {
854
+ if (difficultyMode === "auto" && watchedDifficulty !== difficultyInsight.recommendation) {
855
+ form.setValue("difficulty", difficultyInsight.recommendation, {
856
+ shouldDirty: false,
857
+ shouldValidate: false,
858
+ })
859
+ }
860
+ }, [difficultyMode, difficultyInsight.recommendation, watchedDifficulty, form])
861
+
862
+ const activeType = authoringQuestionType(watchedType)
863
+ const isMulti = watchedType === "mcq_multiple"
864
+ const isMcq = watchedType === "mcq_single" || watchedType === "mcq_multiple"
865
+ const isTrueFalse = watchedType === "true_false"
866
+ const isShortAnswer = watchedType === "short_answer"
867
+ const isEssay = watchedType === "essay"
868
+ const isNumeric = watchedType === "numeric"
869
+ const isFillBlank = watchedType === "fill_blank"
870
+ const isMatching = watchedType === "matching"
871
+ const isOrdering = watchedType === "ordering"
872
+ const isHotspot = watchedType === "hotspot"
873
+ const showOptionsBlock = isMcq || isTrueFalse
874
+ const correctCount = watchedOptions.filter(o => o.isCorrect).length
875
+
876
+ function changeType(next: AuthoringQuestionType) {
877
+ const prev = form.getValues("type")
878
+ // Always collapse the chooser after an interaction — even when the
879
+ // author re-clicks the already-selected tile to confirm their choice.
880
+ setTypeChooserOpen(false)
881
+ if (prev === next) return
882
+ form.setValue("type", next, { shouldValidate: false })
883
+
884
+ // mcq_single ↔ mcq_multiple preserves the typed options the author has
885
+ // already drafted; any other transition rebuilds the options block.
886
+ const mcqFamily = new Set(["mcq_single", "mcq_multiple"])
887
+ if (mcqFamily.has(prev) && mcqFamily.has(next)) {
888
+ if (next !== "mcq_multiple") {
889
+ const opts = form.getValues("options")
890
+ let firstCorrect = true
891
+ form.setValue(
892
+ "options",
893
+ opts.map(o => {
894
+ if (!o.isCorrect) return o
895
+ if (firstCorrect) {
896
+ firstCorrect = false
897
+ return o
898
+ }
899
+ return { ...o, isCorrect: false }
900
+ }),
901
+ { shouldValidate: false },
902
+ )
903
+ }
904
+ return
905
+ }
906
+ form.setValue("options", buildInitialOptions(next), { shouldValidate: false })
907
+ }
908
+
909
+ function patchOption(id: string, patch: Partial<QuestionFormValues["options"][number]>) {
910
+ form.setValue(
911
+ "options",
912
+ form.getValues("options").map(o => (o.id === id ? { ...o, ...patch } : o)),
913
+ { shouldDirty: true },
914
+ )
915
+ }
916
+
917
+ function toggleCorrect(id: string) {
918
+ const opts = form.getValues("options")
919
+ const next = opts.map(o => {
920
+ if (o.id === id) return { ...o, isCorrect: !o.isCorrect }
921
+ if (!isMulti) return { ...o, isCorrect: false }
922
+ return o
923
+ })
924
+ form.setValue("options", next, { shouldValidate: false, shouldDirty: true })
925
+ }
926
+
927
+ function addOption() {
928
+ const opts = form.getValues("options")
929
+ if (opts.length >= AUTHORING_MAX_OPTION_COUNT) return
930
+ form.setValue(
931
+ "options",
932
+ [...opts, { id: newOptionId(), text: "", isCorrect: false, rationale: "" }],
933
+ { shouldDirty: true },
934
+ )
935
+ }
936
+
937
+ function removeOption(id: string) {
938
+ const opts = form.getValues("options")
939
+ if (opts.length <= AUTHORING_MIN_OPTION_COUNT) return
940
+ form.setValue(
941
+ "options",
942
+ opts.filter(o => o.id !== id),
943
+ { shouldDirty: true },
944
+ )
945
+ }
946
+
947
+ function addReference() {
948
+ const refs = form.getValues("references")
949
+ form.setValue("references", [...refs, { id: newReferenceId(), citation: "" }], {
950
+ shouldDirty: true,
951
+ })
952
+ }
953
+ function removeReference(id: string) {
954
+ form.setValue(
955
+ "references",
956
+ form.getValues("references").filter(r => r.id !== id),
957
+ { shouldDirty: true },
958
+ )
959
+ }
960
+
961
+ function patchPair(id: string, patch: Partial<QuestionFormValues["pairs"][number]>) {
962
+ form.setValue(
963
+ "pairs",
964
+ form.getValues("pairs").map(p => (p.id === id ? { ...p, ...patch } : p)),
965
+ { shouldDirty: true },
966
+ )
967
+ }
968
+ function addPair() {
969
+ form.setValue(
970
+ "pairs",
971
+ [...form.getValues("pairs"), { id: newPairId(), left: "", right: "" }],
972
+ { shouldDirty: true },
973
+ )
974
+ }
975
+ function removePair(id: string) {
976
+ const pairs = form.getValues("pairs")
977
+ if (pairs.length <= 2) return
978
+ form.setValue(
979
+ "pairs",
980
+ pairs.filter(p => p.id !== id),
981
+ { shouldDirty: true },
982
+ )
983
+ }
984
+
985
+ function patchOrdered(id: string, text: string) {
986
+ form.setValue(
987
+ "orderedItems",
988
+ form.getValues("orderedItems").map(i => (i.id === id ? { ...i, text } : i)),
989
+ { shouldDirty: true },
990
+ )
991
+ }
992
+ function addOrdered() {
993
+ form.setValue(
994
+ "orderedItems",
995
+ [...form.getValues("orderedItems"), { id: newOrderedId(), text: "" }],
996
+ { shouldDirty: true },
997
+ )
998
+ }
999
+ function removeOrdered(id: string) {
1000
+ const items = form.getValues("orderedItems")
1001
+ if (items.length <= 2) return
1002
+ form.setValue(
1003
+ "orderedItems",
1004
+ items.filter(i => i.id !== id),
1005
+ { shouldDirty: true },
1006
+ )
1007
+ }
1008
+ function moveOrdered(id: string, delta: -1 | 1) {
1009
+ const items = form.getValues("orderedItems")
1010
+ const idx = items.findIndex(i => i.id === id)
1011
+ if (idx < 0) return
1012
+ const target = idx + delta
1013
+ if (target < 0 || target >= items.length) return
1014
+ const next = items.slice()
1015
+ const [moved] = next.splice(idx, 1)
1016
+ next.splice(target, 0, moved)
1017
+ form.setValue("orderedItems", next, { shouldDirty: true })
1018
+ }
1019
+
1020
+ function patchFillBlank(id: string, accepted: string) {
1021
+ form.setValue(
1022
+ "fillBlankAnswers",
1023
+ form.getValues("fillBlankAnswers").map(a => (a.id === id ? { ...a, accepted } : a)),
1024
+ { shouldDirty: true },
1025
+ )
1026
+ }
1027
+ function addFillBlank() {
1028
+ form.setValue(
1029
+ "fillBlankAnswers",
1030
+ [...form.getValues("fillBlankAnswers"), { id: newBlankId(), accepted: "" }],
1031
+ { shouldDirty: true },
1032
+ )
1033
+ }
1034
+ function removeFillBlank(id: string) {
1035
+ const items = form.getValues("fillBlankAnswers")
1036
+ if (items.length <= 1) return
1037
+ form.setValue(
1038
+ "fillBlankAnswers",
1039
+ items.filter(a => a.id !== id),
1040
+ { shouldDirty: true },
1041
+ )
1042
+ }
1043
+
1044
+ function commitTag() {
1045
+ const t = tagDraft.trim()
1046
+ if (!t) return
1047
+ const tags = form.getValues("tags")
1048
+ if (!tags.includes(t)) {
1049
+ form.setValue("tags", [...tags, t], { shouldDirty: true })
1050
+ }
1051
+ setTagDraft("")
1052
+ }
1053
+ function removeTag(t: string) {
1054
+ form.setValue(
1055
+ "tags",
1056
+ form.getValues("tags").filter(x => x !== t),
1057
+ { shouldDirty: true },
1058
+ )
1059
+ }
1060
+
1061
+ /** Two save actions — Save Question (primary, full validation, status
1062
+ moves to In review) and Save as draft (secondary, skips validation,
1063
+ status stays Draft). Both route back to the parent hub on success;
1064
+ no toasts per `exxat-no-toast.mdc`. */
1065
+ async function persist(values: QuestionFormValues, mode: "publish" | "draft") {
1066
+ setSubmitting(true)
1067
+ await new Promise(r => setTimeout(r, 700))
1068
+ devLog(`Question ${mode === "publish" ? "saved" : "drafted"} (${draftQuestionId}):`, values)
1069
+ setSubmitting(false)
1070
+ router.push(backHref)
1071
+ }
1072
+ function handleSaveQuestion() {
1073
+ void form.handleSubmit(values => persist({ ...values, status: "in_review" }, "publish"))()
1074
+ }
1075
+ function handleSaveAsDraft() {
1076
+ if (submitting) return
1077
+ const values = { ...form.getValues(), status: "draft" as const }
1078
+ void persist(values, "draft")
1079
+ }
1080
+ function handleCancel() {
1081
+ router.push(backHref)
1082
+ }
1083
+
1084
+ // PageHeader subtitle — question id + version + last-updated stamp.
1085
+ const headerSubtitle = (
1086
+ <>
1087
+ <span className="font-mono tabular-nums">{draftQuestionId}</span>
1088
+ {" · V1 · Last updated just now"}
1089
+ </>
1090
+ )
1091
+
1092
+ return (
1093
+ <Form {...form}>
1094
+ {/* Global shortcuts — bound while the composer is mounted. The
1095
+ `useShortcut` hook skips inputs/textarea/contenteditable so
1096
+ Enter still types newlines inside the stem; Save fires only
1097
+ when focus is on chrome. */}
1098
+ <Shortcut keys="Escape" disabled={submitting} onInvoke={handleCancel} />
1099
+ <Shortcut keys="Enter" disabled={submitting} onInvoke={handleSaveQuestion} />
1100
+ <Shortcut
1101
+ keys="⌘⌥M"
1102
+ disabled={submitting}
1103
+ onInvoke={() => setMoreOpen(o => !o)}
1104
+ />
1105
+ <Shortcut
1106
+ keys="⌘⌥]"
1107
+ disabled={submitting}
1108
+ onInvoke={() => setInspectorOpen(o => !o)}
1109
+ />
1110
+
1111
+ <form
1112
+ onSubmit={form.handleSubmit(values => persist({ ...values, status: "in_review" }, "publish"))}
1113
+ noValidate
1114
+ aria-label="New question form"
1115
+ className="flex flex-col gap-6"
1116
+ >
1117
+ <PageHeader
1118
+ title="New question"
1119
+ subtitle={headerSubtitle}
1120
+ actions={
1121
+ <>
1122
+ {/* Save as draft — icon-only button (leftmost). */}
1123
+ <Tip side="bottom" label="Save as draft">
1124
+ <Button
1125
+ type="button"
1126
+ size="lg"
1127
+ variant="outline"
1128
+ className="aspect-square px-0"
1129
+ disabled={submitting}
1130
+ onClick={handleSaveAsDraft}
1131
+ aria-label="Save as draft"
1132
+ >
1133
+ <i className="fa-light fa-file-pen text-base" aria-hidden="true" />
1134
+ </Button>
1135
+ </Tip>
1136
+
1137
+ {/* Primary — Save Question. Full validation; the
1138
+ question moves to In review and the user is
1139
+ returned to the parent hub. */}
1140
+ <Button
1141
+ type="button"
1142
+ size="lg"
1143
+ disabled={submitting}
1144
+ aria-busy={submitting}
1145
+ onClick={handleSaveQuestion}
1146
+ >
1147
+ {submitting ? (
1148
+ <>
1149
+ <i
1150
+ className="fa-light fa-spinner-third fa-spin text-[13px]"
1151
+ aria-hidden="true"
1152
+ />
1153
+ Saving…
1154
+ </>
1155
+ ) : (
1156
+ <>
1157
+ Save question
1158
+ <KbdGroup className="ml-1.5">
1159
+ <Kbd variant="bare">⏎</Kbd>
1160
+ </KbdGroup>
1161
+ </>
1162
+ )}
1163
+ </Button>
1164
+
1165
+ {/* More — overflow menu (⌘⌥M). */}
1166
+ <DropdownMenu open={moreOpen} onOpenChange={setMoreOpen}>
1167
+ <Tip side="bottom" label="More actions">
1168
+ <DropdownMenuTrigger asChild>
1169
+ <Button
1170
+ type="button"
1171
+ size="lg"
1172
+ variant="outline"
1173
+ className="aspect-square px-0"
1174
+ aria-label="More actions"
1175
+ >
1176
+ <i
1177
+ className="fa-light fa-ellipsis text-base"
1178
+ aria-hidden="true"
1179
+ />
1180
+ </Button>
1181
+ </DropdownMenuTrigger>
1182
+ </Tip>
1183
+ <DropdownMenuContent align="end">
1184
+ <DropdownMenuItem
1185
+ shortcut="⌘⌥]"
1186
+ onSelect={() => {
1187
+ window.setTimeout(
1188
+ () => setInspectorOpen(o => !o),
1189
+ 0,
1190
+ )
1191
+ }}
1192
+ >
1193
+ <i
1194
+ className={cn(
1195
+ "fa-light",
1196
+ inspectorOpen ? "fa-sidebar-flip" : "fa-sidebar",
1197
+ )}
1198
+ aria-hidden="true"
1199
+ />
1200
+ {inspectorOpen ? "Hide inspector" : "Show inspector"}
1201
+ </DropdownMenuItem>
1202
+ <DropdownMenuSeparator />
1203
+ <DropdownMenuItem
1204
+ shortcut="Esc"
1205
+ onSelect={() => {
1206
+ window.setTimeout(() => handleCancel(), 0)
1207
+ }}
1208
+ variant="destructive"
1209
+ >
1210
+ <i className="fa-light fa-trash-can" aria-hidden="true" />
1211
+ Discard draft
1212
+ </DropdownMenuItem>
1213
+ </DropdownMenuContent>
1214
+ </DropdownMenu>
1215
+ </>
1216
+ }
1217
+ className="px-0 lg:px-0"
1218
+ />
1219
+
1220
+ {/* ── 2-column body — scrolls with the page; inspector sticky (lg+) */}
1221
+ <div
1222
+ className={cn(
1223
+ "flex flex-col gap-8",
1224
+ "lg:flex-row lg:items-start",
1225
+ inspectorOpen ? "lg:gap-x-10" : "lg:gap-x-4",
1226
+ )}
1227
+ >
1228
+ {/* ── Builder (no Card chrome) ─────────────────────────────── */}
1229
+ <div className="flex min-w-0 flex-1 flex-col gap-7 lg:pr-2">
1230
+ {/* Question prompt — a real bordered field, not an
1231
+ inline-editable headline. Larger heading-weight font keeps
1232
+ the question front-and-centre, but the visible border /
1233
+ padding tell the author "this is the field you type in"
1234
+ instead of suggesting a click-to-edit document. */}
1235
+ <FormField
1236
+ control={form.control}
1237
+ name="leadIn"
1238
+ render={({ field }) => (
1239
+ <FormItem className="gap-1.5">
1240
+ <Label
1241
+ htmlFor="qb-lead-in"
1242
+ className="text-xs font-medium text-muted-foreground"
1243
+ >
1244
+ Question
1245
+ </Label>
1246
+ <FormControl>
1247
+ <Textarea
1248
+ {...field}
1249
+ id="qb-lead-in"
1250
+ placeholder={
1251
+ isFillBlank
1252
+ ? "Type your fill-in-the-blank question here. Use {{1}}, {{2}}, … to mark the blanks."
1253
+ : isMatching
1254
+ ? "Match each item on the left to its match on the right."
1255
+ : isOrdering
1256
+ ? "Place the following steps in the correct order."
1257
+ : isHotspot
1258
+ ? "Click the correct region of the image below."
1259
+ : isNumeric
1260
+ ? "What is the calculated value? (Include units in the answer field.)"
1261
+ : AUTHORING_LEAD_IN_PLACEHOLDER
1262
+ }
1263
+ rows={3}
1264
+ aria-required="true"
1265
+ className={cn(
1266
+ "min-h-[5rem] resize-y leading-snug",
1267
+ "text-lg font-semibold tracking-tight md:text-xl",
1268
+ "placeholder:font-medium placeholder:text-muted-foreground",
1269
+ )}
1270
+ />
1271
+ </FormControl>
1272
+ <FormMessage />
1273
+ </FormItem>
1274
+ )}
1275
+ />
1276
+
1277
+ {/* MCQ family + True/False — bordered OptionRows. */}
1278
+ {showOptionsBlock ? (
1279
+ <FormField
1280
+ control={form.control}
1281
+ name="options"
1282
+ render={() => (
1283
+ <FormItem>
1284
+ <BuilderSection
1285
+ title="Answer choices"
1286
+ hint={`${watchedOptions.length} options · ${correctCount} correct`}
1287
+ >
1288
+ <p className="text-xs text-muted-foreground">
1289
+ {isMulti
1290
+ ? "NCLEX-style — mark every correct response."
1291
+ : isTrueFalse
1292
+ ? "Mark whether the statement is True or False."
1293
+ : "Mark the single best answer. Distractors should be plausible — same length and grammar as the correct option."}
1294
+ </p>
1295
+ <div className="space-y-2">
1296
+ {watchedOptions.map((opt, idx) => (
1297
+ <OptionRow
1298
+ key={opt.id}
1299
+ letter={OPTION_LETTERS[idx] ?? `${idx + 1}`}
1300
+ option={opt}
1301
+ locked={isTrueFalse}
1302
+ canRemove={
1303
+ !isTrueFalse &&
1304
+ watchedOptions.length > AUTHORING_MIN_OPTION_COUNT
1305
+ }
1306
+ onTextChange={t => patchOption(opt.id, { text: t })}
1307
+ onToggleCorrect={() => toggleCorrect(opt.id)}
1308
+ onRationaleChange={r => patchOption(opt.id, { rationale: r })}
1309
+ onRemove={() => removeOption(opt.id)}
1310
+ />
1311
+ ))}
1312
+ </div>
1313
+ {!isTrueFalse ? (
1314
+ <Button
1315
+ type="button"
1316
+ variant="outline"
1317
+ size="sm"
1318
+ onClick={addOption}
1319
+ disabled={watchedOptions.length >= AUTHORING_MAX_OPTION_COUNT}
1320
+ className="self-start"
1321
+ >
1322
+ <i className="fa-light fa-plus" aria-hidden="true" />
1323
+ Add option
1324
+ </Button>
1325
+ ) : null}
1326
+ <FormMessage />
1327
+ </BuilderSection>
1328
+ </FormItem>
1329
+ )}
1330
+ />
1331
+ ) : null}
1332
+
1333
+ {/* Numeric — value, tolerance, units. */}
1334
+ {isNumeric ? (
1335
+ <BuilderSection
1336
+ title="Correct value"
1337
+ required
1338
+ hint="Auto-graded on submit — accept ± tolerance band"
1339
+ >
1340
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1fr)]">
1341
+ <FormField
1342
+ control={form.control}
1343
+ name="numericValue"
1344
+ render={({ field }) => (
1345
+ <FormItem className="gap-1.5">
1346
+ <Label htmlFor="qb-numeric-value" className="text-xs text-muted-foreground">
1347
+ Answer
1348
+ </Label>
1349
+ <FormControl>
1350
+ <Input
1351
+ {...field}
1352
+ id="qb-numeric-value"
1353
+ inputMode="decimal"
1354
+ placeholder="e.g. 12"
1355
+ />
1356
+ </FormControl>
1357
+ <FormMessage />
1358
+ </FormItem>
1359
+ )}
1360
+ />
1361
+ <FormField
1362
+ control={form.control}
1363
+ name="numericTolerance"
1364
+ render={({ field }) => (
1365
+ <FormItem className="gap-1.5">
1366
+ <Label htmlFor="qb-numeric-tol" className="text-xs text-muted-foreground">
1367
+ Tolerance ±
1368
+ </Label>
1369
+ <FormControl>
1370
+ <Input
1371
+ {...field}
1372
+ id="qb-numeric-tol"
1373
+ inputMode="decimal"
1374
+ placeholder="e.g. 0.5"
1375
+ />
1376
+ </FormControl>
1377
+ <FormDescription className="text-[11px]">
1378
+ Absolute, in the same unit
1379
+ </FormDescription>
1380
+ </FormItem>
1381
+ )}
1382
+ />
1383
+ <FormField
1384
+ control={form.control}
1385
+ name="numericUnits"
1386
+ render={({ field }) => (
1387
+ <FormItem className="gap-1.5">
1388
+ <Label htmlFor="qb-numeric-unit" className="text-xs text-muted-foreground">
1389
+ Units
1390
+ </Label>
1391
+ <FormControl>
1392
+ <Input
1393
+ {...field}
1394
+ id="qb-numeric-unit"
1395
+ placeholder="e.g. mEq/L"
1396
+ />
1397
+ </FormControl>
1398
+ </FormItem>
1399
+ )}
1400
+ />
1401
+ </div>
1402
+ </BuilderSection>
1403
+ ) : null}
1404
+
1405
+ {/* Fill in the blank — accepted answers per blank. */}
1406
+ {isFillBlank ? (
1407
+ <BuilderSection
1408
+ title="Accepted answers"
1409
+ required
1410
+ hint={`${watchedFillBlankAnswers.length} blank${watchedFillBlankAnswers.length === 1 ? "" : "s"}`}
1411
+ >
1412
+ <p className="text-xs text-muted-foreground">
1413
+ One row per blank — separate accepted variants with a comma. Numbered
1414
+ to match the <code className="rounded bg-muted px-1 py-0.5 text-[11px]">{`{{1}}`}</code>{" "}
1415
+ markers in the lead-in.
1416
+ </p>
1417
+ <div className="space-y-2">
1418
+ {watchedFillBlankAnswers.map((ans, idx) => (
1419
+ <div key={ans.id} className="flex items-center gap-2">
1420
+ <span
1421
+ className="inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-medium text-muted-foreground tabular-nums"
1422
+ aria-hidden="true"
1423
+ >
1424
+ {idx + 1}
1425
+ </span>
1426
+ <Input
1427
+ value={ans.accepted}
1428
+ onChange={e => patchFillBlank(ans.id, e.target.value)}
1429
+ placeholder="e.g. STEMI, ST-elevation MI"
1430
+ aria-label={`Blank ${idx + 1} accepted answers`}
1431
+ />
1432
+ {watchedFillBlankAnswers.length > 1 ? (
1433
+ <Tip side="top" label={`Remove blank ${idx + 1}`}>
1434
+ <Button
1435
+ type="button"
1436
+ variant="ghost"
1437
+ size="icon-sm"
1438
+ onClick={() => removeFillBlank(ans.id)}
1439
+ aria-label={`Remove blank ${idx + 1}`}
1440
+ >
1441
+ <i className="fa-light fa-xmark" aria-hidden="true" />
1442
+ </Button>
1443
+ </Tip>
1444
+ ) : null}
1445
+ </div>
1446
+ ))}
1447
+ </div>
1448
+ <Button
1449
+ type="button"
1450
+ variant="outline"
1451
+ size="sm"
1452
+ onClick={addFillBlank}
1453
+ className="self-start"
1454
+ >
1455
+ <i className="fa-light fa-plus" aria-hidden="true" />
1456
+ Add blank
1457
+ </Button>
1458
+ <FormMessage />
1459
+ </BuilderSection>
1460
+ ) : null}
1461
+
1462
+ {/* Matching — pair editor. */}
1463
+ {isMatching ? (
1464
+ <BuilderSection
1465
+ title="Pairs"
1466
+ required
1467
+ hint={`${watchedPairs.length} pair${watchedPairs.length === 1 ? "" : "s"} · learners drag right side to match left`}
1468
+ >
1469
+ <div className="space-y-2">
1470
+ {watchedPairs.map((p, idx) => (
1471
+ <div
1472
+ key={p.id}
1473
+ className="grid grid-cols-1 items-center gap-2 sm:grid-cols-[24px_minmax(0,1fr)_24px_minmax(0,1fr)_auto]"
1474
+ >
1475
+ <span
1476
+ className="inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-medium text-muted-foreground tabular-nums"
1477
+ aria-hidden="true"
1478
+ >
1479
+ {idx + 1}
1480
+ </span>
1481
+ <Input
1482
+ value={p.left}
1483
+ onChange={e => patchPair(p.id, { left: e.target.value })}
1484
+ placeholder="Prompt — e.g. β-blocker"
1485
+ aria-label={`Pair ${idx + 1} prompt`}
1486
+ />
1487
+ <i
1488
+ className="fa-light fa-arrow-right hidden text-muted-foreground/60 sm:block"
1489
+ aria-hidden="true"
1490
+ />
1491
+ <Input
1492
+ value={p.right}
1493
+ onChange={e => patchPair(p.id, { right: e.target.value })}
1494
+ placeholder="Match — e.g. blocks β1 receptors"
1495
+ aria-label={`Pair ${idx + 1} match`}
1496
+ />
1497
+ {watchedPairs.length > 2 ? (
1498
+ <Tip side="top" label={`Remove pair ${idx + 1}`}>
1499
+ <Button
1500
+ type="button"
1501
+ variant="ghost"
1502
+ size="icon-sm"
1503
+ onClick={() => removePair(p.id)}
1504
+ aria-label={`Remove pair ${idx + 1}`}
1505
+ >
1506
+ <i className="fa-light fa-xmark" aria-hidden="true" />
1507
+ </Button>
1508
+ </Tip>
1509
+ ) : null}
1510
+ </div>
1511
+ ))}
1512
+ </div>
1513
+ <Button
1514
+ type="button"
1515
+ variant="outline"
1516
+ size="sm"
1517
+ onClick={addPair}
1518
+ className="self-start"
1519
+ >
1520
+ <i className="fa-light fa-plus" aria-hidden="true" />
1521
+ Add pair
1522
+ </Button>
1523
+ <FormMessage />
1524
+ </BuilderSection>
1525
+ ) : null}
1526
+
1527
+ {/* Ordering — ordered list editor with up/down moves. */}
1528
+ {isOrdering ? (
1529
+ <BuilderSection
1530
+ title="Items in correct order"
1531
+ required
1532
+ hint={`${watchedOrderedItems.length} step${watchedOrderedItems.length === 1 ? "" : "s"} · the order shown here is the answer key`}
1533
+ >
1534
+ <div className="space-y-2">
1535
+ {watchedOrderedItems.map((item, idx) => (
1536
+ <div
1537
+ key={item.id}
1538
+ className="flex items-center gap-2 rounded-lg border border-border px-2.5 py-2"
1539
+ >
1540
+ <span
1541
+ className="inline-flex size-7 shrink-0 items-center justify-center rounded-md bg-[var(--icon-disc-brand-bg)] text-xs font-semibold text-[var(--icon-disc-brand-fg)] tabular-nums"
1542
+ aria-hidden="true"
1543
+ >
1544
+ {idx + 1}
1545
+ </span>
1546
+ <Input
1547
+ value={item.text}
1548
+ onChange={e => patchOrdered(item.id, e.target.value)}
1549
+ placeholder={`Step ${idx + 1} — e.g. Check responsiveness`}
1550
+ aria-label={`Step ${idx + 1}`}
1551
+ className="border-0 bg-transparent shadow-none focus-visible:ring-0"
1552
+ />
1553
+ <div className="flex shrink-0 items-center gap-0.5">
1554
+ <Tip side="top" label="Move up">
1555
+ <Button
1556
+ type="button"
1557
+ variant="ghost"
1558
+ size="icon-sm"
1559
+ disabled={idx === 0}
1560
+ onClick={() => moveOrdered(item.id, -1)}
1561
+ aria-label={`Move step ${idx + 1} up`}
1562
+ >
1563
+ <i className="fa-light fa-arrow-up" aria-hidden="true" />
1564
+ </Button>
1565
+ </Tip>
1566
+ <Tip side="top" label="Move down">
1567
+ <Button
1568
+ type="button"
1569
+ variant="ghost"
1570
+ size="icon-sm"
1571
+ disabled={idx === watchedOrderedItems.length - 1}
1572
+ onClick={() => moveOrdered(item.id, 1)}
1573
+ aria-label={`Move step ${idx + 1} down`}
1574
+ >
1575
+ <i className="fa-light fa-arrow-down" aria-hidden="true" />
1576
+ </Button>
1577
+ </Tip>
1578
+ {watchedOrderedItems.length > 2 ? (
1579
+ <Tip side="top" label="Remove step">
1580
+ <Button
1581
+ type="button"
1582
+ variant="ghost"
1583
+ size="icon-sm"
1584
+ onClick={() => removeOrdered(item.id)}
1585
+ aria-label={`Remove step ${idx + 1}`}
1586
+ >
1587
+ <i className="fa-light fa-xmark" aria-hidden="true" />
1588
+ </Button>
1589
+ </Tip>
1590
+ ) : null}
1591
+ </div>
1592
+ </div>
1593
+ ))}
1594
+ </div>
1595
+ <Button
1596
+ type="button"
1597
+ variant="outline"
1598
+ size="sm"
1599
+ onClick={addOrdered}
1600
+ className="self-start"
1601
+ >
1602
+ <i className="fa-light fa-plus" aria-hidden="true" />
1603
+ Add step
1604
+ </Button>
1605
+ <FormMessage />
1606
+ </BuilderSection>
1607
+ ) : null}
1608
+
1609
+ {/* Hotspot — placeholder image picker (full region drawing
1610
+ arrives with the asset pipeline; this is a clean empty state). */}
1611
+ {isHotspot ? (
1612
+ <BuilderSection title="Reference image" hint="Upload + draw correct regions">
1613
+ <div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border bg-muted/20 px-6 py-10 text-center">
1614
+ <div
1615
+ className="flex size-12 items-center justify-center rounded-full bg-background text-muted-foreground"
1616
+ aria-hidden="true"
1617
+ >
1618
+ <i className="fa-light fa-bullseye-pointer text-lg" />
1619
+ </div>
1620
+ <div className="max-w-md">
1621
+ <p className="text-sm font-medium text-foreground">
1622
+ Image hotspot authoring
1623
+ </p>
1624
+ <p className="mt-1 text-xs text-muted-foreground">
1625
+ Upload an anatomy diagram, x-ray, or ECG, then draw the correct
1626
+ region(s). Image upload + region drawing tools arrive in the
1627
+ next phase — for now, describe the expected target in the
1628
+ explanation below.
1629
+ </p>
1630
+ </div>
1631
+ <Button type="button" variant="outline" size="sm" disabled>
1632
+ <i className="fa-light fa-arrow-up-from-bracket" aria-hidden="true" />
1633
+ Upload image (coming soon)
1634
+ </Button>
1635
+ </div>
1636
+ </BuilderSection>
1637
+ ) : null}
1638
+
1639
+ <FormField
1640
+ control={form.control}
1641
+ name="rationale"
1642
+ render={({ field }) => (
1643
+ <FormItem>
1644
+ <BuilderSection
1645
+ title={
1646
+ isShortAnswer
1647
+ ? "Model answer"
1648
+ : isEssay
1649
+ ? "Grading rubric"
1650
+ : "Explanation & rationale"
1651
+ }
1652
+ required={isShortAnswer || isEssay}
1653
+ hint={
1654
+ isShortAnswer
1655
+ ? "Canonical answer + accepted variants"
1656
+ : isEssay
1657
+ ? "Criteria reviewers use to score"
1658
+ : "Cite mechanism, guideline, or reference"
1659
+ }
1660
+ >
1661
+ <FormControl>
1662
+ <Textarea
1663
+ {...field}
1664
+ placeholder={
1665
+ isShortAnswer
1666
+ ? "e.g. 50 mg/dL\nAccepted: 50, 50.0, fifty milligrams per deciliter"
1667
+ : isEssay
1668
+ ? "Full marks (4): identifies DKA, orders ABG + serum ketones, starts isotonic fluids, replaces K+ before insulin.\nPartial (2-3): identifies DKA but misses K+ replacement.\nMinimal (0-1): misses DKA diagnosis."
1669
+ : AUTHORING_RATIONALE_PLACEHOLDER
1670
+ }
1671
+ rows={isEssay ? 7 : 5}
1672
+ className={cn(
1673
+ "resize-y leading-relaxed",
1674
+ isEssay ? "min-h-[160px]" : "min-h-[120px]",
1675
+ )}
1676
+ />
1677
+ </FormControl>
1678
+ <FormMessage />
1679
+ </BuilderSection>
1680
+ </FormItem>
1681
+ )}
1682
+ />
1683
+
1684
+ {!isShortAnswer && !isEssay ? (
1685
+ <FormField
1686
+ control={form.control}
1687
+ name="references"
1688
+ render={({ field }) => (
1689
+ <FormItem>
1690
+ <BuilderSection
1691
+ title="References"
1692
+ hint={
1693
+ field.value.length === 0
1694
+ ? "Optional"
1695
+ : `${field.value.length} cited`
1696
+ }
1697
+ >
1698
+ {field.value.length === 0 ? (
1699
+ <FormDescription>
1700
+ Add Harrison&apos;s, UpToDate, AHA / ATS guidelines, or
1701
+ a journal article a reviewer can verify against.
1702
+ </FormDescription>
1703
+ ) : (
1704
+ <div className="space-y-2">
1705
+ {field.value.map((r, idx) => (
1706
+ <div key={r.id} className="flex items-center gap-2">
1707
+ <span
1708
+ className="inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-muted text-xs font-medium text-muted-foreground"
1709
+ aria-hidden="true"
1710
+ >
1711
+ {idx + 1}
1712
+ </span>
1713
+ <Input
1714
+ value={r.citation}
1715
+ onChange={e =>
1716
+ form.setValue(
1717
+ "references",
1718
+ field.value.map(x =>
1719
+ x.id === r.id
1720
+ ? { ...x, citation: e.target.value }
1721
+ : x,
1722
+ ),
1723
+ { shouldDirty: true },
1724
+ )
1725
+ }
1726
+ placeholder='e.g. Harrison&apos;s Internal Medicine, 21st ed., ch. 269'
1727
+ aria-label={`Reference ${idx + 1} citation`}
1728
+ />
1729
+ <Tip side="top" label="Remove reference">
1730
+ <Button
1731
+ type="button"
1732
+ variant="ghost"
1733
+ size="icon-sm"
1734
+ onClick={() => removeReference(r.id)}
1735
+ aria-label={`Remove reference ${idx + 1}`}
1736
+ >
1737
+ <i className="fa-light fa-xmark" aria-hidden="true" />
1738
+ </Button>
1739
+ </Tip>
1740
+ </div>
1741
+ ))}
1742
+ </div>
1743
+ )}
1744
+ <Button
1745
+ type="button"
1746
+ variant="outline"
1747
+ size="sm"
1748
+ onClick={addReference}
1749
+ className="self-start"
1750
+ >
1751
+ <i className="fa-light fa-plus" aria-hidden="true" />
1752
+ Add reference
1753
+ </Button>
1754
+ <FormMessage />
1755
+ </BuilderSection>
1756
+ </FormItem>
1757
+ )}
1758
+ />
1759
+ ) : null}
1760
+ </div>
1761
+
1762
+ {/* ── Inspector (right rail) — sticky while the page scrolls (lg+) */}
1763
+ <aside
1764
+ aria-label="Question inspector"
1765
+ className={cn(
1766
+ "shrink-0",
1767
+ "lg:pl-2",
1768
+ inspectorOpen ? "lg:w-[320px]" : "lg:w-14",
1769
+ )}
1770
+ >
1771
+ {!inspectorOpen ? (
1772
+ <div
1773
+ className={cn(
1774
+ "flex w-12 flex-col items-center gap-1 rounded-xl bg-[var(--secondary-panel-bg)] px-1.5 py-2 ring-1 ring-border shadow-sm",
1775
+ "lg:sticky lg:top-4 lg:z-10",
1776
+ )}
1777
+ >
1778
+ <Tip side="left" label="Show inspector">
1779
+ <button
1780
+ type="button"
1781
+ onClick={() => setInspectorOpen(true)}
1782
+ aria-label="Show inspector"
1783
+ aria-expanded={false}
1784
+ className={cn(
1785
+ "flex size-9 shrink-0 items-center justify-center rounded-md text-sidebar-foreground transition-colors",
1786
+ "hover:bg-sidebar-accent/50",
1787
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
1788
+ )}
1789
+ >
1790
+ <i
1791
+ className="fa-light fa-arrow-left-to-line text-[15px] leading-none"
1792
+ aria-hidden="true"
1793
+ />
1794
+ </button>
1795
+ </Tip>
1796
+ </div>
1797
+ ) : (
1798
+ <div
1799
+ className={cn(
1800
+ "flex flex-col gap-6 rounded-xl border border-border bg-card p-4",
1801
+ "lg:sticky lg:top-4 lg:z-10",
1802
+ "lg:max-h-[calc(100dvh-var(--header-height)-2rem)] lg:overflow-y-auto lg:overscroll-y-contain",
1803
+ )}
1804
+ >
1805
+ {/* Inspector header — title + collapse control. */}
1806
+ <div className="flex items-center justify-between">
1807
+ <p className="text-xs font-medium text-muted-foreground">
1808
+ Inspector
1809
+ </p>
1810
+ <Tip side="bottom" label="Hide inspector">
1811
+ <Button
1812
+ type="button"
1813
+ variant="ghost"
1814
+ size="icon-sm"
1815
+ onClick={() => setInspectorOpen(false)}
1816
+ aria-label="Hide inspector"
1817
+ aria-expanded={true}
1818
+ >
1819
+ <i
1820
+ className="fa-light fa-arrow-right-to-line"
1821
+ aria-hidden="true"
1822
+ />
1823
+ </Button>
1824
+ </Tip>
1825
+ </div>
1826
+
1827
+ {/* Question format — first-time landing shows the full
1828
+ `SelectionTileGrid` (matches the "File format" pattern
1829
+ in `ExportDrawer`). After the author picks once it
1830
+ collapses into a single selected-type tile with a
1831
+ "Change" affordance that re-opens the grid. */}
1832
+ <FormField
1833
+ control={form.control}
1834
+ name="type"
1835
+ render={({ field }) => (
1836
+ <FormItem>
1837
+ <FormControl>
1838
+ {typeChooserOpen ? (
1839
+ <SelectionTileGrid
1840
+ sectionLabel="Question format"
1841
+ options={QUESTION_TYPE_TILES}
1842
+ columns={2}
1843
+ value={field.value}
1844
+ onValueChange={v => changeType(v)}
1845
+ interaction="radio"
1846
+ idPrefix="qb-format"
1847
+ itemVariant="outline"
1848
+ itemMotion="pop"
1849
+ />
1850
+ ) : (
1851
+ <div className="flex flex-col gap-2">
1852
+ <Label
1853
+ className="text-xs font-medium text-muted-foreground"
1854
+ >
1855
+ Question format
1856
+ </Label>
1857
+ <button
1858
+ type="button"
1859
+ onClick={() => setTypeChooserOpen(true)}
1860
+ aria-label={`Change question format — currently ${activeType.label}`}
1861
+ className={cn(
1862
+ "group flex items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 text-left transition-colors",
1863
+ "hover:border-foreground/30 hover:bg-muted/40",
1864
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1865
+ )}
1866
+ >
1867
+ <span
1868
+ className="inline-flex size-9 shrink-0 items-center justify-center rounded-md bg-[var(--icon-disc-brand-bg)] text-[var(--icon-disc-brand-fg)]"
1869
+ aria-hidden="true"
1870
+ >
1871
+ <i className={cn("fa-light text-base", activeType.icon)} />
1872
+ </span>
1873
+ <span className="flex min-w-0 flex-1 flex-col">
1874
+ <span className="truncate text-sm font-medium text-foreground">
1875
+ {activeType.label}
1876
+ </span>
1877
+ <span className="truncate text-xs text-muted-foreground">
1878
+ {activeType.tileSummary ?? activeType.description}
1879
+ </span>
1880
+ </span>
1881
+ <span
1882
+ className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors group-hover:bg-muted group-hover:text-foreground"
1883
+ aria-hidden="true"
1884
+ >
1885
+ <i className="fa-light fa-pen-to-square text-xs" />
1886
+ </span>
1887
+ </button>
1888
+ </div>
1889
+ )}
1890
+ </FormControl>
1891
+ {typeChooserOpen ? (
1892
+ <FormDescription>{activeType.description}</FormDescription>
1893
+ ) : null}
1894
+ <FormMessage />
1895
+ </FormItem>
1896
+ )}
1897
+ />
1898
+
1899
+ {/* Location — compact selected tile (mirrors the
1900
+ collapsed question-format card). Click opens a Command
1901
+ popover with search and an "Add new folder" footer
1902
+ that bridges into `QuestionBankNewFolderSheet`. */}
1903
+ <FormField
1904
+ control={form.control}
1905
+ name="folderId"
1906
+ render={({ field }) => (
1907
+ <FormItem>
1908
+ <Label
1909
+ className="text-xs font-medium text-muted-foreground"
1910
+ >
1911
+ Location
1912
+ </Label>
1913
+ <FormControl>
1914
+ <FolderPickerControl
1915
+ folders={localFolders}
1916
+ value={field.value}
1917
+ onChange={v => field.onChange(v)}
1918
+ open={folderPickerOpen}
1919
+ onOpenChange={setFolderPickerOpen}
1920
+ onRequestNewFolder={() => {
1921
+ setFolderPickerOpen(false)
1922
+ setNewFolderOpen(true)
1923
+ }}
1924
+ />
1925
+ </FormControl>
1926
+ <FormMessage />
1927
+ </FormItem>
1928
+ )}
1929
+ />
1930
+
1931
+ {/* Difficulty — meter + AI estimate + PBI + folder note.
1932
+ Defaults to AI mode (the meter follows the folder
1933
+ recommendation); "Override" flips to manual chips for
1934
+ authors who want to lock the level themselves. */}
1935
+ <FormField
1936
+ control={form.control}
1937
+ name="difficulty"
1938
+ render={({ field }) => (
1939
+ <DifficultyMeter
1940
+ value={field.value}
1941
+ onChange={v => field.onChange(v)}
1942
+ mode={difficultyMode}
1943
+ onModeChange={setDifficultyMode}
1944
+ insight={difficultyInsight}
1945
+ />
1946
+ )}
1947
+ />
1948
+
1949
+ <FormField
1950
+ control={form.control}
1951
+ name="bloom"
1952
+ render={({ field }) => (
1953
+ <InspectorSection title="Bloom's taxonomy">
1954
+ <ToggleGroup
1955
+ type="single"
1956
+ variant="outline"
1957
+ size="sm"
1958
+ spacing={1}
1959
+ value={field.value}
1960
+ onValueChange={v => field.onChange(v)}
1961
+ className="flex-wrap"
1962
+ >
1963
+ {AUTHORING_BLOOM_OPTIONS.map(b => (
1964
+ <ToggleGroupItem
1965
+ key={b.value}
1966
+ value={b.value}
1967
+ title={b.hint}
1968
+ className="rounded-full px-3"
1969
+ >
1970
+ {b.label}
1971
+ </ToggleGroupItem>
1972
+ ))}
1973
+ </ToggleGroup>
1974
+ </InspectorSection>
1975
+ )}
1976
+ />
1977
+
1978
+ <FormField
1979
+ control={form.control}
1980
+ name="cogLevel"
1981
+ render={({ field }) => (
1982
+ <InspectorSection
1983
+ title="NBME cognitive level"
1984
+ description="Distinct from Bloom — broader buckets used by NBME item writers."
1985
+ >
1986
+ <ToggleGroup
1987
+ type="single"
1988
+ variant="outline"
1989
+ size="sm"
1990
+ spacing={1}
1991
+ value={field.value}
1992
+ onValueChange={v => field.onChange(v)}
1993
+ className="flex-wrap"
1994
+ >
1995
+ {AUTHORING_COG_LEVEL_OPTIONS.map(c => (
1996
+ <ToggleGroupItem
1997
+ key={c.value}
1998
+ value={c.value}
1999
+ title={c.hint}
2000
+ className="rounded-full px-3"
2001
+ >
2002
+ {c.label}
2003
+ </ToggleGroupItem>
2004
+ ))}
2005
+ </ToggleGroup>
2006
+ </InspectorSection>
2007
+ )}
2008
+ />
2009
+
2010
+ <InspectorSection title="Tags" htmlFor="qb-tag-input">
2011
+ {watchedTags.length > 0 ? (
2012
+ <div className="flex flex-wrap gap-1.5">
2013
+ {watchedTags.map(t => (
2014
+ <Badge key={t} variant="secondary" className="gap-1.5">
2015
+ <span>#{t}</span>
2016
+ <Tip side="top" label={`Remove tag ${t}`}>
2017
+ <button
2018
+ type="button"
2019
+ onClick={() => removeTag(t)}
2020
+ aria-label={`Remove tag ${t}`}
2021
+ className="-mr-0.5 inline-flex size-3.5 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
2022
+ >
2023
+ <i
2024
+ className="fa-light fa-xmark text-[9px]"
2025
+ aria-hidden="true"
2026
+ />
2027
+ </button>
2028
+ </Tip>
2029
+ </Badge>
2030
+ ))}
2031
+ </div>
2032
+ ) : null}
2033
+ <Input
2034
+ id="qb-tag-input"
2035
+ value={tagDraft}
2036
+ onChange={e => setTagDraft(e.target.value)}
2037
+ onKeyDown={e => {
2038
+ if (e.key === "Enter" || e.key === ",") {
2039
+ e.preventDefault()
2040
+ commitTag()
2041
+ }
2042
+ }}
2043
+ onBlur={commitTag}
2044
+ placeholder="STEMI, antibiotics…"
2045
+ className="h-8 text-xs"
2046
+ />
2047
+ </InspectorSection>
2048
+ </div>
2049
+ )}
2050
+ </aside>
2051
+ </div>
2052
+ </form>
2053
+
2054
+ {/* New folder — invoked from the location picker. Re-uses the
2055
+ shared `QuestionBankNewFolderSheet` (same shell as the folder
2056
+ hub) so the surface stays consistent. The created folder is
2057
+ appended to `localFolders` and immediately selected. */}
2058
+ <QuestionBankNewFolderSheet
2059
+ open={newFolderOpen}
2060
+ onOpenChange={setNewFolderOpen}
2061
+ parentFolderId={null}
2062
+ descriptionText="Drafts created from this composer can land in the new folder right away."
2063
+ onCreated={f => {
2064
+ const id = newFolderId()
2065
+ const created: QuestionBankFolder = { id, ...f }
2066
+ setLocalFolders(prev => [...prev, created])
2067
+ form.setValue("folderId", id, { shouldDirty: true, shouldValidate: false })
2068
+ }}
2069
+ />
2070
+ </Form>
2071
+ )
2072
+ }
2073
+
2074
+ // ─────────────────────────────────────────────────────────────────────────────
2075
+ // Option row — composes Checkbox + Input + Textarea, with the same
2076
+ // "ticked-row" treatment the placement form's compliance checklist uses.
2077
+ // ─────────────────────────────────────────────────────────────────────────────
2078
+
2079
+ interface OptionRowProps {
2080
+ letter: string
2081
+ option: { id: string; text: string; isCorrect: boolean; rationale: string }
2082
+ locked: boolean
2083
+ canRemove: boolean
2084
+ onTextChange: (t: string) => void
2085
+ onToggleCorrect: () => void
2086
+ onRationaleChange: (t: string) => void
2087
+ onRemove: () => void
2088
+ }
2089
+
2090
+ function OptionRow({
2091
+ letter,
2092
+ option,
2093
+ locked,
2094
+ canRemove,
2095
+ onTextChange,
2096
+ onToggleCorrect,
2097
+ onRationaleChange,
2098
+ onRemove,
2099
+ }: OptionRowProps) {
2100
+ const [rationaleOpen, setRationaleOpen] = React.useState(option.rationale.length > 0)
2101
+ const checkboxId = `qb-opt-${option.id}-correct`
2102
+
2103
+ return (
2104
+ <div
2105
+ className={cn(
2106
+ "flex flex-col gap-2 rounded-lg border border-border px-3 py-2.5 transition-colors",
2107
+ "has-[[data-state=checked]]:border-brand has-[[data-state=checked]]:bg-[var(--icon-disc-brand-bg)]",
2108
+ )}
2109
+ >
2110
+ <div className="flex items-center gap-3">
2111
+ <span
2112
+ className={cn(
2113
+ "inline-flex size-8 shrink-0 items-center justify-center rounded-md text-sm font-semibold",
2114
+ option.isCorrect
2115
+ ? "bg-[var(--icon-disc-brand-bg)] text-[var(--icon-disc-brand-fg)]"
2116
+ : "bg-muted text-muted-foreground",
2117
+ )}
2118
+ aria-hidden="true"
2119
+ >
2120
+ {letter}
2121
+ </span>
2122
+
2123
+ <Input
2124
+ value={option.text}
2125
+ onChange={e => onTextChange(e.target.value)}
2126
+ placeholder={
2127
+ option.isCorrect
2128
+ ? "Correct answer — phrased the way the SME would defend it"
2129
+ : "Plausible distractor — same length and grammar as the correct answer"
2130
+ }
2131
+ disabled={locked}
2132
+ aria-label={`Option ${letter} text`}
2133
+ /* Option text reads as h2 — same size as a section heading
2134
+ so the answer choices stand out from the meta chrome. */
2135
+ className="h-11 text-base font-medium md:text-lg"
2136
+ />
2137
+
2138
+ <div className="flex shrink-0 items-center gap-2">
2139
+ <div className="flex items-center gap-2 rounded-md px-2 py-1">
2140
+ <Checkbox
2141
+ id={checkboxId}
2142
+ checked={option.isCorrect}
2143
+ onCheckedChange={() => onToggleCorrect()}
2144
+ aria-label={
2145
+ option.isCorrect
2146
+ ? `Option ${letter} marked correct`
2147
+ : `Mark option ${letter} correct`
2148
+ }
2149
+ />
2150
+ <Label htmlFor={checkboxId} className="cursor-pointer text-xs font-medium">
2151
+ Correct
2152
+ </Label>
2153
+ </div>
2154
+
2155
+ {!locked ? (
2156
+ <Tip side="top" label={rationaleOpen ? "Hide rationale" : "Add rationale"}>
2157
+ <Button
2158
+ type="button"
2159
+ variant="ghost"
2160
+ size="icon-sm"
2161
+ onClick={() => setRationaleOpen(o => !o)}
2162
+ aria-expanded={rationaleOpen}
2163
+ aria-label={
2164
+ rationaleOpen
2165
+ ? `Hide rationale for option ${letter}`
2166
+ : `Add rationale for option ${letter}`
2167
+ }
2168
+ >
2169
+ <i className="fa-light fa-message-pen" aria-hidden="true" />
2170
+ </Button>
2171
+ </Tip>
2172
+ ) : null}
2173
+
2174
+ {canRemove ? (
2175
+ <Tip side="top" label="Remove option">
2176
+ <Button
2177
+ type="button"
2178
+ variant="ghost"
2179
+ size="icon-sm"
2180
+ onClick={onRemove}
2181
+ aria-label={`Remove option ${letter}`}
2182
+ >
2183
+ <i className="fa-light fa-xmark" aria-hidden="true" />
2184
+ </Button>
2185
+ </Tip>
2186
+ ) : null}
2187
+ </div>
2188
+ </div>
2189
+
2190
+ {rationaleOpen && !locked ? (
2191
+ <div className="pl-10">
2192
+ <Textarea
2193
+ value={option.rationale}
2194
+ onChange={e => onRationaleChange(e.target.value)}
2195
+ placeholder={
2196
+ option.isCorrect
2197
+ ? "Why this is the single best answer (mechanism, guideline)…"
2198
+ : "Why this distractor is plausible but wrong (common confusion)…"
2199
+ }
2200
+ rows={2}
2201
+ className="min-h-[64px] resize-y text-sm"
2202
+ aria-label={`Option ${letter} rationale`}
2203
+ />
2204
+ </div>
2205
+ ) : null}
2206
+ </div>
2207
+ )
2208
+ }