@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
@@ -0,0 +1,382 @@
1
+ # Data Table Pattern — Full Implementation Guide
2
+
3
+ Reference implementation: `components/team-table.tsx` (Team) and `components/data-list-table.tsx` (Placements).
4
+
5
+ ---
6
+
7
+ ## Stack Summary
8
+
9
+ ```
10
+ DataTable ← base table component
11
+ └── useTableState ← manages sort/filter/column/group state
12
+ └── toolbarSlot ← renders the properties button + drawer
13
+ └── TablePropertiesDrawer ← columns, density, filters, sort, conditional rules
14
+ ```
15
+
16
+ All imports:
17
+ ```ts
18
+ import { DataTable } from "@/components/data-table"
19
+ import type { ColumnDef } from "@/components/data-table/types"
20
+ import { useTableState } from "@/components/data-table/use-table-state"
21
+ import { TablePropertiesDrawer } from "@/components/table-properties"
22
+ import type {
23
+ ConditionalRule,
24
+ FilterFieldDef,
25
+ FilterOperator,
26
+ } from "@/components/table-properties/types"
27
+ import {
28
+ DEFAULT_DATA_LIST_DISPLAY_OPTIONS,
29
+ type DataListDisplayOptions,
30
+ } from "@/lib/data-list-display-options"
31
+ ```
32
+
33
+ ---
34
+
35
+ ## Column Definition Patterns
36
+
37
+ ### Text column with filter
38
+ ```ts
39
+ {
40
+ key: "name",
41
+ label: "Name",
42
+ width: 240,
43
+ minWidth: 160,
44
+ sortable: true,
45
+ sortKey: "name",
46
+ defaultPin: "left", // pin to left (optional)
47
+ filter: {
48
+ type: "text",
49
+ icon: "fa-user",
50
+ operators: ["contains", "not_contains"],
51
+ },
52
+ cell: row => (
53
+ <span className="text-sm font-medium text-foreground truncate">{row.name}</span>
54
+ ),
55
+ }
56
+ ```
57
+
58
+ ### Select (enum) column with filter
59
+ ```ts
60
+ {
61
+ key: "status",
62
+ label: "Status",
63
+ width: 120,
64
+ minWidth: 100,
65
+ sortable: true,
66
+ sortKey: "status",
67
+ filter: {
68
+ type: "select",
69
+ icon: "fa-circle-dot",
70
+ operators: ["is", "is_not"],
71
+ options: [
72
+ { value: "active", label: "Active" },
73
+ { value: "inactive", label: "Inactive" },
74
+ ],
75
+ },
76
+ cell: row => (
77
+ <Badge variant="outline" className={cn("text-[10px] font-medium uppercase tracking-wide", STATUS_BADGE[row.status])}>
78
+ {STATUS_LABEL[row.status]}
79
+ </Badge>
80
+ ),
81
+ }
82
+ ```
83
+
84
+ ### Select column (pinned right, no filter)
85
+ ```ts
86
+ {
87
+ key: "select",
88
+ label: "",
89
+ width: 40,
90
+ minWidth: 40,
91
+ defaultPin: "left",
92
+ lockPin: true,
93
+ }
94
+ ```
95
+
96
+ ### Actions column (pinned right)
97
+ ```ts
98
+ {
99
+ key: "actions",
100
+ label: "",
101
+ width: 48,
102
+ minWidth: 48,
103
+ defaultPin: "right",
104
+ lockPin: true,
105
+ cell: row => (
106
+ <div className="flex items-center justify-center">
107
+ <DropdownMenu>
108
+ <DropdownMenuTrigger asChild>
109
+ <Button size="icon-sm" variant="ghost" aria-label={`Actions for ${row.name}`}>
110
+ <i className="fa-light fa-ellipsis text-sm" aria-hidden="true" />
111
+ </Button>
112
+ </DropdownMenuTrigger>
113
+ <DropdownMenuContent align="end" className="w-40">
114
+ <DropdownMenuItem disabled>
115
+ <i className="fa-light fa-pen" aria-hidden="true" />
116
+ Edit
117
+ </DropdownMenuItem>
118
+ </DropdownMenuContent>
119
+ </DropdownMenu>
120
+ </div>
121
+ ),
122
+ }
123
+ ```
124
+
125
+ ---
126
+
127
+ ## Status Badge Pattern
128
+
129
+ ```ts
130
+ const STATUS_LABEL: Record<MyType["status"], string> = {
131
+ active: "Active",
132
+ inactive: "Inactive",
133
+ pending: "Pending",
134
+ }
135
+
136
+ const STATUS_BADGE: Record<MyType["status"], string> = {
137
+ active: "bg-emerald-500/15 text-emerald-800 dark:text-emerald-200 border-emerald-500/20",
138
+ inactive: "bg-slate-500/10 text-slate-700 dark:text-slate-200 border-border",
139
+ pending: "bg-amber-500/15 text-amber-900 dark:text-amber-100 border-amber-500/20",
140
+ }
141
+ ```
142
+
143
+ ---
144
+
145
+ ## Filter Field Derivation
146
+
147
+ Convert column defs to filter field defs for the drawer:
148
+
149
+ ```ts
150
+ function columnToFilterFieldDef(c: ColumnDef<T>): FilterFieldDef | null {
151
+ if (!c.filter) return null
152
+ const f = c.filter
153
+ const defaultOps: FilterOperator[] =
154
+ f.type === "select" || f.type === "date" ? ["is", "is_not"] : ["contains", "not_contains"]
155
+ return {
156
+ key: c.key,
157
+ label: c.label,
158
+ icon: f.icon ?? "fa-filter",
159
+ type: f.type,
160
+ operators: (f.operators ?? defaultOps) as FilterOperator[],
161
+ options: f.options,
162
+ }
163
+ }
164
+ ```
165
+
166
+ ---
167
+
168
+ ## Drawer Toolbar Component
169
+
170
+ The `toolbarSlot` renders the Properties button and the drawer. Full pattern:
171
+
172
+ ```tsx
173
+ function FooDrawerToolbar({ state, totalRows, filterFields, fieldDefinitionsForDrawer, resolveColumnLabel, displayOptions, onDisplayOptionsChange, conditionalRules, onAddConditionalRule, onRemoveConditionalRule, onUpdateConditionalRule }) {
174
+ const { sheetOpen, setSheetOpen, showGridlines, setShowGridlines, rowHeight, setRowHeight,
175
+ activeFilters, addFilter, updateFilter, removeFilter, getConnector, toggleConnector,
176
+ filterBarVisible, setFilterBarVisible, drawerExpandedFilters, setDrawerExpandedFilters,
177
+ rows, sortRules, setSortRules, addSortRule, removeSortRule, toggleSortDir,
178
+ colOrder, setColOrder, hiddenCols, toggleColVisibility, moveCol, groupBy, setGroupBy, sortKey,
179
+ } = state
180
+
181
+ return (
182
+ <>
183
+ <TooltipProvider>
184
+ <Tooltip>
185
+ <TooltipTrigger asChild>
186
+ <Button
187
+ type="button" variant="ghost" size="icon-sm" aria-label="Properties"
188
+ onClick={() => setSheetOpen(true)}
189
+ className={cn(sheetOpen ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:text-interactive-hover-foreground hover:bg-interactive-hover")}
190
+ >
191
+ <i className="fa-light fa-sliders text-[13px]" aria-hidden="true" />
192
+ </Button>
193
+ </TooltipTrigger>
194
+ <TooltipContent side="bottom">Properties</TooltipContent>
195
+ </Tooltip>
196
+ </TooltipProvider>
197
+
198
+ <TablePropertiesDrawer
199
+ open={sheetOpen} onOpenChange={setSheetOpen}
200
+ showGridlines={showGridlines} onShowGridlinesChange={setShowGridlines}
201
+ rowHeight={rowHeight} onRowHeightChange={setRowHeight}
202
+ pagination={false} onPaginationChange={() => {}}
203
+ activeFilters={activeFilters} onAddFilter={k => addFilter(k, true)}
204
+ onUpdateFilter={updateFilter} onRemoveFilter={removeFilter}
205
+ getFilterConnector={getConnector} onToggleFilterConnector={toggleConnector}
206
+ filterBarVisible={filterBarVisible} onFilterBarVisibleChange={setFilterBarVisible}
207
+ drawerExpandedFilters={drawerExpandedFilters} onDrawerExpandedFiltersChange={setDrawerExpandedFilters}
208
+ totalRows={totalRows} filteredRows={rows.length}
209
+ sortRules={sortRules} onSortRulesChange={setSortRules}
210
+ onAddSortRule={addSortRule} onRemoveSortRule={removeSortRule} onToggleSortDir={toggleSortDir}
211
+ colOrder={colOrder} onColOrderChange={setColOrder}
212
+ hiddenCols={hiddenCols} onToggleColVisibility={toggleColVisibility} onMoveCol={moveCol}
213
+ groupBy={groupBy} onGroupByChange={setGroupBy} primarySortKey={sortKey}
214
+ conditionalRules={conditionalRules}
215
+ onAddConditionalRule={onAddConditionalRule}
216
+ onRemoveConditionalRule={onRemoveConditionalRule}
217
+ onUpdateConditionalRule={onUpdateConditionalRule}
218
+ filterFields={filterFields}
219
+ lifecycleTabLabel="Foo" // ← change to entity name
220
+ fieldDefinitions={fieldDefinitionsForDrawer}
221
+ resolveColumnLabel={resolveColumnLabel}
222
+ displayOptions={displayOptions}
223
+ onDisplayOptionsChange={onDisplayOptionsChange}
224
+ />
225
+ </>
226
+ )
227
+ }
228
+ ```
229
+
230
+ ---
231
+
232
+ ## Full Table Component Skeleton
233
+
234
+ ```tsx
235
+ "use client"
236
+
237
+ import * as React from "react"
238
+ // ... all imports above
239
+
240
+ export interface FooTableHandle {
241
+ openPropertiesDrawer: () => void
242
+ }
243
+
244
+ export const FooTable = React.forwardRef<FooTableHandle, { items: Foo[] }>(
245
+ function FooTable({ items }, ref) {
246
+ const columns = React.useMemo(() => buildFooColumns(), [])
247
+ const filterFields = React.useMemo(() => columnsToFilterFields(columns), [columns])
248
+ const fieldDefinitionsForDrawer = React.useMemo(
249
+ () => columns.filter(c => c.key !== "select" && c.key !== "actions")
250
+ .map(c => ({ key: c.key, label: c.label, sortable: !!(c.sortable && (c.sortKey ?? c.key)) })),
251
+ [columns],
252
+ )
253
+ const resolveColumnLabel = React.useCallback((key: string) => columns.find(c => c.key === key)?.label ?? key, [columns])
254
+
255
+ const [displayOptions, setDisplayOptions] = React.useState<DataListDisplayOptions>(DEFAULT_DATA_LIST_DISPLAY_OPTIONS)
256
+ const patchDisplay = React.useCallback((patch: Partial<DataListDisplayOptions>) => setDisplayOptions(prev => ({ ...prev, ...patch })), [])
257
+
258
+ const [conditionalRules, setConditionalRules] = React.useState<ConditionalRule[]>([])
259
+ const addConditionalRule = React.useCallback((rule: Omit<ConditionalRule, "id">) => setConditionalRules(prev => [...prev, { ...rule, id: `cr-${Date.now()}` }]), [])
260
+ const removeConditionalRule = React.useCallback((id: string) => setConditionalRules(prev => prev.filter(r => r.id !== id)), [])
261
+ const updateConditionalRule = React.useCallback((id: string, patch: Partial<ConditionalRule>) => setConditionalRules(prev => prev.map(r => r.id === id ? { ...r, ...patch } : r)), [])
262
+
263
+ const tableState = useTableState(items, columns, { key: "name", dir: "asc" })
264
+
265
+ React.useImperativeHandle(ref, () => ({
266
+ openPropertiesDrawer: () => tableState.setSheetOpen(true),
267
+ }), [tableState.setSheetOpen])
268
+
269
+ return (
270
+ <div className="pb-6">
271
+ <DataTable<Foo>
272
+ data={items}
273
+ columns={columns}
274
+ getRowId={row => row.id}
275
+ getRowSelectionLabel={row => row.name}
276
+ selectable
277
+ searchable={displayOptions.showToolbarSearch}
278
+ showColumnHeaders={displayOptions.showColumnLabels}
279
+ groupable
280
+ defaultSort={{ key: "name", dir: "asc" as const }}
281
+ emptyState={<p className="text-sm text-muted-foreground">No items found.</p>}
282
+ conditionalRules={conditionalRules}
283
+ state={tableState}
284
+ toolbarSlot={s => (
285
+ <FooDrawerToolbar
286
+ state={s} totalRows={items.length} filterFields={filterFields}
287
+ fieldDefinitionsForDrawer={fieldDefinitionsForDrawer} resolveColumnLabel={resolveColumnLabel}
288
+ displayOptions={displayOptions} onDisplayOptionsChange={patchDisplay}
289
+ conditionalRules={conditionalRules} onAddConditionalRule={addConditionalRule}
290
+ onRemoveConditionalRule={removeConditionalRule} onUpdateConditionalRule={updateConditionalRule}
291
+ />
292
+ )}
293
+ bulkActionsSlot={selected => {
294
+ if (selected.size === 0) return null
295
+ return (
296
+ <>
297
+ <span className="sr-only">{selected.size} selected</span>
298
+ <Tip label="Export selection (demo)">
299
+ <Button size="sm" variant="outline" type="button">
300
+ <i className="fa-light fa-arrow-down-to-line" aria-hidden="true" />
301
+ Export
302
+ </Button>
303
+ </Tip>
304
+ </>
305
+ )
306
+ }}
307
+ />
308
+ </div>
309
+ )
310
+ }
311
+ )
312
+
313
+ FooTable.displayName = "FooTable"
314
+ ```
315
+
316
+ ---
317
+
318
+ ## ListPageTemplate Client Skeleton
319
+
320
+ ```tsx
321
+ "use client"
322
+
323
+ import * as React from "react"
324
+ import { ListPageTemplate, type ViewTab } from "@/components/templates/list-page"
325
+ import { FooPageHeader } from "@/components/foo-page-header"
326
+ import { FooTable, type FooTableHandle } from "@/components/foo-table"
327
+ import { KeyMetrics } from "@/components/key-metrics"
328
+ import { FOO_ITEMS } from "@/lib/mock/foo"
329
+ import { fooKpiInsight, fooKpiMetrics } from "@/lib/mock/foo-kpi"
330
+ import { dataListViewIcon } from "@/lib/data-list-view"
331
+ import { Button } from "@/components/ui/button"
332
+
333
+ const DEFAULT_TABS: ViewTab[] = [
334
+ { id: "all", label: "All Foos", viewType: "table", icon: "fa-table", filterId: "all" },
335
+ ]
336
+
337
+ export function FooClient() {
338
+ const [exportOpen, setExportOpen] = React.useState(false)
339
+ const [showMetrics, setShowMetrics] = React.useState(true)
340
+ const tableRef = React.useRef<FooTableHandle>(null)
341
+
342
+ const metrics = React.useMemo(() => fooKpiMetrics(FOO_ITEMS), [])
343
+ const insight = React.useMemo(() => fooKpiInsight(FOO_ITEMS), [])
344
+
345
+ return (
346
+ <ListPageTemplate
347
+ defaultTabs={DEFAULT_TABS}
348
+ getTabCount={() => FOO_ITEMS.length}
349
+ onEditView={(tab, { updateTab }) => {
350
+ const mustSwitch = tab.viewType !== "table" && tab.viewType !== "list" && tab.viewType !== "board"
351
+ if (mustSwitch) updateTab({ viewType: "table", icon: dataListViewIcon("table") })
352
+ window.setTimeout(() => tableRef.current?.openPropertiesDrawer(), mustSwitch ? 160 : 0)
353
+ }}
354
+ header={
355
+ <FooPageHeader
356
+ itemCount={FOO_ITEMS.length}
357
+ onAdd={() => {}}
358
+ onExport={() => setExportOpen(true)}
359
+ showMetrics={showMetrics}
360
+ onToggleMetrics={() => setShowMetrics(v => !v)}
361
+ />
362
+ }
363
+ metrics={<KeyMetrics variant="flat" metrics={metrics} insight={insight} showHeader={false} metricsSingleRow />}
364
+ showMetrics={showMetrics}
365
+ exportOpen={exportOpen}
366
+ onExportOpenChange={setExportOpen}
367
+ exportTotalRows={FOO_ITEMS.length}
368
+ renderContent={(tab, updateTab) => {
369
+ if (tab.viewType === "table") return <FooTable key={tab.id} ref={tableRef} items={FOO_ITEMS} />
370
+ return (
371
+ <div className="px-4 py-12 text-center lg:px-6">
372
+ <p className="text-sm font-medium text-foreground">{tab.viewType === "list" ? "List" : "Board"} view is not wired in this demo.</p>
373
+ <Button type="button" className="mt-4" size="sm" onClick={() => updateTab({ viewType: "table", icon: dataListViewIcon("table") })}>
374
+ Switch to table
375
+ </Button>
376
+ </div>
377
+ )
378
+ }}
379
+ />
380
+ )
381
+ }
382
+ ```
@@ -0,0 +1,56 @@
1
+ ---
2
+ name: exxat-mono-ids
3
+ description: Monospace typography for Exxat DS record IDs, question IDs, and system identifiers — font-mono tabular-nums, mixed subtitle lines, table meta. Use when adding or changing questionId, record id columns, header subtitles with IDs, or any copy-pasteable system key in apps/web UI.
4
+ user-invocable: true
5
+ ---
6
+
7
+ # Exxat DS — monospace IDs
8
+
9
+ **Cursor rule:** `.cursor/rules/exxat-mono-ids.mdc`
10
+ **Handbook:** `apps/web/AGENTS.md` (§1, §13 checklist)
11
+
12
+ ## Standard classes
13
+
14
+ ```tsx
15
+ // Default secondary ID (table meta, list subline, inspector)
16
+ <span className="font-mono tabular-nums text-xs text-muted-foreground">{id}</span>
17
+
18
+ // ID in a page subtitle next to sans prose
19
+ <>
20
+ <span className="font-mono tabular-nums">{questionId}</span>
21
+ {" · V1 · Last updated just now"}
22
+ </>
23
+ ```
24
+
25
+ Always include **`tabular-nums`** with **`font-mono`** so fixed-width digits align in tables and subtitles.
26
+
27
+ ## MUST
28
+
29
+ 1. **Every visible system ID** in product UI uses **`font-mono tabular-nums`** (plus contextual size/color).
30
+ 2. **Mixed lines** — mono only on the identifier substring, not the whole sentence.
31
+ 3. **Tables / lists / boards** — ID column or subline under a title: mono + usually **`text-xs text-muted-foreground`**.
32
+
33
+ ## MUST NOT
34
+
35
+ - Mono on **display names**, **emails** (unless the column is explicitly “Principal ID”), **dates**, **KPI values**, or **status text**.
36
+ - Mono on **UI chrome** that is not an identifier (badges, buttons, nav labels).
37
+
38
+ ## Reference implementations
39
+
40
+ | Surface | File |
41
+ |---------|------|
42
+ | Question bank table | `components/question-bank-table.tsx` — `row.questionId` |
43
+ | Question bank list | `components/question-bank-list-view.tsx` |
44
+ | New question subtitle | `components/new-question-composer.tsx` — `questionId` in `PageHeader` subtitle |
45
+ | Sites record id | `components/sites-table.tsx` — `row.id` |
46
+ | OS folder tiles | `components/question-bank-os-folder-view.tsx` |
47
+
48
+ ## Review checklist
49
+
50
+ - [ ] New or changed **ID** fields use **`font-mono tabular-nums`**.
51
+ - [ ] Subtitle / description lines mono-wrap **only** the ID token.
52
+ - [ ] No mono applied to names, statuses, or numeric metrics that are not identifiers.
53
+
54
+ ## See also
55
+
56
+ - `.cursor/rules/exxat-person-identity-display.mdc` — name + email (sans, not mono for email unless ID column)
@@ -15,14 +15,30 @@ user-invocable: true
15
15
  2. **`components/secondary-panel.tsx`** — add **`PANELS["<id>"]`** → panel shell (title, optional search) + secondary nav component.
16
16
  3. **Hub client** — mount **`*PanelActivator`** with **`useAutoPanel("<id>")`** (same id) for the lifetime of the route (e.g. `QuestionBankPanelActivator`).
17
17
  4. **Data** — keep **one** **`useTableState`** / **`tableState.rows`**; drive scope from **URL** + small helpers (see **`lib/question-bank-nav.ts`**).
18
- 5. **Collapse control** — the nested rail header uses **`collapseActiveSecondaryPanel()`** (angles-left icon), not “close”, so the panel stays dismissed until **`openPanel`** runs again (nav, scope hook, or hub re-entry). Layout effects that auto-call **`openPanel`** must respect **`secondaryPanelAutoReopenSuppressed`** (see **`app/(app)/question-bank/layout.tsx`** + **`SecondaryPanelProvider`**).
18
+ 5. **Folder-scoped hub header (Question bank library)** When **`scope === "folder"`** in the URL, **`QuestionBankPageHeader`** **⋯ More** includes **Customize folder**; mount **`QuestionBankNewFolderSheet`** on **`QuestionBankClient`** so it works on **all** **`ListPageTemplate`** view tabs **`.cursor/rules/exxat-question-bank-hub-header.mdc`**, **`docs/question-bank-hub-header-pattern.md`**.
19
+ 6. **Collapse control** — the nested rail header uses **`collapseActiveSecondaryPanel()`** (angles-left icon), not “close”, so the panel stays dismissed until **`openPanel`** runs again (nav, scope hook, or hub re-entry). Layout effects that auto-call **`openPanel`** must respect **`secondaryPanelAutoReopenSuppressed`** (see **`app/(app)/question-bank/layout.tsx`** + **`SecondaryPanelProvider`**).
19
20
 
20
21
  ## MUST NOT
21
22
 
22
23
  - Set **`secondaryPanel`** without **`PANELS[id]`** + **`useAutoPanel`** — broken empty rail.
23
24
  - Use this for full product areas that belong as **primary** or **collapsible child** rows.
25
+ - Invent a parallel zoom / reflow hook for the secondary rail — reuse **`useSidebarReflowZoom`** (the provider already wires it; see §"High-zoom auto-collapse" below).
26
+
27
+ ## High-zoom auto-collapse
28
+
29
+ `SecondaryPanelProvider` reads **`useSidebarReflowZoom()`** (zoom ≥ 200% or very short viewport — same WCAG 1.4.10 signal the primary sidebar uses). On entering high zoom it sets `secondaryPanelCompact = true` so the 16 rem rail drops to the 3 rem icon variant and frees up content space. The user can still re-expand manually (via the icon rail's "Show labels" affordance or any `openPanel` trigger); the next zoom-out → zoom-in cycle re-collapses it. `openPanel` itself opens directly in compact mode when high zoom is already active so newly-navigated panels don't flash expanded.
30
+
31
+ Custom panel content (anything you register under `PANELS[id]`) should **read `secondaryPanelCompact` from the provider context** and render an icon-only layout in that branch — `QuestionBankPanel` / `QuestionBankSecondaryNav` are the reference.
32
+
33
+ ## Pair with
34
+
35
+ - **Collapsible parent ↔ children sidebar pattern** (§3.2 of `exxat-ds-skill`) — when the same primary row also lists sub-routes inline.
24
36
 
25
37
  ## Reference
26
38
 
27
39
  - `components/app-sidebar.tsx` — `openPanel` on same-route primary click.
40
+ - `components/secondary-panel.tsx` — `SecondaryPanelProvider`, `PANELS` registry, **high-zoom auto-collapse**.
41
+ - `hooks/use-sidebar-reflow-zoom.ts` — shared zoom / reflow signal.
42
+ - `components/templates/nested-secondary-panel-shell.tsx` — expanded vs `compact` (icon rail) widths.
28
43
  - `components/question-bank-secondary-nav.tsx` + `lib/question-bank-nav.ts`.
44
+ - **Folder-scoped header customize:** `components/question-bank-page-header.tsx`, `components/question-bank-client.tsx` — **`docs/question-bank-hub-header-pattern.md`**, **`.cursor/rules/exxat-question-bank-hub-header.mdc`**.
@@ -2,6 +2,8 @@
2
2
 
3
3
  Shared UI for **who can access a hub** (face stack in the header) and **inviting people** (floating sheet). **Reference:** Question bank — `QuestionBankPageHeader`, `QuestionBankClient`, `InviteCollaboratorsDrawer`.
4
4
 
5
+ **Folder-scoped question bank:** When the library URL selects a folder (`?scope=folder&folderId=`), the same header **⋯ More** menu also exposes **Customize folder** (name / color / icon) via **`QuestionBankNewFolderSheet`** mounted on **`QuestionBankClient`** so it works on every view tab. See **`docs/question-bank-hub-header-pattern.md`** and **`.cursor/rules/exxat-question-bank-hub-header.mdc`**.
6
+
5
7
  ## When to use
6
8
 
7
9
  - A list hub or library is **shared** across people (not a private directory).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exxatdesignux/ui",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "description": "Exxat shared design system (components, hooks, tokens). Monorepo setup: clone repo then pnpm bootstrap at workspace root — see github.com/ExxatDesign/Exxat-DS-Workspace README.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -68,7 +68,7 @@
68
68
  "react-hook-form": "^7.72.0",
69
69
  "react-payment-inputs": "^1.2.0",
70
70
  "recharts": "^2.15.4",
71
- "shadcn": "^4.1.0",
71
+ "shadcn": "^4.7.0",
72
72
  "sonner": "^2.0.7",
73
73
  "tailwind-merge": "^3.5.0",
74
74
  "tw-animate-css": "^1.4.0",
@@ -80,7 +80,7 @@
80
80
  "@tailwindcss/postcss": "^4.2.1",
81
81
  "@types/react": "^19.2.14",
82
82
  "@types/react-dom": "^19.2.3",
83
- "postcss": "^8",
83
+ "postcss": "^8.5.14",
84
84
  "tailwindcss": "^4.2.1",
85
85
  "typescript": "^5.9.3"
86
86
  },
@@ -153,6 +153,7 @@ export function SystemBanner({
153
153
  <a
154
154
  href={action.href}
155
155
  className="inline-flex shrink-0 items-center gap-1 text-xs font-semibold underline underline-offset-2 hover:no-underline"
156
+ suppressHydrationWarning
156
157
  >
157
158
  {action.label}
158
159
  <i className="fa-light fa-arrow-right text-xs" aria-hidden="true" />
@@ -185,6 +186,7 @@ export function SystemBanner({
185
186
  ...(variant === "promo" ? { boxShadow: promoOuterShadow } : null),
186
187
  ...style,
187
188
  }}
189
+ suppressHydrationWarning
188
190
  {...props}
189
191
  >
190
192
  {decorativeOverlay ? (
@@ -69,6 +69,50 @@ function ChartContainer({
69
69
  )
70
70
  }
71
71
 
72
+ /**
73
+ * Conservative validators for the two values interpolated into a `<style>`
74
+ * block via `dangerouslySetInnerHTML`.
75
+ *
76
+ * `ChartConfig.color` is typed as a free `string` and may be authored by
77
+ * downstream consumers of `@exxatdesignux/ui` who could pass user-controlled
78
+ * data. To prevent CSS injection (escaping the property value, closing the
79
+ * block, or injecting `</style>`) we accept only a documented allowlist of
80
+ * CSS color syntaxes and reject anything that contains rule-terminating or
81
+ * markup-sensitive characters.
82
+ *
83
+ * Keys come from `ChartConfig` and become CSS custom-property names, so they
84
+ * are restricted to a safe identifier alphabet.
85
+ */
86
+ const CSS_KEY_PATTERN = /^[A-Za-z0-9_-]+$/
87
+
88
+ const SAFE_COLOR_PATTERN = new RegExp(
89
+ [
90
+ /^#[0-9a-fA-F]{3,8}$/, // #rgb / #rrggbb / #rrggbbaa
91
+ /^rgba?\([^;{}<>"'\\]*\)$/, // rgb()/rgba()
92
+ /^hsla?\([^;{}<>"'\\]*\)$/, // hsl()/hsla()
93
+ /^hwb\([^;{}<>"'\\]*\)$/, // hwb()
94
+ /^lab\([^;{}<>"'\\]*\)$/, // lab()
95
+ /^lch\([^;{}<>"'\\]*\)$/, // lch()
96
+ /^oklab\([^;{}<>"'\\]*\)$/, // oklab()
97
+ /^oklch\([^;{}<>"'\\]*\)$/, // oklch()
98
+ /^color\([^;{}<>"'\\]*\)$/, // color()
99
+ /^color-mix\([^;{}<>"'\\]*\)$/, // color-mix()
100
+ /^var\(--[A-Za-z0-9_-]+(?:\s*,[^;{}<>"'\\]+)?\)$/, // var(--token[, fallback])
101
+ /^[a-zA-Z]+$/, // named colors + currentColor/transparent
102
+ ]
103
+ .map((re) => re.source)
104
+ .join("|"),
105
+ )
106
+
107
+ function sanitizeChartColor(color: string): string | null {
108
+ const trimmed = color.trim()
109
+ if (!trimmed) return null
110
+ // Defence-in-depth: any of these characters could break out of the value
111
+ // and turn the inline `<style>` block into an injection sink.
112
+ if (/[;{}<>"'\\]/.test(trimmed)) return null
113
+ return SAFE_COLOR_PATTERN.test(trimmed) ? trimmed : null
114
+ }
115
+
72
116
  const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
73
117
  const colorConfig = Object.entries(config).filter(
74
118
  ([, config]) => config.theme || config.color
@@ -78,6 +122,13 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
78
122
  return null
79
123
  }
80
124
 
125
+ // `id` is generated from `React.useId()` in `ChartContainer`, but consumers
126
+ // can override it via the `id` prop, so we still verify the shape before
127
+ // interpolating it into a CSS selector.
128
+ if (!CSS_KEY_PATTERN.test(id)) {
129
+ return null
130
+ }
131
+
81
132
  return (
82
133
  <style
83
134
  dangerouslySetInnerHTML={{
@@ -87,11 +138,15 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
87
138
  ${prefix} [data-chart=${id}] {
88
139
  ${colorConfig
89
140
  .map(([key, itemConfig]) => {
90
- const color =
141
+ if (!CSS_KEY_PATTERN.test(key)) return null
142
+ const rawColor =
91
143
  itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
92
144
  itemConfig.color
93
- return color ? ` --color-${key}: ${color};` : null
145
+ if (!rawColor) return null
146
+ const safeColor = sanitizeChartColor(rawColor)
147
+ return safeColor ? ` --color-${key}: ${safeColor};` : null
94
148
  })
149
+ .filter(Boolean)
95
150
  .join("\n")}
96
151
  }
97
152
  `
@@ -490,6 +490,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
490
490
  <li
491
491
  data-slot="sidebar-menu-item"
492
492
  data-sidebar="menu-item"
493
+ suppressHydrationWarning
493
494
  className={cn(
494
495
  "group/menu-item relative",
495
496
  /* Icon rail: center the square menu control in the column (footer + primary). */
package/src/globals.css CHANGED
@@ -165,6 +165,19 @@ html[data-text-size="large"] {
165
165
  --brand-preview-one: var(--brand-tint);
166
166
  --brand-preview-prism: oklch(0.57 0.24 342);
167
167
 
168
+ /**
169
+ * Ask Leo panel tints (same mixes as the vertical wash). Use for blobs, cards, etc.
170
+ * `--leo-surface-gradient` is the full linear wash on `AskLeoSidebar`.
171
+ */
172
+ --leo-surface-tint-a: color-mix(in oklch, var(--brand-color) 4%, var(--background));
173
+ --leo-surface-tint-b: color-mix(in oklch, var(--brand-color) 8%, var(--background));
174
+ --leo-surface-gradient: linear-gradient(180deg, var(--leo-surface-tint-a) 0%, var(--leo-surface-tint-b) 100%);
175
+
176
+ /* KeyMetrics `variant="flat"` — soft KPI band (lavender wash → canvas; dark: subtle brand lift) */
177
+ --key-metrics-flat-grad-top: color-mix(in oklch, var(--brand-tint-light) 90%, var(--background));
178
+ --key-metrics-flat-grad-mid: color-mix(in oklch, var(--brand-tint) 34%, var(--background));
179
+ --key-metrics-flat-cell-bg: color-mix(in oklch, var(--background) 80%, var(--brand-tint-light));
180
+
168
181
  /* ── Surfaces ────────────────────────────────────────────────── */
169
182
  --background: oklch(1 0 0);
170
183
  --foreground: oklch(0.145 0 0); /* ≈ #1A1A1A — 17:1 on white ✓ */
@@ -243,7 +256,8 @@ html[data-text-size="large"] {
243
256
  --sidebar-ring: oklch(0.25 0 0);
244
257
  /* Nav section titles — ≥4.5:1 vs --sidebar (not vs page white) */
245
258
  --sidebar-section-label-foreground: color-mix(in oklch, var(--sidebar-foreground) 58%, var(--sidebar));
246
- --secondary-panel-bg: oklch(0.99 0.008 286.1);
259
+ /* Nested secondary rail — soft brand wash on canvas (not pure `--background`, not full `--sidebar`). */
260
+ --secondary-panel-bg: color-mix(in oklch, var(--brand-tint) 34%, var(--background));
247
261
 
248
262
  /* Browser UI (meta theme-color) — aligned with --brand-tint */
249
263
  --theme-color-chrome: #f3f2f8;
@@ -351,6 +365,10 @@ html[data-text-size="large"] {
351
365
  --destructive: oklch(0.65 0.20 25); /* brighter for dark bg */
352
366
  --destructive-foreground: oklch(0.10 0 0);
353
367
 
368
+ --key-metrics-flat-grad-top: color-mix(in oklch, var(--brand-color) 14%, var(--background));
369
+ --key-metrics-flat-grad-mid: color-mix(in oklch, var(--muted) 42%, var(--background));
370
+ --key-metrics-flat-cell-bg: color-mix(in oklch, var(--background) 88%, var(--brand-color));
371
+
354
372
  /* Borders — visible but not washed out on dark surfaces */
355
373
  --border: oklch(0.38 0.008 270);
356
374
  --border-control: oklch(0.72 0.012 270);
@@ -399,7 +417,8 @@ html[data-text-size="large"] {
399
417
  --sidebar-border: oklch(0.38 0.010 270);
400
418
  --sidebar-ring: oklch(0.85 0 0);
401
419
  --sidebar-section-label-foreground: color-mix(in oklch, var(--sidebar-foreground) 48%, var(--sidebar));
402
- --secondary-panel-bg: oklch(0.23 0.02 270);
420
+ /* Nested secondary rail — dark: subtle brand lift on canvas (not flat `--background` only). */
421
+ --secondary-panel-bg: color-mix(in oklch, var(--background) 82%, var(--brand-color) 18%);
403
422
  --theme-color-chrome: #2f2d36;
404
423
 
405
424
  /* Lifted scrim on dark — white-tinted veil, not heavy black */