Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions branding-examples/README.md
Original file line number Diff line number Diff line change
@@ -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 `<userData>/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 `<color>` value.
79 changes: 79 additions & 0 deletions branding-examples/cloud-ui.json
Original file line number Diff line number Diff line change
@@ -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%)"
}
}
}
}
87 changes: 87 additions & 0 deletions branding-examples/test-theme.json
Original file line number Diff line number Diff line change
@@ -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)"
}
}
}
}
118 changes: 118 additions & 0 deletions common/branding/__tests__/color-tokens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
colorTokensToStyleContent,
tokensToCssDeclarations,
} from '@common/branding/color-tokens'

let warnSpy: ReturnType<typeof vi.spyOn>

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('')
})
})
Loading
Loading