@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.
- package/CHANGELOG.md +26 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
- 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-kpi-flat-band/SKILL.md +38 -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 +19 -0
- package/consumer-extras/patterns/data-views-pattern.md +2 -0
- package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
- package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -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 +3 -2
- package/src/globals.css +65 -14
- package/src/theme.css +3 -3
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
- package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
- package/template/AGENTS.md +27 -17
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/error.tsx +22 -6
- package/template/app/(app)/layout.tsx +13 -6
- package/template/app/(app)/question-bank/layout.tsx +18 -5
- package/template/app/(app)/question-bank/new/page.tsx +58 -0
- package/template/app/global-error.tsx +63 -0
- package/template/app/globals.css +151 -14
- package/template/app/layout.tsx +43 -5
- package/template/components/app-sidebar.tsx +68 -33
- package/template/components/ask-leo-sidebar.tsx +0 -2
- package/template/components/brand-color-picker.tsx +344 -0
- package/template/components/compliance-list-view.tsx +33 -51
- package/template/components/compliance-table.tsx +4 -0
- package/template/components/data-table/index.tsx +99 -91
- 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 +276 -100
- package/template/components/data-views/data-row-list.tsx +183 -0
- package/template/components/data-views/index.ts +7 -3
- package/template/components/data-views/os-folder-glyph.tsx +8 -0
- package/template/components/dev-chunk-load-recovery.tsx +41 -0
- package/template/components/export-drawer.tsx +1 -1
- package/template/components/exxat-product-logo.tsx +168 -317
- package/template/components/invite-collaborators-drawer.tsx +5 -3
- package/template/components/key-metrics.tsx +122 -62
- 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 +2 -1
- package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +2 -2
- 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 +19 -133
- 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} +42 -66
- package/template/components/product-switcher.tsx +24 -7
- package/template/components/product-wordmark.tsx +282 -0
- package/template/components/question-bank-client.tsx +20 -2
- package/template/components/question-bank-hub-client.tsx +105 -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-secondary-nav.tsx +0 -3
- package/template/components/question-bank-table.tsx +19 -6
- package/template/components/rotations-empty-state.tsx +3 -0
- package/template/components/secondary-panel.tsx +23 -3
- package/template/components/settings-appearance-card.tsx +584 -141
- package/template/components/sidebar-shell.tsx +2 -1
- package/template/components/site-header.tsx +36 -31
- package/template/components/sites-list-view.tsx +31 -36
- package/template/components/sites-table.tsx +4 -0
- package/template/components/table-properties/drawer-button.tsx +38 -20
- package/template/components/table-properties/drawer.tsx +17 -14
- package/template/components/team-client.tsx +1 -1
- package/template/components/team-list-view.tsx +34 -50
- package/template/components/team-table.tsx +8 -3
- package/template/components/templates/list-page.tsx +12 -9
- package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
- package/template/components/ui/dot-pattern.tsx +50 -26
- package/template/components/ui/leo-icon.tsx +23 -3
- package/template/contexts/product-context.tsx +70 -7
- package/template/contexts/system-banner-context.tsx +112 -4
- package/template/docs/data-views-pattern.md +2 -0
- package/template/docs/kpi-flat-band-pattern.md +57 -0
- package/template/docs/kpi-strip-max-four-pattern.md +1 -0
- package/template/docs/shell-surface-elevation-pattern.md +52 -0
- package/template/eslint.config.mjs +18 -0
- package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
- package/template/lib/chunk-load-error.ts +13 -0
- package/template/lib/conditional-rule-match.ts +87 -22
- package/template/lib/data-list-persistence.ts +57 -257
- package/template/lib/data-list-view.ts +6 -0
- 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/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 +44 -0
- package/template/lib/raf-throttle.ts +45 -0
- package/template/lib/sidebar-state-cookie.ts +9 -0
- package/template/lib/table-state-lifecycle.ts +521 -0
- package/template/next.config.mjs +156 -0
- package/template/package.json +3 -3
- package/template/stores/app-store.ts +46 -1
|
@@ -11,11 +11,27 @@ import {
|
|
|
11
11
|
SelectTrigger,
|
|
12
12
|
SelectValue,
|
|
13
13
|
} from "@/components/ui/select"
|
|
14
|
+
import { Button } from "@/components/ui/button"
|
|
15
|
+
import {
|
|
16
|
+
Dialog,
|
|
17
|
+
DialogContent,
|
|
18
|
+
DialogDescription,
|
|
19
|
+
DialogFooter,
|
|
20
|
+
DialogHeader,
|
|
21
|
+
DialogTitle,
|
|
22
|
+
} from "@/components/ui/dialog"
|
|
23
|
+
import { Input } from "@/components/ui/input"
|
|
14
24
|
import { SelectionTileGrid } from "@/components/ui/selection-tile-grid"
|
|
15
25
|
import { useAppTheme, type Brand, type TextSizePreference } from "@/hooks/use-app-theme"
|
|
16
26
|
import { useDashboardView, type DashboardView } from "@/contexts/dashboard-view-context"
|
|
17
27
|
import { useChartVariant, type ChartVariant } from "@/contexts/chart-variant-context"
|
|
18
28
|
import { SettingsFormRow } from "@/components/settings-form-row"
|
|
29
|
+
import { BrandColorPicker } from "@/components/brand-color-picker"
|
|
30
|
+
import { ExxatProductLogo } from "@/components/exxat-product-logo"
|
|
31
|
+
import { useProduct } from "@/contexts/product-context"
|
|
32
|
+
import { DEFAULT_CUSTOM_PRODUCT_BRAND, type Product } from "@/stores/app-store"
|
|
33
|
+
import { brandForProduct, getProductBrand, customProductBrandConfig } from "@/lib/product-brand"
|
|
34
|
+
import { Tip } from "@/components/ui/tip"
|
|
19
35
|
import { cn } from "@/lib/utils"
|
|
20
36
|
|
|
21
37
|
function RadioRow({
|
|
@@ -43,13 +59,21 @@ function RadioRow({
|
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
/** Illustrative split sidebars when “system” shows light+dark (tokens follow active brand hue). */
|
|
46
|
-
const SPLIT_SIDEBAR: Record<Brand, { light: string; dark: string }> = {
|
|
47
|
-
one: {
|
|
48
|
-
|
|
62
|
+
const SPLIT_SIDEBAR: Record<Brand, { light: string; dark: string; markLight: string; markDark: string }> = {
|
|
63
|
+
one: {
|
|
64
|
+
light: "oklch(0.935 0.024 286.1)",
|
|
65
|
+
dark: "oklch(0.32 0.085 286.1)",
|
|
66
|
+
markLight: "oklch(0.58 0.18 286.1)",
|
|
67
|
+
markDark: "oklch(0.72 0.18 286.1)",
|
|
68
|
+
},
|
|
69
|
+
prism: {
|
|
70
|
+
light: "oklch(0.96 0.04 342)",
|
|
71
|
+
dark: "oklch(0.34 0.13 342)",
|
|
72
|
+
markLight: "oklch(0.62 0.21 342)",
|
|
73
|
+
markDark: "oklch(0.78 0.18 342)",
|
|
74
|
+
},
|
|
49
75
|
}
|
|
50
76
|
|
|
51
|
-
const THEME_SVG_CLASS = "h-12 w-auto max-w-[9rem] shrink-0"
|
|
52
|
-
|
|
53
77
|
/** Fills the square preview in Settings appearance tiles (see SelectionTileGraphic below-mode sizing). */
|
|
54
78
|
const APPEARANCE_TILE_SVG = "block h-full w-auto max-h-full max-w-full shrink-0 object-contain"
|
|
55
79
|
|
|
@@ -57,55 +81,234 @@ const APPEARANCE_TILE_SVG = "block h-full w-auto max-h-full max-w-full shrink-0
|
|
|
57
81
|
const CHROME_LIGHT = {
|
|
58
82
|
shell: "oklch(1 0 0)",
|
|
59
83
|
shellStroke: "oklch(0.90 0.003 270)",
|
|
60
|
-
headerBar: "oklch(0.
|
|
61
|
-
|
|
84
|
+
headerBar: "oklch(0.96 0.004 270)",
|
|
85
|
+
headerStroke: "oklch(0.92 0.003 270)",
|
|
86
|
+
content: "oklch(0.985 0.003 270)",
|
|
87
|
+
card: "oklch(1 0 0)",
|
|
88
|
+
cardStroke: "oklch(0.91 0.003 270)",
|
|
89
|
+
navRow: "oklch(0.86 0.012 270)",
|
|
90
|
+
pill: "oklch(0.94 0.003 270)",
|
|
91
|
+
windowRed: "#FF5F57",
|
|
92
|
+
windowYellow: "#FEBC2E",
|
|
93
|
+
windowGreen: "#28C840",
|
|
62
94
|
} as const
|
|
63
95
|
|
|
64
96
|
const CHROME_DARK = {
|
|
65
|
-
shell: "oklch(0.
|
|
66
|
-
shellStroke: "oklch(0.
|
|
67
|
-
headerBar: "oklch(0.
|
|
68
|
-
|
|
97
|
+
shell: "oklch(0.13 0.01 270)",
|
|
98
|
+
shellStroke: "oklch(0.32 0.015 270)",
|
|
99
|
+
headerBar: "oklch(0.19 0.013 270)",
|
|
100
|
+
headerStroke: "oklch(0.28 0.013 270)",
|
|
101
|
+
content: "oklch(0.155 0.012 270)",
|
|
102
|
+
card: "oklch(0.20 0.013 270)",
|
|
103
|
+
cardStroke: "oklch(0.32 0.013 270)",
|
|
104
|
+
navRow: "oklch(0.42 0.014 270)",
|
|
105
|
+
pill: "oklch(0.25 0.013 270)",
|
|
106
|
+
windowRed: "#FF5F57",
|
|
107
|
+
windowYellow: "#FEBC2E",
|
|
108
|
+
windowGreen: "#28C840",
|
|
69
109
|
} as const
|
|
70
110
|
|
|
111
|
+
type ChromeTokens = { -readonly [K in keyof typeof CHROME_LIGHT]: string }
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Reusable mini-chrome — Mac-style traffic lights, sidebar with brand-tinted
|
|
115
|
+
* mark + nav rows, header bar with search pill + avatar, three content cards
|
|
116
|
+
* (KPI tile, mini bar chart, list rows). Coordinates are scoped to a fixed
|
|
117
|
+
* 96×56 viewBox so the System mode can place two side-by-side via `<g transform>`.
|
|
118
|
+
*
|
|
119
|
+
* `strokeBoost` thickens every border for the high-contrast variants without
|
|
120
|
+
* forking the whole illustration.
|
|
121
|
+
*/
|
|
122
|
+
function ChromeIllustration({
|
|
123
|
+
tokens,
|
|
124
|
+
sidebar,
|
|
125
|
+
sidebarMark,
|
|
126
|
+
strokeBoost = 1,
|
|
127
|
+
contentAccent,
|
|
128
|
+
}: {
|
|
129
|
+
tokens: ChromeTokens
|
|
130
|
+
sidebar: string
|
|
131
|
+
sidebarMark: string
|
|
132
|
+
strokeBoost?: number
|
|
133
|
+
contentAccent?: string
|
|
134
|
+
}) {
|
|
135
|
+
const sw = (n: number) => n * strokeBoost
|
|
136
|
+
return (
|
|
137
|
+
<g>
|
|
138
|
+
<rect
|
|
139
|
+
x={0.6}
|
|
140
|
+
y={0.6}
|
|
141
|
+
width={94.8}
|
|
142
|
+
height={54.8}
|
|
143
|
+
rx={5}
|
|
144
|
+
fill={tokens.shell}
|
|
145
|
+
stroke={tokens.shellStroke}
|
|
146
|
+
strokeWidth={sw(1)}
|
|
147
|
+
/>
|
|
148
|
+
|
|
149
|
+
{/* Mac-style traffic lights */}
|
|
150
|
+
<circle cx={5.5} cy={5.5} r={1.2} fill={tokens.windowRed} />
|
|
151
|
+
<circle cx={9} cy={5.5} r={1.2} fill={tokens.windowYellow} />
|
|
152
|
+
<circle cx={12.5} cy={5.5} r={1.2} fill={tokens.windowGreen} />
|
|
153
|
+
<line
|
|
154
|
+
x1={0.6}
|
|
155
|
+
y1={9.5}
|
|
156
|
+
x2={95.4}
|
|
157
|
+
y2={9.5}
|
|
158
|
+
stroke={tokens.shellStroke}
|
|
159
|
+
strokeWidth={sw(0.5)}
|
|
160
|
+
/>
|
|
161
|
+
|
|
162
|
+
{/* Sidebar */}
|
|
163
|
+
<rect x={2.5} y={11.5} width={20} height={42} rx={2.5} fill={sidebar} />
|
|
164
|
+
{/* Brand-tinted product mark + faux team name */}
|
|
165
|
+
<circle cx={6.5} cy={15.5} r={2.2} fill={sidebarMark} />
|
|
166
|
+
<rect x={10} y={14.3} width={9} height={1.2} rx={0.5} fill={tokens.navRow} opacity={0.6} />
|
|
167
|
+
<rect x={10} y={16.4} width={6.5} height={1} rx={0.5} fill={tokens.navRow} opacity={0.45} />
|
|
168
|
+
|
|
169
|
+
{/* Active nav row + 4 inactive rows */}
|
|
170
|
+
<rect x={4.5} y={21.5} width={16} height={2.4} rx={0.8} fill={sidebarMark} opacity={0.85} />
|
|
171
|
+
<rect x={4.5} y={25.5} width={14} height={2.2} rx={0.8} fill={tokens.navRow} opacity={0.7} />
|
|
172
|
+
<rect x={4.5} y={29.2} width={15} height={2.2} rx={0.8} fill={tokens.navRow} opacity={0.55} />
|
|
173
|
+
<rect x={4.5} y={32.9} width={12} height={2.2} rx={0.8} fill={tokens.navRow} opacity={0.5} />
|
|
174
|
+
<rect x={4.5} y={36.6} width={13} height={2.2} rx={0.8} fill={tokens.navRow} opacity={0.45} />
|
|
175
|
+
|
|
176
|
+
{/* Header bar: search pill + avatar */}
|
|
177
|
+
<rect
|
|
178
|
+
x={25}
|
|
179
|
+
y={12}
|
|
180
|
+
width={67}
|
|
181
|
+
height={6}
|
|
182
|
+
rx={1.5}
|
|
183
|
+
fill={tokens.headerBar}
|
|
184
|
+
stroke={tokens.headerStroke}
|
|
185
|
+
strokeWidth={sw(0.5)}
|
|
186
|
+
/>
|
|
187
|
+
<rect x={27} y={13.7} width={28} height={2.6} rx={1.3} fill={tokens.pill} />
|
|
188
|
+
<circle cx={89.5} cy={15} r={1.4} fill={sidebarMark} opacity={0.85} />
|
|
189
|
+
|
|
190
|
+
{/* KPI card */}
|
|
191
|
+
<rect
|
|
192
|
+
x={25}
|
|
193
|
+
y={20}
|
|
194
|
+
width={31.5}
|
|
195
|
+
height={14}
|
|
196
|
+
rx={2}
|
|
197
|
+
fill={tokens.card}
|
|
198
|
+
stroke={tokens.cardStroke}
|
|
199
|
+
strokeWidth={sw(0.5)}
|
|
200
|
+
/>
|
|
201
|
+
<rect x={27.5} y={22.5} width={10} height={1.5} rx={0.6} fill={tokens.navRow} opacity={0.5} />
|
|
202
|
+
<rect x={27.5} y={26.5} width={14} height={4} rx={0.8} fill={contentAccent ?? sidebarMark} opacity={0.85} />
|
|
203
|
+
|
|
204
|
+
{/* Bar-chart card */}
|
|
205
|
+
<rect
|
|
206
|
+
x={60.5}
|
|
207
|
+
y={20}
|
|
208
|
+
width={31.5}
|
|
209
|
+
height={14}
|
|
210
|
+
rx={2}
|
|
211
|
+
fill={tokens.card}
|
|
212
|
+
stroke={tokens.cardStroke}
|
|
213
|
+
strokeWidth={sw(0.5)}
|
|
214
|
+
/>
|
|
215
|
+
<rect x={63} y={22.5} width={10} height={1.5} rx={0.6} fill={tokens.navRow} opacity={0.5} />
|
|
216
|
+
<rect x={63} y={30} width={2.5} height={3} rx={0.4} fill={contentAccent ?? sidebarMark} opacity={0.85} />
|
|
217
|
+
<rect x={67} y={28} width={2.5} height={5} rx={0.4} fill={contentAccent ?? sidebarMark} opacity={0.85} />
|
|
218
|
+
<rect x={71} y={26.5} width={2.5} height={6.5} rx={0.4} fill={contentAccent ?? sidebarMark} opacity={0.85} />
|
|
219
|
+
<rect x={75} y={29} width={2.5} height={4} rx={0.4} fill={contentAccent ?? sidebarMark} opacity={0.85} />
|
|
220
|
+
<rect x={79} y={27} width={2.5} height={6} rx={0.4} fill={contentAccent ?? sidebarMark} opacity={0.85} />
|
|
221
|
+
|
|
222
|
+
{/* List card */}
|
|
223
|
+
<rect
|
|
224
|
+
x={25}
|
|
225
|
+
y={37}
|
|
226
|
+
width={67}
|
|
227
|
+
height={14}
|
|
228
|
+
rx={2}
|
|
229
|
+
fill={tokens.card}
|
|
230
|
+
stroke={tokens.cardStroke}
|
|
231
|
+
strokeWidth={sw(0.5)}
|
|
232
|
+
/>
|
|
233
|
+
<rect x={27.5} y={39.5} width={12} height={1.5} rx={0.6} fill={tokens.navRow} opacity={0.5} />
|
|
234
|
+
<rect x={27.5} y={43} width={62} height={1.6} rx={0.6} fill={tokens.navRow} opacity={0.32} />
|
|
235
|
+
<rect x={27.5} y={45.8} width={62} height={1.6} rx={0.6} fill={tokens.navRow} opacity={0.32} />
|
|
236
|
+
<rect x={27.5} y={48.6} width={62} height={1.6} rx={0.6} fill={tokens.navRow} opacity={0.32} />
|
|
237
|
+
</g>
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
71
241
|
/** Mini browser chrome: illustrative light / dark / split (brand sidebars from SPLIT_SIDEBAR). */
|
|
242
|
+
/**
|
|
243
|
+
* One window, two halves — render the full chrome twice in the same SVG, each
|
|
244
|
+
* clipped to a triangular half by a diagonal that runs from the top-right
|
|
245
|
+
* corner to the bottom-left corner. Light fills the top-left triangle, dark
|
|
246
|
+
* fills the bottom-right triangle. Because both halves use identical geometry
|
|
247
|
+
* inside one viewBox, the result reads as a single window with a diagonal
|
|
248
|
+
* theme split (macOS / iOS "Auto" pattern) rather than two adjacent windows.
|
|
249
|
+
*/
|
|
250
|
+
function SplitSystemSvg({
|
|
251
|
+
light,
|
|
252
|
+
dark,
|
|
253
|
+
}: {
|
|
254
|
+
light: { tokens: ChromeTokens; sidebar: string; sidebarMark: string; strokeBoost?: number; contentAccent?: string }
|
|
255
|
+
dark: { tokens: ChromeTokens; sidebar: string; sidebarMark: string; strokeBoost?: number; contentAccent?: string }
|
|
256
|
+
}) {
|
|
257
|
+
// useId keeps clipPath ids unique across tile instances on the same page.
|
|
258
|
+
const baseId = React.useId().replace(/:/g, "")
|
|
259
|
+
const lightId = `chrome-split-light-${baseId}`
|
|
260
|
+
const darkId = `chrome-split-dark-${baseId}`
|
|
261
|
+
return (
|
|
262
|
+
<svg className={APPEARANCE_TILE_SVG} viewBox="0 0 96 56" fill="none" aria-hidden="true">
|
|
263
|
+
<defs>
|
|
264
|
+
<clipPath id={lightId}>
|
|
265
|
+
{/* Top-left triangle — top edge + left edge + diagonal to bottom-left. */}
|
|
266
|
+
<polygon points="0,0 96,0 0,56" />
|
|
267
|
+
</clipPath>
|
|
268
|
+
<clipPath id={darkId}>
|
|
269
|
+
{/* Bottom-right triangle — right edge + bottom edge + diagonal. */}
|
|
270
|
+
<polygon points="96,0 96,56 0,56" />
|
|
271
|
+
</clipPath>
|
|
272
|
+
</defs>
|
|
273
|
+
<g clipPath={`url(#${lightId})`}>
|
|
274
|
+
<ChromeIllustration {...light} />
|
|
275
|
+
</g>
|
|
276
|
+
<g clipPath={`url(#${darkId})`}>
|
|
277
|
+
<ChromeIllustration {...dark} />
|
|
278
|
+
</g>
|
|
279
|
+
</svg>
|
|
280
|
+
)
|
|
281
|
+
}
|
|
282
|
+
|
|
72
283
|
function ThemeModeSvg({ mode, brand }: { mode: "system" | "light" | "dark"; brand: Brand }) {
|
|
73
284
|
const split = SPLIT_SIDEBAR[brand]
|
|
285
|
+
|
|
74
286
|
if (mode === "light") {
|
|
75
287
|
return (
|
|
76
|
-
<svg className={APPEARANCE_TILE_SVG} viewBox="0 0 56
|
|
77
|
-
<
|
|
78
|
-
<rect x="3" y="3" width="13" height="26" rx="2" fill={split.light} />
|
|
79
|
-
<rect x="19" y="6" width="34" height="4.5" rx="1" fill={CHROME_LIGHT.headerBar} />
|
|
80
|
-
<rect x="19" y="14" width="34" height="14" rx="2" fill={CHROME_LIGHT.content} />
|
|
288
|
+
<svg className={APPEARANCE_TILE_SVG} viewBox="0 0 96 56" fill="none" aria-hidden="true">
|
|
289
|
+
<ChromeIllustration tokens={CHROME_LIGHT} sidebar={split.light} sidebarMark={split.markLight} />
|
|
81
290
|
</svg>
|
|
82
291
|
)
|
|
83
292
|
}
|
|
293
|
+
|
|
84
294
|
if (mode === "dark") {
|
|
85
295
|
return (
|
|
86
|
-
<svg className={APPEARANCE_TILE_SVG} viewBox="0 0 56
|
|
87
|
-
<
|
|
88
|
-
<rect x="3" y="3" width="13" height="26" rx="2" fill={split.dark} />
|
|
89
|
-
<rect x="19" y="6" width="34" height="4.5" rx="1" fill={CHROME_DARK.headerBar} />
|
|
90
|
-
<rect x="19" y="14" width="34" height="14" rx="2" fill={CHROME_DARK.content} />
|
|
296
|
+
<svg className={APPEARANCE_TILE_SVG} viewBox="0 0 96 56" fill="none" aria-hidden="true">
|
|
297
|
+
<ChromeIllustration tokens={CHROME_DARK} sidebar={split.dark} sidebarMark={split.markDark} />
|
|
91
298
|
</svg>
|
|
92
299
|
)
|
|
93
300
|
}
|
|
301
|
+
|
|
302
|
+
// System: one window with a diagonal light↔dark split inside.
|
|
94
303
|
return (
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
<rect x="15" y="13" width="10" height="14" rx="1.5" fill={CHROME_LIGHT.content} />
|
|
100
|
-
<rect x="28.5" y="0.5" width="27" height="31" rx="4" fill={CHROME_DARK.shell} stroke={CHROME_DARK.shellStroke} />
|
|
101
|
-
<rect x="31" y="3" width="10" height="26" rx="1.5" fill={split.dark} />
|
|
102
|
-
<rect x="43" y="6" width="10" height="4" rx="0.75" fill={CHROME_DARK.headerBar} />
|
|
103
|
-
<rect x="43" y="13" width="10" height="14" rx="1.5" fill={CHROME_DARK.content} />
|
|
104
|
-
</svg>
|
|
304
|
+
<SplitSystemSvg
|
|
305
|
+
light={{ tokens: CHROME_LIGHT, sidebar: split.light, sidebarMark: split.markLight }}
|
|
306
|
+
dark={{ tokens: CHROME_DARK, sidebar: split.dark, sidebarMark: split.markDark }}
|
|
307
|
+
/>
|
|
105
308
|
)
|
|
106
309
|
}
|
|
107
310
|
|
|
108
|
-
const HC_STROKE = "oklch(0.
|
|
311
|
+
const HC_STROKE = "oklch(0.18 0.02 270)"
|
|
109
312
|
|
|
110
313
|
/** Illustrative light chrome; stroke weight shows contrast (not tied to active color theme). */
|
|
111
314
|
function ContrastPrefSvg({
|
|
@@ -116,75 +319,73 @@ function ContrastPrefSvg({
|
|
|
116
319
|
brand: Brand
|
|
117
320
|
}) {
|
|
118
321
|
const split = SPLIT_SIDEBAR[brand]
|
|
322
|
+
|
|
119
323
|
if (pref === "normal") {
|
|
120
324
|
return (
|
|
121
|
-
<svg className={APPEARANCE_TILE_SVG} viewBox="0 0 56
|
|
122
|
-
<
|
|
123
|
-
x="0.5"
|
|
124
|
-
y="0.5"
|
|
125
|
-
width="55"
|
|
126
|
-
height="31"
|
|
127
|
-
rx="4"
|
|
128
|
-
fill={CHROME_LIGHT.shell}
|
|
129
|
-
stroke={CHROME_LIGHT.shellStroke}
|
|
130
|
-
strokeWidth="1"
|
|
131
|
-
/>
|
|
132
|
-
<rect x="3" y="3" width="13" height="26" rx="2" fill={split.light} />
|
|
133
|
-
<rect x="19" y="6" width="34" height="4.5" rx="1" fill={CHROME_LIGHT.headerBar} />
|
|
134
|
-
<rect x="19" y="14" width="34" height="14" rx="2" fill={CHROME_LIGHT.content} />
|
|
325
|
+
<svg className={APPEARANCE_TILE_SVG} viewBox="0 0 96 56" fill="none" aria-hidden="true">
|
|
326
|
+
<ChromeIllustration tokens={CHROME_LIGHT} sidebar={split.light} sidebarMark={split.markLight} />
|
|
135
327
|
</svg>
|
|
136
328
|
)
|
|
137
329
|
}
|
|
330
|
+
|
|
138
331
|
if (pref === "high") {
|
|
332
|
+
const tokens: ChromeTokens = {
|
|
333
|
+
...CHROME_LIGHT,
|
|
334
|
+
shellStroke: HC_STROKE,
|
|
335
|
+
headerStroke: HC_STROKE,
|
|
336
|
+
cardStroke: HC_STROKE,
|
|
337
|
+
}
|
|
139
338
|
return (
|
|
140
|
-
<svg className={APPEARANCE_TILE_SVG} viewBox="0 0 56
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
rx="4"
|
|
147
|
-
fill={CHROME_LIGHT.shell}
|
|
148
|
-
stroke={HC_STROKE}
|
|
149
|
-
strokeWidth="2"
|
|
150
|
-
/>
|
|
151
|
-
<rect
|
|
152
|
-
x="3"
|
|
153
|
-
y="3"
|
|
154
|
-
width="13"
|
|
155
|
-
height="26"
|
|
156
|
-
rx="2"
|
|
157
|
-
fill={split.light}
|
|
158
|
-
stroke={HC_STROKE}
|
|
159
|
-
strokeWidth="1.5"
|
|
339
|
+
<svg className={APPEARANCE_TILE_SVG} viewBox="0 0 96 56" fill="none" aria-hidden="true">
|
|
340
|
+
<ChromeIllustration
|
|
341
|
+
tokens={tokens}
|
|
342
|
+
sidebar={split.light}
|
|
343
|
+
sidebarMark={split.markLight}
|
|
344
|
+
strokeBoost={1.8}
|
|
160
345
|
/>
|
|
161
|
-
<rect x="19" y="6" width="34" height="4.5" rx="1" fill="oklch(0.35 0.02 270)" opacity="0.35" />
|
|
162
|
-
<rect x="19" y="14" width="34" height="14" rx="2" fill="oklch(0.35 0.02 270)" opacity="0.22" />
|
|
163
346
|
</svg>
|
|
164
347
|
)
|
|
165
348
|
}
|
|
349
|
+
|
|
166
350
|
if (pref === "windows") {
|
|
167
|
-
|
|
351
|
+
// Classic Windows HC cue: black canvas, white border, yellow header, cyan focus.
|
|
352
|
+
const tokens: ChromeTokens = {
|
|
353
|
+
...CHROME_DARK,
|
|
354
|
+
shell: "#000000",
|
|
355
|
+
shellStroke: "#FFFFFF",
|
|
356
|
+
headerBar: "#FFFF00",
|
|
357
|
+
headerStroke: "#FFFF00",
|
|
358
|
+
content: "#000000",
|
|
359
|
+
card: "#000000",
|
|
360
|
+
cardStroke: "#FFFFFF",
|
|
361
|
+
navRow: "#FFFFFF",
|
|
362
|
+
pill: "#000000",
|
|
363
|
+
}
|
|
168
364
|
return (
|
|
169
|
-
<svg className={APPEARANCE_TILE_SVG} viewBox="0 0 56
|
|
170
|
-
<
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
365
|
+
<svg className={APPEARANCE_TILE_SVG} viewBox="0 0 96 56" fill="none" aria-hidden="true">
|
|
366
|
+
<ChromeIllustration
|
|
367
|
+
tokens={tokens}
|
|
368
|
+
sidebar="#000000"
|
|
369
|
+
sidebarMark="#00FFFF"
|
|
370
|
+
contentAccent="#FFFF00"
|
|
371
|
+
strokeBoost={1.8}
|
|
372
|
+
/>
|
|
174
373
|
</svg>
|
|
175
374
|
)
|
|
176
375
|
}
|
|
376
|
+
|
|
377
|
+
// System: one window with a diagonal Normal↔High split inside.
|
|
378
|
+
const highTokens: ChromeTokens = {
|
|
379
|
+
...CHROME_LIGHT,
|
|
380
|
+
shellStroke: HC_STROKE,
|
|
381
|
+
headerStroke: HC_STROKE,
|
|
382
|
+
cardStroke: HC_STROKE,
|
|
383
|
+
}
|
|
177
384
|
return (
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
<rect x="15" y="13" width="10" height="14" rx="1.5" fill={CHROME_LIGHT.content} />
|
|
183
|
-
<rect x="28.5" y="0.5" width="27" height="31" rx="4" fill={CHROME_LIGHT.shell} stroke={HC_STROKE} strokeWidth="2" />
|
|
184
|
-
<rect x="31" y="3" width="10" height="26" rx="1.5" fill={split.light} stroke={HC_STROKE} strokeWidth="1.5" />
|
|
185
|
-
<rect x="43" y="6" width="10" height="4" rx="0.75" fill="oklch(0.35 0.02 270)" opacity="0.35" />
|
|
186
|
-
<rect x="43" y="13" width="10" height="14" rx="1.5" fill="oklch(0.35 0.02 270)" opacity="0.22" />
|
|
187
|
-
</svg>
|
|
385
|
+
<SplitSystemSvg
|
|
386
|
+
light={{ tokens: CHROME_LIGHT, sidebar: split.light, sidebarMark: split.markLight }}
|
|
387
|
+
dark={{ tokens: highTokens, sidebar: split.light, sidebarMark: split.markLight, strokeBoost: 1.8 }}
|
|
388
|
+
/>
|
|
188
389
|
)
|
|
189
390
|
}
|
|
190
391
|
|
|
@@ -202,63 +403,6 @@ const VIEW_LABELS: Record<DashboardView, string> = {
|
|
|
202
403
|
mix: "Mix",
|
|
203
404
|
}
|
|
204
405
|
|
|
205
|
-
const VIEW_STROKE = "oklch(0.78 0.01 270)"
|
|
206
|
-
const VIEW_FILL_STRONG = "oklch(0.82 0.02 270)"
|
|
207
|
-
const VIEW_FILL_SOFT = "oklch(0.90 0.008 270)"
|
|
208
|
-
|
|
209
|
-
/** Illustrative dashboard layout previews — same chrome, different content grid. */
|
|
210
|
-
function DashboardViewSvg({ view }: { view: DashboardView }) {
|
|
211
|
-
if (view === "report") {
|
|
212
|
-
// Chart card + two-column data rows below.
|
|
213
|
-
return (
|
|
214
|
-
<svg className={THEME_SVG_CLASS} viewBox="0 0 56 32" fill="none" aria-hidden="true">
|
|
215
|
-
<rect x="0.5" y="0.5" width="55" height="31" rx="4" fill={CHROME_LIGHT.shell} stroke={CHROME_LIGHT.shellStroke} />
|
|
216
|
-
<rect x="3" y="3" width="50" height="12" rx="1.5" fill={VIEW_FILL_SOFT} stroke={VIEW_STROKE} strokeWidth="0.5" />
|
|
217
|
-
{/* bars inside chart */}
|
|
218
|
-
<rect x="6" y="9" width="2" height="5" fill={VIEW_FILL_STRONG} />
|
|
219
|
-
<rect x="10" y="7" width="2" height="7" fill={VIEW_FILL_STRONG} />
|
|
220
|
-
<rect x="14" y="10" width="2" height="4" fill={VIEW_FILL_STRONG} />
|
|
221
|
-
<rect x="18" y="6" width="2" height="8" fill={VIEW_FILL_STRONG} />
|
|
222
|
-
<rect x="22" y="8" width="2" height="6" fill={VIEW_FILL_STRONG} />
|
|
223
|
-
<rect x="26" y="5" width="2" height="9" fill={VIEW_FILL_STRONG} />
|
|
224
|
-
<rect x="30" y="9" width="2" height="5" fill={VIEW_FILL_STRONG} />
|
|
225
|
-
{/* rows */}
|
|
226
|
-
<rect x="3" y="18" width="24" height="4" rx="1" fill={VIEW_FILL_SOFT} />
|
|
227
|
-
<rect x="29" y="18" width="24" height="4" rx="1" fill={VIEW_FILL_SOFT} />
|
|
228
|
-
<rect x="3" y="24" width="24" height="4" rx="1" fill={VIEW_FILL_SOFT} />
|
|
229
|
-
<rect x="29" y="24" width="24" height="4" rx="1" fill={VIEW_FILL_SOFT} />
|
|
230
|
-
</svg>
|
|
231
|
-
)
|
|
232
|
-
}
|
|
233
|
-
if (view === "simple") {
|
|
234
|
-
// 2×3 KPI grid — clean tiles.
|
|
235
|
-
return (
|
|
236
|
-
<svg className={THEME_SVG_CLASS} viewBox="0 0 56 32" fill="none" aria-hidden="true">
|
|
237
|
-
<rect x="0.5" y="0.5" width="55" height="31" rx="4" fill={CHROME_LIGHT.shell} stroke={CHROME_LIGHT.shellStroke} />
|
|
238
|
-
{[0, 1, 2].map((col) => (
|
|
239
|
-
<React.Fragment key={col}>
|
|
240
|
-
<rect x={3 + col * 17} y="3" width="16" height="12" rx="1.5" fill={VIEW_FILL_SOFT} stroke={VIEW_STROKE} strokeWidth="0.5" />
|
|
241
|
-
<rect x={3 + col * 17} y="17" width="16" height="12" rx="1.5" fill={VIEW_FILL_SOFT} stroke={VIEW_STROKE} strokeWidth="0.5" />
|
|
242
|
-
</React.Fragment>
|
|
243
|
-
))}
|
|
244
|
-
</svg>
|
|
245
|
-
)
|
|
246
|
-
}
|
|
247
|
-
// mix — one big chart + 3 KPI tiles
|
|
248
|
-
return (
|
|
249
|
-
<svg className={THEME_SVG_CLASS} viewBox="0 0 56 32" fill="none" aria-hidden="true">
|
|
250
|
-
<rect x="0.5" y="0.5" width="55" height="31" rx="4" fill={CHROME_LIGHT.shell} stroke={CHROME_LIGHT.shellStroke} />
|
|
251
|
-
<rect x="3" y="3" width="32" height="26" rx="1.5" fill={VIEW_FILL_SOFT} stroke={VIEW_STROKE} strokeWidth="0.5" />
|
|
252
|
-
{/* sparkline */}
|
|
253
|
-
<path d="M5 22 L10 16 L15 19 L20 12 L25 15 L30 9 L33 13" stroke={VIEW_FILL_STRONG} strokeWidth="1" fill="none" />
|
|
254
|
-
{/* side KPIs */}
|
|
255
|
-
<rect x="37" y="3" width="16" height="8" rx="1.5" fill={VIEW_FILL_SOFT} stroke={VIEW_STROKE} strokeWidth="0.5" />
|
|
256
|
-
<rect x="37" y="12" width="16" height="8" rx="1.5" fill={VIEW_FILL_SOFT} stroke={VIEW_STROKE} strokeWidth="0.5" />
|
|
257
|
-
<rect x="37" y="21" width="16" height="8" rx="1.5" fill={VIEW_FILL_SOFT} stroke={VIEW_STROKE} strokeWidth="0.5" />
|
|
258
|
-
</svg>
|
|
259
|
-
)
|
|
260
|
-
}
|
|
261
|
-
|
|
262
406
|
const THEME_CHOICE_LABEL: Record<"system" | "light" | "dark", string> = {
|
|
263
407
|
system: "System default",
|
|
264
408
|
light: "Light",
|
|
@@ -274,13 +418,44 @@ const TEXT_SIZE_LABEL: Record<TextSizePreference, string> = {
|
|
|
274
418
|
export function SettingsAppearanceCard() {
|
|
275
419
|
const { theme, setTheme } = useTheme()
|
|
276
420
|
const { brand, contrastPref, setContrast, textSizePref, setTextSize, mounted } = useAppTheme()
|
|
421
|
+
const {
|
|
422
|
+
product: activeProduct,
|
|
423
|
+
setProduct,
|
|
424
|
+
customProductBrand,
|
|
425
|
+
setCustomProductBrand,
|
|
426
|
+
productBrandColors,
|
|
427
|
+
setProductBrandColor,
|
|
428
|
+
hiddenProductIds,
|
|
429
|
+
hideProduct,
|
|
430
|
+
showProduct,
|
|
431
|
+
} = useProduct()
|
|
277
432
|
const { activeView, setActiveView } = useDashboardView()
|
|
278
433
|
const { chartVariant, setChartVariant } = useChartVariant()
|
|
434
|
+
const productNameId = React.useId()
|
|
435
|
+
const productColorId = React.useId()
|
|
436
|
+
const [deleteProductOpen, setDeleteProductOpen] = React.useState(false)
|
|
437
|
+
const [deleteProductTarget, setDeleteProductTarget] = React.useState<Product | null>(null)
|
|
438
|
+
const [productEditorOpen, setProductEditorOpen] = React.useState(false)
|
|
439
|
+
const [productNameDraft, setProductNameDraft] = React.useState(
|
|
440
|
+
customProductBrand?.suffix ?? DEFAULT_CUSTOM_PRODUCT_BRAND.suffix,
|
|
441
|
+
)
|
|
442
|
+
const [productColorDraft, setProductColorDraft] = React.useState(
|
|
443
|
+
customProductBrand?.brandColor ?? DEFAULT_CUSTOM_PRODUCT_BRAND.brandColor,
|
|
444
|
+
)
|
|
279
445
|
|
|
280
446
|
const safeTheme = mounted ? ((theme ?? "system") as "system" | "light" | "dark") : "system"
|
|
281
447
|
const safeBrand = mounted ? brand : "one"
|
|
282
448
|
const safeContrast = mounted ? contrastPref : "system"
|
|
283
449
|
const safeTextSize = mounted ? textSizePref : "default"
|
|
450
|
+
const appliedCustomProduct = customProductBrand ?? DEFAULT_CUSTOM_PRODUCT_BRAND
|
|
451
|
+
const customProductDirty =
|
|
452
|
+
productNameDraft.trim() !== appliedCustomProduct.suffix ||
|
|
453
|
+
productColorDraft.trim() !== appliedCustomProduct.brandColor
|
|
454
|
+
|
|
455
|
+
React.useEffect(() => {
|
|
456
|
+
setProductNameDraft(customProductBrand?.suffix ?? DEFAULT_CUSTOM_PRODUCT_BRAND.suffix)
|
|
457
|
+
setProductColorDraft(customProductBrand?.brandColor ?? DEFAULT_CUSTOM_PRODUCT_BRAND.brandColor)
|
|
458
|
+
}, [customProductBrand])
|
|
284
459
|
|
|
285
460
|
const themeTiles = React.useMemo(
|
|
286
461
|
() =>
|
|
@@ -330,6 +505,17 @@ export function SettingsAppearanceCard() {
|
|
|
330
505
|
})),
|
|
331
506
|
[],
|
|
332
507
|
)
|
|
508
|
+
const productOptions = React.useMemo(
|
|
509
|
+
() => [
|
|
510
|
+
{ value: "exxat-one" as const, label: "Exxat One" },
|
|
511
|
+
{ value: "exxat-prism" as const, label: "Exxat Prism" },
|
|
512
|
+
{ value: "exxat-assessment" as const, label: "Exxat Assessment" },
|
|
513
|
+
...(customProductBrand
|
|
514
|
+
? [{ value: "exxat-custom" as const, label: `Exxat ${customProductBrand.suffix}` }]
|
|
515
|
+
: []),
|
|
516
|
+
].filter(option => !hiddenProductIds.includes(option.value)),
|
|
517
|
+
[customProductBrand, hiddenProductIds],
|
|
518
|
+
)
|
|
333
519
|
|
|
334
520
|
return (
|
|
335
521
|
<section id="appearance" className="scroll-mt-20">
|
|
@@ -342,6 +528,229 @@ export function SettingsAppearanceCard() {
|
|
|
342
528
|
<p className="text-sm text-muted-foreground">Loading theme…</p>
|
|
343
529
|
) : (
|
|
344
530
|
<FieldGroup className="gap-8">
|
|
531
|
+
<SettingsFormRow
|
|
532
|
+
label="Products"
|
|
533
|
+
description="Recolour the brand mark + wordmark for each product. Switch the active product from the sidebar."
|
|
534
|
+
>
|
|
535
|
+
<div
|
|
536
|
+
className="overflow-hidden rounded-xl border border-border bg-card"
|
|
537
|
+
role="group"
|
|
538
|
+
aria-label="Product brand colors"
|
|
539
|
+
>
|
|
540
|
+
{productOptions.map(option => {
|
|
541
|
+
const config = brandForProduct(option.value, customProductBrand, productBrandColors)
|
|
542
|
+
// Registry default for this product — drives the picker's
|
|
543
|
+
// "Reset" affordance when the live value diverges from it.
|
|
544
|
+
const defaultConfig =
|
|
545
|
+
option.value === "exxat-custom"
|
|
546
|
+
? customProductBrandConfig()
|
|
547
|
+
: getProductBrand(option.value)
|
|
548
|
+
const pickerId = `settings-product-color-${option.value}`
|
|
549
|
+
const canDelete =
|
|
550
|
+
option.value === "exxat-custom" || option.value === "exxat-assessment"
|
|
551
|
+
const isCustom = option.value === "exxat-custom"
|
|
552
|
+
const isActive = activeProduct === option.value
|
|
553
|
+
// Colour-taken map for this row — keys are every OTHER
|
|
554
|
+
// product's effective brand colour, values are the
|
|
555
|
+
// human-readable product label (with " (active)" suffix
|
|
556
|
+
// when applicable). The BrandColorPicker uses this to pip
|
|
557
|
+
// already-claimed swatches so the user can see at a glance
|
|
558
|
+
// that Pink is taken by Exxat Prism (and which one is the
|
|
559
|
+
// currently-active product driving chrome).
|
|
560
|
+
const usedBy: Record<string, string> = {}
|
|
561
|
+
for (const other of productOptions) {
|
|
562
|
+
if (other.value === option.value) continue
|
|
563
|
+
const otherConfig = brandForProduct(
|
|
564
|
+
other.value,
|
|
565
|
+
customProductBrand,
|
|
566
|
+
productBrandColors,
|
|
567
|
+
)
|
|
568
|
+
const label =
|
|
569
|
+
activeProduct === other.value
|
|
570
|
+
? `${other.label} (active)`
|
|
571
|
+
: other.label
|
|
572
|
+
usedBy[otherConfig.brandColor] = label
|
|
573
|
+
}
|
|
574
|
+
const handleColorChange = (next: string) => {
|
|
575
|
+
if (isCustom) {
|
|
576
|
+
// Custom slot: persist suffix + colour together so the
|
|
577
|
+
// sidebar label / wordmark suffix stay in sync.
|
|
578
|
+
setCustomProductBrand({
|
|
579
|
+
suffix: customProductBrand?.suffix?.trim() || DEFAULT_CUSTOM_PRODUCT_BRAND.suffix,
|
|
580
|
+
brandColor: next,
|
|
581
|
+
})
|
|
582
|
+
} else {
|
|
583
|
+
// Treat a "back to default" pick as clearing the
|
|
584
|
+
// override so the built-in theme class (theme-one /
|
|
585
|
+
// theme-prism / theme-assessment) gets re-applied for
|
|
586
|
+
// the bespoke chrome look.
|
|
587
|
+
if (defaultConfig && next.trim() === defaultConfig.brandColor.trim()) {
|
|
588
|
+
setProductBrandColor(option.value, null)
|
|
589
|
+
} else {
|
|
590
|
+
setProductBrandColor(option.value, next)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return (
|
|
595
|
+
<div
|
|
596
|
+
key={option.value}
|
|
597
|
+
className="flex min-w-0 items-center gap-3 border-b border-border px-3 py-2 last:border-b-0"
|
|
598
|
+
>
|
|
599
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
600
|
+
<span className="min-w-0">
|
|
601
|
+
<span className="sr-only">{option.label}</span>
|
|
602
|
+
<ExxatProductLogo
|
|
603
|
+
product={option.value}
|
|
604
|
+
variant="mutedSuffix"
|
|
605
|
+
className="w-auto max-w-[min(100%,18rem)]"
|
|
606
|
+
/>
|
|
607
|
+
</span>
|
|
608
|
+
{isActive ? (
|
|
609
|
+
// Tells the user which product's chrome the picker
|
|
610
|
+
// they're about to change actually drives — picking
|
|
611
|
+
// a colour for a non-active product stores the
|
|
612
|
+
// override but won't retint the surrounding UI
|
|
613
|
+
// until they switch to that product in the sidebar.
|
|
614
|
+
//
|
|
615
|
+
// Uses **neutral** foreground tokens (not `text-brand`)
|
|
616
|
+
// so the badge reads cleanly regardless of which
|
|
617
|
+
// colour the active product is currently pinned to
|
|
618
|
+
// — e.g. for the grey Sapphire accent the badge
|
|
619
|
+
// would otherwise wash out.
|
|
620
|
+
<Tip
|
|
621
|
+
label="Currently active product — chrome reflects this color"
|
|
622
|
+
side="top"
|
|
623
|
+
>
|
|
624
|
+
<span
|
|
625
|
+
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-foreground"
|
|
626
|
+
aria-label="Active product"
|
|
627
|
+
>
|
|
628
|
+
<i
|
|
629
|
+
className="fa-solid fa-circle text-[6px] text-emerald-500"
|
|
630
|
+
aria-hidden="true"
|
|
631
|
+
/>
|
|
632
|
+
Active
|
|
633
|
+
</span>
|
|
634
|
+
</Tip>
|
|
635
|
+
) : null}
|
|
636
|
+
</div>
|
|
637
|
+
<label htmlFor={pickerId} className="sr-only">
|
|
638
|
+
Brand color for {option.label}
|
|
639
|
+
</label>
|
|
640
|
+
<div className="w-44 shrink-0">
|
|
641
|
+
<BrandColorPicker
|
|
642
|
+
id={pickerId}
|
|
643
|
+
value={config.brandColor}
|
|
644
|
+
defaultValue={defaultConfig?.brandColor}
|
|
645
|
+
usedBy={usedBy}
|
|
646
|
+
onChange={handleColorChange}
|
|
647
|
+
/>
|
|
648
|
+
</div>
|
|
649
|
+
{canDelete ? (
|
|
650
|
+
<Tip label={`Delete ${option.label}`} side="top">
|
|
651
|
+
<Button
|
|
652
|
+
type="button"
|
|
653
|
+
size="icon-sm"
|
|
654
|
+
variant="ghost"
|
|
655
|
+
aria-label={`Delete ${option.label}`}
|
|
656
|
+
onClick={() => {
|
|
657
|
+
setDeleteProductTarget(option.value)
|
|
658
|
+
setDeleteProductOpen(true)
|
|
659
|
+
}}
|
|
660
|
+
>
|
|
661
|
+
<i className="fa-light fa-trash-can text-xs" aria-hidden="true" />
|
|
662
|
+
</Button>
|
|
663
|
+
</Tip>
|
|
664
|
+
) : null}
|
|
665
|
+
</div>
|
|
666
|
+
)
|
|
667
|
+
})}
|
|
668
|
+
<div>
|
|
669
|
+
<button
|
|
670
|
+
type="button"
|
|
671
|
+
className={cn(
|
|
672
|
+
"flex w-full items-center gap-3 px-3 py-2 text-left transition",
|
|
673
|
+
"hover:bg-interactive-hover/20",
|
|
674
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
675
|
+
)}
|
|
676
|
+
aria-expanded={productEditorOpen}
|
|
677
|
+
onClick={() => setProductEditorOpen(open => !open)}
|
|
678
|
+
>
|
|
679
|
+
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-brand/10 text-brand">
|
|
680
|
+
<i className="fa-light fa-plus text-xs" aria-hidden="true" />
|
|
681
|
+
</span>
|
|
682
|
+
<span className="min-w-0 flex-1">
|
|
683
|
+
<span className="block text-sm font-medium text-foreground">Add product</span>
|
|
684
|
+
<span className="block text-xs text-muted-foreground">Create a product name and brand color.</span>
|
|
685
|
+
</span>
|
|
686
|
+
<i
|
|
687
|
+
className={cn(
|
|
688
|
+
"fa-light fa-chevron-down text-xs text-muted-foreground transition-transform",
|
|
689
|
+
productEditorOpen && "rotate-180",
|
|
690
|
+
)}
|
|
691
|
+
aria-hidden="true"
|
|
692
|
+
/>
|
|
693
|
+
</button>
|
|
694
|
+
|
|
695
|
+
{productEditorOpen ? (
|
|
696
|
+
<div className="space-y-3 border-t border-border p-3">
|
|
697
|
+
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
|
698
|
+
<div className="space-y-1.5">
|
|
699
|
+
<label htmlFor={productNameId} className="text-xs font-medium text-muted-foreground">
|
|
700
|
+
Product name
|
|
701
|
+
</label>
|
|
702
|
+
<Input
|
|
703
|
+
id={productNameId}
|
|
704
|
+
value={productNameDraft}
|
|
705
|
+
onChange={event => setProductNameDraft(event.target.value)}
|
|
706
|
+
placeholder={DEFAULT_CUSTOM_PRODUCT_BRAND.suffix}
|
|
707
|
+
autoComplete="off"
|
|
708
|
+
/>
|
|
709
|
+
</div>
|
|
710
|
+
<div className="space-y-1.5">
|
|
711
|
+
<label htmlFor={productColorId} className="text-xs font-medium text-muted-foreground">
|
|
712
|
+
Brand color
|
|
713
|
+
</label>
|
|
714
|
+
<BrandColorPicker
|
|
715
|
+
id={productColorId}
|
|
716
|
+
value={productColorDraft}
|
|
717
|
+
onChange={setProductColorDraft}
|
|
718
|
+
/>
|
|
719
|
+
</div>
|
|
720
|
+
</div>
|
|
721
|
+
<p className="text-xs leading-snug text-muted-foreground">
|
|
722
|
+
Pick a swatch from the Exxat palette, or open{" "}
|
|
723
|
+
<span className="text-foreground">Use custom CSS color</span> to paste any CSS
|
|
724
|
+
value (hex, rgb, oklch).
|
|
725
|
+
</p>
|
|
726
|
+
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-background px-3 py-2">
|
|
727
|
+
<ExxatProductLogo product="exxat-custom" variant="mutedSuffix" className="w-auto max-w-[min(100%,18rem)]" />
|
|
728
|
+
<Button
|
|
729
|
+
type="button"
|
|
730
|
+
size="sm"
|
|
731
|
+
disabled={!productNameDraft.trim() || !productColorDraft.trim()}
|
|
732
|
+
onClick={() => {
|
|
733
|
+
setCustomProductBrand({
|
|
734
|
+
suffix: productNameDraft.trim() || DEFAULT_CUSTOM_PRODUCT_BRAND.suffix,
|
|
735
|
+
brandColor: productColorDraft.trim() || DEFAULT_CUSTOM_PRODUCT_BRAND.brandColor,
|
|
736
|
+
})
|
|
737
|
+
showProduct("exxat-custom")
|
|
738
|
+
setProduct("exxat-custom")
|
|
739
|
+
setProductEditorOpen(false)
|
|
740
|
+
}}
|
|
741
|
+
>
|
|
742
|
+
Apply product
|
|
743
|
+
</Button>
|
|
744
|
+
</div>
|
|
745
|
+
{customProductDirty ? (
|
|
746
|
+
<p className="text-xs text-muted-foreground">Apply to update the sidebar and product selector.</p>
|
|
747
|
+
) : null}
|
|
748
|
+
</div>
|
|
749
|
+
) : null}
|
|
750
|
+
</div>
|
|
751
|
+
</div>
|
|
752
|
+
</SettingsFormRow>
|
|
753
|
+
|
|
345
754
|
<SettingsFormRow label="Theme" description="Light, dark, or match your OS.">
|
|
346
755
|
<SelectionTileGrid<"system" | "light" | "dark">
|
|
347
756
|
className="w-full"
|
|
@@ -419,6 +828,40 @@ export function SettingsAppearanceCard() {
|
|
|
419
828
|
</FieldGroup>
|
|
420
829
|
)}
|
|
421
830
|
</div>
|
|
831
|
+
<Dialog open={deleteProductOpen} onOpenChange={setDeleteProductOpen}>
|
|
832
|
+
<DialogContent className="max-w-sm">
|
|
833
|
+
<DialogHeader>
|
|
834
|
+
<DialogTitle>Delete product?</DialogTitle>
|
|
835
|
+
<DialogDescription>
|
|
836
|
+
This removes the product from the sidebar selector and switches your workspace back to Exxat One.
|
|
837
|
+
</DialogDescription>
|
|
838
|
+
</DialogHeader>
|
|
839
|
+
<DialogFooter className="gap-2 sm:gap-2">
|
|
840
|
+
<Button type="button" variant="outline" onClick={() => setDeleteProductOpen(false)}>
|
|
841
|
+
Cancel
|
|
842
|
+
</Button>
|
|
843
|
+
<Button
|
|
844
|
+
type="button"
|
|
845
|
+
variant="destructive"
|
|
846
|
+
onClick={() => {
|
|
847
|
+
if (deleteProductTarget) {
|
|
848
|
+
hideProduct(deleteProductTarget)
|
|
849
|
+
}
|
|
850
|
+
if (deleteProductTarget === "exxat-custom") {
|
|
851
|
+
setCustomProductBrand(null)
|
|
852
|
+
}
|
|
853
|
+
setProduct("exxat-one")
|
|
854
|
+
setProductNameDraft(DEFAULT_CUSTOM_PRODUCT_BRAND.suffix)
|
|
855
|
+
setProductColorDraft(DEFAULT_CUSTOM_PRODUCT_BRAND.brandColor)
|
|
856
|
+
setDeleteProductTarget(null)
|
|
857
|
+
setDeleteProductOpen(false)
|
|
858
|
+
}}
|
|
859
|
+
>
|
|
860
|
+
Delete product
|
|
861
|
+
</Button>
|
|
862
|
+
</DialogFooter>
|
|
863
|
+
</DialogContent>
|
|
864
|
+
</Dialog>
|
|
422
865
|
</section>
|
|
423
866
|
)
|
|
424
867
|
}
|