From e1fb1563b1cb12f07837102b6a8ffc82050a67a3 Mon Sep 17 00:00:00 2001 From: Philip Zeyliger Date: Sat, 3 Jan 2026 04:59:54 +0000 Subject: [PATCH] shelley: add dark mode with system/light/dark theme switching 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. --- 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(-) create mode 100644 ui/src/services/theme.ts diff --git a/ui/src/components/ChatInterface.tsx b/ui/src/components/ChatInterface.tsx index d0c75365eb348cccf70e1e3209cdf192cc30936e..ee4b7263eadbca5c98da960469ebc4e83147222c 100644 --- a/ui/src/components/ChatInterface.tsx +++ b/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(getStoredTheme); const [showDiffViewer, setShowDiffViewer] = useState(false); const [diffViewerInitialCommit, setDiffViewerInitialCommit] = useState( undefined, @@ -1091,6 +1093,65 @@ function ChatInterface({ {link.title} ))} + + {/* Theme selector */} +
+
+ + + +
)} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 0a063d242343951c7a5262768221b3675d37ec47..7f2ae20b5cc530b00009a0322ae19f718c57f951 100644 --- a/ui/src/main.tsx +++ b/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"); diff --git a/ui/src/services/theme.ts b/ui/src/services/theme.ts new file mode 100644 index 0000000000000000000000000000000000000000..95cedafdf965189aff9203ea313777d3745caa87 --- /dev/null +++ b/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"); + } + }); +} diff --git a/ui/src/styles.css b/ui/src/styles.css index 1f038c4dc7058c0eb2f9ef2036b9eae6be25daac..22cb0aeea3783fe1521d9548908afb3d3548ee71 100644 --- a/ui/src/styles.css +++ b/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);