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:
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user