PSA/docs/ui/theming.md
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

104 lines
6.2 KiB
Markdown

# Theming & Dark Mode
Alga PSA supports light/dark themes via `next-themes` with class-based switching. The `<html>` element gets `.dark` or `.light`, activating CSS variable sets in `globals.css`. All colors flow through CSS custom properties referenced by Tailwind semantic tokens.
## Coding Standards
### Do
1. **Use Tailwind semantic tokens**`bg-primary-50`, `text-foreground`, `border-border`, not raw colors
2. **Use CSS variables** for any custom styling — `rgb(var(--color-text-700))` not `#334155`
3. **Adapt dynamic colors** with `adaptColorsForDarkMode()` for user/entity-generated colors (tags, avatars, etc.)
4. **Use the hydration-safe pattern** when reading `resolvedTheme` in components — track a `mounted` state via `useEffect` and only read the theme after mount to avoid SSR mismatches
5. **Test both themes** — toggle dark mode and verify contrast, readability, borders
6. **Add dark overrides in `globals.css`** when integrating new third-party components
7. **Use `useAppTheme`** from `@alga-psa/ui/hooks` — not `useTheme` from next-themes directly (includes DB sync + preference persistence)
### Don't
1. **Hardcode hex/rgb** in components — won't respond to theme changes
2. **Use `bg-white`/`bg-black`** — use `bg-background` or `bg-card` (existing `bg-white` has a global override but new code should use tokens)
3. **Forget `suppressHydrationWarning`** on `<html>`/`<body>` if modifying root layout
4. **Import `useTheme` directly** from next-themes — use `useAppTheme` instead
### Common Mistakes
**Wrong** — hardcoded background:
```tsx
<div className="bg-[#f8fafc]">
```
**Fix** — use token:
```tsx
<div className="bg-background">
```
**Wrong** — inline color without dark variant:
```tsx
<span style={{ color: '#64748b' }}>
```
**Fix** — CSS variable:
```tsx
<span style={{ color: 'rgb(var(--color-text-500))' }}>
```
## CSS Variable System
Colors are space-separated RGB triples in `server/src/app/globals.css` under `.light`/`.dark`. Tailwind references them via `rgb(var(--color-*))` in `server/tailwind.config.ts` (`darkMode: 'class'`).
In dark mode, 50-900 scales are **inverted** (low=dark, high=light), so `bg-primary-50` is always the subtlest background regardless of theme.
**Token groups**: `--color-background`, `--color-card`, `--color-border-{50-900}`, `--color-text-{50-900}`, `--color-primary/secondary/accent-{50-900}`, `--color-status-success/warning/error`, `--badge-*` (semantic, branding-independent), `--color-sidebar-*`, `--alga-*` (semantic aliases).
**Global dark overrides** in `globals.css` remap common utilities (`bg-white`, `text-gray-700`, `border-slate-200`) to CSS variables. Also overrides for react-day-picker, react-big-calendar, Tiptap/ProseMirror.
## Provider Chain
```
AppThemeProvider → ThemeBridge → BrandingProvider
```
| Provider | File | Role |
|---|---|---|
| `AppThemeProvider` | `server/src/components/providers/AppThemeProvider.tsx` | Wraps next-themes (`attribute="class"`, `defaultTheme="system"`, `disableTransitionOnChange`). Accepts optional `forcedTheme` prop to lock a specific theme (disables system detection). Injects DB persistence via `ThemeActionsProvider`. |
| `ThemeBridge` | `server/src/components/providers/ThemeBridge.tsx` | Syncs `resolvedTheme` to Mantine (`forceColorScheme`) and Radix (`appearance`). Sets `data-theme` on `<html>`. Hides content until mounted (FOUC prevention). |
| `BrandingProvider` | `packages/tenancy/src/components/providers/BrandingProvider.tsx` | Generates CSS var overrides from tenant branding. `.dark {}` block with inverted shades. Checks for server-injected styles to avoid duplication. |
Used in: MSP layout (`server/src/app/layout.tsx`), client portal (`server/src/app/client-portal/ClientPortalLayoutClient.tsx`), client auth (`server/src/app/auth/client-portal/layout.tsx`), MSP auth (`server/src/app/auth/layout.tsx` — with `forcedTheme="light"`).
## Theme Toggle & Persistence
- **`ThemeToggle`** (`packages/ui/src/components/ThemeToggle.tsx`): Light/Dark/System dropdown. Returns `null` when flag disabled. IDs: `theme-toggle`, `data-automation-id="theme-toggle"`.
- **`useAppTheme`** (`packages/ui/src/hooks/useAppTheme.tsx`): Extends next-themes with DB persistence + feature flag check. `lastSyncedTheme` ref prevents circular saves.
**Flow**: Toggle → next-themes sets class → `useAppTheme` calls `updateThemePreferenceAction` → upserts `user_preferences` table (`setting_name='theme'`). On login, loads via `getThemePreferenceAction`.
**Server actions** (`packages/users/src/actions/user-actions/themeActions.ts`): `getThemePreferenceAction` (withOptionalAuth), `updateThemePreferenceAction` (withAuth).
## Branding + Dark Mode
Tenant colors override primary/secondary CSS vars. Dark mode inverts shade scales (`packages/tenancy/src/lib/generateBrandingStyles.ts`): `invertShades()` flips 50<->900, 100<->800, etc. (400/500 stay). FOUC prevention: server-side `<style id="server-tenant-branding-styles">` injection for client-portal routes.
## Extension Iframe Bridge
Extensions receive theme tokens via postMessage (`server/src/lib/extensions/ui/iframeBridge.ts`):
1. `resolveThemeTokens()` reads computed CSS vars from `:root`
2. Sent in bootstrap: `{ type: 'bootstrap', payload: { theme_tokens } }`
3. `MutationObserver` on `<html>` class/data-theme changes triggers re-send
## Color Utilities
`packages/ui/src/lib/colorUtils.ts` exports:
- `adaptColorsForDarkMode(colors)` — darkens light backgrounds to ~18% lightness, lightens dark text to ~78%, preserves hue
- `generateEntityColor(tagOrString)` — generates deterministic pastel colors from a string/tag
- `generateAvatarColor(str)` — generates avatar-specific colors (HSL s:75, l:60 with white text)
- `darkenColor(hex, amount)` — linearly darkens a hex color toward black
`EntityAvatar`, `AvatarIcon`, `ColorPicker`, and all tag components (`TagGrid`, `TagInput`, `TagInputInline`, `TagList`) automatically adapt their colors for dark mode using these utilities.
**Hydration-safe pattern** (required when using `resolvedTheme`):
```tsx
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { resolvedTheme } = useTheme();
const isDark = mounted && resolvedTheme === 'dark';
const colors = isDark ? adaptColorsForDarkMode(rawColors) : rawColors;
```