@exxatdesignux/ui 0.2.16 → 0.2.18

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 (111) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
  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-kpi-flat-band/SKILL.md +38 -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 +19 -0
  9. package/consumer-extras/patterns/data-views-pattern.md +2 -0
  10. package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
  11. package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -0
  12. package/package.json +3 -3
  13. package/src/components/ui/banner.tsx +2 -0
  14. package/src/components/ui/chart.tsx +57 -2
  15. package/src/components/ui/sidebar.tsx +3 -2
  16. package/src/globals.css +65 -14
  17. package/src/theme.css +3 -3
  18. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  19. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
  20. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  21. package/template/AGENTS.md +27 -17
  22. package/template/app/(app)/data-list/page.tsx +2 -2
  23. package/template/app/(app)/error.tsx +22 -6
  24. package/template/app/(app)/layout.tsx +13 -6
  25. package/template/app/(app)/question-bank/layout.tsx +18 -5
  26. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  27. package/template/app/global-error.tsx +63 -0
  28. package/template/app/globals.css +151 -14
  29. package/template/app/layout.tsx +43 -5
  30. package/template/components/app-sidebar.tsx +68 -33
  31. package/template/components/ask-leo-sidebar.tsx +0 -2
  32. package/template/components/brand-color-picker.tsx +344 -0
  33. package/template/components/compliance-list-view.tsx +33 -51
  34. package/template/components/compliance-table.tsx +4 -0
  35. package/template/components/data-table/index.tsx +99 -91
  36. package/template/components/data-table/pagination.tsx +0 -1
  37. package/template/components/data-table/types.ts +4 -1
  38. package/template/components/data-table/use-table-state.ts +276 -100
  39. package/template/components/data-views/data-row-list.tsx +183 -0
  40. package/template/components/data-views/index.ts +7 -3
  41. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  42. package/template/components/dev-chunk-load-recovery.tsx +41 -0
  43. package/template/components/export-drawer.tsx +1 -1
  44. package/template/components/exxat-product-logo.tsx +168 -317
  45. package/template/components/invite-collaborators-drawer.tsx +5 -3
  46. package/template/components/key-metrics.tsx +122 -62
  47. package/template/components/new-placement-form.tsx +4 -2
  48. package/template/components/new-question-composer.tsx +2208 -0
  49. package/template/components/page-breadcrumb-trail.tsx +131 -0
  50. package/template/components/page-header.tsx +2 -1
  51. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +2 -2
  52. package/template/components/placement-detail.tsx +1 -1
  53. package/template/components/placements-board-view.tsx +1 -1
  54. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  55. package/template/components/placements-list-view.tsx +19 -133
  56. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  57. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  58. package/template/components/placements-table-columns.tsx +2 -2
  59. package/template/components/{data-list-table.tsx → placements-table.tsx} +42 -66
  60. package/template/components/product-switcher.tsx +24 -7
  61. package/template/components/product-wordmark.tsx +282 -0
  62. package/template/components/question-bank-client.tsx +20 -2
  63. package/template/components/question-bank-hub-client.tsx +105 -115
  64. package/template/components/question-bank-list-view.tsx +30 -54
  65. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  66. package/template/components/question-bank-secondary-nav.tsx +0 -3
  67. package/template/components/question-bank-table.tsx +19 -6
  68. package/template/components/rotations-empty-state.tsx +3 -0
  69. package/template/components/secondary-panel.tsx +23 -3
  70. package/template/components/settings-appearance-card.tsx +584 -141
  71. package/template/components/sidebar-shell.tsx +2 -1
  72. package/template/components/site-header.tsx +36 -31
  73. package/template/components/sites-list-view.tsx +31 -36
  74. package/template/components/sites-table.tsx +4 -0
  75. package/template/components/table-properties/drawer-button.tsx +38 -20
  76. package/template/components/table-properties/drawer.tsx +17 -14
  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 +8 -3
  80. package/template/components/templates/list-page.tsx +12 -9
  81. package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
  82. package/template/components/ui/dot-pattern.tsx +50 -26
  83. package/template/components/ui/leo-icon.tsx +23 -3
  84. package/template/contexts/product-context.tsx +70 -7
  85. package/template/contexts/system-banner-context.tsx +112 -4
  86. package/template/docs/data-views-pattern.md +2 -0
  87. package/template/docs/kpi-flat-band-pattern.md +57 -0
  88. package/template/docs/kpi-strip-max-four-pattern.md +1 -0
  89. package/template/docs/shell-surface-elevation-pattern.md +52 -0
  90. package/template/eslint.config.mjs +18 -0
  91. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  92. package/template/lib/chunk-load-error.ts +13 -0
  93. package/template/lib/conditional-rule-match.ts +87 -22
  94. package/template/lib/data-list-persistence.ts +57 -257
  95. package/template/lib/data-list-view.ts +6 -0
  96. package/template/lib/dev-log.test.ts +6 -5
  97. package/template/lib/exxat-palette.json +1462 -0
  98. package/template/lib/exxat-palette.ts +136 -0
  99. package/template/lib/list-page-table-properties.ts +1 -1
  100. package/template/lib/list-status-badges.ts +1 -1
  101. package/template/lib/mailto.ts +29 -0
  102. package/template/lib/placement-board-card-layout.ts +1 -1
  103. package/template/lib/product-brand.ts +268 -0
  104. package/template/lib/question-bank-authoring.ts +308 -0
  105. package/template/lib/question-bank-nav.ts +44 -0
  106. package/template/lib/raf-throttle.ts +45 -0
  107. package/template/lib/sidebar-state-cookie.ts +9 -0
  108. package/template/lib/table-state-lifecycle.ts +521 -0
  109. package/template/next.config.mjs +156 -0
  110. package/template/package.json +3 -3
  111. 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,38 @@
1
+ ---
2
+ name: exxat-kpi-flat-band
3
+ description: KeyMetrics variant flat — transparent KPI strip, OKLCH brand glow only, cell-border hairlines (no grid surface). Use when wiring ListPageTemplate metrics, dashboard mix KPIs, or fixing flat band looking like a grey box.
4
+ user-invocable: true
5
+ ---
6
+
7
+ # Exxat DS — KPI flat band
8
+
9
+ **Rule:** `.cursor/rules/exxat-kpi-flat-band.mdc`
10
+ **Doc:** `apps/web/docs/kpi-flat-band-pattern.md`
11
+
12
+ ## Checklist
13
+
14
+ - [ ] `variant="flat"` on hub / mix view — not `card` for list-page strip.
15
+ - [ ] `flatBandStyle` = **only** `var(--key-metrics-flat-band-radial)`; shadow **`none`**.
16
+ - [ ] No `--key-metrics-flat-band-linear` in component or hub inline `style`.
17
+ - [ ] Cells **`bg-transparent`**; grid uses **`flatMetricsHairlineClass(count, halfLayout)`** — borders only, **no** `gap-px` fill.
18
+ - [ ] **4 KPIs:** verticals between 1|2|3|4 when wide; 2×2 dividers only below `@[max-width:29.99rem]` container.
19
+ - [ ] Divider + glow tokens stay **OKLCH** (`--key-metrics-flat-divider`, `color-mix(in oklch, var(--brand-color) …)`).
20
+ - [ ] **≤ 4** `MetricItem` — `docs/kpi-strip-max-four-pattern.md`.
21
+ - [ ] KPI helpers use **`tableState.rows`** on connected hubs.
22
+
23
+ ## MUST NOT
24
+
25
+ - Grey/lavender **panel** behind metrics (removed linear wash + gap fill).
26
+ - Duplicate KPI **`Card`** wall for same numbers.
27
+ - Mute product suffix to grey in dark (`mutedSuffix` does **not** change `wordmarkColor`).
28
+
29
+ ## Code pointers
30
+
31
+ - `apps/web/components/key-metrics.tsx` — `flatMetricsHairlineClass`, `flatBandStyle`
32
+ - `apps/web/app/globals.css` — `--key-metrics-flat-*`
33
+ - `question-bank-client.tsx`, `dashboard-tabs.tsx` — reference usage
34
+
35
+ ## Pair with
36
+
37
+ - `exxat-kpi-max-four`, `exxat-kpi-trends`, `exxat-list-page-connected-views`
38
+ - `docs/shell-surface-elevation-pattern.md` — sidebar vs page (not the KPI band)
@@ -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)
@@ -17,14 +17,33 @@ user-invocable: true
17
17
  4. **Data** — keep **one** **`useTableState`** / **`tableState.rows`**; drive scope from **URL** + small helpers (see **`lib/question-bank-nav.ts`**).
18
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
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`**).
20
+ 7. **Surface elevation** — secondary panel = **level 1** (lighter than sidebar, darker than page). Use **`--secondary-panel-bg`** on **`NestedSecondaryPanelShell`**; derive from **`--brand-tint*`** per active product (**One** indigo, **Prism** rose). See **`docs/shell-surface-elevation-pattern.md`**.
20
21
 
21
22
  ## MUST NOT
22
23
 
23
24
  - Set **`secondaryPanel`** without **`PANELS[id]`** + **`useAutoPanel`** — broken empty rail.
24
25
  - Use this for full product areas that belong as **primary** or **collapsible child** rows.
26
+ - Invent a parallel zoom / reflow hook for the secondary rail — reuse **`useSidebarReflowZoom`** (the provider already wires it; see §"High-zoom auto-collapse" below).
27
+ - Set secondary panel **`bg-sidebar`** or a fixed rose mix for every product — breaks **One** indigo chrome.
28
+
29
+ ## High-zoom auto-collapse
30
+
31
+ `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.
32
+
33
+ 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.
34
+
35
+ ## Pair with
36
+
37
+ - **Collapsible parent ↔ children sidebar pattern** (§3.2 of `exxat-ds-skill`) — when the same primary row also lists sub-routes inline.
25
38
 
26
39
  ## Reference
27
40
 
28
41
  - `components/app-sidebar.tsx` — `openPanel` on same-route primary click.
42
+ - `components/secondary-panel.tsx` — `SecondaryPanelProvider`, `PANELS` registry, **high-zoom auto-collapse**.
43
+ - `hooks/use-sidebar-reflow-zoom.ts` — shared zoom / reflow signal.
44
+ - `components/templates/nested-secondary-panel-shell.tsx` — expanded vs `compact` (icon rail) widths; **`bg-[var(--secondary-panel-bg)]`**.
45
+ - `app/globals.css` — `--secondary-panel-bg`, `--sidebar`, product **`theme-one`** / **`theme-prism`** blocks.
46
+ - `contexts/product-context.tsx` — `accentOverrideActive`, theme class on `<html>`.
47
+ - **`docs/shell-surface-elevation-pattern.md`**
29
48
  - `components/question-bank-secondary-nav.tsx` + `lib/question-bank-nav.ts`.
30
49
  - **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`**.
@@ -18,6 +18,8 @@ This document describes how list pages combine **views**, **toolbar** behavior,
18
18
  | **Team page (primary template)** | `TeamClient` = `ListPageTemplate` + `KeyMetrics` + `TeamPageHeader` + `TeamTable` (same composition as `DataListClient`) | `components/team-client.tsx`, `lib/mock/team-kpi.ts` |
19
19
  | **Team roster** | `TeamTable` — `DataTable` + `useTableState` + `TablePropertiesDrawer`; list/board/dashboard read **`tableState.rows`** | `components/team-table.tsx` |
20
20
  | **Dashboard view (list tab)** | **`KeyMetrics`** (`variant="flat"` or `"card"`) — same KPI system as the template metrics strip; **do not** add ad-hoc `Card` grids for entity summaries | `TeamTable` dashboard branch, `lib/mock/team-kpi.ts` |
21
+ | **List hub metrics strip** | **`KeyMetrics variant="flat"`** — transparent cells, OKLCH brand glow only, border hairlines (**no** grey panel) | **`docs/kpi-flat-band-pattern.md`**, Placements / Team / Question bank clients |
22
+ | **Secondary panel chrome** | **`--secondary-panel-bg`** on **`NestedSecondaryPanelShell`** (lighter than sidebar, follows active product) | **`docs/shell-surface-elevation-pattern.md`**, Question bank |
21
23
  | **Export** | `ExportDrawer` | `ListPageTemplate` export props; `DataListClient` |
22
24
  | **View body layout** (gutter + centered max-width for folder / icon / panel-style content) | **`ListPageViewFrame`** (`components/data-views/list-page-view-frame.tsx`, re-exported from `components/data-views`) | **`FolderGridView`** (uses the frame); **`QuestionBankOsFolderView`** — see **`AGENTS.md` §4.5** |
23
25
 
@@ -0,0 +1,57 @@
1
+ # KPI flat band (`KeyMetrics` `variant="flat"`)
2
+
3
+ > **Component:** `components/key-metrics.tsx` — **`flatMetricsHairlineClass`**, **`flatBandStyle`**.
4
+ > **Tokens:** `app/globals.css` — `--key-metrics-flat-*`.
5
+ > **Cursor:** `.cursor/rules/exxat-kpi-flat-band.mdc` · `.cursor/skills/exxat-kpi-flat-band/SKILL.md`
6
+ > **Related:** `docs/kpi-strip-max-four-pattern.md`, `docs/kpi-trend-pattern.md`
7
+
8
+ ## Intent
9
+
10
+ List hubs and the main dashboard mix view use **`KeyMetrics variant="flat"`** as a **metrics strip without a surface**: users see KPI copy and deltas on the **page canvas**, with a **brand-colored glow** under the band only. This is **not** a card, tinted panel, or `gap-px` grid fill.
11
+
12
+ ## MUST
13
+
14
+ 1. **No band surface** — The `<section>` background is **only** `var(--key-metrics-flat-band-radial)`. **Do not** stack `--key-metrics-flat-band-linear`, opaque gradients, or `box-shadow` fills that read as a grey/lavender box.
15
+ 2. **Transparent cells** — `metricsCellSurfaceClassName` is **`bg-transparent`** for `variant="flat"`. **Do not** use `bg-background`, `bg-card`, or `gap-px` + `bg-border` / `bg-foreground/*` on the grid (that paints tile surfaces).
16
+ 3. **Hairlines = borders only** — Use **`flatMetricsHairlineClass(itemCount, metricsHalfWidthLayout)`** in `key-metrics.tsx`:
17
+ - **2 tiles:** `border-r` on the first cell only.
18
+ - **4 tiles, wide strip (default):** `border-r` on cells 1–3 (verticals between all columns); **no** horizontal rule.
19
+ - **4 tiles, narrow `@container` (&lt; 30rem, 2×2 grid):** odd-column `border-r` + `border-b` on the top row only (via `@[max-width:29.99rem]` overrides).
20
+ 4. **Divider color (OKLCH)** — `--key-metrics-flat-divider: color-mix(in oklch, var(--sidebar-border) 55%, transparent)`; apply on children with `[&>*]:border-[color:var(--key-metrics-flat-divider)]`. Dividers follow **active product** hue (`--sidebar-border`), not neutral grey alone.
21
+ 5. **Glow (OKLCH)** — Radial stops use `color-mix(in oklch, var(--brand-color) …%, transparent)` so **Exxat One / Prism / Assessment / `theme-custom`** each tint correctly. **Do not** hardcode rose/indigo literals on theme blocks unless documenting a one-off.
22
+ 6. **List page usage** — Prefer **`showHeader={false}`**, **`metricsSingleRow`** when four KPIs share one row; pass **`insight`** only when the insight rail is product-required (same row uses `lg:grid-cols-[3fr_2fr]`).
23
+ 7. **Cap at four tiles** — See **`docs/kpi-strip-max-four-pattern.md`**.
24
+
25
+ ## MUST NOT
26
+
27
+ - Add **`--key-metrics-flat-band-linear`** back into `flatBandStyle` or hub inline styles (e.g. question-bank hub hero).
28
+ - Use **`variant="card"`** on **`ListPageTemplate`** metrics when the design calls for a **flat strip** on the page background.
29
+ - Duplicate KPI numbers in ad-hoc **`Card`** grids on the same hub.
30
+ - Set **`variant="mutedSuffix"`** on product wordmarks to grey out the **suffix** in dark mode — suffix stays **Exxat pink** (`wordmarkColor`); see **`lib/product-brand.ts`**.
31
+
32
+ ## Tokens (`app/globals.css`)
33
+
34
+ | Token | Role |
35
+ |--------|------|
36
+ | `--key-metrics-flat-band-radial` | Bottom brand glow (only layer on flat `<section>`) |
37
+ | `--key-metrics-flat-band-shadow` | **`none`** for flat band (no faux surface lift) |
38
+ | `--key-metrics-flat-cell-bg` | **`transparent`** |
39
+ | `--key-metrics-flat-divider` | OKLCH hairline between cells |
40
+
41
+ Dark mode (`.dark`): same rules — transparent cells, radial glow only, no linear fill to `--background`.
42
+
43
+ ## Reference implementations
44
+
45
+ - `components/question-bank-client.tsx` — `KeyMetrics variant="flat" metricsSingleRow`
46
+ - `components/dashboard-tabs.tsx` — mix view flat band + insight
47
+ - `components/placements-client.tsx`, `team-client.tsx`, `compliance-client.tsx` — list hub metrics slot
48
+
49
+ ## Insight rail (flat + side-by-side)
50
+
51
+ When **`insight`** is shown beside KPIs, the insight **`Card`** may keep its own surface; the **KPI grid** stays transparent. **Do not** add `lg:border-l` on the insight column for flat band — the insight card ring is the separator (`key-metrics.tsx`).
52
+
53
+ ## See also
54
+
55
+ - **`docs/kpi-strip-max-four-pattern.md`**
56
+ - **`docs/kpi-trend-pattern.md`**
57
+ - **`docs/shell-surface-elevation-pattern.md`** — sidebar / secondary panel / page stack
@@ -0,0 +1,52 @@
1
+ # Shell surface elevation (sidebar · secondary panel · page)
2
+
3
+ > **Tokens:** `app/globals.css` — `--sidebar`, `--secondary-panel-bg`, `--background`, `--brand-tint*`.
4
+ > **Shell:** `components/templates/nested-secondary-panel-shell.tsx` — `bg-[var(--secondary-panel-bg)]`.
5
+ > **Cursor:** `.cursor/rules/exxat-primary-nav-secondary-panel.mdc` · `.cursor/skills/exxat-primary-nav-secondary-panel/SKILL.md`
6
+
7
+ ## Stack (back → front)
8
+
9
+ | Level | Surface | Token / class | Notes |
10
+ |-------|---------|---------------|--------|
11
+ | **0** | Primary icon rail + app chrome | `--sidebar` (= `--brand-tint` on light product themes) | Darkest brand wash in the shell |
12
+ | **1** | Nested secondary panel (Library, etc.) | `--secondary-panel-bg` | **Lighter** than level 0; **same product hue** |
13
+ | **2** | Main page / inset content | `--background` | Lightest (white canvas light; dark charcoal dark) |
14
+
15
+ **MUST** derive secondary panel fill from **`--brand-tint` / `--brand-tint-light`**, not a fixed rose or neutral grey. When the user selects **Exxat One**, both levels use **indigo hue ~286**; **Prism** uses **rose ~342**; **`theme-custom`** follows `--custom-product-brand-color` via `ProductProvider`.
16
+
17
+ ## OKLCH formulas (light)
18
+
19
+ ```css
20
+ --sidebar: var(--brand-tint);
21
+ --secondary-panel-bg: color-mix(in oklch, var(--background) 40%, var(--brand-tint-light) 60%);
22
+ ```
23
+
24
+ ## OKLCH formulas (dark)
25
+
26
+ ```css
27
+ --secondary-panel-bg: color-mix(in oklch, var(--card) 32%, var(--brand-tint) 68%);
28
+ ```
29
+
30
+ Per-product **dark** theme blocks (`.theme-one.dark`, `.theme-prism.dark`, …) set **`--brand-tint-light`** where needed so mixes stay on-hue.
31
+
32
+ ## Implementation
33
+
34
+ - **`NestedSecondaryPanelShell`** — `bg-[var(--secondary-panel-bg)]`, `ring-sidebar-border` (not generic `ring-border` alone).
35
+ - **Do not** set secondary panel to `bg-sidebar` (same as level 0 — loses elevation).
36
+ - **Do not** use `color-mix(… var(--sidebar) …)` without brand tokens if it drifts from active product theme.
37
+
38
+ ## Product theme classes
39
+
40
+ - **`theme-one`** / **`theme-prism`** / **`theme-assessment`** — built-in OKLCH brand scales in `globals.css`.
41
+ - **`theme-custom`** — when user picks an accent in Settings; driven by `--custom-product-brand-color`.
42
+ - **`ProductProvider`** — applies `theme-one` vs `theme-prism` vs `theme-custom`; accent override only when it **differs** from the product default (see `accentOverrideActive` in `contexts/product-context.tsx`).
43
+
44
+ ## Logo vs chrome
45
+
46
+ - **Chrome** (sidebar, secondary panel, KPI glow) follows **`--brand-tint` / `--brand-color`** per product.
47
+ - **Logo art** (mark + suffix) stays **Exxat pink** via `wordmarkColor` / `markGradient` in `lib/product-brand.ts` — recolouring a product in Settings changes **theme accent**, not corporate logo pink.
48
+
49
+ ## See also
50
+
51
+ - **`docs/kpi-flat-band-pattern.md`** — flat KPI strip uses brand glow only, no surface
52
+ - **`apps/web/AGENTS.md` §4.6** — secondary panel wiring
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exxatdesignux/ui",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
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
  },