@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
|
@@ -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
|
-
* (
|
|
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/
|
|
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
|
|
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
|
+
}
|