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
24 changes: 24 additions & 0 deletions branding-examples/README.md
Original file line number Diff line number Diff line change
@@ -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 `<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
```

## 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.
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)"
}
}
}
}
98 changes: 98 additions & 0 deletions common/branding/__tests__/color-tokens.test.ts
Original file line number Diff line number Diff line change
@@ -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('')
})
})
120 changes: 120 additions & 0 deletions common/branding/color-tokens.ts
Original file line number Diff line number Diff line change
@@ -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 `<style>` tag at runtime, so they reach
// the browser as raw CSS. Values MUST be sanitized before emission to prevent
// stylesheet escape via `;`, `}`, comment markers, etc.

// Per-mode color overrides from `BrandingConfig.design_tokens.colors`.
export type ColorTokens = {
light?: { [key: string]: string }
dark?: { [key: string]: string }
}

// Allowlist of CSS variable names that may be overridden. Mirrors cloud-ui's
// list so a single `BrandingConfig` JSON is portable across both surfaces.
// Studio's `renderer/src/index.css` declares all of these except
// `avatar-background` and `destructive-foreground`; those two are silently
// no-ops here until studio's CSS adds them, but stay in the allowlist so
// configs authored for cloud-ui don't trip the unknown-key drop. The leading
// `--` is added at serialization time.
const COLOR_TOKEN_KEYS = [
'background',
'foreground',
'card',
'card-foreground',
'popover',
'popover-foreground',
'primary',
'primary-foreground',
'secondary',
'secondary-foreground',
'muted',
'muted-foreground',
'accent',
'accent-foreground',
'destructive',
'destructive-foreground',
'border',
'input',
'ring',
'avatar-background',
'nav-background',
'nav-border',
'nav-button-active-bg',
'nav-button-active-text',
'nav-foreground',
'success',
'warning',
'warning-foreground',
'info',
'info-foreground',
'sidebar',
'sidebar-foreground',
'sidebar-primary',
'sidebar-primary-foreground',
'sidebar-accent',
'sidebar-accent-foreground',
'sidebar-border',
'sidebar-ring',
] as const

type ColorTokenKey = (typeof COLOR_TOKEN_KEYS)[number]

const COLOR_TOKEN_KEY_SET: ReadonlySet<string> = new Set(COLOR_TOKEN_KEYS)

// Longest plausible CSS color value (e.g. `oklch(0.5849 0.095 159.91 / 100%)`)
// is well under 50 chars; 100 is generous enough to absorb formatting variation
// while still capping pathological input that could explode the `<style>` body.
const MAX_VALUE_LENGTH = 100

// Reject any character or sequence that could close the property/declaration
// or open a comment inside the emitted `<style>` block. `url(` is blocked as
// defense-in-depth.
const UNSAFE_VALUE_PATTERN = /[;{}<>\n\r\\]|\/\*|\*\/|url\(/i

function isColorTokenKey(key: string): key is ColorTokenKey {
return COLOR_TOKEN_KEY_SET.has(key)
}

function isValidColorTokenValue(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= MAX_VALUE_LENGTH &&
!UNSAFE_VALUE_PATTERN.test(value)
)
}

// Render one mode's color token map as a CSS declaration list (no surrounding
// selector). Unknown keys and unsafe values are dropped silently — partial
// output is preferable to no override at all.
export function tokensToCssDeclarations(
tokens: ColorTokens['light'] | undefined
): string {
if (!tokens) return ''
const parts: string[] = []
for (const [key, value] of Object.entries(tokens)) {
if (!isColorTokenKey(key)) continue
if (!isValidColorTokenValue(value)) continue
parts.push(`--${key}: ${value};`)
}
return parts.join(' ')
}

// Selector choice — `:root:not(.dark)` for light, `.dark` for dark — matches
// cloud-ui. The `:not(.dark)` is load-bearing: a bare `:root` would tie with
// `.dark` on specificity and leak light values into dark mode via source
// order. `:root:not(.dark)` doesn't match in dark mode, so the cascade picks
// up the default `.dark` declaration in `renderer/src/index.css`.
export function colorTokensToStyleContent(
tokens: ColorTokens | null | undefined
): string {
if (!tokens) return ''
const lightDecls = tokensToCssDeclarations(tokens.light)
const darkDecls = tokensToCssDeclarations(tokens.dark)
const blocks: string[] = []
if (lightDecls) blocks.push(`:root:not(.dark) { ${lightDecls} }`)
if (darkDecls) blocks.push(`.dark { ${darkDecls} }`)
return blocks.join(' ')
}
35 changes: 35 additions & 0 deletions common/branding/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { z } from 'zod/v4'

// SEP#725 — Shape of the JSON at the branding config path. Matches cloud-ui's
// `BrandingConfig` (`sep/enterprise/toolhive-cloud-ui/src/lib/branding-config.ts`).
// Field names are snake-cased so operators write idiomatic JSON. All fields
// are optional; missing fields fall back to the bundled defaults.
//
// Studio currently consumes only `design_tokens.colors`; the non-color fields
// (`app_name`, `logo_url`, `favicon_url`) are parsed for forward compatibility
// but not yet applied — see the "Non-color fields" deferred decision in
// scratchpad / SEP#725.

const colorMap = z.record(z.string(), z.string())

export const brandingConfigSchema = z
.object({
app_name: z.string().optional(),
logo_url: z.string().optional(),
favicon_url: z.string().optional(),
design_tokens: z
.object({
colors: z
.object({
light: colorMap.optional(),
dark: colorMap.optional(),
})
.optional(),
})
.optional(),
})
// Reject unknown top-level keys silently rather than failing — operators
// may pass through extra fields the config server adds in the future.
.loose()

export type BrandingConfig = z.infer<typeof brandingConfigSchema>
Loading
Loading