@exxatdesignux/ui 0.2.16 → 0.2.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +148 -3
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  6. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
  7. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +14 -0
  8. package/package.json +3 -3
  9. package/src/components/ui/banner.tsx +2 -0
  10. package/src/components/ui/chart.tsx +57 -2
  11. package/src/components/ui/sidebar.tsx +1 -0
  12. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  13. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  14. package/template/AGENTS.md +18 -15
  15. package/template/app/(app)/data-list/page.tsx +2 -2
  16. package/template/app/(app)/question-bank/layout.tsx +18 -5
  17. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  18. package/template/app/globals.css +108 -1
  19. package/template/app/layout.tsx +41 -5
  20. package/template/components/app-sidebar.tsx +68 -34
  21. package/template/components/ask-leo-sidebar.tsx +0 -2
  22. package/template/components/brand-color-picker.tsx +344 -0
  23. package/template/components/compliance-list-view.tsx +33 -51
  24. package/template/components/compliance-table.tsx +24 -0
  25. package/template/components/data-table/index.tsx +68 -24
  26. package/template/components/data-table/pagination.tsx +0 -1
  27. package/template/components/data-table/types.ts +4 -1
  28. package/template/components/data-table/use-table-state.ts +243 -94
  29. package/template/components/data-views/data-row-list.tsx +183 -0
  30. package/template/components/data-views/index.ts +7 -3
  31. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  32. package/template/components/export-drawer.tsx +1 -1
  33. package/template/components/exxat-product-logo.tsx +172 -317
  34. package/template/components/invite-collaborators-drawer.tsx +5 -3
  35. package/template/components/key-metrics.tsx +74 -46
  36. package/template/components/new-placement-form.tsx +4 -2
  37. package/template/components/new-question-composer.tsx +2208 -0
  38. package/template/components/page-breadcrumb-trail.tsx +131 -0
  39. package/template/components/page-header.tsx +2 -1
  40. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
  41. package/template/components/placement-detail.tsx +1 -1
  42. package/template/components/placements-board-view.tsx +1 -1
  43. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  44. package/template/components/placements-list-view.tsx +18 -132
  45. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  46. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  47. package/template/components/placements-table-columns.tsx +2 -2
  48. package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
  49. package/template/components/product-switcher.tsx +26 -8
  50. package/template/components/product-wordmark.tsx +285 -0
  51. package/template/components/question-bank-client.tsx +20 -2
  52. package/template/components/question-bank-hub-client.tsx +108 -115
  53. package/template/components/question-bank-list-view.tsx +30 -54
  54. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  55. package/template/components/question-bank-secondary-nav.tsx +0 -3
  56. package/template/components/question-bank-table.tsx +30 -5
  57. package/template/components/rotations-empty-state.tsx +3 -0
  58. package/template/components/secondary-panel.tsx +23 -3
  59. package/template/components/settings-appearance-card.tsx +584 -141
  60. package/template/components/site-header.tsx +36 -31
  61. package/template/components/sites-list-view.tsx +31 -36
  62. package/template/components/sites-table.tsx +24 -0
  63. package/template/components/table-properties/drawer.tsx +1 -1
  64. package/template/components/team-client.tsx +1 -1
  65. package/template/components/team-list-view.tsx +34 -50
  66. package/template/components/team-table.tsx +29 -3
  67. package/template/components/templates/nested-secondary-panel-shell.tsx +8 -2
  68. package/template/components/ui/dot-pattern.tsx +50 -26
  69. package/template/components/ui/leo-icon.tsx +23 -3
  70. package/template/contexts/product-context.tsx +51 -7
  71. package/template/contexts/system-banner-context.tsx +112 -4
  72. package/template/eslint.config.mjs +18 -0
  73. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  74. package/template/lib/data-list-persistence.ts +57 -257
  75. package/template/lib/dev-log.test.ts +6 -5
  76. package/template/lib/exxat-palette.json +1462 -0
  77. package/template/lib/exxat-palette.ts +136 -0
  78. package/template/lib/list-page-table-properties.ts +1 -1
  79. package/template/lib/list-status-badges.ts +1 -1
  80. package/template/lib/mailto.ts +29 -0
  81. package/template/lib/placement-board-card-layout.ts +1 -1
  82. package/template/lib/product-brand.ts +268 -0
  83. package/template/lib/question-bank-authoring.ts +308 -0
  84. package/template/lib/question-bank-nav.ts +44 -0
  85. package/template/lib/raf-throttle.ts +45 -0
  86. package/template/lib/table-state-lifecycle.ts +474 -0
  87. package/template/next.config.mjs +156 -0
  88. package/template/package.json +3 -3
  89. package/template/stores/app-store.ts +46 -1
@@ -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: { light: "oklch(0.935 0.024 286.1)", dark: "oklch(0.38 0.09 286.1)" },
48
- prism: { light: "oklch(0.96 0.04 342)", dark: "oklch(0.4 0.14 342)" },
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.93 0.004 270)",
61
- content: "oklch(0.96 0.004 270)",
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.12 0.01 270)",
66
- shellStroke: "oklch(0.38 0.02 270)",
67
- headerBar: "oklch(0.22 0.02 270)",
68
- content: "oklch(0.17 0.015 270)",
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 32" fill="none" aria-hidden="true">
77
- <rect x="0.5" y="0.5" width="55" height="31" rx="4" fill={CHROME_LIGHT.shell} stroke={CHROME_LIGHT.shellStroke} />
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 32" fill="none" aria-hidden="true">
87
- <rect x="0.5" y="0.5" width="55" height="31" rx="4" fill={CHROME_DARK.shell} stroke={CHROME_DARK.shellStroke} />
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
- <svg className={APPEARANCE_TILE_SVG} viewBox="0 0 56 32" fill="none" aria-hidden="true">
96
- <rect x="0.5" y="0.5" width="26" height="31" rx="4" fill={CHROME_LIGHT.shell} stroke={CHROME_LIGHT.shellStroke} />
97
- <rect x="3" y="3" width="10" height="26" rx="1.5" fill={split.light} />
98
- <rect x="15" y="6" width="10" height="4" rx="0.75" fill={CHROME_LIGHT.headerBar} />
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.22 0.02 270)"
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 32" fill="none" aria-hidden="true">
122
- <rect
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 32" fill="none" aria-hidden="true">
141
- <rect
142
- x="0.5"
143
- y="0.5"
144
- width="55"
145
- height="31"
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
- /* Classic Windows HC cue: black canvas, white border, yellow + cyan accents */
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 32" fill="none" aria-hidden="true">
170
- <rect x="0.5" y="0.5" width="55" height="31" rx="4" fill="#000000" stroke="#ffffff" strokeWidth="2" />
171
- <rect x="4" y="5" width="14" height="22" rx="2" fill="#000000" stroke="#ffffff" strokeWidth="1.5" />
172
- <rect x="22" y="7" width="30" height="5" rx="1" fill="#ffff00" />
173
- <rect x="22" y="15" width="30" height="10" rx="1.5" fill="#000000" stroke="#00ffff" strokeWidth="1.25" />
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
- <svg className={APPEARANCE_TILE_SVG} viewBox="0 0 56 32" fill="none" aria-hidden="true">
179
- <rect x="0.5" y="0.5" width="26" height="31" rx="4" fill={CHROME_LIGHT.shell} stroke={CHROME_LIGHT.shellStroke} strokeWidth="1" />
180
- <rect x="3" y="3" width="10" height="26" rx="1.5" fill={split.light} />
181
- <rect x="15" y="6" width="10" height="4" rx="0.75" fill={CHROME_LIGHT.headerBar} />
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
  }