@exxatdesignux/ui 0.3.0 → 0.4.1

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 (214) hide show
  1. package/CHANGELOG.md +701 -6
  2. package/README.md +138 -0
  3. package/bin/init.mjs +134 -31
  4. package/consumer-extras/cursor-rules/exxat-board-cards.mdc +1 -1
  5. package/consumer-extras/cursor-rules/exxat-centralized-list-dataset.mdc +2 -2
  6. package/consumer-extras/cursor-rules/exxat-collaboration-access.mdc +1 -1
  7. package/consumer-extras/cursor-rules/exxat-data-tables.mdc +2 -0
  8. package/consumer-extras/cursor-rules/exxat-dedicated-search-surfaces.mdc +1 -1
  9. package/consumer-extras/cursor-rules/exxat-ds-agents.mdc +3 -3
  10. package/consumer-extras/cursor-rules/exxat-library-hub-header.mdc +28 -0
  11. package/consumer-extras/cursor-rules/exxat-mono-ids.mdc +1 -1
  12. package/consumer-extras/cursor-rules/exxat-person-identity-display.mdc +1 -1
  13. package/consumer-extras/cursor-rules/exxat-primary-nav-secondary-panel.mdc +6 -6
  14. package/consumer-extras/cursor-rules/exxat-reuse-before-custom.mdc +1 -1
  15. package/consumer-extras/cursor-skills/exxat-board-cards/SKILL.md +2 -2
  16. package/consumer-extras/cursor-skills/exxat-centralized-list-dataset/SKILL.md +1 -1
  17. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -3
  18. package/consumer-extras/cursor-skills/exxat-dedicated-search-surfaces/SKILL.md +2 -2
  19. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +7 -7
  20. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +1 -1
  21. package/consumer-extras/cursor-skills/exxat-list-page-view-shells/SKILL.md +1 -1
  22. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +4 -4
  23. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +8 -8
  24. package/consumer-extras/cursor-skills/exxat-token-economy/SKILL.md +277 -0
  25. package/consumer-extras/handbook/HANDBOOK.md +2 -0
  26. package/consumer-extras/handbook/glossary.md +2 -1
  27. package/consumer-extras/handbook/reference-implementations.md +31 -4
  28. package/consumer-extras/patterns/collaboration-access-pattern.md +7 -7
  29. package/consumer-extras/patterns/data-views-pattern.md +18 -16
  30. package/consumer-extras/patterns/kpi-flat-band-pattern.md +2 -2
  31. package/dist/components/data-table/index.js +2 -2
  32. package/dist/components/data-table/index.js.map +1 -1
  33. package/dist/components/data-table/pagination.js +3 -3
  34. package/dist/components/data-table/pagination.js.map +1 -1
  35. package/dist/components/data-table/use-table-state.d.ts +1 -1
  36. package/dist/components/data-table/use-table-state.js.map +1 -1
  37. package/dist/components/data-views/data-row-list.js.map +1 -1
  38. package/dist/components/data-views/finder-panel-view.d.ts +1 -1
  39. package/dist/components/data-views/finder-panel-view.js.map +1 -1
  40. package/dist/components/data-views/hub-table.d.ts +9 -3
  41. package/dist/components/data-views/hub-table.js +262 -40
  42. package/dist/components/data-views/hub-table.js.map +1 -1
  43. package/dist/components/data-views/index.js +262 -40
  44. package/dist/components/data-views/index.js.map +1 -1
  45. package/dist/components/data-views/list-page-split-hub-tokens.d.ts +2 -2
  46. package/dist/components/data-views/list-page-split-hub-tokens.js.map +1 -1
  47. package/dist/components/data-views/list-page-tree-column-header.d.ts +1 -1
  48. package/dist/components/data-views/list-page-tree-column-header.js.map +1 -1
  49. package/dist/components/data-views/list-page-tree-panel-shell.js.map +1 -1
  50. package/dist/components/data-views/os-folder-glyph.d.ts +1 -1
  51. package/dist/components/data-views/os-folder-glyph.js.map +1 -1
  52. package/dist/components/ui/avatar.d.ts +1 -1
  53. package/dist/components/ui/key-metrics.js.map +1 -1
  54. package/dist/index.js +136 -39
  55. package/dist/index.js.map +1 -1
  56. package/package.json +3 -2
  57. package/src/components/data-table/index.tsx +2 -2
  58. package/src/components/data-table/pagination.tsx +5 -1
  59. package/src/components/data-table/use-table-state.ts +1 -1
  60. package/src/components/data-views/data-row-list.tsx +1 -1
  61. package/src/components/data-views/finder-panel-view.tsx +2 -2
  62. package/src/components/data-views/hub-table.tsx +149 -41
  63. package/src/components/data-views/list-page-split-hub-tokens.ts +2 -2
  64. package/src/components/data-views/list-page-tree-column-header.tsx +1 -1
  65. package/src/components/data-views/os-folder-glyph.tsx +1 -1
  66. package/src/components/ui/key-metrics.tsx +1 -1
  67. package/template/.claude/skills/exxat-ds-skill/SKILL.md +8 -7
  68. package/template/.cursor/rules/exxat-accessibility.mdc +1 -1
  69. package/template/.cursor/rules/exxat-command-menu.mdc +1 -1
  70. package/template/.cursor/rules/exxat-dashboard-view-charts.mdc +6 -6
  71. package/template/.cursor/rules/exxat-data-tables.mdc +3 -3
  72. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +5 -5
  73. package/template/.cursor/rules/exxat-mono-ids.mdc +1 -1
  74. package/template/.cursor/rules/exxat-page-vs-drawer.mdc +1 -1
  75. package/template/.cursor/rules/exxat-table-properties-drawer.mdc +1 -1
  76. package/template/AGENTS.md +43 -37
  77. package/template/app/(app)/columns/page.tsx +11 -0
  78. package/template/app/(app)/library/all/page.tsx +11 -0
  79. package/template/app/(app)/library/find/page.tsx +12 -0
  80. package/template/app/(app)/{question-bank → library}/layout.tsx +16 -16
  81. package/template/app/(app)/library/list/page.tsx +12 -0
  82. package/template/app/(app)/{question-bank → library}/new/page.tsx +10 -10
  83. package/template/app/(app)/library/page.tsx +11 -0
  84. package/template/app/(app)/tokens-themes/page.tsx +11 -0
  85. package/template/components/ask-leo-composer.tsx +2 -2
  86. package/template/components/columns-client.tsx +158 -0
  87. package/template/components/columns-showcase.tsx +541 -0
  88. package/template/components/data-views/index.ts +32 -6
  89. package/template/components/data-views/{question-bank-folder-tree-branch.tsx → library-folder-tree-branch.tsx} +19 -19
  90. package/template/components/data-views/table-cells.tsx +673 -0
  91. package/template/components/folder-details-shell.tsx +11 -11
  92. package/template/components/hub-tree-panel-view.tsx +24 -24
  93. package/template/components/{question-bank-board-view.tsx → library-board-view.tsx} +44 -44
  94. package/template/components/{question-bank-client.tsx → library-client.tsx} +82 -82
  95. package/template/components/{question-bank-dashboard-charts.tsx → library-dashboard-charts.tsx} +14 -14
  96. package/template/components/{question-bank-favorite-button.tsx → library-favorite-button.tsx} +7 -7
  97. package/template/components/{question-bank-hub-client.tsx → library-hub-client.tsx} +43 -43
  98. package/template/components/{question-bank-new-folder-sheet.tsx → library-new-folder-sheet.tsx} +14 -14
  99. package/template/components/{question-bank-os-folder-view.tsx → library-os-folder-view.tsx} +31 -31
  100. package/template/components/{question-bank-page-header.tsx → library-page-header.tsx} +6 -6
  101. package/template/components/library-panel-activator.tsx +8 -0
  102. package/template/components/{question-bank-secondary-nav.tsx → library-secondary-nav.tsx} +60 -60
  103. package/template/components/{question-bank-table.tsx → library-table.tsx} +97 -97
  104. package/template/components/list-hub-status-badge.tsx +2 -2
  105. package/template/components/{new-question-composer.tsx → new-library-item-form.tsx} +37 -37
  106. package/template/components/sidebar/app-sidebar.tsx +61 -5
  107. package/template/components/sidebar/secondary-panel.tsx +109 -56
  108. package/template/components/sidebar/sidebar-auto-collapse.tsx +2 -2
  109. package/template/components/sidebar/sidebar-auto-open.tsx +2 -1
  110. package/template/components/table-properties/types.ts +1 -1
  111. package/template/components/templates/discovery-hub-template.tsx +1 -1
  112. package/template/components/templates/new-focus-template.tsx +2 -2
  113. package/template/components/templates/secondary-panel-hub-template.tsx +1 -1
  114. package/template/components/tokens-secondary-nav.tsx +192 -0
  115. package/template/components/tokens-themes-client.tsx +476 -0
  116. package/template/components/tokens-themes-section.tsx +386 -0
  117. package/template/docs/HANDBOOK.md +187 -0
  118. package/template/docs/blueprints/README.md +1 -1
  119. package/template/docs/blueprints/board-card.md +1 -1
  120. package/template/docs/blueprints/data-table.md +2 -2
  121. package/template/docs/blueprints/list-page-template.md +3 -3
  122. package/template/docs/blueprints/page-header.md +4 -4
  123. package/template/docs/collaboration-access-pattern.md +7 -7
  124. package/template/docs/component-selection-guide.md +1 -1
  125. package/template/docs/data-views-pattern.md +18 -16
  126. package/template/docs/glossary.md +58 -0
  127. package/template/docs/kpi-flat-band-pattern.md +3 -3
  128. package/template/docs/kpi-trend-pattern.md +18 -3
  129. package/template/docs/large-dataset-strategy.md +155 -0
  130. package/template/docs/library-hub-header-pattern.md +25 -0
  131. package/template/docs/migrations/_template.md +1 -1
  132. package/template/docs/reference-implementations.md +151 -0
  133. package/template/docs/token-taxonomy.md +1 -1
  134. package/template/docs/voice-and-tone.md +262 -0
  135. package/template/eslint.config.mjs +9 -39
  136. package/template/hooks/use-secondary-panel-hub-nav.ts +10 -10
  137. package/template/lib/ask-leo-route-context.ts +6 -18
  138. package/template/lib/coach-mark-registry.ts +0 -16
  139. package/template/lib/command-menu-config.ts +5 -12
  140. package/template/lib/command-menu-search-data.ts +8 -39
  141. package/template/lib/{question-bank-authoring.ts → library-authoring.ts} +89 -88
  142. package/template/lib/library-dedicated-search.ts +19 -0
  143. package/template/lib/library-hub-search.ts +90 -0
  144. package/template/lib/library-nav.ts +477 -0
  145. package/template/lib/library-recent-searches.ts +22 -0
  146. package/template/lib/{placements-supported-views.ts → library-supported-views.ts} +2 -2
  147. package/template/lib/list-status-badges.ts +16 -104
  148. package/template/lib/mock/dashboard.ts +1 -1
  149. package/template/lib/mock/{question-bank-folders.ts → library-folders.ts} +30 -30
  150. package/template/lib/mock/library-header-collaborators.ts +54 -0
  151. package/template/lib/mock/{question-bank-inspector.ts → library-inspector.ts} +29 -29
  152. package/template/lib/mock/{question-bank-kpi.ts → library-kpi.ts} +20 -20
  153. package/template/lib/mock/library.ts +249 -0
  154. package/template/lib/mock/navigation.tsx +32 -26
  155. package/template/lib/table-state-lifecycle.ts +1 -1
  156. package/template/next.config.mjs +7 -4
  157. package/template/package.json +0 -1
  158. package/tokens/hooks-index.json +2874 -0
  159. package/consumer-extras/cursor-rules/exxat-question-bank-hub-header.mdc +0 -28
  160. package/template/app/(app)/examples/page.tsx +0 -41
  161. package/template/app/(app)/question-bank/find/page.tsx +0 -12
  162. package/template/app/(app)/question-bank/library/page.tsx +0 -11
  163. package/template/app/(app)/question-bank/list/page.tsx +0 -12
  164. package/template/app/(app)/question-bank/page.tsx +0 -11
  165. package/template/components/compliance-board-view.tsx +0 -142
  166. package/template/components/compliance-client.tsx +0 -92
  167. package/template/components/compliance-page-header.tsx +0 -89
  168. package/template/components/compliance-table.tsx +0 -468
  169. package/template/components/data-view-dashboard-charts-compliance.tsx +0 -963
  170. package/template/components/data-view-dashboard-charts-team.tsx +0 -971
  171. package/template/components/data-view-dashboard-charts.tsx +0 -1503
  172. package/template/components/new-placement-back-btn.tsx +0 -28
  173. package/template/components/new-placement-form.tsx +0 -942
  174. package/template/components/placement-board-card.tsx +0 -250
  175. package/template/components/placement-detail.tsx +0 -438
  176. package/template/components/placements-board-view.tsx +0 -397
  177. package/template/components/placements-client.tsx +0 -220
  178. package/template/components/placements-list-view.tsx +0 -124
  179. package/template/components/placements-page-header.tsx +0 -166
  180. package/template/components/placements-table-cells.test.tsx +0 -22
  181. package/template/components/placements-table-cells.tsx +0 -173
  182. package/template/components/placements-table-columns.tsx +0 -210
  183. package/template/components/placements-table.tsx +0 -934
  184. package/template/components/question-bank-panel-activator.tsx +0 -8
  185. package/template/components/rotations-empty-state.tsx +0 -50
  186. package/template/components/rotations-panel-activator.tsx +0 -8
  187. package/template/components/sites-board-view.tsx +0 -67
  188. package/template/components/sites-client.tsx +0 -154
  189. package/template/components/sites-table.tsx +0 -249
  190. package/template/components/team-board-view.tsx +0 -122
  191. package/template/components/team-client.tsx +0 -100
  192. package/template/components/team-page-header.tsx +0 -92
  193. package/template/components/team-table.tsx +0 -553
  194. package/template/docs/question-bank-hub-header-pattern.md +0 -25
  195. package/template/lib/compliance-supported-views.ts +0 -10
  196. package/template/lib/data-view-dashboard-placements-layout.ts +0 -215
  197. package/template/lib/mock/compliance-kpi.ts +0 -61
  198. package/template/lib/mock/compliance.ts +0 -146
  199. package/template/lib/mock/placements-kpi.ts +0 -134
  200. package/template/lib/mock/placements.ts +0 -176
  201. package/template/lib/mock/question-bank-header-collaborators.ts +0 -54
  202. package/template/lib/mock/question-bank.ts +0 -249
  203. package/template/lib/mock/sites-directory.ts +0 -16
  204. package/template/lib/mock/sites-kpi.ts +0 -25
  205. package/template/lib/mock/team-kpi.ts +0 -60
  206. package/template/lib/mock/team.ts +0 -118
  207. package/template/lib/placement-board-card-layout.ts +0 -79
  208. package/template/lib/question-bank-dedicated-search.ts +0 -19
  209. package/template/lib/question-bank-hub-search.ts +0 -90
  210. package/template/lib/question-bank-nav.ts +0 -477
  211. package/template/lib/question-bank-recent-searches.ts +0 -22
  212. package/template/lib/question-bank-supported-views.ts +0 -12
  213. package/template/lib/sites-supported-views.ts +0 -10
  214. package/template/lib/team-supported-views.ts +0 -10
@@ -0,0 +1,192 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Tokens secondary navigation — the body of the `tokens` panel.
5
+ *
6
+ * Same shape as `LibrarySecondaryNav` but simpler — flat list of
7
+ * categories. URL scope: `?category=color|gradient|radius|…` (default = `all`).
8
+ *
9
+ * Compact mode (icon rail) and expanded mode share the same active-state
10
+ * logic. Clicking the active row while already on the same href reopens the
11
+ * panel (in case it was collapsed) — matching the Library "All
12
+ * questions" reopen behavior.
13
+ */
14
+
15
+ import * as React from "react"
16
+ import Link from "next/link"
17
+ import { usePathname, useSearchParams } from "next/navigation"
18
+
19
+ import { Tip } from "@/components/ui/tip"
20
+ import { cn } from "@/lib/utils"
21
+ import { useSecondaryPanel } from "@/components/sidebar"
22
+ import {
23
+ CATEGORY_TABS,
24
+ CATEGORY_COUNTS,
25
+ TOKENS_INDEX,
26
+ type TokenCategory,
27
+ } from "@/components/tokens-themes-section"
28
+
29
+ /**
30
+ * URL value for "show everything". Centralized so consumers (panel + client +
31
+ * page header subtitle) all agree on the canonical default.
32
+ */
33
+ export const TOKENS_ALL_CATEGORY = "all" as const
34
+
35
+ export type TokensCategoryParam = "all" | TokenCategory
36
+
37
+ /** Read the active category from a `URLSearchParams`. Falls back to `"all"`. */
38
+ export function readTokensCategory(params: URLSearchParams | null): TokensCategoryParam {
39
+ const raw = (params?.get("category") ?? "").toLowerCase()
40
+ if (raw === TOKENS_ALL_CATEGORY) return TOKENS_ALL_CATEGORY
41
+ const match = CATEGORY_TABS.find((c) => c.id === raw)
42
+ return match ? (match.id as TokenCategory) : TOKENS_ALL_CATEGORY
43
+ }
44
+
45
+ interface CategoryEntry {
46
+ id: TokensCategoryParam
47
+ label: string
48
+ icon: string
49
+ count: number
50
+ }
51
+
52
+ const CATEGORY_ENTRIES: CategoryEntry[] = [
53
+ {
54
+ id: TOKENS_ALL_CATEGORY,
55
+ label: "All tokens",
56
+ icon: "fa-grid-2",
57
+ count: TOKENS_INDEX.tokenCount,
58
+ },
59
+ ...CATEGORY_TABS.filter((c) => CATEGORY_COUNTS[c.id] > 0).map((c) => ({
60
+ id: c.id as TokensCategoryParam,
61
+ label: c.label,
62
+ icon: c.icon,
63
+ count: CATEGORY_COUNTS[c.id],
64
+ })),
65
+ ]
66
+
67
+ /** Build `?category=…` URL preserving the current pathname (tokens hub). */
68
+ function tokensCategoryHref(pathname: string, id: TokensCategoryParam): string {
69
+ if (id === TOKENS_ALL_CATEGORY) return pathname
70
+ return `${pathname}?category=${encodeURIComponent(id)}`
71
+ }
72
+
73
+ function CategoryRow({
74
+ entry,
75
+ active,
76
+ onActiveClick,
77
+ }: {
78
+ entry: CategoryEntry
79
+ active: boolean
80
+ onActiveClick: () => void
81
+ }) {
82
+ const pathname = usePathname()
83
+ const href = tokensCategoryHref(pathname, entry.id)
84
+ return (
85
+ <li className="min-w-0">
86
+ <Link
87
+ href={href}
88
+ scroll={false}
89
+ onClick={() => { if (active) onActiveClick() }}
90
+ aria-current={active ? "page" : undefined}
91
+ className={cn(
92
+ "flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
93
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
94
+ active
95
+ ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
96
+ : "text-sidebar-foreground hover:bg-sidebar-accent/50",
97
+ )}
98
+ >
99
+ <span className="size-4 shrink-0 text-center text-[13px] leading-none" aria-hidden="true">
100
+ <i className={cn(active ? "fa-solid" : "fa-light", entry.icon)} aria-hidden="true" />
101
+ </span>
102
+ <span className="min-w-0 flex-1 truncate">{entry.label}</span>
103
+ <span
104
+ className={cn(
105
+ "shrink-0 rounded-sm px-1.5 py-0.5 text-[10px] font-medium tabular-nums",
106
+ active ? "bg-foreground/10 text-sidebar-accent-foreground" : "bg-muted/60 text-muted-foreground",
107
+ )}
108
+ >
109
+ {entry.count}
110
+ </span>
111
+ </Link>
112
+ </li>
113
+ )
114
+ }
115
+
116
+ function IconCategoryRow({
117
+ entry,
118
+ active,
119
+ onActiveClick,
120
+ }: {
121
+ entry: CategoryEntry
122
+ active: boolean
123
+ onActiveClick: () => void
124
+ }) {
125
+ const pathname = usePathname()
126
+ const href = tokensCategoryHref(pathname, entry.id)
127
+ return (
128
+ <li className="flex w-full justify-center" role="none">
129
+ <Tip label={`${entry.label} (${entry.count})`} side="right">
130
+ <Link
131
+ href={href}
132
+ scroll={false}
133
+ onClick={() => { if (active) onActiveClick() }}
134
+ aria-current={active ? "page" : undefined}
135
+ aria-label={entry.label}
136
+ className={cn(
137
+ "flex size-9 shrink-0 items-center justify-center rounded-md transition-colors",
138
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
139
+ active
140
+ ? "bg-sidebar-accent text-sidebar-accent-foreground"
141
+ : "text-sidebar-foreground hover:bg-sidebar-accent/50",
142
+ )}
143
+ >
144
+ <span className="text-center text-[15px] leading-none" aria-hidden="true">
145
+ <i className={cn(active ? "fa-solid" : "fa-light", entry.icon)} aria-hidden="true" />
146
+ </span>
147
+ </Link>
148
+ </Tip>
149
+ </li>
150
+ )
151
+ }
152
+
153
+ export function TokensSecondaryNav() {
154
+ const searchParams = useSearchParams()
155
+ const searchParamsKey = searchParams.toString()
156
+ const { openPanel, secondaryPanelCompact } = useSecondaryPanel()
157
+
158
+ const active = React.useMemo(
159
+ () => readTokensCategory(new URLSearchParams(searchParamsKey)),
160
+ [searchParamsKey],
161
+ )
162
+
163
+ const onActiveClick = React.useCallback(() => openPanel("tokens"), [openPanel])
164
+
165
+ if (secondaryPanelCompact) {
166
+ return (
167
+ <ul className="flex flex-col gap-1 px-1 py-3" role="list">
168
+ {CATEGORY_ENTRIES.map((entry) => (
169
+ <IconCategoryRow
170
+ key={entry.id}
171
+ entry={entry}
172
+ active={entry.id === active}
173
+ onActiveClick={onActiveClick}
174
+ />
175
+ ))}
176
+ </ul>
177
+ )
178
+ }
179
+
180
+ return (
181
+ <ul className="flex flex-col gap-0.5 px-3 pb-4" role="list">
182
+ {CATEGORY_ENTRIES.map((entry) => (
183
+ <CategoryRow
184
+ key={entry.id}
185
+ entry={entry}
186
+ active={entry.id === active}
187
+ onActiveClick={onActiveClick}
188
+ />
189
+ ))}
190
+ </ul>
191
+ )
192
+ }
@@ -0,0 +1,476 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Tokens & themes — hub client.
5
+ *
6
+ * Composition mirrors `Library → Library` and matches every other
7
+ * primary hub (Placements / Team / Sites / Compliance):
8
+ * - **Secondary panel** (`tokens`) — category scope lives in the rail
9
+ * (Colors, Radius, Motion, …) via `TokensSecondaryNav`. Opening the panel
10
+ * also collapses the main sidebar via `secondary-panel.tsx#openPanel`.
11
+ * - `PrimaryPageTemplate` + `ListPageTemplate` — same hub frame as
12
+ * Placements / Library.
13
+ * - **`HubTable`** (NOT raw `<DataTable>`) — the canonical primitive that
14
+ * wires `useTableState`, the toolbar (search + filter chips + filter
15
+ * dropdown + sort), `TablePropertiesDrawerButton`, view-type tiles,
16
+ * bulk-actions, and conditional rules. Hubs that drop down to raw
17
+ * `<DataTable>` silently lose filters and Properties; do not do that.
18
+ * - One view tab (`viewType: "table"`) — category scope is the panel's
19
+ * job, not the view tabs'.
20
+ *
21
+ * Token index (`packages/ui/tokens/hooks-index.json`) is the single source of
22
+ * truth; visualizers live in `tokens-themes-section.tsx`.
23
+ */
24
+
25
+ import * as React from "react"
26
+ import { useRouter, useSearchParams } from "next/navigation"
27
+
28
+ import { PrimaryPageTemplate } from "@/components/templates/primary-page-template"
29
+ import { PageHeader } from "@/components/page-header"
30
+ import {
31
+ KeyMetrics,
32
+ type MetricInsight,
33
+ type MetricItem,
34
+ } from "@/components/key-metrics"
35
+ import {
36
+ HubTable,
37
+ ListPageTemplate,
38
+ type ViewTab,
39
+ } from "@/components/data-views"
40
+ import type { ColumnDef } from "@/components/data-table/types"
41
+ import type { DataListViewType } from "@/lib/data-list-view"
42
+ import { Button } from "@/components/ui/button"
43
+ import { Tip } from "@/components/ui/tip"
44
+ import { Badge } from "@/components/ui/badge"
45
+ import { useAutoPanel } from "@/components/sidebar"
46
+ import {
47
+ CATEGORY_COUNTS,
48
+ CATEGORY_TABS,
49
+ DEPRECATED_COUNT,
50
+ TOKENS_INDEX,
51
+ categoryPreview,
52
+ primaryValueText,
53
+ type TokenCategory,
54
+ type TokenRecord,
55
+ } from "@/components/tokens-themes-section"
56
+ import {
57
+ readTokensCategory,
58
+ TOKENS_ALL_CATEGORY,
59
+ type TokensCategoryParam,
60
+ } from "@/components/tokens-secondary-nav"
61
+
62
+ /** Row shape consumed by `DataTable` — flat fields make built-in search work out of the box. */
63
+ interface TokenRow extends Record<string, unknown> {
64
+ id: string // == name, unique
65
+ name: string // var(--…)
66
+ namespace: string
67
+ category: TokenCategory | string
68
+ value: string
69
+ deprecated: boolean
70
+ /** The original token index entry — used by the Preview cell renderer. */
71
+ record: TokenRecord
72
+ }
73
+
74
+ /** Build all token rows once at module load (token index is static at runtime). */
75
+ const TOKEN_ROWS: TokenRow[] = (() => {
76
+ const out: TokenRow[] = []
77
+ for (const [name, record] of Object.entries(TOKENS_INDEX.tokens)) {
78
+ out.push({
79
+ id: name,
80
+ name,
81
+ namespace: record.namespace,
82
+ category: record.category,
83
+ value: primaryValueText(record),
84
+ deprecated: Boolean(record.deprecated),
85
+ record,
86
+ })
87
+ }
88
+ return out.sort((a, b) => a.name.localeCompare(b.name))
89
+ })()
90
+
91
+ /** Pre-bucket rows by category so each panel selection slices in O(1). */
92
+ const ROWS_BY_CATEGORY = (() => {
93
+ const map = new Map<TokensCategoryParam, TokenRow[]>()
94
+ map.set(TOKENS_ALL_CATEGORY, TOKEN_ROWS)
95
+ for (const tab of CATEGORY_TABS) {
96
+ map.set(
97
+ tab.id as TokensCategoryParam,
98
+ TOKEN_ROWS.filter((r) => tab.matches(String(r.category))),
99
+ )
100
+ }
101
+ return map
102
+ })()
103
+
104
+ /** Namespace select-filter options — built once from the index. */
105
+ const NAMESPACE_OPTIONS = TOKENS_INDEX.namespaces
106
+ .slice()
107
+ .sort()
108
+ .map((ns) => ({ value: ns, label: ns }))
109
+
110
+ const STATUS_OPTIONS = [
111
+ { value: "active", label: "Active" },
112
+ { value: "deprecated", label: "Deprecated" },
113
+ ]
114
+
115
+ const TOKENS_HEADER_SUBTITLE = `${TOKENS_INDEX.tokenCount} tokens · ${TOKENS_INDEX.namespaces.length} namespaces · v${TOKENS_INDEX.version}`
116
+
117
+ const TOKENS_VIEW_TABS: ViewTab[] = [
118
+ {
119
+ id: "tokens-table",
120
+ label: "Tokens",
121
+ viewType: "table",
122
+ icon: "fa-table",
123
+ filterId: "all",
124
+ },
125
+ ]
126
+
127
+ /** Tokens hub only supports the table view — Properties drawer hides everything else. */
128
+ const TOKENS_SUPPORTED_VIEWS: readonly DataListViewType[] = ["table"] as const
129
+
130
+ /**
131
+ * Canonical KPI shape (matches `placement-kpi.ts` precedent):
132
+ * - every `MetricItem` is clickable — tiles drive the secondary-panel
133
+ * category by pushing `?category=…`, the same URL the panel rail uses,
134
+ * - a `MetricInsight` summarizes the current scope on the right.
135
+ * See `apps/web/docs/kpi-flat-band-pattern.md` + `exxat-kpi-trends.mdc`.
136
+ */
137
+ function buildMetrics(
138
+ navigate: (category: TokensCategoryParam) => void,
139
+ ): MetricItem[] {
140
+ return [
141
+ {
142
+ id: "total",
143
+ label: "Total tokens",
144
+ value: TOKENS_INDEX.tokenCount,
145
+ delta: "",
146
+ trend: "neutral",
147
+ trendPolarity: "informational",
148
+ metricVariant: "hero",
149
+ description: `${TOKENS_INDEX.namespaces.length} namespaces`,
150
+ onClick: () => navigate(TOKENS_ALL_CATEGORY),
151
+ },
152
+ {
153
+ id: "color",
154
+ label: "Color tokens",
155
+ value: CATEGORY_COUNTS.color ?? 0,
156
+ delta: "",
157
+ trend: "neutral",
158
+ trendPolarity: "informational",
159
+ description: "semantic + alias",
160
+ onClick: () => navigate("color" as TokensCategoryParam),
161
+ },
162
+ {
163
+ id: "motion",
164
+ label: "Motion tokens",
165
+ value: CATEGORY_COUNTS.transition ?? 0,
166
+ delta: "",
167
+ trend: "neutral",
168
+ trendPolarity: "informational",
169
+ description: "easings + durations",
170
+ onClick: () => navigate("motion" as TokensCategoryParam),
171
+ },
172
+ {
173
+ id: "deprecated",
174
+ label: "Deprecated",
175
+ value: DEPRECATED_COUNT,
176
+ delta: "",
177
+ trend: "neutral",
178
+ trendPolarity: "lower_is_better",
179
+ description:
180
+ DEPRECATED_COUNT > 0
181
+ ? "scheduled for removal"
182
+ : "none scheduled for removal",
183
+ onClick: () => navigate("deprecated" as TokensCategoryParam),
184
+ },
185
+ ]
186
+ }
187
+
188
+ function buildInsight(activeCategory: TokensCategoryParam, rowCount: number): MetricInsight {
189
+ const label = categoryDisplayLabel(activeCategory)
190
+ return {
191
+ title:
192
+ activeCategory === TOKENS_ALL_CATEGORY
193
+ ? "Token index"
194
+ : `${label} in scope`,
195
+ description:
196
+ activeCategory === TOKENS_ALL_CATEGORY
197
+ ? `${rowCount.toLocaleString()} tokens across ${TOKENS_INDEX.namespaces.length} namespaces. Click any KPI above to scope by category, or use the rail on the left for finer slicing.`
198
+ : `${rowCount.toLocaleString()} ${label.toLowerCase()}. Filter by namespace or status from the table toolbar, or jump back to the full index from the rail.`,
199
+ severity: "info",
200
+ actionLabel: "Ask Leo",
201
+ }
202
+ }
203
+
204
+ /** Friendly display label for the category currently scoped from the panel. */
205
+ function categoryDisplayLabel(category: TokensCategoryParam): string {
206
+ if (category === TOKENS_ALL_CATEGORY) return "All tokens"
207
+ return CATEGORY_TABS.find((c) => c.id === category)?.label ?? "Tokens"
208
+ }
209
+
210
+ /* ── Cell renderers ───────────────────────────────────────────────────── */
211
+
212
+ function useClipboard() {
213
+ const [copied, setCopied] = React.useState<string | null>(null)
214
+ const copy = React.useCallback((text: string) => {
215
+ if (typeof navigator === "undefined" || !navigator.clipboard) return
216
+ navigator.clipboard.writeText(text).then(() => {
217
+ setCopied(text)
218
+ window.setTimeout(() => setCopied((c) => (c === text ? null : c)), 1200)
219
+ }).catch(() => {})
220
+ }, [])
221
+ return { copied, copy }
222
+ }
223
+
224
+ function TokenNameCell({
225
+ row,
226
+ onCopy,
227
+ copiedNow,
228
+ }: {
229
+ row: TokenRow
230
+ onCopy: (text: string) => void
231
+ copiedNow: boolean
232
+ }) {
233
+ const cssRef = `var(${row.name})`
234
+ return (
235
+ <div className="group/token-name flex min-w-0 items-center gap-2">
236
+ <code className="truncate font-mono text-xs text-foreground tabular-nums">{row.name}</code>
237
+ <Tip side="top" label={copiedNow ? `Copied ${cssRef}` : `Copy ${cssRef}`}>
238
+ <Button
239
+ type="button"
240
+ size="icon"
241
+ variant="ghost"
242
+ className="size-6 shrink-0 opacity-0 transition-opacity group-hover/token-name:opacity-100 focus-visible:opacity-100"
243
+ onClick={(e) => {
244
+ e.stopPropagation()
245
+ onCopy(cssRef)
246
+ }}
247
+ aria-label={`Copy ${cssRef}`}
248
+ >
249
+ <i
250
+ className={
251
+ copiedNow
252
+ ? "fa-light fa-check text-xs"
253
+ : "fa-light fa-copy text-xs"
254
+ }
255
+ aria-hidden="true"
256
+ />
257
+ </Button>
258
+ </Tip>
259
+ </div>
260
+ )
261
+ }
262
+
263
+ function StatusCell({ row }: { row: TokenRow }) {
264
+ if (row.deprecated) {
265
+ return (
266
+ <Badge variant="destructive" className="h-5 px-1.5 text-[10px]">
267
+ deprecated
268
+ </Badge>
269
+ )
270
+ }
271
+ return <span className="text-xs text-muted-foreground">—</span>
272
+ }
273
+
274
+ /* ── Public ───────────────────────────────────────────────────────────── */
275
+
276
+ export function TokensThemesClient() {
277
+ // Opens the `tokens` secondary panel on mount and closes it on unmount.
278
+ // `openPanel` also collapses the main sidebar (`persist: false` — does not
279
+ // overwrite the user's saved rail preference). See
280
+ // `apps/web/components/sidebar/secondary-panel.tsx#openPanel`.
281
+ useAutoPanel("tokens")
282
+
283
+ const router = useRouter()
284
+ const searchParams = useSearchParams()
285
+ const searchParamsKey = searchParams.toString()
286
+ const activeCategory = React.useMemo(
287
+ () => readTokensCategory(new URLSearchParams(searchParamsKey)),
288
+ [searchParamsKey],
289
+ )
290
+
291
+ const [tabs, setTabs] = React.useState<ViewTab[]>(TOKENS_VIEW_TABS)
292
+ const [activeTabId, setActiveTabId] = React.useState<string>(TOKENS_VIEW_TABS[0]!.id)
293
+ const [pagination, setPagination] = React.useState(false)
294
+ const { copied, copy } = useClipboard()
295
+
296
+ const navigateToCategory = React.useCallback(
297
+ (category: TokensCategoryParam) => {
298
+ const params = new URLSearchParams(searchParamsKey)
299
+ if (category === TOKENS_ALL_CATEGORY) {
300
+ params.delete("category")
301
+ } else {
302
+ params.set("category", String(category))
303
+ }
304
+ const next = params.toString()
305
+ router.push(next ? `/tokens-themes?${next}` : "/tokens-themes", { scroll: false })
306
+ },
307
+ [router, searchParamsKey],
308
+ )
309
+
310
+ const metrics = React.useMemo(
311
+ () => buildMetrics(navigateToCategory),
312
+ [navigateToCategory],
313
+ )
314
+
315
+ const rows = React.useMemo(
316
+ () => ROWS_BY_CATEGORY.get(activeCategory) ?? TOKEN_ROWS,
317
+ [activeCategory],
318
+ )
319
+
320
+ const insight = React.useMemo(
321
+ () => buildInsight(activeCategory, rows.length),
322
+ [activeCategory, rows.length],
323
+ )
324
+
325
+ const getTabCount = React.useCallback(() => rows.length, [rows.length])
326
+
327
+ const columns: ColumnDef<TokenRow>[] = React.useMemo(() => [
328
+ {
329
+ key: "preview",
330
+ label: "Preview",
331
+ width: 110,
332
+ minWidth: 90,
333
+ cell: (row) => (
334
+ <div className="group flex w-full items-center justify-center">
335
+ {categoryPreview(row.name, row.record)}
336
+ </div>
337
+ ),
338
+ },
339
+ {
340
+ key: "name",
341
+ label: "Token",
342
+ width: 320,
343
+ minWidth: 220,
344
+ defaultPin: "left",
345
+ sortable: true,
346
+ sortKey: "name",
347
+ filter: { type: "text", icon: "fa-font" },
348
+ cell: (row) => (
349
+ <TokenNameCell
350
+ row={row}
351
+ onCopy={copy}
352
+ copiedNow={copied === `var(${row.name})`}
353
+ />
354
+ ),
355
+ },
356
+ {
357
+ key: "namespace",
358
+ label: "Namespace",
359
+ width: 200,
360
+ minWidth: 140,
361
+ sortable: true,
362
+ sortKey: "namespace",
363
+ filter: {
364
+ type: "select",
365
+ icon: "fa-tag",
366
+ options: NAMESPACE_OPTIONS,
367
+ },
368
+ cell: (row) => (
369
+ <span className="rounded-sm bg-muted/60 px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">
370
+ {row.namespace}
371
+ </span>
372
+ ),
373
+ },
374
+ {
375
+ key: "value",
376
+ label: "Value",
377
+ width: 340,
378
+ minWidth: 200,
379
+ sortable: true,
380
+ sortKey: "value",
381
+ filter: { type: "text", icon: "fa-magnifying-glass" },
382
+ cell: (row) => (
383
+ <code
384
+ className="block truncate font-mono text-[11px] text-muted-foreground"
385
+ title={row.value}
386
+ >
387
+ {row.value || "—"}
388
+ </code>
389
+ ),
390
+ },
391
+ {
392
+ key: "status",
393
+ label: "Status",
394
+ width: 120,
395
+ minWidth: 100,
396
+ sortable: true,
397
+ sortKey: "deprecated",
398
+ filter: {
399
+ type: "select",
400
+ icon: "fa-circle-check",
401
+ options: STATUS_OPTIONS,
402
+ },
403
+ cell: (row) => <StatusCell row={row} />,
404
+ },
405
+ ], [copy, copied])
406
+
407
+ const headerTitle =
408
+ activeCategory === TOKENS_ALL_CATEGORY
409
+ ? "Tokens & themes"
410
+ : `${categoryDisplayLabel(activeCategory)} tokens`
411
+
412
+ return (
413
+ <PrimaryPageTemplate
414
+ siteHeader={{
415
+ breadcrumbs: [
416
+ { label: "Dashboard", href: "/dashboard" },
417
+ { label: "Tokens & themes", href: "/tokens-themes" },
418
+ ],
419
+ title: headerTitle,
420
+ }}
421
+ >
422
+ <ListPageTemplate
423
+ defaultTabs={TOKENS_VIEW_TABS}
424
+ tabs={tabs}
425
+ onTabsChange={setTabs}
426
+ activeTabId={activeTabId}
427
+ onActiveTabChange={setActiveTabId}
428
+ supportedViewTypes={["table"]}
429
+ getTabCount={getTabCount}
430
+ header={
431
+ <PageHeader
432
+ title={headerTitle}
433
+ subtitle={TOKENS_HEADER_SUBTITLE}
434
+ />
435
+ }
436
+ metrics={
437
+ <KeyMetrics
438
+ variant="flat"
439
+ metrics={metrics}
440
+ insight={insight}
441
+ showHeader={false}
442
+ metricsSingleRow
443
+ />
444
+ }
445
+ renderContent={(tab, updateTab) => (
446
+ <HubTable<TokenRow>
447
+ rows={rows}
448
+ columns={columns}
449
+ view={tab.viewType}
450
+ onViewChange={(v) =>
451
+ updateTab({ viewType: v })
452
+ }
453
+ supportedViewTypes={TOKENS_SUPPORTED_VIEWS}
454
+ hubLabel="Tokens"
455
+ lifecycleTabLabel="Tokens & themes"
456
+ searchAriaLabel="Search tokens"
457
+ getRowId={(r) => r.id}
458
+ getRowSelectionLabel={(r) => r.name}
459
+ defaultSort={{ key: "name", dir: "asc" }}
460
+ selectable={false}
461
+ pagination={pagination}
462
+ onPaginationChange={setPagination}
463
+ paginationInitialPageSize={25}
464
+ paginationPageSizeOptions={[10, 25, 50, 100]}
465
+ emptyState={
466
+ <p className="text-sm text-muted-foreground">
467
+ No tokens match your filters.
468
+ </p>
469
+ }
470
+ renderers={{}}
471
+ />
472
+ )}
473
+ />
474
+ </PrimaryPageTemplate>
475
+ )
476
+ }