diff --git a/branding-examples/README.md b/branding-examples/README.md new file mode 100644 index 000000000..945da3490 --- /dev/null +++ b/branding-examples/README.md @@ -0,0 +1,38 @@ +# 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. +- `cloud-ui.json` — Port of `toolhive-cloud-ui`'s default palette (zinc primary, + dark-green nav, green success / accent). Bare HSL triplets from the source are + wrapped in `hsl(…)` so they resolve in studio's bare-`var` consumers. Useful + for previewing what studio looks like when aligned with the cloud surface. + +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 +# or to test the cloud-ui-aligned palette: +BRANDING_CONFIG_PATH="$PWD/branding-examples/cloud-ui.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/cloud-ui.json b/branding-examples/cloud-ui.json new file mode 100644 index 000000000..ec0812d17 --- /dev/null +++ b/branding-examples/cloud-ui.json @@ -0,0 +1,79 @@ +{ + "app_name": "ToolHive (cloud-ui palette)", + "design_tokens": { + "colors": { + "light": { + "background": "hsl(0 0% 100%)", + "foreground": "hsl(240 10% 3.9%)", + "card": "hsl(0 0% 100%)", + "card-foreground": "hsl(240 10% 3.9%)", + "popover": "hsl(0 0% 100%)", + "popover-foreground": "hsl(240 10% 3.9%)", + "primary": "hsl(240 5.9% 10%)", + "primary-foreground": "hsl(0 0% 98%)", + "secondary": "hsl(240 4.8% 95.9%)", + "secondary-foreground": "hsl(240 5.9% 10%)", + "muted": "hsl(240 4.8% 95.9%)", + "muted-foreground": "hsl(240 3.8% 46.1%)", + "accent": "hsl(140 30% 95%)", + "accent-foreground": "hsl(150 20% 15%)", + "destructive": "hsl(0 84.2% 60.2%)", + "destructive-foreground": "hsl(0 0% 98%)", + "border": "hsl(240 5.9% 90%)", + "input": "hsl(240 5.9% 90%)", + "ring": "hsl(240 5.9% 10%)", + "avatar-background": "oklch(0.696 0 0 / 89.8%)", + "nav-background": "#18442e", + "nav-border": "#265b41", + "nav-button-active-bg": "#398560", + "nav-button-active-text": "#ffffff", + "success": "oklch(0.5849 0.095 159.91)", + "warning": "oklch(0.769 0.158 70.08)", + "sidebar": "hsl(40 20% 98.5%)", + "sidebar-foreground": "hsl(240 5.3% 26.1%)", + "sidebar-primary": "hsl(240 5.9% 10%)", + "sidebar-primary-foreground": "hsl(0 0% 98%)", + "sidebar-accent": "hsl(240 4.8% 95.9%)", + "sidebar-accent-foreground": "hsl(240 5.9% 10%)", + "sidebar-border": "hsl(220 13% 91%)", + "sidebar-ring": "hsl(217.2 91.2% 59.8%)" + }, + "dark": { + "background": "hsl(0 0% 7.5%)", + "foreground": "hsl(0 0% 98%)", + "card": "hsl(240 10% 3.9%)", + "card-foreground": "hsl(0 0% 98%)", + "popover": "hsl(240 10% 3.9%)", + "popover-foreground": "hsl(0 0% 98%)", + "primary": "hsl(0 0% 98%)", + "primary-foreground": "hsl(240 5.9% 10%)", + "secondary": "hsl(240 3.7% 15.9%)", + "secondary-foreground": "hsl(0 0% 98%)", + "muted": "hsl(240 3.7% 15.9%)", + "muted-foreground": "hsl(240 5% 64.9%)", + "accent": "hsl(150 40% 14%)", + "accent-foreground": "hsl(0 0% 98%)", + "destructive": "hsl(0 62.8% 30.6%)", + "destructive-foreground": "hsl(0 0% 98%)", + "border": "hsl(240 3.7% 15.9%)", + "input": "hsl(240 3.7% 15.9%)", + "ring": "hsl(240 4.9% 83.9%)", + "avatar-background": "oklch(0.696 0 0 / 89.8%)", + "nav-background": "#0f2e1e", + "nav-border": "#1a4430", + "nav-button-active-bg": "#2d6b4a", + "nav-button-active-text": "#f0f0f0", + "success": "oklch(0.724 0.1091 160.66)", + "warning": "oklch(0.828 0.159 70.13)", + "sidebar": "hsl(0 0% 7.5%)", + "sidebar-foreground": "hsl(240 4.8% 95.9%)", + "sidebar-primary": "hsl(224.3 76.3% 48%)", + "sidebar-primary-foreground": "hsl(0 0% 100%)", + "sidebar-accent": "hsl(240 3.7% 15.9%)", + "sidebar-accent-foreground": "hsl(240 4.8% 95.9%)", + "sidebar-border": "hsl(240 3.7% 15.9%)", + "sidebar-ring": "hsl(217.2 91.2% 59.8%)" + } + } + } +} 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..b158d03e2 --- /dev/null +++ b/common/branding/__tests__/color-tokens.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + colorTokensToStyleContent, + tokensToCssDeclarations, +} from '@common/branding/color-tokens' + +let warnSpy: ReturnType + +beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + warnSpy.mockRestore() +}) + +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 with a warn-log', () => { + const out = tokensToCssDeclarations({ + 'not-a-real-key': '#000', + primary: '#fff', + }) + expect(out).toBe('--primary: #fff;') + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('unknown token "not-a-real-key"') + ) + }) + + 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('') + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('unsafe value for "primary"') + ) + }) + + it('drops non-string values (numbers, null) with a warn-log', () => { + const out = tokensToCssDeclarations({ primary: 123, secondary: null }) + expect(out).toBe('') + expect(warnSpy).toHaveBeenCalledTimes(2) + }) + + 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 undefined', () => { + expect(colorTokensToStyleContent(undefined)).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..5931f66f2 --- /dev/null +++ b/common/branding/color-tokens.ts @@ -0,0 +1,126 @@ +// Brand-color override serialization. Values are emitted into a `