Theming — Dark & Light Mode
All three apps support light mode, dark mode, and system-preference mode. The user can switch modes at any time via the theme toggle in the sidebar footer.
Implementation Status
| App | Path | Theming status |
|---|---|---|
| Dashboard | apps/dashboard | [Live] — implemented April 2026 |
| DM Portal | apps/dm | [Live] — implemented and verified April 2026 |
| Manage | apps/manage | [Live] — implemented April 2026 |
Technology
| Concern | Solution |
|---|---|
| Theme toggle logic | next-themes — wraps root layout, reads/writes localStorage |
| Dark mode CSS strategy | Tailwind CSS v4 class strategy — .dark class on <html> |
| Color tokens | CSS custom properties (oklch) via Tailwind v4 @theme inline |
| Component styles | shadcn/ui — all components use CSS variable tokens, adapt automatically |
Color Palette
All colors are defined as CSS custom properties on :root (light) and .dark (dark). Components reference these tokens, never raw Tailwind color values directly.
Semantic tokens
| Token | Purpose | Light | Dark |
|---|---|---|---|
--background | Page background | zinc-50 | zinc-950 |
--foreground | Default text | zinc-950 | zinc-50 |
--card | Card / panel surface | white | zinc-900 |
--card-foreground | Text on cards | zinc-950 | zinc-50 |
--popover | Dropdown / tooltip background | white | zinc-900 |
--popover-foreground | Text in popovers | zinc-950 | zinc-50 |
--primary | Primary action (buttons, links, focus rings) | indigo-600 | indigo-500 |
--primary-foreground | Text on primary elements | white | white |
--secondary | Secondary action / surface | zinc-100 | zinc-800 |
--secondary-foreground | Text on secondary | zinc-900 | zinc-100 |
--muted | Muted background (empty states, subtle bg) | zinc-100 | zinc-800 |
--muted-foreground | Muted / placeholder text | zinc-500 | zinc-400 |
--accent | Hover highlight | zinc-100 | zinc-800 |
--accent-foreground | Text on accent | zinc-900 | zinc-50 |
--destructive | Destructive actions (delete, reject) | red-600 | red-500 |
--border | Borders and dividers | zinc-200 | zinc-800 |
--input | Form field borders | zinc-200 | zinc-700 |
--ring | Focus ring | indigo-600 | indigo-500 |
Sidebar-specific tokens
The sidebar has its own token set so it can have a distinct surface color from the page background.
| Token | Light | Dark |
|---|---|---|
--sidebar | white | zinc-900 |
--sidebar-foreground | zinc-900 | zinc-100 |
--sidebar-primary | indigo-600 | indigo-500 |
--sidebar-primary-foreground | white | white |
--sidebar-accent | zinc-100 | zinc-800 |
--sidebar-accent-foreground | zinc-900 | zinc-50 |
--sidebar-border | zinc-200 | zinc-800 |
Project-specific semantic colors
These are project-level tokens used for agent status, risk levels, and content type badges. They sit on top of shadcn/ui’s base palette.
| Token | Value | Usage |
|---|---|---|
--agent-accent | violet-600 / violet-400 | Anything AI/agent-related (agent icon bg, agent badges, live pulse) |
--success | emerald-600 / emerald-400 | Completed status, approval badges, connected channel |
--warning | amber-500 / amber-400 | Retrying, budget warnings, expiring tokens |
--info | blue-500 / blue-400 | Running status, informational banners |
CSS Variable Definition
All variables are defined in packages/ui/src/globals.css, which every app imports.
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-agent-accent: var(--agent-accent);
--color-success: var(--success);
--color-warning: var(--warning);
--color-info: var(--info);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--background: oklch(0.980 0.002 247); /* zinc-50 */
--foreground: oklch(0.141 0.005 285); /* zinc-950 */
--card: oklch(1 0 0); /* white */
--card-foreground: oklch(0.141 0.005 285);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285);
--primary: oklch(0.511 0.262 276); /* indigo-600 */
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.946 0.003 264); /* zinc-100 */
--secondary-foreground: oklch(0.210 0.006 285);
--muted: oklch(0.946 0.003 264);
--muted-foreground: oklch(0.553 0.013 285); /* zinc-500 */
--accent: oklch(0.946 0.003 264);
--accent-foreground: oklch(0.210 0.006 285);
--destructive: oklch(0.577 0.245 27); /* red-600 */
--border: oklch(0.898 0.005 247); /* zinc-200 */
--input: oklch(0.898 0.005 247);
--ring: oklch(0.511 0.262 276); /* indigo-600 */
--radius: 0.5rem;
--sidebar: oklch(1 0 0); /* white */
--sidebar-foreground: oklch(0.210 0.006 285);
--sidebar-primary: oklch(0.511 0.262 276);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.946 0.003 264);
--sidebar-accent-foreground: oklch(0.210 0.006 285);
--sidebar-border: oklch(0.898 0.005 247);
--agent-accent: oklch(0.558 0.288 293); /* violet-600 */
--success: oklch(0.596 0.170 151); /* emerald-600 */
--warning: oklch(0.769 0.188 70); /* amber-500 */
--info: oklch(0.623 0.214 256); /* blue-500 */
}
.dark {
--background: oklch(0.100 0.004 285); /* zinc-950 */
--foreground: oklch(0.985 0.001 247); /* zinc-50 */
--card: oklch(0.179 0.006 285); /* zinc-900 */
--card-foreground: oklch(0.985 0.001 247);
--popover: oklch(0.179 0.006 285);
--popover-foreground: oklch(0.985 0.001 247);
--primary: oklch(0.585 0.233 277); /* indigo-500 */
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.272 0.006 286); /* zinc-800 */
--secondary-foreground: oklch(0.915 0.003 264);
--muted: oklch(0.272 0.006 286);
--muted-foreground: oklch(0.710 0.009 285); /* zinc-400 */
--accent: oklch(0.272 0.006 286);
--accent-foreground: oklch(0.985 0.001 247);
--destructive: oklch(0.637 0.237 25); /* red-500 */
--border: oklch(0.272 0.006 286); /* zinc-800 */
--input: oklch(0.329 0.007 286); /* zinc-700 */
--ring: oklch(0.585 0.233 277); /* indigo-500 */
--sidebar: oklch(0.179 0.006 285); /* zinc-900 */
--sidebar-foreground: oklch(0.915 0.003 264);
--sidebar-primary: oklch(0.585 0.233 277);
--sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.272 0.006 286);
--sidebar-accent-foreground: oklch(0.985 0.001 247);
--sidebar-border: oklch(0.272 0.006 286);
--agent-accent: oklch(0.702 0.202 293); /* violet-400 */
--success: oklch(0.697 0.142 161); /* emerald-400 */
--warning: oklch(0.836 0.157 78); /* amber-400 */
--info: oklch(0.707 0.165 257); /* blue-400 */
}Theme Provider Setup
Each app’s root layout wraps the entire tree with ThemeProvider from next-themes.
// app/layout.tsx
import { ThemeProvider } from 'next-themes';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class" // adds/removes .dark on <html>
defaultTheme="system" // respects OS preference by default
enableSystem
disableTransitionOnChange // prevents flash when switching
>
{children}
</ThemeProvider>
</body>
</html>
);
}suppressHydrationWarning is required on <html> because next-themes adds the class attribute server-side from a cookie, which may differ from the client’s initial render.
Theme Toggle Component
Placed in the topbar (right-hand action cluster, before the notification bell). Uses a three-way cycle: Light → Dark → System.
[Light ☀] → [Dark 🌙] → [System 💻] → [Light ☀] ...Actual implementation (src/components/theme-toggle.tsx)
"use client";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Sun, Moon, Monitor } from "lucide-react";
const THEMES = ["light", "dark", "system"] as const;
type Theme = (typeof THEMES)[number];
const ICONS: Record<Theme, React.ReactNode> = {
light: <Sun className="w-3.5 h-3.5" />,
dark: <Moon className="w-3.5 h-3.5" />,
system: <Monitor className="w-3.5 h-3.5" />,
};
const LABELS: Record<Theme, string> = {
light: "Light",
dark: "Dark",
system: "System",
};
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
// Avoid hydration mismatch — only render after mount
useEffect(() => setMounted(true), []);
const current = (mounted ? theme : "system") as Theme;
const cycle = () => {
const idx = THEMES.indexOf(current);
setTheme(THEMES[(idx + 1) % THEMES.length]);
};
return (
<button
onClick={cycle}
aria-label={`Theme: ${LABELS[current]}. Click to switch.`}
title={`Theme: ${LABELS[current]}`}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded text-xs text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
{mounted ? ICONS[current] : <Monitor className="w-3.5 h-3.5" />}
<span>{mounted ? LABELS[current] : "System"}</span>
</button>
);
}Behaviour
| State | Icon | Label | next-themes value |
|---|---|---|---|
| Light | Sun | Light | "light" |
| Dark | Moon | Dark | "dark" |
| System | Monitor | System | "system" |
On first load (SSR), the button renders a neutral Monitor icon to avoid hydration mismatch — mounted is false until useEffect runs on the client.
Dark Mode for Specific Elements
Agent status pulses
Running agents have an animated pulse ring. The ring colour adapts:
light: ring-blue-500 / bg-blue-100
dark: ring-blue-400 / bg-blue-900/30Terminal / live output pane
The streaming output pane (activity detail, agent run detail) always uses a dark surface regardless of theme, because it resembles a terminal:
bg-zinc-950 text-emerald-400 font-monoThis is achieved by using hard-coded classes (not CSS variables) on this specific component.
Charts (Recharts)
Recharts components receive colours via props, not CSS. A useTheme() hook reads the current theme and passes the appropriate colour set:
const { resolvedTheme } = useTheme();
const chartColors = resolvedTheme === 'dark' ? darkChartColors : lightChartColors;Light chart colors: indigo-500, violet-500, emerald-500, amber-500, blue-500 Dark chart colors: indigo-400, violet-400, emerald-400, amber-400, blue-400
Markdown-rendered content
Deliverable content (blog posts, approval review) uses react-markdown with a custom renderer. Prose styles come from @tailwindcss/typography. Both light and dark prose classes must be applied:
className="prose prose-zinc dark:prose-invert max-w-none"No-Flash Strategy
To avoid a flash of unstyled content (FOUC) when the page loads in dark mode:
next-themesinjects a small inline script in<head>that readslocalStorageand sets theclasson<html>before the page renders.suppressHydrationWarningon<html>suppresses the React mismatch warning that this causes.disableTransitionOnChangeonThemeProviderprevents a jarring CSS transition when the class flips.- CSS transitions for color changes use
transition-colors duration-200only on interactive elements (buttons, links), not on background/text — this keeps switching snappy.
Accessibility
- Color contrast meets WCAG AA (4.5:1 for normal text, 3:1 for large text) in both modes.
- The theme toggle is keyboard-accessible and announces its state via
aria-label. - Users who prefer
prefers-reduced-motionstill see the theme toggle work, but animated pulses and transitions are disabled via@media (prefers-reduced-motion: reduce). - Never rely solely on color to convey meaning — status badges always include text, not just a colored dot.