From 2eab705a8ae2cf2760fd06689f03def816b48cd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 15 Mar 2026 01:42:38 +0100 Subject: [PATCH] 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) --- .../components/layout/ThemePreferences.tsx | 60 +++++++++++++++- frontend/src/main.tsx | 15 ++-- frontend/src/store/theme.ts | 72 +++++++++++++++++-- 3 files changed, 132 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/layout/ThemePreferences.tsx b/frontend/src/components/layout/ThemePreferences.tsx index fbc4671..0b52c81 100644 --- a/frontend/src/components/layout/ThemePreferences.tsx +++ b/frontend/src/components/layout/ThemePreferences.tsx @@ -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 (
@@ -39,7 +39,8 @@ export default function ThemePreferences() { {/* Accent row */}
Accent -
+
+ {/* Preset colors */} {ACCENT_PRESETS.map(({ key, label, hex }) => ( +
+ + {/* Color input (visible when custom is active) */} + {accent === 'custom' && ( +
+ setCustomHex(e.target.value)} + className="w-6 h-5 rounded border border-border-default cursor-pointer p-0" + title="Pick a custom accent color" + /> + { + 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" + /> +
+ )}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a52636d..7cf6152 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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 ( <> diff --git a/frontend/src/store/theme.ts b/frontend/src/store/theme.ts index e09a0f5..c5658bd 100644 --- a/frontend/src/store/theme.ts +++ b/frontend/src/store/theme.ts @@ -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 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()( @@ -44,13 +101,18 @@ export const useThemeStore = create()( (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' },