@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
@@ -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} />
@@ -27,7 +27,7 @@ export interface QuestionBankPageHeaderProps {
27
27
  showMetrics?: boolean
28
28
  onToggleMetrics?: () => void
29
29
  showTitleBlock?: boolean
30
- /** `collaboration` adds access line + collaborator stack before CTAs. */
30
+ /** `collaboration` adds access line + collaborator face row before CTAs. */
31
31
  variant?: PageHeaderVariant
32
32
  /** Optional role / access row when `variant="collaboration"` (badge + copy). */
33
33
  accessInfo?: React.ReactNode
@@ -44,6 +44,11 @@ export interface QuestionBankPageHeaderProps {
44
44
  subtitleOverride?: string
45
45
  /** Omits the action column (e.g. search landing before first query). */
46
46
  hideActions?: boolean
47
+ /**
48
+ * When provided, the **More** menu includes **Customize folder** (opens the hub folder sheet).
49
+ * Wire this when the library is scoped to a folder (`?scope=folder&folderId=…`).
50
+ */
51
+ onCustomizeFolder?: () => void
47
52
  }
48
53
 
49
54
  export function QuestionBankPageHeader({
@@ -57,7 +62,7 @@ export function QuestionBankPageHeader({
57
62
  variant = "default",
58
63
  accessInfo,
59
64
  collaborators = QUESTION_BANK_HEADER_COLLABORATORS,
60
- collaboratorDisplayLimit = 4,
65
+ collaboratorDisplayLimit = 3,
61
66
  onAddCollaborator = () => {},
62
67
  onCollaboratorsOpen,
63
68
  collaborationAddLabel = COLLABORATION_HEADER_ADD_LABEL,
@@ -65,6 +70,7 @@ export function QuestionBankPageHeader({
65
70
  hideNewQuestion = false,
66
71
  subtitleOverride,
67
72
  hideActions = false,
73
+ onCustomizeFolder,
68
74
  }: QuestionBankPageHeaderProps) {
69
75
  const [moreOpen, setMoreOpen] = React.useState(false)
70
76
  const countLine =
@@ -119,6 +125,16 @@ export function QuestionBankPageHeader({
119
125
  {addCollaboratorLabel}
120
126
  </DropdownMenuItem>
121
127
  ) : null}
128
+ {onCustomizeFolder ? (
129
+ <DropdownMenuItem
130
+ onSelect={() => {
131
+ window.setTimeout(() => onCustomizeFolder(), 0)
132
+ }}
133
+ >
134
+ <i className="fa-light fa-wand-magic-sparkles" aria-hidden="true" />
135
+ Customize folder
136
+ </DropdownMenuItem>
137
+ ) : null}
122
138
  <DropdownMenuItem
123
139
  onSelect={() => {
124
140
  window.setTimeout(() => onExport(), 0)
@@ -8,7 +8,6 @@
8
8
  import * as React from "react"
9
9
  import Link from "next/link"
10
10
  import { usePathname, useRouter, useSearchParams } from "next/navigation"
11
- import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
12
11
  import { Button } from "@/components/ui/button"
13
12
  import {
14
13
  Dialog,
@@ -22,7 +21,6 @@ import {
22
21
  DropdownMenu,
23
22
  DropdownMenuContent,
24
23
  DropdownMenuItem,
25
- DropdownMenuSeparator,
26
24
  DropdownMenuTrigger,
27
25
  Shortcut,
28
26
  } from "@/components/ui/dropdown-menu"
@@ -30,43 +28,17 @@ import { Kbd, KbdGroup } from "@/components/ui/kbd"
30
28
  import { Tip } from "@/components/ui/tip"
31
29
  import { cn } from "@/lib/utils"
32
30
  import type { QuestionBankFolder } from "@/lib/mock/question-bank-folders"
33
- import { QUESTION_BANK_FOLDER_ICON_COLORS } from "@/lib/mock/question-bank-folders"
34
31
  import { DEFAULT_QUESTION_BANK_FOLDERS, newFolderId, collectFolderDescendantIds } from "@/lib/mock/question-bank-folders"
35
32
  import { useSecondaryPanel } from "@/components/secondary-panel"
36
33
  import { QuestionBankNewFolderSheet } from "@/components/question-bank-new-folder-sheet"
37
34
  import {
38
- isQuestionBankDedicatedSearchPathname,
35
+ isQuestionBankNavActive,
39
36
  parseQuestionBankNav,
40
37
  QUESTION_BANK_FAVORITES_FOLDER_ID,
41
- QUESTION_BANK_LIBRARY_HUB_PATHS,
42
- isQuestionBankSearchNavActive,
43
38
  questionBankFavoritesFolderHref,
44
39
  questionBankHubScopeHref,
45
- questionBankSearchLandingNavHref,
46
- type QuestionBankNavScope,
47
40
  } from "@/lib/question-bank-nav"
48
-
49
- function isNavActive(
50
- pathname: string,
51
- nav: ReturnType<typeof parseQuestionBankNav>,
52
- scope: QuestionBankNavScope,
53
- folderId?: string | null,
54
- ) {
55
- if (!QUESTION_BANK_LIBRARY_HUB_PATHS.includes(pathname)) return false
56
- // Dedicated search shells (list / hub-find) use the “Search” row for All/My — not these rows.
57
- if (scope === "all") {
58
- if (isQuestionBankDedicatedSearchPathname(pathname)) return false
59
- return nav.scope === "all"
60
- }
61
- if (scope === "my") {
62
- if (isQuestionBankDedicatedSearchPathname(pathname)) return false
63
- return nav.scope === "my"
64
- }
65
- if (scope === "folder" && folderId) {
66
- return nav.scope === "folder" && nav.folderId === folderId
67
- }
68
- return false
69
- }
41
+ import { QuestionBankFolderTreeBranch } from "@/components/data-views/question-bank-folder-tree-branch"
70
42
 
71
43
  function NavRow({
72
44
  href,
@@ -147,179 +119,6 @@ function IconNavRow({
147
119
  )
148
120
  }
149
121
 
150
- function PanelFolderBranch({
151
- folder,
152
- folders,
153
- depth,
154
- pathname,
155
- hubSearchParams,
156
- nav,
157
- canManageFolders,
158
- canManageAccess,
159
- onAddSubfolder,
160
- onCustomizeFolder,
161
- onManageAccess,
162
- onDeleteFolder,
163
- }: {
164
- folder: QuestionBankFolder
165
- folders: QuestionBankFolder[]
166
- depth: number
167
- pathname: string
168
- hubSearchParams: URLSearchParams
169
- nav: ReturnType<typeof parseQuestionBankNav>
170
- canManageFolders: boolean
171
- canManageAccess: boolean
172
- onAddSubfolder: (parentId: string) => void
173
- onCustomizeFolder: (folder: QuestionBankFolder) => void
174
- onManageAccess: () => void
175
- onDeleteFolder: (folder: QuestionBankFolder) => void
176
- }) {
177
- const childFolders = React.useMemo(
178
- () =>
179
- folders
180
- .filter(f => f.parentId === folder.id)
181
- .sort((a, b) => a.name.localeCompare(b.name)),
182
- [folders, folder.id],
183
- )
184
-
185
- const hasSubfolders = childFolders.length > 0
186
- const indent = depth * 10
187
-
188
- const folderHref = questionBankHubScopeHref(pathname, hubSearchParams, {
189
- scope: "folder",
190
- folderId: folder.id,
191
- })
192
- const folderActive = isNavActive(pathname, nav, "folder", folder.id)
193
-
194
- return (
195
- <Collapsible defaultOpen={depth < 1} className="group">
196
- <div
197
- className={cn(
198
- "group/row flex min-w-0 items-center rounded-md px-2 transition-colors",
199
- folderActive
200
- ? "bg-sidebar-accent text-sidebar-accent-foreground"
201
- : "hover:bg-sidebar-accent/50",
202
- )}
203
- >
204
- <div style={{ width: indent }} className="shrink-0" aria-hidden />
205
- {hasSubfolders ? (
206
- <CollapsibleTrigger asChild>
207
- <button
208
- type="button"
209
- className="flex size-8 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
210
- aria-label={`${folder.name} — expand or collapse`}
211
- >
212
- <i
213
- className="fa-light fa-chevron-right text-xs transition-transform duration-150 group-data-[state=open]:rotate-90"
214
- aria-hidden
215
- />
216
- </button>
217
- </CollapsibleTrigger>
218
- ) : (
219
- <div className="size-8 shrink-0" aria-hidden />
220
- )}
221
- <Tip label={folder.name} side="right">
222
- <Link
223
- href={folderHref}
224
- scroll={false}
225
- aria-current={folderActive ? "page" : undefined}
226
- className={cn(
227
- "flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left text-sm transition-colors",
228
- "rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
229
- folderActive ? "font-medium" : "text-sidebar-foreground",
230
- )}
231
- >
232
- <i
233
- className={cn("fa-light shrink-0 text-sm", folder.icon, QUESTION_BANK_FOLDER_ICON_COLORS[folder.colorKey])}
234
- aria-hidden
235
- />
236
- <span className="min-w-0 flex-1 truncate leading-tight">{folder.name}</span>
237
- {hasSubfolders ? (
238
- <span className="shrink-0 text-xs tabular-nums text-muted-foreground">{childFolders.length}</span>
239
- ) : null}
240
- </Link>
241
- </Tip>
242
- {canManageFolders ? (
243
- <DropdownMenu>
244
- <Tip label={`Folder actions for ${folder.name}`} side="right">
245
- <DropdownMenuTrigger asChild>
246
- <Button
247
- type="button"
248
- size="icon-xs"
249
- variant="ghost"
250
- aria-label={`Folder actions for ${folder.name}`}
251
- className="shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/row:opacity-100 group-focus-within/row:opacity-100"
252
- onClick={event => event.stopPropagation()}
253
- >
254
- <i className="fa-light fa-ellipsis text-xs" aria-hidden="true" />
255
- </Button>
256
- </DropdownMenuTrigger>
257
- </Tip>
258
- <DropdownMenuContent align="end">
259
- <DropdownMenuItem
260
- onSelect={() => {
261
- window.setTimeout(() => onAddSubfolder(folder.id), 0)
262
- }}
263
- >
264
- <i className="fa-light fa-plus text-xs" aria-hidden="true" />
265
- Add folder
266
- </DropdownMenuItem>
267
- <DropdownMenuItem
268
- onSelect={() => {
269
- window.setTimeout(() => onCustomizeFolder(folder), 0)
270
- }}
271
- >
272
- <i className="fa-light fa-wand-magic-sparkles text-xs" aria-hidden="true" />
273
- Customize
274
- </DropdownMenuItem>
275
- <DropdownMenuItem
276
- disabled={!canManageAccess}
277
- onSelect={() => {
278
- window.setTimeout(() => onManageAccess(), 0)
279
- }}
280
- >
281
- <i className="fa-light fa-user-gear text-xs" aria-hidden="true" />
282
- Manage access
283
- </DropdownMenuItem>
284
- <DropdownMenuSeparator />
285
- <DropdownMenuItem
286
- variant="destructive"
287
- onSelect={() => {
288
- window.setTimeout(() => onDeleteFolder(folder), 0)
289
- }}
290
- >
291
- <i className="fa-light fa-trash text-xs" aria-hidden="true" />
292
- Delete
293
- </DropdownMenuItem>
294
- </DropdownMenuContent>
295
- </DropdownMenu>
296
- ) : null}
297
- </div>
298
- {hasSubfolders ? (
299
- <CollapsibleContent>
300
- {childFolders.map(child => (
301
- <PanelFolderBranch
302
- key={child.id}
303
- folder={child}
304
- folders={folders}
305
- depth={depth + 1}
306
- pathname={pathname}
307
- hubSearchParams={hubSearchParams}
308
- nav={nav}
309
- canManageFolders={canManageFolders}
310
- canManageAccess={canManageAccess}
311
- onAddSubfolder={onAddSubfolder}
312
- onCustomizeFolder={onCustomizeFolder}
313
- onManageAccess={onManageAccess}
314
- onDeleteFolder={onDeleteFolder}
315
- />
316
- ))}
317
- </CollapsibleContent>
318
- ) : null}
319
- </Collapsible>
320
- )
321
- }
322
-
323
122
  export function QuestionBankSecondaryNav() {
324
123
  const pathname = usePathname()
325
124
  const router = useRouter()
@@ -340,7 +139,7 @@ export function QuestionBankSecondaryNav() {
340
139
  const canManageFolders = questionBankFolderBridge != null
341
140
  const canManageAccess = questionBankAccessBridge != null
342
141
 
343
- /** Favorites is a primary nav row (with All / My / Search), not under “Folders”. */
142
+ /** Favorites is a primary nav row (with All / My), not under “Folders”. */
344
143
  const folderTreeRoots = React.useMemo(
345
144
  () =>
346
145
  folders
@@ -518,32 +317,25 @@ export function QuestionBankSecondaryNav() {
518
317
  <ul className="flex flex-1 flex-col items-center gap-1 overflow-y-auto px-1 py-2" role="list">
519
318
  <IconNavRow
520
319
  href={questionBankHubScopeHref(pathname, searchParams, { scope: "all" })}
521
- active={isNavActive(pathname, nav, "all")}
320
+ active={isQuestionBankNavActive(pathname, nav, "all")}
522
321
  iconClass="fa-table-list"
523
322
  label="All questions"
524
323
  onClick={() => openPanel("question-bank")}
525
324
  />
526
325
  <IconNavRow
527
326
  href={questionBankHubScopeHref(pathname, searchParams, { scope: "my" })}
528
- active={isNavActive(pathname, nav, "my")}
327
+ active={isQuestionBankNavActive(pathname, nav, "my")}
529
328
  iconClass="fa-user"
530
329
  label="My questions"
531
330
  onClick={() => openPanel("question-bank")}
532
331
  />
533
332
  <IconNavRow
534
333
  href={questionBankFavoritesFolderHref(pathname, searchParams)}
535
- active={isNavActive(pathname, nav, "folder", QUESTION_BANK_FAVORITES_FOLDER_ID)}
334
+ active={isQuestionBankNavActive(pathname, nav, "folder", QUESTION_BANK_FAVORITES_FOLDER_ID)}
536
335
  iconClass="fa-star"
537
336
  label="Favorites"
538
337
  onClick={() => openPanel("question-bank")}
539
338
  />
540
- <IconNavRow
541
- href={questionBankSearchLandingNavHref(nav, searchParams)}
542
- active={isQuestionBankSearchNavActive(pathname, nav)}
543
- iconClass="fa-magnifying-glass"
544
- label="Search"
545
- onClick={() => openPanel("question-bank")}
546
- />
547
339
  <li className="flex w-full justify-center pt-1" role="none">
548
340
  <DropdownMenu>
549
341
  <DropdownMenuTrigger asChild>
@@ -566,7 +358,7 @@ export function QuestionBankSecondaryNav() {
566
358
  scope: "folder",
567
359
  folderId: folder.id,
568
360
  })
569
- const active = isNavActive(pathname, nav, "folder", folder.id)
361
+ const active = isQuestionBankNavActive(pathname, nav, "folder", folder.id)
570
362
  return (
571
363
  <DropdownMenuItem key={folder.id} asChild>
572
364
  <Link
@@ -613,31 +405,24 @@ export function QuestionBankSecondaryNav() {
613
405
  <ul className="space-y-0.5" role="list">
614
406
  <NavRow
615
407
  href={questionBankHubScopeHref(pathname, searchParams, { scope: "all" })}
616
- active={isNavActive(pathname, nav, "all")}
408
+ active={isQuestionBankNavActive(pathname, nav, "all")}
617
409
  iconClass="fa-table-list"
618
410
  label="All questions"
619
411
  onClick={() => openPanel("question-bank")}
620
412
  />
621
413
  <NavRow
622
414
  href={questionBankHubScopeHref(pathname, searchParams, { scope: "my" })}
623
- active={isNavActive(pathname, nav, "my")}
415
+ active={isQuestionBankNavActive(pathname, nav, "my")}
624
416
  iconClass="fa-user"
625
417
  label="My questions"
626
418
  />
627
419
  <NavRow
628
420
  href={questionBankFavoritesFolderHref(pathname, searchParams)}
629
- active={isNavActive(pathname, nav, "folder", QUESTION_BANK_FAVORITES_FOLDER_ID)}
421
+ active={isQuestionBankNavActive(pathname, nav, "folder", QUESTION_BANK_FAVORITES_FOLDER_ID)}
630
422
  iconClass="fa-star"
631
423
  label="Favorites"
632
424
  onClick={() => openPanel("question-bank")}
633
425
  />
634
- <NavRow
635
- href={questionBankSearchLandingNavHref(nav, searchParams)}
636
- active={isQuestionBankSearchNavActive(pathname, nav)}
637
- iconClass="fa-magnifying-glass"
638
- label="Search"
639
- onClick={() => openPanel("question-bank")}
640
- />
641
426
  <li role="presentation" className="select-none">
642
427
  <div className="flex items-center justify-between gap-2 px-2 pt-3 pb-1">
643
428
  <span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">
@@ -659,11 +444,10 @@ export function QuestionBankSecondaryNav() {
659
444
  </div>
660
445
  </li>
661
446
  {folderTreeRoots.map(folder => (
662
- <li key={folder.id} className="min-w-0">
663
- <PanelFolderBranch
447
+ <li key={folder.id} className="min-w-0 w-full list-none">
448
+ <QuestionBankFolderTreeBranch
664
449
  folder={folder}
665
450
  folders={folders}
666
- depth={0}
667
451
  pathname={pathname}
668
452
  hubSearchParams={searchParams}
669
453
  nav={nav}
@@ -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) => {
@@ -160,7 +180,7 @@ function QuestionBankPanel() {
160
180
  className="text-xl font-semibold leading-tight text-sidebar-foreground"
161
181
  style={{ fontFamily: "var(--font-heading)" }}
162
182
  >
163
- Question bank
183
+ Library
164
184
  </h2>
165
185
  <Tip label="Collapse to icons" side="bottom">
166
186
  <Button