@exxatdesignux/ui 0.2.16 → 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 (89) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +148 -3
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  6. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
  7. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +14 -0
  8. package/package.json +3 -3
  9. package/src/components/ui/banner.tsx +2 -0
  10. package/src/components/ui/chart.tsx +57 -2
  11. package/src/components/ui/sidebar.tsx +1 -0
  12. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  13. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  14. package/template/AGENTS.md +18 -15
  15. package/template/app/(app)/data-list/page.tsx +2 -2
  16. package/template/app/(app)/question-bank/layout.tsx +18 -5
  17. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  18. package/template/app/globals.css +108 -1
  19. package/template/app/layout.tsx +41 -5
  20. package/template/components/app-sidebar.tsx +68 -34
  21. package/template/components/ask-leo-sidebar.tsx +0 -2
  22. package/template/components/brand-color-picker.tsx +344 -0
  23. package/template/components/compliance-list-view.tsx +33 -51
  24. package/template/components/compliance-table.tsx +24 -0
  25. package/template/components/data-table/index.tsx +68 -24
  26. package/template/components/data-table/pagination.tsx +0 -1
  27. package/template/components/data-table/types.ts +4 -1
  28. package/template/components/data-table/use-table-state.ts +243 -94
  29. package/template/components/data-views/data-row-list.tsx +183 -0
  30. package/template/components/data-views/index.ts +7 -3
  31. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  32. package/template/components/export-drawer.tsx +1 -1
  33. package/template/components/exxat-product-logo.tsx +172 -317
  34. package/template/components/invite-collaborators-drawer.tsx +5 -3
  35. package/template/components/key-metrics.tsx +74 -46
  36. package/template/components/new-placement-form.tsx +4 -2
  37. package/template/components/new-question-composer.tsx +2208 -0
  38. package/template/components/page-breadcrumb-trail.tsx +131 -0
  39. package/template/components/page-header.tsx +2 -1
  40. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
  41. package/template/components/placement-detail.tsx +1 -1
  42. package/template/components/placements-board-view.tsx +1 -1
  43. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  44. package/template/components/placements-list-view.tsx +18 -132
  45. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  46. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  47. package/template/components/placements-table-columns.tsx +2 -2
  48. package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
  49. package/template/components/product-switcher.tsx +26 -8
  50. package/template/components/product-wordmark.tsx +285 -0
  51. package/template/components/question-bank-client.tsx +20 -2
  52. package/template/components/question-bank-hub-client.tsx +108 -115
  53. package/template/components/question-bank-list-view.tsx +30 -54
  54. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  55. package/template/components/question-bank-secondary-nav.tsx +0 -3
  56. package/template/components/question-bank-table.tsx +30 -5
  57. package/template/components/rotations-empty-state.tsx +3 -0
  58. package/template/components/secondary-panel.tsx +23 -3
  59. package/template/components/settings-appearance-card.tsx +584 -141
  60. package/template/components/site-header.tsx +36 -31
  61. package/template/components/sites-list-view.tsx +31 -36
  62. package/template/components/sites-table.tsx +24 -0
  63. package/template/components/table-properties/drawer.tsx +1 -1
  64. package/template/components/team-client.tsx +1 -1
  65. package/template/components/team-list-view.tsx +34 -50
  66. package/template/components/team-table.tsx +29 -3
  67. package/template/components/templates/nested-secondary-panel-shell.tsx +8 -2
  68. package/template/components/ui/dot-pattern.tsx +50 -26
  69. package/template/components/ui/leo-icon.tsx +23 -3
  70. package/template/contexts/product-context.tsx +51 -7
  71. package/template/contexts/system-banner-context.tsx +112 -4
  72. package/template/eslint.config.mjs +18 -0
  73. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  74. package/template/lib/data-list-persistence.ts +57 -257
  75. package/template/lib/dev-log.test.ts +6 -5
  76. package/template/lib/exxat-palette.json +1462 -0
  77. package/template/lib/exxat-palette.ts +136 -0
  78. package/template/lib/list-page-table-properties.ts +1 -1
  79. package/template/lib/list-status-badges.ts +1 -1
  80. package/template/lib/mailto.ts +29 -0
  81. package/template/lib/placement-board-card-layout.ts +1 -1
  82. package/template/lib/product-brand.ts +268 -0
  83. package/template/lib/question-bank-authoring.ts +308 -0
  84. package/template/lib/question-bank-nav.ts +44 -0
  85. package/template/lib/raf-throttle.ts +45 -0
  86. package/template/lib/table-state-lifecycle.ts +474 -0
  87. package/template/next.config.mjs +156 -0
  88. package/template/package.json +3 -3
  89. package/template/stores/app-store.ts +46 -1
@@ -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)
@@ -22,9 +22,23 @@ user-invocable: true
22
22
 
23
23
  - Set **`secondaryPanel`** without **`PANELS[id]`** + **`useAutoPanel`** — broken empty rail.
24
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.
25
36
 
26
37
  ## Reference
27
38
 
28
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.
29
43
  - `components/question-bank-secondary-nav.tsx` + `lib/question-bank-nav.ts`.
30
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`**.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exxatdesignux/ui",
3
- "version": "0.2.16",
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). */
@@ -21,7 +21,7 @@ description: >
21
21
  - **Stack:** Next.js 16 (App Router), React, TypeScript, Tailwind CSS, shadcn/ui primitives, Font Awesome icons
22
22
  - **App root:** `exxat-ds/app/(app)/` — route group that wraps all authenticated pages
23
23
  - **Single source of truth:** `exxat-ds/AGENTS.md` for full prose explanations; this skill is the actionable summary
24
- - **Companion skills (narrow topics):** `exxat-fontawesome-icons`, `exxat-primary-nav-secondary-panel`, `exxat-centralized-list-dataset`, `exxat-list-page-view-shells`, `exxat-dedicated-search-surfaces`, `exxat-accessibility`, `exxat-board-cards`, `exxat-collaboration-access` — live under `.cursor/skills/`; vetted copies ship with **`@exxatdesignux/ui`** in `consumer-extras/cursor-skills/` after **`pnpm --filter @exxatdesignux/ui vendor:consumer-extras`**.
24
+ - **Companion skills (narrow topics):** `exxat-fontawesome-icons`, `exxat-mono-ids`, `exxat-primary-nav-secondary-panel`, `exxat-centralized-list-dataset`, `exxat-list-page-view-shells`, `exxat-dedicated-search-surfaces`, `exxat-accessibility`, `exxat-board-cards`, `exxat-collaboration-access` — live under `.cursor/skills/`; vetted copies ship with **`@exxatdesignux/ui`** in `consumer-extras/cursor-skills/` after **`pnpm --filter @exxatdesignux/ui vendor:consumer-extras`**.
25
25
 
26
26
  ---
27
27
 
@@ -0,0 +1,30 @@
1
+ ---
2
+ description: Exxat DS — monospace typography for record IDs, question IDs, and other system identifiers
3
+ globs: apps/web/**/*.tsx
4
+ alwaysApply: false
5
+ ---
6
+
7
+ # Exxat DS — monospace IDs
8
+
9
+ Use this when rendering **system identifiers** — values a user copies, searches, or matches in APIs and tables (not human-readable names or prose).
10
+
11
+ ## MUST
12
+
13
+ 1. **Class** — Wrap identifier text in **`font-mono tabular-nums`**. Add size/color from context: typically **`text-xs text-muted-foreground`** (secondary line, table meta) or **`text-sm`** when the ID is the primary label in a narrow cell.
14
+ 2. **What counts as an ID** — Question IDs (`questionId`, `Q-YYMM-XXXX`), record/entity keys shown in UI, folder/surface technical keys when displayed as identifiers, hex tokens in pickers, audit/log principals, site/row **`id`** columns meant for lookup.
15
+ 3. **Mixed lines** — When an ID sits beside prose (e.g. page subtitle), only the ID segment is mono; keep separators and labels in the default sans stack.
16
+
17
+ ## SHOULD
18
+
19
+ - Match existing hubs: **`question-bank-table.tsx`**, **`question-bank-list-view.tsx`**, **`new-question-composer.tsx`** (header subtitle), **`sites-table.tsx`** (`row.id`).
20
+ - Prefer **`truncate`** / **`min-w-0`** on mono IDs in tight layouts so long tokens do not blow out columns.
21
+
22
+ ## MUST NOT
23
+
24
+ - Apply **`font-mono`** to **person names**, **folder display names**, **status labels**, **dates**, **counts**, **currency**, or **body copy** — only the identifier token.
25
+ - Use mono for **option letters** (A/B/C) or **step numbers** unless they are literal system IDs.
26
+
27
+ ## See also
28
+
29
+ - **`.cursor/skills/exxat-mono-ids/SKILL.md`**
30
+ - **`apps/web/AGENTS.md`** — §1 item on IDs, §13 checklist