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

View File

@@ -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<ThemeId | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const timeoutRef = useRef<NodeJS.Timeout | null>(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 (
<div
ref={containerRef}
className="relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Button
variant="outline"
size="sm"
title="Theme"
aria-label="Select theme"
aria-expanded={isOpen}
aria-haspopup="true"
>
<Palette size={18} />
</Button>
{/* Dropdown */}
{isOpen && (
<div
className="absolute right-0 top-full mt-2 w-56 bg-popover border-2 border-border rounded-lg shadow-lg z-50 animate-slide-in-down overflow-hidden"
role="menu"
aria-orientation="vertical"
>
<div className="p-2 space-y-1">
{themes.map((theme) => (
<button
key={theme.id}
onClick={() => handleThemeClick(theme.id)}
onMouseEnter={() => handleThemeHover(theme.id)}
onMouseLeave={() => setPreviewTheme(null)}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors ${
currentTheme === theme.id
? 'bg-primary/10 text-foreground'
: 'hover:bg-muted text-foreground'
}`}
role="menuitem"
>
{/* Color swatches */}
<div className="flex gap-0.5 shrink-0">
<div
className="w-4 h-4 rounded-sm border border-border/50"
style={{ backgroundColor: theme.previewColors.background }}
/>
<div
className="w-4 h-4 rounded-sm border border-border/50"
style={{ backgroundColor: theme.previewColors.primary }}
/>
<div
className="w-4 h-4 rounded-sm border border-border/50"
style={{ backgroundColor: theme.previewColors.accent }}
/>
</div>
{/* Theme name and description */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{theme.name}</div>
<div className="text-xs text-muted-foreground truncate">
{theme.description}
</div>
</div>
{/* Checkmark for current theme */}
{currentTheme === theme.id && (
<Check size={16} className="text-primary shrink-0" />
)}
</button>
))}
</div>
</div>
)}
</div>
)
}