From afd0829dc6aa91ff60bc766197f103809c429183 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Thu, 2 Apr 2026 15:50:36 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20design=20tokens=20=E2=80=94=20multi-hos?= =?UTF-8?q?pital=20theming=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - 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) --- .../plans/2026-04-02-design-tokens.md | 600 ++++++++++++++++++ .../specs/2026-04-02-design-tokens-design.md | 240 +++++++ src/components/call-desk/ai-chat-panel.tsx | 12 +- src/components/layout/sidebar.tsx | 11 +- src/main.tsx | 5 + src/pages/branding-settings.tsx | 397 ++++++++++++ src/pages/login.tsx | 22 +- src/pages/team-performance.tsx | 8 +- src/providers/theme-token-provider.tsx | 87 +++ src/styles/theme.css | 4 +- 10 files changed, 1358 insertions(+), 28 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-02-design-tokens.md create mode 100644 docs/superpowers/specs/2026-04-02-design-tokens-design.md create mode 100644 src/pages/branding-settings.tsx create mode 100644 src/providers/theme-token-provider.tsx diff --git a/docs/superpowers/plans/2026-04-02-design-tokens.md b/docs/superpowers/plans/2026-04-02-design-tokens.md new file mode 100644 index 0000000..b0a2f46 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-design-tokens.md @@ -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 ``, 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; + }; + 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 { + 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) { + 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 }; + 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; +}; + +const ThemeTokenContext = createContext({ tokens: DEFAULT_TOKENS, refresh: async () => {} }); + +export const useThemeTokens = () => useContext(ThemeTokenContext); + +const applyColorTokens = (brandColors: Record) => { + 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(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 ( + + {children} + + ); +}; +``` + +- [ ] **Step 2: Wrap app in main.tsx** + +In `main.tsx`, add import: +```typescript +import { ThemeTokenProvider } from '@/providers/theme-token-provider'; +``` + +Wrap inside `ThemeProvider`: +```tsx + + + + ... + + + +``` + +- [ ] **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 +} /> +``` + +- [ ] **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 `` 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 diff --git a/docs/superpowers/specs/2026-04-02-design-tokens-design.md b/docs/superpowers/specs/2026-04-02-design-tokens-design.md new file mode 100644 index 0000000..b1c7e04 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-design-tokens-design.md @@ -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 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 `` 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 ``, 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) diff --git a/src/components/call-desk/ai-chat-panel.tsx b/src/components/call-desk/ai-chat-panel.tsx index 9e76b92..6d73315 100644 --- a/src/components/call-desk/ai-chat-panel.tsx +++ b/src/components/call-desk/ai-chat-panel.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react'; import { useRef, useEffect } from 'react'; +import { useThemeTokens } from '@/providers/theme-token-provider'; import { useChat } from '@ai-sdk/react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPaperPlaneTop, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons'; @@ -18,14 +19,9 @@ interface AiChatPanelProps { onChatStart?: () => void; } -const QUICK_ACTIONS = [ - { 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?' }, -]; - export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => { + const { tokens } = useThemeTokens(); + const quickActions = tokens.ai.quickActions; const messagesEndRef = useRef(null); const chatStartedRef = useRef(false); @@ -67,7 +63,7 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => Ask me about doctors, clinics, packages, or patient info.

- {QUICK_ACTIONS.map((action) => ( + {quickActions.map((action) => ( + + {value && {value.startsWith('data:') ? 'Uploaded file' : value}} +
+ + ); +}; + +export const BrandingSettingsPage = () => { + const { tokens, refresh } = useThemeTokens(); + const [form, setForm] = useState(tokens); + const [saving, setSaving] = useState(false); + const [resetting, setResetting] = useState(false); + + useEffect(() => { setForm(tokens); }, [tokens]); + + const updateBrand = (key: keyof ThemeTokens['brand'], value: string) => { + setForm(prev => ({ ...prev, brand: { ...prev.brand, [key]: value } })); + }; + + const updateColor = (stop: string, value: string) => { + setForm(prev => ({ ...prev, colors: { ...prev.colors, brand: { ...prev.colors.brand, [stop]: value } } })); + }; + + const updateTypography = (key: keyof ThemeTokens['typography'], value: string) => { + setForm(prev => ({ ...prev, typography: { ...prev.typography, [key]: value } })); + }; + + const updateLogin = (key: string, value: any) => { + if (key === 'poweredBy.label') { + setForm(prev => ({ ...prev, login: { ...prev.login, poweredBy: { ...prev.login.poweredBy, label: value } } })); + } else if (key === 'poweredBy.url') { + setForm(prev => ({ ...prev, login: { ...prev.login, poweredBy: { ...prev.login.poweredBy, url: value } } })); + } else { + setForm(prev => ({ ...prev, login: { ...prev.login, [key]: value } })); + } + }; + + const updateSidebar = (key: keyof ThemeTokens['sidebar'], value: string) => { + setForm(prev => ({ ...prev, sidebar: { ...prev.sidebar, [key]: value } })); + }; + + const updateQuickAction = (index: number, key: 'label' | 'prompt', value: string) => { + setForm(prev => { + const actions = [...prev.ai.quickActions]; + actions[index] = { ...actions[index], [key]: value }; + return { ...prev, ai: { quickActions: actions } }; + }); + }; + + const addQuickAction = () => { + setForm(prev => ({ + ...prev, + ai: { quickActions: [...prev.ai.quickActions, { label: '', prompt: '' }] }, + })); + }; + + const removeQuickAction = (index: number) => { + setForm(prev => ({ + ...prev, + ai: { quickActions: prev.ai.quickActions.filter((_, i) => i !== index) }, + })); + }; + + const handleSave = async () => { + setSaving(true); + try { + await fetch(`${THEME_API_URL}/api/config/theme`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form) }); + await refresh(); + notify.success('Branding Saved', 'Theme updated successfully'); + } catch { + notify.error('Save Failed', 'Could not update theme'); + } finally { + setSaving(false); + } + }; + + const handleReset = async () => { + setResetting(true); + try { + await fetch(`${THEME_API_URL}/api/config/theme/reset`, { method: 'POST' }); + await refresh(); + notify.success('Branding Reset', 'Theme restored to defaults'); + } catch { + notify.error('Reset Failed', 'Could not reset theme'); + } finally { + setResetting(false); + } + }; + + return ( + <> + + +
+
+
+ + {/* LEFT COLUMN */} +
+ + {/* Brand Identity */} +
+
+ updateBrand('name', v)} /> + updateBrand('hospitalName', v)} /> +
+ updateBrand('logo', v)} accept="image/*" /> + updateBrand('favicon', v)} accept="image/*,.ico" /> +
+ + {/* Typography */} +
+
+ + +
+
+ + {/* Login Page */} +
+
+ updateLogin('title', v)} /> + updateLogin('subtitle', v)} /> +
+
+ updateLogin('showGoogleSignIn', v)} label="Google Sign-in" /> + updateLogin('showForgotPassword', v)} label="Forgot Password" /> +
+
+ updateLogin('poweredBy.label', v)} /> + updateLogin('poweredBy.url', v)} /> +
+
+ + {/* Sidebar */} +
+
+ updateSidebar('title', v)} /> + updateSidebar('subtitle', v)} hint="{role} = user role" /> +
+
+ +
+ + {/* RIGHT COLUMN */} +
+ + {/* Brand Colors */} +
+

Pick a base color or preset — the full palette generates automatically.

+ + {/* Color picker */} +
+ +
+ Base Color +

{rgbToHex(form.colors.brand['500'] ?? 'rgb(37 99 235)')}

+
+
+ + {/* Presets */} +
+ {Object.entries(COLOR_PRESETS).map(([key, preset]) => ( + + ))} +
+ + {/* Generated palette preview */} +
+ {COLOR_STOPS.map(stop => ( +
+ {stop} +
+ ))} +
+
+ + {/* AI Quick Actions */} +
+
+ {form.ai.quickActions.map((action, i) => ( +
+
+ updateQuickAction(i, 'label', v)} /> + updateQuickAction(i, 'prompt', v)} /> +
+ +
+ ))} +
+ +
+ +
+
+ +
+ + {/* Footer — pinned */} +
+ + +
+
+ + ); +}; diff --git a/src/pages/login.tsx b/src/pages/login.tsx index 725ceff..29410a7 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -10,12 +10,14 @@ import { Checkbox } from '@/components/base/checkbox/checkbox'; import { Input } from '@/components/base/input/input'; import { MaintOtpModal } from '@/components/modals/maint-otp-modal'; import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts'; +import { useThemeTokens } from '@/providers/theme-token-provider'; export const LoginPage = () => { const { loginWithUser } = useAuth(); const { refresh } = useData(); const navigate = useNavigate(); const { isOpen, activeAction, close } = useMaintShortcuts(); + const { tokens } = useThemeTokens(); const saved = localStorage.getItem('helix_remember'); const savedCreds = saved ? JSON.parse(saved) : null; @@ -89,13 +91,13 @@ export const LoginPage = () => {
{/* Logo */}
- Helix Engage -

Sign in to Helix Engage

-

Global Hospital

+ {tokens.brand.name} +

{tokens.login.title}

+

{tokens.login.subtitle}

{/* Google sign-in */} - { className="w-full rounded-xl py-3 border-2 border-secondary font-semibold hover:bg-secondary transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]" > Sign in with Google - + } {/* Divider */} -
+ {tokens.login.showGoogleSignIn &&
or continue with
-
+
} {/* Form */}
@@ -156,13 +158,13 @@ export const LoginPage = () => { isSelected={rememberMe} onChange={setRememberMe} /> - + }
{/* Footer */} - Powered by F0rty2.ai + {tokens.login.poweredBy.label} !open && close()} action={activeAction} />
diff --git a/src/pages/team-performance.tsx b/src/pages/team-performance.tsx index aa618e3..95e0af4 100644 --- a/src/pages/team-performance.tsx +++ b/src/pages/team-performance.tsx @@ -64,7 +64,7 @@ const DateFilter = ({ value, onChange }: { value: DateRange; onChange: (v: DateR ); const KpiCard = ({ icon, value, label, color }: { icon: any; value: string | number; label: string; color?: string }) => ( -
+
@@ -210,8 +210,8 @@ export const TeamPerformancePage = () => { const days = Object.keys(dayMap); return { tooltip: { trigger: 'axis' }, - legend: { data: ['Inbound', 'Outbound'], bottom: 0 }, - grid: { top: 10, right: 10, bottom: 30, left: 40 }, + legend: { data: ['Inbound', 'Outbound'], bottom: 0, textStyle: { fontSize: 11 } }, + grid: { top: 10, right: 10, bottom: 50, left: 40 }, xAxis: { type: 'category', data: days }, yAxis: { type: 'value' }, series: [ @@ -292,7 +292,7 @@ export const TeamPerformancePage = () => {

Key Metrics

-
+
diff --git a/src/providers/theme-token-provider.tsx b/src/providers/theme-token-provider.tsx new file mode 100644 index 0000000..a255f18 --- /dev/null +++ b/src/providers/theme-token-provider.tsx @@ -0,0 +1,87 @@ +import type { ReactNode } from 'react'; +import { createContext, useContext, useEffect, useState, useCallback } from 'react'; + +const THEME_API_URL = import.meta.env.VITE_THEME_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 }; + 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: { + '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 \u00b7 {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; +}; + +const ThemeTokenContext = createContext({ tokens: DEFAULT_TOKENS, refresh: async () => {} }); + +export const useThemeTokens = () => useContext(ThemeTokenContext); + +const applyColorTokens = (brandColors: Record) => { + 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(DEFAULT_TOKENS); + + const fetchTheme = useCallback(async () => { + try { + const res = await fetch(`${THEME_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 ( + + {children} + + ); +}; diff --git a/src/styles/theme.css b/src/styles/theme.css index 84f7996..ab86481 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -764,9 +764,9 @@ /* SIDEBAR-SPECIFIC COLORS (Light Mode Only) */ --color-sidebar-bg: rgb(28, 33, 44); --color-sidebar-nav-item-hover-bg: rgb(42, 48, 60); - --color-sidebar-nav-item-hover-text: rgb(73, 160, 225); + --color-sidebar-nav-item-hover-text: var(--color-brand-400); --color-sidebar-nav-item-active-bg: rgb(42, 48, 60); - --color-sidebar-nav-item-active-text: rgb(73, 160, 225); + --color-sidebar-nav-item-active-text: var(--color-brand-400); /* COMPONENT COLORS */ --color-app-store-badge-border: rgb(166 166 166);