10 KiB
Branding — Volume Mount Contract
Version: 0.7.3 Status: Spec
Overview
Chat Switchboard supports white-label branding through a volume mount at
/branding/ on the frontend container. Deployers provide a ConfigMap (or
bind mount) with their identity assets. The app reads a JSON config on
startup, serves static assets at runtime, and falls back gracefully when
no branding is mounted.
Switchboard provides the hooks. Your brand lives in its own repo.
Mount Path
/branding/ ← volume mount root (frontend container)
├── branding.json ← config seed (required if mount exists)
├── favicon.png ← tab/bookmark icon
├── logo.png ← splash page hero, optional sidebar
└── custom.css ← style overrides (power-user escape hatch)
All files are optional individually, but branding.json is expected if the
mount exists. Missing files degrade gracefully — no broken images, no JS errors.
branding.json — Config Seed
{
"org_name": "Gobha.ai",
"tagline": "Something clever here",
"accent_color": "#4a9eff",
"logo": "logo.png",
"favicon": "favicon.png"
}
Fields:
| Field | Type | Default | Description |
|---|---|---|---|
org_name |
string | "Chat Switchboard" |
Displayed in splash, header, auth card, <title> |
tagline |
string | "Multi-Model AI Chat" |
Splash page subtitle, auth footer |
headline |
string | null |
Splash hero headline (default preserved if null) |
accent_color |
string | "#4a9eff" |
Primary UI accent (hex) |
logo |
string | null |
Filename relative to /branding/ |
favicon |
string | null |
Filename relative to /branding/ |
pills |
array | (Switchboard defaults) | Feature pills on splash. [] = hide. See below. |
Pills format:
"pills": [
{ "icon": "⚡", "text": "Fast inference", "style": "accent" },
{ "icon": "🔒", "text": "Zero trust", "style": "purple" },
{ "icon": "🏢", "text": "Enterprise ready" }
]
style is optional: "accent" uses the accent color, "purple" uses purple,
omit for the default neutral pill. Set "pills": [] to hide the section entirely.
Omit the field to keep the stock Switchboard pills.
Lifecycle:
- Frontend entrypoint (
docker-entrypoint-fe.sh) reads/branding/branding.jsonon container start - Values are injected into
index.htmlas a<script>block:window.__BRANDING__ - The
brandingkey is also upserted intoglobal_settingsvia the backend on startup (admin panel override layer — if admins change values in the UI, those take precedence until the next cold deploy with a branding mount) - Frontend
initBranding()applies values fromwindow.__BRANDING__(fast, no API call) then overlays any DB overrides fromApp.serverSettings.branding(from public settings)
Resolution order: branding.json (fast init) → DB global_settings.branding (override layer)
favicon.png — Static Asset
Served at /branding/favicon.png by nginx. The frontend <link rel="icon"> is
set dynamically by initBranding():
// If branding favicon exists, use it; otherwise keep built-in default
document.querySelector('link[rel="icon"]').href = '/branding/favicon.png';
Recommendations:
- PNG format (modern browsers prefer it over ICO)
- 32×32 minimum, 192×192 recommended (covers PWA + high-DPI)
- Transparent background works best with dark themes
logo.png — Static Asset
Served at /branding/logo.png. Used in:
- Splash page hero (replaces the 🔀 emoji)
- Sidebar header (optional, depends on size)
Recommendations:
- PNG with transparency
- Max 512×512 (larger files are wasteful; displayed at ~80px on splash)
- Aspect ratio: square or landscape (tall logos will be clamped)
custom.css — Style Extension
Loaded after the main stylesheet:
<link rel="stylesheet" href="/branding/custom.css" onerror="this.remove()">
The onerror handler silently removes the tag if the file doesn't exist (no 404
console noise in unbrandeded deployments).
What you can do:
- Override
--accent-colorand any other CSS custom property - Change fonts (
@importor@font-facewith files in/branding/) - Add background textures or patterns
- Hide elements you don't want (
display: none) - Override specific component styles
What you shouldn't do:
- Rely on internal class names that may change between versions
- Override layout properties (
flex,grid) unless you're testing against the current release - Import external resources (breaks airgapped deployments)
Example:
:root {
--accent-color: #e74c3c;
--bg-primary: #1a1a2e;
}
.splash-logo img {
border-radius: 50%;
}
Frontend Touchpoints
These are the DOM elements and CSS properties that branding affects:
| Element / Property | Default Value | Branding Source |
|---|---|---|
<title> |
Chat Switchboard |
org_name |
.brand-text |
Chat Switchboard |
org_name |
.hero-wordmark |
Chat Switchboard |
org_name |
.hero-headline |
One interface. Every AI model. |
headline |
.hero-sub |
(default description) | tagline |
.splash-logo |
SVG switchboard icon | logo.png → <img> |
link[rel="icon"] |
favicon-32.png |
favicon.png |
--accent-color |
#4a9eff |
accent_color |
.auth-card-header h2 |
Welcome back |
Welcome to {org_name} |
.auth-card-header p |
Sign in to continue to your workspace |
Sign in to continue |
.auth-footer p |
Self-hosted AI chat... |
tagline |
.hero-features |
Switchboard feature pills | pills array or [] to hide |
Nginx Configuration
The frontend entrypoint adds a branding location block. For path-based deployments,
the block is under BASE_PATH so Traefik routes requests to the correct pod:
# Root deployment (no BASE_PATH):
location /branding/ {
alias /branding/;
expires 1h;
add_header Cache-Control "public";
try_files $uri =404;
}
# Path-based deployment (e.g. /dev, /test):
location ${BASE_PATH}/branding/ {
alias /branding/;
expires 1h;
add_header Cache-Control "public";
try_files $uri =404;
}
Frontend JS resolves paths via window.__BASE__ + '/branding/' so URLs
automatically include the environment prefix.
Short cache (1h) so branding updates via ConfigMap rollout are picked up without requiring users to hard-refresh.
K8s Deployment
The frontend deployment mounts the branding ConfigMap:
spec:
containers:
- name: frontend
volumeMounts:
- name: branding
mountPath: /branding
readOnly: true
volumes:
- name: branding
configMap:
name: switchboard-branding
optional: true # ← app works without it
The optional: true is critical — Switchboard deploys cleanly with zero
branding config. The switchboard-gobha-ai repo (or any deployer's
equivalent) creates this ConfigMap.
Deployer Repo Structure (Example: switchboard-gobha-ai)
switchboard-gobha-ai/
├── branding/
│ ├── branding.json
│ ├── favicon.png
│ ├── logo.png
│ └── custom.css
├── k8s/
│ └── configmap.yaml
├── README.md
└── .gitea/
└── workflows/
└── deploy.yaml
configmap.yaml:
apiVersion: v1
kind: ConfigMap
metadata:
name: switchboard-branding
namespace: ${NAMESPACE}
data:
branding.json: |
{
"org_name": "Gobha.ai",
"tagline": "Your tagline",
"accent_color": "#4a9eff"
}
binaryData:
favicon.png: <base64-encoded>
logo.png: <base64-encoded>
custom.css: <base64-encoded-or-use-data>
Note: For binary files in ConfigMaps, use binaryData with base64 encoding.
Alternatively, use a script that creates the ConfigMap from files:
kubectl create configmap switchboard-branding \
--from-file=branding/branding.json \
--from-file=branding/favicon.png \
--from-file=branding/logo.png \
--from-file=branding/custom.css \
--dry-run=client -o yaml | kubectl apply -f -
Backend Integration
The backend participates in branding in two ways:
-
Startup seed (optional, future): If the backend also mounts
/branding/, it can readbranding.jsonand upsert intoglobal_settingsalongside the admin bootstrap. This enables admin-panel overrides without redeployment. -
Public settings: The
brandingkey is added topublicSettingKeys, making it available to non-admin users viaGET /api/v1/settings/public.
For 0.7.3, the frontend reads branding from the static mount at init time. Backend DB seeding is a future enhancement for admin-panel editing.
Graceful Degradation
| Condition | Behavior |
|---|---|
No /branding/ mount |
All defaults. App looks like stock Switchboard |
Mount exists, no branding.json |
Static assets served, no text/color overrides |
branding.json missing fields |
Each field falls back to its default |
logo field set, file missing |
<img> gets 404, onerror shows emoji fallback |
custom.css missing |
<link> tag self-removes via onerror |
favicon.png missing |
Built-in favicon remains |