@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
@@ -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
+ * lifecycle persistence wiring (via `useTableStateLifecycle`).
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,14 @@ 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"
60
+ import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
61
+ import type { PlacementsLifecycleExtras } from "@/lib/data-list-persistence"
62
+
63
+ /** Storage namespace for the placements hub. Keep `"data-list"` so existing
64
+ * user payloads in localStorage remain readable. */
65
+ const PLACEMENTS_LIFECYCLE_NAMESPACE = "data-list"
58
66
  import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
59
- import { StatusBadge } from "@/components/data-list-table-cells"
67
+ import { StatusBadge } from "@/components/placements-table-cells"
60
68
  import { columnsToFilterFields } from "@/components/placements-table-columns"
61
69
  import { DataTable, DataTableToolbar } from "@/components/data-table"
62
70
  import { CountSyncer, PaginationBar } from "@/components/data-table/pagination"
@@ -127,9 +135,14 @@ function DataListBoardShell({
127
135
  displayOptions: DataListDisplayOptions
128
136
  onDisplayOptionsChange: (patch: Partial<DataListDisplayOptions>) => void
129
137
  }) {
138
+ // Store the "open properties drawer" callback on a stable ref so the parent
139
+ // imperative handle can invoke it without re-rendering the whole table.
140
+ // `state` is freshly returned each render by useTableState; only the React
141
+ // setter is stable and needed here.
130
142
  React.useEffect(() => {
131
143
  openDrawerRef.current = () => state.setSheetOpen(true)
132
- }, [state.setSheetOpen])
144
+ // eslint-disable-next-line react-hooks/exhaustive-deps
145
+ }, [openDrawerRef, state.setSheetOpen])
133
146
 
134
147
  const boardColumnMenu: PlacementsBoardColumnMenu = React.useMemo(
135
148
  () => ({
@@ -248,9 +261,11 @@ function DataListListShell({
248
261
  listRows: Placement[]
249
262
  emptyTableCopy: string
250
263
  }) {
264
+ // Stable "open properties drawer" callback ref — see top of this file.
251
265
  React.useEffect(() => {
252
266
  openDrawerRef.current = () => state.setSheetOpen(true)
253
- }, [state.setSheetOpen])
267
+ // eslint-disable-next-line react-hooks/exhaustive-deps
268
+ }, [openDrawerRef, state.setSheetOpen])
254
269
 
255
270
  return (
256
271
  <>
@@ -336,9 +351,11 @@ function DataListDashboardShell({
336
351
  displayOptions: DataListDisplayOptions
337
352
  onDisplayOptionsChange: (patch: Partial<DataListDisplayOptions>) => void
338
353
  }) {
354
+ // Stable "open properties drawer" callback ref — see top of this file.
339
355
  React.useEffect(() => {
340
356
  openDrawerRef.current = () => state.setSheetOpen(true)
341
- }, [state.setSheetOpen])
357
+ // eslint-disable-next-line react-hooks/exhaustive-deps
358
+ }, [openDrawerRef, state.setSheetOpen])
342
359
 
343
360
  const dashboardKpi = React.useMemo(
344
361
  () => ({
@@ -627,9 +644,11 @@ function DataListFolderShell({
627
644
  }) {
628
645
  const router = useRouter()
629
646
 
647
+ // Stable "open properties drawer" callback ref — see top of this file.
630
648
  React.useEffect(() => {
631
649
  openDrawerRef.current = () => state.setSheetOpen(true)
632
- }, [state.setSheetOpen])
650
+ // eslint-disable-next-line react-hooks/exhaustive-deps
651
+ }, [openDrawerRef, state.setSheetOpen])
633
652
 
634
653
  const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
635
654
 
@@ -735,9 +754,11 @@ function DataListTreeShell({
735
754
  const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
736
755
  const [selectedId, setSelectedId] = React.useState<number | null>(() => listRows[0]?.id ?? null)
737
756
 
757
+ // Stable "open properties drawer" callback ref — see top of this file.
738
758
  React.useEffect(() => {
739
759
  openDrawerRef.current = () => state.setSheetOpen(true)
740
- }, [state.setSheetOpen])
760
+ // eslint-disable-next-line react-hooks/exhaustive-deps
761
+ }, [openDrawerRef, state.setSheetOpen])
741
762
 
742
763
  React.useEffect(() => {
743
764
  if (selectedId == null) {
@@ -941,7 +962,7 @@ function PlacementFinderDetail({
941
962
  <i className="fa-light fa-envelope text-[10px]" aria-hidden="true" /> Email
942
963
  </dt>
943
964
  <dd className="text-[13px]">
944
- <a href={`mailto:${row.email}`} className="text-interactive-foreground hover:underline">{row.email}</a>
965
+ <a href={mailtoHref(row.email)} className="text-interactive-foreground hover:underline">{row.email}</a>
945
966
  </dd>
946
967
  </div>
947
968
  )}
@@ -1099,9 +1120,11 @@ function DataListPanelShell({
1099
1120
  panelRenderListRow?: (row: Placement, isSelected: boolean) => React.ReactNode
1100
1121
  panelRenderDetail?: (row: Placement) => React.ReactNode
1101
1122
  }) {
1123
+ // Stable "open properties drawer" callback ref — see top of this file.
1102
1124
  React.useEffect(() => {
1103
1125
  openDrawerRef.current = () => state.setSheetOpen(true)
1104
- }, [state.setSheetOpen])
1126
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1127
+ }, [openDrawerRef, state.setSheetOpen])
1105
1128
 
1106
1129
  const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
1107
1130
  const groups = React.useMemo(
@@ -1187,7 +1210,7 @@ function DataListPanelShell({
1187
1210
  // Props
1188
1211
  // ─────────────────────────────────────────────────────────────────────────────
1189
1212
 
1190
- export interface DataListTableProps {
1213
+ export interface PlacementsTableProps {
1191
1214
  view?: DataListViewType
1192
1215
  onViewChange?: (view: DataListViewType) => void
1193
1216
  /** Demo row segment: drives filtered rows + column set (`all` | `upcoming` | `ongoing` | `completed`). */
@@ -1210,13 +1233,13 @@ export interface DataListTableProps {
1210
1233
  }
1211
1234
 
1212
1235
  /** Imperative handle — open Table Properties (table view only). */
1213
- export type DataListTableHandle = OpenTablePropertiesHandle
1236
+ export type PlacementsTableHandle = OpenTablePropertiesHandle
1214
1237
 
1215
1238
  // ─────────────────────────────────────────────────────────────────────────────
1216
1239
  // Main component
1217
1240
  // ─────────────────────────────────────────────────────────────────────────────
1218
1241
 
1219
- export const DataListTable = React.forwardRef<DataListTableHandle, DataListTableProps>(function DataListTable({
1242
+ export const PlacementsTable = React.forwardRef<PlacementsTableHandle, PlacementsTableProps>(function PlacementsTable({
1220
1243
  view = "table",
1221
1244
  onViewChange,
1222
1245
  lifecycleTabId = "all",
@@ -1320,50 +1343,36 @@ export const DataListTable = React.forwardRef<DataListTableHandle, DataListTable
1320
1343
 
1321
1344
  const columnKeys = React.useMemo(() => new Set(columns.map(c => c.key)), [columns])
1322
1345
 
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
-
1346
+ // Stable "open properties drawer" callback ref — see top of this file.
1333
1347
  React.useEffect(() => {
1334
1348
  openDrawerRef.current = () => tableState.setSheetOpen(true)
1335
- }, [tableState.setSheetOpen])
1336
-
1337
- React.useEffect(() => {
1338
- const payload = serializeLifecycle(tableState as unknown as TableStatePersistSlice, {
1349
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1350
+ }, [openDrawerRef, tableState.setSheetOpen])
1351
+
1352
+ // ── Lifecycle persistence ─────────────────────────────────────────────
1353
+ // Centralised in `lib/table-state-lifecycle` — one hook wires both the
1354
+ // load (layout effect) and the debounced save (effect) including all the
1355
+ // table slices plus placements-specific extras. Hubs that don't want
1356
+ // localStorage persistence simply don't call this hook.
1357
+ useTableStateLifecycle<PlacementsLifecycleExtras>({
1358
+ namespace: PLACEMENTS_LIFECYCLE_NAMESPACE,
1359
+ tabId: lifecycleTabId,
1360
+ tableState,
1361
+ columnKeys,
1362
+ extras: {
1339
1363
  conditionalRules,
1340
1364
  pagination,
1341
1365
  paginationPage: safePage,
1342
1366
  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
- ])
1367
+ },
1368
+ onLoadExtras: e => {
1369
+ if (!e) return
1370
+ if (Array.isArray(e.conditionalRules)) setConditionalRules(e.conditionalRules as ConditionalRule[])
1371
+ if (typeof e.pagination === "boolean") setPagination(e.pagination)
1372
+ if (typeof e.paginationPage === "number") setPaginationPage(e.paginationPage)
1373
+ if (typeof e.paginationPageSize === "number") setPaginationPageSize(e.paginationPageSize)
1374
+ },
1375
+ })
1367
1376
 
1368
1377
  function buildToolbarSlot(
1369
1378
  s: ReturnType<typeof useTableState<Placement>>,
@@ -1658,7 +1667,7 @@ export const DataListTable = React.forwardRef<DataListTableHandle, DataListTable
1658
1667
  return <DataTable<Placement> key={lifecycleTabId} {...tableProps} />
1659
1668
  })
1660
1669
 
1661
- DataListTable.displayName = "DataListTable"
1670
+ PlacementsTable.displayName = "PlacementsTable"
1662
1671
 
1663
1672
 
1664
1673
  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,11 +64,10 @@ export function ProductSwitcher() {
51
64
  suppressHydrationWarning
52
65
  >
53
66
  {iconRail ? (
67
+ // Collapsed icon-rail product mark must read as a peer of the
68
+ // school selector, without a white cutout patch on the rail.
54
69
  <span className="flex size-8 shrink-0 items-center justify-center">
55
- <ExxatProductMark
56
- product={current.id}
57
- className="size-7 max-h-none"
58
- />
70
+ <ExxatProductMark product={current.id} className="size-8" cutoutColor="var(--sidebar)" />
59
71
  </span>
60
72
  ) : (
61
73
  <>
@@ -66,7 +78,7 @@ export function ProductSwitcher() {
66
78
  <ExxatProductLogo
67
79
  product={current.id}
68
80
  variant="mutedSuffix"
69
- className="h-7 w-auto max-w-[min(100%,260px)] object-left object-contain"
81
+ className="w-auto max-w-[min(100%,260px)]"
70
82
  />
71
83
  </span>
72
84
  <i
@@ -87,7 +99,7 @@ export function ProductSwitcher() {
87
99
  Switch product
88
100
  </DropdownMenuLabel>
89
101
  <DropdownMenuSeparator />
90
- {PRODUCTS.map(p => (
102
+ {products.map(p => (
91
103
  <DropdownMenuItem
92
104
  key={p.id}
93
105
  onClick={() => setProduct(p.id)}
@@ -97,7 +109,10 @@ export function ProductSwitcher() {
97
109
  <ExxatProductLogo
98
110
  product={p.id}
99
111
  variant="mutedSuffix"
100
- className="h-7 w-auto shrink-0 max-w-[min(100%,200px)]"
112
+ // h-9 matches the sidebar trigger so the mark renders at the
113
+ // same 32 px footprint in both contexts. Dropdown rows
114
+ // accommodate the bump via `py-2` on `DropdownMenuItem`.
115
+ className="h-9 w-auto shrink-0 max-w-[min(100%,240px)]"
101
116
  />
102
117
  {p.id === product && (
103
118
  <i
@@ -0,0 +1,285 @@
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"` (used by the product switcher / sidebar): in **dark**
20
+ * mode only, the suffix tints to `--muted-foreground` so the wordmark recedes
21
+ * into the rail. Light mode keeps the brand color for recognition.
22
+ */
23
+
24
+ import * as React from "react"
25
+ import { cn } from "@/lib/utils"
26
+ import type { ProductBrandConfig } from "@/lib/product-brand"
27
+
28
+ export type ProductWordmarkVariant = "default" | "mutedSuffix"
29
+
30
+ export interface ProductWordmarkProps {
31
+ config: ProductBrandConfig
32
+ variant?: ProductWordmarkVariant
33
+ className?: string
34
+ }
35
+
36
+ /* ── Wordmark ──────────────────────────────────────────────────────────────── */
37
+
38
+ /**
39
+ * Inline product wordmark. Sizing is height-driven — the parent sets the
40
+ * height (e.g. `className="h-7"`) and the text scales via `text-[...]`
41
+ * derived from `--wordmark-size` (set inline from the rendered font-size).
42
+ *
43
+ * Use `aria-hidden` because the wordmark is decorative — pair it with an
44
+ * `aria-label` on the trigger/link (see {@link productBrandLabel}).
45
+ */
46
+ export function ProductWordmark({
47
+ config,
48
+ variant = "default",
49
+ className,
50
+ }: ProductWordmarkProps) {
51
+ const prefix = config.prefix ?? "Exxat"
52
+ const { suffix, brandColor, wordmarkColor } = config
53
+ const mutedSuffix = variant === "mutedSuffix"
54
+ const suffixColor = wordmarkColor ?? brandColor
55
+
56
+ return (
57
+ <span
58
+ aria-hidden="true"
59
+ data-product-wordmark
60
+ data-product-id={config.id}
61
+ className={cn(
62
+ // Inline-flex so it sits on a text baseline; whitespace-nowrap so the
63
+ // suffix never wraps under the prefix at narrow widths.
64
+ "inline-flex items-baseline whitespace-nowrap leading-none select-none",
65
+ // Sized **relative to the inherited font-size** so the wordmark always
66
+ // dominates whatever surface hosts it. The parent (`ProductLogo` /
67
+ // `ExxatProductLogo`) pins `text-base` (16 px) → this resolves to
68
+ // ~28 px wordmark text (~20 px cap), matching the cap-to-render-height
69
+ // ratio in the standalone Exxat brand assets (~0.72; image dims 446×124
70
+ // with ~89 px caps). Slight (~1 px) overflow against a 28 px parent
71
+ // height is acceptable — sidebar / switcher slots use `overflow-visible`.
72
+ "text-[1.78em] tracking-tight",
73
+ // Vertically centre the **cap mid-line** on the parent's mid-line.
74
+ // Without this nudge the cap sits ~9 % of font-size above span centre
75
+ // because Inter / Ivy Presto baseline metrics put glyphs in the upper
76
+ // portion of the line box. 0.09 em moves the cap centre down by that
77
+ // exact offset so it shares an axis with the mark centre.
78
+ "translate-y-[0.09em]",
79
+ className,
80
+ )}
81
+ >
82
+ <span
83
+ className={cn(
84
+ "font-sans font-extrabold",
85
+ // Neutral wordmark prefix: deep slate on light, soft cool grey on dark.
86
+ "text-[#273441] dark:text-[#A8B2BA]",
87
+ )}
88
+ >
89
+ {prefix}
90
+ </span>
91
+ <span
92
+ data-product-wordmark-suffix
93
+ className={cn(
94
+ // Per the official Exxat brand spec (Figma):
95
+ // font-family: IvyPresto Text
96
+ // weight: SemiBold (600) — NOT Bold / ExtraBold
97
+ // tracking: -3% — overrides parent `tracking-tight`
98
+ // line-height: auto — inherited (parent sets `leading-none`)
99
+ // IvyPresto's Bodoni-lineage SemiBold already has the thick verticals
100
+ // that read as a logo; pushing to 700/800 makes the letterforms
101
+ // visually heavier than the brand asset.
102
+ "ms-[0.18em] font-semibold tracking-[-0.03em]",
103
+ // mutedSuffix: dark mode recedes to muted; light mode keeps brand.
104
+ mutedSuffix && "dark:!text-[var(--muted-foreground)]",
105
+ )}
106
+ style={{
107
+ // Ivy Presto Text from Adobe Fonts. Upright (NOT italic) — matches
108
+ // the official Exxat wordmark. Fallback chain ends in `serif` so
109
+ // FOUT still renders a serif that reads as a logo rather than Inter.
110
+ fontFamily: "var(--font-heading), 'ivypresto-text', Georgia, serif",
111
+ color: suffixColor,
112
+ }}
113
+ >
114
+ {suffix}
115
+ </span>
116
+ </span>
117
+ )
118
+ }
119
+
120
+ /* ── Circular mark ─────────────────────────────────────────────────────────── */
121
+
122
+ export interface ProductMarkProps {
123
+ config: ProductBrandConfig
124
+ className?: string
125
+ cutoutColor?: string
126
+ }
127
+
128
+ /**
129
+ * Generate a stable id suffix for SVG gradient defs so multiple marks on the
130
+ * same page never collide. Strip colons because IDs in HTML/SVG can't legally
131
+ * include them (Radix uses `:`-style IDs by default).
132
+ */
133
+ function useMarkGradientId(brandId: string) {
134
+ const raw = React.useId().replace(/:/g, "")
135
+ return `pmk-${brandId.replace(/[^a-z0-9-]/gi, "")}-${raw}`
136
+ }
137
+
138
+ /**
139
+ * Defer SVG `<defs>` (gradient refs) until after mount so server HTML matches
140
+ * the first client paint. `useId()` returns different suffixes in SSR vs CSR
141
+ * trees that conditionally mount the sidebar.
142
+ */
143
+ function useBrowserPaintReady() {
144
+ const [ready, setReady] = React.useState(false)
145
+ React.useLayoutEffect(() => {
146
+ setReady(true)
147
+ }, [])
148
+ return ready
149
+ }
150
+
151
+ /**
152
+ * Recoloured Exxat "E" mark. Same geometry as the canonical brand mark, so
153
+ * existing pixel-aligned layouts (sidebar header, dropdown rows) don't shift.
154
+ *
155
+ * Fills:
156
+ * - Outer circle: `markGradient` if provided, else flat `brandColor`.
157
+ * - Inner shadow plate: `markShadow` (defaults to `brandColor`).
158
+ * - Cut-out "E" strokes: white by default; callers can override on tinted rails.
159
+ */
160
+ export function ProductMark({ config, className, cutoutColor = "white" }: ProductMarkProps) {
161
+ const ready = useBrowserPaintReady()
162
+ const gradId = useMarkGradientId(config.id)
163
+ const [from, to] = config.markGradient ?? [config.brandColor, config.brandColor]
164
+ const shadow = config.markShadow ?? config.brandColor
165
+
166
+ // No size default. Callers MUST set explicit dimensions (`size-7`, `h-full
167
+ // w-auto`, etc.). A `size-*` default here loses to a downstream `h-full /
168
+ // w-auto` only when `tailwind-merge` correctly identifies `size-7` as a
169
+ // `w-7 + h-7` shorthand — fragile across versions and causes the mark to
170
+ // render at the default size instead of the parent's height (see
171
+ // `ExxatProductLogo` h-full mark → 32 px in h-8 parent). Aspect-square stays
172
+ // so the mark renders as a circle when only one of width/height is set.
173
+ const sharedClass = cn(
174
+ "box-border block aspect-square shrink-0 flex-none object-contain",
175
+ className,
176
+ )
177
+
178
+ if (!ready) {
179
+ return (
180
+ <svg
181
+ viewBox="0 8.25 147 147"
182
+ preserveAspectRatio="xMidYMid meet"
183
+ fill="none"
184
+ xmlns="http://www.w3.org/2000/svg"
185
+ data-product-mark
186
+ data-product-logo-mark
187
+ data-product-id={config.id}
188
+ className={sharedClass}
189
+ aria-hidden="true"
190
+ suppressHydrationWarning
191
+ />
192
+ )
193
+ }
194
+
195
+ return (
196
+ <svg
197
+ viewBox="0 8.25 147 147"
198
+ preserveAspectRatio="xMidYMid meet"
199
+ fill="none"
200
+ xmlns="http://www.w3.org/2000/svg"
201
+ data-product-mark
202
+ data-product-logo-mark
203
+ data-product-id={config.id}
204
+ className={sharedClass}
205
+ aria-hidden="true"
206
+ suppressHydrationWarning
207
+ >
208
+ <path
209
+ 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"
210
+ fill={`url(#${gradId})`}
211
+ />
212
+ <path
213
+ 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"
214
+ fill={shadow}
215
+ />
216
+ <path d="M102.474 116.355H50.5576L58.6764 102.302H102.474V116.355Z" fill={cutoutColor} />
217
+ <path d="M102.474 60.1303H58.6764L50.5576 46.0771H102.474V60.1303Z" fill={cutoutColor} />
218
+ <path d="M102.474 88.2368H66.7949L70.8483 81.2102L66.7949 74.1836H102.474V88.2368Z" fill={cutoutColor} />
219
+ <path d="M39.2227 74.1835H66.795L58.6762 60.1304H39.2227V74.1835Z" fill={cutoutColor} />
220
+ <path d="M39.2227 102.302H58.6762L66.795 88.2368H39.2227V102.302Z" fill={cutoutColor} />
221
+ <defs>
222
+ <linearGradient
223
+ id={gradId}
224
+ x1="28.3733"
225
+ y1="134.255"
226
+ x2="117.195"
227
+ y2="30.9074"
228
+ gradientUnits="userSpaceOnUse"
229
+ >
230
+ <stop offset="0" stopColor={from} />
231
+ <stop offset="1" stopColor={to} />
232
+ </linearGradient>
233
+ </defs>
234
+ </svg>
235
+ )
236
+ }
237
+
238
+ /* ── Mark + wordmark combo ─────────────────────────────────────────────────── */
239
+
240
+ export interface ProductLogoProps {
241
+ config: ProductBrandConfig
242
+ variant?: ProductWordmarkVariant
243
+ /** Render only the mark (omit the wordmark). */
244
+ markOnly?: boolean
245
+ /** Render only the wordmark (omit the mark). */
246
+ wordmarkOnly?: boolean
247
+ className?: string
248
+ /** Class applied to the inner mark — useful for sizing it independently. */
249
+ markClassName?: string
250
+ /** Class applied to the inner wordmark. */
251
+ wordmarkClassName?: string
252
+ }
253
+
254
+ /**
255
+ * Mark + wordmark composed inline. Pass `markOnly` for collapsed sidebar /
256
+ * favicon-like contexts, or `wordmarkOnly` if you've already rendered the
257
+ * mark separately (e.g. switcher dropdown rows).
258
+ */
259
+ export function ProductLogo({
260
+ config,
261
+ variant = "default",
262
+ markOnly = false,
263
+ wordmarkOnly = false,
264
+ className,
265
+ markClassName,
266
+ wordmarkClassName,
267
+ }: ProductLogoProps) {
268
+ if (markOnly) {
269
+ return <ProductMark config={config} className={cn(className, markClassName)} />
270
+ }
271
+ if (wordmarkOnly) {
272
+ return <ProductWordmark config={config} variant={variant} className={cn(className, wordmarkClassName)} />
273
+ }
274
+ return (
275
+ <span
276
+ aria-hidden="true"
277
+ data-product-logo
278
+ data-product-id={config.id}
279
+ className={cn("inline-flex items-center gap-2", className)}
280
+ >
281
+ <ProductMark config={config} className={cn("size-7", markClassName)} />
282
+ <ProductWordmark config={config} variant={variant} className={wordmarkClassName} />
283
+ </span>
284
+ )
285
+ }