Backend (sidecar): - ThemeService: read/write/backup/reset theme.json with versioning - ThemeController: GET/PUT/POST /api/config/theme endpoints - ConfigThemeModule registered in app Frontend: - ThemeTokenProvider: fetches theme, injects CSS variables on <html> - Login page: logo, title, subtitle, Google/forgot toggles from tokens - Sidebar: title, subtitle, active highlight from brand color scale - AI chat: quick actions from tokens - Branding settings page: 2-column layout, file upload for logo/favicon, single color picker with palette generation, font dropdowns, presets, pinned footer, versioning Theme CSS: - Sidebar active/hover text now references --color-brand-400 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
7.5 KiB
Design Tokens — Multi-Hospital Theming
Date: 2026-04-02 Status: Draft
Overview
A JSON-driven design token system that allows each hospital customer to rebrand Helix Engage by providing a single JSON configuration file. The JSON is served by the sidecar, consumed by the frontend at runtime via a React provider that injects CSS custom properties.
Architecture
Sidecar (helix-engage-server)
└─ GET /api/config/theme → returns hospital theme JSON
└─ theme stored as JSON file at data/theme.json (editable, hot-reloadable)
Frontend (helix-engage)
└─ ThemeTokenProvider (wraps app) → fetches theme JSON on mount
└─ Injects CSS custom properties on <html> element
└─ Exposes useThemeTokens() hook for content tokens (logo, name, text)
└─ Components read colors via existing Tailwind classes (no changes needed)
Theme JSON Schema
{
"brand": {
"name": "Helix Engage",
"hospitalName": "Global Hospital",
"logo": "/helix-logo.png",
"favicon": "/favicon.ico"
},
"colors": {
"brand": {
"25": "rgb(239 246 255)",
"50": "rgb(219 234 254)",
"100": "rgb(191 219 254)",
"200": "rgb(147 197 253)",
"300": "rgb(96 165 250)",
"400": "rgb(59 130 246)",
"500": "rgb(37 99 235)",
"600": "rgb(29 78 216)",
"700": "rgb(30 64 175)",
"800": "rgb(30 58 138)",
"900": "rgb(23 37 84)",
"950": "rgb(15 23 42)"
}
},
"typography": {
"body": "Satoshi, Inter, -apple-system, sans-serif",
"display": "General Sans, Inter, -apple-system, sans-serif"
},
"login": {
"title": "Sign in to Helix Engage",
"subtitle": "Global Hospital",
"showGoogleSignIn": true,
"showForgotPassword": true,
"poweredBy": {
"label": "Powered by F0rty2.ai",
"url": "https://f0rty2.ai"
}
},
"sidebar": {
"title": "Helix Engage",
"subtitle": "Global Hospital · Call Center Agent"
},
"ai": {
"quickActions": [
{ "label": "Doctor availability", "prompt": "What doctors are available?" },
{ "label": "Clinic timings", "prompt": "What are the clinic timings?" },
{ "label": "Patient history", "prompt": "Summarize this patient's history" },
{ "label": "Treatment packages", "prompt": "What packages are available?" }
]
}
}
Sidecar Implementation
Endpoints
GET /api/config/theme — Returns theme JSON (no auth, public — needed before login)
PUT /api/config/theme — Updates theme JSON (auth required, admin only)
POST /api/config/theme/reset — Resets to default theme (auth required, admin only)
- Stored in
data/theme.jsonon the sidecar filesystem - Cached in memory, invalidated on PUT
- If file doesn't exist, returns a hardcoded default (Global Hospital theme)
- PUT validates the JSON schema before saving
- PUT also writes a timestamped backup to
data/theme-backups/
Files
helix-engage-server/src/config/theme.controller.ts— REST endpointshelix-engage-server/src/config/theme.service.ts— read/write/validate/backup logic
Frontend Implementation
ThemeTokenProvider
New provider wrapping the app in main.tsx. Responsibilities:
- Fetch
GET /api/config/themeon mount (before rendering anything) - Inject CSS variables on
document.documentElement.style:--color-brand-25through--color-brand-950(overrides the Untitled UI brand scale)--font-body,--font-display(overrides typography)
- Store content tokens in React context (brand name, logo, login text, sidebar text, quick actions)
- Expose
useThemeTokens()hook for components to read content tokens
File: src/providers/theme-token-provider.tsx
type ThemeTokens = {
brand: { name: string; hospitalName: string; logo: string; favicon: string };
login: { title: string; subtitle: string; showGoogleSignIn: boolean; showForgotPassword: boolean; poweredBy: { label: string; url: string } };
sidebar: { title: string; subtitle: string };
ai: { quickActions: Array<{ label: string; prompt: string }> };
};
CSS Variable Injection
The provider maps colors.brand.* to CSS custom properties that Untitled UI already reads:
theme.colors.brand["500"] → document.documentElement.style.setProperty('--color-brand-500', value)
Since theme.css defines --color-brand-500: var(--color-blue-500), setting --color-brand-500 directly on <html> overrides the alias with higher specificity.
Typography:
theme.typography.body → --font-body
theme.typography.display → --font-display
Consumers
Components that currently hardcode hospital-specific content:
| Component | Current hardcoded value | Token path |
|---|---|---|
login.tsx line 93 |
"Sign in to Helix Engage" | login.title |
login.tsx line 94 |
"Global Hospital" | login.subtitle |
login.tsx line 92 |
/helix-logo.png |
brand.logo |
login.tsx line 181 |
"Powered by F0rty2.ai" | login.poweredBy.label |
sidebar.tsx |
"Helix Engage" | sidebar.title |
sidebar.tsx |
"Global Hospital · Call Center Agent" | sidebar.subtitle |
ai-chat-panel.tsx lines 21-25 |
Quick action prompts | ai.quickActions |
app-shell.tsx |
favicon | brand.favicon |
Default Theme
If the sidecar returns no theme (endpoint down, file missing), the frontend uses a hardcoded default matching the current Global Hospital branding. This ensures the app works without a sidecar theme endpoint.
Settings UI (Supervisor)
New tab in the Settings page: Branding. Visible only to admin role.
Sections
1. Brand Identity
- Hospital name (text input)
- App name (text input)
- Logo upload (file input → stores URL)
- Favicon upload
2. Brand Colors
- 12 color swatches (25 through 950) with hex/rgb input per swatch
- Live preview strip showing the full scale
- "Reset to default" button per section
3. Typography
- Body font family (text input with common font suggestions)
- Display font family (text input)
4. Login Page
- Title text
- Subtitle text
- Show Google sign-in (toggle)
- Show forgot password (toggle)
- Powered-by label + URL
5. Sidebar
- Title text
- Subtitle template (supports
{role}placeholder — "Global Hospital · {role}")
6. AI Quick Actions
- Editable list of label + prompt pairs
- Add / remove / reorder
Save Flow
- Supervisor edits fields → clicks Save →
PUT /api/config/theme→ sidecar validates + saves + backs up - Frontend re-fetches theme on save → CSS variables update → page reflects changes immediately (no reload needed)
File
src/pages/settings.tsx — new "Branding" tab (or src/pages/branding-settings.tsx if settings page is already complex)
What This Does NOT Change
- Tailwind classes — no changes. Components continue using
text-brand-secondary,bg-brand-solid, etc. The CSS variables they reference are overridden at runtime. - Component structure — no layout changes. Only content strings and colors change.
- Untitled UI theme.css — not modified. The provider overrides are applied inline on
<html>, higher specificity.
Scope
In scope:
- Sidecar theme endpoint + JSON file
- ThemeTokenProvider + useThemeTokens hook
- Login page consuming tokens
- Sidebar consuming tokens
- AI quick actions consuming tokens
- Brand color override via CSS variables
- Typography override via CSS variables
Out of scope:
- Dark mode customization (inherits from Untitled UI)
- Per-role theming
- Logo upload to cloud storage (uses URL for now — can be a data URI or hosted path)