diff --git a/ui/index.html b/ui/index.html index afbdba2..3be92ba 100644 --- a/ui/index.html +++ b/ui/index.html @@ -7,7 +7,7 @@ AutoCoder - +
diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 5794285..05f9986 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -4,6 +4,7 @@ import { useProjects, useFeatures, useAgentStatus, useSettings } from './hooks/u import { useProjectWebSocket } from './hooks/useWebSocket' import { useFeatureSound } from './hooks/useFeatureSound' import { useCelebration } from './hooks/useCelebration' +import { useTheme } from './hooks/useTheme' import { ProjectSelector } from './components/ProjectSelector' import { KanbanBoard } from './components/KanbanBoard' import { AgentControl } from './components/AgentControl' @@ -24,6 +25,7 @@ import { DevServerControl } from './components/DevServerControl' import { ViewToggle, type ViewMode } from './components/ViewToggle' import { DependencyGraph } from './components/DependencyGraph' import { KeyboardShortcutsHelp } from './components/KeyboardShortcutsHelp' +import { ThemeSelector } from './components/ThemeSelector' import { getDependencyGraph } from './lib/api' import { Loader2, Settings, Moon, Sun } from 'lucide-react' import type { Feature } from './lib/types' @@ -32,7 +34,6 @@ import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' const STORAGE_KEY = 'autocoder-selected-project' -const DARK_MODE_KEY = 'autocoder-dark-mode' const VIEW_MODE_KEY = 'autocoder-view-mode' function App() { @@ -56,13 +57,6 @@ function App() { const [showKeyboardHelp, setShowKeyboardHelp] = useState(false) const [isSpecCreating, setIsSpecCreating] = useState(false) const [showSpecChat, setShowSpecChat] = useState(false) // For "Create Spec" button in empty kanban - const [darkMode, setDarkMode] = useState(() => { - try { - return localStorage.getItem(DARK_MODE_KEY) === 'true' - } catch { - return false - } - }) const [viewMode, setViewMode] = useState(() => { try { const stored = localStorage.getItem(VIEW_MODE_KEY) @@ -78,6 +72,7 @@ function App() { const { data: settings } = useSettings() useAgentStatus(selectedProject) // Keep polling for status updates const wsState = useProjectWebSocket(selectedProject) + const { theme, setTheme, darkMode, toggleDarkMode, themes } = useTheme() // Get has_spec from the selected project const selectedProjectData = projects?.find(p => p.name === selectedProject) @@ -91,20 +86,6 @@ function App() { refetchInterval: 5000, // Refresh every 5 seconds }) - // Apply dark mode class to document - useEffect(() => { - if (darkMode) { - document.documentElement.classList.add('dark') - } else { - document.documentElement.classList.remove('dark') - } - try { - localStorage.setItem(DARK_MODE_KEY, String(darkMode)) - } catch { - // localStorage not available - } - }, [darkMode]) - // Persist view mode to localStorage useEffect(() => { try { @@ -325,9 +306,16 @@ function App() { )} + {/* Theme selector */} + + {/* Dark mode toggle - always visible */} + ))} + + + + {/* Dark Mode Toggle */} +
+
+ +

+ Switch between light and dark appearance +

+
+ +
+ +
+ {/* YOLO Mode Toggle */}
diff --git a/ui/src/components/ThemeSelector.tsx b/ui/src/components/ThemeSelector.tsx new file mode 100644 index 0000000..025ec8d --- /dev/null +++ b/ui/src/components/ThemeSelector.tsx @@ -0,0 +1,155 @@ +import { useState, useRef, useEffect } from 'react' +import { Palette, Check } from 'lucide-react' +import { Button } from '@/components/ui/button' +import type { ThemeId, ThemeOption } from '../hooks/useTheme' + +interface ThemeSelectorProps { + themes: ThemeOption[] + currentTheme: ThemeId + onThemeChange: (theme: ThemeId) => void +} + +export function ThemeSelector({ themes, currentTheme, onThemeChange }: ThemeSelectorProps) { + const [isOpen, setIsOpen] = useState(false) + const [previewTheme, setPreviewTheme] = useState(null) + const containerRef = useRef(null) + const timeoutRef = useRef(null) + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false) + setPreviewTheme(null) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + // Apply preview theme temporarily + useEffect(() => { + if (previewTheme) { + const root = document.documentElement + root.classList.remove('theme-claude', 'theme-neo-brutalism') + if (previewTheme === 'claude') { + root.classList.add('theme-claude') + } else if (previewTheme === 'neo-brutalism') { + root.classList.add('theme-neo-brutalism') + } + } + + // Cleanup: restore current theme when preview ends + return () => { + if (previewTheme) { + const root = document.documentElement + root.classList.remove('theme-claude', 'theme-neo-brutalism') + if (currentTheme === 'claude') { + root.classList.add('theme-claude') + } else if (currentTheme === 'neo-brutalism') { + root.classList.add('theme-neo-brutalism') + } + } + } + }, [previewTheme, currentTheme]) + + const handleMouseEnter = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + setIsOpen(true) + } + + const handleMouseLeave = () => { + timeoutRef.current = setTimeout(() => { + setIsOpen(false) + setPreviewTheme(null) + }, 150) + } + + const handleThemeHover = (themeId: ThemeId) => { + setPreviewTheme(themeId) + } + + const handleThemeClick = (themeId: ThemeId) => { + onThemeChange(themeId) + setPreviewTheme(null) + setIsOpen(false) + } + + return ( +
+ + + {/* Dropdown */} + {isOpen && ( +
+
+ {themes.map((theme) => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/ui/src/hooks/useTheme.ts b/ui/src/hooks/useTheme.ts new file mode 100644 index 0000000..5acfda2 --- /dev/null +++ b/ui/src/hooks/useTheme.ts @@ -0,0 +1,124 @@ +import { useState, useEffect, useCallback } from 'react' + +export type ThemeId = 'twitter' | 'claude' | 'neo-brutalism' + +export interface ThemeOption { + id: ThemeId + name: string + description: string + previewColors: { + primary: string + background: string + accent: string + } +} + +export const THEMES: ThemeOption[] = [ + { + id: 'twitter', + name: 'Twitter', + description: 'Clean and modern blue design', + previewColors: { primary: '#4a9eff', background: '#ffffff', accent: '#e8f4ff' } + }, + { + id: 'claude', + name: 'Claude', + description: 'Warm beige tones with orange accents', + previewColors: { primary: '#c75b2a', background: '#faf6f0', accent: '#f5ede4' } + }, + { + id: 'neo-brutalism', + name: 'Neo Brutalism', + description: 'Bold colors with hard shadows', + previewColors: { primary: '#ff4d00', background: '#ffffff', accent: '#ffeb00' } + } +] + +const THEME_STORAGE_KEY = 'autocoder-theme' +const DARK_MODE_STORAGE_KEY = 'autocoder-dark-mode' + +function getThemeClass(themeId: ThemeId): string { + switch (themeId) { + case 'twitter': + return '' // Default, no class needed + case 'claude': + return 'theme-claude' + case 'neo-brutalism': + return 'theme-neo-brutalism' + default: + return '' + } +} + +export function useTheme() { + const [theme, setThemeState] = useState(() => { + try { + const stored = localStorage.getItem(THEME_STORAGE_KEY) + if (stored === 'twitter' || stored === 'claude' || stored === 'neo-brutalism') { + return stored + } + } catch { + // localStorage not available + } + return 'twitter' + }) + + const [darkMode, setDarkModeState] = useState(() => { + try { + return localStorage.getItem(DARK_MODE_STORAGE_KEY) === 'true' + } catch { + return false + } + }) + + // Apply theme and dark mode classes to document + useEffect(() => { + const root = document.documentElement + + // Remove all theme classes + root.classList.remove('theme-claude', 'theme-neo-brutalism') + + // Add current theme class (if not twitter/default) + const themeClass = getThemeClass(theme) + if (themeClass) { + root.classList.add(themeClass) + } + + // Handle dark mode + if (darkMode) { + root.classList.add('dark') + } else { + root.classList.remove('dark') + } + + // Persist to localStorage + try { + localStorage.setItem(THEME_STORAGE_KEY, theme) + localStorage.setItem(DARK_MODE_STORAGE_KEY, String(darkMode)) + } catch { + // localStorage not available + } + }, [theme, darkMode]) + + const setTheme = useCallback((newTheme: ThemeId) => { + setThemeState(newTheme) + }, []) + + const setDarkMode = useCallback((enabled: boolean) => { + setDarkModeState(enabled) + }, []) + + const toggleDarkMode = useCallback(() => { + setDarkModeState(prev => !prev) + }, []) + + return { + theme, + setTheme, + darkMode, + setDarkMode, + toggleDarkMode, + themes: THEMES, + currentTheme: THEMES.find(t => t.id === theme) ?? THEMES[0] + } +} diff --git a/ui/src/styles/globals.css b/ui/src/styles/globals.css index 58b2fd3..edbe89a 100644 --- a/ui/src/styles/globals.css +++ b/ui/src/styles/globals.css @@ -5,7 +5,8 @@ @custom-variant dark (&:where(.dark, .dark *)); /* ============================================================================ - ShadCN Theme - Clean Twitter-Style Design + Theme: Twitter (Default) + Clean, modern blue design ============================================================================ */ :root { @@ -43,6 +44,12 @@ --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); + /* Shadow variables */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + /* Log level colors (kept for Terminal/Debug components) */ --color-log-error: #ef4444; --color-log-warning: #f59e0b; @@ -99,6 +106,12 @@ --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.556 0 0); + /* Shadow variables - dark mode */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3); + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.4), 0 1px 2px -1px rgb(0 0 0 / 0.3); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.4), 0 4px 6px -4px rgb(0 0 0 / 0.3); + /* Log level colors for dark mode */ --color-log-error: #f87171; --color-log-warning: #fbbf24; @@ -112,7 +125,242 @@ --color-status-done: oklch(0.25 0.05 245); } -/* ShadCN Tailwind v4 Theme Integration */ +/* ============================================================================ + Theme: Claude + Warm beige/cream tones with orange primary + ============================================================================ */ + +.theme-claude { + --radius: 0.5rem; + --background: oklch(0.9818 0.0054 95.0986); + --foreground: oklch(0.3438 0.0269 95.7226); + --card: oklch(0.9650 0.0080 90); + --card-foreground: oklch(0.3438 0.0269 95.7226); + --popover: oklch(0.9818 0.0054 95.0986); + --popover-foreground: oklch(0.3438 0.0269 95.7226); + --primary: oklch(0.6171 0.1375 39.0427); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.9400 0.0120 85); + --secondary-foreground: oklch(0.3438 0.0269 95.7226); + --muted: oklch(0.9300 0.0100 90); + --muted-foreground: oklch(0.5500 0.0200 95); + --accent: oklch(0.9200 0.0150 80); + --accent-foreground: oklch(0.3438 0.0269 95.7226); + --destructive: oklch(0.6188 0.2376 25.7658); + --destructive-foreground: oklch(0.985 0 0); + --border: oklch(0.8900 0.0180 85); + --input: oklch(0.9500 0.0080 90); + --ring: oklch(0.6171 0.1375 39.0427); + --chart-1: oklch(0.6171 0.1375 39.0427); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.9700 0.0070 92); + --sidebar-foreground: oklch(0.3438 0.0269 95.7226); + --sidebar-primary: oklch(0.6171 0.1375 39.0427); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.9200 0.0150 80); + --sidebar-accent-foreground: oklch(0.3438 0.0269 95.7226); + --sidebar-border: oklch(0.8900 0.0180 85); + --sidebar-ring: oklch(0.6171 0.1375 39.0427); + + /* Shadow variables - softer for Claude theme */ + --shadow-sm: 0 1px 2px 0 rgb(139 115 85 / 0.05); + --shadow: 0 1px 3px 0 rgb(139 115 85 / 0.08), 0 1px 2px -1px rgb(139 115 85 / 0.06); + --shadow-md: 0 4px 6px -1px rgb(139 115 85 / 0.08), 0 2px 4px -2px rgb(139 115 85 / 0.06); + --shadow-lg: 0 10px 15px -3px rgb(139 115 85 / 0.08), 0 4px 6px -4px rgb(139 115 85 / 0.06); + + /* Log level colors */ + --color-log-error: #dc6b52; + --color-log-warning: #d9a74a; + --color-log-info: #6b9dc4; + --color-log-debug: #8b8578; + --color-log-success: #6b9e6b; + + /* Status colors for Kanban */ + --color-status-pending: oklch(0.9200 0.0300 80); + --color-status-progress: oklch(0.8800 0.0500 60); + --color-status-done: oklch(0.8800 0.0500 140); + + /* Font stacks - system fonts for Claude theme */ + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-mono: 'SF Mono', SFMono-Regular, ui-monospace, 'DejaVu Sans Mono', Menlo, Consolas, monospace; +} + +.theme-claude.dark { + --background: oklch(0.2679 0.0036 106.6427); + --foreground: oklch(0.8074 0.0142 93.0137); + --card: oklch(0.3200 0.0050 100); + --card-foreground: oklch(0.8074 0.0142 93.0137); + --popover: oklch(0.3200 0.0050 100); + --popover-foreground: oklch(0.8074 0.0142 93.0137); + --primary: oklch(0.6800 0.1500 39); + --primary-foreground: oklch(0.15 0 0); + --secondary: oklch(0.3500 0.0080 100); + --secondary-foreground: oklch(0.8074 0.0142 93.0137); + --muted: oklch(0.3800 0.0060 100); + --muted-foreground: oklch(0.6500 0.0120 93); + --accent: oklch(0.4000 0.0100 90); + --accent-foreground: oklch(0.8074 0.0142 93.0137); + --destructive: oklch(0.704 0.191 22.216); + --destructive-foreground: oklch(0.985 0 0); + --border: oklch(0.4200 0.0080 95); + --input: oklch(0.3500 0.0050 100); + --ring: oklch(0.6800 0.1500 39); + --chart-1: oklch(0.6800 0.1500 39); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.2900 0.0040 105); + --sidebar-foreground: oklch(0.8074 0.0142 93.0137); + --sidebar-primary: oklch(0.6800 0.1500 39); + --sidebar-primary-foreground: oklch(0.15 0 0); + --sidebar-accent: oklch(0.3800 0.0080 95); + --sidebar-accent-foreground: oklch(0.8074 0.0142 93.0137); + --sidebar-border: oklch(0.4000 0.0060 100); + --sidebar-ring: oklch(0.6800 0.1500 39); + + /* Shadow variables - dark mode */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.25); + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.35), 0 1px 2px -1px rgb(0 0 0 / 0.25); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.35), 0 2px 4px -2px rgb(0 0 0 / 0.25); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.35), 0 4px 6px -4px rgb(0 0 0 / 0.25); + + /* Log level colors for dark mode */ + --color-log-error: #e8877a; + --color-log-warning: #e5be6d; + --color-log-info: #8bb5d6; + --color-log-debug: #a8a49a; + --color-log-success: #8bb58b; + + /* Status colors for Kanban - dark mode */ + --color-status-pending: oklch(0.3500 0.0300 80); + --color-status-progress: oklch(0.4000 0.0500 60); + --color-status-done: oklch(0.4000 0.0500 140); +} + +/* ============================================================================ + Theme: Neo Brutalism + Bold colors, hard shadows, no border radius + ============================================================================ */ + +.theme-neo-brutalism { + --radius: 0px; + --background: oklch(1.0000 0 0); + --foreground: oklch(0 0 0); + --card: oklch(0.9800 0.0150 95); + --card-foreground: oklch(0 0 0); + --popover: oklch(1.0000 0 0); + --popover-foreground: oklch(0 0 0); + --primary: oklch(0.6489 0.2370 26.9728); + --primary-foreground: oklch(0 0 0); + --secondary: oklch(0.9500 0.1500 100); + --secondary-foreground: oklch(0 0 0); + --muted: oklch(0.9400 0.0100 90); + --muted-foreground: oklch(0.4000 0 0); + --accent: oklch(0.8800 0.1800 85); + --accent-foreground: oklch(0 0 0); + --destructive: oklch(0.6500 0.2500 25); + --destructive-foreground: oklch(0 0 0); + --border: oklch(0 0 0); + --input: oklch(1.0000 0 0); + --ring: oklch(0.6489 0.2370 26.9728); + --chart-1: oklch(0.6489 0.2370 26.9728); + --chart-2: oklch(0.8000 0.2000 130); + --chart-3: oklch(0.7000 0.2200 280); + --chart-4: oklch(0.8800 0.1800 85); + --chart-5: oklch(0.6500 0.2500 330); + --sidebar: oklch(0.9500 0.1500 100); + --sidebar-foreground: oklch(0 0 0); + --sidebar-primary: oklch(0.6489 0.2370 26.9728); + --sidebar-primary-foreground: oklch(0 0 0); + --sidebar-accent: oklch(0.8800 0.1800 85); + --sidebar-accent-foreground: oklch(0 0 0); + --sidebar-border: oklch(0 0 0); + --sidebar-ring: oklch(0.6489 0.2370 26.9728); + + /* Shadow variables - hard shadows */ + --shadow-sm: 2px 2px 0px rgb(0 0 0); + --shadow: 3px 3px 0px rgb(0 0 0); + --shadow-md: 4px 4px 0px rgb(0 0 0); + --shadow-lg: 6px 6px 0px rgb(0 0 0); + + /* Log level colors */ + --color-log-error: #ff0000; + --color-log-warning: #ffaa00; + --color-log-info: #0066ff; + --color-log-debug: #666666; + --color-log-success: #00cc00; + + /* Status colors for Kanban */ + --color-status-pending: oklch(0.9500 0.1500 100); + --color-status-progress: oklch(0.8200 0.1800 200); + --color-status-done: oklch(0.8000 0.2000 130); + + /* Font stacks - DM Sans for Neo Brutalism */ + --font-sans: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: 'Space Mono', 'JetBrains Mono', monospace; +} + +.theme-neo-brutalism.dark { + --background: oklch(0.1200 0 0); + --foreground: oklch(1.0000 0 0); + --card: oklch(0.1800 0.0080 280); + --card-foreground: oklch(1.0000 0 0); + --popover: oklch(0.1500 0 0); + --popover-foreground: oklch(1.0000 0 0); + --primary: oklch(0.7200 0.2500 27); + --primary-foreground: oklch(0 0 0); + --secondary: oklch(0.4500 0.1200 100); + --secondary-foreground: oklch(1.0000 0 0); + --muted: oklch(0.2500 0.0050 0); + --muted-foreground: oklch(0.6500 0 0); + --accent: oklch(0.5500 0.1500 85); + --accent-foreground: oklch(0 0 0); + --destructive: oklch(0.6500 0.2500 25); + --destructive-foreground: oklch(0 0 0); + --border: oklch(0.7000 0 0); + --input: oklch(0.2000 0 0); + --ring: oklch(0.7200 0.2500 27); + --chart-1: oklch(0.7200 0.2500 27); + --chart-2: oklch(0.7500 0.1800 130); + --chart-3: oklch(0.6500 0.2000 280); + --chart-4: oklch(0.7000 0.1500 85); + --chart-5: oklch(0.6000 0.2200 330); + --sidebar: oklch(0.1500 0.0050 280); + --sidebar-foreground: oklch(1.0000 0 0); + --sidebar-primary: oklch(0.7200 0.2500 27); + --sidebar-primary-foreground: oklch(0 0 0); + --sidebar-accent: oklch(0.4500 0.1200 85); + --sidebar-accent-foreground: oklch(1.0000 0 0); + --sidebar-border: oklch(0.5000 0 0); + --sidebar-ring: oklch(0.7200 0.2500 27); + + /* Shadow variables - hard shadows for dark mode */ + --shadow-sm: 2px 2px 0px rgb(255 255 255 / 0.3); + --shadow: 3px 3px 0px rgb(255 255 255 / 0.3); + --shadow-md: 4px 4px 0px rgb(255 255 255 / 0.3); + --shadow-lg: 6px 6px 0px rgb(255 255 255 / 0.3); + + /* Log level colors for dark mode */ + --color-log-error: #ff4444; + --color-log-warning: #ffcc00; + --color-log-info: #4499ff; + --color-log-debug: #999999; + --color-log-success: #44dd44; + + /* Status colors for Kanban - dark mode */ + --color-status-pending: oklch(0.4500 0.1200 100); + --color-status-progress: oklch(0.4500 0.1500 200); + --color-status-done: oklch(0.4500 0.1500 130); +} + +/* ============================================================================ + ShadCN Tailwind v4 Theme Integration + ============================================================================ */ + @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); @@ -155,6 +403,10 @@ --color-sidebar-ring: var(--sidebar-ring); --font-sans: var(--font-sans); --font-mono: var(--font-mono); + --shadow-sm: var(--shadow-sm); + --shadow: var(--shadow); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); } /* ============================================================================ @@ -182,6 +434,11 @@ color: inherit; font-family: inherit; } + + /* Smooth theme transitions */ + :root { + transition: background-color 0.2s ease, color 0.2s ease; + } } /* ============================================================================ @@ -496,6 +753,19 @@ .font-mono { font-family: var(--font-mono); } + + /* Neo Brutalism specific utilities */ + .shadow-neo { + box-shadow: var(--shadow-md); + } + + .shadow-neo-sm { + box-shadow: var(--shadow-sm); + } + + .shadow-neo-lg { + box-shadow: var(--shadow-lg); + } } /* ============================================================================