@exxatdesignux/ui 0.2.15 → 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 (110) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -1
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +151 -3
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  6. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -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 +17 -1
  9. package/consumer-extras/patterns/collaboration-access-pattern.md +2 -0
  10. package/package.json +3 -3
  11. package/src/components/ui/banner.tsx +2 -0
  12. package/src/components/ui/chart.tsx +57 -2
  13. package/src/components/ui/sidebar.tsx +1 -0
  14. package/src/globals.css +21 -2
  15. package/src/theme.css +4 -2
  16. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  17. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  18. package/template/AGENTS.md +23 -18
  19. package/template/app/(app)/data-list/page.tsx +2 -2
  20. package/template/app/(app)/question-bank/layout.tsx +27 -7
  21. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  22. package/template/app/globals.css +136 -2
  23. package/template/app/layout.tsx +41 -5
  24. package/template/components/app-sidebar.tsx +141 -59
  25. package/template/components/ask-leo-sidebar.tsx +1 -4
  26. package/template/components/brand-color-picker.tsx +344 -0
  27. package/template/components/compliance-list-view.tsx +33 -51
  28. package/template/components/compliance-table.tsx +24 -0
  29. package/template/components/data-table/index.tsx +68 -24
  30. package/template/components/data-table/pagination.tsx +0 -1
  31. package/template/components/data-table/types.ts +4 -1
  32. package/template/components/data-table/use-table-state.ts +243 -94
  33. package/template/components/data-views/data-row-list.tsx +183 -0
  34. package/template/components/data-views/finder-panel-view.tsx +2 -2
  35. package/template/components/data-views/index.ts +26 -3
  36. package/template/components/data-views/list-page-split-details-placeholder.tsx +3 -3
  37. package/template/components/data-views/list-page-split-hub-tokens.ts +1 -1
  38. package/template/components/data-views/list-page-tree-column-header.tsx +1 -1
  39. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  40. package/template/components/data-views/outline-tree-menu.tsx +157 -0
  41. package/template/components/data-views/question-bank-folder-tree-branch.tsx +210 -0
  42. package/template/components/export-drawer.tsx +1 -1
  43. package/template/components/exxat-product-logo.tsx +173 -379
  44. package/template/components/folder-details-shell.tsx +1 -1
  45. package/template/components/hub-tree-panel-view.tsx +88 -80
  46. package/template/components/invite-collaborators-drawer.tsx +5 -3
  47. package/template/components/key-metrics.tsx +116 -51
  48. package/template/components/new-placement-form.tsx +4 -2
  49. package/template/components/new-question-composer.tsx +2208 -0
  50. package/template/components/page-breadcrumb-trail.tsx +131 -0
  51. package/template/components/page-header.tsx +21 -11
  52. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
  53. package/template/components/placement-detail.tsx +1 -1
  54. package/template/components/placements-board-view.tsx +1 -1
  55. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  56. package/template/components/placements-list-view.tsx +18 -132
  57. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  58. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  59. package/template/components/placements-table-columns.tsx +2 -2
  60. package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
  61. package/template/components/product-switcher.tsx +26 -11
  62. package/template/components/product-wordmark.tsx +285 -0
  63. package/template/components/question-bank-client.tsx +130 -70
  64. package/template/components/question-bank-hub-client.tsx +108 -115
  65. package/template/components/question-bank-list-view.tsx +30 -54
  66. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  67. package/template/components/question-bank-page-header.tsx +18 -2
  68. package/template/components/question-bank-secondary-nav.tsx +12 -228
  69. package/template/components/question-bank-table.tsx +30 -5
  70. package/template/components/rotations-empty-state.tsx +3 -0
  71. package/template/components/secondary-panel.tsx +24 -4
  72. package/template/components/settings-appearance-card.tsx +584 -141
  73. package/template/components/site-header.tsx +56 -32
  74. package/template/components/sites-list-view.tsx +31 -36
  75. package/template/components/sites-table.tsx +24 -0
  76. package/template/components/table-properties/drawer.tsx +1 -1
  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 +29 -3
  80. package/template/components/templates/dedicated-search-landing-template.tsx +86 -20
  81. package/template/components/templates/list-page.tsx +1 -3
  82. package/template/components/templates/nested-secondary-panel-shell.tsx +11 -6
  83. package/template/components/ui/dot-pattern.tsx +50 -26
  84. package/template/components/ui/leo-icon.tsx +23 -3
  85. package/template/contexts/product-context.tsx +51 -7
  86. package/template/contexts/system-banner-context.tsx +112 -4
  87. package/template/docs/collaboration-access-pattern.md +2 -0
  88. package/template/docs/question-bank-hub-header-pattern.md +25 -0
  89. package/template/eslint.config.mjs +18 -0
  90. package/template/hooks/use-secondary-panel-hub-nav.ts +17 -1
  91. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  92. package/template/lib/data-list-persistence.ts +57 -257
  93. package/template/lib/dev-log.test.ts +6 -5
  94. package/template/lib/exxat-palette.json +1462 -0
  95. package/template/lib/exxat-palette.ts +136 -0
  96. package/template/lib/list-page-table-properties.ts +1 -1
  97. package/template/lib/list-status-badges.ts +1 -1
  98. package/template/lib/mailto.ts +29 -0
  99. package/template/lib/mock/navigation.tsx +30 -1
  100. package/template/lib/placement-board-card-layout.ts +1 -1
  101. package/template/lib/product-brand.ts +268 -0
  102. package/template/lib/question-bank-authoring.ts +308 -0
  103. package/template/lib/question-bank-nav.ts +70 -0
  104. package/template/lib/raf-throttle.ts +45 -0
  105. package/template/lib/table-state-lifecycle.ts +474 -0
  106. package/template/next.config.mjs +156 -0
  107. package/template/package.json +6 -6
  108. package/template/stores/app-store.ts +46 -1
  109. package/template/components/command-menu-01.tsx +0 -133
  110. package/template/components/command-menu-02.tsx +0 -386
@@ -3,6 +3,7 @@
3
3
  /**
4
4
  * Question bank hub — ListPageTemplate + KeyMetrics + QuestionBankTable (Team / Compliance pattern).
5
5
  * URL hash syncs the active view tab; `?scope=` + `folderId=` sync with the secondary nav (`lib/question-bank-nav.ts`).
6
+ * (Primary sidebar “Library” must not treat that hash as “off-route” — `app-sidebar` `isNavActive` ignores hash for `href` without `#…`.)
6
7
  */
7
8
 
8
9
  import * as React from "react"
@@ -14,15 +15,17 @@ import {
14
15
  type DataListViewType,
15
16
  } from "@/components/data-views"
16
17
  import { QuestionBankPageHeader } from "@/components/question-bank-page-header"
18
+ import { QuestionBankNewFolderSheet } from "@/components/question-bank-new-folder-sheet"
17
19
  import { CollaborationAccessFlow } from "@/components/collaboration-access-flow"
18
20
  import { QuestionBankTable, type QuestionBankTableHandle } from "@/components/question-bank-table"
19
21
  import { SecondaryPanelHubTemplate } from "@/components/templates/secondary-panel-hub-template"
20
22
  import { QuestionBankAccessBridge, QuestionBankFolderBridge } from "@/components/secondary-panel"
21
23
  import { KeyMetrics } from "@/components/key-metrics"
24
+ import { useSidebar } from "@/components/ui/sidebar"
22
25
  import { useSecondaryPanelHubNav } from "@/hooks/use-secondary-panel-hub-nav"
23
26
  import { QUESTION_BANK_ITEMS } from "@/lib/mock/question-bank"
24
27
  import { QUESTION_BANK_HEADER_COLLABORATORS } from "@/lib/mock/question-bank-header-collaborators"
25
- import { DEFAULT_QUESTION_BANK_FOLDERS } from "@/lib/mock/question-bank-folders"
28
+ import { DEFAULT_QUESTION_BANK_FOLDERS, type QuestionBankFolder } from "@/lib/mock/question-bank-folders"
26
29
  import { questionBankKpiInsight, questionBankKpiMetrics } from "@/lib/mock/question-bank-kpi"
27
30
  import {
28
31
  applyQuestionBankHubDisplayFilters,
@@ -97,6 +100,8 @@ export function QuestionBankClient() {
97
100
  parseNav: parseQuestionBankNav,
98
101
  canonicalHref: questionBankCanonicalNavHref,
99
102
  shouldReopenPanel: isQuestionBankDefaultNav,
103
+ /** Hub/find + list are full-width — layout closes the panel; do not fight it with `openPanel`. */
104
+ reopenPanelOnPathnames: [QUESTION_BANK_LIBRARY_PATH],
100
105
  })
101
106
  const isDedicatedSearch = isQuestionBankDedicatedSearchPathname(pathname)
102
107
  const isHubFindSurface = pathname === QUESTION_BANK_HUB_FIND_PATH
@@ -173,6 +178,34 @@ export function QuestionBankClient() {
173
178
  const [items, setItems] = React.useState(() => QUESTION_BANK_ITEMS.map(q => ({ ...q })))
174
179
  const [folders, setFolders] = React.useState(() => DEFAULT_QUESTION_BANK_FOLDERS.map(f => ({ ...f })))
175
180
 
181
+ const [hubFolderCustomizeSheetOpen, setHubFolderCustomizeSheetOpen] = React.useState(false)
182
+ const [hubFolderCustomizeTarget, setHubFolderCustomizeTarget] = React.useState<QuestionBankFolder | null>(null)
183
+
184
+ const openHubScopedFolderCustomize = React.useCallback(() => {
185
+ if (navState.scope !== "folder" || !navState.folderId) return
186
+ const f = folders.find(x => x.id === navState.folderId)
187
+ if (!f) return
188
+ setHubFolderCustomizeTarget(f)
189
+ setHubFolderCustomizeSheetOpen(true)
190
+ }, [folders, navState.folderId, navState.scope])
191
+
192
+ /**
193
+ * Open the full-page authoring composer (`/question-bank/new`).
194
+ * Pre-collapses the main sidebar (Placements pattern) so the user sees one
195
+ * smooth animation into the focused authoring flow. Folder scope, when
196
+ * present, is forwarded as `?folderId=` so the destination dropdown lands
197
+ * pre-selected on the right rail.
198
+ */
199
+ const { setOpen: setMainSidebarOpen } = useSidebar()
200
+ const handleNewQuestion = React.useCallback(() => {
201
+ const folderQuery =
202
+ navState.scope === "folder" && navState.folderId
203
+ ? `?folderId=${encodeURIComponent(navState.folderId)}`
204
+ : ""
205
+ setMainSidebarOpen(false)
206
+ window.setTimeout(() => router.push(`/question-bank/new${folderQuery}`), 260)
207
+ }, [navState.folderId, navState.scope, router, setMainSidebarOpen])
208
+
176
209
  const filteredItems = React.useMemo(
177
210
  () => applyQuestionBankHubDisplayFilters(items, folders, navState, landingFilters),
178
211
  [items, folders, landingFilters, navState],
@@ -235,7 +268,7 @@ export function QuestionBankClient() {
235
268
  title={dedicatedSearchTitle}
236
269
  questionCount={count}
237
270
  hideNewQuestion
238
- onNewQuestion={() => {}}
271
+ onNewQuestion={handleNewQuestion}
239
272
  onExport={() => setExportOpen(true)}
240
273
  />
241
274
  <DedicatedSearchUrlComposer
@@ -331,76 +364,103 @@ export function QuestionBankClient() {
331
364
  resourceLabel={hubHeader.title}
332
365
  >
333
366
  {({ collaborators, openInvite }) => (
334
- <SecondaryPanelHubTemplate
335
- bridges={(
336
- <>
337
- <QuestionBankFolderBridge
338
- folders={folders}
339
- onFoldersChange={setFolders}
340
- items={items}
341
- onItemsChange={setItems}
342
- />
343
- <QuestionBankAccessBridge openManageAccess={openInvite} />
344
- </>
345
- )}
346
- siteHeader={{
347
- title: hubHeader.title,
348
- breadcrumbs: hubHeader.breadcrumbs,
349
- }}
350
- >
351
- <ListPageTemplate
352
- defaultTabs={DEFAULT_TABS}
353
- tabs={tabs}
354
- onTabsChange={setTabs}
355
- activeTabId={activeTabId}
356
- onActiveTabChange={onActiveTabChange}
357
- getTabCount={() => count}
358
- tablePropertiesRef={tableRef}
359
- header={(
360
- <QuestionBankPageHeader
361
- variant="collaboration"
362
- title={hubHeader.title}
363
- questionCount={count}
364
- collaborators={collaborators}
365
- onNewQuestion={() => {}}
366
- onExport={() => setExportOpen(true)}
367
- onAddCollaborator={openInvite}
368
- onCollaboratorsOpen={openInvite}
369
- showMetrics={showMetrics}
370
- onToggleMetrics={() => setShowMetrics(v => !v)}
371
- />
372
- )}
373
- metrics={(
374
- <KeyMetrics
375
- variant="flat"
376
- metrics={metrics}
377
- insight={insight}
378
- showHeader={false}
379
- metricsSingleRow
380
- />
381
- )}
382
- showMetrics={showMetrics}
383
- exportOpen={exportOpen}
384
- onExportOpenChange={setExportOpen}
385
- exportTotalRows={count}
386
- renderContent={(tab, updateTab) => (
387
- <QuestionBankTable
388
- key={tab.id}
389
- ref={tableRef}
390
- items={items}
391
- navState={navState}
392
- urlListSearch={urlToolbarSearchSync}
393
- landingFilters={null}
394
- searchLanding={false}
395
- folders={folders}
396
- onFoldersChange={setFolders}
397
- onItemsChange={setItems}
398
- view={tab.viewType}
399
- onViewChange={(v: DataListViewType) => updateTab({ viewType: v, icon: dataListViewIcon(v) })}
400
- />
367
+ <>
368
+ <SecondaryPanelHubTemplate
369
+ bridges={(
370
+ <>
371
+ <QuestionBankFolderBridge
372
+ folders={folders}
373
+ onFoldersChange={setFolders}
374
+ items={items}
375
+ onItemsChange={setItems}
376
+ />
377
+ <QuestionBankAccessBridge openManageAccess={openInvite} />
378
+ </>
401
379
  )}
380
+ siteHeader={{
381
+ title: hubHeader.title,
382
+ breadcrumbs: hubHeader.breadcrumbs,
383
+ }}
384
+ >
385
+ <ListPageTemplate
386
+ defaultTabs={DEFAULT_TABS}
387
+ tabs={tabs}
388
+ onTabsChange={setTabs}
389
+ activeTabId={activeTabId}
390
+ onActiveTabChange={onActiveTabChange}
391
+ getTabCount={() => count}
392
+ tablePropertiesRef={tableRef}
393
+ header={(
394
+ <QuestionBankPageHeader
395
+ variant="collaboration"
396
+ title={hubHeader.title}
397
+ questionCount={count}
398
+ collaborators={collaborators}
399
+ onNewQuestion={handleNewQuestion}
400
+ onExport={() => setExportOpen(true)}
401
+ onAddCollaborator={openInvite}
402
+ onCollaboratorsOpen={openInvite}
403
+ showMetrics={showMetrics}
404
+ onToggleMetrics={() => setShowMetrics(v => !v)}
405
+ onCustomizeFolder={
406
+ navState.scope === "folder" && navState.folderId ? openHubScopedFolderCustomize : undefined
407
+ }
408
+ />
409
+ )}
410
+ metrics={(
411
+ <KeyMetrics
412
+ variant="flat"
413
+ metrics={metrics}
414
+ insight={insight}
415
+ showHeader={false}
416
+ metricsSingleRow
417
+ />
418
+ )}
419
+ showMetrics={showMetrics}
420
+ exportOpen={exportOpen}
421
+ onExportOpenChange={setExportOpen}
422
+ exportTotalRows={count}
423
+ renderContent={(tab, updateTab) => (
424
+ <QuestionBankTable
425
+ key={tab.id}
426
+ ref={tableRef}
427
+ items={items}
428
+ navState={navState}
429
+ urlListSearch={urlToolbarSearchSync}
430
+ landingFilters={null}
431
+ searchLanding={false}
432
+ folders={folders}
433
+ onFoldersChange={setFolders}
434
+ onItemsChange={setItems}
435
+ view={tab.viewType}
436
+ onViewChange={(v: DataListViewType) => updateTab({ viewType: v, icon: dataListViewIcon(v) })}
437
+ />
438
+ )}
439
+ />
440
+ </SecondaryPanelHubTemplate>
441
+ <QuestionBankNewFolderSheet
442
+ open={hubFolderCustomizeSheetOpen}
443
+ onOpenChange={open => {
444
+ setHubFolderCustomizeSheetOpen(open)
445
+ if (!open) setHubFolderCustomizeTarget(null)
446
+ }}
447
+ parentFolderId={hubFolderCustomizeTarget?.parentId ?? null}
448
+ customizingFolder={hubFolderCustomizeTarget}
449
+ descriptionText="Update how this folder appears in the bank. Name, color, and icon apply everywhere the folder is shown."
450
+ onCreated={newFolder => {
451
+ const target = hubFolderCustomizeTarget
452
+ if (!target) return
453
+ setFolders(prev =>
454
+ prev.map(f =>
455
+ f.id === target.id
456
+ ? { ...f, name: newFolder.name, icon: newFolder.icon, colorKey: newFolder.colorKey }
457
+ : f,
458
+ ),
459
+ )
460
+ setHubFolderCustomizeTarget(null)
461
+ }}
402
462
  />
403
- </SecondaryPanelHubTemplate>
463
+ </>
404
464
  )}
405
465
  </CollaborationAccessFlow>
406
466
  )
@@ -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
  }