@exxatdesignux/ui 0.2.16 → 0.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  6. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
  7. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +19 -0
  9. package/consumer-extras/patterns/data-views-pattern.md +2 -0
  10. package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
  11. package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -0
  12. package/package.json +3 -3
  13. package/src/components/ui/banner.tsx +2 -0
  14. package/src/components/ui/chart.tsx +57 -2
  15. package/src/components/ui/sidebar.tsx +3 -2
  16. package/src/globals.css +65 -14
  17. package/src/theme.css +3 -3
  18. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  19. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
  20. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  21. package/template/AGENTS.md +27 -17
  22. package/template/app/(app)/data-list/page.tsx +2 -2
  23. package/template/app/(app)/error.tsx +22 -6
  24. package/template/app/(app)/layout.tsx +13 -6
  25. package/template/app/(app)/question-bank/layout.tsx +18 -5
  26. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  27. package/template/app/global-error.tsx +63 -0
  28. package/template/app/globals.css +151 -14
  29. package/template/app/layout.tsx +43 -5
  30. package/template/components/app-sidebar.tsx +68 -33
  31. package/template/components/ask-leo-sidebar.tsx +0 -2
  32. package/template/components/brand-color-picker.tsx +344 -0
  33. package/template/components/compliance-list-view.tsx +33 -51
  34. package/template/components/compliance-table.tsx +4 -0
  35. package/template/components/data-table/index.tsx +99 -91
  36. package/template/components/data-table/pagination.tsx +0 -1
  37. package/template/components/data-table/types.ts +4 -1
  38. package/template/components/data-table/use-table-state.ts +276 -100
  39. package/template/components/data-views/data-row-list.tsx +183 -0
  40. package/template/components/data-views/index.ts +7 -3
  41. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  42. package/template/components/dev-chunk-load-recovery.tsx +41 -0
  43. package/template/components/export-drawer.tsx +1 -1
  44. package/template/components/exxat-product-logo.tsx +168 -317
  45. package/template/components/invite-collaborators-drawer.tsx +5 -3
  46. package/template/components/key-metrics.tsx +122 -62
  47. package/template/components/new-placement-form.tsx +4 -2
  48. package/template/components/new-question-composer.tsx +2208 -0
  49. package/template/components/page-breadcrumb-trail.tsx +131 -0
  50. package/template/components/page-header.tsx +2 -1
  51. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +2 -2
  52. package/template/components/placement-detail.tsx +1 -1
  53. package/template/components/placements-board-view.tsx +1 -1
  54. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  55. package/template/components/placements-list-view.tsx +19 -133
  56. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  57. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  58. package/template/components/placements-table-columns.tsx +2 -2
  59. package/template/components/{data-list-table.tsx → placements-table.tsx} +42 -66
  60. package/template/components/product-switcher.tsx +24 -7
  61. package/template/components/product-wordmark.tsx +282 -0
  62. package/template/components/question-bank-client.tsx +20 -2
  63. package/template/components/question-bank-hub-client.tsx +105 -115
  64. package/template/components/question-bank-list-view.tsx +30 -54
  65. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  66. package/template/components/question-bank-secondary-nav.tsx +0 -3
  67. package/template/components/question-bank-table.tsx +19 -6
  68. package/template/components/rotations-empty-state.tsx +3 -0
  69. package/template/components/secondary-panel.tsx +23 -3
  70. package/template/components/settings-appearance-card.tsx +584 -141
  71. package/template/components/sidebar-shell.tsx +2 -1
  72. package/template/components/site-header.tsx +36 -31
  73. package/template/components/sites-list-view.tsx +31 -36
  74. package/template/components/sites-table.tsx +4 -0
  75. package/template/components/table-properties/drawer-button.tsx +38 -20
  76. package/template/components/table-properties/drawer.tsx +17 -14
  77. package/template/components/team-client.tsx +1 -1
  78. package/template/components/team-list-view.tsx +34 -50
  79. package/template/components/team-table.tsx +8 -3
  80. package/template/components/templates/list-page.tsx +12 -9
  81. package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
  82. package/template/components/ui/dot-pattern.tsx +50 -26
  83. package/template/components/ui/leo-icon.tsx +23 -3
  84. package/template/contexts/product-context.tsx +70 -7
  85. package/template/contexts/system-banner-context.tsx +112 -4
  86. package/template/docs/data-views-pattern.md +2 -0
  87. package/template/docs/kpi-flat-band-pattern.md +57 -0
  88. package/template/docs/kpi-strip-max-four-pattern.md +1 -0
  89. package/template/docs/shell-surface-elevation-pattern.md +52 -0
  90. package/template/eslint.config.mjs +18 -0
  91. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  92. package/template/lib/chunk-load-error.ts +13 -0
  93. package/template/lib/conditional-rule-match.ts +87 -22
  94. package/template/lib/data-list-persistence.ts +57 -257
  95. package/template/lib/data-list-view.ts +6 -0
  96. package/template/lib/dev-log.test.ts +6 -5
  97. package/template/lib/exxat-palette.json +1462 -0
  98. package/template/lib/exxat-palette.ts +136 -0
  99. package/template/lib/list-page-table-properties.ts +1 -1
  100. package/template/lib/list-status-badges.ts +1 -1
  101. package/template/lib/mailto.ts +29 -0
  102. package/template/lib/placement-board-card-layout.ts +1 -1
  103. package/template/lib/product-brand.ts +268 -0
  104. package/template/lib/question-bank-authoring.ts +308 -0
  105. package/template/lib/question-bank-nav.ts +44 -0
  106. package/template/lib/raf-throttle.ts +45 -0
  107. package/template/lib/sidebar-state-cookie.ts +9 -0
  108. package/template/lib/table-state-lifecycle.ts +521 -0
  109. package/template/next.config.mjs +156 -0
  110. package/template/package.json +3 -3
  111. package/template/stores/app-store.ts +46 -1
@@ -1,7 +1,15 @@
1
1
  "use client"
2
2
 
3
3
  /**
4
- * DataListTablelist hub shell on top of the generic DataTable.
4
+ * PlacementsTableplacements hub composition on top of the generic
5
+ * `DataTable`. Owns: placement-specific column defs, board column grouping,
6
+ * KPI dashboards, the "open table properties" imperative handle, and the
7
+ * Properties drawer + multi-view composition (table / list / board / …).
8
+ *
9
+ * NOTE: this is hub composition, NOT a parallel table primitive. Every hub
10
+ * has its own `*-table.tsx` of the same shape (`team-table.tsx`,
11
+ * `compliance-table.tsx`, …); all of them render `<DataTable>` from
12
+ * `@/components/data-table`.
5
13
  *
6
14
  * View tabs drive `view` (table | list | board | …). `lifecycleTabId` selects which **demo row
7
15
  * segment** (columns + filtered rows) to use — keep in sync with each tab's `filterId`, or pass
@@ -11,6 +19,7 @@
11
19
  import * as React from "react"
12
20
  import dynamic from "next/dynamic"
13
21
  import { cn } from "@/lib/utils"
22
+ import { mailtoHref } from "@/lib/mailto"
14
23
  import { useRouter } from "next/navigation"
15
24
  import { Button } from "@/components/ui/button"
16
25
  import { Tip } from "@/components/ui/tip"
@@ -48,15 +57,8 @@ import {
48
57
  DEFAULT_DATA_LIST_DISPLAY_OPTIONS,
49
58
  type DataListDisplayOptions,
50
59
  } from "@/lib/data-list-display-options"
51
- import {
52
- applyLifecyclePersisted,
53
- loadLifecycleFromStorage,
54
- scheduleLifecycleSave,
55
- serializeLifecycle,
56
- type TableStatePersistSlice,
57
- } from "@/lib/data-list-persistence"
58
60
  import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
59
- import { StatusBadge } from "@/components/data-list-table-cells"
61
+ import { StatusBadge } from "@/components/placements-table-cells"
60
62
  import { columnsToFilterFields } from "@/components/placements-table-columns"
61
63
  import { DataTable, DataTableToolbar } from "@/components/data-table"
62
64
  import { CountSyncer, PaginationBar } from "@/components/data-table/pagination"
@@ -127,9 +129,14 @@ function DataListBoardShell({
127
129
  displayOptions: DataListDisplayOptions
128
130
  onDisplayOptionsChange: (patch: Partial<DataListDisplayOptions>) => void
129
131
  }) {
132
+ // Store the "open properties drawer" callback on a stable ref so the parent
133
+ // imperative handle can invoke it without re-rendering the whole table.
134
+ // `state` is freshly returned each render by useTableState; only the React
135
+ // setter is stable and needed here.
130
136
  React.useEffect(() => {
131
137
  openDrawerRef.current = () => state.setSheetOpen(true)
132
- }, [state.setSheetOpen])
138
+ // eslint-disable-next-line react-hooks/exhaustive-deps
139
+ }, [openDrawerRef, state.setSheetOpen])
133
140
 
134
141
  const boardColumnMenu: PlacementsBoardColumnMenu = React.useMemo(
135
142
  () => ({
@@ -248,9 +255,11 @@ function DataListListShell({
248
255
  listRows: Placement[]
249
256
  emptyTableCopy: string
250
257
  }) {
258
+ // Stable "open properties drawer" callback ref — see top of this file.
251
259
  React.useEffect(() => {
252
260
  openDrawerRef.current = () => state.setSheetOpen(true)
253
- }, [state.setSheetOpen])
261
+ // eslint-disable-next-line react-hooks/exhaustive-deps
262
+ }, [openDrawerRef, state.setSheetOpen])
254
263
 
255
264
  return (
256
265
  <>
@@ -336,9 +345,11 @@ function DataListDashboardShell({
336
345
  displayOptions: DataListDisplayOptions
337
346
  onDisplayOptionsChange: (patch: Partial<DataListDisplayOptions>) => void
338
347
  }) {
348
+ // Stable "open properties drawer" callback ref — see top of this file.
339
349
  React.useEffect(() => {
340
350
  openDrawerRef.current = () => state.setSheetOpen(true)
341
- }, [state.setSheetOpen])
351
+ // eslint-disable-next-line react-hooks/exhaustive-deps
352
+ }, [openDrawerRef, state.setSheetOpen])
342
353
 
343
354
  const dashboardKpi = React.useMemo(
344
355
  () => ({
@@ -526,7 +537,7 @@ function PlacementFolderTile({
526
537
  conditionalRules?: ConditionalRule[]
527
538
  onClick: () => void
528
539
  }) {
529
- const ruleBg = getConditionalRowBackground(row, conditionalRules)
540
+ const ruleBg = getConditionalRowBackground(row, conditionalRules, boardColumns)
530
541
  const showStudent = isBoardFieldActive("student", tab, hiddenColKeys, boardColumns)
531
542
  const showStatus = isBoardFieldActive("status", tab, hiddenColKeys, boardColumns)
532
543
  const showSite = isBoardFieldActive("site", tab, hiddenColKeys, boardColumns)
@@ -627,9 +638,11 @@ function DataListFolderShell({
627
638
  }) {
628
639
  const router = useRouter()
629
640
 
641
+ // Stable "open properties drawer" callback ref — see top of this file.
630
642
  React.useEffect(() => {
631
643
  openDrawerRef.current = () => state.setSheetOpen(true)
632
- }, [state.setSheetOpen])
644
+ // eslint-disable-next-line react-hooks/exhaustive-deps
645
+ }, [openDrawerRef, state.setSheetOpen])
633
646
 
634
647
  const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
635
648
 
@@ -735,9 +748,11 @@ function DataListTreeShell({
735
748
  const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
736
749
  const [selectedId, setSelectedId] = React.useState<number | null>(() => listRows[0]?.id ?? null)
737
750
 
751
+ // Stable "open properties drawer" callback ref — see top of this file.
738
752
  React.useEffect(() => {
739
753
  openDrawerRef.current = () => state.setSheetOpen(true)
740
- }, [state.setSheetOpen])
754
+ // eslint-disable-next-line react-hooks/exhaustive-deps
755
+ }, [openDrawerRef, state.setSheetOpen])
741
756
 
742
757
  React.useEffect(() => {
743
758
  if (selectedId == null) {
@@ -861,7 +876,7 @@ function PlacementFinderListRow({
861
876
  boardColumns: ColumnDef<Placement>[]
862
877
  conditionalRules?: ConditionalRule[]
863
878
  }) {
864
- const ruleBg = getConditionalRowBackground(row, conditionalRules)
879
+ const ruleBg = getConditionalRowBackground(row, conditionalRules, boardColumns)
865
880
  const showStudent = isBoardFieldActive("student", tab, hiddenColKeys, boardColumns)
866
881
  const showSite = isBoardFieldActive("site", tab, hiddenColKeys, boardColumns)
867
882
  const name = showStudent ? row.student : `Placement ${row.id}`
@@ -941,7 +956,7 @@ function PlacementFinderDetail({
941
956
  <i className="fa-light fa-envelope text-[10px]" aria-hidden="true" /> Email
942
957
  </dt>
943
958
  <dd className="text-[13px]">
944
- <a href={`mailto:${row.email}`} className="text-interactive-foreground hover:underline">{row.email}</a>
959
+ <a href={mailtoHref(row.email)} className="text-interactive-foreground hover:underline">{row.email}</a>
945
960
  </dd>
946
961
  </div>
947
962
  )}
@@ -1099,9 +1114,11 @@ function DataListPanelShell({
1099
1114
  panelRenderListRow?: (row: Placement, isSelected: boolean) => React.ReactNode
1100
1115
  panelRenderDetail?: (row: Placement) => React.ReactNode
1101
1116
  }) {
1117
+ // Stable "open properties drawer" callback ref — see top of this file.
1102
1118
  React.useEffect(() => {
1103
1119
  openDrawerRef.current = () => state.setSheetOpen(true)
1104
- }, [state.setSheetOpen])
1120
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1121
+ }, [openDrawerRef, state.setSheetOpen])
1105
1122
 
1106
1123
  const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
1107
1124
  const groups = React.useMemo(
@@ -1187,7 +1204,7 @@ function DataListPanelShell({
1187
1204
  // Props
1188
1205
  // ─────────────────────────────────────────────────────────────────────────────
1189
1206
 
1190
- export interface DataListTableProps {
1207
+ export interface PlacementsTableProps {
1191
1208
  view?: DataListViewType
1192
1209
  onViewChange?: (view: DataListViewType) => void
1193
1210
  /** Demo row segment: drives filtered rows + column set (`all` | `upcoming` | `ongoing` | `completed`). */
@@ -1210,13 +1227,13 @@ export interface DataListTableProps {
1210
1227
  }
1211
1228
 
1212
1229
  /** Imperative handle — open Table Properties (table view only). */
1213
- export type DataListTableHandle = OpenTablePropertiesHandle
1230
+ export type PlacementsTableHandle = OpenTablePropertiesHandle
1214
1231
 
1215
1232
  // ─────────────────────────────────────────────────────────────────────────────
1216
1233
  // Main component
1217
1234
  // ─────────────────────────────────────────────────────────────────────────────
1218
1235
 
1219
- export const DataListTable = React.forwardRef<DataListTableHandle, DataListTableProps>(function DataListTable({
1236
+ export const PlacementsTable = React.forwardRef<PlacementsTableHandle, PlacementsTableProps>(function PlacementsTable({
1220
1237
  view = "table",
1221
1238
  onViewChange,
1222
1239
  lifecycleTabId = "all",
@@ -1318,52 +1335,11 @@ export const DataListTable = React.forwardRef<DataListTableHandle, DataListTable
1318
1335
 
1319
1336
  const tableState = useTableState(tableData, columns, { key: "student", dir: "asc" }, paginationOverride)
1320
1337
 
1321
- const columnKeys = React.useMemo(() => new Set(columns.map(c => c.key)), [columns])
1322
-
1323
- React.useLayoutEffect(() => {
1324
- const raw = loadLifecycleFromStorage(lifecycleTabId)
1325
- if (!raw) return
1326
- applyLifecyclePersisted(tableState as unknown as TableStatePersistSlice, raw, columnKeys)
1327
- setConditionalRules(raw.conditionalRules)
1328
- setPagination(raw.pagination)
1329
- setPaginationPage(raw.paginationPage)
1330
- setPaginationPageSize(raw.paginationPageSize)
1331
- }, [lifecycleTabId, columnKeys])
1332
-
1338
+ // Stable "open properties drawer" callback ref see top of this file.
1333
1339
  React.useEffect(() => {
1334
1340
  openDrawerRef.current = () => tableState.setSheetOpen(true)
1335
- }, [tableState.setSheetOpen])
1336
-
1337
- React.useEffect(() => {
1338
- const payload = serializeLifecycle(tableState as unknown as TableStatePersistSlice, {
1339
- conditionalRules,
1340
- pagination,
1341
- paginationPage: safePage,
1342
- paginationPageSize,
1343
- })
1344
- scheduleLifecycleSave(lifecycleTabId, payload)
1345
- }, [
1346
- lifecycleTabId,
1347
- tableState.sortRules,
1348
- tableState.search,
1349
- tableState.activeFilters,
1350
- tableState.filterConnectors,
1351
- tableState.groupBy,
1352
- tableState.colOrder,
1353
- tableState.hiddenCols,
1354
- tableState.colWidths,
1355
- tableState.colPins,
1356
- tableState.colWrap,
1357
- tableState.colMenuSearch,
1358
- tableState.rowHeight,
1359
- tableState.showGridlines,
1360
- tableState.filterBarVisible,
1361
- tableState.searchOpen,
1362
- conditionalRules,
1363
- pagination,
1364
- safePage,
1365
- paginationPageSize,
1366
- ])
1341
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1342
+ }, [openDrawerRef, tableState.setSheetOpen])
1367
1343
 
1368
1344
  function buildToolbarSlot(
1369
1345
  s: ReturnType<typeof useTableState<Placement>>,
@@ -1658,7 +1634,7 @@ export const DataListTable = React.forwardRef<DataListTableHandle, DataListTable
1658
1634
  return <DataTable<Placement> key={lifecycleTabId} {...tableProps} />
1659
1635
  })
1660
1636
 
1661
- DataListTable.displayName = "DataListTable"
1637
+ PlacementsTable.displayName = "PlacementsTable"
1662
1638
 
1663
1639
 
1664
1640
  export type { DataListViewType } from "@/lib/data-list-view"
@@ -18,17 +18,30 @@ import {
18
18
  } from "@/components/ui/sidebar"
19
19
  import { ExxatProductLogo, ExxatProductMark } from "@/components/exxat-product-logo"
20
20
  import { useProduct, type Product } from "@/contexts/product-context"
21
+ import { customProductBrandConfig, productBrandLabel } from "@/lib/product-brand"
21
22
 
22
23
  const PRODUCTS: { id: Product; label: string }[] = [
23
- { id: "exxat-one", label: "Exxat One" },
24
- { id: "exxat-prism", label: "Exxat Prism" },
24
+ { id: "exxat-one", label: "Exxat One" },
25
+ { id: "exxat-prism", label: "Exxat Prism" },
26
+ { id: "exxat-assessment", label: "Exxat Assessment" },
27
+ { id: "exxat-custom", label: "Custom product" },
25
28
  ]
26
29
 
27
30
  export function ProductSwitcher() {
28
- const { product, setProduct } = useProduct()
31
+ const { product, setProduct, customProductBrand, hiddenProductIds } = useProduct()
29
32
  const { state, isMobile } = useSidebar()
30
33
 
31
- const current = PRODUCTS.find(p => p.id === product) ?? PRODUCTS[0]
34
+ const products = React.useMemo(
35
+ () => PRODUCTS.flatMap(p => {
36
+ if (hiddenProductIds.includes(p.id)) return []
37
+ if (p.id !== "exxat-custom") return [p]
38
+ return customProductBrand
39
+ ? [{ ...p, label: productBrandLabel(customProductBrandConfig(customProductBrand)) }]
40
+ : []
41
+ }),
42
+ [customProductBrand, hiddenProductIds],
43
+ )
44
+ const current = products.find(p => p.id === product) ?? products[0]
32
45
  const iconRail = state === "collapsed" && !isMobile
33
46
  const expandedOrMobile = state === "expanded" || isMobile
34
47
 
@@ -51,6 +64,7 @@ export function ProductSwitcher() {
51
64
  suppressHydrationWarning
52
65
  >
53
66
  {iconRail ? (
67
+ // Collapsed icon-rail product mark — same frame as school avatar.
54
68
  <span className="flex size-8 shrink-0 items-center justify-center">
55
69
  <ExxatProductMark product={current.id} className="size-7" />
56
70
  </span>
@@ -63,7 +77,7 @@ export function ProductSwitcher() {
63
77
  <ExxatProductLogo
64
78
  product={current.id}
65
79
  variant="mutedSuffix"
66
- className="h-7 w-auto max-w-[min(100%,260px)] object-left object-contain"
80
+ className="w-auto max-w-[min(100%,260px)]"
67
81
  />
68
82
  </span>
69
83
  <i
@@ -84,7 +98,7 @@ export function ProductSwitcher() {
84
98
  Switch product
85
99
  </DropdownMenuLabel>
86
100
  <DropdownMenuSeparator />
87
- {PRODUCTS.map(p => (
101
+ {products.map(p => (
88
102
  <DropdownMenuItem
89
103
  key={p.id}
90
104
  onClick={() => setProduct(p.id)}
@@ -94,7 +108,10 @@ export function ProductSwitcher() {
94
108
  <ExxatProductLogo
95
109
  product={p.id}
96
110
  variant="mutedSuffix"
97
- className="h-7 w-auto shrink-0 max-w-[min(100%,200px)]"
111
+ // h-9 matches the sidebar trigger so the mark renders at the
112
+ // same 32 px footprint in both contexts. Dropdown rows
113
+ // accommodate the bump via `py-2` on `DropdownMenuItem`.
114
+ className="h-9 w-auto shrink-0 max-w-[min(100%,240px)]"
98
115
  />
99
116
  {p.id === product && (
100
117
  <i
@@ -0,0 +1,282 @@
1
+ "use client"
2
+
3
+ /**
4
+ * ProductWordmark + ProductMark — render any product brand as a logo.
5
+ *
6
+ * - `ProductWordmark` renders `${prefix} ${suffix}` as HTML text:
7
+ * • `prefix` (e.g. "Exxat") in `font-sans` extra-bold (Inter 800), neutral.
8
+ * • `suffix` (e.g. "One" / "Prism" / "Pulse") in **Ivy Presto Italic**
9
+ * (`var(--font-heading)`, Adobe Fonts kit `wuk5wqn` preloaded in
10
+ * `app/layout.tsx`) tinted with `brandColor`.
11
+ *
12
+ * We render real font glyphs rather than baked-in SVG paths so a new product
13
+ * only needs `{ prefix, suffix, brandColor }` — no path-tracing required.
14
+ *
15
+ * - `ProductMark` renders the same "E"-style circular mark used by Exxat,
16
+ * recolored with the brand's gradient / fill. The SVG geometry stays
17
+ * constant so existing layouts keep working.
18
+ *
19
+ * `variant="mutedSuffix"` (sidebar / switcher): prefix recedes in dark mode;
20
+ * suffix always uses `wordmarkColor` (Exxat pink) for brand recognition.
21
+ */
22
+
23
+ import * as React from "react"
24
+ import { cn } from "@/lib/utils"
25
+ import type { ProductBrandConfig } from "@/lib/product-brand"
26
+
27
+ export type ProductWordmarkVariant = "default" | "mutedSuffix"
28
+
29
+ export interface ProductWordmarkProps {
30
+ config: ProductBrandConfig
31
+ variant?: ProductWordmarkVariant
32
+ className?: string
33
+ }
34
+
35
+ /* ── Wordmark ──────────────────────────────────────────────────────────────── */
36
+
37
+ /**
38
+ * Inline product wordmark. Sizing is height-driven — the parent sets the
39
+ * height (e.g. `className="h-7"`) and the text scales via `text-[...]`
40
+ * derived from `--wordmark-size` (set inline from the rendered font-size).
41
+ *
42
+ * Use `aria-hidden` because the wordmark is decorative — pair it with an
43
+ * `aria-label` on the trigger/link (see {@link productBrandLabel}).
44
+ */
45
+ export function ProductWordmark({
46
+ config,
47
+ variant = "default",
48
+ className,
49
+ }: ProductWordmarkProps) {
50
+ const prefix = config.prefix ?? "Exxat"
51
+ const { suffix, brandColor, wordmarkColor } = config
52
+ const suffixColor = wordmarkColor ?? brandColor
53
+
54
+ return (
55
+ <span
56
+ aria-hidden="true"
57
+ data-product-wordmark
58
+ data-product-id={config.id}
59
+ className={cn(
60
+ // Inline-flex so it sits on a text baseline; whitespace-nowrap so the
61
+ // suffix never wraps under the prefix at narrow widths.
62
+ "inline-flex items-baseline whitespace-nowrap leading-none select-none",
63
+ // Sized **relative to the inherited font-size** so the wordmark always
64
+ // dominates whatever surface hosts it. The parent (`ProductLogo` /
65
+ // `ExxatProductLogo`) pins `text-base` (16 px) → this resolves to
66
+ // ~28 px wordmark text (~20 px cap), matching the cap-to-render-height
67
+ // ratio in the standalone Exxat brand assets (~0.72; image dims 446×124
68
+ // with ~89 px caps). Slight (~1 px) overflow against a 28 px parent
69
+ // height is acceptable — sidebar / switcher slots use `overflow-visible`.
70
+ "text-[1.78em] tracking-tight",
71
+ // Vertically centre the **cap mid-line** on the parent's mid-line.
72
+ // Without this nudge the cap sits ~9 % of font-size above span centre
73
+ // because Inter / Ivy Presto baseline metrics put glyphs in the upper
74
+ // portion of the line box. 0.09 em moves the cap centre down by that
75
+ // exact offset so it shares an axis with the mark centre.
76
+ "translate-y-[0.09em]",
77
+ className,
78
+ )}
79
+ >
80
+ <span
81
+ className={cn(
82
+ "font-sans font-extrabold",
83
+ // Neutral wordmark prefix: deep slate on light, soft cool grey on dark.
84
+ "text-[#273441] dark:text-[#A8B2BA]",
85
+ )}
86
+ >
87
+ {prefix}
88
+ </span>
89
+ <span
90
+ data-product-wordmark-suffix
91
+ className={cn(
92
+ // Per the official Exxat brand spec (Figma):
93
+ // font-family: IvyPresto Text
94
+ // weight: SemiBold (600) — NOT Bold / ExtraBold
95
+ // tracking: -3% — overrides parent `tracking-tight`
96
+ // line-height: auto — inherited (parent sets `leading-none`)
97
+ // IvyPresto's Bodoni-lineage SemiBold already has the thick verticals
98
+ // that read as a logo; pushing to 700/800 makes the letterforms
99
+ // visually heavier than the brand asset.
100
+ "ms-[0.18em] font-semibold tracking-[-0.03em]",
101
+ )}
102
+ style={{
103
+ // Ivy Presto Text from Adobe Fonts. Upright (NOT italic) — matches
104
+ // the official Exxat wordmark. Fallback chain ends in `serif` so
105
+ // FOUT still renders a serif that reads as a logo rather than Inter.
106
+ fontFamily: "var(--font-heading), 'ivypresto-text', Georgia, serif",
107
+ // `wordmarkColor` (Exxat pink) in light and dark — never muted to grey.
108
+ color: suffixColor,
109
+ }}
110
+ >
111
+ {suffix}
112
+ </span>
113
+ </span>
114
+ )
115
+ }
116
+
117
+ /* ── Circular mark ─────────────────────────────────────────────────────────── */
118
+
119
+ export interface ProductMarkProps {
120
+ config: ProductBrandConfig
121
+ className?: string
122
+ cutoutColor?: string
123
+ }
124
+
125
+ /**
126
+ * Generate a stable id suffix for SVG gradient defs so multiple marks on the
127
+ * same page never collide. Strip colons because IDs in HTML/SVG can't legally
128
+ * include them (Radix uses `:`-style IDs by default).
129
+ */
130
+ function useMarkGradientId(brandId: string) {
131
+ const raw = React.useId().replace(/:/g, "")
132
+ return `pmk-${brandId.replace(/[^a-z0-9-]/gi, "")}-${raw}`
133
+ }
134
+
135
+ /**
136
+ * Defer SVG `<defs>` (gradient refs) until after mount so server HTML matches
137
+ * the first client paint. `useId()` returns different suffixes in SSR vs CSR
138
+ * trees that conditionally mount the sidebar.
139
+ */
140
+ function useBrowserPaintReady() {
141
+ const [ready, setReady] = React.useState(false)
142
+ React.useLayoutEffect(() => {
143
+ setReady(true)
144
+ }, [])
145
+ return ready
146
+ }
147
+
148
+ /**
149
+ * Recoloured Exxat "E" mark. Same geometry as the canonical brand mark, so
150
+ * existing pixel-aligned layouts (sidebar header, dropdown rows) don't shift.
151
+ *
152
+ * Fills:
153
+ * - Outer circle: `markGradient` if provided, else flat `brandColor`.
154
+ * - Inner shadow plate: `markShadow` (defaults to `brandColor`).
155
+ * - Cut-out "E" strokes: always white in product chrome (sidebar / switcher).
156
+ */
157
+ export function ProductMark({ config, className, cutoutColor = "white" }: ProductMarkProps) {
158
+ const ready = useBrowserPaintReady()
159
+ const gradId = useMarkGradientId(config.id)
160
+ const [from, to] = config.markGradient ?? [config.brandColor, config.brandColor]
161
+ const shadow = config.markShadow ?? config.brandColor
162
+
163
+ // No size default. Callers MUST set explicit dimensions (`size-7`, `h-full
164
+ // w-auto`, etc.). A `size-*` default here loses to a downstream `h-full /
165
+ // w-auto` only when `tailwind-merge` correctly identifies `size-7` as a
166
+ // `w-7 + h-7` shorthand — fragile across versions and causes the mark to
167
+ // render at the default size instead of the parent's height (see
168
+ // `ExxatProductLogo` h-full mark → 32 px in h-8 parent). Aspect-square stays
169
+ // so the mark renders as a circle when only one of width/height is set.
170
+ const sharedClass = cn(
171
+ "box-border block aspect-square shrink-0 flex-none object-contain",
172
+ className,
173
+ )
174
+
175
+ if (!ready) {
176
+ return (
177
+ <svg
178
+ viewBox="0 8.25 147 147"
179
+ preserveAspectRatio="xMidYMid meet"
180
+ fill="none"
181
+ xmlns="http://www.w3.org/2000/svg"
182
+ data-product-mark
183
+ data-product-logo-mark
184
+ data-product-id={config.id}
185
+ className={sharedClass}
186
+ aria-hidden="true"
187
+ suppressHydrationWarning
188
+ />
189
+ )
190
+ }
191
+
192
+ return (
193
+ <svg
194
+ viewBox="0 8.25 147 147"
195
+ preserveAspectRatio="xMidYMid meet"
196
+ fill="none"
197
+ xmlns="http://www.w3.org/2000/svg"
198
+ data-product-mark
199
+ data-product-logo-mark
200
+ data-product-id={config.id}
201
+ className={sharedClass}
202
+ aria-hidden="true"
203
+ suppressHydrationWarning
204
+ >
205
+ <path
206
+ d="M73.4939 155.238C114.084 155.238 146.988 122.334 146.988 81.7439C146.988 41.1544 114.084 8.25 73.4939 8.25C32.9044 8.25 0 41.1544 0 81.7439C0 122.334 32.9044 155.238 73.4939 155.238Z"
207
+ fill={`url(#${gradId})`}
208
+ />
209
+ <path
210
+ d="M0.594727 90.9915C4.59951 122.921 29.0894 148.466 60.4966 154.085L102.462 116.355V102.302H86.8312L102.462 88.2489V74.1957H86.8312L102.462 60.1425V46.0894H50.5575L0.594727 90.9915Z"
211
+ fill={shadow}
212
+ />
213
+ <path d="M102.474 116.355H50.5576L58.6764 102.302H102.474V116.355Z" fill={cutoutColor} />
214
+ <path d="M102.474 60.1303H58.6764L50.5576 46.0771H102.474V60.1303Z" fill={cutoutColor} />
215
+ <path d="M102.474 88.2368H66.7949L70.8483 81.2102L66.7949 74.1836H102.474V88.2368Z" fill={cutoutColor} />
216
+ <path d="M39.2227 74.1835H66.795L58.6762 60.1304H39.2227V74.1835Z" fill={cutoutColor} />
217
+ <path d="M39.2227 102.302H58.6762L66.795 88.2368H39.2227V102.302Z" fill={cutoutColor} />
218
+ <defs>
219
+ <linearGradient
220
+ id={gradId}
221
+ x1="28.3733"
222
+ y1="134.255"
223
+ x2="117.195"
224
+ y2="30.9074"
225
+ gradientUnits="userSpaceOnUse"
226
+ >
227
+ <stop offset="0" stopColor={from} />
228
+ <stop offset="1" stopColor={to} />
229
+ </linearGradient>
230
+ </defs>
231
+ </svg>
232
+ )
233
+ }
234
+
235
+ /* ── Mark + wordmark combo ─────────────────────────────────────────────────── */
236
+
237
+ export interface ProductLogoProps {
238
+ config: ProductBrandConfig
239
+ variant?: ProductWordmarkVariant
240
+ /** Render only the mark (omit the wordmark). */
241
+ markOnly?: boolean
242
+ /** Render only the wordmark (omit the mark). */
243
+ wordmarkOnly?: boolean
244
+ className?: string
245
+ /** Class applied to the inner mark — useful for sizing it independently. */
246
+ markClassName?: string
247
+ /** Class applied to the inner wordmark. */
248
+ wordmarkClassName?: string
249
+ }
250
+
251
+ /**
252
+ * Mark + wordmark composed inline. Pass `markOnly` for collapsed sidebar /
253
+ * favicon-like contexts, or `wordmarkOnly` if you've already rendered the
254
+ * mark separately (e.g. switcher dropdown rows).
255
+ */
256
+ export function ProductLogo({
257
+ config,
258
+ variant = "default",
259
+ markOnly = false,
260
+ wordmarkOnly = false,
261
+ className,
262
+ markClassName,
263
+ wordmarkClassName,
264
+ }: ProductLogoProps) {
265
+ if (markOnly) {
266
+ return <ProductMark config={config} className={cn(className, markClassName)} />
267
+ }
268
+ if (wordmarkOnly) {
269
+ return <ProductWordmark config={config} variant={variant} className={cn(className, wordmarkClassName)} />
270
+ }
271
+ return (
272
+ <span
273
+ aria-hidden="true"
274
+ data-product-logo
275
+ data-product-id={config.id}
276
+ className={cn("inline-flex items-center gap-2", className)}
277
+ >
278
+ <ProductMark config={config} className={cn("size-7", markClassName)} />
279
+ <ProductWordmark config={config} variant={variant} className={wordmarkClassName} />
280
+ </span>
281
+ )
282
+ }
@@ -21,6 +21,7 @@ import { QuestionBankTable, type QuestionBankTableHandle } from "@/components/qu
21
21
  import { SecondaryPanelHubTemplate } from "@/components/templates/secondary-panel-hub-template"
22
22
  import { QuestionBankAccessBridge, QuestionBankFolderBridge } from "@/components/secondary-panel"
23
23
  import { KeyMetrics } from "@/components/key-metrics"
24
+ import { useSidebar } from "@/components/ui/sidebar"
24
25
  import { useSecondaryPanelHubNav } from "@/hooks/use-secondary-panel-hub-nav"
25
26
  import { QUESTION_BANK_ITEMS } from "@/lib/mock/question-bank"
26
27
  import { QUESTION_BANK_HEADER_COLLABORATORS } from "@/lib/mock/question-bank-header-collaborators"
@@ -188,6 +189,23 @@ export function QuestionBankClient() {
188
189
  setHubFolderCustomizeSheetOpen(true)
189
190
  }, [folders, navState.folderId, navState.scope])
190
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
+
191
209
  const filteredItems = React.useMemo(
192
210
  () => applyQuestionBankHubDisplayFilters(items, folders, navState, landingFilters),
193
211
  [items, folders, landingFilters, navState],
@@ -250,7 +268,7 @@ export function QuestionBankClient() {
250
268
  title={dedicatedSearchTitle}
251
269
  questionCount={count}
252
270
  hideNewQuestion
253
- onNewQuestion={() => {}}
271
+ onNewQuestion={handleNewQuestion}
254
272
  onExport={() => setExportOpen(true)}
255
273
  />
256
274
  <DedicatedSearchUrlComposer
@@ -378,7 +396,7 @@ export function QuestionBankClient() {
378
396
  title={hubHeader.title}
379
397
  questionCount={count}
380
398
  collaborators={collaborators}
381
- onNewQuestion={() => {}}
399
+ onNewQuestion={handleNewQuestion}
382
400
  onExport={() => setExportOpen(true)}
383
401
  onAddCollaborator={openInvite}
384
402
  onCollaboratorsOpen={openInvite}