feat(ui): add theme switching system with Twitter, Claude, and Neo Brutalism themes

Add a comprehensive theme system allowing users to switch between three
distinct visual themes, each supporting both light and dark modes:

- Twitter (default): Clean blue design with soft shadows
- Claude: Warm beige/cream tones with orange primary accents
- Neo Brutalism: Bold colors, hard shadows, 0px border radius

New files:
- ui/src/hooks/useTheme.ts: Theme state management hook with localStorage
  persistence for both theme selection and dark mode preference
- ui/src/components/ThemeSelector.tsx: Header dropdown with hover preview
  and color swatches for quick theme switching

Modified files:
- ui/src/styles/globals.css: Added CSS custom properties for Claude and
  Neo Brutalism themes with light/dark variants, shadow variables
  integrated into @theme inline block
- ui/src/App.tsx: Integrated useTheme hook and ThemeSelector component
- ui/src/components/SettingsModal.tsx: Added theme selection UI with
  preview swatches and dark mode toggle
- ui/index.html: Added DM Sans and Space Mono fonts for Neo Brutalism

Features:
- Independent theme and dark mode controls
- Smooth CSS transitions when switching themes
- Theme-specific shadow styles (soft vs hard)
- Theme-specific fonts and border radius
- Persisted preferences in localStorage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Auto
2026-01-26 18:40:24 +02:00
parent c917582a64
commit c402736b92
6 changed files with 637 additions and 27 deletions

124
ui/src/hooks/useTheme.ts Normal file
View File

@@ -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<ThemeId>(() => {
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]
}
}