feat: custom accent color picker in theme preferences

- Added 'custom' accent option with color picker + hex input
- Pipette icon on the custom swatch (switches to solid when active)
- Color picker appears inline when custom is selected
- Generates hover/light variants automatically from hex (darken/lighten)
- Dark mode accent-light uses rgba for translucency
- Persisted in localStorage, applied before React hydration (no flash)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 01:42:38 +01:00
parent 79651bc41d
commit 2eab705a8a
3 changed files with 132 additions and 15 deletions
@@ -1,4 +1,4 @@
import { Sun, Monitor, Moon } from 'lucide-react'
import { Sun, Monitor, Moon, Pipette } from 'lucide-react'
import { clsx } from 'clsx'
import { useThemeStore, ACCENT_PRESETS, type ThemeMode } from '../../store/theme'
@@ -9,7 +9,7 @@ const MODES: { key: ThemeMode; icon: typeof Sun; label: string }[] = [
]
export default function ThemePreferences() {
const { mode, accent, setMode, setAccent } = useThemeStore()
const { mode, accent, customHex, setMode, setAccent, setCustomHex } = useThemeStore()
return (
<div className="px-3 py-2 space-y-2">
@@ -39,7 +39,8 @@ export default function ThemePreferences() {
{/* Accent row */}
<div className="flex items-center gap-2">
<span className="text-xs text-content-muted w-12 shrink-0">Accent</span>
<div className="flex gap-2">
<div className="flex items-center gap-2">
{/* Preset colors */}
{ACCENT_PRESETS.map(({ key, label, hex }) => (
<button
key={key}
@@ -56,6 +57,59 @@ export default function ThemePreferences() {
}}
/>
))}
{/* Separator */}
<div className="w-px h-4 bg-border-default" />
{/* Custom color picker */}
<div className="relative">
<button
onClick={() => setAccent('custom')}
title="Custom color"
className={clsx(
'w-5 h-5 rounded-full transition-all flex items-center justify-center border',
accent === 'custom' ? 'scale-125' : 'hover:scale-110',
)}
style={{
backgroundColor: customHex,
borderColor: accent === 'custom' ? 'transparent' : 'var(--color-border)',
outline: accent === 'custom' ? `2px solid ${customHex}` : undefined,
outlineOffset: accent === 'custom' ? '2px' : undefined,
}}
>
{accent !== 'custom' && (
<Pipette size={10} className="text-white drop-shadow-sm" />
)}
</button>
</div>
{/* Color input (visible when custom is active) */}
{accent === 'custom' && (
<div className="flex items-center gap-1.5">
<input
type="color"
value={customHex}
onChange={(e) => setCustomHex(e.target.value)}
className="w-6 h-5 rounded border border-border-default cursor-pointer p-0"
title="Pick a custom accent color"
/>
<input
type="text"
value={customHex}
onChange={(e) => {
const v = e.target.value
if (/^#[0-9a-fA-F]{6}$/.test(v)) setCustomHex(v)
}}
onBlur={(e) => {
let v = e.target.value
if (!v.startsWith('#')) v = '#' + v
if (/^#[0-9a-fA-F]{6}$/.test(v)) setCustomHex(v)
}}
className="w-[4.5rem] px-1.5 py-0.5 text-[10px] font-mono border border-border-default rounded bg-surface text-content focus:outline-none focus:border-accent"
placeholder="#6366f1"
/>
</div>
)}
</div>
</div>
</div>
+8 -7
View File
@@ -14,8 +14,8 @@ import { useThemeStore, applyTheme, resolveTheme, type ThemeMode, type AccentKey
try {
const raw = localStorage.getItem('schaeffler-theme')
if (raw) {
const { state } = JSON.parse(raw) as { state: { mode: ThemeMode; accent: AccentKey } }
applyTheme(state.mode ?? 'light', state.accent ?? 'green')
const { state } = JSON.parse(raw) as { state: { mode: ThemeMode; accent: AccentKey; customHex?: string } }
applyTheme(state.mode ?? 'light', state.accent ?? 'green', state.customHex)
}
} catch {
// ignore — default theme already applied by CSS
@@ -35,21 +35,22 @@ const queryClient = new QueryClient({
function ThemeProvider({ children }: { children: React.ReactNode }) {
const mode = useThemeStore((s) => s.mode)
const accent = useThemeStore((s) => s.accent)
const customHex = useThemeStore((s) => s.customHex)
const resolvedTheme = resolveTheme(mode)
// Apply whenever mode or accent changes
// Apply whenever mode, accent, or customHex changes
useEffect(() => {
applyTheme(mode, accent)
}, [mode, accent])
applyTheme(mode, accent, customHex)
}, [mode, accent, customHex])
// Listen to system preference changes when mode='system'
useEffect(() => {
if (mode !== 'system') return
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => applyTheme('system', accent)
const handler = () => applyTheme('system', accent, customHex)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [mode, accent])
}, [mode, accent, customHex])
return (
<>
+67 -5
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type ThemeMode = 'light' | 'dark' | 'system'
export type AccentKey = 'green' | 'blue' | 'purple' | 'amber' | 'teal'
export type AccentKey = 'green' | 'blue' | 'purple' | 'amber' | 'teal' | 'custom'
export const ACCENT_PRESETS: { key: AccentKey; label: string; hex: string }[] = [
{ key: 'green', label: 'Schaeffler Green', hex: '#00893d' },
@@ -12,11 +12,61 @@ export const ACCENT_PRESETS: { key: AccentKey; label: string; hex: string }[] =
{ key: 'teal', label: 'Teal', hex: '#0d9488' },
]
/** Parse hex color to RGB components */
function hexToRgb(hex: string): [number, number, number] {
const h = hex.replace('#', '')
return [
parseInt(h.substring(0, 2), 16),
parseInt(h.substring(2, 4), 16),
parseInt(h.substring(4, 6), 16),
]
}
/** Darken a hex color by a factor (0-1) */
function darken(hex: string, amount: number): string {
const [r, g, b] = hexToRgb(hex)
const f = 1 - amount
return `#${Math.round(r * f).toString(16).padStart(2, '0')}${Math.round(g * f).toString(16).padStart(2, '0')}${Math.round(b * f).toString(16).padStart(2, '0')}`
}
/** Lighten a hex color to a pastel (for light theme accent-light) */
function lighten(hex: string, amount: number): string {
const [r, g, b] = hexToRgb(hex)
const f = amount
return `#${Math.round(r + (255 - r) * f).toString(16).padStart(2, '0')}${Math.round(g + (255 - g) * f).toString(16).padStart(2, '0')}${Math.round(b + (255 - b) * f).toString(16).padStart(2, '0')}`
}
/** Apply custom accent color CSS variables directly to :root */
function applyCustomAccent(hex: string) {
const root = document.documentElement
const [r, g, b] = hexToRgb(hex)
const isDark = root.classList.contains('dark')
root.style.setProperty('--color-accent', hex)
root.style.setProperty('--color-accent-hover', darken(hex, 0.2))
root.style.setProperty('--color-accent-light', isDark
? `rgba(${r}, ${g}, ${b}, 0.12)`
: lighten(hex, 0.85)
)
root.style.setProperty('--color-accent-text', '#ffffff')
}
/** Clear custom accent CSS variables (revert to preset-driven) */
function clearCustomAccent() {
const root = document.documentElement
root.style.removeProperty('--color-accent')
root.style.removeProperty('--color-accent-hover')
root.style.removeProperty('--color-accent-light')
root.style.removeProperty('--color-accent-text')
}
interface ThemeState {
mode: ThemeMode
accent: AccentKey
customHex: string
setMode: (mode: ThemeMode) => void
setAccent: (accent: AccentKey) => void
setCustomHex: (hex: string) => void
}
/** Returns 'light' | 'dark' based on mode + system preference */
@@ -28,7 +78,7 @@ export function resolveTheme(mode: ThemeMode): 'light' | 'dark' {
}
/** Applies theme to <html> element */
export function applyTheme(mode: ThemeMode, accent: AccentKey) {
export function applyTheme(mode: ThemeMode, accent: AccentKey, customHex?: string) {
const resolved = resolveTheme(mode)
const html = document.documentElement
if (resolved === 'dark') {
@@ -36,7 +86,14 @@ export function applyTheme(mode: ThemeMode, accent: AccentKey) {
} else {
html.classList.remove('dark')
}
html.setAttribute('data-accent', accent)
if (accent === 'custom' && customHex) {
html.setAttribute('data-accent', 'custom')
applyCustomAccent(customHex)
} else {
html.setAttribute('data-accent', accent)
clearCustomAccent()
}
}
export const useThemeStore = create<ThemeState>()(
@@ -44,13 +101,18 @@ export const useThemeStore = create<ThemeState>()(
(set, get) => ({
mode: 'light',
accent: 'green',
customHex: '#6366f1',
setMode: (mode) => {
set({ mode })
applyTheme(mode, get().accent)
applyTheme(mode, get().accent, get().customHex)
},
setAccent: (accent) => {
set({ accent })
applyTheme(get().mode, accent)
applyTheme(get().mode, accent, get().customHex)
},
setCustomHex: (hex) => {
set({ customHex: hex, accent: 'custom' })
applyTheme(get().mode, 'custom', hex)
},
}),
{ name: 'schaeffler-theme' },