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

6.2 KiB

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 tokensbg-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:

<div className="bg-[#f8fafc]">

Fix — use token:

<div className="bg-background">

Wrong — inline color without dark variant:

<span style={{ color: '#64748b' }}>

Fix — CSS variable:

<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):

const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { resolvedTheme } = useTheme();
const isDark = mounted && resolvedTheme === 'dark';
const colors = isDark ? adaptColorsForDarkMode(rawColors) : rawColors;