[eric] theme: accent picker gets a Settings home; applies live like theme

This commit is contained in:
ciregenz
2026-07-15 01:23:39 -07:00
parent 9831941047
commit dd5a67b266
3 changed files with 118 additions and 1 deletions
@@ -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<HTMLDivElement | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (draggingRef.current) applyFromEvent(e.clientX, e.clientY);
}, [applyFromEvent]);
const onPointerUp = useCallback(() => { draggingRef.current = false; }, []);
const dot = accent ? hexToHsl(accent) : null;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, width: '100%' }}>
<div
ref={padRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
style={{
position: 'relative', height, borderRadius: c.radius.lg, cursor: 'crosshair',
border: `1px solid ${c.border.medium}`, touchAction: 'none',
background: 'linear-gradient(to bottom, rgba(255,255,255,0.55), rgba(0,0,0,0.45)), linear-gradient(to right, hsl(0,72%,55%), hsl(60,72%,55%), hsl(120,72%,55%), hsl(180,72%,55%), hsl(240,72%,55%), hsl(300,72%,55%), hsl(360,72%,55%))',
}}
>
{dot && (
<span style={{
position: 'absolute',
left: `${dot.h * 100}%`,
top: `${Math.min(100, Math.max(0, ((0.62 - dot.l) / 0.34) * 100))}%`,
transform: 'translate(-50%, -50%)',
width: 26, height: 26, borderRadius: 999, background: accent ?? 'transparent',
border: '3px solid #fff', boxShadow: '0 2px 8px rgba(0,0,0,0.35)', pointerEvents: 'none',
}} />
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
{ACCENT_PRESETS.map((hex) => (
<button
key={hex}
onClick={() => onPick(hex)}
style={{
width: 26, height: 26, borderRadius: 999, background: hex, cursor: 'pointer',
border: accent === hex ? '2.5px solid #fff' : '2.5px solid transparent',
boxShadow: accent === hex ? `0 0 0 2px ${hex}` : 'none', padding: 0,
}}
/>
))}
<button
onClick={() => onPick(null)}
style={{
marginLeft: 'auto', border: 'none', background: 'transparent', padding: 0,
color: '#8a8a86', fontSize: '0.8rem', cursor: 'pointer', fontFamily: 'inherit', textDecoration: 'underline',
}}
>
Reset
</button>
</div>
</div>
);
};
export default AccentColorPad;
+8 -1
View File
@@ -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;
@@ -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<{
</ToggleButtonGroup>
</Box>
<Box sx={rowSx} {...settingSelectAttrs('accent_color', 'Accent color', 'Interface', 'The accent color used across the app.')}>
<Typography sx={labelSx}>Accent color</Typography>
<Typography sx={{ ...descSx, mb: 1.5 }}>
Pick any color; buttons, highlights, and glows follow it. Reset returns the stock accent.
</Typography>
<AccentColorPad
c={c}
accent={form.accent_color ?? null}
onPick={(hex) => setForm({ ...form, accent_color: hex })}
height={120}
/>
</Box>
<Box sx={rowSx} {...settingSelectAttrs('zoom_sensitivity', 'Zoom sensitivity', 'Interface', 'Scroll-to-zoom responsiveness.')}>
<Typography sx={labelSx}>Zoom sensitivity</Typography>
<Typography sx={{ ...descSx, mb: 1 }}>