Skip to main content

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

ComponentFileStatus
Dark theme variablesui/src/theme.css✅ 50+ variables in :root
Light theme variables❌ Does not exist
Theme dropdown UISettingsPanel.svelte:276-280✅ Renders (light | dark | system)
Theme persistence❌ Not implemented (TODO on line 143)
data-theme attribute❌ Not set on <html>
OS preference detectionprefers-color-scheme not used
Tauri window theme syncset_theme API not called
Component hardcoded colors22+ components⚠️ Some components still have hardcoded colors (GAP-H01 from gap-analysis-v2)

Industry Patterns Reviewed

ApplicationApproachKey Insight
VS Codedata-theme attribute + CSS variables, user-created themes as JSON extensionsThemes are variable overrides, not separate stylesheets
LinearCSS variables with [data-theme] selectorOff-white backgrounds (#f5f5f7), not pure white
Figmaprefers-color-scheme + manual toggleSystem-first with manual override
Ableton Live 12Built-in light/dark + accent color pickerAccent customization highest user demand
Logic ProFollows macOS system themeNo manual control — respects OS preference
BitwigCommunity-driven theming via skin filesFull customization but no official light theme

Design Principles

  1. 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.
  2. Dark theme is the :root default. Light theme is an additive override via [data-theme="light"] selector. This ensures backward compatibility — existing components that only reference var(--x) work without changes.
  3. Off-white, not white. Light backgrounds use #f5f5f7 (warm gray) to reduce eye strain during long sessions. Industry standard (Apple, Linear, VS Code).
  4. 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.
  5. WCAG AA compliance. Every text-on-background pair passes 4.5:1 minimum contrast ratio. Verified programmatically.
  6. System preference respected. data-theme="system" uses @media (prefers-color-scheme: light) to auto-switch, and Tauri's setTheme(null) to match native window chrome.
  7. 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):

VariableDark ValueLight ValueRationale
--bg-app#1a1a2e#f5f5f7Off-white warm gray, not pure white
--bg-panel#16213e#eeeef2Slightly darker for panel layering
--bg-panel-alt#0f3460#e4e4eaCool gray for active/selected areas
--bg-card#1e2a4a#ffffffWhite cards float above gray panels
--bg-input#1a1a3e#ffffffWhite inputs on gray panels
--border#2a2a5a#d4d4dcCool gray decorative borders
--text#e0e0f0#1a1a2eSwapped — dark navy on light (15.7:1)
--text-dim#8888aa#6b6b80Muted slate (4.8:1 on bg-app)
--text-bright#ffffff#0d0d1aNear-black emphasis (17.7:1)
--accent#e94560#c92e49Deepened coral (4.9:1 on bg-app)
--green#4ecca3#227a58Deepened teal (4.8:1 on bg-app)
--blue#5dade2#246fa3Deepened sky blue (5.0:1)
--amber#f0b429#8c6200Deepened gold (5.0:1)
--purple#bb86fc#7c4dbaDeepened lavender (5.3:1)
--green-dim#2a7a5a#e6f5efInverted: dark wash → light wash for badges
--amber-dim#5a4a2a#fef6e0Inverted: dark wash → light wash for badges
--shadow0 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 become rgba(0,0,0,*) — "lighten" overlays in dark mode become "darken" overlays in light mode
  • --green-dim / --amber-dim flip 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 --accent override, 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:

  1. Base theme toggle — Light / Dark / System (same as today)
  2. Accent color picker — visual color wheel or preset swatches
  3. Live preview — changes apply immediately, revert on cancel
  4. Save as preset — name and persist custom themes
  5. 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 :root default. All existing components work unchanged.
  • Minimal file additions. One new CSS file (theme-light.css), one new store (stores/theme.js), minor edits to main.js and SettingsPanel.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.
  • @media duplication. 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 @media queries cannot reference selectors outside them, so duplication is unavoidable without JS-based switching for the "system" case.
  • No FOUC protection yet. If localStorage read 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

RiskMitigation
Hardcoded colors break light themePhase 1 includes component audit + migration as a prerequisite step
Color contrast issues in edge casesProgrammatic 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 burdenPhase 3 is deferred until demand exists; base themes are Anthropic-maintained

References