diff --git a/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx b/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx
index e9394763..cf7801e6 100644
--- a/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx
+++ b/frontend/src/app/components/OnboardingV3/OnboardingV3Root.tsx
@@ -1,15 +1,16 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { ArrowRight } from 'lucide-react';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { updateSettingsPatch } from '@/shared/state/settingsSlice';
import { setFlowActive } from '@/shared/state/onboardingV3Slice';
import { selectSubscriptionConnections } from '@/shared/state/subscriptionsSlice';
-import { useClaudeTokens } from '@/shared/styles/ThemeContext';
+import { useClaudeTokens, useThemeAccent } from '@/shared/styles/ThemeContext';
import type { ClaudeTokens } from '@/shared/styles/claudeTokens';
import { useOnboardingV3Pipeline } from './useOnboardingV3Pipeline';
-import { GRAIN_URL } from './beats/BeatShell';
-import Starburst from './Starburst';
+import { GRAIN_URL } from '@/shared/styles/grainTexture';
+import { ARC_BLUE_BG, ONBOARDING_SANS } from './beats/BeatShell';
+import OnboardingLogo from './OnboardingLogo';
import BeatConnect from './beats/BeatConnect';
import BeatApps from './beats/BeatApps';
import BeatTheme from './beats/BeatTheme';
@@ -19,8 +20,6 @@ type Beat = 'welcome' | 'newos' | 'connect' | 'apps' | 'theme' | 'card';
const V2_STORAGE_KEY = 'openswarm.onboarding.v2';
const WINDOWED_BEATS: Beat[] = ['welcome', 'newos'];
-// The opening is its own vivid moment (electric blue -> indigo -> violet), deliberately off-brand; the user's picked color takes over from the theme beat on.
-const INTRO = { core: '#93b5ff', mid: '#5b6cff', edge: '#a855f7' };
// 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 {
@@ -34,52 +33,51 @@ function useOnboardingV3Gate(): boolean {
try { return localStorage.getItem(V2_STORAGE_KEY) !== null; } catch { return false; }
}, []);
+ // Dev/QA only (stripped from production builds): localStorage 'osw_force_onboarding'='1' replays the
+ // v3 flow on a non-fresh install, since the gate is otherwise first-install-only. Lets us showcase
+ // + live-test the whole flow without a truly clean profile.
+ const forceShow = useMemo(() => {
+ if (process.env.NODE_ENV === 'production') return false;
+ try { return localStorage.getItem('osw_force_onboarding') === '1'; } catch { return false; }
+ }, []);
+
+ // Under the replay flag we open the flow exactly ONCE (a ref, not on every v3State change) so that
+ // finish() setting flowActive=false actually closes the curtain instead of the effect re-opening it.
+ const forcedOpenRef = useRef(false);
useEffect(() => {
+ if (forceShow) {
+ if (!forcedOpenRef.current) { forcedOpenRef.current = true; dispatch(setFlowActive(true)); }
+ return;
+ }
if (!settingsLoaded || v3State) return;
if (hasV2History) {
dispatch(updateSettingsPatch({ onboarding_v3: 'skipped' }));
return;
}
dispatch(setFlowActive(true));
- }, [settingsLoaded, v3State, hasV2History, dispatch]);
+ }, [settingsLoaded, v3State, hasV2History, dispatch, forceShow]);
- // Backstop for a veteran who cleared localStorage: real sessions arriving mid-flow means this is not a fresh install.
+ // Backstop for a veteran who cleared localStorage: real sessions arriving mid-flow means this is not a fresh install. (Skipped under the dev replay flag, whose demo dashboard legitimately has sessions.)
useEffect(() => {
- if (!flowActive || sessionCount === 0) return;
+ if (forceShow || !flowActive || sessionCount === 0) return;
dispatch(setFlowActive(false));
dispatch(updateSettingsPatch({ onboarding_v3: 'skipped' }));
- }, [flowActive, sessionCount, dispatch]);
+ }, [flowActive, sessionCount, dispatch, forceShow]);
- return flowActive && settingsLoaded && !v3State;
+ // forceShow bypasses the v2/v3 ENTRY block but still respects flowActive, so finish() closes it.
+ return forceShow ? (flowActive && settingsLoaded) : (flowActive && settingsLoaded && !v3State);
}
-// Full-bleed intro room: a soft accent blob drifts once behind giant type, one arrow, nothing else.
+// Full-bleed intro room, Arc's "A browser for you.": giant heavy white type centered on the grained
+// electric-blue gradient, one arrow, nothing else.
const IntroBeat: React.FC<{ c: ClaudeTokens; line: string; sub?: string; onNext: () => void }> = ({ c, line, sub, onNext }) => (
-
-
-
-
-
-
+
+
{line}
@@ -88,7 +86,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.02rem', color: c.text.inverse + '99' }}
+ style={{ position: 'relative', margin: '14px 0 0', fontSize: '1.02rem', color: 'rgba(255,255,255,0.75)' }}
>
{sub}
@@ -117,26 +115,37 @@ const OnboardingV3Root: React.FC = () => {
const dispatch = useAppDispatch();
const pipeline = useOnboardingV3Pipeline();
const [beat, setBeat] = useState('welcome');
- const [scanConsent, setScanConsent] = useState(true);
- const [usageConsent, setUsageConsent] = useState(true);
const [picks, setPicks] = useState([]);
const connectedProvider = useAppSelector((s) => selectSubscriptionConnections(s).find((cx) => cx.isActive !== false)?.provider ?? null);
const [finishing, setFinishing] = useState(false);
+ // The curtain wears whatever the user just picked (their gradient under a soft veil), Arc's post-theme rule.
+ const { accent, gradient } = useThemeAccent();
+ const finishStops = gradient ?? (accent ? [accent] : null);
+ const finishBackdrop = finishStops
+ ? `linear-gradient(rgba(255,255,255,0.16), rgba(255,255,255,0.16)), linear-gradient(160deg, ${finishStops.map((hex, i) => `${hex} ${finishStops.length > 1 ? (i / (finishStops.length - 1)) * 100 : 100}%`).join(', ')})`
+ : ARC_BLUE_BG;
const { kickIdentity, kickScan, kickUsageRead, kickPrep, finish } = pipeline;
+ // Start ALL the background work the instant they connect: identity + scan + usage + prep + the
+ // audit/app jobs. They then run through the entire rest of the flow (connect -> apps -> theme ->
+ // card), so the reveal lands on work already well underway. Personalization is default-on (no
+ // opt-in checkbox), so scan + chat-read always fire here. kickPrep is idempotent and awaits the
+ // scan/usage promises just kicked here; picks aren't in yet and prep doesn't gate on them.
const onConnected = useCallback(() => {
kickIdentity();
- kickScan(scanConsent);
- if (connectedProvider) kickUsageRead(connectedProvider, usageConsent);
- }, [kickIdentity, kickScan, kickUsageRead, scanConsent, usageConsent, connectedProvider]);
+ kickScan(true);
+ if (connectedProvider) kickUsageRead(connectedProvider, true);
+ kickPrep(picks);
+ }, [kickIdentity, kickScan, kickUsageRead, kickPrep, connectedProvider, picks]);
- // Fire prep + the background jobs at connect-exit (scan/usage/identity are all ready by now), so they run through the apps + theme + card beats (~three beats of runway) and the reveal lands on work well underway, not just-started. kickPrep is idempotent; picks arrive a beat later and feed the reveal's connect suggestions, not the already-launched jobs.
+ // Backstop: onConnected fires prep for subscription/api-key connects; this covers any path where it
+ // didn't (e.g. free trial). Idempotent, so it's a no-op when prep already started at connect.
const leaveConnect = useCallback(() => {
- kickScan(scanConsent);
+ kickScan(true);
kickPrep(picks);
setBeat('apps');
- }, [kickScan, kickPrep, scanConsent, picks]);
+ }, [kickScan, kickPrep, picks]);
const leaveApps = useCallback(() => {
kickPrep(picks);
@@ -149,7 +158,6 @@ const OnboardingV3Root: React.FC = () => {
await finish('done');
}, [dispatch, finish]);
- const skipAll = useCallback(() => { void finish('skipped'); }, [finish]);
const windowed = WINDOWED_BEATS.includes(beat);
const vw = window.innerWidth;
@@ -197,10 +205,6 @@ const OnboardingV3Root: React.FC = () => {
setBeat('newos')}
@@ -211,41 +215,32 @@ const OnboardingV3Root: React.FC = () => {
{beat === 'card' && { void leaveCard(name); }} onBack={() => setBeat('theme')} />}
- {/* Traffic lights sell the floating window during the intro; they fade as the window takes the screen. */}
+ {/* Traffic lights on every beat: Arc's onboarding is a real window and never hides them. */}
{['#FF5F57', '#FEBC2E', '#28C840'].map((dot) => (
))}
{finishing && (
-
+
+
-
+
-
+
Setting up your canvas...
)}
- {!finishing && (
-
- )}
)}
diff --git a/frontend/src/app/components/OnboardingV3/beats/BeatConnect.tsx b/frontend/src/app/components/OnboardingV3/beats/BeatConnect.tsx
index 03259f5e..3e35db89 100644
--- a/frontend/src/app/components/OnboardingV3/beats/BeatConnect.tsx
+++ b/frontend/src/app/components/OnboardingV3/beats/BeatConnect.tsx
@@ -1,37 +1,31 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { motion } from 'framer-motion';
-import { Check } from 'lucide-react';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { API_BASE } from '@/shared/config';
import { fetchModels } from '@/shared/state/modelsSlice';
-import { fetchSubscriptionStatus, markSubscriptionConnected } from '@/shared/state/subscriptionsSlice';
-import { updateSettingsPatch } from '@/shared/state/settingsSlice';
+import { fetchSubscriptionStatus, markSubscriptionConnected, selectSubscriptionConnections } from '@/shared/state/subscriptionsSlice';
import { hasFreeTrialActive, hasModelConnected } from '@/app/components/Onboarding/steps/skipPredicates';
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 OnboardingLogo from '../OnboardingLogo';
+import { providerLogo } from '../providerLogos';
import BeatShell from './BeatShell';
-// The single ask of the whole flow, staged as Arc's import list: radio rows, no filler copy. Reuses the proven Settings connect flow verbatim; the scan disclosure is one quiet line, and the scan runs during the OAuth wait.
+// The single ask of the whole flow, staged as Arc's import list: radio rows, no filler copy. Reuses the proven Settings connect flow verbatim. Personalization (local scan + one-time chat read) just happens the moment they connect; it's not an opt-in checkbox anymore.
const BeatConnect: React.FC<{
c: ClaudeTokens;
identity: ProviderIdentity[];
- scanConsent: boolean;
- setScanConsent: (v: boolean) => void;
- usageConsent: boolean;
- setUsageConsent: (v: boolean) => void;
onConnected: () => void;
onNext: () => void;
onBack: () => void;
-}> = ({ c, identity, scanConsent, setScanConsent, usageConsent, setUsageConsent, onConnected, onNext, onBack }) => {
+}> = ({ c, identity, onConnected, onNext, onBack }) => {
const dispatch = useAppDispatch();
const connected = useAppSelector((s) => hasModelConnected(s));
const freeTrial = useAppSelector((s) => hasFreeTrialActive(s));
const [connecting, setConnecting] = useState(null);
const [userCode, setUserCode] = useState('');
- const [showKeys, setShowKeys] = useState(false);
- const [keyDraft, setKeyDraft] = useState('');
const pollTimerRef = useRef | null>(null);
const connectedOnce = useRef(false);
@@ -70,15 +64,14 @@ const BeatConnect: React.FC<{
}
}, [dispatch]);
- const saveKey = useCallback(() => {
- const v = keyDraft.trim();
- if (!v) return;
- const field = v.startsWith('sk-ant-') ? 'anthropic_api_key' : v.startsWith('sk-or-') ? 'openrouter_api_key' : v.startsWith('AIza') ? 'google_api_key' : 'openai_api_key';
- dispatch(updateSettingsPatch({ [field]: v }));
- setKeyDraft('');
- }, [keyDraft, dispatch]);
-
- const connectedIdentity = identity.length > 0 ? identity[0] : null;
+ // Which provider rows are live, so the tab itself shows "Connected", not a floating label below.
+ const connections = useAppSelector(selectSubscriptionConnections);
+ const connectedIds = new Set(connections.filter((cx) => cx.isActive !== false).map((cx) => cx.provider));
+ const identityFor = (pid: string): ProviderIdentity | undefined =>
+ identity.find((id) => id.provider === pid) ?? (pid === 'antigravity' ? identity.find((id) => id.provider === 'gemini') : undefined);
+ // The whole flow leans on a real connection (identity, chat-history read, personalized reveal), so
+ // there is no skip: Continue stays locked until a provider is connected or the free trial is armed.
+ const canContinue = connected || freeTrial;
return (
}
>