[eric] theme: gradient engine (1-3 stops on the pad, canvas wash, gradient-tinted card emblem)

This commit is contained in:
ciregenz
2026-07-15 11:20:05 -07:00
parent 6ad74bea0b
commit 14d83bf499
11 changed files with 184 additions and 52 deletions
+2
View File
@@ -65,6 +65,8 @@ class AppSettings(BaseModel):
onboarding_v3: Optional[str] = None
# User-picked accent hex from the onboarding theme pad; None = stock accent.
accent_color: Optional[str] = None
# Multi-stop gradient from the theme pad (2-3 hexes); washes the canvas.
accent_gradient: Optional[list[str]] = None
personalized_greeting: Optional[str] = None
personalized_starters: list["PersonalizedStarter"] = Field(default_factory=list)
# Suppresses preflight suggestion modal entries the user dismissed; keyed by ToolDefinition.name, value ISO timestamp.
+9 -1
View File
@@ -203,9 +203,10 @@ const DeepLinkListener: React.FC<{ children: React.ReactNode }> = ({ children })
const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const dispatch = useAppDispatch();
const { setMode: setThemeMode } = useThemeMode();
const { setAccent } = useThemeAccent();
const { setAccent, setGradient } = useThemeAccent();
const theme = useAppSelector((s) => s.settings.data.theme);
const accentColor = useAppSelector((s) => s.settings.data.accent_color);
const accentGradient = useAppSelector((s) => s.settings.data.accent_gradient);
const loaded = useAppSelector((s) => s.settings.loaded);
const allowExperimentalUpdates = useAppSelector((s) => s.settings.data.allow_experimental_updates);
useEffect(() => {
@@ -263,6 +264,13 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
if (loaded) setAccent(accentColor ?? null);
}, [loaded, accentColor, setAccent]);
// Object identity churns on every settings fetch, so key the effect on the serialized stops.
const gradientKey = JSON.stringify(accentGradient ?? null);
useEffect(() => {
if (loaded) setGradient(accentGradient ?? null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loaded, gradientKey, setGradient]);
useEffect(() => {
if (!loaded) return;
(window as any).openswarm?.setAllowPrerelease?.(allowExperimentalUpdates);
@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react';
import { motion } from 'framer-motion';
import { useThemeAccent } from '@/shared/styles/ThemeContext';
import { Dices } from 'lucide-react';
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
import type { ProviderIdentity } from '../onboardingV3Api';
@@ -27,6 +28,7 @@ const BeatCard: React.FC<{
onFinish: (name: string | null) => void;
onBack: () => void;
}> = ({ c, identity, onFinish, onBack }) => {
const { gradient } = useThemeAccent();
const [name, setName] = useState(() => nameFromIdentity(identity));
const seed = useMemo(() => Math.floor(Math.random() * EPITHETS.length), []);
const [roll, setRoll] = useState(0);
@@ -54,7 +56,7 @@ const BeatCard: React.FC<{
}}
>
<div style={{ alignSelf: 'flex-start', marginLeft: -8, marginTop: -4 }}>
<Starburst size={158} from={c.accent.hover} to={c.accent.pressed} />
<Starburst size={158} from={gradient?.[0] ?? c.accent.hover} to={gradient?.[gradient.length - 1] ?? c.accent.pressed} />
</div>
<input
value={name}
@@ -12,14 +12,19 @@ const BeatTheme: React.FC<{
onNext: () => void;
onBack: () => void;
}> = ({ c, onNext, onBack }) => {
const { accent, setAccent } = useThemeAccent();
const { accent, setAccent, gradient, setGradient } = useThemeAccent();
const { mode, setMode } = useThemeMode();
const stops = gradient ?? (accent ? [accent] : []);
const onStops = (next: string[] | null) => {
setAccent(next?.[0] ?? null);
setGradient(next && next.length > 1 ? next : null);
};
return (
<BeatShell
c={c}
title="Make it yours."
body="Pick a color, any color. The whole app repaints as you drag; this is your home now."
body="Pick a color, or add a second dot for a gradient. The whole app repaints as you drag; this is your home now."
nextLabel="Continue"
onNext={onNext}
onBack={onBack}
@@ -52,7 +57,7 @@ const BeatTheme: React.FC<{
</button>
))}
</div>
<AccentColorPad c={c} accent={accent} onPick={setAccent} height={210} />
<AccentColorPad c={c} stops={stops} onChange={onStops} height={210} />
</motion.div>
</BeatShell>
);
@@ -11,7 +11,7 @@ import {
// The curtain machinery: scan kicks off during the OAuth wait, prep during the theme beat, so by the reveal everything personal is already sitting in memory. Every stage fails soft; the flow never blocks on any of it.
export function useOnboardingV3Pipeline() {
const dispatch = useAppDispatch();
const { accent } = useThemeAccent();
const { accent, gradient } = useThemeAccent();
const { mode } = useThemeMode();
const [identity, setIdentity] = useState<ProviderIdentity[]>([]);
const scanRef = useRef<Promise<ScanResult | null> | null>(null);
@@ -38,7 +38,7 @@ export function useOnboardingV3Pipeline() {
const finish = useCallback(async (outcome: 'done' | 'skipped') => {
if (outcome === 'skipped') {
dispatch(setFlowActive(false));
dispatch(updateSettingsPatch({ onboarding_v3: 'skipped', accent_color: accent, theme: mode }));
dispatch(updateSettingsPatch({ onboarding_v3: 'skipped', accent_color: accent, accent_gradient: gradient, theme: mode }));
return;
}
// Cap the wait so a slow aux call degrades to generic starters instead of a hung curtain.
@@ -51,6 +51,7 @@ export function useOnboardingV3Pipeline() {
await dispatch(updateSettingsPatch({
onboarding_v3: 'done',
accent_color: accent,
accent_gradient: gradient,
theme: mode,
personalized_greeting: greeting,
personalized_starters: starters,
@@ -58,7 +59,7 @@ export function useOnboardingV3Pipeline() {
} catch {}
dispatch(stageReveal({ greeting, starters, scanSummary: summarizeScan(scanResultRef.current) }));
dispatch(setFlowActive(false));
}, [dispatch, accent, mode]);
}, [dispatch, accent, gradient, mode]);
return { identity, kickIdentity, kickScan, kickPrep, finish };
}
@@ -1,47 +1,90 @@
import React, { useCallback, useRef } from 'react';
import { Minus, Plus } from 'lucide-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'];
const MAX_STOPS = 3;
// 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.
function stopToXY(hex: string): { x: number; y: number } | null {
const hsl = hexToHsl(hex);
if (!hsl) return 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.
const AccentColorPad: React.FC<{
c: ClaudeTokens;
accent: string | null;
onPick: (hex: string | null) => void;
stops: string[];
onChange: (stops: string[] | null) => void;
height?: number;
}> = ({ c, accent, onPick, height = 240 }) => {
}> = ({ c, stops, onChange, height = 240 }) => {
const padRef = useRef<HTMLDivElement | null>(null);
const draggingRef = useRef(false);
const grabbedRef = useRef<number | null>(null);
const lastApplyRef = useRef(0);
const applyFromEvent = useCallback((clientX: number, clientY: number) => {
const pointToHex = useCallback((clientX: number, clientY: number): string | null => {
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;
if (!pad) return null;
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]);
return hslToHex({ h: fx, s: 0.72, l: 0.62 - fy * 0.34 });
}, []);
const nearestStop = useCallback((clientX: number, clientY: number): number => {
const pad = padRef.current;
if (!pad || stops.length === 0) return 0;
const rect = pad.getBoundingClientRect();
let best = 0;
let bestDist = Infinity;
stops.forEach((hex, i) => {
const xy = stopToXY(hex);
if (!xy) return;
const dx = rect.left + xy.x * rect.width - clientX;
const dy = rect.top + xy.y * rect.height - clientY;
const d = dx * dx + dy * dy;
if (d < bestDist) { bestDist = d; best = i; }
});
return best;
}, [stops]);
const applyAt = useCallback((clientX: number, clientY: number) => {
// ~30ms throttle: a live listener re-derives tokens and re-renders the tree per apply.
const now = performance.now();
if (now - lastApplyRef.current < 30) return;
lastApplyRef.current = now;
const hex = pointToHex(clientX, clientY);
if (!hex) return;
const idx = grabbedRef.current ?? 0;
const next = stops.length === 0 ? [hex] : stops.map((s, i) => (i === idx ? hex : s));
onChange(next);
}, [pointToHex, stops, onChange]);
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
draggingRef.current = true;
grabbedRef.current = nearestStop(e.clientX, e.clientY);
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
lastApplyRef.current = 0;
applyFromEvent(e.clientX, e.clientY);
}, [applyFromEvent]);
applyAt(e.clientX, e.clientY);
}, [nearestStop, applyAt]);
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (draggingRef.current) applyFromEvent(e.clientX, e.clientY);
}, [applyFromEvent]);
if (grabbedRef.current !== null) applyAt(e.clientX, e.clientY);
}, [applyAt]);
const onPointerUp = useCallback(() => { draggingRef.current = false; }, []);
const onPointerUp = useCallback(() => { grabbedRef.current = null; }, []);
const dot = accent ? hexToHsl(accent) : null;
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 })]);
}, [stops, onChange]);
const removeStop = useCallback(() => {
if (stops.length <= 1) return;
onChange(stops.slice(0, -1));
}, [stops, onChange]);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, width: '100%' }}>
@@ -56,31 +99,61 @@ const AccentColorPad: React.FC<{
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',
}} />
)}
{stops.map((hex, i) => {
const xy = stopToXY(hex);
if (!xy) return null;
return (
<span key={i} style={{
position: 'absolute', left: `${xy.x * 100}%`, top: `${xy.y * 100}%`,
transform: 'translate(-50%, -50%)',
width: i === 0 ? 28 : 22, height: i === 0 ? 28 : 22,
borderRadius: 999, background: hex,
border: '3px solid #fff', boxShadow: '0 2px 8px rgba(0,0,0,0.35)', pointerEvents: 'none',
}} />
);
})}
<div
onPointerDown={(e) => e.stopPropagation()}
style={{ position: 'absolute', bottom: 8, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 6 }}
>
<button
onClick={removeStop}
disabled={stops.length <= 1}
style={{
width: 26, height: 22, borderRadius: 7, border: 'none', cursor: stops.length > 1 ? 'pointer' : 'default',
background: 'rgba(20,20,19,0.55)', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: stops.length > 1 ? 1 : 0.4,
}}
>
<Minus size={13} />
</button>
<button
onClick={addStop}
disabled={stops.length >= MAX_STOPS}
style={{
width: 26, height: 22, borderRadius: 7, border: 'none', cursor: stops.length < MAX_STOPS ? 'pointer' : 'default',
background: 'rgba(20,20,19,0.55)', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: stops.length < MAX_STOPS ? 1 : 0.4,
}}
>
<Plus size={13} />
</button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
{ACCENT_PRESETS.map((hex) => (
<button
key={hex}
onClick={() => onPick(hex)}
onClick={() => onChange([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,
border: stops[0] === hex && stops.length === 1 ? '2.5px solid #fff' : '2.5px solid transparent',
boxShadow: stops[0] === hex && stops.length === 1 ? `0 0 0 2px ${hex}` : 'none', padding: 0,
}}
/>
))}
<button
onClick={() => onPick(null)}
onClick={() => onChange(null)}
style={{
marginLeft: 'auto', border: 'none', background: 'transparent', padding: 0,
color: '#8a8a86', fontSize: '0.8rem', cursor: 'pointer', fontFamily: 'inherit', textDecoration: 'underline',
@@ -6,6 +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 type { AgentSession } from '@/shared/state/agentsSlice';
import type {
CardPosition,
@@ -153,6 +154,7 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
onTidy,
onSearchPaletteClose,
}) => {
const { gradient } = useThemeAccent();
const dotSize = Math.max(1, 1.5 * canvas.zoom);
const dotSpacing = 24 * canvas.zoom;
@@ -215,6 +217,18 @@ 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 && gradient.length > 1 && (
<Box
sx={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
background: `linear-gradient(115deg, ${gradient.map((hex, i) => `${hex}24 ${(i / (gradient.length - 1)) * 100}%`).join(', ')})`,
}}
/>
)}
{/* Dot grid background */}
<Box
sx={{
+7 -4
View File
@@ -58,7 +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 { setAccent, setGradient } = useThemeAccent();
const modesList = useMemo(() => Object.values(modes), [modes]);
@@ -169,11 +169,14 @@ 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.
// Accent + gradient apply live too, same contract as theme: instant paint, debounced persist.
useEffect(() => {
if (open && loaded) setAccent(form.accent_color ?? null);
if (open && loaded) {
setAccent(form.accent_color ?? null);
setGradient(form.accent_gradient ?? null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form.accent_color]);
}, [form.accent_color, form.accent_gradient]);
useEffect(() => {
if (!open || !loaded) return;
@@ -69,12 +69,12 @@ const GeneralInterface: React.FC<{
<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.
Pick any color; buttons, highlights, and glows follow it. Add a second dot for a canvas gradient. Reset returns the stock accent.
</Typography>
<AccentColorPad
c={c}
accent={form.accent_color ?? null}
onPick={(hex) => setForm({ ...form, accent_color: hex })}
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}
/>
</Box>
@@ -80,6 +80,8 @@ export interface AppSettings {
onboarding_v3?: string | null;
/** User-picked accent hex from the onboarding theme pad; null = stock accent. */
accent_color?: string | null;
/** Multi-stop gradient from the theme pad (2-3 hexes); washes the canvas. */
accent_gradient?: string[] | null;
personalized_greeting?: string | null;
personalized_starters?: PersonalizedStarter[];
}
+25 -3
View File
@@ -7,13 +7,24 @@ interface ThemeContextValue {
mode: ThemeMode;
tokens: ClaudeTokens;
accent: string | null;
gradient: string[] | null;
toggleMode: () => void;
setMode: (mode: ThemeMode) => void;
setAccent: (hex: string | null) => void;
setGradient: (stops: string[] | null) => void;
}
const STORAGE_KEY = 'self-swarm-theme-mode';
const ACCENT_STORAGE_KEY = 'self-swarm-theme-accent';
const GRADIENT_STORAGE_KEY = 'self-swarm-theme-gradient';
function getInitialGradient(): string[] | null {
try {
const stored = JSON.parse(localStorage.getItem(GRADIENT_STORAGE_KEY) ?? 'null');
if (Array.isArray(stored) && stored.length > 1 && stored.every((v) => /^#[0-9a-f]{6}$/i.test(v))) return stored;
} catch {}
return null;
}
function getInitialAccent(): string | null {
try {
@@ -46,14 +57,17 @@ const ThemeContext = createContext<ThemeContextValue>({
mode: 'light',
tokens: lightTokens,
accent: null,
gradient: null,
toggleMode: () => {},
setMode: () => {},
setAccent: () => {},
setGradient: () => {},
});
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 firstMount = useRef(true);
useEffect(() => {
@@ -70,14 +84,22 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
} catch {}
}, [accent]);
useEffect(() => {
try {
if (gradient) localStorage.setItem(GRADIENT_STORAGE_KEY, JSON.stringify(gradient));
else localStorage.removeItem(GRADIENT_STORAGE_KEY);
} catch {}
}, [gradient]);
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.
const toggleMode = useCallback(() => setModeState((m) => (m === 'light' ? 'dark' : 'light')), []);
const setMode = useCallback((m: ThemeMode) => setModeState(m), []);
const setAccent = useCallback((hex: string | null) => setAccentState(hex), []);
const setGradient = useCallback((stops: string[] | null) => setGradientState(stops), []);
const value = useMemo(() => ({ mode, tokens, accent, toggleMode, setMode, setAccent }), [mode, tokens, accent, toggleMode, setMode, setAccent]);
const value = useMemo(() => ({ mode, tokens, accent, gradient, toggleMode, setMode, setAccent, setGradient }), [mode, tokens, accent, gradient, toggleMode, setMode, setAccent, setGradient]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
};
@@ -88,6 +110,6 @@ export const useThemeMode = () => {
return { mode, toggleMode, setMode };
};
export const useThemeAccent = () => {
const { accent, setAccent } = useContext(ThemeContext);
return { accent, setAccent };
const { accent, setAccent, gradient, setGradient } = useContext(ThemeContext);
return { accent, setAccent, gradient, setGradient };
};