Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
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
- Use Tailwind semantic tokens —
bg-primary-50,text-foreground,border-border, not raw colors - Use CSS variables for any custom styling —
rgb(var(--color-text-700))not#334155 - Adapt dynamic colors with
adaptColorsForDarkMode()for user/entity-generated colors (tags, avatars, etc.) - Use the hydration-safe pattern when reading
resolvedThemein components — track amountedstate viauseEffectand only read the theme after mount to avoid SSR mismatches - Test both themes — toggle dark mode and verify contrast, readability, borders
- Add dark overrides in
globals.csswhen integrating new third-party components - Use
useAppThemefrom@alga-psa/ui/hooks— notuseThemefrom next-themes directly (includes DB sync + preference persistence)
Don't
- Hardcode hex/rgb in components — won't respond to theme changes
- Use
bg-white/bg-black— usebg-backgroundorbg-card(existingbg-whitehas a global override but new code should use tokens) - Forget
suppressHydrationWarningon<html>/<body>if modifying root layout - Import
useThemedirectly from next-themes — useuseAppThemeinstead
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. Returnsnullwhen 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.lastSyncedThemeref 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):
resolveThemeTokens()reads computed CSS vars from:root- Sent in bootstrap:
{ type: 'bootstrap', payload: { theme_tokens } } MutationObserveron<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 huegenerateEntityColor(tagOrString)— generates deterministic pastel colors from a string/taggenerateAvatarColor(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;