diff --git a/frontend/src/app/components/theme/AccentColorPad.tsx b/frontend/src/app/components/theme/AccentColorPad.tsx new file mode 100644 index 00000000..43750d46 --- /dev/null +++ b/frontend/src/app/components/theme/AccentColorPad.tsx @@ -0,0 +1,96 @@ +import React, { useCallback, useRef } from 'react'; +import { hexToHsl, hslToHex } from '@/shared/styles/claudeTokens'; +import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; + +export const ACCENT_PRESETS = ['#ae5630', '#b0453c', '#8e5cb8', '#3a6fc4', '#2e8f6f', '#b08b2e', '#c2588f', '#5c6470']; + +// One control, two homes: the onboarding theme beat drives the live theme through it, and Settings > Interface edits the saved draft. Hue on x, lightness on y; the pad reports a hex and never knows who is listening. +const AccentColorPad: React.FC<{ + c: ClaudeTokens; + accent: string | null; + onPick: (hex: string | null) => void; + height?: number; +}> = ({ c, accent, onPick, height = 240 }) => { + const padRef = useRef(null); + const draggingRef = useRef(false); + const lastApplyRef = useRef(0); + + const applyFromEvent = useCallback((clientX: number, clientY: number) => { + const pad = padRef.current; + if (!pad) return; + // ~30ms throttle: a live listener re-derives tokens and re-renders the tree per apply, and pointermove fires far faster than paint needs. + const now = performance.now(); + if (now - lastApplyRef.current < 30) return; + lastApplyRef.current = now; + const rect = pad.getBoundingClientRect(); + const fx = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); + const fy = Math.min(1, Math.max(0, (clientY - rect.top) / rect.height)); + onPick(hslToHex({ h: fx, s: 0.72, l: 0.62 - fy * 0.34 })); + }, [onPick]); + + const onPointerDown = useCallback((e: React.PointerEvent) => { + draggingRef.current = true; + (e.target as HTMLElement).setPointerCapture?.(e.pointerId); + lastApplyRef.current = 0; + applyFromEvent(e.clientX, e.clientY); + }, [applyFromEvent]); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + if (draggingRef.current) applyFromEvent(e.clientX, e.clientY); + }, [applyFromEvent]); + + const onPointerUp = useCallback(() => { draggingRef.current = false; }, []); + + const dot = accent ? hexToHsl(accent) : null; + + return ( +
+
+ {dot && ( + + )} +
+
+ {ACCENT_PRESETS.map((hex) => ( + +
+
+ ); +}; + +export default AccentColorPad; diff --git a/frontend/src/app/pages/Settings/Settings.tsx b/frontend/src/app/pages/Settings/Settings.tsx index f1eacc28..42c1e5a4 100644 --- a/frontend/src/app/pages/Settings/Settings.tsx +++ b/frontend/src/app/pages/Settings/Settings.tsx @@ -10,7 +10,7 @@ import { updateSettingsPatch, closeSettingsModal, AppSettings } from '@/shared/s import { onboardingBus } from '@/app/components/Onboarding/eventBus'; import { fetchModels } from '@/shared/state/modelsSlice'; import { fetchModes } from '@/shared/state/modesSlice'; -import { useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { useThemeMode, useThemeAccent, useClaudeTokens } from '@/shared/styles/ThemeContext'; import DirectoryBrowser from '@/app/components/editor/DirectoryBrowser'; import { CommandsContent } from '@/app/pages/Commands/Commands'; import GeneralTab from './sections/general/GeneralTab'; @@ -58,6 +58,7 @@ const Settings: React.FC = () => { const loaded = useAppSelector((s) => s.settings.loaded); const modes = useAppSelector((s) => s.modes.items); const { setMode: setThemeMode } = useThemeMode(); + const { setAccent } = useThemeAccent(); const modesList = useMemo(() => Object.values(modes), [modes]); @@ -168,6 +169,12 @@ const Settings: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [form.theme]); + // Accent applies live too, same contract as theme: instant paint, debounced persist. + useEffect(() => { + if (open && loaded) setAccent(form.accent_color ?? null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [form.accent_color]); + useEffect(() => { if (!open || !loaded) return; if (!buildSubmit()) return; diff --git a/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx b/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx index bf06cae0..fe3582ad 100644 --- a/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx +++ b/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx @@ -12,6 +12,7 @@ import KeyboardIcon from '@mui/icons-material/Keyboard'; import LanguageIcon from '@mui/icons-material/Language'; import { AppSettings } from '@/shared/state/settingsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import AccentColorPad from '@/app/components/theme/AccentColorPad'; import type { SettingsStyles } from '../settingsStyles'; import { settingSelectAttrs } from '../settingSelect'; @@ -65,6 +66,19 @@ const GeneralInterface: React.FC<{ + + Accent color + + Pick any color; buttons, highlights, and glows follow it. Reset returns the stock accent. + + setForm({ ...form, accent_color: hex })} + height={120} + /> + + Zoom sensitivity