@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.
- package/CHANGELOG.md +23 -0
- package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -1
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +151 -3
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
- package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +17 -1
- package/consumer-extras/patterns/collaboration-access-pattern.md +2 -0
- package/package.json +3 -3
- package/src/components/ui/banner.tsx +2 -0
- package/src/components/ui/chart.tsx +57 -2
- package/src/components/ui/sidebar.tsx +1 -0
- package/src/globals.css +21 -2
- package/src/theme.css +4 -2
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
- package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
- package/template/AGENTS.md +23 -18
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/question-bank/layout.tsx +27 -7
- package/template/app/(app)/question-bank/new/page.tsx +58 -0
- package/template/app/globals.css +136 -2
- package/template/app/layout.tsx +41 -5
- package/template/components/app-sidebar.tsx +141 -59
- package/template/components/ask-leo-sidebar.tsx +1 -4
- package/template/components/brand-color-picker.tsx +344 -0
- package/template/components/compliance-list-view.tsx +33 -51
- package/template/components/compliance-table.tsx +24 -0
- package/template/components/data-table/index.tsx +68 -24
- package/template/components/data-table/pagination.tsx +0 -1
- package/template/components/data-table/types.ts +4 -1
- package/template/components/data-table/use-table-state.ts +243 -94
- package/template/components/data-views/data-row-list.tsx +183 -0
- package/template/components/data-views/finder-panel-view.tsx +2 -2
- package/template/components/data-views/index.ts +26 -3
- package/template/components/data-views/list-page-split-details-placeholder.tsx +3 -3
- package/template/components/data-views/list-page-split-hub-tokens.ts +1 -1
- package/template/components/data-views/list-page-tree-column-header.tsx +1 -1
- package/template/components/data-views/os-folder-glyph.tsx +8 -0
- package/template/components/data-views/outline-tree-menu.tsx +157 -0
- package/template/components/data-views/question-bank-folder-tree-branch.tsx +210 -0
- package/template/components/export-drawer.tsx +1 -1
- package/template/components/exxat-product-logo.tsx +173 -379
- package/template/components/folder-details-shell.tsx +1 -1
- package/template/components/hub-tree-panel-view.tsx +88 -80
- package/template/components/invite-collaborators-drawer.tsx +5 -3
- package/template/components/key-metrics.tsx +116 -51
- package/template/components/new-placement-form.tsx +4 -2
- package/template/components/new-question-composer.tsx +2208 -0
- package/template/components/page-breadcrumb-trail.tsx +131 -0
- package/template/components/page-header.tsx +21 -11
- package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
- package/template/components/placement-detail.tsx +1 -1
- package/template/components/placements-board-view.tsx +1 -1
- package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
- package/template/components/placements-list-view.tsx +18 -132
- package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
- package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
- package/template/components/placements-table-columns.tsx +2 -2
- package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
- package/template/components/product-switcher.tsx +26 -11
- package/template/components/product-wordmark.tsx +285 -0
- package/template/components/question-bank-client.tsx +130 -70
- package/template/components/question-bank-hub-client.tsx +108 -115
- package/template/components/question-bank-list-view.tsx +30 -54
- package/template/components/question-bank-new-folder-sheet.tsx +1 -1
- package/template/components/question-bank-page-header.tsx +18 -2
- package/template/components/question-bank-secondary-nav.tsx +12 -228
- package/template/components/question-bank-table.tsx +30 -5
- package/template/components/rotations-empty-state.tsx +3 -0
- package/template/components/secondary-panel.tsx +24 -4
- package/template/components/settings-appearance-card.tsx +584 -141
- package/template/components/site-header.tsx +56 -32
- package/template/components/sites-list-view.tsx +31 -36
- package/template/components/sites-table.tsx +24 -0
- package/template/components/table-properties/drawer.tsx +1 -1
- package/template/components/team-client.tsx +1 -1
- package/template/components/team-list-view.tsx +34 -50
- package/template/components/team-table.tsx +29 -3
- package/template/components/templates/dedicated-search-landing-template.tsx +86 -20
- package/template/components/templates/list-page.tsx +1 -3
- package/template/components/templates/nested-secondary-panel-shell.tsx +11 -6
- package/template/components/ui/dot-pattern.tsx +50 -26
- package/template/components/ui/leo-icon.tsx +23 -3
- package/template/contexts/product-context.tsx +51 -7
- package/template/contexts/system-banner-context.tsx +112 -4
- package/template/docs/collaboration-access-pattern.md +2 -0
- package/template/docs/question-bank-hub-header-pattern.md +25 -0
- package/template/eslint.config.mjs +18 -0
- package/template/hooks/use-secondary-panel-hub-nav.ts +17 -1
- package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
- package/template/lib/data-list-persistence.ts +57 -257
- package/template/lib/dev-log.test.ts +6 -5
- package/template/lib/exxat-palette.json +1462 -0
- package/template/lib/exxat-palette.ts +136 -0
- package/template/lib/list-page-table-properties.ts +1 -1
- package/template/lib/list-status-badges.ts +1 -1
- package/template/lib/mailto.ts +29 -0
- package/template/lib/mock/navigation.tsx +30 -1
- package/template/lib/placement-board-card-layout.ts +1 -1
- package/template/lib/product-brand.ts +268 -0
- package/template/lib/question-bank-authoring.ts +308 -0
- package/template/lib/question-bank-nav.ts +70 -0
- package/template/lib/raf-throttle.ts +45 -0
- package/template/lib/table-state-lifecycle.ts +474 -0
- package/template/next.config.mjs +156 -0
- package/template/package.json +6 -6
- package/template/stores/app-store.ts +46 -1
- package/template/components/command-menu-01.tsx +0 -133
- 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. **
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 */
|