mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-06 09:47:44 +02:00
[eric] theme: zen-parity device (color-theory harmonies, intensity + grain dials, light/dark/system)
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user