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 }) => (
))}
+
+ {/* Separator */}
+
+
+ {/* Custom color picker */}
+
+
+
+
+ {/* 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' },