From 2b28193e15e345a882f2a29d5d8a9d799cbfdcc3 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 15 Jul 2026 01:23:39 -0700 Subject: [PATCH] [eric] onboarding: arc stagecraft (window birth, zigzag grain rooms, picker device, mini-canvas, swarm card) --- .../app/components/OnboardingV3/BeatApps.tsx | 70 --------- .../app/components/OnboardingV3/BeatShell.tsx | 85 ---------- .../app/components/OnboardingV3/BeatTheme.tsx | 131 ---------------- .../OnboardingV3/OnboardingV3Root.tsx | 148 +++++++++++------- .../OnboardingV3/beats/BeatApps.tsx | 86 ++++++++++ .../OnboardingV3/beats/BeatCard.tsx | 102 ++++++++++++ .../OnboardingV3/{ => beats}/BeatConnect.tsx | 2 +- .../OnboardingV3/beats/BeatShell.tsx | 101 ++++++++++++ .../OnboardingV3/beats/BeatTheme.tsx | 61 ++++++++ frontend/src/shared/state/settingsSlice.ts | 1 + 10 files changed, 443 insertions(+), 344 deletions(-) delete mode 100644 frontend/src/app/components/OnboardingV3/BeatApps.tsx delete mode 100644 frontend/src/app/components/OnboardingV3/BeatShell.tsx delete mode 100644 frontend/src/app/components/OnboardingV3/BeatTheme.tsx create mode 100644 frontend/src/app/components/OnboardingV3/beats/BeatApps.tsx create mode 100644 frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx rename frontend/src/app/components/OnboardingV3/{ => beats}/BeatConnect.tsx (99%) create mode 100644 frontend/src/app/components/OnboardingV3/beats/BeatShell.tsx create mode 100644 frontend/src/app/components/OnboardingV3/beats/BeatTheme.tsx diff --git a/frontend/src/app/components/OnboardingV3/BeatApps.tsx b/frontend/src/app/components/OnboardingV3/BeatApps.tsx deleted file mode 100644 index c32511e0..00000000 --- a/frontend/src/app/components/OnboardingV3/BeatApps.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react'; -import { motion } from 'framer-motion'; -import { Check } from 'lucide-react'; -import { INTEGRATIONS } from '@/app/pages/Tools/integrations'; -import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; -import BeatShell from './BeatShell'; - -// Order matters: the first row should read as "your work lives here" for the widest audience. -const PICKER_IDS = ['google-workspace', 'notion', 'slack', 'github', 'discord', 'microsoft-365', 'reddit', 'youtube', 'x', 'airtable', 'hubspot', 'tiktok']; - -// Picks do double duty: they brief the prep call on what this person's work looks like, and they seed which integrations we suggest connecting later. Nothing installs here; the MCP gate stays untouched. -const BeatApps: React.FC<{ - c: ClaudeTokens; - picks: string[]; - setPicks: (ids: string[]) => void; - onNext: () => void; - onBack: () => void; -}> = ({ c, picks, setPicks, onNext, onBack }) => { - const entries = PICKER_IDS - .map((id) => INTEGRATIONS.find((i) => i.id === id)) - .filter((i): i is NonNullable => !!i); - - const toggle = (id: string) => { - setPicks(picks.includes(id) ? picks.filter((p) => p !== id) : [...picks, id]); - }; - - return ( - 0 ? 'Continue' : 'Skip for now'} - onNext={onNext} - onBack={onBack} - > -
- {entries.map((entry, i) => { - const picked = picks.includes(entry.id); - return ( - toggle(entry.id)} - initial={{ opacity: 0, scale: 0.9 }} - animate={{ opacity: 1, scale: 1 }} - transition={{ type: 'spring', stiffness: 360, damping: 24, delay: 0.06 + i * 0.04 }} - style={{ - position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, - padding: '18px 10px 14px', borderRadius: c.radius.md, - border: `1.5px solid ${picked ? c.accent.primary : c.border.medium}`, - background: c.bg.surface, cursor: 'pointer', fontFamily: 'inherit', - boxShadow: picked ? `0 0 0 3px ${c.accent.primary}22` : c.shadow.sm, - transition: 'border-color 140ms ease, box-shadow 140ms ease', - }} - > - {picked && ( - - - - )} - {entry.icon} - {entry.name} - - ); - })} -
-
- ); -}; - -export default BeatApps; diff --git a/frontend/src/app/components/OnboardingV3/BeatShell.tsx b/frontend/src/app/components/OnboardingV3/BeatShell.tsx deleted file mode 100644 index f36bbf71..00000000 --- a/frontend/src/app/components/OnboardingV3/BeatShell.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { motion } from 'framer-motion'; -import { ArrowLeft } from 'lucide-react'; -import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; - -// Split-stage layout for the interactive beats: dark copy panel left, live artifact right. One loud button, a whisper Back, no progress bar; each beat is a room, not step 3 of 9. -const BeatShell: React.FC<{ - c: ClaudeTokens; - title: string; - body: string; - nextLabel: string; - nextDisabled?: boolean; - onNext: () => void; - onBack?: () => void; - children: React.ReactNode; -}> = ({ c, title, body, nextLabel, nextDisabled, onNext, onBack, children }) => { - // Zen steal: controls stay inert until the entrance animation lands so a double-click from the prior beat can't fire them. - const [armed, setArmed] = useState(false); - useEffect(() => { - const t = window.setTimeout(() => setArmed(true), 450); - return () => window.clearTimeout(t); - }, []); - - return ( -
- - {onBack && ( - - )} -

- {title} -

-

- {body} -

-
- -
-
- - {children} - -
- ); -}; - -export default BeatShell; diff --git a/frontend/src/app/components/OnboardingV3/BeatTheme.tsx b/frontend/src/app/components/OnboardingV3/BeatTheme.tsx deleted file mode 100644 index 2a6d14ef..00000000 --- a/frontend/src/app/components/OnboardingV3/BeatTheme.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import React, { useCallback, useRef } from 'react'; -import { motion } from 'framer-motion'; -import { Moon, Sun } from 'lucide-react'; -import { useThemeAccent, useThemeMode } from '@/shared/styles/ThemeContext'; -import { hexToHsl, hslToHex } from '@/shared/styles/claudeTokens'; -import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; -import BeatShell from './BeatShell'; - -const PRESETS = ['#ae5630', '#b0453c', '#8e5cb8', '#3a6fc4', '#2e8f6f', '#b08b2e', '#c2588f', '#5c6470']; - -// The IKEA-effect beat: dragging on the pad drives the REAL app theme live through ThemeContext, so the product becomes theirs before they've entered it. Persistence happens at finish(), not here. -const BeatTheme: React.FC<{ - c: ClaudeTokens; - onNext: () => void; - onBack: () => void; -}> = ({ c, onNext, onBack }) => { - const { accent, setAccent } = useThemeAccent(); - const { mode, setMode } = useThemeMode(); - const padRef = useRef(null); - const draggingRef = useRef(false); - const lastApplyRef = useRef(0); - - const applyFromEvent = useCallback((clientX: number, clientY: number) => { - const pad = padRef.current; - if (!pad) return; - // ~30ms throttle: every apply re-derives tokens and re-renders the tree, and pointermove fires far faster than paint needs. - const now = performance.now(); - if (now - lastApplyRef.current < 30) return; - lastApplyRef.current = now; - 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)); - setAccent(hslToHex({ h: fx, s: 0.72, l: 0.62 - fy * 0.34 })); - }, [setAccent]); - - const onPointerDown = useCallback((e: React.PointerEvent) => { - draggingRef.current = true; - (e.target as HTMLElement).setPointerCapture?.(e.pointerId); - lastApplyRef.current = 0; - applyFromEvent(e.clientX, e.clientY); - }, [applyFromEvent]); - - const onPointerMove = useCallback((e: React.PointerEvent) => { - if (draggingRef.current) applyFromEvent(e.clientX, e.clientY); - }, [applyFromEvent]); - - const onPointerUp = useCallback(() => { draggingRef.current = false; }, []); - - const dot = accent ? hexToHsl(accent) : null; - - return ( - -
- - {dot && ( - - )} - -
- {PRESETS.map((hex) => ( - -
-
- {(['light', 'dark'] as const).map((m) => ( - - ))} -
-
-
- ); -}; - -export default BeatTheme; diff --git a/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx b/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx index e3d959a4..7eadaf49 100644 --- a/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx +++ b/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx @@ -7,13 +7,16 @@ import { setFlowActive } from '@/shared/state/onboardingV3Slice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; import { useOnboardingV3Pipeline } from './useOnboardingV3Pipeline'; -import BeatConnect from './BeatConnect'; -import BeatApps from './BeatApps'; -import BeatTheme from './BeatTheme'; +import { GRAIN_URL } from './beats/BeatShell'; +import BeatConnect from './beats/BeatConnect'; +import BeatApps from './beats/BeatApps'; +import BeatTheme from './beats/BeatTheme'; +import BeatCard from './beats/BeatCard'; -type Beat = 'welcome' | 'newos' | 'connect' | 'apps' | 'theme'; +type Beat = 'welcome' | 'newos' | 'connect' | 'apps' | 'theme' | 'card'; const V2_STORAGE_KEY = 'openswarm.onboarding.v2'; +const WINDOWED_BEATS: Beat[] = ['welcome', 'newos']; // Decides whether the v3 full-screen flow owns this launch. Only genuinely fresh installs see it: anyone with the v2 tour key or existing sessions is auto-marked skipped so an update never re-onboards a veteran. function useOnboardingV3Gate(): boolean { @@ -46,24 +49,25 @@ function useOnboardingV3Gate(): boolean { return flowActive && settingsLoaded && !v3State; } -// Full-bleed intro rooms: a soft accent blob blooms behind giant type, one arrow, nothing else. +// Full-bleed intro room: a soft accent blob drifts once behind giant type, one arrow, nothing else. const IntroBeat: React.FC<{ c: ClaudeTokens; line: string; sub?: string; onNext: () => void }> = ({ c, line, sub, onNext }) => (
+
{line} @@ -72,7 +76,7 @@ const IntroBeat: React.FC<{ c: ClaudeTokens; line: string; sub?: string; onNext: initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.6, delay: 0.8 }} - style={{ position: 'relative', margin: '14px 0 0', fontSize: '1.05rem', color: c.text.inverse + '99' }} + style={{ position: 'relative', margin: '14px 0 0', fontSize: '1.02rem', color: c.text.inverse + '99' }} > {sub} @@ -84,7 +88,7 @@ const IntroBeat: React.FC<{ c: ClaudeTokens; line: string; sub?: string; onNext: transition={{ duration: 0.5, delay: 1.05 }} whileHover={{ scale: 1.06 }} style={{ - position: 'relative', marginTop: 44, width: 54, height: 40, borderRadius: 12, border: 'none', + position: 'relative', marginTop: 40, width: 54, height: 40, borderRadius: 12, border: 'none', background: 'rgba(255,255,255,0.92)', color: '#1a1a18', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', }} @@ -94,10 +98,11 @@ const IntroBeat: React.FC<{ c: ClaudeTokens; line: string; sub?: string; onNext:
); -// Onboarding v3: connect-first, Arc/Zen-style staged rooms over the live app. Each beat commits its side effect on exit; the overlay dissolving IS the reveal (the seeder has already dressed the canvas behind it). +// Onboarding v3, staged like Arc: a floating window births over the dimmed canvas, expands to own the screen on the first commitment, then each beat is a room. Side effects commit on beat exit; the overlay dissolving IS the reveal. const OnboardingV3Root: React.FC = () => { const active = useOnboardingV3Gate(); const c = useClaudeTokens(); + const dispatch = useAppDispatch(); const pipeline = useOnboardingV3Pipeline(); const [beat, setBeat] = useState('welcome'); const [scanConsent, setScanConsent] = useState(true); @@ -121,13 +126,20 @@ const OnboardingV3Root: React.FC = () => { setBeat('theme'); }, [kickPrep, picks]); - const leaveTheme = useCallback(async () => { + const leaveCard = useCallback(async (name: string | null) => { + if (name) dispatch(updateSettingsPatch({ user_name: name })); setFinishing(true); await finish('done'); - }, [finish]); + }, [dispatch, finish]); const skipAll = useCallback(() => { void finish('skipped'); }, [finish]); + const windowed = WINDOWED_BEATS.includes(beat); + const vw = window.innerWidth; + const vh = window.innerHeight; + const stageW = windowed ? Math.min(900, Math.round(vw * 0.72)) : vw; + const stageH = windowed ? Math.min(560, Math.round(vh * 0.74)) : vh; + // AnimatePresence stays mounted so the overlay's exit fade (the curtain lift) actually plays when active flips false. return ( @@ -136,56 +148,78 @@ const OnboardingV3Root: React.FC = () => { key="onboarding-v3" exit={{ opacity: 0 }} transition={{ duration: 0.6 }} - style={{ position: 'fixed', inset: 0, zIndex: 100000, background: c.bg.page }} + style={{ + position: 'fixed', inset: 0, zIndex: 100000, + display: 'flex', alignItems: 'center', justifyContent: 'center', + background: 'rgba(10, 10, 9, 0.42)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', + }} > - - - {beat === 'welcome' && setBeat('newos')} />} - {beat === 'newos' && setBeat('connect')} />} - {beat === 'connect' && ( - setBeat('newos')} - /> - )} - {beat === 'apps' && setBeat('connect')} />} - {beat === 'theme' && { void leaveTheme(); }} onBack={() => setBeat('apps')} />} - - - {finishing && ( -
+ + - Setting up your canvas... + {beat === 'welcome' && setBeat('newos')} />} + {beat === 'newos' && setBeat('connect')} />} + {beat === 'connect' && ( + setBeat('newos')} + /> + )} + {beat === 'apps' && setBeat('connect')} />} + {beat === 'theme' && setBeat('card')} onBack={() => setBeat('apps')} />} + {beat === 'card' && { void leaveCard(name); }} onBack={() => setBeat('theme')} />} -
- )} - {!finishing && ( - - )} + {[0, 1, 2].map((i) => ( + + ))} +
+ {finishing && ( +
+ + Setting up your canvas... + +
+ )} + {!finishing && ( + + )} + )} diff --git a/frontend/src/app/components/OnboardingV3/beats/BeatApps.tsx b/frontend/src/app/components/OnboardingV3/beats/BeatApps.tsx new file mode 100644 index 00000000..7024836e --- /dev/null +++ b/frontend/src/app/components/OnboardingV3/beats/BeatApps.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { Check } from 'lucide-react'; +import { INTEGRATIONS } from '@/app/pages/Tools/integrations'; +import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; +import BeatShell from './BeatShell'; + +// Order matters: the first row should read as "your work lives here" for the widest audience. +const PICKER_IDS = ['google-workspace', 'notion', 'slack', 'github', 'discord', 'microsoft-365', 'reddit', 'youtube', 'x', 'airtable', 'hubspot', 'tiktok']; + +// Picks do double duty: they brief the prep call on what this person's work looks like, and they seed which integrations we suggest connecting later. Nothing installs here; the MCP gate stays untouched. Staged inside a miniature OpenSwarm window (Zen's diegetic picker) so the canvas metaphor lands before the canvas exists. +const BeatApps: React.FC<{ + c: ClaudeTokens; + picks: string[]; + setPicks: (ids: string[]) => void; + onNext: () => void; + onBack: () => void; +}> = ({ c, picks, setPicks, onNext, onBack }) => { + const entries = PICKER_IDS + .map((id) => INTEGRATIONS.find((i) => i.id === id)) + .filter((i): i is NonNullable => !!i); + + const toggle = (id: string) => { + setPicks(picks.includes(id) ? picks.filter((p) => p !== id) : [...picks, id]); + }; + + return ( + 0 ? 'Continue' : 'Skip for now'} + onNext={onNext} + onBack={onBack} + > + +
+ {[0, 1, 2].map((i) => ( + + ))} + OpenSwarm +
+
+ {entries.map((entry, i) => { + const picked = picks.includes(entry.id); + return ( + toggle(entry.id)} + initial={{ opacity: 0, scale: 0.9 }} + animate={{ opacity: 1, scale: 1 }} + transition={{ type: 'spring', stiffness: 360, damping: 24, delay: 0.28 + i * 0.035 }} + style={{ + position: 'relative', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 9, + padding: '16px 8px 12px', borderRadius: c.radius.md, + border: `1.5px solid ${picked ? c.accent.primary : c.border.medium}`, + background: c.bg.surface, cursor: 'pointer', fontFamily: 'inherit', + boxShadow: picked ? `0 0 0 3px ${c.accent.primary}22` : c.shadow.sm, + transition: 'border-color 140ms ease, box-shadow 140ms ease', + }} + > + {picked && ( + + + + )} + {entry.icon} + {entry.name} + + ); + })} +
+
+
+ ); +}; + +export default BeatApps; diff --git a/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx b/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx new file mode 100644 index 00000000..c8b039b9 --- /dev/null +++ b/frontend/src/app/components/OnboardingV3/beats/BeatCard.tsx @@ -0,0 +1,102 @@ +import React, { useMemo, useState } from 'react'; +import { motion } from 'framer-motion'; +import { Dices } from 'lucide-react'; +import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; +import type { ProviderIdentity } from '../onboardingV3Api'; +import BeatShell from './BeatShell'; + +const EPITHETS = [ + 'METHODICAL PERFECTIONIST', 'SAVORY ARCHIVIST', 'MIDNIGHT ORCHESTRATOR', 'GENTLE MAXIMALIST', + 'PRACTICAL DREAMER', 'QUIET POWER USER', 'CURIOUS CARTOGRAPHER', 'SWARM WHISPERER', + 'DELIBERATE TINKERER', 'WARM SYSTEMATIZER', 'PATIENT ACCELERATIONIST', 'ANALOG FUTURIST', +]; + +function nameFromIdentity(identity: ProviderIdentity[]): string { + const email = identity.find((p) => p.email)?.email ?? ''; + const local = email.split('@')[0] ?? ''; + const letters = local.replace(/[^a-zA-Z]/g, ''); + if (!letters) return ''; + return letters.charAt(0).toUpperCase() + letters.slice(1, 12); +} + +// The Arc Card moment: onboarding ends with an identity artifact, not a settings screen. Name is editable in place, the epithet re-rolls, and the leaf wears the accent they just picked. +const BeatCard: React.FC<{ + c: ClaudeTokens; + identity: ProviderIdentity[]; + onFinish: (name: string | null) => void; + onBack: () => void; +}> = ({ c, identity, onFinish, onBack }) => { + const [name, setName] = useState(() => nameFromIdentity(identity)); + const seed = useMemo(() => Math.floor(Math.random() * EPITHETS.length), []); + const [roll, setRoll] = useState(0); + const epithet = EPITHETS[(seed + roll) % EPITHETS.length]; + const today = useMemo(() => new Date().toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }), []); + + return ( + onFinish(name.trim() || null)} + onBack={onBack} + > +
+ +
+ setName(e.target.value.slice(0, 18))} + placeholder="Your name" + style={{ + marginTop: 26, border: 'none', outline: 'none', background: 'transparent', + fontSize: '1.7rem', fontWeight: 800, color: c.accent.pressed, fontFamily: 'inherit', + width: '100%', padding: 0, + }} + /> +
+ {epithet} +
+
+ + OPENSWARM ยท {today.toUpperCase()} + + + OPEN
SWARM +
+
+ + setRoll((r) => r + 1)} + style={{ + display: 'flex', alignItems: 'center', gap: 7, border: 'none', background: 'transparent', + color: c.text.tertiary, fontSize: '0.82rem', cursor: 'pointer', fontFamily: 'inherit', padding: 4, + }} + > + Re-roll the title + +
+ + ); +}; + +export default BeatCard; diff --git a/frontend/src/app/components/OnboardingV3/BeatConnect.tsx b/frontend/src/app/components/OnboardingV3/beats/BeatConnect.tsx similarity index 99% rename from frontend/src/app/components/OnboardingV3/BeatConnect.tsx rename to frontend/src/app/components/OnboardingV3/beats/BeatConnect.tsx index bbb28389..d64d2489 100644 --- a/frontend/src/app/components/OnboardingV3/BeatConnect.tsx +++ b/frontend/src/app/components/OnboardingV3/beats/BeatConnect.tsx @@ -10,7 +10,7 @@ import { hasFreeTrialActive, hasModelConnected } from '@/app/components/Onboardi import { SUBSCRIPTION_PROVIDERS } from '@/app/pages/Settings/sections/subscription/subscriptionProviders'; import { runConnectFlow } from '@/app/pages/Settings/sections/subscription/subscriptionConnect'; import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; -import type { ProviderIdentity } from './onboardingV3Api'; +import type { ProviderIdentity } from '../onboardingV3Api'; import BeatShell from './BeatShell'; // The single ask of the whole flow. Reuses the proven Settings connect flow verbatim; the scan disclosure lives here as one honest line with an opt-out, and the scan runs during the OAuth wait. diff --git a/frontend/src/app/components/OnboardingV3/beats/BeatShell.tsx b/frontend/src/app/components/OnboardingV3/beats/BeatShell.tsx new file mode 100644 index 00000000..c1277924 --- /dev/null +++ b/frontend/src/app/components/OnboardingV3/beats/BeatShell.tsx @@ -0,0 +1,101 @@ +import React, { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; +import { ArrowLeft } from 'lucide-react'; +import type { ClaudeTokens } from '@/shared/styles/claudeTokens'; + +// Film grain as a data URI so the CSP never phones out; opacity keeps it a texture, not noise. +export const GRAIN_URL = "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\")"; + +// Arc's torn seam: the dark copy panel ends in a zigzag bite instead of a straight border. +const ZIGZAG_CLIP = `polygon(0 0, 100% 0, ${Array.from({ length: 50 }, (unused, i) => `calc(100% - 6px) ${i * 2 + 1}%, 100% ${i * 2 + 2}%`).join(', ')}, 0 100%)`; + +const SPRING = { type: 'spring' as const, stiffness: 260, damping: 26 }; + +// One idea per room: dark copy panel left (torn edge, grain, staggered spring+blur copy), live artifact on a grained stage right. Copy never moves after it lands; only the artifact is alive. +const BeatShell: React.FC<{ + c: ClaudeTokens; + title: string; + body: string; + nextLabel: string; + nextDisabled?: boolean; + onNext: () => void; + onBack?: () => void; + children: React.ReactNode; +}> = ({ c, title, body, nextLabel, nextDisabled, onNext, onBack, children }) => { + // Zen steal: controls stay inert until the entrance lands so a double-click from the prior beat can't fire them. + const [armed, setArmed] = useState(false); + useEffect(() => { + const t = window.setTimeout(() => setArmed(true), 600); + return () => window.clearTimeout(t); + }, []); + + const enter = (delay: number) => ({ + initial: { opacity: 0, y: 14, filter: 'blur(6px)' }, + animate: { opacity: 1, y: 0, filter: 'blur(0px)' }, + transition: { ...SPRING, delay }, + }); + + return ( +
+
+
+ {onBack && ( + armed && onBack()} + style={{ + display: 'inline-flex', alignItems: 'center', gap: 6, alignSelf: 'flex-start', + marginBottom: 18, padding: 0, border: 'none', background: 'transparent', + color: c.text.inverse + '77', fontSize: '0.85rem', cursor: 'pointer', fontFamily: 'inherit', + }} + > + Back + + )} + + {title} + + + {body} + + + + +
+ +
+
+ {children} +
+ +
+ ); +}; + +export default BeatShell; diff --git a/frontend/src/app/components/OnboardingV3/beats/BeatTheme.tsx b/frontend/src/app/components/OnboardingV3/beats/BeatTheme.tsx new file mode 100644 index 00000000..14886f8c --- /dev/null +++ b/frontend/src/app/components/OnboardingV3/beats/BeatTheme.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { Moon, Sun } from 'lucide-react'; +import { useThemeAccent, useThemeMode } 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(). +const BeatTheme: React.FC<{ + c: ClaudeTokens; + onNext: () => void; + onBack: () => void; +}> = ({ c, onNext, onBack }) => { + const { accent, setAccent } = useThemeAccent(); + const { mode, setMode } = useThemeMode(); + + return ( + + +
+ {(['light', 'dark'] as const).map((m) => ( + + ))} +
+ +
+
+ ); +}; + +export default BeatTheme; diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index e4441a41..37242795 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -71,6 +71,7 @@ export interface AppSettings { openswarm_usage_cached?: SubscriptionUsage | null; /** Identity populated by /api/auth/signin-activate; Stripe checkout also fills these. */ user_id?: string | null; + user_name?: string | null; user_email?: string | null; signin_method?: 'google' | 'email' | 'stripe' | null; /** Anonymous device id (first-run generated); stitches anon to authed PostHog Persons. */