From ae696478b4dc1eecddf98fd49b5eef1c58a948e4 Mon Sep 17 00:00:00 2001 From: Daniel Kantor Date: Fri, 15 May 2026 19:35:15 +0200 Subject: [PATCH 1/6] feat: add color scheme customization --- branding-examples/README.md | 24 +++ branding-examples/test-theme.json | 87 +++++++++++ .../branding/__tests__/color-tokens.test.ts | 98 ++++++++++++ common/branding/color-tokens.ts | 120 +++++++++++++++ common/branding/schema.ts | 35 +++++ main/src/branding/__tests__/load.test.ts | 144 ++++++++++++++++++ main/src/branding/load.ts | 73 +++++++++ main/src/branding/paths.ts | 23 +++ main/src/main-window.ts | 10 ++ package.json | 1 + preload/src/api/__tests__/branding.test.ts | 53 +++++++ preload/src/api/branding.ts | 39 +++++ preload/src/preload.ts | 5 +- renderer/src/common/mocks/electronAPI.ts | 1 + renderer/src/index.css | 19 +++ renderer/src/renderer.tsx | 14 ++ scripts/start-custom-theme.ts | 38 +++++ vitest.setup.ts | 1 + 18 files changed, 784 insertions(+), 1 deletion(-) create mode 100644 branding-examples/README.md create mode 100644 branding-examples/test-theme.json create mode 100644 common/branding/__tests__/color-tokens.test.ts create mode 100644 common/branding/color-tokens.ts create mode 100644 common/branding/schema.ts create mode 100644 main/src/branding/__tests__/load.test.ts create mode 100644 main/src/branding/load.ts create mode 100644 main/src/branding/paths.ts create mode 100644 preload/src/api/__tests__/branding.test.ts create mode 100644 preload/src/api/branding.ts create mode 100644 scripts/start-custom-theme.ts diff --git a/branding-examples/README.md b/branding-examples/README.md new file mode 100644 index 000000000..a690fd671 --- /dev/null +++ b/branding-examples/README.md @@ -0,0 +1,24 @@ +# Branding examples + +JSON files matching the `BrandingConfig` schema (`common/branding/schema.ts`). Point `BRANDING_CONFIG_PATH` at one and launch — they replace the default Stacklok palette without touching code. + +- `test-theme.json` — wine / coral / gold palette, deliberately distinct from studio's defaults. Used by `pnpm run start:customTheme` to stress-test that every themeable surface is wired to the override. + +To use any of these on a normal launch, copy to `/branding-0.json`: + +```bash +# Linux +cp branding-examples/test-theme.json ~/.config/ToolHive/branding-0.json +# macOS +cp branding-examples/test-theme.json "$HOME/Library/Application Support/ToolHive/branding-0.json" +``` + +Or override the path entirely: + +```bash +BRANDING_CONFIG_PATH="$PWD/branding-examples/test-theme.json" pnpm run start +``` + +## Schema + +See `common/branding/schema.ts`. Values must be complete CSS colors (`#hex`, `hsl(...)`, `oklch(...)`, `oklab(...)`, `rgb(...)`, `lab(...)`, `lch(...)`, `color(...)`, or CSS named colors). Bare HSL triplets like `0 60% 20%` are **not** supported — studio consumes vars as bare `var(--X)`, which needs a complete `` value. diff --git a/branding-examples/test-theme.json b/branding-examples/test-theme.json new file mode 100644 index 000000000..e3594e632 --- /dev/null +++ b/branding-examples/test-theme.json @@ -0,0 +1,87 @@ +{ + "app_name": "ToolHive (test-theme)", + "design_tokens": { + "colors": { + "light": { + "background": "oklch(0.98 0.005 30)", + "foreground": "oklch(0.18 0.04 350)", + "card": "oklch(0.99 0.008 30)", + "card-foreground": "oklch(0.18 0.04 350)", + "popover": "oklch(0.99 0.008 30)", + "popover-foreground": "oklch(0.18 0.04 350)", + "primary": "oklch(0.40 0.20 15)", + "primary-foreground": "oklch(0.98 0.005 30)", + "secondary": "oklch(0.94 0.03 30)", + "secondary-foreground": "oklch(0.28 0.10 15)", + "muted": "oklch(0.93 0.02 30)", + "muted-foreground": "oklch(0.50 0.05 30)", + "accent": "oklch(0.86 0.13 55)", + "accent-foreground": "oklch(0.25 0.10 15)", + "destructive": "oklch(0.55 0.25 25)", + "destructive-foreground": "oklch(0.98 0.005 30)", + "border": "oklch(0.88 0.04 25)", + "input": "oklch(0.88 0.04 25)", + "ring": "oklch(0.50 0.20 15)", + "avatar-background": "oklch(0.70 0.10 30 / 89.8%)", + "nav-background": "oklch(0.30 0.15 10)", + "nav-border": "oklch(0.38 0.16 10)", + "nav-button-active-bg": "oklch(0.50 0.22 20)", + "nav-button-active-text": "oklch(0.98 0.005 30)", + "nav-foreground": "oklch(0.98 0.005 30)", + "success": "oklch(0.55 0.15 145)", + "warning": "oklch(0.78 0.17 75)", + "warning-foreground": "oklch(0.22 0.08 75)", + "info": "oklch(0.60 0.18 35)", + "info-foreground": "oklch(0.98 0.005 30)", + "sidebar": "oklch(0.96 0.02 30)", + "sidebar-foreground": "oklch(0.25 0.06 15)", + "sidebar-primary": "oklch(0.40 0.20 15)", + "sidebar-primary-foreground": "oklch(0.98 0.005 30)", + "sidebar-accent": "oklch(0.92 0.05 30)", + "sidebar-accent-foreground": "oklch(0.30 0.10 15)", + "sidebar-border": "oklch(0.86 0.04 25)", + "sidebar-ring": "oklch(0.55 0.20 15)" + }, + "dark": { + "background": "oklch(0.16 0.05 350)", + "foreground": "oklch(0.96 0.02 30)", + "card": "oklch(0.20 0.06 350)", + "card-foreground": "oklch(0.96 0.02 30)", + "popover": "oklch(0.20 0.06 350)", + "popover-foreground": "oklch(0.96 0.02 30)", + "primary": "oklch(0.75 0.16 25)", + "primary-foreground": "oklch(0.16 0.05 350)", + "secondary": "oklch(0.26 0.07 350)", + "secondary-foreground": "oklch(0.96 0.02 30)", + "muted": "oklch(0.26 0.07 350)", + "muted-foreground": "oklch(0.72 0.05 30)", + "accent": "oklch(0.42 0.16 25)", + "accent-foreground": "oklch(0.96 0.02 30)", + "destructive": "oklch(0.55 0.25 25)", + "destructive-foreground": "oklch(0.96 0.02 30)", + "border": "oklch(0.30 0.07 350)", + "input": "oklch(0.30 0.07 350)", + "ring": "oklch(0.70 0.18 25)", + "avatar-background": "oklch(0.50 0.10 30 / 89.8%)", + "nav-background": "oklch(0.10 0.05 350)", + "nav-border": "oklch(0.22 0.07 350)", + "nav-button-active-bg": "oklch(0.50 0.22 20)", + "nav-button-active-text": "oklch(0.98 0.005 30)", + "nav-foreground": "oklch(0.98 0.005 30)", + "success": "oklch(0.65 0.15 145)", + "warning": "oklch(0.80 0.16 75)", + "warning-foreground": "oklch(0.22 0.08 75)", + "info": "oklch(0.70 0.16 35)", + "info-foreground": "oklch(0.16 0.05 350)", + "sidebar": "oklch(0.13 0.05 350)", + "sidebar-foreground": "oklch(0.94 0.03 30)", + "sidebar-primary": "oklch(0.75 0.16 25)", + "sidebar-primary-foreground": "oklch(0.16 0.05 350)", + "sidebar-accent": "oklch(0.26 0.07 350)", + "sidebar-accent-foreground": "oklch(0.94 0.03 30)", + "sidebar-border": "oklch(0.26 0.07 350)", + "sidebar-ring": "oklch(0.70 0.18 25)" + } + } + } +} diff --git a/common/branding/__tests__/color-tokens.test.ts b/common/branding/__tests__/color-tokens.test.ts new file mode 100644 index 000000000..774fbedd2 --- /dev/null +++ b/common/branding/__tests__/color-tokens.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { + colorTokensToStyleContent, + tokensToCssDeclarations, +} from '@common/branding/color-tokens' + +describe('tokensToCssDeclarations', () => { + it('returns an empty string for undefined', () => { + expect(tokensToCssDeclarations(undefined)).toBe('') + }) + + it('returns an empty string for an empty object', () => { + expect(tokensToCssDeclarations({})).toBe('') + }) + + it('emits a known key with a valid value', () => { + expect(tokensToCssDeclarations({ primary: '#ff6600' })).toBe( + '--primary: #ff6600;' + ) + }) + + it('joins multiple keys with single spaces', () => { + const out = tokensToCssDeclarations({ + primary: '#ff6600', + secondary: '#0066ff', + }) + expect(out).toBe('--primary: #ff6600; --secondary: #0066ff;') + }) + + it('drops unknown keys silently', () => { + // ColorTokens permits any string key on the wire; the runtime allowlist + // filters non-token keys before they're emitted as CSS variables. + const out = tokensToCssDeclarations({ + 'not-a-real-key': '#000', + primary: '#fff', + }) + expect(out).toBe('--primary: #fff;') + }) + + it.each([ + ['semicolon-injection', '#fff; background: url(x)'], + ['closing-brace', '#fff }'], + ['opening-comment', '/* nope'], + ['closing-comment', '*/'], + ['newline', '#fff\n--evil: 1'], + ['empty-string', ''], + ['over-length', 'a'.repeat(101)], + ])('drops values rejected by the safety check (%s)', (_label, badValue) => { + expect(tokensToCssDeclarations({ primary: badValue })).toBe('') + }) + + it('accepts oklch and hsl values', () => { + const out = tokensToCssDeclarations({ + 'nav-background': 'oklch(0.4282 0.0561 216.14)', + sidebar: 'hsl(40 20% 98.5%)', + }) + expect(out).toContain('--nav-background: oklch(0.4282 0.0561 216.14);') + expect(out).toContain('--sidebar: hsl(40 20% 98.5%);') + }) +}) + +describe('colorTokensToStyleContent', () => { + it('returns an empty string when theme is null', () => { + expect(colorTokensToStyleContent(null)).toBe('') + }) + + it('returns an empty string when theme is empty', () => { + expect(colorTokensToStyleContent({})).toBe('') + }) + + it('emits a :root:not(.dark) block for light overrides', () => { + const out = colorTokensToStyleContent({ light: { primary: '#fff' } }) + expect(out).toBe(':root:not(.dark) { --primary: #fff; }') + }) + + it('emits a .dark block for dark overrides', () => { + const out = colorTokensToStyleContent({ dark: { primary: '#000' } }) + expect(out).toBe('.dark { --primary: #000; }') + }) + + it('emits both blocks when both modes are set', () => { + const out = colorTokensToStyleContent({ + light: { primary: '#fff' }, + dark: { primary: '#000' }, + }) + expect(out).toBe( + ':root:not(.dark) { --primary: #fff; } .dark { --primary: #000; }' + ) + }) + + it('returns an empty string when every supplied value is invalid', () => { + expect( + colorTokensToStyleContent({ + light: { primary: 'color: red; }' }, + }) + ).toBe('') + }) +}) diff --git a/common/branding/color-tokens.ts b/common/branding/color-tokens.ts new file mode 100644 index 000000000..4164c4240 --- /dev/null +++ b/common/branding/color-tokens.ts @@ -0,0 +1,120 @@ +// SEP#725 — brand color customization. Port of cloud-ui's `color-tokens.ts` +// (see `sep/enterprise/toolhive-cloud-ui/src/lib/color-tokens.ts`). +// +// Override values are emitted into a `