Files
helix-engage/docs/superpowers/plans/2026-04-02-design-tokens.md
saridsa2 afd0829dc6 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>
2026-04-02 15:50:36 +05:30

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.label and tokens.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_ACTIONS array → 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/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

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 @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