[eric] onboarding: arc stagecraft (window birth, zigzag grain rooms, picker device, mini-canvas, swarm card)

This commit is contained in:
ciregenz
2026-07-15 01:23:39 -07:00
parent 99dbc414d6
commit 2b28193e15
10 changed files with 443 additions and 344 deletions
@@ -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<typeof i> => !!i);
const toggle = (id: string) => {
setPicks(picks.includes(id) ? picks.filter((p) => p !== id) : [...picks, id]);
};
return (
<BeatShell
c={c}
title="Choose the apps you live in."
body="I'll shape your starting canvas around them, and I can connect to them later so your agents work where you already do."
nextLabel={picks.length > 0 ? 'Continue' : 'Skip for now'}
onNext={onNext}
onBack={onBack}
>
<div style={{ width: 'min(520px, 100%)', display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(112px, 1fr))', gap: 12 }}>
{entries.map((entry, i) => {
const picked = picks.includes(entry.id);
return (
<motion.button
key={entry.id}
onClick={() => 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 && (
<span style={{ position: 'absolute', top: 7, right: 7, width: 18, height: 18, borderRadius: 999, background: c.accent.primary, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Check size={12} color="#fff" />
</span>
)}
<span style={{ width: 34, height: 34, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{entry.icon}</span>
<span style={{ fontSize: '0.8rem', fontWeight: 500, color: c.text.secondary, textAlign: 'center' }}>{entry.name}</span>
</motion.button>
);
})}
</div>
</BeatShell>
);
};
export default BeatApps;
@@ -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 (
<div style={{ display: 'flex', width: '100%', height: '100%' }}>
<motion.div
initial={{ opacity: 0, x: -24 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
style={{
width: 'min(400px, 36%)', flexShrink: 0, display: 'flex', flexDirection: 'column',
justifyContent: 'center', padding: '48px 44px', boxSizing: 'border-box',
background: c.bg.inverse, color: c.text.inverse,
}}
>
{onBack && (
<button
onClick={() => 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',
}}
>
<ArrowLeft size={14} /> Back
</button>
)}
<h1 style={{ margin: 0, fontSize: 'clamp(1.9rem, 3.2vw, 2.6rem)', lineHeight: 1.12, fontWeight: 700, letterSpacing: '-0.01em' }}>
{title}
</h1>
<p style={{ margin: '16px 0 0', fontSize: '0.98rem', lineHeight: 1.55, color: c.text.inverse + '99', maxWidth: '34ch' }}>
{body}
</p>
<div style={{ marginTop: 40 }}>
<button
onClick={() => armed && !nextDisabled && onNext()}
disabled={!!nextDisabled}
style={{
width: '100%', padding: '13px 18px', borderRadius: c.radius.md,
border: 'none', background: c.accent.primary, color: '#fff',
fontSize: '0.98rem', fontWeight: 600, cursor: nextDisabled ? 'default' : 'pointer',
opacity: nextDisabled ? 0.45 : 1, fontFamily: 'inherit',
transition: 'background 150ms ease, opacity 150ms ease',
}}
>
{nextLabel}
</button>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.55, delay: 0.12 }}
style={{
flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: c.bg.page, padding: 36, boxSizing: 'border-box', overflow: 'auto',
}}
>
{children}
</motion.div>
</div>
);
};
export default BeatShell;
@@ -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<HTMLDivElement | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (draggingRef.current) applyFromEvent(e.clientX, e.clientY);
}, [applyFromEvent]);
const onPointerUp = useCallback(() => { draggingRef.current = false; }, []);
const dot = accent ? hexToHsl(accent) : 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."
nextLabel="Continue"
onNext={onNext}
onBack={onBack}
>
<div style={{ width: 'min(420px, 100%)', display: 'flex', flexDirection: 'column', gap: 16 }}>
<motion.div
ref={padRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }}
style={{
position: 'relative', height: 240, borderRadius: c.radius.lg, cursor: 'crosshair',
border: `1px solid ${c.border.medium}`, touchAction: 'none',
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: `${((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',
}} />
)}
</motion.div>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
{PRESETS.map((hex) => (
<button
key={hex}
onClick={() => setAccent(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,
}}
/>
))}
<button
onClick={() => setAccent(null)}
style={{
marginLeft: 'auto', border: 'none', background: 'transparent', padding: 0,
color: c.text.ghost, fontSize: '0.8rem', cursor: 'pointer', fontFamily: 'inherit', textDecoration: 'underline',
}}
>
Reset
</button>
</div>
<div style={{ display: 'flex', gap: 8 }}>
{(['light', 'dark'] as const).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
padding: '11px 0', borderRadius: c.radius.md, fontFamily: 'inherit', fontSize: '0.88rem', fontWeight: 500,
border: `1.5px solid ${mode === m ? c.accent.primary : c.border.medium}`,
background: c.bg.surface, color: c.text.secondary, cursor: 'pointer',
transition: 'border-color 140ms ease',
}}
>
{m === 'light' ? <Sun size={15} /> : <Moon size={15} />}
{m === 'light' ? 'Light' : 'Dark'}
</button>
))}
</div>
</div>
</BeatShell>
);
};
export default BeatTheme;
@@ -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 }) => (
<div style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: c.bg.inverse, overflow: 'hidden' }}>
<motion.div
initial={{ scale: 0.35, opacity: 0 }}
animate={{ scale: 1, opacity: 0.55 }}
transition={{ duration: 1.4, ease: [0.22, 1, 0.36, 1] }}
initial={{ scale: 0.35, opacity: 0, x: 0, y: 0 }}
animate={{ scale: [0.35, 1, 1.06, 1], opacity: [0, 0.55, 0.5, 0.55], x: [0, 0, 22, 0], y: [0, 0, -14, 0] }}
transition={{ duration: 9, times: [0, 0.16, 0.6, 1], ease: 'easeInOut' }}
style={{
position: 'absolute', width: 560, height: 560, borderRadius: 999,
background: `radial-gradient(circle at 42% 38%, ${c.accent.hover}, ${c.accent.primary} 55%, transparent 75%)`,
filter: 'blur(70px)', pointerEvents: 'none',
}}
/>
<div style={{ position: 'absolute', inset: 0, backgroundImage: GRAIN_URL, opacity: 0.14, pointerEvents: 'none', mixBlendMode: 'overlay' }} />
<motion.h1
initial={{ opacity: 0, y: 16, filter: 'blur(8px)' }}
animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
transition={{ duration: 0.7, delay: 0.35, ease: [0.22, 1, 0.36, 1] }}
style={{ position: 'relative', margin: 0, fontSize: 'clamp(2.6rem, 6vw, 4.4rem)', fontWeight: 700, color: c.text.inverse, letterSpacing: '-0.02em', textAlign: 'center', padding: '0 24px' }}
style={{ position: 'relative', margin: 0, fontSize: 'clamp(2.4rem, 5vw, 3.8rem)', fontWeight: 700, color: c.text.inverse, letterSpacing: '-0.02em', textAlign: 'center', padding: '0 24px' }}
>
{line}
</motion.h1>
@@ -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}
</motion.p>
@@ -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:
</div>
);
// 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<Beat>('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 (
<AnimatePresence>
@@ -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)',
}}
>
<AnimatePresence mode="wait">
<motion.div
key={beat}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.32 }}
style={{ width: '100%', height: '100%' }}
>
{beat === 'welcome' && <IntroBeat c={c} line="Welcome." onNext={() => setBeat('newos')} />}
{beat === 'newos' && <IntroBeat c={c} line="This is your new OS." sub="A canvas where AI agents do real work for you." onNext={() => setBeat('connect')} />}
{beat === 'connect' && (
<BeatConnect
c={c}
identity={pipeline.identity}
scanConsent={scanConsent}
setScanConsent={setScanConsent}
onConnected={onConnected}
onNext={leaveConnect}
onBack={() => setBeat('newos')}
/>
)}
{beat === 'apps' && <BeatApps c={c} picks={picks} setPicks={setPicks} onNext={leaveApps} onBack={() => setBeat('connect')} />}
{beat === 'theme' && <BeatTheme c={c} onNext={() => { void leaveTheme(); }} onBack={() => setBeat('apps')} />}
</motion.div>
</AnimatePresence>
{finishing && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: c.bg.page }}>
<motion.div
initial={{ opacity: 0, scale: 0.72, filter: 'blur(18px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)', width: stageW, height: stageH, borderRadius: windowed ? 14 : 0 }}
transition={{ type: 'spring', stiffness: 170, damping: 24, mass: 0.9 }}
style={{
position: 'relative', overflow: 'hidden',
boxShadow: windowed ? '0 30px 90px rgba(0,0,0,0.5)' : 'none',
background: c.bg.page,
}}
>
<AnimatePresence mode="wait">
<motion.div
key={beat}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{ fontSize: '1.05rem', color: c.text.tertiary }}
exit={{ opacity: 0 }}
transition={{ duration: 0.32 }}
style={{ width: '100%', height: '100%' }}
>
Setting up your canvas...
{beat === 'welcome' && <IntroBeat c={c} line="Welcome." onNext={() => setBeat('newos')} />}
{beat === 'newos' && <IntroBeat c={c} line="This is your new OS." sub="A canvas where AI agents do real work for you." onNext={() => setBeat('connect')} />}
{beat === 'connect' && (
<BeatConnect
c={c}
identity={pipeline.identity}
scanConsent={scanConsent}
setScanConsent={setScanConsent}
onConnected={onConnected}
onNext={leaveConnect}
onBack={() => setBeat('newos')}
/>
)}
{beat === 'apps' && <BeatApps c={c} picks={picks} setPicks={setPicks} onNext={leaveApps} onBack={() => setBeat('connect')} />}
{beat === 'theme' && <BeatTheme c={c} onNext={() => setBeat('card')} onBack={() => setBeat('apps')} />}
{beat === 'card' && <BeatCard c={c} identity={pipeline.identity} onFinish={(name) => { void leaveCard(name); }} onBack={() => setBeat('theme')} />}
</motion.div>
</div>
)}
{!finishing && (
<button
onClick={skipAll}
style={{
position: 'absolute', bottom: 18, left: 20, border: 'none', background: 'transparent',
color: c.text.tertiary, fontSize: '0.8rem', cursor: 'pointer', fontFamily: 'inherit', padding: 4,
}}
</AnimatePresence>
{/* Traffic lights sell the floating window during the intro; they fade as the window takes the screen. */}
<motion.div
animate={{ opacity: windowed ? 1 : 0 }}
transition={{ duration: 0.3 }}
style={{ position: 'absolute', top: 13, left: 14, display: 'flex', gap: 7, pointerEvents: 'none' }}
>
Skip setup
</button>
)}
{[0, 1, 2].map((i) => (
<span key={i} style={{ width: 11, height: 11, borderRadius: 999, background: 'rgba(255,255,255,0.22)' }} />
))}
</motion.div>
{finishing && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: c.bg.page }}>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} style={{ fontSize: '1.05rem', color: c.text.tertiary }}>
Setting up your canvas...
</motion.div>
</div>
)}
{!finishing && (
<button
onClick={skipAll}
style={{
position: 'absolute', bottom: 18, left: 20, border: 'none', background: 'transparent',
color: c.text.tertiary, fontSize: '0.8rem', cursor: 'pointer', fontFamily: 'inherit', padding: 4,
}}
>
Skip setup
</button>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
@@ -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<typeof i> => !!i);
const toggle = (id: string) => {
setPicks(picks.includes(id) ? picks.filter((p) => p !== id) : [...picks, id]);
};
return (
<BeatShell
c={c}
title="Choose the apps you live in."
body="I'll shape your starting canvas around them, and I can connect to them later so your agents work where you already do."
nextLabel={picks.length > 0 ? 'Continue' : 'Skip for now'}
onNext={onNext}
onBack={onBack}
>
<motion.div
initial={{ opacity: 0, y: 16, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 220, damping: 24, delay: 0.2 }}
style={{
width: 'min(600px, 100%)', borderRadius: 14, background: c.bg.surface,
border: `1px solid ${c.border.medium}`, boxShadow: '0 18px 50px rgba(0,0,0,0.22)', overflow: 'hidden',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '9px 12px', borderBottom: `1px solid ${c.border.subtle}`, background: c.bg.elevated }}>
{[0, 1, 2].map((i) => (
<span key={i} style={{ width: 10, height: 10, borderRadius: 999, background: c.border.strong }} />
))}
<span style={{ marginLeft: 8, fontSize: '0.72rem', fontWeight: 600, color: c.text.tertiary, letterSpacing: '0.04em' }}>OpenSwarm</span>
</div>
<div style={{ padding: 18, display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(108px, 1fr))', gap: 11 }}>
{entries.map((entry, i) => {
const picked = picks.includes(entry.id);
return (
<motion.button
key={entry.id}
onClick={() => 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 && (
<span style={{ position: 'absolute', top: 6, right: 6, width: 17, height: 17, borderRadius: 999, background: c.accent.primary, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Check size={11} color="#fff" />
</span>
)}
<span style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{entry.icon}</span>
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: c.text.secondary, textAlign: 'center' }}>{entry.name}</span>
</motion.button>
);
})}
</div>
</motion.div>
</BeatShell>
);
};
export default BeatApps;
@@ -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 (
<BeatShell
c={c}
title={name ? `Welcome to OpenSwarm, ${name}.` : 'Welcome to OpenSwarm.'}
body="Here's your Swarm Card. And with that, your canvas is ready."
nextLabel="Get started"
onNext={() => onFinish(name.trim() || null)}
onBack={onBack}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16 }}>
<motion.div
initial={{ opacity: 0, y: 22, rotate: -3, scale: 0.94 }}
animate={{ opacity: 1, y: 0, rotate: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 22, delay: 0.25 }}
style={{
width: 290, height: 400, borderRadius: 18, background: '#FCFBF5',
boxShadow: '0 24px 60px rgba(0,0,0,0.28)', padding: '26px 24px', boxSizing: 'border-box',
display: 'flex', flexDirection: 'column', position: 'relative',
}}
>
<div style={{
width: 168, height: 158, alignSelf: 'flex-start',
borderRadius: '6% 64% 6% 64%',
background: `linear-gradient(135deg, ${c.accent.hover}, ${c.accent.primary} 70%, ${c.accent.pressed})`,
}} />
<input
value={name}
onChange={(e) => 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,
}}
/>
<div style={{ marginTop: 6, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: '0.68rem', letterSpacing: '0.14em', color: c.accent.primary }}>
{epithet}
</div>
<div style={{ marginTop: 'auto', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: '0.6rem', letterSpacing: '0.1em',
color: c.accent.primary, border: `1px solid ${c.accent.primary}55`, borderRadius: 4, padding: '2px 7px',
}}>
OPENSWARM · {today.toUpperCase()}
</span>
<span style={{ fontSize: '0.6rem', letterSpacing: '0.06em', color: c.accent.pressed, fontWeight: 700, textAlign: 'right', lineHeight: 1.35 }}>
OPEN<br />SWARM
</span>
</div>
</motion.div>
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.7 }}
onClick={() => 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,
}}
>
<Dices size={15} /> Re-roll the title
</motion.button>
</div>
</BeatShell>
);
};
export default BeatCard;
@@ -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.
@@ -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 (
<div style={{ display: 'flex', width: '100%', height: '100%', background: c.bg.secondary }}>
<div
style={{
position: 'relative', width: 'min(400px, 36%)', flexShrink: 0, display: 'flex', flexDirection: 'column',
justifyContent: 'center', padding: '48px 46px 48px 44px', boxSizing: 'border-box',
background: c.bg.inverse, color: c.text.inverse, clipPath: ZIGZAG_CLIP,
}}
>
<div style={{ position: 'absolute', inset: 0, backgroundImage: GRAIN_URL, opacity: 0.16, pointerEvents: 'none', mixBlendMode: 'overlay' }} />
{onBack && (
<motion.button
{...enter(0.05)}
onClick={() => 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',
}}
>
<ArrowLeft size={14} /> Back
</motion.button>
)}
<motion.h1 {...enter(0.12)} style={{ margin: 0, fontSize: 'clamp(1.9rem, 3.2vw, 2.6rem)', lineHeight: 1.12, fontWeight: 700, letterSpacing: '-0.01em' }}>
{title}
</motion.h1>
<motion.p {...enter(0.26)} style={{ margin: '16px 0 0', fontSize: '0.98rem', lineHeight: 1.55, color: c.text.inverse + '99', maxWidth: '34ch' }}>
{body}
</motion.p>
<motion.div {...enter(0.42)} style={{ marginTop: 40 }}>
<button
onClick={() => armed && !nextDisabled && onNext()}
disabled={!!nextDisabled}
style={{
width: '100%', padding: '13px 18px', borderRadius: c.radius.md,
border: 'none', background: c.accent.primary, color: '#fff',
fontSize: '0.98rem', fontWeight: 600, cursor: nextDisabled ? 'default' : 'pointer',
opacity: nextDisabled ? 0.45 : 1, fontFamily: 'inherit',
transition: 'background 150ms ease, opacity 150ms ease',
}}
>
{nextLabel}
</button>
</motion.div>
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.55, delay: 0.15 }}
style={{
position: 'relative', flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 36, boxSizing: 'border-box', overflow: 'auto',
}}
>
<div style={{ position: 'absolute', inset: 0, backgroundImage: GRAIN_URL, opacity: 0.07, pointerEvents: 'none' }} />
<div style={{ position: 'relative', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{children}
</div>
</motion.div>
</div>
);
};
export default BeatShell;
@@ -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 (
<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."
nextLabel="Continue"
onNext={onNext}
onBack={onBack}
>
<motion.div
initial={{ opacity: 0, scale: 0.94, y: 14 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 220, damping: 24, delay: 0.2 }}
style={{
width: 'min(430px, 100%)', borderRadius: 20, background: c.bg.inverse,
boxShadow: '0 18px 50px rgba(0,0,0,0.3)', padding: '14px 16px 18px', boxSizing: 'border-box',
display: 'flex', flexDirection: 'column', gap: 12,
}}
>
<div style={{ display: 'flex', justifyContent: 'center', gap: 10 }}>
{(['light', 'dark'] as const).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
title={m === 'light' ? 'Light' : 'Dark'}
style={{
width: 34, height: 28, borderRadius: 8, border: 'none', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: mode === m ? c.accent.primary : 'transparent',
color: mode === m ? '#fff' : c.text.inverse + '88',
transition: 'background 140ms ease, color 140ms ease',
}}
>
{m === 'light' ? <Sun size={15} /> : <Moon size={15} />}
</button>
))}
</div>
<AccentColorPad c={c} accent={accent} onPick={setAccent} height={210} />
</motion.div>
</BeatShell>
);
};
export default BeatTheme;
@@ -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. */