[eric] onboarding: connected state lives on the provider tabs; drop consent checkboxes + api-key link

This commit is contained in:
ciregenz
2026-07-19 12:07:21 -07:00
parent 8ef0cc1c5b
commit 9e8760e048
2 changed files with 100 additions and 160 deletions
@@ -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 }) => (
<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, x: 0, y: 0 }}
animate={{ scale: [0.35, 1, 1.06, 1], opacity: [0, 0.7, 0.62, 0.7], 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: 620, height: 620, borderRadius: 999,
background: `radial-gradient(circle at 42% 38%, ${INTRO.core}, ${INTRO.mid} 46%, ${INTRO.edge} 70%, transparent 82%)`,
filter: 'blur(64px)', pointerEvents: 'none',
}}
/>
<div style={{ position: 'absolute', inset: 0, backgroundImage: GRAIN_URL, opacity: 0.14, pointerEvents: 'none', mixBlendMode: 'overlay' }} />
<motion.div
initial={{ opacity: 0, scale: 0.4, rotate: -80 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={{ type: 'spring', stiffness: 160, damping: 18, delay: 0.2 }}
style={{ position: 'relative', marginBottom: 22 }}
>
<Starburst size={44} from={INTRO.core} to={INTRO.edge} />
</motion.div>
<div style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: ARC_BLUE_BG, overflow: 'hidden', fontFamily: ONBOARDING_SANS }}>
<div style={{ position: 'absolute', inset: 0, backgroundImage: GRAIN_URL, opacity: 0.32, pointerEvents: 'none' }} />
<motion.h1
initial={{ opacity: 0, y: 16, filter: 'blur(8px)' }}
animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
transition={{ duration: 0.7, delay: 0.45, ease: [0.22, 1, 0.36, 1] }}
style={{ position: 'relative', margin: 0, fontSize: 'clamp(2.4rem, 5vw, 3.8rem)', fontWeight: 600, color: c.text.inverse, letterSpacing: '-0.01em', textAlign: 'center', padding: '0 24px' }}
style={{ position: 'relative', margin: 0, fontSize: 'clamp(2.8rem, 5.4vw, 4.4rem)', fontWeight: 800, color: '#fff', letterSpacing: '-0.02em', textAlign: 'center', padding: '0 24px', fontFamily: 'inherit' }}
>
{line}
</motion.h1>
@@ -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}
</motion.p>
@@ -117,26 +115,37 @@ const OnboardingV3Root: React.FC = () => {
const dispatch = useAppDispatch();
const pipeline = useOnboardingV3Pipeline();
const [beat, setBeat] = useState<Beat>('welcome');
const [scanConsent, setScanConsent] = useState(true);
const [usageConsent, setUsageConsent] = useState(true);
const [picks, setPicks] = useState<string[]>([]);
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 = () => {
<BeatConnect
c={c}
identity={pipeline.identity}
scanConsent={scanConsent}
setScanConsent={setScanConsent}
usageConsent={usageConsent}
setUsageConsent={setUsageConsent}
onConnected={onConnected}
onNext={leaveConnect}
onBack={() => setBeat('newos')}
@@ -211,41 +215,32 @@ const OnboardingV3Root: React.FC = () => {
{beat === 'card' && <BeatCard c={c} identity={pipeline.identity} onFinish={(name) => { void leaveCard(name); }} onBack={() => setBeat('theme')} />}
</motion.div>
</AnimatePresence>
{/* 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. */}
<motion.div
animate={{ opacity: windowed ? 1 : 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
style={{ position: 'absolute', top: 13, left: 14, display: 'flex', gap: 7, pointerEvents: 'none' }}
style={{ position: 'absolute', top: 13, left: 14, display: 'flex', gap: 7, pointerEvents: 'none', zIndex: 10 }}
>
{['#FF5F57', '#FEBC2E', '#28C840'].map((dot) => (
<span key={dot} style={{ width: 11, height: 11, borderRadius: 999, background: dot, opacity: 0.9 }} />
))}
</motion.div>
{finishing && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', gap: 18, alignItems: 'center', justifyContent: 'center', background: c.bg.page }}>
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', gap: 18, alignItems: 'center', justifyContent: 'center', background: finishBackdrop, fontFamily: ONBOARDING_SANS }}>
<div style={{ position: 'absolute', inset: 0, backgroundImage: GRAIN_URL, opacity: 0.3, pointerEvents: 'none' }} />
<motion.div
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: [0, 1, 0.55, 1], scale: [0.7, 1, 0.92, 1], rotate: [0, 0, 22, 45] }}
transition={{ duration: 4.5, times: [0, 0.2, 0.6, 1], repeat: 3 }}
animate={{ opacity: [0.55, 1, 0.55], scale: [0.9, 1.06, 0.9] }}
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
style={{ position: 'relative' }}
>
<Starburst size={40} from={c.accent.hover} to={c.accent.pressed} />
<OnboardingLogo size={54} />
</motion.div>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} style={{ fontSize: '1.02rem', color: c.text.tertiary }}>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} style={{ position: 'relative', fontSize: '1.02rem', fontWeight: 600, color: '#fff', textShadow: '0 1px 8px rgba(0,0,0,0.25)' }}>
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>
)}
@@ -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<string | null>(null);
const [userCode, setUserCode] = useState('');
const [showKeys, setShowKeys] = useState(false);
const [keyDraft, setKeyDraft] = useState('');
const pollTimerRef = useRef<ReturnType<typeof setInterval> | 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 (
<BeatShell
@@ -87,12 +80,16 @@ const BeatConnect: React.FC<{
body="Use the subscription you already pay for."
nextLabel="Continue"
onNext={onNext}
nextDisabled={!canContinue}
onBack={onBack}
wide
logo={<OnboardingLogo size={52} />}
>
<div style={{ width: 'min(440px, 100%)', display: 'flex', flexDirection: 'column', gap: 10 }}>
{SUBSCRIPTION_PROVIDERS.map((p, i) => {
const isThis = connecting === p.id;
const filled = connected && isThis;
const isConnected = connectedIds.has(p.id);
const ident = isConnected ? identityFor(p.id) : undefined;
return (
<motion.button
key={p.id}
@@ -101,95 +98,43 @@ const BeatConnect: React.FC<{
animate={{ opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 320, damping: 26, delay: 0.1 + i * 0.08 }}
style={{
display: 'flex', alignItems: 'center', gap: 14, padding: '13px 14px 13px 16px', textAlign: 'left',
borderRadius: c.radius.md, border: `1px solid ${filled ? c.accent.primary : c.border.medium}`,
background: c.bg.surface, cursor: connected ? 'default' : 'pointer', fontFamily: 'inherit',
display: 'flex', alignItems: 'center', gap: 14, padding: '0 0 0 18px', textAlign: 'left', overflow: 'hidden',
borderRadius: 14, border: 'none', boxShadow: isConnected ? '0 0 0 2px #1a9e6a, 0 10px 26px rgba(20,16,80,0.22)' : '0 10px 26px rgba(20,16,80,0.22)',
background: '#ffffff', cursor: connected ? 'default' : 'pointer', fontFamily: 'inherit', minHeight: 64,
}}
>
<span style={{
width: 18, height: 18, borderRadius: 999, flexShrink: 0, boxSizing: 'border-box',
border: `2px solid ${filled ? c.accent.primary : c.border.strong}`,
background: filled ? c.accent.primary : 'transparent',
boxShadow: filled ? `inset 0 0 0 3px ${c.bg.surface}` : 'none',
width: 19, height: 19, borderRadius: 999, flexShrink: 0, boxSizing: 'border-box',
border: `1.5px solid ${isConnected ? '#1a9e6a' : '#c9c7c2'}`,
background: isConnected ? '#1a9e6a' : 'transparent',
boxShadow: isConnected ? 'inset 0 0 0 3px #ffffff' : 'none',
}} />
<span style={{ flex: 1, fontSize: '1rem', fontWeight: 600, color: c.text.primary }}>
{p.name}
{isThis && !connected && <span style={{ marginLeft: 10, fontSize: '0.8rem', fontWeight: 400, color: c.text.tertiary }}>waiting for sign-in...</span>}
<span style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 2, padding: '11px 0' }}>
<span style={{ fontSize: '0.98rem', fontWeight: 600, color: '#3d3d3a' }}>{p.name}</span>
{isConnected
? <span style={{ fontSize: '0.78rem', fontWeight: 600, color: '#1a9e6a' }}>Connected{ident?.email ? ` as ${ident.email}` : ''}</span>
: isThis && !connected
? <span style={{ fontSize: '0.78rem', fontWeight: 400, color: '#8a8a86' }}>waiting for sign-in...</span>
: null}
</span>
{/* Arc's icon tile: a full-height soft-tinted zone on the row's right edge, real brand mark inside. */}
<span style={{
width: 36, height: 36, borderRadius: 9, flexShrink: 0, background: `${p.color}26`,
alignSelf: 'stretch', width: 78, flexShrink: 0, background: `linear-gradient(135deg, ${p.color}30, ${p.color}14)`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<span style={{ width: 13, height: 13, borderRadius: 999, background: p.color }} />
{providerLogo(p.id, 28)}
</span>
</motion.button>
);
})}
{userCode && !connected && (
<div style={{ textAlign: 'center', padding: '6px 0', fontSize: '0.9rem', color: c.text.secondary }}>
<div style={{ textAlign: 'center', padding: '6px 0', fontSize: '0.9rem', color: 'rgba(255,255,255,0.92)' }}>
Your code: <strong style={{ fontFamily: c.font.mono, letterSpacing: '0.08em' }}>{userCode}</strong>
</div>
)}
{connected && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} style={{ padding: '4px 2px', fontSize: '0.9rem', fontWeight: 500, color: c.status.success }}>
Connected{connectedIdentity?.email ? ` as ${connectedIdentity.email}` : ''}
{connectedIdentity?.plan ? ` · ${connectedIdentity.label} ${connectedIdentity.plan}` : ''}
</motion.div>
)}
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 9, marginTop: 8, cursor: 'pointer', fontSize: '0.82rem', color: c.text.tertiary, lineHeight: 1.5 }}>
<button
onClick={(e) => { e.preventDefault(); setScanConsent(!scanConsent); }}
style={{
width: 16, height: 16, marginTop: 2, borderRadius: 5, padding: 0, cursor: 'pointer', flexShrink: 0,
border: `1.5px solid ${scanConsent ? c.accent.primary : c.border.strong}`,
background: scanConsent ? c.accent.primary : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background 120ms ease, border-color 120ms ease',
}}
>
{scanConsent && <Check size={11} color="#fff" strokeWidth={3.2} />}
</button>
<span>Take a quick local look around to personalize my setup. Nothing leaves this Mac.</span>
</label>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 9, cursor: 'pointer', fontSize: '0.82rem', color: c.text.tertiary, lineHeight: 1.5 }}>
<button
onClick={(e) => { e.preventDefault(); setUsageConsent(!usageConsent); }}
style={{
width: 16, height: 16, marginTop: 2, borderRadius: 5, padding: 0, cursor: 'pointer', flexShrink: 0,
border: `1.5px solid ${usageConsent ? c.accent.primary : c.border.strong}`,
background: usageConsent ? c.accent.primary : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background 120ms ease, border-color 120ms ease',
}}
>
{usageConsent && <Check size={11} color="#fff" strokeWidth={3.2} />}
</button>
<span>Learn what I work on from my chats (read once, never stored).</span>
</label>
<div style={{ display: 'flex', gap: 18, marginTop: 2 }}>
<button onClick={() => setShowKeys(!showKeys)} style={{ border: 'none', background: 'transparent', padding: 0, color: c.text.ghost, fontSize: '0.82rem', cursor: 'pointer', fontFamily: 'inherit', textDecoration: 'underline' }}>
Use an API key instead
</button>
{freeTrial && !connected && (
<button onClick={onNext} style={{ border: 'none', background: 'transparent', padding: 0, color: c.text.ghost, fontSize: '0.82rem', cursor: 'pointer', fontFamily: 'inherit', textDecoration: 'underline' }}>
Start free, connect later
</button>
)}
</div>
{showKeys && (
<div style={{ display: 'flex', gap: 8 }}>
<input
value={keyDraft}
onChange={(e) => setKeyDraft(e.target.value)}
placeholder="Paste an Anthropic, OpenAI, Google, or OpenRouter key"
style={{
flex: 1, padding: '10px 12px', borderRadius: c.radius.sm, border: `1px solid ${c.border.medium}`,
background: c.bg.surface, color: c.text.primary, fontSize: '0.85rem', fontFamily: c.font.mono,
}}
/>
<button onClick={saveKey} style={{ padding: '10px 16px', borderRadius: c.radius.sm, border: 'none', background: c.accent.primary, color: '#fff', fontSize: '0.85rem', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
Save
</button>
{!canContinue && (
<div style={{ fontSize: '0.82rem', color: 'rgba(255,255,255,0.6)', marginTop: 6 }}>
Pick a subscription above to continue.
</div>
)}
</div>