@exxatdesignux/ui 0.2.16 → 0.2.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  6. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
  7. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +19 -0
  9. package/consumer-extras/patterns/data-views-pattern.md +2 -0
  10. package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
  11. package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -0
  12. package/package.json +3 -3
  13. package/src/components/ui/banner.tsx +2 -0
  14. package/src/components/ui/chart.tsx +57 -2
  15. package/src/components/ui/sidebar.tsx +3 -2
  16. package/src/globals.css +65 -14
  17. package/src/theme.css +3 -3
  18. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  19. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
  20. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  21. package/template/AGENTS.md +27 -17
  22. package/template/app/(app)/data-list/page.tsx +2 -2
  23. package/template/app/(app)/error.tsx +22 -6
  24. package/template/app/(app)/layout.tsx +13 -6
  25. package/template/app/(app)/question-bank/layout.tsx +18 -5
  26. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  27. package/template/app/global-error.tsx +63 -0
  28. package/template/app/globals.css +151 -14
  29. package/template/app/layout.tsx +43 -5
  30. package/template/components/app-sidebar.tsx +68 -33
  31. package/template/components/ask-leo-sidebar.tsx +0 -2
  32. package/template/components/brand-color-picker.tsx +344 -0
  33. package/template/components/compliance-list-view.tsx +33 -51
  34. package/template/components/compliance-table.tsx +4 -0
  35. package/template/components/data-table/index.tsx +99 -91
  36. package/template/components/data-table/pagination.tsx +0 -1
  37. package/template/components/data-table/types.ts +4 -1
  38. package/template/components/data-table/use-table-state.ts +276 -100
  39. package/template/components/data-views/data-row-list.tsx +183 -0
  40. package/template/components/data-views/index.ts +7 -3
  41. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  42. package/template/components/dev-chunk-load-recovery.tsx +41 -0
  43. package/template/components/export-drawer.tsx +1 -1
  44. package/template/components/exxat-product-logo.tsx +168 -317
  45. package/template/components/invite-collaborators-drawer.tsx +5 -3
  46. package/template/components/key-metrics.tsx +122 -62
  47. package/template/components/new-placement-form.tsx +4 -2
  48. package/template/components/new-question-composer.tsx +2208 -0
  49. package/template/components/page-breadcrumb-trail.tsx +131 -0
  50. package/template/components/page-header.tsx +2 -1
  51. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +2 -2
  52. package/template/components/placement-detail.tsx +1 -1
  53. package/template/components/placements-board-view.tsx +1 -1
  54. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  55. package/template/components/placements-list-view.tsx +19 -133
  56. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  57. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  58. package/template/components/placements-table-columns.tsx +2 -2
  59. package/template/components/{data-list-table.tsx → placements-table.tsx} +42 -66
  60. package/template/components/product-switcher.tsx +24 -7
  61. package/template/components/product-wordmark.tsx +282 -0
  62. package/template/components/question-bank-client.tsx +20 -2
  63. package/template/components/question-bank-hub-client.tsx +105 -115
  64. package/template/components/question-bank-list-view.tsx +30 -54
  65. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  66. package/template/components/question-bank-secondary-nav.tsx +0 -3
  67. package/template/components/question-bank-table.tsx +19 -6
  68. package/template/components/rotations-empty-state.tsx +3 -0
  69. package/template/components/secondary-panel.tsx +23 -3
  70. package/template/components/settings-appearance-card.tsx +584 -141
  71. package/template/components/sidebar-shell.tsx +2 -1
  72. package/template/components/site-header.tsx +36 -31
  73. package/template/components/sites-list-view.tsx +31 -36
  74. package/template/components/sites-table.tsx +4 -0
  75. package/template/components/table-properties/drawer-button.tsx +38 -20
  76. package/template/components/table-properties/drawer.tsx +17 -14
  77. package/template/components/team-client.tsx +1 -1
  78. package/template/components/team-list-view.tsx +34 -50
  79. package/template/components/team-table.tsx +8 -3
  80. package/template/components/templates/list-page.tsx +12 -9
  81. package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
  82. package/template/components/ui/dot-pattern.tsx +50 -26
  83. package/template/components/ui/leo-icon.tsx +23 -3
  84. package/template/contexts/product-context.tsx +70 -7
  85. package/template/contexts/system-banner-context.tsx +112 -4
  86. package/template/docs/data-views-pattern.md +2 -0
  87. package/template/docs/kpi-flat-band-pattern.md +57 -0
  88. package/template/docs/kpi-strip-max-four-pattern.md +1 -0
  89. package/template/docs/shell-surface-elevation-pattern.md +52 -0
  90. package/template/eslint.config.mjs +18 -0
  91. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  92. package/template/lib/chunk-load-error.ts +13 -0
  93. package/template/lib/conditional-rule-match.ts +87 -22
  94. package/template/lib/data-list-persistence.ts +57 -257
  95. package/template/lib/data-list-view.ts +6 -0
  96. package/template/lib/dev-log.test.ts +6 -5
  97. package/template/lib/exxat-palette.json +1462 -0
  98. package/template/lib/exxat-palette.ts +136 -0
  99. package/template/lib/list-page-table-properties.ts +1 -1
  100. package/template/lib/list-status-badges.ts +1 -1
  101. package/template/lib/mailto.ts +29 -0
  102. package/template/lib/placement-board-card-layout.ts +1 -1
  103. package/template/lib/product-brand.ts +268 -0
  104. package/template/lib/question-bank-authoring.ts +308 -0
  105. package/template/lib/question-bank-nav.ts +44 -0
  106. package/template/lib/raf-throttle.ts +45 -0
  107. package/template/lib/sidebar-state-cookie.ts +9 -0
  108. package/template/lib/table-state-lifecycle.ts +521 -0
  109. package/template/next.config.mjs +156 -0
  110. package/template/package.json +3 -3
  111. package/template/stores/app-store.ts +46 -1
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Exxat web color palette — single source of truth for the curated brand
3
+ * color picker (and any future surface that needs a typed enumeration of the
4
+ * Exxat color tokens with both hex and OKLCH forms).
5
+ *
6
+ * Data lives in `exxat-palette.json` (16 shades × 9 families). This module
7
+ * adds:
8
+ *
9
+ * - Static TypeScript types for the JSON shape so consumers get autocomplete
10
+ * on family ids and shade keys.
11
+ * - Stable display labels for each family (`exxatPink` → `"Pink"`, etc.) so
12
+ * the picker UI does not have to know the JSON key naming convention.
13
+ * - A flattened `EXXAT_PALETTE_SWATCHES` array of { family, shade, hex,
14
+ * oklch, label } records for grid rendering, plus `findExxatPaletteSwatch`
15
+ * so we can highlight the "selected" swatch when the brand color matches a
16
+ * palette token.
17
+ *
18
+ * The picker may also store a free-form CSS color (the underlying state stays
19
+ * `string`); helpers here only describe the curated subset so the UI can
20
+ * expose recognised tokens with a friendlier label / hover tooltip.
21
+ */
22
+ import paletteRaw from "./exxat-palette.json"
23
+
24
+ export interface ExxatPaletteSwatchData {
25
+ hex: string
26
+ oklch: string
27
+ oklchValues: { l: number; c: number; h: number }
28
+ }
29
+
30
+ export type ExxatPaletteJson = Record<string, Record<string, ExxatPaletteSwatchData>>
31
+
32
+ const PALETTE: ExxatPaletteJson = paletteRaw as ExxatPaletteJson
33
+
34
+ export type ExxatPaletteFamilyId =
35
+ | "exxatPink"
36
+ | "exxatBlue"
37
+ | "exxatIndigo"
38
+ | "sapphireGrayBlack"
39
+ | "orange"
40
+ | "teal"
41
+ | "red"
42
+ | "neutral"
43
+ | "purple"
44
+ | "green"
45
+
46
+ /**
47
+ * Display labels — keep ordering consistent with how the palette is composed
48
+ * (Exxat brand families first, then auxiliary hues, neutrals last).
49
+ */
50
+ export const EXXAT_PALETTE_FAMILIES: ReadonlyArray<{ id: ExxatPaletteFamilyId; label: string }> = [
51
+ { id: "exxatPink", label: "Pink" },
52
+ { id: "exxatBlue", label: "Blue" },
53
+ { id: "exxatIndigo", label: "Indigo" },
54
+ { id: "purple", label: "Purple" },
55
+ { id: "teal", label: "Teal" },
56
+ { id: "green", label: "Green" },
57
+ { id: "orange", label: "Orange" },
58
+ { id: "red", label: "Red" },
59
+ { id: "neutral", label: "Neutral" },
60
+ { id: "sapphireGrayBlack", label: "Sapphire" },
61
+ ]
62
+
63
+ /** Shade keys preserved in palette order (50, 100, 150, …, 900). Some "round" stops
64
+ * are intentionally absent (no 350 / 800) — keep this in sync with the JSON. */
65
+ export const EXXAT_PALETTE_SHADES: ReadonlyArray<string> = [
66
+ "50",
67
+ "100",
68
+ "150",
69
+ "200",
70
+ "250",
71
+ "300",
72
+ "400",
73
+ "450",
74
+ "500",
75
+ "550",
76
+ "600",
77
+ "650",
78
+ "700",
79
+ "750",
80
+ "850",
81
+ "900",
82
+ ]
83
+
84
+ export interface ExxatPaletteSwatch extends ExxatPaletteSwatchData {
85
+ family: ExxatPaletteFamilyId
86
+ familyLabel: string
87
+ shade: string
88
+ /** Human-readable label, e.g. `"Pink 500"`. */
89
+ label: string
90
+ }
91
+
92
+ function buildSwatch(family: ExxatPaletteFamilyId, familyLabel: string, shade: string): ExxatPaletteSwatch | null {
93
+ const data = PALETTE[family]?.[shade]
94
+ if (!data) return null
95
+ return {
96
+ family,
97
+ familyLabel,
98
+ shade,
99
+ label: `${familyLabel} ${shade}`,
100
+ hex: data.hex,
101
+ oklch: data.oklch,
102
+ oklchValues: data.oklchValues,
103
+ }
104
+ }
105
+
106
+ /** Family → ordered list of swatches (only shades that actually exist in the JSON). */
107
+ export const EXXAT_PALETTE_BY_FAMILY: ReadonlyArray<{
108
+ id: ExxatPaletteFamilyId
109
+ label: string
110
+ swatches: ReadonlyArray<ExxatPaletteSwatch>
111
+ }> = EXXAT_PALETTE_FAMILIES.map(({ id, label }) => ({
112
+ id,
113
+ label,
114
+ swatches: EXXAT_PALETTE_SHADES.map(shade => buildSwatch(id, label, shade)).filter(
115
+ (swatch): swatch is ExxatPaletteSwatch => swatch !== null,
116
+ ),
117
+ }))
118
+
119
+ /** Flat list — convenient for "find by value" lookups. */
120
+ export const EXXAT_PALETTE_SWATCHES: ReadonlyArray<ExxatPaletteSwatch> = EXXAT_PALETTE_BY_FAMILY.flatMap(
121
+ family => family.swatches,
122
+ )
123
+
124
+ /**
125
+ * Try to resolve a stored brand color (any CSS string) to a known palette
126
+ * swatch — matches both the canonical OKLCH form and the hex form so values
127
+ * authored either way still light up in the picker.
128
+ */
129
+ export function findExxatPaletteSwatch(value: string | null | undefined): ExxatPaletteSwatch | undefined {
130
+ if (!value) return undefined
131
+ const normalized = value.trim().toLowerCase()
132
+ if (!normalized) return undefined
133
+ return EXXAT_PALETTE_SWATCHES.find(
134
+ swatch => swatch.oklch.toLowerCase() === normalized || swatch.hex.toLowerCase() === normalized,
135
+ )
136
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Connects ListPageTemplate “View → Edit” to a surface that hosts TablePropertiesDrawer
3
- * (DataListTable, TeamTable, ComplianceTable, …). Import from `@/components/table-properties`
3
+ * (PlacementsTable, TeamTable, ComplianceTable, …). Import from `@/components/table-properties`
4
4
  * or use here — see `createListPageEditViewHandler`.
5
5
  *
6
6
  * View **labels** for tabs and Properties are centralized in `@/lib/data-list-view`
@@ -6,7 +6,7 @@
6
6
  * Labels use **sentence / title case** (e.g. "Due soon", "Under Review"). Do **not** add **`uppercase`**.
7
7
  *
8
8
  * **Rendering:** Use **`ListHubStatusBadge`** from `@/components/list-hub-status-badge`, or
9
- * **`StatusBadge`** from **`components/data-list-table-cells.tsx`** for placement rows (wrapper
9
+ * **`StatusBadge`** from **`components/placements-table-cells.tsx`** for placement rows (wrapper
10
10
  * around **`ListHubStatusBadge`** + **`PLACEMENT_STATUS_*`** below). Task priority → **`TaskPriorityBadge`**.
11
11
  *
12
12
  * **Semantic tints:** Map domain statuses onto **`LIST_HUB_STATUS_TINT_*`** before inventing new colors.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Build a safe `mailto:` URL from a possibly-untrusted email string.
3
+ *
4
+ * Per RFC 6068, a `mailto:` href is parsed as `mailto:<addr>[?<headers>]`,
5
+ * where `<headers>` is a sequence of `key=value` pairs separated by `&`.
6
+ * Without encoding, an email-shaped string that contains `?`, `&`, `\r`,
7
+ * `\n`, or other URI-reserved characters could append arbitrary mail headers
8
+ * (`?cc=`, `?bcc=`, `?subject=`, `%0A` for newlines) or break out of the URL
9
+ * entirely.
10
+ *
11
+ * We split on the first `@` so the address still reads naturally in tooltips
12
+ * and address-bar previews (`user@example.com` instead of `user%40example.com`),
13
+ * and percent-encode each side independently with `encodeURIComponent`. We
14
+ * also strip CR/LF defensively before encoding to short-circuit header
15
+ * injection even if a future bug allows them through validation upstream.
16
+ */
17
+ export function mailtoHref(email: string): string {
18
+ const cleaned = email.replace(/[\r\n]+/g, "").trim()
19
+ if (!cleaned) return "mailto:"
20
+
21
+ const at = cleaned.indexOf("@")
22
+ if (at <= 0 || at === cleaned.length - 1) {
23
+ return `mailto:${encodeURIComponent(cleaned)}`
24
+ }
25
+
26
+ const local = encodeURIComponent(cleaned.slice(0, at))
27
+ const domain = encodeURIComponent(cleaned.slice(at + 1))
28
+ return `mailto:${local}@${domain}`
29
+ }
@@ -7,7 +7,7 @@
7
7
  import type { ColumnDef } from "@/components/data-table/types"
8
8
  import type { Placement } from "@/lib/mock/placements"
9
9
 
10
- /** Mirrors PlacementLifecycleTabId without importing data-list-table (avoids circular imports). */
10
+ /** Mirrors PlacementLifecycleTabId without importing placements-table (avoids circular imports). */
11
11
  export type BoardCardLifecycleTabId = "all" | "upcoming" | "ongoing" | "completed"
12
12
 
13
13
  /** Default card fields per tab — intersected with visible table columns (except header-only rules below). */
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Product brand registry — single source of truth for wordmark + mark colors.
3
+ *
4
+ * Use {@link defineProductBrand} (or just author a `ProductBrandConfig` literal)
5
+ * to add a new product without touching the rendering code. The registry feeds
6
+ * {@link import("../components/product-wordmark").ProductWordmark} and
7
+ * {@link import("../components/product-wordmark").ProductMark}, which in turn
8
+ * power `ExxatProductLogo` / `ExxatProductMark` and the product switcher.
9
+ *
10
+ * Adding a new product:
11
+ *
12
+ * ```ts
13
+ * // 1. Author the brand
14
+ * const exxatPulse = defineProductBrand({
15
+ * id: "exxat-pulse",
16
+ * prefix: "Exxat",
17
+ * suffix: "Pulse",
18
+ * brandColor: "#00A8E8", // any CSS color
19
+ * markGradient: ["#0083C7", "#3FC6FF"], // optional 2-stop linear gradient
20
+ * })
21
+ *
22
+ * // 2. Register it (or just import the config where you render the logo)
23
+ * registerProductBrand(exxatPulse)
24
+ *
25
+ * // 3. Render anywhere
26
+ * <ProductWordmark config={exxatPulse} className="h-7" />
27
+ * ```
28
+ *
29
+ * The renderer uses Ivy Presto (Adobe Fonts kit `wuk5wqn`, loaded in
30
+ * `app/layout.tsx`) for the suffix so it reads as a real italic serif logo
31
+ * rather than fake "logo-styled" text. The prefix uses Inter extra-bold.
32
+ */
33
+
34
+ import {
35
+ DEFAULT_CUSTOM_PRODUCT_BRAND,
36
+ type CustomProductBrand,
37
+ type Product,
38
+ } from "@/stores/app-store"
39
+
40
+ export interface ProductBrandConfig {
41
+ /** Stable identifier — matches `Product` for the two built-in brands. */
42
+ id: string
43
+ /** Word before the suffix in the wordmark. Defaults to "Exxat". */
44
+ prefix?: string
45
+ /**
46
+ * Product suffix — rendered in Ivy Presto italic with `brandColor`.
47
+ * Should read as a single word; the wordmark renders it inline after
48
+ * `prefix` with a small em-based gap.
49
+ */
50
+ suffix: string
51
+ /**
52
+ * Primary brand color (any CSS color). Drives the suffix text fill and the
53
+ * mark's flat fill. When `markGradient` is provided, the gradient overrides
54
+ * the mark fill but the suffix text still uses `brandColor`.
55
+ */
56
+ brandColor: string
57
+ /** Optional logo suffix fill when product theme color differs from the logo art. */
58
+ wordmarkColor?: string
59
+ /** Use the circular Exxat mark + suffix only when the full wordmark would crowd the shell. */
60
+ compactLogo?: boolean
61
+ /**
62
+ * Optional 2-stop linear gradient for the circular mark. Two CSS color
63
+ * strings — first is the upper-left stop, second is the lower-right stop.
64
+ * Defaults to `[brandColor, brandColor]` (flat fill).
65
+ */
66
+ markGradient?: [string, string]
67
+ /**
68
+ * Darker tone for the inner cut-out behind the "E" glyph on the mark.
69
+ * Defaults to `brandColor` darkened by ~12 % (or you can pass an explicit
70
+ * color for finer control over contrast).
71
+ */
72
+ markShadow?: string
73
+ /**
74
+ * Optional accessible name override. Defaults to `"${prefix} ${suffix}"`.
75
+ * The wordmark itself stays `aria-hidden`; this name is used by parent
76
+ * affordances (logo links, switcher triggers) via {@link productBrandLabel}.
77
+ */
78
+ label?: string
79
+ }
80
+
81
+ /**
82
+ * Author-friendly factory — same signature as the type, plus runtime
83
+ * normalisation (trims strings, defaults `prefix` to "Exxat"). Use it when you
84
+ * want a quick literal in JSX; for module-level registry entries the literal
85
+ * `ProductBrandConfig` form is equally fine.
86
+ */
87
+ export function defineProductBrand(input: ProductBrandConfig): ProductBrandConfig {
88
+ return {
89
+ ...input,
90
+ prefix: (input.prefix ?? "Exxat").trim(),
91
+ suffix: input.suffix.trim(),
92
+ }
93
+ }
94
+
95
+ /** Human-readable label for a brand — used in aria-labels and dropdown copy. */
96
+ export function productBrandLabel(config: ProductBrandConfig): string {
97
+ if (config.label) return config.label
98
+ const prefix = config.prefix ?? "Exxat"
99
+ return `${prefix} ${config.suffix}`.replace(/\s+/g, " ").trim()
100
+ }
101
+
102
+ /* ── Built-in Exxat brands ─────────────────────────────────────────────────── */
103
+
104
+ /**
105
+ * Exxat brand pink — Pink 500 from `lib/exxat-palette.json`. Used to pin the
106
+ * **logo art** (mark gradient, shadow plate, suffix wordmark) across every
107
+ * product so the corporate identity stays consistent even when a product's
108
+ * accent / theme colour is recoloured by the user.
109
+ *
110
+ * Exact-hex match with `Pink 500` so the brand color picker highlights "Pink
111
+ * 500" instead of falling back to "Custom" when the user opens the popover
112
+ * for Exxat One.
113
+ */
114
+ const EXXAT_LOGO_PINK = "#E31C79" // palette: exxatPink-500
115
+ const EXXAT_LOGO_PINK_GRADIENT_END = "#EF609D" // ~ exxatPink-400 lifted
116
+ const EXXAT_LOGO_PINK_SHADOW = "#BE1E6D" // ~ exxatPink-600 deepened
117
+
118
+ /**
119
+ * Per-product **theme accent** defaults — these match palette swatches exactly
120
+ * so the BrandColorPicker labels each product's current colour ("Indigo 600",
121
+ * "Pink 550", "Green 550") instead of falling back to a generic "Custom" chip.
122
+ *
123
+ * They are also the closest palette mappings of the hardcoded `theme-one /
124
+ * theme-prism / theme-assessment` CSS hues in `app/globals.css`:
125
+ * - One → Lavender / Indigo (hue 286 ≈ Indigo 600, hue 278.83)
126
+ * - Prism → Rose (hue 342 ≈ Pink 550, hue 358.57)
127
+ * - Assessment → Green (hue 159.88 ≈ Green 550, hue 145.88)
128
+ *
129
+ * Picking another swatch flips the html theme class to `theme-custom` and
130
+ * writes the new colour into `--custom-product-brand-color` so chrome retints
131
+ * — see `ProductProvider`.
132
+ */
133
+ // Anchor each built-in to its palette family's **500** shade so the
134
+ // BrandColorPicker labels the current value by family name ("Indigo", "Pink",
135
+ // "Green") and the simplified one-anchor-per-family picker can highlight the
136
+ // default selection cleanly.
137
+ const EXXAT_ONE_ACCENT = "oklch(57.84% 0.1560 279.93)" // palette: exxatIndigo-500
138
+ const EXXAT_PRISM_ACCENT = "oklch(60.07% 0.2312 0.68)" // palette: exxatPink-500
139
+ const EXXAT_ASSESSMENT_ACCENT = "oklch(76.40% 0.0773 145.90)" // palette: green-500
140
+
141
+ export const EXXAT_ONE_BRAND: ProductBrandConfig = defineProductBrand({
142
+ id: "exxat-one",
143
+ prefix: "Exxat",
144
+ suffix: "One",
145
+ brandColor: EXXAT_ONE_ACCENT,
146
+ // Logo art pinned to pink — see EXXAT_LOGO_PINK doc. The picker only
147
+ // overrides `brandColor` (theme accent), never the logo colours.
148
+ wordmarkColor: EXXAT_LOGO_PINK,
149
+ markGradient: [EXXAT_LOGO_PINK, EXXAT_LOGO_PINK_GRADIENT_END],
150
+ markShadow: EXXAT_LOGO_PINK_SHADOW,
151
+ })
152
+
153
+ export const EXXAT_PRISM_BRAND: ProductBrandConfig = defineProductBrand({
154
+ id: "exxat-prism",
155
+ prefix: "Exxat",
156
+ suffix: "Prism",
157
+ brandColor: EXXAT_PRISM_ACCENT,
158
+ wordmarkColor: EXXAT_LOGO_PINK,
159
+ markGradient: [EXXAT_LOGO_PINK, EXXAT_LOGO_PINK_GRADIENT_END],
160
+ markShadow: EXXAT_LOGO_PINK_SHADOW,
161
+ })
162
+
163
+ export const EXXAT_ASSESSMENT_BRAND: ProductBrandConfig = defineProductBrand({
164
+ id: "exxat-assessment",
165
+ prefix: "Exxat",
166
+ suffix: "Assessment",
167
+ brandColor: EXXAT_ASSESSMENT_ACCENT,
168
+ // Logo art pinned to pink — Exxat brand identity is shared across every
169
+ // product. The colour picker only controls the **theme accent / chrome**
170
+ // (via `applyBrandColorOverride` → `brandColor`), never the mark / suffix.
171
+ wordmarkColor: EXXAT_LOGO_PINK,
172
+ markGradient: [EXXAT_LOGO_PINK, EXXAT_LOGO_PINK_GRADIENT_END],
173
+ markShadow: EXXAT_LOGO_PINK_SHADOW,
174
+ compactLogo: true,
175
+ })
176
+
177
+ export function customProductBrandConfig(
178
+ customBrand: CustomProductBrand | null | undefined = DEFAULT_CUSTOM_PRODUCT_BRAND,
179
+ ): ProductBrandConfig {
180
+ const suffix = customBrand?.suffix.trim() || DEFAULT_CUSTOM_PRODUCT_BRAND.suffix
181
+ const brandColor =
182
+ customBrand?.brandColor.trim() || DEFAULT_CUSTOM_PRODUCT_BRAND.brandColor
183
+ // Logo pinned to pink across every product (Exxat brand identity).
184
+ // `brandColor` is the **theme / accent** colour only (drives `theme-custom`
185
+ // chrome via `--custom-product-brand-color`), never the wordmark tint.
186
+ return defineProductBrand({
187
+ id: "exxat-custom",
188
+ prefix: "Exxat",
189
+ suffix,
190
+ brandColor,
191
+ wordmarkColor: EXXAT_LOGO_PINK,
192
+ markGradient: [EXXAT_LOGO_PINK, EXXAT_LOGO_PINK_GRADIENT_END],
193
+ markShadow: EXXAT_LOGO_PINK_SHADOW,
194
+ compactLogo: suffix.length > 8,
195
+ })
196
+ }
197
+
198
+ /**
199
+ * Apply a per-product brand color override to a base config — used when the
200
+ * user picks a colour for a built-in product (One / Prism / Assessment) in
201
+ * Settings → Appearance.
202
+ *
203
+ * **Only `brandColor` is overridden** — the wordmark / mark stay pinned to
204
+ * the Exxat brand pink defined on the base config. `brandColor` is the
205
+ * product **accent / theme** colour (it drives `theme-custom` chrome via
206
+ * `--custom-product-brand-color`), so recolouring a product changes the
207
+ * surrounding UI without ever rebranding the corporate logo.
208
+ *
209
+ * Keeping `compactLogo`, `prefix`, `suffix`, `wordmarkColor`, `markGradient`,
210
+ * and `markShadow` from the base config means the picker can never
211
+ * accidentally change the product's logo identity.
212
+ */
213
+ export function applyBrandColorOverride(
214
+ config: ProductBrandConfig,
215
+ color: string | undefined | null,
216
+ ): ProductBrandConfig {
217
+ const next = color?.trim()
218
+ if (!next) return config
219
+ return { ...config, brandColor: next }
220
+ }
221
+
222
+ /* ── Registry ──────────────────────────────────────────────────────────────── */
223
+
224
+ const REGISTRY = new Map<string, ProductBrandConfig>()
225
+
226
+ /** Register a brand. Idempotent — re-registering overrides the existing entry. */
227
+ export function registerProductBrand(config: ProductBrandConfig): void {
228
+ REGISTRY.set(config.id, config)
229
+ }
230
+
231
+ /** Look up a brand by id. Returns `undefined` if not registered. */
232
+ export function getProductBrand(id: string): ProductBrandConfig | undefined {
233
+ return REGISTRY.get(id)
234
+ }
235
+
236
+ /** All registered brands in insertion order. */
237
+ export function listProductBrands(): ProductBrandConfig[] {
238
+ return Array.from(REGISTRY.values())
239
+ }
240
+
241
+ // Eagerly register the two built-ins so call-sites can `getProductBrand("exxat-one")`
242
+ // without an explicit setup step. Safe to call multiple times (Map dedupes on key).
243
+ registerProductBrand(EXXAT_ONE_BRAND)
244
+ registerProductBrand(EXXAT_PRISM_BRAND)
245
+ registerProductBrand(EXXAT_ASSESSMENT_BRAND)
246
+ registerProductBrand(customProductBrandConfig())
247
+
248
+ /**
249
+ * Convenience helper for the existing `Product` union — resolves to the
250
+ * Exxat-branded config, falling back to {@link EXXAT_ONE_BRAND}. Callers that
251
+ * already have a `Product` value (e.g. `useProduct().product`) can use this
252
+ * instead of touching the registry directly.
253
+ *
254
+ * `productBrandColors` is the per-product override map from the app store
255
+ * (`useProduct().productBrandColors`). When set, the matching entry retints
256
+ * the resolved config end-to-end (mark + suffix). Pass `undefined` or `{}` to
257
+ * use the registry defaults.
258
+ */
259
+ export function brandForProduct(
260
+ product: Product,
261
+ customBrand?: CustomProductBrand | null,
262
+ productBrandColors?: Partial<Record<Product, string>>,
263
+ ): ProductBrandConfig {
264
+ const base = product === "exxat-custom"
265
+ ? customProductBrandConfig(customBrand)
266
+ : getProductBrand(product) ?? EXXAT_ONE_BRAND
267
+ return applyBrandColorOverride(base, productBrandColors?.[product])
268
+ }