mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-10 03:37:44 +02:00
[eric] theme: gradient engine (1-3 stops on the pad, canvas wash, gradient-tinted card emblem)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user