@exxatdesignux/ui 0.2.16 → 0.2.17

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 (89) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +148 -3
  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-mono-ids/SKILL.md +56 -0
  7. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +14 -0
  8. package/package.json +3 -3
  9. package/src/components/ui/banner.tsx +2 -0
  10. package/src/components/ui/chart.tsx +57 -2
  11. package/src/components/ui/sidebar.tsx +1 -0
  12. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  13. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  14. package/template/AGENTS.md +18 -15
  15. package/template/app/(app)/data-list/page.tsx +2 -2
  16. package/template/app/(app)/question-bank/layout.tsx +18 -5
  17. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  18. package/template/app/globals.css +108 -1
  19. package/template/app/layout.tsx +41 -5
  20. package/template/components/app-sidebar.tsx +68 -34
  21. package/template/components/ask-leo-sidebar.tsx +0 -2
  22. package/template/components/brand-color-picker.tsx +344 -0
  23. package/template/components/compliance-list-view.tsx +33 -51
  24. package/template/components/compliance-table.tsx +24 -0
  25. package/template/components/data-table/index.tsx +68 -24
  26. package/template/components/data-table/pagination.tsx +0 -1
  27. package/template/components/data-table/types.ts +4 -1
  28. package/template/components/data-table/use-table-state.ts +243 -94
  29. package/template/components/data-views/data-row-list.tsx +183 -0
  30. package/template/components/data-views/index.ts +7 -3
  31. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  32. package/template/components/export-drawer.tsx +1 -1
  33. package/template/components/exxat-product-logo.tsx +172 -317
  34. package/template/components/invite-collaborators-drawer.tsx +5 -3
  35. package/template/components/key-metrics.tsx +74 -46
  36. package/template/components/new-placement-form.tsx +4 -2
  37. package/template/components/new-question-composer.tsx +2208 -0
  38. package/template/components/page-breadcrumb-trail.tsx +131 -0
  39. package/template/components/page-header.tsx +2 -1
  40. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
  41. package/template/components/placement-detail.tsx +1 -1
  42. package/template/components/placements-board-view.tsx +1 -1
  43. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  44. package/template/components/placements-list-view.tsx +18 -132
  45. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  46. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  47. package/template/components/placements-table-columns.tsx +2 -2
  48. package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
  49. package/template/components/product-switcher.tsx +26 -8
  50. package/template/components/product-wordmark.tsx +285 -0
  51. package/template/components/question-bank-client.tsx +20 -2
  52. package/template/components/question-bank-hub-client.tsx +108 -115
  53. package/template/components/question-bank-list-view.tsx +30 -54
  54. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  55. package/template/components/question-bank-secondary-nav.tsx +0 -3
  56. package/template/components/question-bank-table.tsx +30 -5
  57. package/template/components/rotations-empty-state.tsx +3 -0
  58. package/template/components/secondary-panel.tsx +23 -3
  59. package/template/components/settings-appearance-card.tsx +584 -141
  60. package/template/components/site-header.tsx +36 -31
  61. package/template/components/sites-list-view.tsx +31 -36
  62. package/template/components/sites-table.tsx +24 -0
  63. package/template/components/table-properties/drawer.tsx +1 -1
  64. package/template/components/team-client.tsx +1 -1
  65. package/template/components/team-list-view.tsx +34 -50
  66. package/template/components/team-table.tsx +29 -3
  67. package/template/components/templates/nested-secondary-panel-shell.tsx +8 -2
  68. package/template/components/ui/dot-pattern.tsx +50 -26
  69. package/template/components/ui/leo-icon.tsx +23 -3
  70. package/template/contexts/product-context.tsx +51 -7
  71. package/template/contexts/system-banner-context.tsx +112 -4
  72. package/template/eslint.config.mjs +18 -0
  73. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  74. package/template/lib/data-list-persistence.ts +57 -257
  75. package/template/lib/dev-log.test.ts +6 -5
  76. package/template/lib/exxat-palette.json +1462 -0
  77. package/template/lib/exxat-palette.ts +136 -0
  78. package/template/lib/list-page-table-properties.ts +1 -1
  79. package/template/lib/list-status-badges.ts +1 -1
  80. package/template/lib/mailto.ts +29 -0
  81. package/template/lib/placement-board-card-layout.ts +1 -1
  82. package/template/lib/product-brand.ts +268 -0
  83. package/template/lib/question-bank-authoring.ts +308 -0
  84. package/template/lib/question-bank-nav.ts +44 -0
  85. package/template/lib/raf-throttle.ts +45 -0
  86. package/template/lib/table-state-lifecycle.ts +474 -0
  87. package/template/next.config.mjs +156 -0
  88. package/template/package.json +3 -3
  89. package/template/stores/app-store.ts +46 -1
@@ -8,10 +8,8 @@ import { OsFolderGlyph } from "@/components/data-views/os-folder-glyph"
8
8
  import { AskLeoComposer } from "@/components/ask-leo-composer"
9
9
  import { useAskLeo, useAskLeoPageContext } from "@/components/ask-leo-sidebar"
10
10
  import { PrimaryPageTemplate } from "@/components/templates/primary-page-template"
11
- import { Button } from "@/components/ui/button"
12
11
  import { Shortcut } from "@/components/ui/dropdown-menu"
13
- import { Kbd, KbdGroup } from "@/components/ui/kbd"
14
- import { Tip } from "@/components/ui/tip"
12
+ import { useSidebar } from "@/components/ui/sidebar"
15
13
  import { useAltKeyLabel, useModKeyLabel } from "@/hooks/use-mod-key-label"
16
14
  import {
17
15
  DEFAULT_QUESTION_BANK_FOLDERS,
@@ -27,8 +25,10 @@ import {
27
25
  } from "@/lib/question-bank-nav"
28
26
  import { cn } from "@/lib/utils"
29
27
 
30
- const NEW_QUESTION_PROMPT =
31
- "Help me create a new assessment question. Start by asking for topic, item type, difficulty, and learning objective, then draft the stem, answer choices, and rationale."
28
+ const NEW_QUESTION_AUTHORING_PATH = "/question-bank/new"
29
+
30
+ const DRAFT_WITH_LEO_PROMPT =
31
+ "Help me draft a new assessment question. Ask me for the topic, item type (NBME single best answer / NCLEX SATA / vignette / EMQ / T/F / short answer), Bloom level, and discipline, then propose stem, lead-in, answer options, and rationale I can paste into the authoring composer."
32
32
 
33
33
  const TEMPLATE_PROMPT =
34
34
  "Walk me through choosing a question template (MCQ, OSCE, short answer, true/false) and produce a starter item with stem, options, rationale, and tags."
@@ -140,7 +140,8 @@ function formatRelativeDate(iso: string): string {
140
140
 
141
141
  export function QuestionBankHubClient() {
142
142
  const router = useRouter()
143
- const { openWithPrompt, toggle } = useAskLeo()
143
+ const { openWithPrompt } = useAskLeo()
144
+ const { setOpen: setMainSidebarOpen } = useSidebar()
144
145
  const mod = useModKeyLabel()
145
146
  const alt = useAltKeyLabel()
146
147
 
@@ -160,8 +161,19 @@ export function QuestionBankHubClient() {
160
161
  [openWithPrompt],
161
162
  )
162
163
 
164
+ /**
165
+ * Navigate to the full-page authoring composer (`/question-bank/new`).
166
+ * Mirrors the Placements "New placement" pre-collapse: animates the sidebar
167
+ * closed first so the user sees one smooth transition into the focused flow
168
+ * (the route also mounts `SidebarAutoCollapse` to lock it shut while there).
169
+ */
163
170
  const openCreateQuestion = React.useCallback(() => {
164
- openWithPrompt(NEW_QUESTION_PROMPT)
171
+ setMainSidebarOpen(false)
172
+ window.setTimeout(() => router.push(NEW_QUESTION_AUTHORING_PATH), 260)
173
+ }, [router, setMainSidebarOpen])
174
+
175
+ const openDraftWithLeo = React.useCallback(() => {
176
+ openWithPrompt(DRAFT_WITH_LEO_PROMPT)
165
177
  }, [openWithPrompt])
166
178
 
167
179
  const onHubComposerSubmit = React.useCallback(
@@ -183,25 +195,25 @@ export function QuestionBankHubClient() {
183
195
  () => [
184
196
  {
185
197
  id: "scratch",
186
- label: "From scratch",
198
+ label: "Start from scratch",
187
199
  description: "Start with an empty editor and build the item by hand.",
188
- icon: "fa-pen-to-square",
200
+ icon: "fa-plus",
189
201
  iconTint: "bg-brand/15 text-brand",
190
202
  onClick: openCreateQuestion,
191
203
  shortcutKeys: createShortcut,
192
204
  },
193
205
  {
194
206
  id: "ask-leo",
195
- label: "Draft with Ask Leo",
207
+ label: "Draft with Leo",
196
208
  description: "Describe the outcome and let Leo propose stem, options, and rationale.",
197
209
  icon: "fa-star-christmas",
198
210
  iconTint: "bg-brand/15 text-brand",
199
211
  badge: "AI",
200
- onClick: openCreateQuestion,
212
+ onClick: openDraftWithLeo,
201
213
  },
202
214
  {
203
215
  id: "template",
204
- label: "From a template",
216
+ label: "From template",
205
217
  description: "Pick MCQ, OSCE, short answer or true/false — Leo fills the scaffold.",
206
218
  icon: "fa-clone",
207
219
  iconTint: "bg-sky-500/15 text-sky-700 dark:text-sky-300",
@@ -209,14 +221,14 @@ export function QuestionBankHubClient() {
209
221
  },
210
222
  {
211
223
  id: "import",
212
- label: "Import in bulk",
224
+ label: "Import",
213
225
  description: "Bring in CSV, QTI, or paste from another tool — Leo will map the columns.",
214
226
  icon: "fa-file-import",
215
227
  iconTint: "bg-muted text-muted-foreground",
216
228
  onClick: () => sendLeoSuggestion(IMPORT_PROMPT),
217
229
  },
218
230
  ],
219
- [openCreateQuestion, sendLeoSuggestion, createShortcut],
231
+ [openCreateQuestion, openDraftWithLeo, sendLeoSuggestion, createShortcut],
220
232
  )
221
233
 
222
234
  return (
@@ -225,50 +237,101 @@ export function QuestionBankHubClient() {
225
237
  breadcrumbs: [{ label: "Dashboard", href: "/dashboard" }],
226
238
  title: "Question hub",
227
239
  }}
228
- maxWidthClassName="max-w-5xl"
240
+ maxWidthClassName="max-w-none"
229
241
  contentClassName="px-4 py-8 md:px-6 md:py-10"
230
242
  >
231
243
  <Shortcut keys={createShortcut} onInvoke={openCreateQuestion} />
232
244
  {/* ⌘⌥K (Ask Leo toggle) is bound globally in AskLeoProvider — do not double-bind here. */}
233
245
 
234
246
  <div className="flex min-h-0 flex-1 flex-col gap-10">
235
- <header>
247
+ <header className="mx-auto w-full max-w-5xl">
236
248
  <h1 className="text-2xl font-semibold tracking-tight text-foreground md:text-3xl" style={{ fontFamily: "var(--font-heading)" }}>
237
249
  Question hub
238
250
  </h1>
239
251
  </header>
240
252
 
241
- <div className="min-w-0">
242
- <p className="sr-only">
243
- Example searches rotate in the field. Type your own request in plain language, then press Enter to open
244
- the library with that AI search applied to the question list. This control does not open Ask Leo.
245
- </p>
246
- <div
247
- className={cn(
248
- "min-w-0 max-w-full border border-[color:var(--control-border)] bg-card shadow-sm transition-[border-radius,padding,box-shadow] duration-200 ease-out",
249
- hubComposerExpanded ? "rounded-2xl p-1.5 shadow-md" : "rounded-full px-1 py-1",
250
- )}
251
- >
252
- <AskLeoComposer
253
- value={hubComposerValue}
254
- onChange={setHubComposerValue}
255
- onSubmit={onHubComposerSubmit}
256
- onExpandedChange={setHubComposerExpanded}
257
- animatedPlaceholders={[...HUB_COMPOSER_PLACEHOLDERS]}
258
- animatedPlaceholderIntervalMs={4800}
259
- animatedPlaceholderMaxLines={2}
260
- leadingSlot="ai-mark"
261
- inputLabel="AI search"
262
- submitAppearance="search"
263
- submitButtonAriaLabel="Run AI search"
264
- placeholder="Search the bank…"
265
- className="[&_form>div]:rounded-none [&_form>div]:border-0 [&_form>div]:bg-transparent [&_form>div]:shadow-none"
266
- />
253
+ <section
254
+ aria-label="Search and create questions"
255
+ className="-mx-4 overflow-hidden px-4 py-6 md:-mx-6 md:px-6"
256
+ style={{
257
+ background: [
258
+ "radial-gradient(ellipse 118% 70% at 50% 100%, oklch(from var(--brand-color) l c h / 0.11) 0%, transparent 60%)",
259
+ "linear-gradient(180deg, var(--key-metrics-flat-grad-top) 0%, var(--key-metrics-flat-grad-mid) 48%, var(--background) 100%)",
260
+ ].join(", "),
261
+ boxShadow: "0 18px 42px -26px color-mix(in oklch, var(--brand-color) 10%, transparent)",
262
+ }}
263
+ >
264
+ <div className="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 md:px-6">
265
+ <div className="min-w-0">
266
+ <p className="sr-only">
267
+ Example searches rotate in the field. Type your own request in plain language, then press Enter to open
268
+ the library with that AI search applied to the question list. This control does not open Ask Leo.
269
+ </p>
270
+ <div
271
+ className={cn(
272
+ "min-w-0 max-w-full border border-[color:var(--control-border)] bg-card shadow-sm transition-[border-radius,padding,box-shadow] duration-200 ease-out",
273
+ hubComposerExpanded ? "rounded-2xl p-1.5 shadow-md" : "rounded-full px-1 py-1",
274
+ )}
275
+ >
276
+ <AskLeoComposer
277
+ value={hubComposerValue}
278
+ onChange={setHubComposerValue}
279
+ onSubmit={onHubComposerSubmit}
280
+ onExpandedChange={setHubComposerExpanded}
281
+ animatedPlaceholders={[...HUB_COMPOSER_PLACEHOLDERS]}
282
+ animatedPlaceholderIntervalMs={4800}
283
+ animatedPlaceholderMaxLines={2}
284
+ leadingSlot="ai-mark"
285
+ inputLabel="AI search"
286
+ submitAppearance="search"
287
+ submitButtonAriaLabel="Run AI search"
288
+ placeholder="Search the bank…"
289
+ className="[&_form>div]:rounded-none [&_form>div]:border-0 [&_form>div]:bg-transparent [&_form>div]:shadow-none"
290
+ />
291
+ </div>
292
+ </div>
293
+
294
+ {/* Create a question */}
295
+ <section aria-labelledby="qb-create" className="space-y-3">
296
+ <h2 id="qb-create" className="text-base font-semibold tracking-tight text-foreground">
297
+ Create a question
298
+ </h2>
299
+ <div className="flex flex-wrap items-center gap-2">
300
+ {createTiles.map(tile => (
301
+ <button
302
+ key={tile.id}
303
+ type="button"
304
+ onClick={tile.onClick}
305
+ className={cn(
306
+ "inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground transition",
307
+ "hover:border-interactive-hover hover:bg-interactive-hover/30 hover:shadow-sm",
308
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
309
+ )}
310
+ >
311
+ <i
312
+ className={cn(
313
+ tile.badge === "AI" ? "fa-duotone fa-solid" : "fa-light",
314
+ tile.icon,
315
+ "text-xs",
316
+ tile.badge === "AI" ? "text-brand" : "text-muted-foreground",
317
+ )}
318
+ aria-hidden="true"
319
+ />
320
+ {tile.label}
321
+ {tile.badge === "AI" && (
322
+ <span className="rounded-full bg-brand/10 px-1.5 py-px text-[9px] font-bold uppercase tracking-wider text-brand">
323
+ AI
324
+ </span>
325
+ )}
326
+ </button>
327
+ ))}
328
+ </div>
329
+ </section>
267
330
  </div>
268
- </div>
331
+ </section>
269
332
 
270
333
  {recents.length > 0 && (
271
- <section aria-labelledby="qb-recent" className="space-y-4">
334
+ <section aria-labelledby="qb-recent" className="mx-auto w-full max-w-5xl space-y-4">
272
335
  <div className="flex items-baseline justify-between gap-3">
273
336
  <h2 id="qb-recent" className="text-base font-semibold tracking-tight text-foreground">
274
337
  Continue where you left off
@@ -305,7 +368,7 @@ export function QuestionBankHubClient() {
305
368
  </section>
306
369
  )}
307
370
 
308
- <section aria-labelledby="qb-browse" className="space-y-4">
371
+ <section aria-labelledby="qb-browse" className="mx-auto w-full max-w-5xl space-y-4">
309
372
  <div className="flex items-baseline justify-between gap-3">
310
373
  <h2 id="qb-browse" className="text-base font-semibold tracking-tight text-foreground">
311
374
  Browse the library
@@ -360,76 +423,6 @@ export function QuestionBankHubClient() {
360
423
  </div>
361
424
  </section>
362
425
 
363
- <section aria-labelledby="qb-create" className="space-y-4">
364
- <div className="flex flex-wrap items-baseline justify-between gap-3">
365
- <h2 id="qb-create" className="text-base font-semibold tracking-tight text-foreground">
366
- Create a question
367
- </h2>
368
- <Tip
369
- label={(
370
- <span className="inline-flex items-center gap-1.5">
371
- Ask Leo
372
- <KbdGroup>
373
- <Kbd>{mod}</Kbd>
374
- <Kbd>{alt}</Kbd>
375
- <Kbd>K</Kbd>
376
- </KbdGroup>
377
- </span>
378
- )}
379
- >
380
- <Button type="button" variant="ghost" size="sm" onClick={toggle}>
381
- <i className="fa-duotone fa-solid fa-star-christmas text-brand" aria-hidden="true" />
382
- Open Ask Leo
383
- </Button>
384
- </Tip>
385
- </div>
386
-
387
- <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4" role="list">
388
- {createTiles.map(tile => (
389
- <li key={tile.id}>
390
- <button
391
- type="button"
392
- onClick={tile.onClick}
393
- className="group flex h-full w-full flex-col items-start gap-3 rounded-xl border border-border bg-card p-4 text-left transition hover:border-interactive-hover hover:bg-interactive-hover/30 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
394
- >
395
- <span className="flex w-full items-center justify-between">
396
- <span
397
- className={cn(
398
- "inline-flex h-10 w-10 items-center justify-center rounded-lg",
399
- tile.iconTint,
400
- )}
401
- aria-hidden="true"
402
- >
403
- <i
404
- className={cn(
405
- tile.badge === "AI" ? "fa-duotone fa-solid" : "fa-light",
406
- tile.icon,
407
- "text-lg",
408
- )}
409
- />
410
- </span>
411
- {tile.badge === "AI" && (
412
- <span className="inline-flex items-center rounded-full bg-brand/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand">
413
- AI
414
- </span>
415
- )}
416
- </span>
417
- <span className="space-y-1">
418
- <span className="block text-sm font-semibold text-foreground">{tile.label}</span>
419
- <span className="block text-xs leading-relaxed text-muted-foreground">
420
- {tile.description}
421
- </span>
422
- </span>
423
- {tile.shortcutKeys && (
424
- <KbdGroup className="mt-auto">
425
- <Kbd variant="bare">{tile.shortcutKeys}</Kbd>
426
- </KbdGroup>
427
- )}
428
- </button>
429
- </li>
430
- ))}
431
- </ul>
432
- </section>
433
426
  </div>
434
427
  </PrimaryPageTemplate>
435
428
  )
@@ -2,48 +2,13 @@
2
2
 
3
3
  import type { QuestionBankItem } from "@/lib/mock/question-bank"
4
4
  import { ListPageBoardCard } from "@/components/data-views/list-page-board-card"
5
+ import { DataRowList } from "@/components/data-views/data-row-list"
5
6
  import { formatDateUS } from "@/lib/date-filter"
6
7
  import {
7
8
  QuestionBankFavoriteButton,
8
9
  QUESTION_BANK_FAVORITE_HOVER_GROUP,
9
10
  } from "@/components/question-bank-favorite-button"
10
11
 
11
- function QuestionBankListRow({
12
- row,
13
- onToggleFavorite,
14
- onRowActivate,
15
- }: {
16
- row: QuestionBankItem
17
- onToggleFavorite: (row: QuestionBankItem) => void
18
- onRowActivate?: (row: QuestionBankItem) => void
19
- }) {
20
- return (
21
- <li>
22
- <ListPageBoardCard
23
- className={QUESTION_BANK_FAVORITE_HOVER_GROUP}
24
- layout="row"
25
- rowContainerClassName="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:gap-4"
26
- onClick={onRowActivate ? () => onRowActivate(row) : undefined}
27
- rowEnd={(
28
- <div className="flex shrink-0 items-center gap-1">
29
- <QuestionBankFavoriteButton row={row} onToggleFavorite={onToggleFavorite} />
30
- <i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
31
- </div>
32
- )}
33
- >
34
- <div className="space-y-0.5">
35
- <p className="line-clamp-2 text-sm font-semibold text-foreground">{row.stem}</p>
36
- <p className="font-mono text-xs text-muted-foreground">{row.questionId}</p>
37
- <p className="text-xs text-muted-foreground">
38
- {row.topic} · Updated {formatDateUS(row.updatedAt)}
39
- </p>
40
- <p className="text-xs text-muted-foreground">{row.author}</p>
41
- </div>
42
- </ListPageBoardCard>
43
- </li>
44
- )
45
- }
46
-
47
12
  export function QuestionBankListView({
48
13
  rows,
49
14
  onToggleFavorite,
@@ -54,24 +19,35 @@ export function QuestionBankListView({
54
19
  /** When set (e.g. table selection), clicking a row toggles the same selection as the grid. */
55
20
  onRowActivate?: (row: QuestionBankItem) => void
56
21
  }) {
57
- if (rows.length === 0) {
58
- return (
59
- <div className="px-4 py-16 text-center lg:px-6">
60
- <p className="text-sm text-muted-foreground">No questions match your filters.</p>
61
- </div>
62
- )
63
- }
64
-
65
22
  return (
66
- <ul className="flex list-none flex-col gap-2 px-4 pb-8 pt-2 lg:px-6">
67
- {rows.map(row => (
68
- <QuestionBankListRow
69
- key={row.id}
70
- row={row}
71
- onToggleFavorite={onToggleFavorite}
72
- onRowActivate={onRowActivate}
73
- />
74
- ))}
75
- </ul>
23
+ <DataRowList<QuestionBankItem>
24
+ rows={rows}
25
+ getRowId={row => row.id}
26
+ emptyState="No questions match your filters."
27
+ ariaLabel="Questions"
28
+ renderRow={row => (
29
+ <ListPageBoardCard
30
+ className={QUESTION_BANK_FAVORITE_HOVER_GROUP}
31
+ layout="row"
32
+ rowContainerClassName="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:gap-4"
33
+ onClick={onRowActivate ? () => onRowActivate(row) : undefined}
34
+ rowEnd={
35
+ <div className="flex shrink-0 items-center gap-1">
36
+ <QuestionBankFavoriteButton row={row} onToggleFavorite={onToggleFavorite} />
37
+ <i className="fa-light fa-chevron-right text-xs text-muted-foreground" aria-hidden="true" />
38
+ </div>
39
+ }
40
+ >
41
+ <div className="space-y-0.5">
42
+ <p className="line-clamp-2 text-sm font-semibold text-foreground">{row.stem}</p>
43
+ <p className="font-mono text-xs text-muted-foreground">{row.questionId}</p>
44
+ <p className="text-xs text-muted-foreground">
45
+ {row.topic} · Updated {formatDateUS(row.updatedAt)}
46
+ </p>
47
+ <p className="text-xs text-muted-foreground">{row.author}</p>
48
+ </div>
49
+ </ListPageBoardCard>
50
+ )}
51
+ />
76
52
  )
77
53
  }
@@ -131,7 +131,7 @@ export function QuestionBankNewFolderSheet({
131
131
  side="right"
132
132
  showCloseButton={false}
133
133
  showOverlay={false}
134
- className="z-[60] w-80 sm:max-w-80 flex flex-col gap-0 overflow-hidden rounded-xl border border-border p-0 shadow-xl"
134
+ className="z-[80] w-80 sm:max-w-80 flex flex-col gap-0 overflow-hidden rounded-xl border border-border p-0 shadow-xl"
135
135
  style={{ top: "0.5rem", bottom: "0.5rem", right: "0.5rem", height: "calc(100vh - 1rem)" }}
136
136
  >
137
137
  <Shortcut keys="Enter" disabled={createDisabled} onInvoke={commit} />
@@ -21,7 +21,6 @@ import {
21
21
  DropdownMenu,
22
22
  DropdownMenuContent,
23
23
  DropdownMenuItem,
24
- DropdownMenuSeparator,
25
24
  DropdownMenuTrigger,
26
25
  Shortcut,
27
26
  } from "@/components/ui/dropdown-menu"
@@ -36,10 +35,8 @@ import {
36
35
  isQuestionBankNavActive,
37
36
  parseQuestionBankNav,
38
37
  QUESTION_BANK_FAVORITES_FOLDER_ID,
39
- QUESTION_BANK_LIBRARY_HUB_PATHS,
40
38
  questionBankFavoritesFolderHref,
41
39
  questionBankHubScopeHref,
42
- type QuestionBankNavScope,
43
40
  } from "@/lib/question-bank-nav"
44
41
  import { QuestionBankFolderTreeBranch } from "@/components/data-views/question-bank-folder-tree-branch"
45
42
 
@@ -6,11 +6,13 @@
6
6
 
7
7
  import * as React from "react"
8
8
  import dynamic from "next/dynamic"
9
+ import { mailtoHref } from "@/lib/mailto"
9
10
  import { DataTable, DataTableToolbar } from "@/components/data-table"
10
11
  import type { DataListViewType } from "@/lib/data-list-view"
11
12
  import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
12
13
  import type { ColumnDef } from "@/components/data-table/types"
13
14
  import { useTableState } from "@/components/data-table/use-table-state"
15
+ import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
14
16
  import { TablePropertiesDrawerButton } from "@/components/table-properties"
15
17
  import type { ConditionalRule, FilterFieldDef, FilterOperator } from "@/components/table-properties/types"
16
18
  import { Button } from "@/components/ui/button"
@@ -281,7 +283,7 @@ function buildQuestionBankColumns(
281
283
  <span className="truncate text-sm font-medium text-foreground">{row.author}</span>
282
284
  {row.authorEmail ? (
283
285
  <a
284
- href={`mailto:${row.authorEmail}`}
286
+ href={mailtoHref(row.authorEmail)}
285
287
  className="truncate text-xs text-muted-foreground transition-colors hover:text-foreground hover:underline"
286
288
  onClick={e => e.stopPropagation()}
287
289
  >
@@ -382,25 +384,26 @@ function HubFolderColumnsPanel({
382
384
  setSelectedPath(prev => [...prev.slice(0, depth), item])
383
385
  }
384
386
 
385
- // Auto-select first item at each level (only on first render)
387
+ // Auto-select first item at each level (only on first render). Intentional
388
+ // empty deps: we want this to run exactly once on mount; depending on the
389
+ // referenced values (folders / rows / selectedPath) would re-run on every
390
+ // edit and keep re-seeding the selection, undoing the user's choice.
386
391
  React.useEffect(() => {
387
- // Only auto-select if we're at a folder in the path and this is the first render
388
392
  if (isFirstRenderRef.current && selectedPath.length > 0) {
389
393
  const lastItem = selectedPath[selectedPath.length - 1]
390
394
  if (isFolder(lastItem)) {
391
395
  const folder = lastItem as QuestionBankFolder
392
- // Get the items in this folder
393
396
  const subfolders = folders.filter(f => f.parentId === folder.id).sort((a, b) => a.name.localeCompare(b.name))
394
397
  const questionsInFolder = rows.filter(r => r.folderId === folder.id)
395
398
  const items: HierarchyItem[] = [...subfolders, ...questionsInFolder]
396
399
 
397
- // If there are items and nothing is selected at the next level, select the first item
398
400
  if (items.length > 0 && !selectedPath[selectedPath.length + 1]) {
399
401
  setSelectedPath(prev => [...prev, items[0]])
400
402
  isFirstRenderRef.current = false
401
403
  }
402
404
  }
403
405
  }
406
+ // eslint-disable-next-line react-hooks/exhaustive-deps
404
407
  }, [])
405
408
 
406
409
  // Build columns dynamically based on selected path
@@ -715,6 +718,28 @@ export const QuestionBankTable = React.forwardRef<
715
718
  searchLanding ? undefined : urlListSearch,
716
719
  )
717
720
 
721
+ // Persist this hub's table lifecycle (sort / search / filters / column
722
+ // visibility / etc.) to localStorage. See `lib/table-state-lifecycle`.
723
+ // NOTE: tabId is `"main"` here — the question-bank folder scope is
724
+ // already URL-driven (`?scope=`, `?folderId=`), so we only persist
725
+ // table chrome, not navigation.
726
+ const lifecycleColumnKeys = React.useMemo(
727
+ () => new Set(columns.map(c => c.key)),
728
+ [columns],
729
+ )
730
+ useTableStateLifecycle({
731
+ namespace: "question-bank",
732
+ tabId: "main",
733
+ tableState,
734
+ columnKeys: lifecycleColumnKeys,
735
+ extras: { conditionalRules },
736
+ onLoadExtras: e => {
737
+ if (e && Array.isArray(e.conditionalRules)) {
738
+ setConditionalRules(e.conditionalRules as ConditionalRule[])
739
+ }
740
+ },
741
+ })
742
+
718
743
  const openNewFolderForColumn = React.useCallback((parentId: string | null) => {
719
744
  setNewFolderParentId(parentId)
720
745
  setCustomizingFolder(null)
@@ -17,6 +17,9 @@ export function RotationsEmptyState() {
17
17
  className="flex flex-1 flex-col items-center justify-center rounded-xl border border-dashed border-border/80 bg-muted/25 px-6 py-12 text-center min-h-[min(420px,calc(100svh-var(--header-height)-6rem))]"
18
18
  >
19
19
  <div className="mb-6 w-full max-w-[min(100%,280px)] shrink-0">
20
+ {/* Static SVG hero, above the fold — next/image can't optimize SVGs
21
+ without `dangerouslyAllowSVG`, and lazy-loading is wrong here. */}
22
+ {/* eslint-disable-next-line @next/next/no-img-element -- SVG; next/image can't optimize without dangerouslyAllowSVG */}
20
23
  <img
21
24
  src="/Illustration/Rotation.svg"
22
25
  alt=""
@@ -9,12 +9,12 @@
9
9
  */
10
10
 
11
11
  import * as React from "react"
12
- import { cn } from "@/lib/utils"
13
12
  import { useSidebar } from "@/components/ui/sidebar"
14
13
  import { Tip } from "@/components/ui/tip"
15
14
  import { Button } from "@/components/ui/button"
16
15
  import { QuestionBankSecondaryNav } from "@/components/question-bank-secondary-nav"
17
16
  import { NestedSecondaryPanelShell } from "@/components/templates/nested-secondary-panel-shell"
17
+ import { useSidebarReflowZoom } from "@/hooks/use-sidebar-reflow-zoom"
18
18
  import type { QuestionBankItem } from "@/lib/mock/question-bank"
19
19
  import type { QuestionBankFolder } from "@/lib/mock/question-bank-folders"
20
20
 
@@ -87,13 +87,33 @@ export function SecondaryPanelProvider({ children }: { children: React.ReactNode
87
87
  React.useState<QuestionBankAccessBridge | null>(null)
88
88
  const { setOpen } = useSidebar()
89
89
 
90
+ /**
91
+ * Browser zoom ≥ 200% (or very short viewport) — same `useSidebarReflowZoom`
92
+ * signal the primary sidebar uses (WCAG 1.4.10). At that scale the 16rem
93
+ * secondary rail crowds out primary content, so we **auto-collapse it to
94
+ * the icon variant on entering high zoom**. We don't keep overriding —
95
+ * users can re-expand once collapsed; the next zoom-out → zoom-in cycle
96
+ * re-collapses. This matches the pattern users get from clicking the
97
+ * panel's own "Collapse to icons" button.
98
+ */
99
+ const reflowZoom = useSidebarReflowZoom()
100
+ const wasReflowZoomRef = React.useRef(false)
101
+ React.useEffect(() => {
102
+ if (reflowZoom && !wasReflowZoomRef.current) {
103
+ setSecondaryPanelCompact(true)
104
+ }
105
+ wasReflowZoomRef.current = reflowZoom
106
+ }, [reflowZoom])
107
+
90
108
  const openPanel = React.useCallback(
91
109
  (id: string) => {
92
- setSecondaryPanelCompact(false)
110
+ // High zoom → keep the icon rail (auto-collapse rule above). At normal
111
+ // zoom this stays the legacy behavior (full-width on open).
112
+ setSecondaryPanelCompact(reflowZoom)
93
113
  setActivePanel(id)
94
114
  setOpen(false) // collapse main sidebar to icon rail
95
115
  },
96
- [setOpen],
116
+ [setOpen, reflowZoom],
97
117
  )
98
118
 
99
119
  const closePanel = React.useCallback((opts?: ClosePanelOptions) => {