ADR-012: Theme Architecture — Light/Dark/System Switching & Custom Themes
Status
Proposed (Pending LLM Council Review)
Context
Problem Statement
Conductor's GUI v2 has a comprehensive dark theme (theme.css) with 50+ CSS custom properties covering palette, typography, spacing, radii, and layout dimensions. The SettingsPanel includes a theme dropdown (light | dark | system) but no light theme exists and no switching mechanism is wired up. Selecting "Light" or "System" in the dropdown has no effect.
This is a UX gap. Users working in bright environments, during daytime, or who simply prefer light interfaces have no option. Music production sessions can span hours — eye comfort is a real concern (Ableton Live 12 added official light theme support for exactly this reason).
Current State
| Component | File | Status |
|---|---|---|
| Dark theme variables | ui/src/theme.css | ✅ 50+ variables in :root |
| Light theme variables | — | ❌ Does not exist |
| Theme dropdown UI | SettingsPanel.svelte:276-280 | ✅ Renders (light | dark | system) |
| Theme persistence | — | ❌ Not implemented (TODO on line 143) |
data-theme attribute | — | ❌ Not set on <html> |
| OS preference detection | — | ❌ prefers-color-scheme not used |
| Tauri window theme sync | — | ❌ set_theme API not called |
| Component hardcoded colors | 22+ components | ⚠️ Some components still have hardcoded colors (GAP-H01 from gap-analysis-v2) |
Industry Patterns Reviewed
| Application | Approach | Key Insight |
|---|---|---|
| VS Code | data-theme attribute + CSS variables, user-created themes as JSON extensions | Themes are variable overrides, not separate stylesheets |
| Linear | CSS variables with [data-theme] selector | Off-white backgrounds (#f5f5f7), not pure white |
| Figma | prefers-color-scheme + manual toggle | System-first with manual override |
| Ableton Live 12 | Built-in light/dark + accent color picker | Accent customization highest user demand |
| Logic Pro | Follows macOS system theme | No manual control — respects OS preference |
| Bitwig | Community-driven theming via skin files | Full customization but no official light theme |
Design Principles
- CSS custom properties as the single abstraction layer. All theming is variable overrides — no separate stylesheets per theme, no CSS-in-JS, no build-time compilation.
- Dark theme is the
:rootdefault. Light theme is an additive override via[data-theme="light"]selector. This ensures backward compatibility — existing components that only referencevar(--x)work without changes. - Off-white, not white. Light backgrounds use
#f5f5f7(warm gray) to reduce eye strain during long sessions. Industry standard (Apple, Linear, VS Code). - Same hues, shifted lightness. Accent colors keep their identity across themes but are deepened to maintain WCAG AA contrast (4.5:1) on light backgrounds.
- WCAG AA compliance. Every text-on-background pair passes 4.5:1 minimum contrast ratio. Verified programmatically.
- System preference respected.
data-theme="system"uses@media (prefers-color-scheme: light)to auto-switch, and Tauri'ssetTheme(null)to match native window chrome. - Layered customization (future). Architecture supports user-created themes as JSON variable overrides applied via
style.setProperty()on:root, without stylesheet proliferation.
Decision
Phase 1: Light Theme & Switching (Core)
D1: Theme Variable Architecture
Dark theme remains in theme.css as :root defaults. Light theme lives in a separate theme-light.css file imported after theme.css. The light file contains:
[data-theme="light"] { ... }— overrides all color variables for explicit light mode@media (prefers-color-scheme: light) { [data-theme="system"] { ... } }— overrides for system-preference auto-switching
Only color variables are overridden. Layout (--panel-chat-width), typography (--font-mono, --font-size-*), spacing (--space-*), and radii (--radius-*) are shared across themes.
D2: Switching Mechanism
Theme switching is driven by a data-theme attribute on <html>:
<html data-theme="dark"> → :root variables (dark)
<html data-theme="light"> → [data-theme="light"] overrides
<html data-theme="system"> → @media query decides
A Svelte store (themePreference) persists the choice to localStorage and applies the attribute:
// stores/theme.js
import { writable } from 'svelte/store';
function createThemeStore() {
const stored = localStorage.getItem('conductor-theme') || 'system';
const { subscribe, set } = writable(stored);
function apply(value) {
document.documentElement.setAttribute('data-theme', value);
localStorage.setItem('conductor-theme', value);
set(value);
}
// Apply on creation
document.documentElement.setAttribute('data-theme', stored);
return { subscribe, set: apply };
}
export const themePreference = createThemeStore();
D3: Light Theme Color Palette
All colors verified for WCAG AA compliance (4.5:1 minimum for text):
| Variable | Dark Value | Light Value | Rationale |
|---|---|---|---|
--bg-app | #1a1a2e | #f5f5f7 | Off-white warm gray, not pure white |
--bg-panel | #16213e | #eeeef2 | Slightly darker for panel layering |
--bg-panel-alt | #0f3460 | #e4e4ea | Cool gray for active/selected areas |
--bg-card | #1e2a4a | #ffffff | White cards float above gray panels |
--bg-input | #1a1a3e | #ffffff | White inputs on gray panels |
--border | #2a2a5a | #d4d4dc | Cool gray decorative borders |
--text | #e0e0f0 | #1a1a2e | Swapped — dark navy on light (15.7:1) |
--text-dim | #8888aa | #6b6b80 | Muted slate (4.8:1 on bg-app) |
--text-bright | #ffffff | #0d0d1a | Near-black emphasis (17.7:1) |
--accent | #e94560 | #c92e49 | Deepened coral (4.9:1 on bg-app) |
--green | #4ecca3 | #227a58 | Deepened teal (4.8:1 on bg-app) |
--blue | #5dade2 | #246fa3 | Deepened sky blue (5.0:1) |
--amber | #f0b429 | #8c6200 | Deepened gold (5.0:1) |
--purple | #bb86fc | #7c4dba | Deepened lavender (5.3:1) |
--green-dim | #2a7a5a | #e6f5ef | Inverted: dark wash → light wash for badges |
--amber-dim | #5a4a2a | #fef6e0 | Inverted: dark wash → light wash for badges |
--shadow | 0 4px 12px rgba(0,0,0,0.4) | 0 2px 8px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04) | Dual-layer subtle shadow |
Key inversions for light theme:
--white-*tints becomergba(0,0,0,*)— "lighten" overlays in dark mode become "darken" overlays in light mode--green-dim/--amber-dimflip from dark washes to light washes — badge backgrounds must be lighter than their text in light mode- Brand tints use slightly lower opacity on light backgrounds (0.06/0.10/0.14 vs 0.08/0.15/0.20) for appropriate subtlety
- Overlay opacity scale is compressed — dark theme uses 0.2–0.8, light theme uses 0.06–0.55
D4: Tauri Native Window Chrome Sync
When the theme changes, sync the native title bar appearance via Tauri's window API:
import { getCurrentWindow } from '@tauri-apps/api/window';
async function syncNativeTheme(value) {
const appWindow = getCurrentWindow();
if (value === 'light') await appWindow.setTheme('light');
else if (value === 'dark') await appWindow.setTheme('dark');
else await appWindow.setTheme(null); // system — let OS decide
}
This requires core:window:allow-set-theme in the Tauri ACL config (already present in generated schemas).
D5: Hardcoded Color Migration Prerequisite
Gap analysis v2 identified 22+ components with hardcoded colors (GAP-H01). These must be migrated to CSS variables before the light theme will look correct across the full UI. The light theme CSS is ready, but components using hardcoded dark-mode colors (e.g., background: #1e2a4a instead of var(--bg-card)) will not respond to theme switching.
This is an implementation dependency, not a design decision — the variable names already exist, the components just need updating.
Phase 2: Theme Persistence via Settings Backend
D6: Backend Settings Integration
The SettingsPanel currently has TODO: Add get_settings command (line 143) and TODO: Add save_settings command (line 159). Theme preference should be included in the settings backend:
#[derive(Serialize, Deserialize)]
pub struct GuiSettings {
pub theme: String, // "light" | "dark" | "system"
// ... other settings
}
On startup: read from backend settings → apply to data-theme attribute. The localStorage fallback ensures theme works even before the backend is ready (fast flash-free startup).
Phase 3: Custom Theme Support (Future)
D7: Custom Theme Architecture
Rather than multiple CSS files per theme, store user customizations as a JSON blob that layers on top of the base theme:
{
"name": "Ocean",
"base": "dark",
"overrides": {
"--accent": "#6366f1",
"--green": "#22c55e",
"--font-size-base": "12px"
}
}
Applied at runtime:
function applyCustomTheme(theme) {
document.documentElement.setAttribute('data-theme', theme.base);
for (const [prop, value] of Object.entries(theme.overrides)) {
document.documentElement.style.setProperty(prop, value);
}
}
This avoids stylesheet proliferation and supports:
- Accent color picker — single
--accentoverride, highest-impact customization - Font size scale — adjust
--font-size-base, rest scales proportionally - Custom palette builder — expose 6-8 core color slots as editable swatches
- Preset gallery — ship 3-4 curated themes, let users create/share their own
- Export/import — JSON files for sharing, stored alongside
config.toml
D8: Theme Configuration Panel (Workspace View)
A dedicated ThemeSettings workspace view would replace the simple dropdown in SettingsPanel with:
- Base theme toggle — Light / Dark / System (same as today)
- Accent color picker — visual color wheel or preset swatches
- Live preview — changes apply immediately, revert on cancel
- Save as preset — name and persist custom themes
- Reset to default — one-click restore
This is a future enhancement — the simple dropdown is sufficient for Phase 1-2. The workspace panel makes sense once custom themes have user demand.
Consequences
Positive
- Zero breaking changes. Dark theme remains the
:rootdefault. All existing components work unchanged. - Minimal file additions. One new CSS file (
theme-light.css), one new store (stores/theme.js), minor edits tomain.jsandSettingsPanel.svelte. - WCAG AA compliant. All text-on-background pairs verified at 4.5:1+ contrast.
- System preference respected. Users who set OS-level dark/light mode get automatic matching.
- Custom theme ready. The
style.setProperty()approach for Phase 3 requires no architectural changes — it layers on top of Phase 1-2.
Negative
- Hardcoded color dependency. 22+ components need variable migration (GAP-H01) before light theme looks correct everywhere. This is the main implementation risk.
@mediaduplication. The[data-theme="system"]block inside@media (prefers-color-scheme: light)duplicates the[data-theme="light"]values. This is a deliberate trade-off — CSS custom properties inside@mediaqueries cannot reference selectors outside them, so duplication is unavoidable without JS-based switching for the "system" case.- No FOUC protection yet. If
localStorageread is async or delayed, there may be a flash of dark theme before light applies. Phase 2 (backend persistence) should set the attribute server-side in the HTML template.
Risks
| Risk | Mitigation |
|---|---|
| Hardcoded colors break light theme | Phase 1 includes component audit + migration as a prerequisite step |
| Color contrast issues in edge cases | Programmatic WCAG verification on all pairs; manual visual review in Phase 1 |
| Flash of unstyled content (FOUC) | localStorage read is synchronous — set attribute in <script> before CSS loads |
| Custom themes create support burden | Phase 3 is deferred until demand exists; base themes are Anthropic-maintained |