Dispatch supports multiple color themes. Themes are defined as sets of CSS custom properties and can be switched at runtime via the Settings > Appearance picker. The user's choice persists in localStorage and is applied before React mounts to avoid a flash of wrong colors.
apps/web/src/index.css ← Theme CSS variable definitions (:root + [data-theme="..."])
apps/web/tailwind.config.ts ← Maps CSS vars to Tailwind color utilities
apps/web/src/hooks/use-theme.ts ← React hook + theme registry (ThemeId, THEMES array)
apps/web/src/components/app/settings-pane.tsx ← Appearance UI (theme picker)
apps/web/index.html ← Inline script for flash-free theme on load
All theme colors live in apps/web/src/index.css. The default theme is defined on :root. Additional themes use [data-theme="<id>"] selectors.
These are the standard shadcn tokens. Every theme must define all of them:
| Variable | Purpose |
|---|---|
--background |
Page background |
--foreground |
Default text color |
--card / --card-foreground |
Card surfaces |
--popover / --popover-foreground |
Popover/dropdown surfaces |
--primary / --primary-foreground |
Primary action color (buttons, links) |
--muted / --muted-foreground |
Muted/secondary surfaces and text |
--accent / --accent-foreground |
Accent highlight |
--destructive / --destructive-foreground |
Destructive/error actions |
--border |
Border color |
--input |
Input field border |
--ring |
Focus ring color |
These control agent state indicators, badges, buttons, and status dots throughout the UI:
| Variable | Purpose | Default theme | Example usage |
|---|---|---|---|
--status-working |
Active/success state | Emerald | Agent working indicator, "running" badge, service OK dot |
--status-blocked |
Error/blocked state | Red | Agent blocked, error badge, live stream indicator |
--status-waiting |
Warning/pending state | Amber | Waiting for user, reconnect indicator, full-access warning |
--status-done |
Info/complete state | Sky blue | Agent done, transitional badge, info buttons |
--status-idle |
Neutral/idle state | Zinc gray | Idle agent border |
| Variable | Purpose |
|---|---|
--surface |
Header, footer, and toolbar background (slightly darker than --background) |
The terminal pane paints its own background from the theme's TerminalPalette.background field (see Step 2). There is no separate CSS variable to keep in sync.
All values use bare HSL components without the hsl() wrapper — this allows Tailwind's opacity modifier syntax to work:
--status-working: 158 64% 52%; /* ✓ correct */
--status-working: hsl(158, 64%, 52%); /* ✗ wrong — breaks Tailwind opacity */apps/web/tailwind.config.ts maps CSS vars to Tailwind utilities. The status colors use <alpha-value> for opacity support:
status: {
working: "hsl(var(--status-working) / <alpha-value>)",
blocked: "hsl(var(--status-blocked) / <alpha-value>)",
waiting: "hsl(var(--status-waiting) / <alpha-value>)",
done: "hsl(var(--status-done) / <alpha-value>)",
idle: "hsl(var(--status-idle) / <alpha-value>)",
}This means you can use standard Tailwind patterns:
<span className="text-status-working" /> // solid color
<span className="bg-status-working/15" /> // 15% opacity background
<span className="border-status-blocked/50" /> // 50% opacity borderThe surface color is also available as bg-surface.
Add a new [data-theme="your-theme-id"] block in apps/web/src/index.css. You must define every variable listed above. Copy an existing theme block as a starting point:
[data-theme="midnight"] {
/* Base tokens */
--background: 240 20% 4%;
--foreground: 0 0% 95%;
--card: 240 18% 7%;
--card-foreground: 0 0% 95%;
--popover: 240 18% 7%;
--popover-foreground: 0 0% 95%;
--primary: 270 80% 60%;
--primary-foreground: 240 20% 4%;
--muted: 240 12% 12%;
--muted-foreground: 240 8% 60%;
--accent: 240 12% 12%;
--accent-foreground: 0 0% 95%;
--destructive: 0 80% 55%;
--destructive-foreground: 0 0% 98%;
--border: 240 10% 18%;
--input: 240 10% 18%;
--ring: 270 80% 60%;
/* Status tokens */
--status-working: 140 60% 50%;
--status-blocked: 0 80% 55%;
--status-waiting: 50 90% 60%;
--status-done: 210 90% 60%;
--status-idle: 240 5% 45%;
/* Surface tokens */
--surface: 240 20% 3%;
}- Add the new ID to the
ThemeIdunion type:
export type ThemeId = "default" | "cool-navy" | "midnight";- Add an entry to the
THEMESarray with a label, description, 4 representative hex swatches (shown in the picker UI), and aterminalpalette:
{
id: "midnight",
label: "Midnight",
description: "Deep purple with violet accents",
swatches: ["#0a0a14", "#9966ff", "#f0f0f0", "#2d2d3d"],
terminal: { ...MONOKAI, background: "#0a0a14", cursorAccent: "#0a0a14", black: "#0a0a14" },
},The terminal field is a TerminalPalette object with 22 color properties (foreground, background, cursor, 16 ANSI colors, etc.). For dark themes, you can spread MONOKAI and override just the background. For themes with a distinctive palette (like Solarized), define a full custom palette. See SOLARIZED_DARK in use-theme.ts for an example.
The Light theme intentionally reuses MONOKAI so terminal output from TUIs like the Claude CLI — which emit truecolor escapes tuned for dark backgrounds and bypass xterm's minimumContrastRatio — remains readable. Keep the terminal pane dark when authoring light UI themes.
When the user switches themes, the terminal palette is updated live and the session reconnects so tmux re-sends the viewport with the correct colors.
That's it — the theme picker, localStorage persistence, flash-free loading, and terminal palette all work automatically.
- Run
pnpm run finalize:webto verify the build compiles. - Start a dev server and visually check: sidebar, status footer, badges, buttons, terminal pane, settings pane, and the create-agent dialog.
- Run
pnpm run test:e2eto confirm no regressions.
- Background should be very dark (lightness 4–8%). The
--surfaceshould be slightly darker than--backgroundfor the header/footer strip. - Primary is the most prominent accent — used on the Create button, selected states, and focus rings. Pick something that pops against the background.
- Status colors should be visually distinct from each other. They appear as small dots and text labels, so they need good contrast against both
--backgroundand--card. - Border should be subtle — lightness around 18–22% works well for dark themes.
- Terminal ANSI palette — each theme defines a full 16-color ANSI palette in its
terminalfield. For dark themes, the Monokai base palette works well — just override the background to match. For themes with a distinctive palette (Solarized), define all 16 colors. Ensure good contrast between ANSI colors and the terminal background. Light UI themes should keep a dark terminal palette (see note above).
- Don't use hardcoded Tailwind color classes like
text-emerald-400orbg-red-500for anything that should change with the theme. Use the semanticstatus-*utilities or the base tokens (primary,destructive, etc.) instead. - Don't use hardcoded hex values for backgrounds like
bg-[#141414]. Usebg-surfaceorbg-background. - Don't wrap CSS var values in
hsl()— the bareH S% L%format is required for Tailwind opacity support. - Don't forget to define all variables — a missing variable will cause that color to disappear (transparent) in your theme.
| File | What to edit |
|---|---|
apps/web/src/index.css |
Add [data-theme="..."] CSS variable block |
apps/web/src/hooks/use-theme.ts |
Add to ThemeId type and THEMES array |
apps/web/tailwind.config.ts |
Only if adding new semantic color tokens (rarely needed) |
apps/web/src/components/app/settings-pane.tsx |
Only if changing the picker UI itself |
apps/web/index.html |
Only if changing the flash-prevention script |