mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-23 13:02:23 +02:00
[eric] onboarding: arc stagecraft (window birth, zigzag grain rooms, picker device, mini-canvas, swarm card)
This commit is contained in:
@@ -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;
|
||||
+1
-1
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user