From 14d83bf499816c260fbd949a51a2ef3d17d5e45b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 15 Jul 2026 11:20:05 -0700 Subject: [PATCH] [eric] theme: gradient engine (1-3 stops on the pad, canvas wash, gradient-tinted card emblem) --- backend/apps/settings/models.py | 2 + frontend/src/app/Main.tsx | 10 +- .../OnboardingV3/beats/BeatCard.tsx | 4 +- .../OnboardingV3/beats/BeatTheme.tsx | 11 +- .../OnboardingV3/useOnboardingV3Pipeline.ts | 7 +- .../app/components/theme/AccentColorPad.tsx | 141 +++++++++++++----- .../Dashboard/canvas/DashboardCanvas.tsx | 14 ++ frontend/src/app/pages/Settings/Settings.tsx | 11 +- .../sections/general/GeneralInterface.tsx | 6 +- frontend/src/shared/state/settingsSlice.ts | 2 + frontend/src/shared/styles/ThemeContext.tsx | 28 +++- 11 files changed, 184 insertions(+), 52 deletions(-) diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 70ad7e70..9507599c 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -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. diff --git a/frontend/src/app/Main.tsx b/frontend/src/app/Main.tsx index e2536068..127f92fd 100644 --- a/frontend/src/app/Main.tsx +++ b/frontend/src/app/Main.tsx @@ -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); diff --git a/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx b/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx index b3653cef..219547d5 100644 --- a/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx +++ b/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx @@ -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<{ }} >
- +
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 ( ))} - + ); diff --git a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts index 41e762b1..80f107c3 100644 --- a/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts +++ b/frontend/src/app/components/OnboardingV3/useOnboardingV3Pipeline.ts @@ -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([]); const scanRef = useRef | 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 }; } diff --git a/frontend/src/app/components/theme/AccentColorPad.tsx b/frontend/src/app/components/theme/AccentColorPad.tsx index 43750d46..3a2e2919 100644 --- a/frontend/src/app/components/theme/AccentColorPad.tsx +++ b/frontend/src/app/components/theme/AccentColorPad.tsx @@ -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(null); - const draggingRef = useRef(false); + const grabbedRef = useRef(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) => { - 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) => { - 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 (
@@ -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 && ( - - )} + {stops.map((hex, i) => { + const xy = stopToXY(hex); + if (!xy) return null; + return ( + + ); + })} +
e.stopPropagation()} + style={{ position: 'absolute', bottom: 8, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 6 }} + > + + +
{ACCENT_PRESETS.map((hex) => (