shelley: add dark mode with system/light/dark theme switching

Philip Zeyliger created

Prompt: fetch, and in a new worktree based on origin/main, add dark mode to Shelley. In the "dot dot dot" menu in the top right, have a way to switch between system/light/dark, and default to the system mode.
Prompt: menu is taking too much space; can you just have one row, with the three buttons for the three modes?
Prompt: Lots of dark on dark still in dark mode. Not readable. Fix it.

- Add theme service (services/theme.ts) to manage theme state and persistence
- Theme preference stored in localStorage and defaults to 'system'
- Add compact theme toggle row to overflow menu with icon-only buttons
- Apply dark class to document root based on selected theme
- Listen for system color scheme changes when in 'system' mode
- Initialize theme before React render to avoid flash
- Fix text colors for dark mode readability (body, drawer, messages)
- Add link-color CSS variable for both light and dark modes

The UI already had dark mode CSS variables defined (.dark class in styles.css),
this change adds the mechanism to switch between themes and fixes text color
inheritance for proper dark mode contrast.

Change summary

ui/src/components/ChatInterface.tsx | 61 +++++++++++++++++++++++++++++++
ui/src/main.tsx                     |  4 ++
ui/src/services/theme.ts            | 38 +++++++++++++++++++
ui/src/styles.css                   | 58 ++++++++++++++++++++++++++++
4 files changed, 160 insertions(+), 1 deletion(-)

Detailed changes

ui/src/components/ChatInterface.tsx 🔗

@@ -1,6 +1,7 @@
 import React, { useState, useEffect, useRef } from "react";
 import { Message, Conversation, StreamResponse, LLMContent } from "../types";
 import { api } from "../services/api";
+import { ThemeMode, getStoredTheme, setStoredTheme, applyTheme } from "../services/theme";
 import MessageComponent from "./Message";
 import MessageInput from "./MessageInput";
 import DiffViewer from "./DiffViewer";
@@ -428,6 +429,7 @@ function ChatInterface({
   const [showDirectoryPicker, setShowDirectoryPicker] = useState(false);
   // Settings modal removed - configuration moved to status bar for empty conversations
   const [showOverflowMenu, setShowOverflowMenu] = useState(false);
+  const [themeMode, setThemeMode] = useState<ThemeMode>(getStoredTheme);
   const [showDiffViewer, setShowDiffViewer] = useState(false);
   const [diffViewerInitialCommit, setDiffViewerInitialCommit] = useState<string | undefined>(
     undefined,
@@ -1091,6 +1093,65 @@ function ChatInterface({
                     {link.title}
                   </button>
                 ))}
+
+                {/* Theme selector */}
+                <div className="overflow-menu-divider" />
+                <div className="theme-toggle-row">
+                  <button
+                    onClick={() => {
+                      setThemeMode("system");
+                      setStoredTheme("system");
+                      applyTheme("system");
+                    }}
+                    className={`theme-toggle-btn${themeMode === "system" ? " theme-toggle-btn-selected" : ""}`}
+                    title="System"
+                  >
+                    <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                      <path
+                        strokeLinecap="round"
+                        strokeLinejoin="round"
+                        strokeWidth={2}
+                        d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
+                      />
+                    </svg>
+                  </button>
+                  <button
+                    onClick={() => {
+                      setThemeMode("light");
+                      setStoredTheme("light");
+                      applyTheme("light");
+                    }}
+                    className={`theme-toggle-btn${themeMode === "light" ? " theme-toggle-btn-selected" : ""}`}
+                    title="Light"
+                  >
+                    <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                      <path
+                        strokeLinecap="round"
+                        strokeLinejoin="round"
+                        strokeWidth={2}
+                        d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
+                      />
+                    </svg>
+                  </button>
+                  <button
+                    onClick={() => {
+                      setThemeMode("dark");
+                      setStoredTheme("dark");
+                      applyTheme("dark");
+                    }}
+                    className={`theme-toggle-btn${themeMode === "dark" ? " theme-toggle-btn-selected" : ""}`}
+                    title="Dark"
+                  >
+                    <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                      <path
+                        strokeLinecap="round"
+                        strokeLinejoin="round"
+                        strokeWidth={2}
+                        d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
+                      />
+                    </svg>
+                  </button>
+                </div>
               </div>
             )}
           </div>

ui/src/main.tsx 🔗

@@ -1,6 +1,10 @@
 import React from "react";
 import { createRoot } from "react-dom/client";
 import App from "./App";
+import { initializeTheme } from "./services/theme";
+
+// Apply theme before render to avoid flash
+initializeTheme();
 
 // Render main app
 const rootContainer = document.getElementById("root");

ui/src/services/theme.ts 🔗

@@ -0,0 +1,38 @@
+export type ThemeMode = "system" | "light" | "dark";
+
+const STORAGE_KEY = "shelley-theme";
+
+export function getStoredTheme(): ThemeMode {
+  const stored = localStorage.getItem(STORAGE_KEY);
+  if (stored === "light" || stored === "dark" || stored === "system") {
+    return stored;
+  }
+  return "system";
+}
+
+export function setStoredTheme(theme: ThemeMode): void {
+  localStorage.setItem(STORAGE_KEY, theme);
+}
+
+export function getSystemPrefersDark(): boolean {
+  return window.matchMedia("(prefers-color-scheme: dark)").matches;
+}
+
+export function applyTheme(theme: ThemeMode): void {
+  const isDark = theme === "dark" || (theme === "system" && getSystemPrefersDark());
+  document.documentElement.classList.toggle("dark", isDark);
+}
+
+// Initialize theme on load
+export function initializeTheme(): void {
+  const theme = getStoredTheme();
+  applyTheme(theme);
+
+  // Listen for system preference changes
+  window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
+    const currentTheme = getStoredTheme();
+    if (currentTheme === "system") {
+      applyTheme("system");
+    }
+  });
+}

ui/src/styles.css 🔗

@@ -18,6 +18,8 @@ body {
   line-height: 1.6;
   -webkit-font-smoothing: antialiased;
   letter-spacing: -0.01em;
+  color: var(--text-primary);
+  background: var(--bg-secondary);
 }
 
 /* CSS Variables */
@@ -42,6 +44,8 @@ body {
   --blue-bg: #eff6ff;
   --blue-border: #bfdbfe;
   --blue-text: #1e40af;
+  --user-message-text: #2563eb;
+  --link-color: #2563eb;
   --green-600: #16a34a;
   --green-700: #15803d;
   --gray-50: #f9fafb;
@@ -73,6 +77,8 @@ body {
   --blue-bg: rgba(59, 130, 246, 0.1);
   --blue-border: rgba(59, 130, 246, 0.3);
   --blue-text: #93c5fd;
+  --user-message-text: #60a5fa;
+  --link-color: #60a5fa;
 }
 
 /* Layout */
@@ -422,6 +428,7 @@ button {
   max-width: calc(100vw - 3rem); /* Ensure drawer doesn't fill entire screen on mobile */
   background: var(--bg-base);
   border-right: 1px solid var(--border);
+  color: var(--text-primary);
   display: flex;
   flex-direction: column;
   height: 100%;
@@ -569,13 +576,14 @@ button {
 .message-user .message-content {
   margin-left: auto;
   max-width: 80%;
-  color: var(--primary);
+  color: var(--user-message-text);
 }
 
 .message-agent .message-content,
 .message-tool .message-content {
   margin-right: auto;
   max-width: 100%;
+  color: var(--text-primary);
 }
 
 .thinking-indicator {
@@ -1990,6 +1998,54 @@ svg {
   border-bottom-right-radius: 0.5rem;
 }
 
+.overflow-menu-divider {
+  height: 1px;
+  background: var(--border);
+  margin: 0.5rem 0;
+}
+
+.theme-toggle-row {
+  display: flex;
+  gap: 0.25rem;
+  padding: 0.5rem;
+}
+
+.theme-toggle-btn {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0.5rem;
+  background: transparent;
+  border: 1px solid var(--border);
+  border-radius: 0.375rem;
+  color: var(--text-secondary);
+  cursor: pointer;
+  transition: all 0.15s;
+}
+
+.theme-toggle-btn svg {
+  width: 1.125rem;
+  height: 1.125rem;
+}
+
+.theme-toggle-btn:hover {
+  background: var(--bg-tertiary);
+  color: var(--text-primary);
+}
+
+.theme-toggle-btn-selected {
+  background: var(--primary);
+  border-color: var(--primary);
+  color: white;
+}
+
+.theme-toggle-btn-selected:hover {
+  background: var(--primary-dark);
+  border-color: var(--primary-dark);
+  color: white;
+}
+
 /* Diff display styling */
 .diff-display {
   font-family: var(--font-mono);