diff --git a/frontend/src/app/components/OnboardingV3/beats/BeatTheme.tsx b/frontend/src/app/components/OnboardingV3/beats/BeatTheme.tsx index d786f17e..e8599311 100644 --- a/frontend/src/app/components/OnboardingV3/beats/BeatTheme.tsx +++ b/frontend/src/app/components/OnboardingV3/beats/BeatTheme.tsx @@ -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 (
- {(['light', 'dark'] as const).map((m) => ( + {MODES.map(({ key, Icon, onPick }) => ( ))}
- +
); diff --git a/frontend/src/app/components/theme/AccentColorPad.tsx b/frontend/src/app/components/theme/AccentColorPad.tsx index 3a2e2919..afc76983 100644 --- a/frontend/src/app/components/theme/AccentColorPad.tsx +++ b/frontend/src/app/components/theme/AccentColorPad.tsx @@ -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(null); const grabbedRef = useRef(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 + {wash && ( +
+ + +
+ )} ); }; diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx index fe02b730..2f7a9f62 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx @@ -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 = ({ 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 = ({ : '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 && ( `${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 && ( + )} diff --git a/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx b/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx index 12ca5ad9..94d62fa3 100644 --- a/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx +++ b/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx @@ -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 }} /> diff --git a/frontend/src/shared/styles/ThemeContext.tsx b/frontend/src/shared/styles/ThemeContext.tsx index e1279f1f..f047f6e2 100644 --- a/frontend/src/shared/styles/ThemeContext.tsx +++ b/frontend/src/shared/styles/ThemeContext.tsx @@ -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({ 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(getInitialMode); const [accent, setAccentState] = useState(getInitialAccent); const [gradient, setGradientState] = useState(getInitialGradient); + const [washOpacity, setWashOpacityState] = useState(() => getInitialNum(WASH_OPACITY_KEY, DEFAULT_WASH_OPACITY)); + const [grain, setGrainState] = useState(() => 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 {children}; }; @@ -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 }; +};