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>
19 KiB
Design Tokens — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: JSON-driven multi-hospital theming — sidecar serves theme config, frontend provider injects CSS variables + content tokens, supervisor edits branding from Settings.
Architecture: Sidecar stores data/theme.json, serves via REST. Frontend ThemeTokenProvider fetches on mount, overrides CSS custom properties on <html>, exposes content tokens via React context. Settings page has a Branding tab for admins.
Tech Stack: NestJS (sidecar controller/service), React context + CSS custom properties (frontend), Untitled UI components (settings form)
Spec: docs/superpowers/specs/2026-04-02-design-tokens-design.md
File Map
| File | Action | Responsibility |
|---|---|---|
helix-engage-server/src/config/theme.controller.ts |
Create | GET/PUT/POST endpoints for theme |
helix-engage-server/src/config/theme.service.ts |
Create | Read/write/validate/backup theme JSON |
helix-engage-server/src/config/theme.defaults.ts |
Create | Default Global Hospital theme constant |
helix-engage-server/src/config/config.module.ts |
Create | NestJS module for theme |
helix-engage-server/src/app.module.ts |
Modify | Import ConfigThemeModule |
helix-engage-server/data/theme.json |
Create | Default theme file |
helix-engage/src/providers/theme-token-provider.tsx |
Create | Fetch theme, inject CSS vars, expose context |
helix-engage/src/main.tsx |
Modify | Wrap app with ThemeTokenProvider |
helix-engage/src/pages/login.tsx |
Modify | Consume tokens instead of hardcoded strings |
helix-engage/src/components/layout/sidebar.tsx |
Modify | Consume tokens for title/subtitle |
helix-engage/src/components/call-desk/ai-chat-panel.tsx |
Modify | Consume tokens for quick actions |
helix-engage/src/pages/branding-settings.tsx |
Create | Branding tab in settings for admins |
helix-engage/src/main.tsx |
Modify | Add branding settings route |
Task 1: Default Theme Constant + Theme Service (Sidecar)
Files:
-
Create:
helix-engage-server/src/config/theme.defaults.ts -
Create:
helix-engage-server/src/config/theme.service.ts -
Step 1: Create theme.defaults.ts
// src/config/theme.defaults.ts
export type ThemeConfig = {
brand: {
name: string;
hospitalName: string;
logo: string;
favicon: string;
};
colors: {
brand: Record<string, string>;
};
typography: {
body: string;
display: 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 }>;
};
};
export const DEFAULT_THEME: ThemeConfig = {
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', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
display: "'General Sans', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, 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 · {role}',
},
ai: {
quickActions: [
{ label: 'Doctor availability', prompt: 'What doctors are available and what are their visiting hours?' },
{ label: 'Clinic timings', prompt: 'What are the clinic locations and timings?' },
{ label: 'Patient history', prompt: "Can you summarize this patient's history?" },
{ label: 'Treatment packages', prompt: 'What treatment packages are available?' },
],
},
};
- Step 2: Create theme.service.ts
// src/config/theme.service.ts
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs';
import { join, dirname } from 'path';
import { DEFAULT_THEME, type ThemeConfig } from './theme.defaults';
const THEME_PATH = join(process.cwd(), 'data', 'theme.json');
const BACKUP_DIR = join(process.cwd(), 'data', 'theme-backups');
@Injectable()
export class ThemeService implements OnModuleInit {
private readonly logger = new Logger(ThemeService.name);
private cached: ThemeConfig | null = null;
onModuleInit() {
this.load();
}
getTheme(): ThemeConfig {
if (this.cached) return this.cached;
return this.load();
}
updateTheme(updates: Partial<ThemeConfig>): ThemeConfig {
const current = this.getTheme();
// Deep merge
const merged: ThemeConfig = {
brand: { ...current.brand, ...updates.brand },
colors: {
brand: { ...current.colors.brand, ...updates.colors?.brand },
},
typography: { ...current.typography, ...updates.typography },
login: { ...current.login, ...updates.login, poweredBy: { ...current.login.poweredBy, ...updates.login?.poweredBy } },
sidebar: { ...current.sidebar, ...updates.sidebar },
ai: {
quickActions: updates.ai?.quickActions ?? current.ai.quickActions,
},
};
// Backup current
this.backup();
// Save
const dir = dirname(THEME_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(THEME_PATH, JSON.stringify(merged, null, 2), 'utf8');
this.cached = merged;
this.logger.log('Theme updated and saved');
return merged;
}
resetTheme(): ThemeConfig {
this.backup();
const dir = dirname(THEME_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(THEME_PATH, JSON.stringify(DEFAULT_THEME, null, 2), 'utf8');
this.cached = DEFAULT_THEME;
this.logger.log('Theme reset to defaults');
return DEFAULT_THEME;
}
private load(): ThemeConfig {
try {
if (existsSync(THEME_PATH)) {
const raw = readFileSync(THEME_PATH, 'utf8');
const parsed = JSON.parse(raw);
// Merge with defaults to fill missing fields
this.cached = {
brand: { ...DEFAULT_THEME.brand, ...parsed.brand },
colors: { brand: { ...DEFAULT_THEME.colors.brand, ...parsed.colors?.brand } },
typography: { ...DEFAULT_THEME.typography, ...parsed.typography },
login: { ...DEFAULT_THEME.login, ...parsed.login, poweredBy: { ...DEFAULT_THEME.login.poweredBy, ...parsed.login?.poweredBy } },
sidebar: { ...DEFAULT_THEME.sidebar, ...parsed.sidebar },
ai: { quickActions: parsed.ai?.quickActions ?? DEFAULT_THEME.ai.quickActions },
};
this.logger.log('Theme loaded from file');
return this.cached;
}
} catch (err) {
this.logger.warn(`Failed to load theme: ${err}`);
}
this.cached = DEFAULT_THEME;
this.logger.log('Using default theme');
return DEFAULT_THEME;
}
private backup() {
try {
if (!existsSync(THEME_PATH)) return;
if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true });
const ts = new Date().toISOString().replace(/[:.]/g, '-');
copyFileSync(THEME_PATH, join(BACKUP_DIR, `theme-${ts}.json`));
} catch (err) {
this.logger.warn(`Backup failed: ${err}`);
}
}
}
- Step 3: Commit
cd helix-engage-server
git add src/config/theme.defaults.ts src/config/theme.service.ts
git commit -m "feat: theme service — read/write/backup theme JSON"
Task 2: Theme Controller + Module (Sidecar)
Files:
-
Create:
helix-engage-server/src/config/theme.controller.ts -
Create:
helix-engage-server/src/config/config.module.ts -
Modify:
helix-engage-server/src/app.module.ts -
Step 1: Create theme.controller.ts
// src/config/theme.controller.ts
import { Controller, Get, Put, Post, Body, Logger } from '@nestjs/common';
import { ThemeService } from './theme.service';
import type { ThemeConfig } from './theme.defaults';
@Controller('api/config')
export class ThemeController {
private readonly logger = new Logger(ThemeController.name);
constructor(private readonly theme: ThemeService) {}
@Get('theme')
getTheme() {
return this.theme.getTheme();
}
@Put('theme')
updateTheme(@Body() body: Partial<ThemeConfig>) {
this.logger.log('Theme update request');
return this.theme.updateTheme(body);
}
@Post('theme/reset')
resetTheme() {
this.logger.log('Theme reset request');
return this.theme.resetTheme();
}
}
- Step 2: Create config.module.ts
// src/config/config.module.ts
// Named ConfigThemeModule to avoid conflict with NestJS ConfigModule
import { Module } from '@nestjs/common';
import { ThemeController } from './theme.controller';
import { ThemeService } from './theme.service';
@Module({
controllers: [ThemeController],
providers: [ThemeService],
exports: [ThemeService],
})
export class ConfigThemeModule {}
- Step 3: Register in app.module.ts
Add import at top:
import { ConfigThemeModule } from './config/config.module';
Add to imports array:
ConfigThemeModule,
- Step 4: Build and verify
cd helix-engage-server && npm run build
- Step 5: Commit
git add src/config/ src/app.module.ts
git commit -m "feat: theme REST API — GET/PUT/POST endpoints"
Task 3: ThemeTokenProvider (Frontend)
Files:
-
Create:
helix-engage/src/providers/theme-token-provider.tsx -
Modify:
helix-engage/src/main.tsx -
Step 1: Create theme-token-provider.tsx
// src/providers/theme-token-provider.tsx
import type { ReactNode } from 'react';
import { createContext, useContext, useEffect, useState, useCallback } from 'react';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
export type ThemeTokens = {
brand: { name: string; hospitalName: string; logo: string; favicon: string };
colors: { brand: Record<string, string> };
typography: { body: string; display: 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 }> };
};
const DEFAULT_TOKENS: ThemeTokens = {
brand: { name: 'Helix Engage', hospitalName: 'Global Hospital', logo: '/helix-logo.png', favicon: '/favicon.ico' },
colors: { brand: {} },
typography: { body: '', display: '' },
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 · {role}' },
ai: { quickActions: [
{ label: 'Doctor availability', prompt: 'What doctors are available and what are their visiting hours?' },
{ label: 'Clinic timings', prompt: 'What are the clinic locations and timings?' },
{ label: 'Patient history', prompt: "Can you summarize this patient's history?" },
{ label: 'Treatment packages', prompt: 'What treatment packages are available?' },
] },
};
type ThemeTokenContextType = {
tokens: ThemeTokens;
refresh: () => Promise<void>;
};
const ThemeTokenContext = createContext<ThemeTokenContextType>({ tokens: DEFAULT_TOKENS, refresh: async () => {} });
export const useThemeTokens = () => useContext(ThemeTokenContext);
const applyColorTokens = (brandColors: Record<string, string>) => {
const root = document.documentElement;
for (const [stop, value] of Object.entries(brandColors)) {
root.style.setProperty(`--color-brand-${stop}`, value);
}
};
const applyTypographyTokens = (typography: { body: string; display: string }) => {
const root = document.documentElement;
if (typography.body) root.style.setProperty('--font-body', typography.body);
if (typography.display) root.style.setProperty('--font-display', typography.display);
};
export const ThemeTokenProvider = ({ children }: { children: ReactNode }) => {
const [tokens, setTokens] = useState<ThemeTokens>(DEFAULT_TOKENS);
const fetchTheme = useCallback(async () => {
try {
const res = await fetch(`${API_URL}/api/config/theme`);
if (res.ok) {
const data: ThemeTokens = await res.json();
setTokens(data);
if (data.colors?.brand && Object.keys(data.colors.brand).length > 0) {
applyColorTokens(data.colors.brand);
}
if (data.typography) {
applyTypographyTokens(data.typography);
}
}
} catch {
// Use defaults silently
}
}, []);
useEffect(() => { fetchTheme(); }, [fetchTheme]);
return (
<ThemeTokenContext.Provider value={{ tokens, refresh: fetchTheme }}>
{children}
</ThemeTokenContext.Provider>
);
};
- Step 2: Wrap app in main.tsx
In main.tsx, add import:
import { ThemeTokenProvider } from '@/providers/theme-token-provider';
Wrap inside ThemeProvider:
<ThemeProvider>
<ThemeTokenProvider>
<AuthProvider>
...
</AuthProvider>
</ThemeTokenProvider>
</ThemeProvider>
- Step 3: Build and verify
npx tsc --noEmit
- Step 4: Commit
git add src/providers/theme-token-provider.tsx src/main.tsx
git commit -m "feat: ThemeTokenProvider — fetch theme, inject CSS variables"
Task 4: Consume Tokens in Login Page
Files:
-
Modify:
helix-engage/src/pages/login.tsx -
Step 1: Replace hardcoded values
Import useThemeTokens:
import { useThemeTokens } from '@/providers/theme-token-provider';
Inside the component:
const { tokens } = useThemeTokens();
Replace hardcoded strings:
-
src="/helix-logo.png"→src={tokens.brand.logo} -
"Sign in to Helix Engage"→{tokens.login.title} -
"Global Hospital"→{tokens.login.subtitle} -
Google sign-in section: wrap with
{tokens.login.showGoogleSignIn && (...)} -
Forgot password: wrap with
{tokens.login.showForgotPassword && (...)} -
Powered by:
tokens.login.poweredBy.labelandtokens.login.poweredBy.url -
Step 2: Commit
git add src/pages/login.tsx
git commit -m "feat: login page consumes theme tokens"
Task 5: Consume Tokens in Sidebar + AI Chat
Files:
-
Modify:
helix-engage/src/components/layout/sidebar.tsx -
Modify:
helix-engage/src/components/call-desk/ai-chat-panel.tsx -
Step 1: Update sidebar.tsx
Import useThemeTokens and replace:
-
Line 167:
"Helix Engage"→{tokens.sidebar.title} -
Line 168:
"Global Hospital · {getRoleSubtitle(user.role)}"→{tokens.sidebar.subtitle.replace('{role}', getRoleSubtitle(user.role))} -
Line 164: favicon src →
tokens.brand.logo -
Step 2: Update ai-chat-panel.tsx
Import useThemeTokens and replace:
- Lines 21-25: hardcoded
QUICK_ACTIONSarray →tokens.ai.quickActions
Move QUICK_ACTIONS usage inside the component:
const { tokens } = useThemeTokens();
const quickActions = tokens.ai.quickActions;
- Step 3: Commit
git add src/components/layout/sidebar.tsx src/components/call-desk/ai-chat-panel.tsx
git commit -m "feat: sidebar + AI chat consume theme tokens"
Task 6: Branding Settings Page (Frontend)
Files:
-
Create:
helix-engage/src/pages/branding-settings.tsx -
Modify:
helix-engage/src/main.tsx(add route) -
Modify:
helix-engage/src/components/layout/sidebar.tsx(add nav item) -
Step 1: Create branding-settings.tsx
The page has 6 collapsible sections matching the spec. Uses Untitled UI Input, TextArea, Checkbox, Button components. On save, PUTs to /api/config/theme and calls refresh() from useThemeTokens().
Key patterns:
-
Fetch current theme on mount via
GET /api/config/theme -
Local state mirrors the theme JSON structure
-
Each section is a collapsible card
-
Color section: 12 text inputs for hex/rgb values with colored preview dots
-
Save button calls
PUT /api/config/themewith the full state -
Reset button calls
POST /api/config/theme/reset -
After save/reset, call
refresh()to re-apply CSS variables immediately -
Step 2: Add route in main.tsx
import { BrandingSettingsPage } from '@/pages/branding-settings';
Add route:
<Route path="/branding" element={<BrandingSettingsPage />} />
- Step 3: Add nav item in sidebar.tsx
Under the Configuration section (near Rules Engine), add "Branding" link for admin role only.
- Step 4: Build and verify
npx tsc --noEmit
- Step 5: Commit
git add src/pages/branding-settings.tsx src/main.tsx src/components/layout/sidebar.tsx
git commit -m "feat: branding settings page — theme editor for supervisors"
Task 7: Default Theme File + Build Verification
Files:
-
Create:
helix-engage-server/data/theme.json -
Step 1: Create default theme.json
Copy the DEFAULT_THEME object as JSON to data/theme.json.
- Step 2: Build both projects
cd helix-engage-server && npm run build
cd ../helix-engage && npm run build
- Step 3: Commit all
git add data/theme.json
git commit -m "chore: default theme.json file"
Execution Notes
- ThemeTokenProvider fetches before login — the endpoint is public (no auth)
- CSS variable override on
<html>has higher specificity than the@themeblock intheme.css tokens.sidebar.subtitlesupports{role}placeholder — replaced at render time by the sidebar component- The branding settings page is admin-only but the theme endpoint itself is unauthenticated (GET) — PUT requires auth
- If the sidecar is unreachable, the frontend silently falls back to hardcoded defaults