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)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useRef, useEffect } from 'react';
|
import { useRef, useEffect } from 'react';
|
||||||
|
import { useThemeTokens } from '@/providers/theme-token-provider';
|
||||||
import { useChat } from '@ai-sdk/react';
|
import { useChat } from '@ai-sdk/react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faPaperPlaneTop, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
import { faPaperPlaneTop, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
@@ -18,14 +19,9 @@ interface AiChatPanelProps {
|
|||||||
onChatStart?: () => void;
|
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) => {
|
export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => {
|
||||||
|
const { tokens } = useThemeTokens();
|
||||||
|
const quickActions = tokens.ai.quickActions;
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const chatStartedRef = useRef(false);
|
const chatStartedRef = useRef(false);
|
||||||
|
|
||||||
@@ -67,7 +63,7 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
|||||||
Ask me about doctors, clinics, packages, or patient info.
|
Ask me about doctors, clinics, packages, or patient info.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex flex-wrap justify-center gap-1.5">
|
<div className="mt-3 flex flex-wrap justify-center gap-1.5">
|
||||||
{QUICK_ACTIONS.map((action) => (
|
{quickActions.map((action) => (
|
||||||
<button
|
<button
|
||||||
key={action.label}
|
key={action.label}
|
||||||
onClick={() => handleQuickAction(action.prompt)}
|
onClick={() => handleQuickAction(action.prompt)}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import type { NavItemType } from "@/components/application/app-navigation/config
|
|||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
import { useAgentState } from "@/hooks/use-agent-state";
|
import { useAgentState } from "@/hooks/use-agent-state";
|
||||||
|
import { useThemeTokens } from "@/providers/theme-token-provider";
|
||||||
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
|
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
@@ -80,6 +81,7 @@ const getNavSections = (role: string): NavSection[] => {
|
|||||||
]},
|
]},
|
||||||
{ label: 'Configuration', items: [
|
{ label: 'Configuration', items: [
|
||||||
{ label: 'Rules Engine', href: '/rules', icon: IconSlidersUp },
|
{ label: 'Rules Engine', href: '/rules', icon: IconSlidersUp },
|
||||||
|
{ label: 'Branding', href: '/branding', icon: IconGear },
|
||||||
]},
|
]},
|
||||||
{ label: 'Admin', items: [
|
{ label: 'Admin', items: [
|
||||||
{ label: 'Settings', href: '/settings', icon: IconGear },
|
{ label: 'Settings', href: '/settings', icon: IconGear },
|
||||||
@@ -128,6 +130,7 @@ interface SidebarProps {
|
|||||||
|
|
||||||
export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
||||||
const { logout, user } = useAuth();
|
const { logout, user } = useAuth();
|
||||||
|
const { tokens } = useThemeTokens();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
|
const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
|
||||||
const agentConfig = typeof window !== 'undefined' ? localStorage.getItem('helix_agent_config') : null;
|
const agentConfig = typeof window !== 'undefined' ? localStorage.getItem('helix_agent_config') : null;
|
||||||
@@ -161,11 +164,11 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
{/* Logo + collapse toggle */}
|
{/* Logo + collapse toggle */}
|
||||||
<div className={cx("flex items-center gap-2", collapsed ? "justify-center px-2" : "justify-between px-4 lg:px-5")}>
|
<div className={cx("flex items-center gap-2", collapsed ? "justify-center px-2" : "justify-between px-4 lg:px-5")}>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<img src="/favicon-32.png" alt="Helix Engage" className="size-8 rounded-lg shrink-0" />
|
<img src={tokens.brand.logo} alt={tokens.brand.name} className="size-8 rounded-lg shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-md font-bold text-white">Helix Engage</span>
|
<span className="text-md font-bold text-white">{tokens.sidebar.title}</span>
|
||||||
<span className="text-xs text-white opacity-70">Global Hospital · {getRoleSubtitle(user.role)}</span>
|
<span className="text-xs text-white opacity-70">{tokens.sidebar.subtitle.replace('{role}', getRoleSubtitle(user.role))}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -204,7 +207,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
className={cx(
|
className={cx(
|
||||||
"flex size-10 items-center justify-center rounded-lg transition duration-100 ease-linear",
|
"flex size-10 items-center justify-center rounded-lg transition duration-100 ease-linear",
|
||||||
item.href === activeUrl
|
item.href === activeUrl
|
||||||
? "bg-active text-fg-brand-primary"
|
? "bg-sidebar-active text-sidebar-active"
|
||||||
: "text-fg-quaternary hover:bg-(--hover-bg) hover:text-(--hover-text)",
|
: "text-fg-quaternary hover:bg-(--hover-bg) hover:text-(--hover-text)",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -29,16 +29,19 @@ import { MissedCallsPage } from "@/pages/missed-calls";
|
|||||||
import { ProfilePage } from "@/pages/profile";
|
import { ProfilePage } from "@/pages/profile";
|
||||||
import { AccountSettingsPage } from "@/pages/account-settings";
|
import { AccountSettingsPage } from "@/pages/account-settings";
|
||||||
import { RulesSettingsPage } from "@/pages/rules-settings";
|
import { RulesSettingsPage } from "@/pages/rules-settings";
|
||||||
|
import { BrandingSettingsPage } from "@/pages/branding-settings";
|
||||||
import { AuthProvider } from "@/providers/auth-provider";
|
import { AuthProvider } from "@/providers/auth-provider";
|
||||||
import { DataProvider } from "@/providers/data-provider";
|
import { DataProvider } from "@/providers/data-provider";
|
||||||
import { RouteProvider } from "@/providers/router-provider";
|
import { RouteProvider } from "@/providers/router-provider";
|
||||||
import { ThemeProvider } from "@/providers/theme-provider";
|
import { ThemeProvider } from "@/providers/theme-provider";
|
||||||
|
import { ThemeTokenProvider } from "@/providers/theme-token-provider";
|
||||||
import { Toaster } from "@/components/application/notifications/toaster";
|
import { Toaster } from "@/components/application/notifications/toaster";
|
||||||
import "@/styles/globals.css";
|
import "@/styles/globals.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<ThemeTokenProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<DataProvider>
|
<DataProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@@ -77,6 +80,7 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
<Route path="/account-settings" element={<AccountSettingsPage />} />
|
<Route path="/account-settings" element={<AccountSettingsPage />} />
|
||||||
<Route path="/rules" element={<RulesSettingsPage />} />
|
<Route path="/rules" element={<RulesSettingsPage />} />
|
||||||
|
<Route path="/branding" element={<BrandingSettingsPage />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
@@ -85,6 +89,7 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</DataProvider>
|
</DataProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</ThemeTokenProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
397
src/pages/branding-settings.tsx
Normal file
397
src/pages/branding-settings.tsx
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faUpload } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
|
import { useThemeTokens } from '@/providers/theme-token-provider';
|
||||||
|
import type { ThemeTokens } from '@/providers/theme-token-provider';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
|
||||||
|
const THEME_API_URL = import.meta.env.VITE_THEME_API_URL ?? import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||||
|
|
||||||
|
const COLOR_STOPS = ['25', '50', '100', '200', '300', '400', '500', '600', '700', '800', '900', '950'];
|
||||||
|
|
||||||
|
const FONT_OPTIONS = [
|
||||||
|
{ id: "'Satoshi', 'Inter', -apple-system, sans-serif", label: 'Satoshi' },
|
||||||
|
{ id: "'General Sans', 'Inter', -apple-system, sans-serif", label: 'General Sans' },
|
||||||
|
{ id: "'Inter', -apple-system, sans-serif", label: 'Inter' },
|
||||||
|
{ id: "'DM Sans', 'Inter', sans-serif", label: 'DM Sans' },
|
||||||
|
{ id: "'Plus Jakarta Sans', 'Inter', sans-serif", label: 'Plus Jakarta Sans' },
|
||||||
|
{ id: "'Nunito Sans', 'Inter', sans-serif", label: 'Nunito Sans' },
|
||||||
|
{ id: "'Source Sans 3', 'Inter', sans-serif", label: 'Source Sans 3' },
|
||||||
|
{ id: "'Poppins', 'Inter', sans-serif", label: 'Poppins' },
|
||||||
|
{ id: "'Lato', 'Inter', sans-serif", label: 'Lato' },
|
||||||
|
{ id: "'Open Sans', 'Inter', sans-serif", label: 'Open Sans' },
|
||||||
|
{ id: "'Roboto', 'Inter', sans-serif", label: 'Roboto' },
|
||||||
|
{ id: "'Noto Sans', 'Inter', sans-serif", label: 'Noto Sans' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const COLOR_PRESETS: Record<string, { label: string; colors: Record<string, string> }> = {
|
||||||
|
blue: {
|
||||||
|
label: 'Blue',
|
||||||
|
colors: { '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)' },
|
||||||
|
},
|
||||||
|
teal: {
|
||||||
|
label: 'Teal',
|
||||||
|
colors: { '25': 'rgb(240 253 250)', '50': 'rgb(204 251 241)', '100': 'rgb(153 246 228)', '200': 'rgb(94 234 212)', '300': 'rgb(45 212 191)', '400': 'rgb(20 184 166)', '500': 'rgb(13 148 136)', '600': 'rgb(15 118 110)', '700': 'rgb(17 94 89)', '800': 'rgb(19 78 74)', '900': 'rgb(17 63 61)', '950': 'rgb(4 47 46)' },
|
||||||
|
},
|
||||||
|
violet: {
|
||||||
|
label: 'Violet',
|
||||||
|
colors: { '25': 'rgb(250 245 255)', '50': 'rgb(245 235 255)', '100': 'rgb(235 215 254)', '200': 'rgb(214 187 251)', '300': 'rgb(182 146 246)', '400': 'rgb(158 119 237)', '500': 'rgb(127 86 217)', '600': 'rgb(105 65 198)', '700': 'rgb(83 56 158)', '800': 'rgb(66 48 125)', '900': 'rgb(53 40 100)', '950': 'rgb(44 28 95)' },
|
||||||
|
},
|
||||||
|
rose: {
|
||||||
|
label: 'Rose',
|
||||||
|
colors: { '25': 'rgb(255 241 242)', '50': 'rgb(255 228 230)', '100': 'rgb(254 205 211)', '200': 'rgb(253 164 175)', '300': 'rgb(251 113 133)', '400': 'rgb(244 63 94)', '500': 'rgb(225 29 72)', '600': 'rgb(190 18 60)', '700': 'rgb(159 18 57)', '800': 'rgb(136 19 55)', '900': 'rgb(112 26 53)', '950': 'rgb(76 5 25)' },
|
||||||
|
},
|
||||||
|
emerald: {
|
||||||
|
label: 'Emerald',
|
||||||
|
colors: { '25': 'rgb(236 253 245)', '50': 'rgb(209 250 229)', '100': 'rgb(167 243 208)', '200': 'rgb(110 231 183)', '300': 'rgb(52 211 153)', '400': 'rgb(16 185 129)', '500': 'rgb(5 150 105)', '600': 'rgb(4 120 87)', '700': 'rgb(6 95 70)', '800': 'rgb(6 78 59)', '900': 'rgb(6 62 48)', '950': 'rgb(2 44 34)' },
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
label: 'Amber',
|
||||||
|
colors: { '25': 'rgb(255 251 235)', '50': 'rgb(254 243 199)', '100': 'rgb(253 230 138)', '200': 'rgb(252 211 77)', '300': 'rgb(251 191 36)', '400': 'rgb(245 158 11)', '500': 'rgb(217 119 6)', '600': 'rgb(180 83 9)', '700': 'rgb(146 64 14)', '800': 'rgb(120 53 15)', '900': 'rgb(99 49 18)', '950': 'rgb(69 26 3)' },
|
||||||
|
},
|
||||||
|
slate: {
|
||||||
|
label: 'Slate',
|
||||||
|
colors: { '25': 'rgb(248 250 252)', '50': 'rgb(241 245 249)', '100': 'rgb(226 232 240)', '200': 'rgb(203 213 225)', '300': 'rgb(148 163 184)', '400': 'rgb(100 116 139)', '500': 'rgb(71 85 105)', '600': 'rgb(47 64 89)', '700': 'rgb(37 49 72)', '800': 'rgb(30 41 59)', '900': 'rgb(15 23 42)', '950': 'rgb(2 6 23)' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate a full color scale from a single hex color
|
||||||
|
const hexToHsl = (hex: string): [number, number, number] => {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||||
|
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||||
|
let h = 0, s = 0;
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
if (max !== min) {
|
||||||
|
const d = max - min;
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||||
|
else if (max === g) h = ((b - r) / d + 2) / 6;
|
||||||
|
else h = ((r - g) / d + 4) / 6;
|
||||||
|
}
|
||||||
|
return [h * 360, s * 100, l * 100];
|
||||||
|
};
|
||||||
|
|
||||||
|
const hslToRgb = (h: number, s: number, l: number): string => {
|
||||||
|
h /= 360; s /= 100; l /= 100;
|
||||||
|
let r: number, g: number, b: number;
|
||||||
|
if (s === 0) { r = g = b = l; } else {
|
||||||
|
const hue2rgb = (p: number, q: number, t: number) => {
|
||||||
|
if (t < 0) t += 1; if (t > 1) t -= 1;
|
||||||
|
if (t < 1/6) return p + (q - p) * 6 * t;
|
||||||
|
if (t < 1/2) return q;
|
||||||
|
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||||
|
const p = 2 * l - q;
|
||||||
|
r = hue2rgb(p, q, h + 1/3);
|
||||||
|
g = hue2rgb(p, q, h);
|
||||||
|
b = hue2rgb(p, q, h - 1/3);
|
||||||
|
}
|
||||||
|
return `rgb(${Math.round(r * 255)} ${Math.round(g * 255)} ${Math.round(b * 255)})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatePalette = (hex: string): Record<string, string> => {
|
||||||
|
const [h, s] = hexToHsl(hex);
|
||||||
|
// Map each stop to a lightness value
|
||||||
|
const stops: Record<string, number> = {
|
||||||
|
'25': 97, '50': 94, '100': 89, '200': 80, '300': 68,
|
||||||
|
'400': 56, '500': 46, '600': 38, '700': 31, '800': 26,
|
||||||
|
'900': 20, '950': 13,
|
||||||
|
};
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const [stop, lightness] of Object.entries(stops)) {
|
||||||
|
// Desaturate lighter stops slightly for natural feel
|
||||||
|
const satAdj = lightness > 80 ? s * 0.6 : lightness > 60 ? s * 0.85 : s;
|
||||||
|
result[stop] = hslToRgb(h, satAdj, lightness);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rgbToHex = (rgb: string): string => {
|
||||||
|
const match = rgb.match(/(\d+)\s+(\d+)\s+(\d+)/);
|
||||||
|
if (!match) return '#3b82f6';
|
||||||
|
const [, r, g, b] = match;
|
||||||
|
return `#${[r, g, b].map(c => parseInt(c).toString(16).padStart(2, '0')).join('')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Section = ({ title, children }: { title: string; children: React.ReactNode }) => (
|
||||||
|
<div className="rounded-xl border border-secondary bg-primary p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-primary mb-3">{title}</h3>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FileUploadField = ({ label, value, onChange, accept }: { label: string; value: string; onChange: (v: string) => void; accept: string }) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => onChange(reader.result as string);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<span className="text-xs font-medium text-secondary">{label}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{value && <img src={value} alt={label} className="size-10 rounded-lg border border-secondary object-contain" />}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-secondary px-3 py-2 text-xs font-medium text-secondary hover:bg-secondary transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faUpload} className="size-3" />
|
||||||
|
{value ? 'Change' : 'Upload'}
|
||||||
|
</button>
|
||||||
|
<input ref={inputRef} type="file" accept={accept} className="hidden" onChange={handleFile} />
|
||||||
|
{value && <span className="text-xs text-tertiary truncate max-w-[200px]">{value.startsWith('data:') ? 'Uploaded file' : value}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BrandingSettingsPage = () => {
|
||||||
|
const { tokens, refresh } = useThemeTokens();
|
||||||
|
const [form, setForm] = useState<ThemeTokens>(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 (
|
||||||
|
<>
|
||||||
|
<TopBar title="Branding" subtitle="Customize the look and feel of your application" />
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
|
||||||
|
{/* LEFT COLUMN */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
|
||||||
|
{/* Brand Identity */}
|
||||||
|
<Section title="Brand Identity">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input size="sm" label="App Name" value={form.brand.name} onChange={(v) => updateBrand('name', v)} />
|
||||||
|
<Input size="sm" label="Hospital Name" value={form.brand.hospitalName} onChange={(v) => updateBrand('hospitalName', v)} />
|
||||||
|
</div>
|
||||||
|
<FileUploadField label="Logo" value={form.brand.logo} onChange={(v) => updateBrand('logo', v)} accept="image/*" />
|
||||||
|
<FileUploadField label="Favicon" value={form.brand.favicon} onChange={(v) => updateBrand('favicon', v)} accept="image/*,.ico" />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Typography */}
|
||||||
|
<Section title="Typography">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Select size="sm" label="Body Font" items={FONT_OPTIONS} selectedKey={form.typography.body || null}
|
||||||
|
onSelectionChange={(key) => updateTypography('body', key as string)} placeholder="Select body font">
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
<Select size="sm" label="Display Font" items={FONT_OPTIONS} selectedKey={form.typography.display || null}
|
||||||
|
onSelectionChange={(key) => updateTypography('display', key as string)} placeholder="Select display font">
|
||||||
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Login Page */}
|
||||||
|
<Section title="Login Page">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input size="sm" label="Title" value={form.login.title} onChange={(v) => updateLogin('title', v)} />
|
||||||
|
<Input size="sm" label="Subtitle" value={form.login.subtitle} onChange={(v) => updateLogin('subtitle', v)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<Checkbox isSelected={form.login.showGoogleSignIn} onChange={(v) => updateLogin('showGoogleSignIn', v)} label="Google Sign-in" />
|
||||||
|
<Checkbox isSelected={form.login.showForgotPassword} onChange={(v) => updateLogin('showForgotPassword', v)} label="Forgot Password" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input size="sm" label="Powered By Label" value={form.login.poweredBy.label} onChange={(v) => updateLogin('poweredBy.label', v)} />
|
||||||
|
<Input size="sm" label="Powered By URL" value={form.login.poweredBy.url} onChange={(v) => updateLogin('poweredBy.url', v)} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<Section title="Sidebar">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input size="sm" label="Title" value={form.sidebar.title} onChange={(v) => updateSidebar('title', v)} />
|
||||||
|
<Input size="sm" label="Subtitle" value={form.sidebar.subtitle} onChange={(v) => updateSidebar('subtitle', v)} hint="{role} = user role" />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT COLUMN */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
|
||||||
|
{/* Brand Colors */}
|
||||||
|
<Section title="Brand Colors">
|
||||||
|
<p className="text-xs text-tertiary -mt-1">Pick a base color or preset — the full palette generates automatically.</p>
|
||||||
|
|
||||||
|
{/* Color picker */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="relative size-10 rounded-lg overflow-hidden border border-secondary cursor-pointer" style={{ backgroundColor: rgbToHex(form.colors.brand['500'] ?? 'rgb(37 99 235)') }}>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={rgbToHex(form.colors.brand['500'] ?? 'rgb(37 99 235)')}
|
||||||
|
onChange={(e) => setForm(prev => ({ ...prev, colors: { brand: generatePalette(e.target.value) } }))}
|
||||||
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-xs font-medium text-secondary">Base Color</span>
|
||||||
|
<p className="text-xs text-tertiary">{rgbToHex(form.colors.brand['500'] ?? 'rgb(37 99 235)')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Presets */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(COLOR_PRESETS).map(([key, preset]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setForm(prev => ({ ...prev, colors: { brand: { ...preset.colors } } }))}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-secondary px-2.5 py-1.5 text-xs font-medium text-secondary hover:border-brand hover:text-brand-secondary transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<div className="flex h-3.5 w-10 rounded overflow-hidden">
|
||||||
|
{['300', '500', '700'].map(s => (
|
||||||
|
<div key={s} className="flex-1" style={{ backgroundColor: preset.colors[s] }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generated palette preview */}
|
||||||
|
<div className="flex h-8 rounded-lg overflow-hidden border border-secondary">
|
||||||
|
{COLOR_STOPS.map(stop => (
|
||||||
|
<div
|
||||||
|
key={stop}
|
||||||
|
className="flex-1 flex items-end justify-center pb-0.5"
|
||||||
|
style={{ backgroundColor: form.colors.brand[stop] ?? '#ccc' }}
|
||||||
|
title={`${stop}: ${form.colors.brand[stop] ?? ''}`}
|
||||||
|
>
|
||||||
|
<span className="text-[8px] font-bold" style={{ color: parseInt(stop) < 400 ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.6)' }}>{stop}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* AI Quick Actions */}
|
||||||
|
<Section title="AI Quick Actions">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{form.ai.quickActions.map((action, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2">
|
||||||
|
<div className="flex-1 grid grid-cols-2 gap-2">
|
||||||
|
<Input size="sm" placeholder="Label" value={action.label} onChange={(v) => updateQuickAction(i, 'label', v)} />
|
||||||
|
<Input size="sm" placeholder="Prompt" value={action.prompt} onChange={(v) => updateQuickAction(i, 'prompt', v)} />
|
||||||
|
</div>
|
||||||
|
<Button size="sm" color="tertiary" onClick={() => removeQuickAction(i)} className="mt-1">Remove</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" color="secondary" onClick={addQuickAction}>Add Quick Action</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer — pinned */}
|
||||||
|
<div className="shrink-0 flex items-center justify-between px-6 py-4 border-t border-secondary">
|
||||||
|
<Button size="sm" color="secondary" onClick={handleReset} isLoading={resetting}>
|
||||||
|
Reset to Defaults
|
||||||
|
</Button>
|
||||||
|
<Button size="md" color="primary" onClick={handleSave} isLoading={saving} showTextWhileLoading>
|
||||||
|
{saving ? 'Saving...' : 'Save Branding'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,12 +10,14 @@ import { Checkbox } from '@/components/base/checkbox/checkbox';
|
|||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
||||||
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||||
|
import { useThemeTokens } from '@/providers/theme-token-provider';
|
||||||
|
|
||||||
export const LoginPage = () => {
|
export const LoginPage = () => {
|
||||||
const { loginWithUser } = useAuth();
|
const { loginWithUser } = useAuth();
|
||||||
const { refresh } = useData();
|
const { refresh } = useData();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isOpen, activeAction, close } = useMaintShortcuts();
|
const { isOpen, activeAction, close } = useMaintShortcuts();
|
||||||
|
const { tokens } = useThemeTokens();
|
||||||
|
|
||||||
const saved = localStorage.getItem('helix_remember');
|
const saved = localStorage.getItem('helix_remember');
|
||||||
const savedCreds = saved ? JSON.parse(saved) : null;
|
const savedCreds = saved ? JSON.parse(saved) : null;
|
||||||
@@ -89,13 +91,13 @@ export const LoginPage = () => {
|
|||||||
<div className="w-full max-w-[420px] bg-primary rounded-xl shadow-xl p-8">
|
<div className="w-full max-w-[420px] bg-primary rounded-xl shadow-xl p-8">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex flex-col items-center mb-8">
|
<div className="flex flex-col items-center mb-8">
|
||||||
<img src="/helix-logo.png" alt="Helix Engage" className="size-12 rounded-xl mb-3" />
|
<img src={tokens.brand.logo} alt={tokens.brand.name} className="size-12 rounded-xl mb-3" />
|
||||||
<h1 className="text-display-xs font-bold text-primary font-display">Sign in to Helix Engage</h1>
|
<h1 className="text-display-xs font-bold text-primary font-display">{tokens.login.title}</h1>
|
||||||
<p className="text-sm text-tertiary mt-1">Global Hospital</p>
|
<p className="text-sm text-tertiary mt-1">{tokens.login.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Google sign-in */}
|
{/* Google sign-in */}
|
||||||
<SocialButton
|
{tokens.login.showGoogleSignIn && <SocialButton
|
||||||
social="google"
|
social="google"
|
||||||
size="lg"
|
size="lg"
|
||||||
theme="gray"
|
theme="gray"
|
||||||
@@ -104,14 +106,14 @@ export const LoginPage = () => {
|
|||||||
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)]"
|
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
|
Sign in with Google
|
||||||
</SocialButton>
|
</SocialButton>}
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="mt-5 mb-5 flex items-center gap-3">
|
{tokens.login.showGoogleSignIn && <div className="mt-5 mb-5 flex items-center gap-3">
|
||||||
<div className="flex-1 h-px bg-secondary" />
|
<div className="flex-1 h-px bg-secondary" />
|
||||||
<span className="text-xs font-semibold text-quaternary tracking-wider uppercase">or continue with</span>
|
<span className="text-xs font-semibold text-quaternary tracking-wider uppercase">or continue with</span>
|
||||||
<div className="flex-1 h-px bg-secondary" />
|
<div className="flex-1 h-px bg-secondary" />
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
|
||||||
@@ -156,13 +158,13 @@ export const LoginPage = () => {
|
|||||||
isSelected={rememberMe}
|
isSelected={rememberMe}
|
||||||
onChange={setRememberMe}
|
onChange={setRememberMe}
|
||||||
/>
|
/>
|
||||||
<button
|
{tokens.login.showForgotPassword && <button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-sm font-semibold text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear"
|
className="text-sm font-semibold text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear"
|
||||||
onClick={() => setError('Password reset is not yet configured. Contact your administrator.')}
|
onClick={() => setError('Password reset is not yet configured. Contact your administrator.')}
|
||||||
>
|
>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</button>
|
</button>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -178,7 +180,7 @@ export const LoginPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<a href="https://f0rty2.ai" target="_blank" rel="noopener noreferrer" className="mt-6 text-xs text-primary_on-brand opacity-60 hover:opacity-90 transition duration-100 ease-linear">Powered by F0rty2.ai</a>
|
<a href={tokens.login.poweredBy.url} target="_blank" rel="noopener noreferrer" className="mt-6 text-xs text-primary_on-brand opacity-60 hover:opacity-90 transition duration-100 ease-linear">{tokens.login.poweredBy.label}</a>
|
||||||
|
|
||||||
<MaintOtpModal isOpen={isOpen} onOpenChange={(open) => !open && close()} action={activeAction} />
|
<MaintOtpModal isOpen={isOpen} onOpenChange={(open) => !open && close()} action={activeAction} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 }) => (
|
const KpiCard = ({ icon, value, label, color }: { icon: any; value: string | number; label: string; color?: string }) => (
|
||||||
<div className="flex flex-1 items-center gap-3 rounded-xl border border-secondary bg-primary p-4">
|
<div className="flex items-center gap-3 rounded-xl border border-secondary bg-primary p-4 min-w-0">
|
||||||
<div className={cx('flex size-10 items-center justify-center rounded-lg', color ?? 'bg-brand-secondary')}>
|
<div className={cx('flex size-10 items-center justify-center rounded-lg', color ?? 'bg-brand-secondary')}>
|
||||||
<FontAwesomeIcon icon={icon} className="size-4 text-fg-white" />
|
<FontAwesomeIcon icon={icon} className="size-4 text-fg-white" />
|
||||||
</div>
|
</div>
|
||||||
@@ -210,8 +210,8 @@ export const TeamPerformancePage = () => {
|
|||||||
const days = Object.keys(dayMap);
|
const days = Object.keys(dayMap);
|
||||||
return {
|
return {
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: 'axis' },
|
||||||
legend: { data: ['Inbound', 'Outbound'], bottom: 0 },
|
legend: { data: ['Inbound', 'Outbound'], bottom: 0, textStyle: { fontSize: 11 } },
|
||||||
grid: { top: 10, right: 10, bottom: 30, left: 40 },
|
grid: { top: 10, right: 10, bottom: 50, left: 40 },
|
||||||
xAxis: { type: 'category', data: days },
|
xAxis: { type: 'category', data: days },
|
||||||
yAxis: { type: 'value' },
|
yAxis: { type: 'value' },
|
||||||
series: [
|
series: [
|
||||||
@@ -292,7 +292,7 @@ export const TeamPerformancePage = () => {
|
|||||||
<h3 className="text-sm font-semibold text-secondary">Key Metrics</h3>
|
<h3 className="text-sm font-semibold text-secondary">Key Metrics</h3>
|
||||||
<DateFilter value={range} onChange={setRange} />
|
<DateFilter value={range} onChange={setRange} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="grid grid-cols-5 gap-3">
|
||||||
<KpiCard icon={faUsers} value={activeAgents} label="Active Agents" color="bg-brand-secondary" />
|
<KpiCard icon={faUsers} value={activeAgents} label="Active Agents" color="bg-brand-secondary" />
|
||||||
<KpiCard icon={faPhoneVolume} value={totalCalls} label="Total Calls" color="bg-brand-solid" />
|
<KpiCard icon={faPhoneVolume} value={totalCalls} label="Total Calls" color="bg-brand-solid" />
|
||||||
<KpiCard icon={faCalendarCheck} value={totalAppts} label="Appointments" color="bg-success-solid" />
|
<KpiCard icon={faCalendarCheck} value={totalAppts} label="Appointments" color="bg-success-solid" />
|
||||||
|
|||||||
87
src/providers/theme-token-provider.tsx
Normal file
87
src/providers/theme-token-provider.tsx
Normal file
@@ -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<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: {
|
||||||
|
'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<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(`${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 (
|
||||||
|
<ThemeTokenContext.Provider value={{ tokens, refresh: fetchTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeTokenContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -764,9 +764,9 @@
|
|||||||
/* SIDEBAR-SPECIFIC COLORS (Light Mode Only) */
|
/* SIDEBAR-SPECIFIC COLORS (Light Mode Only) */
|
||||||
--color-sidebar-bg: rgb(28, 33, 44);
|
--color-sidebar-bg: rgb(28, 33, 44);
|
||||||
--color-sidebar-nav-item-hover-bg: rgb(42, 48, 60);
|
--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-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 */
|
/* COMPONENT COLORS */
|
||||||
--color-app-store-badge-border: rgb(166 166 166);
|
--color-app-store-badge-border: rgb(166 166 166);
|
||||||
|
|||||||
Reference in New Issue
Block a user