mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
feat: design tokens — multi-hospital theming system
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>
This commit is contained in:
600
docs/superpowers/plans/2026-04-02-design-tokens.md
Normal file
600
docs/superpowers/plans/2026-04-02-design-tokens.md
Normal file
@@ -0,0 +1,600 @@
|
||||
# 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**
|
||||
|
||||
```typescript
|
||||
// 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**
|
||||
|
||||
```typescript
|
||||
// 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**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```typescript
|
||||
// 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**
|
||||
|
||||
```typescript
|
||||
// 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:
|
||||
```typescript
|
||||
import { ConfigThemeModule } from './config/config.module';
|
||||
```
|
||||
|
||||
Add to imports array:
|
||||
```typescript
|
||||
ConfigThemeModule,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build and verify**
|
||||
|
||||
```bash
|
||||
cd helix-engage-server && npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```typescript
|
||||
// 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:
|
||||
```typescript
|
||||
import { ThemeTokenProvider } from '@/providers/theme-token-provider';
|
||||
```
|
||||
|
||||
Wrap inside `ThemeProvider`:
|
||||
```tsx
|
||||
<ThemeProvider>
|
||||
<ThemeTokenProvider>
|
||||
<AuthProvider>
|
||||
...
|
||||
</AuthProvider>
|
||||
</ThemeTokenProvider>
|
||||
</ThemeProvider>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build and verify**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
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`:
|
||||
```typescript
|
||||
import { useThemeTokens } from '@/providers/theme-token-provider';
|
||||
```
|
||||
|
||||
Inside the component:
|
||||
```typescript
|
||||
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.label` and `tokens.login.poweredBy.url`
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
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_ACTIONS` array → `tokens.ai.quickActions`
|
||||
|
||||
Move `QUICK_ACTIONS` usage inside the component:
|
||||
```typescript
|
||||
const { tokens } = useThemeTokens();
|
||||
const quickActions = tokens.ai.quickActions;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
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/theme` with 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**
|
||||
|
||||
```typescript
|
||||
import { BrandingSettingsPage } from '@/pages/branding-settings';
|
||||
```
|
||||
|
||||
Add route:
|
||||
```tsx
|
||||
<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**
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```bash
|
||||
cd helix-engage-server && npm run build
|
||||
cd ../helix-engage && npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit all**
|
||||
|
||||
```bash
|
||||
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 `@theme` block in `theme.css`
|
||||
- `tokens.sidebar.subtitle` supports `{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
|
||||
240
docs/superpowers/specs/2026-04-02-design-tokens-design.md
Normal file
240
docs/superpowers/specs/2026-04-02-design-tokens-design.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# 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
|
||||
|
||||
```json
|
||||
{
|
||||
"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.json` on 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 endpoints
|
||||
- `helix-engage-server/src/config/theme.service.ts` — read/write/validate/backup logic
|
||||
|
||||
---
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### ThemeTokenProvider
|
||||
|
||||
New provider wrapping the app in `main.tsx`. Responsibilities:
|
||||
|
||||
1. **Fetch** `GET /api/config/theme` on mount (before rendering anything)
|
||||
2. **Inject CSS variables** on `document.documentElement.style`:
|
||||
- `--color-brand-25` through `--color-brand-950` (overrides the Untitled UI brand scale)
|
||||
- `--font-body`, `--font-display` (overrides typography)
|
||||
3. **Store content tokens** in React context (brand name, logo, login text, sidebar text, quick actions)
|
||||
4. **Expose** `useThemeTokens()` hook for components to read content tokens
|
||||
|
||||
### File: `src/providers/theme-token-provider.tsx`
|
||||
|
||||
```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)
|
||||
Reference in New Issue
Block a user