@exxatdesignux/ui 0.5.2 → 0.5.3

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 (82) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/consumer-extras/cursor-rules/exxat-data-tables.mdc +8 -6
  3. package/consumer-extras/cursor-rules/exxat-ds-agents.mdc +2 -1
  4. package/consumer-extras/cursor-rules/exxat-hub-supported-views.mdc +54 -0
  5. package/consumer-extras/cursor-rules/exxat-nav-single-active.mdc +31 -0
  6. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +8 -3
  7. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +15 -5
  8. package/consumer-extras/cursor-skills/exxat-token-economy/SKILL.md +11 -4
  9. package/consumer-extras/handbook/HANDBOOK.md +1 -1
  10. package/consumer-extras/handbook/reference-implementations.md +2 -2
  11. package/consumer-extras/patterns/data-views-pattern.md +6 -0
  12. package/consumer-extras/patterns/hub-supported-views-pattern.md +53 -0
  13. package/dist/components/data-table/index.js +13 -9
  14. package/dist/components/data-table/index.js.map +1 -1
  15. package/dist/components/data-table/pagination.js +13 -9
  16. package/dist/components/data-table/pagination.js.map +1 -1
  17. package/dist/components/data-views/hub-table.d.ts +8 -4
  18. package/dist/components/data-views/hub-table.js +25 -10
  19. package/dist/components/data-views/hub-table.js.map +1 -1
  20. package/dist/components/data-views/index.d.ts +1 -1
  21. package/dist/components/data-views/index.js +25 -10
  22. package/dist/components/data-views/index.js.map +1 -1
  23. package/dist/components/data-views/list-page-connected-view-body.d.ts +1 -1
  24. package/dist/components/data-views/list-page-connected-view-body.js +1 -0
  25. package/dist/components/data-views/list-page-connected-view-body.js.map +1 -1
  26. package/dist/components/table-properties/drawer-button.js +1 -0
  27. package/dist/components/table-properties/drawer-button.js.map +1 -1
  28. package/dist/components/table-properties/drawer.js +1 -0
  29. package/dist/components/table-properties/drawer.js.map +1 -1
  30. package/dist/components/table-properties/index.d.ts +1 -1
  31. package/dist/components/table-properties/index.js +1 -0
  32. package/dist/components/table-properties/index.js.map +1 -1
  33. package/dist/components/templates/index.d.ts +1 -1
  34. package/dist/components/templates/index.js +12 -2
  35. package/dist/components/templates/index.js.map +1 -1
  36. package/dist/components/templates/list-page.d.ts +4 -2
  37. package/dist/components/templates/list-page.js +12 -2
  38. package/dist/components/templates/list-page.js.map +1 -1
  39. package/dist/{data-list-view-registry-CyBoBML4.d.ts → data-list-view-registry-BstmlfQ3.d.ts} +16 -1
  40. package/dist/index.d.ts +2 -1
  41. package/dist/index.js +135 -13
  42. package/dist/index.js.map +1 -1
  43. package/dist/lib/data-list-view-registry.d.ts +1 -1
  44. package/dist/lib/data-list-view-registry.js +17 -1
  45. package/dist/lib/data-list-view-registry.js.map +1 -1
  46. package/dist/lib/data-list-view-surface.d.ts +1 -1
  47. package/dist/lib/data-list-view-surface.js +1 -0
  48. package/dist/lib/data-list-view-surface.js.map +1 -1
  49. package/dist/lib/list-page-table-properties.d.ts +1 -1
  50. package/dist/lib/list-page-table-properties.js +1 -0
  51. package/dist/lib/list-page-table-properties.js.map +1 -1
  52. package/dist/lib/nav-active.d.ts +38 -0
  53. package/dist/lib/nav-active.js +104 -0
  54. package/dist/lib/nav-active.js.map +1 -0
  55. package/package.json +1 -1
  56. package/src/components/data-table/index.tsx +25 -17
  57. package/src/components/data-views/hub-table.tsx +9 -3
  58. package/src/components/templates/list-page.tsx +9 -3
  59. package/src/index.ts +1 -0
  60. package/src/lib/data-list-view-registry.ts +31 -0
  61. package/src/lib/nav-active.ts +162 -0
  62. package/template/.claude/skills/exxat-ds-skill/SKILL.md +2 -1
  63. package/template/AGENTS.md +16 -1
  64. package/template/components/columns-client.tsx +3 -2
  65. package/template/components/columns-showcase.tsx +22 -18
  66. package/template/components/exxat-product-logo.tsx +1 -1
  67. package/template/components/library-table.tsx +62 -23
  68. package/template/components/new-library-item-form.tsx +0 -7
  69. package/template/components/product-wordmark.tsx +1 -1
  70. package/template/components/sidebar/app-sidebar.tsx +14 -106
  71. package/template/components/sidebar/secondary-nav.tsx +22 -4
  72. package/template/components/tokens-hub-auxiliary-views.tsx +301 -0
  73. package/template/components/tokens-themes-client.tsx +44 -16
  74. package/template/docs/HANDBOOK.md +1 -1
  75. package/template/docs/data-views-pattern.md +6 -0
  76. package/template/docs/glossary.md +2 -1
  77. package/template/docs/hub-supported-views-pattern.md +53 -0
  78. package/template/docs/reference-implementations.md +2 -2
  79. package/template/lib/full-hub-supported-views.ts +8 -0
  80. package/template/lib/library-supported-views.ts +5 -12
  81. package/template/package.json +1 -0
  82. package/tokens/hooks-index.json +2 -2
@@ -87,115 +87,23 @@ import {
87
87
  type NavSchool,
88
88
  type NavProgram,
89
89
  } from "@/lib/mock/navigation"
90
- /** Path segment of a nav URL (strip `#fragment` for matching). */
91
- function navUrlPath(url: string): string {
92
- if (!url || url === "#") return ""
93
- const i = url.indexOf("#")
94
- return i === -1 ? url : url.slice(0, i)
95
- }
96
-
97
- /** Hash segment from a nav `href` (no `#`). `null` when the URL has no `#`. */
98
- function navUrlFragment(url: string): string | null {
99
- if (!url.includes("#")) return null
100
- return url.slice(url.indexOf("#") + 1)
101
- }
102
-
103
- function normalizedLocationHash(locationHash: string): string {
104
- if (!locationHash) return ""
105
- return locationHash.startsWith("#") ? locationHash.slice(1) : locationHash
106
- }
90
+ import {
91
+ buildNavHashClaims,
92
+ collectNavUrls,
93
+ isNavHrefActive,
94
+ navUrlPath,
95
+ normalizedLocationHash,
96
+ } from "@exxatdesignux/ui/lib/nav-active"
107
97
 
108
- /**
109
- * Path → set of hash fragments claimed by *another* nav item at the same path.
110
- *
111
- * Why: several rows deliberately share a route and disambiguate via `#fragment`.
112
- * Example: `Tokens & themes → /settings#appearance` and `Settings → /settings`
113
- * both render under the same page. Without a registry, when the user is on
114
- * `/settings#appearance` the no-fragment "Settings" row matches by path-equality
115
- * (line below: `pathname === pathOnly`) and lights up too — so *both* rows
116
- * appear active.
117
- *
118
- * The registry is computed once from the static nav config and consulted by
119
- * `isNavActive` to make the no-fragment item defer when a hash-bearing sibling
120
- * claims the current `location.hash`.
121
- */
122
- const NAV_HASH_CLAIMS: ReadonlyMap<string, ReadonlySet<string>> = (() => {
123
- const map = new Map<string, Set<string>>()
124
- const record = (url: string) => {
125
- const p = navUrlPath(url)
126
- const f = navUrlFragment(url)
127
- if (!p || f === null) return
128
- let set = map.get(p)
129
- if (!set) { set = new Set<string>(); map.set(p, set) }
130
- set.add(f)
131
- }
132
- const walk = (items: ReadonlyArray<{ url?: string; children?: ReadonlyArray<{ url?: string; children?: unknown }> }>) => {
133
- for (const it of items) {
134
- if (typeof it.url === "string") record(it.url)
135
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
136
- if (Array.isArray((it as any).children)) walk((it as any).children)
137
- }
138
- }
139
- walk(NAV_PRIMARY)
140
- walk(NAV_DOCUMENTS)
141
- walk(NAV_QUICK_ACTIONS)
142
- walk(NAV_SECONDARY)
143
- return map
144
- })()
98
+ const ALL_NAV_URLS = collectNavUrls([...NAV_PRIMARY, ...NAV_DOCUMENTS, ...NAV_SECONDARY])
99
+ const NAV_HASH_CLAIMS = buildNavHashClaims(ALL_NAV_URLS)
145
100
 
146
- /** True when another nav item at the same path claims the current location hash. */
147
- function navHasMoreSpecificMatch(pathname: string, locationHash: string): boolean {
148
- const claims = NAV_HASH_CLAIMS.get(pathname)
149
- if (!claims) return false
150
- const h = normalizedLocationHash(locationHash)
151
- if (h === "") return false
152
- return claims.has(h)
153
- }
154
-
155
- /**
156
- * Whether `pathname` (+ optional `location.hash`) matches a sidebar `href`.
157
- * When several links share the same path (e.g. `/settings`), disambiguate with `#fragment`
158
- * in each `href` — those rows use the `frag !== null` branch below.
159
- * For `href` without `#…`, an in-page hash (e.g. QB view tabs) does not clear the match.
160
- */
101
+ /** Single active primary/secondary sidebar row longest matching path wins. */
161
102
  function isNavActive(pathname: string, url: string, locationHash = ""): boolean {
162
- const pathOnly = navUrlPath(url)
163
- const frag = navUrlFragment(url)
164
- const h = normalizedLocationHash(locationHash)
165
-
166
- if (!pathOnly || pathOnly === "#") return false
167
-
168
- if (frag !== null) {
169
- if (pathOnly === "/") return pathname === "/" && h === frag
170
- if (pathOnly === "/library") {
171
- return pathname.startsWith("/library/") && h === frag
172
- }
173
- if (pathOnly.startsWith("/library/")) {
174
- return pathname === pathOnly && h === frag
175
- }
176
- return pathname === pathOnly && h === frag
177
- }
178
-
179
- if (pathOnly === "/") {
180
- if (pathname !== "/" || h !== "") return false
181
- return !navHasMoreSpecificMatch(pathname, locationHash)
182
- }
183
- /**
184
- * Exact path match — ignore `location.hash` when the nav `href` has no `#…` fragment (QB view tabs use hash).
185
- * EXCEPTION: when another nav item at the same path claims the current hash
186
- * (e.g. `Tokens & themes → /settings#appearance` while we're evaluating
187
- * `Settings → /settings`), defer to that more-specific row — otherwise both
188
- * would light up. See `NAV_HASH_CLAIMS`.
189
- */
190
- if (pathname === pathOnly) return !navHasMoreSpecificMatch(pathname, locationHash)
191
- // Design system library — active on hub and detail routes.
192
- if (pathOnly === "/library") {
193
- return pathname.startsWith("/library/")
194
- }
195
- if (pathOnly.startsWith("/library/")) {
196
- return pathname === pathOnly
197
- }
198
- return pathname.startsWith(`${pathOnly}/`)
103
+ return isNavHrefActive(pathname, url, ALL_NAV_URLS, {
104
+ locationHash,
105
+ hashClaimsByPath: NAV_HASH_CLAIMS,
106
+ })
199
107
  }
200
108
 
201
109
  /** Sub-item active — catalog detail routes, hash fragments, or duplicate hub URLs (Rotations). */
@@ -26,6 +26,7 @@
26
26
  import * as React from "react"
27
27
  import { usePathname } from "next/navigation"
28
28
  import { cn } from "@/lib/utils"
29
+ import { resolveActiveNavHref } from "@exxatdesignux/ui/lib/nav-active"
29
30
  import {
30
31
  Tooltip,
31
32
  TooltipContent,
@@ -137,9 +138,16 @@ function RailButton({
137
138
  // NavLink — single item in the content panel
138
139
  // ─────────────────────────────────────────────────────────────────────────────
139
140
 
140
- function NavLink({ link }: { link: SecondaryNavLink }) {
141
+ function NavLink({
142
+ link,
143
+ allLinkHrefs,
144
+ }: {
145
+ link: SecondaryNavLink
146
+ allLinkHrefs: readonly string[]
147
+ }) {
141
148
  const pathname = usePathname()
142
- const isActive = pathname === link.href || pathname.startsWith(link.href + "/")
149
+ const activeHref = resolveActiveNavHref(pathname, allLinkHrefs)
150
+ const isActive = activeHref !== null && activeHref === link.href
143
151
 
144
152
  if (link.isSectionHeader) {
145
153
  return (
@@ -239,9 +247,12 @@ export function SecondaryNavRail({
239
247
 
240
248
  export function SecondaryNavPanel({
241
249
  section,
250
+ allLinkHrefs,
242
251
  className,
243
252
  }: {
244
253
  section: SecondaryNavSection
254
+ /** All navigable hrefs (every section) so only one row is active at a time. */
255
+ allLinkHrefs: readonly string[]
245
256
  className?: string
246
257
  }) {
247
258
  const [query, setQuery] = React.useState("")
@@ -329,7 +340,7 @@ export function SecondaryNavPanel({
329
340
 
330
341
  <ul role="list" className="flex flex-col gap-0.5 px-1.5">
331
342
  {visibleLinks.map(link => (
332
- <NavLink key={link.key} link={link} />
343
+ <NavLink key={link.key} link={link} allLinkHrefs={allLinkHrefs} />
333
344
  ))}
334
345
  {section.searchable && q && visibleLinks.length === 0 && (
335
346
  <li className="px-3 py-2 text-xs text-muted-foreground">No results</li>
@@ -363,6 +374,13 @@ export function SecondaryNav({
363
374
  )
364
375
 
365
376
  const currentSection = sections.find(s => s.key === activeSection) ?? sections[0]
377
+ const allLinkHrefs = React.useMemo(
378
+ () =>
379
+ sections.flatMap(s =>
380
+ s.links.filter(l => !l.isSectionHeader).map(l => l.href),
381
+ ),
382
+ [sections],
383
+ )
366
384
 
367
385
  function handleSectionChange(key: string) {
368
386
  setActiveSection(key)
@@ -388,7 +406,7 @@ export function SecondaryNav({
388
406
  )}
389
407
 
390
408
  {/* Right content panel */}
391
- <SecondaryNavPanel section={currentSection} />
409
+ <SecondaryNavPanel section={currentSection} allLinkHrefs={allLinkHrefs} />
392
410
  </div>
393
411
  )
394
412
  }
@@ -0,0 +1,301 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import {
5
+ FolderGridView,
6
+ ListPageBoardCard,
7
+ ListPageSplitHubChrome,
8
+ type HubTableRendererArgs,
9
+ type HubTableRenderers,
10
+ } from "@/components/data-views"
11
+ import { ListPageTreeColumnHeader } from "@/components/data-views/list-page-tree-column-header"
12
+ import {
13
+ LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS,
14
+ LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS,
15
+ LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS,
16
+ } from "@/components/data-views/list-page-split-hub-tokens"
17
+ import { KeyMetrics, type MetricInsight, type MetricItem } from "@/components/key-metrics"
18
+ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"
19
+ import { cn } from "@/lib/utils"
20
+ import { categoryPreview, type TokenRecord } from "@/components/tokens-themes-section"
21
+
22
+ export interface TokenHubRow extends Record<string, unknown> {
23
+ id: string
24
+ name: string
25
+ namespace: string
26
+ category: string
27
+ value: string
28
+ deprecated: boolean
29
+ record: TokenRecord
30
+ }
31
+
32
+ export function TokensDashboardBody({
33
+ metrics,
34
+ insight,
35
+ }: {
36
+ metrics: MetricItem[]
37
+ insight: MetricInsight
38
+ }) {
39
+ return (
40
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 lg:px-6">
41
+ <KeyMetrics variant="flat" metrics={metrics} insight={insight} showHeader={false} metricsSingleRow />
42
+ </div>
43
+ )
44
+ }
45
+
46
+ export function TokensFolderBody({ rows }: { rows: TokenHubRow[] }) {
47
+ return (
48
+ <FolderGridView
49
+ rows={rows}
50
+ getRowId={r => r.id}
51
+ ariaLabel="Design tokens"
52
+ constrainWidth
53
+ renderTile={row => (
54
+ <button
55
+ type="button"
56
+ className={cn(
57
+ "flex w-full flex-col items-center gap-2 rounded-lg border border-border bg-card p-4 text-center",
58
+ "transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
59
+ )}
60
+ >
61
+ <div className="flex h-12 w-12 items-center justify-center">{categoryPreview(row.name, row.record)}</div>
62
+ <span className="line-clamp-2 font-mono text-xs text-foreground">{row.name}</span>
63
+ <span className="text-[10px] text-muted-foreground">{row.namespace}</span>
64
+ </button>
65
+ )}
66
+ emptyContent={<p className="text-sm text-muted-foreground">No tokens match your filters.</p>}
67
+ />
68
+ )
69
+ }
70
+
71
+ function TokenDetailPanel({ row }: { row: TokenHubRow }) {
72
+ return (
73
+ <div className="flex min-w-0 flex-col gap-3">
74
+ <div className="flex items-center gap-3">
75
+ <div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-md border border-border bg-muted/30">
76
+ {categoryPreview(row.name, row.record)}
77
+ </div>
78
+ <div className="min-w-0">
79
+ <p className="font-mono text-sm font-semibold text-foreground">{row.name}</p>
80
+ <p className="text-xs text-muted-foreground">{row.namespace}</p>
81
+ </div>
82
+ </div>
83
+ <div>
84
+ <span className="text-xs font-medium text-muted-foreground">Value</span>
85
+ <p className="mt-1 font-mono text-xs text-foreground break-all">{row.value}</p>
86
+ </div>
87
+ <div>
88
+ <span className="text-xs font-medium text-muted-foreground">Category</span>
89
+ <p className="mt-1 text-sm text-foreground">{String(row.category)}</p>
90
+ </div>
91
+ </div>
92
+ )
93
+ }
94
+
95
+ export function TokensPanelBody({ rows }: { rows: TokenHubRow[] }) {
96
+ const namespaces = React.useMemo(
97
+ () => [...new Set(rows.map(r => r.namespace))].sort((a, b) => a.localeCompare(b)),
98
+ [rows],
99
+ )
100
+ const [activeNamespace, setActiveNamespace] = React.useState<string | null>(namespaces[0] ?? null)
101
+ const [activeId, setActiveId] = React.useState<string | null>(null)
102
+
103
+ React.useEffect(() => {
104
+ if (namespaces.length === 0) {
105
+ setActiveNamespace(null)
106
+ setActiveId(null)
107
+ return
108
+ }
109
+ if (!activeNamespace || !namespaces.includes(activeNamespace)) {
110
+ setActiveNamespace(namespaces[0]!)
111
+ }
112
+ }, [namespaces, activeNamespace])
113
+
114
+ const inNamespace = React.useMemo(
115
+ () => (activeNamespace ? rows.filter(r => r.namespace === activeNamespace) : []),
116
+ [rows, activeNamespace],
117
+ )
118
+
119
+ React.useEffect(() => {
120
+ if (inNamespace.length === 0) {
121
+ setActiveId(null)
122
+ return
123
+ }
124
+ if (!activeId || !inNamespace.some(r => r.id === activeId)) {
125
+ setActiveId(inNamespace[0]!.id)
126
+ }
127
+ }, [inNamespace, activeId])
128
+
129
+ const activeRow = inNamespace.find(r => r.id === activeId) ?? null
130
+
131
+ return (
132
+ <ListPageSplitHubChrome aria-label="Token namespaces and details">
133
+ <ResizablePanelGroup direction="horizontal" className="flex h-full min-h-0 w-full flex-1">
134
+ <ResizablePanel defaultSize={28} minSize={18} className={LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS}>
135
+ <ListPageTreeColumnHeader title="Namespaces" />
136
+ <div className="min-h-0 flex-1 overflow-y-auto p-1">
137
+ {namespaces.map(ns => (
138
+ <button
139
+ key={ns}
140
+ type="button"
141
+ onClick={() => setActiveNamespace(ns)}
142
+ className={cn(
143
+ "flex w-full items-center rounded-md px-3 py-2 text-left text-sm",
144
+ activeNamespace === ns ? "bg-muted font-medium text-foreground" : "text-muted-foreground hover:bg-muted/50",
145
+ )}
146
+ >
147
+ {ns}
148
+ </button>
149
+ ))}
150
+ </div>
151
+ </ResizablePanel>
152
+ <ResizableHandle withHandle className={LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS} />
153
+ <ResizablePanel defaultSize={32} minSize={20} className={LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS}>
154
+ <ListPageTreeColumnHeader title={activeNamespace ?? "Tokens"} />
155
+ <div className="min-h-0 flex-1 overflow-y-auto p-1">
156
+ {inNamespace.map(row => (
157
+ <button
158
+ key={row.id}
159
+ type="button"
160
+ onClick={() => setActiveId(row.id)}
161
+ className={cn(
162
+ "flex w-full flex-col rounded-md px-3 py-2 text-left",
163
+ activeId === row.id ? "bg-muted" : "hover:bg-muted/50",
164
+ )}
165
+ >
166
+ <span className="truncate font-mono text-xs text-foreground">{row.name}</span>
167
+ <span className="truncate text-[10px] text-muted-foreground">{String(row.category)}</span>
168
+ </button>
169
+ ))}
170
+ </div>
171
+ </ResizablePanel>
172
+ <ResizableHandle withHandle className={LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS} />
173
+ <ResizablePanel defaultSize={40} minSize={24} className={LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS}>
174
+ <ListPageTreeColumnHeader title="Details" className="px-4" />
175
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
176
+ {activeRow ? <TokenDetailPanel row={activeRow} /> : <p className="text-sm text-muted-foreground">Select a token.</p>}
177
+ </div>
178
+ </ResizablePanel>
179
+ </ResizablePanelGroup>
180
+ </ListPageSplitHubChrome>
181
+ )
182
+ }
183
+
184
+ export function TokensTreePanelBody({ rows }: { rows: TokenHubRow[] }) {
185
+ const byNamespace = React.useMemo(() => {
186
+ const map = new Map<string, TokenHubRow[]>()
187
+ for (const row of rows) {
188
+ const list = map.get(row.namespace) ?? []
189
+ list.push(row)
190
+ map.set(row.namespace, list)
191
+ }
192
+ return [...map.entries()].sort(([a], [b]) => a.localeCompare(b))
193
+ }, [rows])
194
+
195
+ const [openNs, setOpenNs] = React.useState<Set<string>>(() => new Set(byNamespace.map(([ns]) => ns)))
196
+ const [activeId, setActiveId] = React.useState<string | null>(rows[0]?.id ?? null)
197
+ const activeRow = rows.find(r => r.id === activeId) ?? null
198
+
199
+ return (
200
+ <ListPageSplitHubChrome aria-label="Token tree">
201
+ <ResizablePanelGroup direction="horizontal" className="flex h-full min-h-0 w-full flex-1">
202
+ <ResizablePanel defaultSize={40} minSize={24} className={LIST_PAGE_SPLIT_MILLER_COLUMN_PANEL_CLASS}>
203
+ <ListPageTreeColumnHeader title="All tokens" />
204
+ <div className="min-h-0 flex-1 overflow-y-auto p-2">
205
+ {byNamespace.map(([ns, items]) => {
206
+ const open = openNs.has(ns)
207
+ return (
208
+ <div key={ns} className="mb-1">
209
+ <button
210
+ type="button"
211
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm font-medium hover:bg-muted/50"
212
+ onClick={() =>
213
+ setOpenNs(prev => {
214
+ const next = new Set(prev)
215
+ if (next.has(ns)) next.delete(ns)
216
+ else next.add(ns)
217
+ return next
218
+ })
219
+ }
220
+ >
221
+ <i
222
+ className={cn("fa-light text-xs text-muted-foreground", open ? "fa-chevron-down" : "fa-chevron-right")}
223
+ aria-hidden="true"
224
+ />
225
+ {ns}
226
+ </button>
227
+ {open ? (
228
+ <div className="ms-4 border-s border-border ps-2">
229
+ {items.map(row => (
230
+ <button
231
+ key={row.id}
232
+ type="button"
233
+ onClick={() => setActiveId(row.id)}
234
+ className={cn(
235
+ "flex w-full rounded-md px-2 py-1.5 text-left font-mono text-xs",
236
+ activeId === row.id ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/40",
237
+ )}
238
+ >
239
+ {row.name}
240
+ </button>
241
+ ))}
242
+ </div>
243
+ ) : null}
244
+ </div>
245
+ )
246
+ })}
247
+ </div>
248
+ </ResizablePanel>
249
+ <ResizableHandle withHandle className={LIST_PAGE_SPLIT_RESIZABLE_HANDLE_CLASS} />
250
+ <ResizablePanel defaultSize={60} minSize={30} className={LIST_PAGE_SPLIT_MILLER_DETAIL_PANEL_CLASS}>
251
+ <ListPageTreeColumnHeader title="Details" className="px-4" />
252
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
253
+ {activeRow ? <TokenDetailPanel row={activeRow} /> : <p className="text-sm text-muted-foreground">Select a token.</p>}
254
+ </div>
255
+ </ResizablePanel>
256
+ </ResizablePanelGroup>
257
+ </ListPageSplitHubChrome>
258
+ )
259
+ }
260
+
261
+ export function buildTokensHubRenderers(
262
+ metrics: MetricItem[],
263
+ insight: MetricInsight,
264
+ ): Pick<
265
+ HubTableRenderers<TokenHubRow>,
266
+ "dashboard-with-toolbar" | "folder-with-toolbar" | "panel-with-toolbar" | "tree-panel-with-toolbar"
267
+ > {
268
+ return {
269
+ "dashboard-with-toolbar": ({ toolbar }: HubTableRendererArgs<TokenHubRow>) => (
270
+ <div className="flex min-h-0 flex-1 flex-col">
271
+ {toolbar}
272
+ <TokensDashboardBody metrics={metrics} insight={insight} />
273
+ </div>
274
+ ),
275
+ "folder-with-toolbar": ({ state, toolbarShell }) =>
276
+ toolbarShell(<TokensFolderBody rows={state.rows as TokenHubRow[]} />),
277
+ "panel-with-toolbar": ({ state, toolbarShell }) =>
278
+ toolbarShell(<TokensPanelBody rows={state.rows as TokenHubRow[]} />),
279
+ "tree-panel-with-toolbar": ({ state, toolbarShell }) =>
280
+ toolbarShell(<TokensTreePanelBody rows={state.rows as TokenHubRow[]} />),
281
+ }
282
+ }
283
+
284
+ export function renderTokenListRow(row: TokenHubRow) {
285
+ return (
286
+ <ListPageBoardCard layout="row" rowContainerClassName="flex w-full flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
287
+ <div className="flex min-w-0 items-center gap-3">
288
+ <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-border bg-muted/20">
289
+ {categoryPreview(row.name, row.record)}
290
+ </div>
291
+ <div className="min-w-0 space-y-0.5">
292
+ <p className="truncate font-mono text-sm font-semibold text-foreground">{row.name}</p>
293
+ <p className="text-xs text-muted-foreground">
294
+ {row.namespace} · {String(row.category)}
295
+ </p>
296
+ <p className="truncate font-mono text-[10px] text-muted-foreground">{row.value}</p>
297
+ </div>
298
+ </div>
299
+ </ListPageBoardCard>
300
+ )
301
+ }
@@ -34,11 +34,18 @@ import {
34
34
  } from "@/components/key-metrics"
35
35
  import {
36
36
  HubTable,
37
+ ListPageBoardCard,
37
38
  ListPageTemplate,
38
39
  type ViewTab,
39
40
  } from "@/components/data-views"
41
+ import type { ListPageBoardColumnDef } from "@/components/data-views/list-page-board-template"
40
42
  import type { ColumnDef } from "@/components/data-table/types"
41
- import type { DataListViewType } from "@/lib/data-list-view"
43
+ import { FULL_HUB_SUPPORTED_VIEWS } from "@/lib/data-list-view-registry"
44
+ import {
45
+ buildTokensHubRenderers,
46
+ renderTokenListRow,
47
+ type TokenHubRow,
48
+ } from "@/components/tokens-hub-auxiliary-views"
42
49
  import { Button } from "@/components/ui/button"
43
50
  import { Tip } from "@/components/ui/tip"
44
51
  import { Badge } from "@/components/ui/badge"
@@ -51,7 +58,6 @@ import {
51
58
  categoryPreview,
52
59
  primaryValueText,
53
60
  type TokenCategory,
54
- type TokenRecord,
55
61
  } from "@/components/tokens-themes-section"
56
62
  import {
57
63
  readTokensCategory,
@@ -60,16 +66,7 @@ import {
60
66
  } from "@/components/tokens-secondary-nav"
61
67
 
62
68
  /** 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
- }
69
+ type TokenRow = TokenHubRow & { category: TokenCategory | string }
73
70
 
74
71
  /** Build all token rows once at module load (token index is static at runtime). */
75
72
  const TOKEN_ROWS: TokenRow[] = (() => {
@@ -124,8 +121,8 @@ const TOKENS_VIEW_TABS: ViewTab[] = [
124
121
  },
125
122
  ]
126
123
 
127
- /** Tokens hub only supports the table viewProperties drawer hides everything else. */
128
- const TOKENS_SUPPORTED_VIEWS: readonly DataListViewType[] = ["table"] as const
124
+ /** Same seven views as Library / Column types each has a renderer on `HubTable` below. */
125
+ const TOKENS_SUPPORTED_VIEWS = FULL_HUB_SUPPORTED_VIEWS
129
126
 
130
127
  /**
131
128
  * Canonical KPI shape (matches `placement-kpi.ts` precedent):
@@ -324,6 +321,19 @@ export function TokensThemesClient() {
324
321
 
325
322
  const getTabCount = React.useCallback(() => rows.length, [rows.length])
326
323
 
324
+ const tokenBoardGroups = React.useMemo((): ListPageBoardColumnDef<TokenRow>[] => {
325
+ return CATEGORY_TABS.map(tab => ({
326
+ id: String(tab.id),
327
+ label: tab.label,
328
+ filter: (row: TokenRow) => tab.matches(String(row.category)),
329
+ }))
330
+ }, [])
331
+
332
+ const tokenHubRenderers = React.useMemo(
333
+ () => buildTokensHubRenderers(metrics, insight),
334
+ [metrics, insight],
335
+ )
336
+
327
337
  const columns: ColumnDef<TokenRow>[] = React.useMemo(() => [
328
338
  {
329
339
  key: "preview",
@@ -425,7 +435,7 @@ export function TokensThemesClient() {
425
435
  onTabsChange={setTabs}
426
436
  activeTabId={activeTabId}
427
437
  onActiveTabChange={setActiveTabId}
428
- supportedViewTypes={["table"]}
438
+ supportedViewTypes={TOKENS_SUPPORTED_VIEWS}
429
439
  getTabCount={getTabCount}
430
440
  header={
431
441
  <PageHeader
@@ -467,7 +477,25 @@ export function TokensThemesClient() {
467
477
  No tokens match your filters.
468
478
  </p>
469
479
  }
470
- renderers={{}}
480
+ listAriaLabel="Tokens"
481
+ listEmptyState="No tokens match your filters."
482
+ renderListRow={renderTokenListRow}
483
+ renderBoardCard={row => (
484
+ <ListPageBoardCard layout="stack">
485
+ <div className="flex items-center gap-2">
486
+ <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded border border-border bg-muted/20">
487
+ {categoryPreview(row.name, row.record)}
488
+ </div>
489
+ <div className="min-w-0">
490
+ <p className="truncate font-mono text-xs font-semibold text-foreground">{row.name}</p>
491
+ <p className="text-[10px] text-muted-foreground">{row.namespace}</p>
492
+ </div>
493
+ </div>
494
+ </ListPageBoardCard>
495
+ )}
496
+ boardGroups={tokenBoardGroups}
497
+ boardEmptyColumnLabel="No tokens"
498
+ renderers={tokenHubRenderers}
471
499
  />
472
500
  )}
473
501
  />
@@ -33,7 +33,7 @@ This is the **happy path** for the most common task: "I have an entity (records,
33
33
  | 5 | Compose the page client with `PrimaryPageTemplate` → `ListPageTemplate` (KPIs in `metrics`, view tabs in `defaultTabs`, the `HubTable` in `renderContent`). | `apps/web/components/<entity>-client.tsx` | `exxat-list-page-connected-views.mdc` |
34
34
  | 6 | Add to nav (`lib/mock/navigation.tsx`). If the hub needs scoped sub-navigation (e.g. categories), declare `secondaryPanel: "<id>"` and register the panel. | `apps/web/lib/mock/navigation.tsx`, `apps/web/components/sidebar/secondary-panel.tsx` | `exxat-primary-nav-secondary-panel.mdc` |
35
35
 
36
- **Reference pages to copy:** `apps/web/components/columns-showcase.tsx` (single-view catalog hub composing every reusable cell), `apps/web/components/tokens-themes-client.tsx` (hub with a secondary panel + URL-driven scope), or `apps/web/components/library-table.tsx` (full hub: table / board / dashboard + conditional rules).
36
+ **Reference pages to copy:** `apps/web/components/library-table.tsx` + `library-client.tsx` (canonical seven-view hub), `apps/web/components/columns-showcase.tsx` (custom columns + same Add view via `LibraryTable`), `apps/web/components/tokens-themes-client.tsx` + `tokens-hub-auxiliary-views.tsx`. See **`hub-supported-views-pattern.md`** before changing Add view.
37
37
 
38
38
  > **Stop signs.** If you find yourself building a parallel table stack, a second metrics strip, a custom filter row, or pasting raw `<DataTable>` into `renderContent` — **stop and re-read** `.cursor/rules/exxat-reuse-before-custom.mdc`.
39
39
 
@@ -6,6 +6,12 @@
6
6
 
7
7
  This document describes how list pages combine **views**, **toolbar** behavior, **filters**, **properties**, and **persistence** in this codebase.
8
8
 
9
+ ## Add view parity (seven views)
10
+
11
+ **Binding:** `.cursor/rules/exxat-hub-supported-views.mdc`. **Detail:** `docs/hub-supported-views-pattern.md`.
12
+
13
+ Every list hub **should** use **`FULL_HUB_SUPPORTED_VIEWS`** (table, list, board, dashboard, folder, panel, tree-panel) on both **`ListPageTemplate`** and **`HubTable`**, with a working renderer per view. **Library** (`library-table.tsx`) is the reference. Do not ship table-only or four-view allowlists on showcase/catalog hubs unless product documents an exception.
14
+
9
15
  ## Reuse existing components (required)
10
16
 
11
17
  **Prefer composing what already exists** over rebuilding one-off tabs, search, filters, or property panels. The **Placements** flow is the reference implementation; new list/table/board pages should wire the same building blocks with new data and column definitions.
@@ -46,7 +46,8 @@
46
46
  | **Secondary panel** | A scoped navigation rail (e.g. "Library → All / Mine / Tree", "Tokens & themes → Colors / Radius / Motion / …") that sits between the main sidebar and the page. Opening one collapses the main sidebar; closing one restores the previous sidebar state. | `exxat-primary-nav-secondary-panel.mdc` |
47
47
  | **Site header** | The top bar on a primary route (org/product switcher + breadcrumbs + actions). Owned by `PrimaryPageTemplate`. | `apps/web/components/templates/primary-page-template.tsx` |
48
48
  | **Skill** | A `.cursor/skills/<name>/SKILL.md` (mirrored in `.claude/skills/`) workflow + checklist for a recurring agent task. Use a skill when the same checklist would be repeated across many sessions. | `apps/web/AGENTS.md` |
49
- | **`supportedViewTypes`** | The allowlist of `DataListViewType` values a hub implements. Passed to `HubTable.supportedViewTypes` so the Properties drawer never offers a view the hub can't render. | `packages/ui/src/components/data-views/hub-table.tsx` |
49
+ | **`supportedViewTypes`** | The allowlist of `DataListViewType` values a hub implements. Default **`FULL_HUB_SUPPORTED_VIEWS`** (seven views, same Add view as Library). Must match on **`ListPageTemplate`** + **`HubTable`**; every entry needs a renderer. | `packages/ui/src/lib/data-list-view-registry.ts`, `docs/hub-supported-views-pattern.md` |
50
+ | **`FULL_HUB_SUPPORTED_VIEWS`** | table · list · board · dashboard · folder · panel · tree-panel — canonical list-hub allowlist. | `data-list-view-registry.ts` |
50
51
  | **Trend polarity** | `MetricItem.trendPolarity` says whether "up" is good (`higher_is_better`, default), bad (`lower_is_better`), or value-neutral (`informational`). The arrow's tint follows the polarity, not the sign. | `exxat-kpi-trends.mdc` |
51
52
  | **`useTableState`** | The state hook that owns rows, filters, search, sort, pagination, group-by, and column visibility. Always one instance per hub. | `exxat-centralized-list-dataset.mdc` |
52
53
  | **View tab** | A tab on `ListPageTemplate` representing one view of the same dataset (table, list, board, dashboard, folder, panel, tree, …). Each tab carries a `viewType` and the `renderContent` callback receives it. | `exxat-list-page-connected-views.mdc` |