[eric] theme: zen-parity device (color-theory harmonies, intensity + grain dials, light/dark/system)

This commit is contained in:
ciregenz
2026-07-15 16:05:19 -07:00
parent 96384285d5
commit c836bd2731
5 changed files with 117 additions and 22 deletions
@@ -1,12 +1,12 @@
import React from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import { Moon, Sun } from 'lucide-react';
import { useThemeAccent, useThemeMode } from '@/shared/styles/ThemeContext';
import { Monitor, Moon, Sun } from 'lucide-react';
import { useThemeAccent, useThemeMode, useThemeWash } from '@/shared/styles/ThemeContext';
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
import AccentColorPad from '@/app/components/theme/AccentColorPad';
import BeatShell from './BeatShell';
// The IKEA-effect beat, staged as a physical picker device (Arc's theme gadget): mode icons on the bezel, the shared pad as the screen. Every touch drives the REAL app theme live; persistence happens at finish().
// The IKEA-effect beat, staged as a physical picker device (Arc/Zen theme gadget): light/dark/system on the bezel, the shared pad (color-theory stops + intensity + grain) as the screen. Every touch drives the REAL app theme live; persistence happens at finish().
const BeatTheme: React.FC<{
c: ClaudeTokens;
onNext: () => void;
@@ -14,12 +14,35 @@ const BeatTheme: React.FC<{
}> = ({ c, onNext, onBack }) => {
const { accent, setAccent, gradient, setGradient } = useThemeAccent();
const { mode, setMode } = useThemeMode();
const { washOpacity, grain, setWashOpacity, setGrain } = useThemeWash();
const stops = gradient ?? (accent ? [accent] : []);
const onStops = (next: string[] | null) => {
setAccent(next?.[0] ?? null);
setGradient(next && next.length > 1 ? next : null);
};
// 'system' isn't a persisted mode; it applies the OS preference now and follows it while this beat is mounted.
const [choice, setChoice] = React.useState<'light' | 'dark' | 'system'>(mode);
const followSystem = useRef(false);
const pickSystem = useCallback(() => {
setChoice('system');
followSystem.current = true;
setMode(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
}, [setMode]);
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => { if (followSystem.current) setMode(mq.matches ? 'dark' : 'light'); };
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, [setMode]);
const pickMode = useCallback((m: 'light' | 'dark') => { followSystem.current = false; setChoice(m); setMode(m); }, [setMode]);
const MODES = [
{ key: 'light' as const, Icon: Sun, onPick: () => pickMode('light') },
{ key: 'dark' as const, Icon: Moon, onPick: () => pickMode('dark') },
{ key: 'system' as const, Icon: Monitor, onPick: pickSystem },
];
return (
<BeatShell
c={c}
@@ -40,24 +63,30 @@ const BeatTheme: React.FC<{
}}
>
<div style={{ display: 'flex', justifyContent: 'center', gap: 10 }}>
{(['light', 'dark'] as const).map((m) => (
{MODES.map(({ key, Icon, onPick }) => (
<button
key={m}
onClick={() => setMode(m)}
title={m === 'light' ? 'Light' : 'Dark'}
key={key}
onClick={onPick}
title={key.charAt(0).toUpperCase() + key.slice(1)}
style={{
width: 34, height: 28, borderRadius: 8, border: 'none', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: mode === m ? c.accent.primary : 'transparent',
color: mode === m ? '#fff' : c.text.inverse + '88',
background: choice === key ? c.accent.primary : 'transparent',
color: choice === key ? '#fff' : c.text.inverse + '88',
transition: 'background 140ms ease, color 140ms ease',
}}
>
{m === 'light' ? <Sun size={15} /> : <Moon size={15} />}
<Icon size={15} />
</button>
))}
</div>
<AccentColorPad c={c} stops={stops} onChange={onStops} height={210} />
<AccentColorPad
c={c}
stops={stops}
onChange={onStops}
height={210}
wash={{ opacity: washOpacity, grain, onOpacity: setWashOpacity, onGrain: setGrain }}
/>
</motion.div>
</BeatShell>
);
@@ -12,13 +12,21 @@ function stopToXY(hex: string): { x: number; y: number } | null {
return { x: hsl.h, y: Math.min(1, Math.max(0, (0.62 - hsl.l) / 0.34)) };
}
// Arc's gradient engine, one control with two homes: 1-3 draggable stops on a hue/lightness field, + adds an analogous stop, - removes the newest. The first stop is the accent the tokens derive from; two or more stops become the canvas gradient wash. The pad reports stops and never knows who is listening.
export interface WashControls {
opacity: number;
grain: number;
onOpacity: (v: number) => void;
onGrain: (v: number) => void;
}
// Arc/Zen gradient engine, one control with two homes: 1-3 draggable stops on a hue/lightness field, + adds a color-theory-harmonized stop (analogous, then triadic), - removes the newest. The first stop is the accent the tokens derive from; 2+ stops become the canvas gradient wash, whose intensity + grain the optional sliders tune. The pad reports stops and never knows who is listening.
const AccentColorPad: React.FC<{
c: ClaudeTokens;
stops: string[];
onChange: (stops: string[] | null) => void;
height?: number;
}> = ({ c, stops, onChange, height = 240 }) => {
wash?: WashControls;
}> = ({ c, stops, onChange, height = 240, wash }) => {
const padRef = useRef<HTMLDivElement | null>(null);
const grabbedRef = useRef<number | null>(null);
const lastApplyRef = useRef(0);
@@ -76,9 +84,11 @@ const AccentColorPad: React.FC<{
const addStop = useCallback(() => {
if (stops.length >= MAX_STOPS) return;
const base = hexToHsl(stops[stops.length - 1] ?? ACCENT_PRESETS[0]);
const hue = ((base?.h ?? 0.08) + 0.11) % 1;
onChange([...stops, hslToHex({ h: hue, s: 0.72, l: base?.l ?? 0.45 })]);
// Color-theory harmony off the FIRST stop: 2nd = analogous (+30deg), 3rd = triadic (+120deg).
const anchor = hexToHsl(stops[0] ?? ACCENT_PRESETS[0]);
const h0 = anchor?.h ?? 0.08;
const hue = (h0 + (stops.length === 1 ? 0.083 : 0.333)) % 1;
onChange([...stops, hslToHex({ h: hue, s: anchor?.s ?? 0.72, l: anchor?.l ?? 0.5 })]);
}, [stops, onChange]);
const removeStop = useCallback(() => {
@@ -162,6 +172,18 @@ const AccentColorPad: React.FC<{
Reset
</button>
</div>
{wash && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.78rem', color: c.text.tertiary }}>
<span style={{ width: 52, flexShrink: 0 }}>Intensity</span>
<input type="range" min={0} max={1} step={0.01} value={wash.opacity} onChange={(e) => wash.onOpacity(parseFloat(e.target.value))} style={{ flex: 1, accentColor: c.accent.primary }} />
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: '0.78rem', color: c.text.tertiary }}>
<span style={{ width: 52, flexShrink: 0 }}>Grain</span>
<input type="range" min={0} max={1} step={0.01} value={wash.grain} onChange={(e) => wash.onGrain(parseFloat(e.target.value))} style={{ flex: 1, accentColor: c.accent.primary }} />
</label>
</div>
)}
</div>
);
};
@@ -6,7 +6,7 @@ import DashboardCardLayer from './DashboardCardLayer';
import DashboardOverlays from './DashboardOverlays';
import DashboardEmptyState from './DashboardEmptyState';
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
import { useThemeAccent } from '@/shared/styles/ThemeContext';
import { useThemeAccent, useThemeWash } from '@/shared/styles/ThemeContext';
import type { AgentSession } from '@/shared/state/agentsSlice';
import type {
CardPosition,
@@ -155,6 +155,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
onSearchPaletteClose,
}) => {
const { gradient } = useThemeAccent();
const { washOpacity, grain } = useThemeWash();
const dotSize = Math.max(1, 1.5 * canvas.zoom);
const dotSpacing = 24 * canvas.zoom;
@@ -217,14 +218,25 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
: 'default',
}}
>
{/* Gradient wash: the user's theme-pad stops tint the canvas, Arc-window style; sits under the dot grid. */}
{/* Gradient wash: the user's theme-pad stops tint the canvas, Arc-window style; intensity + grain come from the theme device; sits under the dot grid. */}
{gradient && gradient.length > 1 && (
<Box
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
background: `linear-gradient(115deg, ${gradient.map((hex, i) => `${hex}2b ${(i / (gradient.length - 1)) * 100}%`).join(', ')})`,
background: `linear-gradient(115deg, ${gradient.map((hex, i) => `${hex}${Math.round(washOpacity * 255).toString(16).padStart(2, '0')} ${(i / (gradient.length - 1)) * 100}%`).join(', ')})`,
}}
/>
)}
{gradient && gradient.length > 1 && grain > 0 && (
<Box
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
opacity: grain * 0.6,
backgroundImage: "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E\")",
}}
/>
)}
@@ -11,7 +11,7 @@ import DarkModeIcon from '@mui/icons-material/DarkMode';
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 { useClaudeTokens, useThemeWash } from '@/shared/styles/ThemeContext';
import AccentColorPad from '@/app/components/theme/AccentColorPad';
import type { SettingsStyles } from '../settingsStyles';
import { settingSelectAttrs } from '../settingSelect';
@@ -22,6 +22,7 @@ const GeneralInterface: React.FC<{
styles: SettingsStyles;
}> = ({ form, setForm, styles }) => {
const c = useClaudeTokens();
const { washOpacity, grain, setWashOpacity, setGrain } = useThemeWash();
const [recordingShortcut, setRecordingShortcut] = useState(false);
const { fieldSx, sectionSx, rowSx, rowLastSx, inlineRowSx, inlineRowLastSx, labelSx, descSx } = styles;
@@ -76,6 +77,7 @@ const GeneralInterface: React.FC<{
stops={form.accent_gradient ?? (form.accent_color ? [form.accent_color] : [])}
onChange={(next) => setForm({ ...form, accent_color: next?.[0] ?? null, accent_gradient: next && next.length > 1 ? next : null })}
height={120}
wash={{ opacity: washOpacity, grain, onOpacity: setWashOpacity, onGrain: setGrain }}
/>
</Box>
+31 -1
View File
@@ -8,15 +8,30 @@ interface ThemeContextValue {
tokens: ClaudeTokens;
accent: string | null;
gradient: string[] | null;
washOpacity: number;
grain: number;
toggleMode: () => void;
setMode: (mode: ThemeMode) => void;
setAccent: (hex: string | null) => void;
setGradient: (stops: string[] | null) => void;
setWashOpacity: (v: number) => void;
setGrain: (v: number) => void;
}
const STORAGE_KEY = 'self-swarm-theme-mode';
const ACCENT_STORAGE_KEY = 'self-swarm-theme-accent';
const GRADIENT_STORAGE_KEY = 'self-swarm-theme-gradient';
const WASH_OPACITY_KEY = 'self-swarm-theme-wash-opacity';
const GRAIN_KEY = 'self-swarm-theme-grain';
export const DEFAULT_WASH_OPACITY = 0.17;
function getInitialNum(key: string, def: number): number {
try {
const v = parseFloat(localStorage.getItem(key) ?? '');
if (Number.isFinite(v) && v >= 0 && v <= 1) return v;
} catch {}
return def;
}
function getInitialGradient(): string[] | null {
try {
@@ -58,16 +73,22 @@ const ThemeContext = createContext<ThemeContextValue>({
tokens: lightTokens,
accent: null,
gradient: null,
washOpacity: DEFAULT_WASH_OPACITY,
grain: 0,
toggleMode: () => {},
setMode: () => {},
setAccent: () => {},
setGradient: () => {},
setWashOpacity: () => {},
setGrain: () => {},
});
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [mode, setModeState] = useState<ThemeMode>(getInitialMode);
const [accent, setAccentState] = useState<string | null>(getInitialAccent);
const [gradient, setGradientState] = useState<string[] | null>(getInitialGradient);
const [washOpacity, setWashOpacityState] = useState<number>(() => getInitialNum(WASH_OPACITY_KEY, DEFAULT_WASH_OPACITY));
const [grain, setGrainState] = useState<number>(() => getInitialNum(GRAIN_KEY, 0));
const firstMount = useRef(true);
useEffect(() => {
@@ -91,6 +112,9 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
} catch {}
}, [gradient]);
useEffect(() => { try { localStorage.setItem(WASH_OPACITY_KEY, String(washOpacity)); } catch {} }, [washOpacity]);
useEffect(() => { try { localStorage.setItem(GRAIN_KEY, String(grain)); } catch {} }, [grain]);
const tokens = useMemo(() => withAccent(mode === 'dark' ? darkTokens : lightTokens, accent, mode), [mode, accent]);
// Stable identities: SettingsLoader's "apply settings.theme" effect lists setMode in its deps, so a setter that changed every render made that effect re-fire on each toggle and re-assert the OLD persisted theme until the debounced save caught up: live theme snapped back for ~900ms = the switch flicker.
@@ -98,8 +122,10 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
const setMode = useCallback((m: ThemeMode) => setModeState(m), []);
const setAccent = useCallback((hex: string | null) => setAccentState(hex), []);
const setGradient = useCallback((stops: string[] | null) => setGradientState(stops), []);
const setWashOpacity = useCallback((v: number) => setWashOpacityState(Math.min(1, Math.max(0, v))), []);
const setGrain = useCallback((v: number) => setGrainState(Math.min(1, Math.max(0, v))), []);
const value = useMemo(() => ({ mode, tokens, accent, gradient, toggleMode, setMode, setAccent, setGradient }), [mode, tokens, accent, gradient, toggleMode, setMode, setAccent, setGradient]);
const value = useMemo(() => ({ mode, tokens, accent, gradient, washOpacity, grain, toggleMode, setMode, setAccent, setGradient, setWashOpacity, setGrain }), [mode, tokens, accent, gradient, washOpacity, grain, toggleMode, setMode, setAccent, setGradient, setWashOpacity, setGrain]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
};
@@ -113,3 +139,7 @@ export const useThemeAccent = () => {
const { accent, setAccent, gradient, setGradient } = useContext(ThemeContext);
return { accent, setAccent, gradient, setGradient };
};
export const useThemeWash = () => {
const { washOpacity, grain, setWashOpacity, setGrain } = useContext(ThemeContext);
return { washOpacity, grain, setWashOpacity, setGrain };
};