mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-29 19:29:48 +02:00
[eric] onboarding: theme-aware skin (follows light/dark), remove progress dots, smooth word-fade consent (no janky typewriter)
This commit is contained in:
@@ -1,54 +1,62 @@
|
||||
// Small shared UI atoms for the onboarding screens (serif heading, subtitle, white CTA, ghost link).
|
||||
// Small shared UI atoms for the onboarding screens (serif heading, subtitle, CTA, ghost link).
|
||||
// All theme-aware via useOnboardingSkin().
|
||||
|
||||
import React from 'react';
|
||||
import { ONBOARDING_SKIN as S } from './onboardingSkin';
|
||||
import { useOnboardingSkin } from './onboardingSkin';
|
||||
|
||||
export const Heading: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<h1 style={{ fontFamily: S.serif, fontWeight: 500, fontSize: 33, lineHeight: 1.15, letterSpacing: '-0.005em', color: S.text, margin: 0 }}>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
export const Heading: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const S = useOnboardingSkin();
|
||||
return (
|
||||
<h1 style={{ fontFamily: S.serif, fontWeight: 500, fontSize: 33, lineHeight: 1.15, letterSpacing: '-0.005em', color: S.text, margin: 0 }}>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
|
||||
export const Sub: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<div style={{ marginTop: 12, fontSize: 15, color: S.muted }}>{children}</div>
|
||||
);
|
||||
export const Sub: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const S = useOnboardingSkin();
|
||||
return <div style={{ marginTop: 12, fontSize: 15, color: S.muted }}>{children}</div>;
|
||||
};
|
||||
|
||||
export const PrimaryButton: React.FC<{ onClick: () => void; children: React.ReactNode; style?: React.CSSProperties }> = ({
|
||||
onClick,
|
||||
children,
|
||||
style,
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
marginTop: 24,
|
||||
width: '100%',
|
||||
maxWidth: 430,
|
||||
background: S.ctaBg,
|
||||
color: S.ctaText,
|
||||
border: 'none',
|
||||
borderRadius: 11,
|
||||
padding: 14,
|
||||
fontFamily: S.sans,
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}) => {
|
||||
const S = useOnboardingSkin();
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
marginTop: 24,
|
||||
width: '100%',
|
||||
maxWidth: 430,
|
||||
background: S.ctaBg,
|
||||
color: S.ctaText,
|
||||
border: 'none',
|
||||
borderRadius: 11,
|
||||
padding: 14,
|
||||
fontFamily: S.sans,
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const GhostLink: React.FC<{ onClick: () => void; children: React.ReactNode; style?: React.CSSProperties }> = ({
|
||||
onClick,
|
||||
children,
|
||||
style,
|
||||
}) => (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{ marginTop: 26, color: S.muted, fontSize: 14, cursor: 'pointer', ...style }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}) => {
|
||||
const S = useOnboardingSkin();
|
||||
return (
|
||||
<div onClick={onClick} style={{ marginTop: 26, color: S.muted, fontSize: 14, cursor: 'pointer', ...style }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,14 +18,6 @@ import type { FlowStepId, PersonaId, PayoffIdea, IconName } from './onboardingFl
|
||||
// Icons for profile-generated ideas (the agent returns text, not icons); rotated for variety.
|
||||
const IDEA_ICONS: IconName[] = ['sun', 'tray', 'build', 'globe'];
|
||||
|
||||
const DOT_INDEX: Record<FlowStepId, number | undefined> = {
|
||||
help: 0,
|
||||
name: 1,
|
||||
consent: 2,
|
||||
connect: 3,
|
||||
payoff: undefined,
|
||||
};
|
||||
|
||||
function greetingFor(name: string): string {
|
||||
const h = new Date().getHours();
|
||||
const part = h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
|
||||
@@ -108,9 +100,5 @@ export const OnboardingFlow: React.FC<{ onExit: () => void }> = ({ onExit }) =>
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingShell stepKey={step} stepIndex={DOT_INDEX[step]} totalSteps={4}>
|
||||
{body()}
|
||||
</OnboardingShell>
|
||||
);
|
||||
return <OnboardingShell stepKey={step}>{body()}</OnboardingShell>;
|
||||
};
|
||||
|
||||
@@ -1,53 +1,39 @@
|
||||
// The premium frame every onboarding screen sits in: brand pinned top-center, content
|
||||
// vertically centered, entrance fade (reduced-motion aware), and the discovery progress dots.
|
||||
// The premium frame every onboarding screen sits in: brand pinned top-center, content vertically
|
||||
// centered, entrance fade (reduced-motion aware). Theme-aware. Portaled to body + near-max z-index
|
||||
// so it escapes any ancestor transform's stacking context and covers ALL app chrome.
|
||||
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useReducedMotion } from '@/shared/hooks/useReducedMotion';
|
||||
import { ONBOARDING_SKIN as S, ONBOARDING_EASE } from './onboardingSkin';
|
||||
import { useOnboardingSkin, ONBOARDING_EASE } from './onboardingSkin';
|
||||
import { Spark } from './OnboardingIcons';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
/** Changing this re-triggers the entrance fade (one per screen). */
|
||||
stepKey: string;
|
||||
/** 0-based; when set with totalSteps, renders the bottom dots. */
|
||||
stepIndex?: number;
|
||||
totalSteps?: number;
|
||||
}
|
||||
|
||||
// Portaled to document.body + a near-max z-index so it escapes any ancestor transform's stacking
|
||||
// context and covers ALL app chrome (the ⌘K search bar, headers). This is the real takeover layer.
|
||||
const overlay: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 2147483000,
|
||||
background: S.bg,
|
||||
color: S.text,
|
||||
fontFamily: S.sans,
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
export const OnboardingShell: React.FC<Props> = ({ children, stepKey, stepIndex, totalSteps }) => {
|
||||
export const OnboardingShell: React.FC<Props> = ({ children, stepKey }) => {
|
||||
const reduce = useReducedMotion();
|
||||
const showDots = typeof stepIndex === 'number' && typeof totalSteps === 'number';
|
||||
const S = useOnboardingSkin();
|
||||
if (typeof document === 'undefined') return null;
|
||||
|
||||
return createPortal(
|
||||
<div style={overlay}>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 34,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 9,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 2147483000,
|
||||
background: S.bg,
|
||||
color: S.text,
|
||||
fontFamily: S.sans,
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'fixed', top: 34, left: '50%', transform: 'translateX(-50%)', display: 'flex', alignItems: 'center', gap: 9 }}>
|
||||
<span style={{ lineHeight: 0 }}>
|
||||
<Spark size={20} color={S.accent} />
|
||||
</span>
|
||||
@@ -73,22 +59,6 @@ export const OnboardingShell: React.FC<Props> = ({ children, stepKey, stepIndex,
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{showDots && (
|
||||
<div style={{ position: 'fixed', bottom: 40, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 9 }}>
|
||||
{Array.from({ length: totalSteps as number }).map((unused, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: i === stepIndex ? S.accent : 'rgba(243,241,234,0.16)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// The payoff: name + quirky remark + insight + a prefilled runnable task + tailored "Ideas for you".
|
||||
// Presentational; the orchestrator feeds it content (persona-derived now, profile-derived later).
|
||||
// Presentational + theme-aware; the orchestrator feeds it content (persona floor or profile-derived).
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ONBOARDING_SKIN as S } from './onboardingSkin';
|
||||
import { useOnboardingSkin } from './onboardingSkin';
|
||||
import { LineIcon } from './OnboardingIcons';
|
||||
import { Heading } from './OnboardingAtoms';
|
||||
import type { PayoffIdea } from './onboardingFlowTypes';
|
||||
|
||||
const IdeaRow: React.FC<{ idea: PayoffIdea; onPick: () => void }> = ({ idea, onPick }) => {
|
||||
const S = useOnboardingSkin();
|
||||
const [hover, setHover] = useState(false);
|
||||
return (
|
||||
<div
|
||||
@@ -42,62 +43,65 @@ export const Payoff: React.FC<{
|
||||
ideas: PayoffIdea[];
|
||||
onRun: () => void;
|
||||
onPickIdea: (idea: PayoffIdea) => void;
|
||||
}> = ({ greeting, remark, insight, prefilledPrompt, ideas, onRun, onPickIdea }) => (
|
||||
<>
|
||||
<Heading>{greeting}</Heading>
|
||||
{remark && <div style={{ marginTop: 10, fontSize: 15, color: S.muted, fontStyle: 'italic' }}>{remark}</div>}
|
||||
<div style={{ marginTop: 20, fontSize: 16, color: S.muted, lineHeight: 1.5, maxWidth: 540 }}>{insight}</div>
|
||||
}> = ({ greeting, remark, insight, prefilledPrompt, ideas, onRun, onPickIdea }) => {
|
||||
const S = useOnboardingSkin();
|
||||
return (
|
||||
<>
|
||||
<Heading>{greeting}</Heading>
|
||||
{remark && <div style={{ marginTop: 10, fontSize: 15, color: S.muted, fontStyle: 'italic' }}>{remark}</div>}
|
||||
<div style={{ marginTop: 20, fontSize: 16, color: S.muted, lineHeight: 1.5, maxWidth: 540 }}>{insight}</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 26,
|
||||
width: '100%',
|
||||
maxWidth: 600,
|
||||
background: S.surface,
|
||||
border: `1px solid ${S.border}`,
|
||||
borderRadius: S.radius,
|
||||
padding: '20px 22px',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 15.5, lineHeight: 1.55, color: S.text }}>{prefilledPrompt}</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
color: S.muted,
|
||||
fontSize: 13,
|
||||
borderTop: `1px solid ${S.border}`,
|
||||
paddingTop: 14,
|
||||
marginTop: 26,
|
||||
width: '100%',
|
||||
maxWidth: 600,
|
||||
background: S.surface,
|
||||
border: `1px solid ${S.border}`,
|
||||
borderRadius: S.radius,
|
||||
padding: '20px 22px',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span>+</span>
|
||||
<span>Cowork</span>
|
||||
<span style={{ marginLeft: 8 }}>Opus 4.8</span>
|
||||
<span
|
||||
onClick={onRun}
|
||||
<div style={{ fontSize: 15.5, lineHeight: 1.55, color: S.text }}>{prefilledPrompt}</div>
|
||||
<div
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
background: S.ctaBg,
|
||||
color: S.ctaText,
|
||||
borderRadius: 9,
|
||||
padding: '7px 15px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
marginTop: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
color: S.muted,
|
||||
fontSize: 13,
|
||||
borderTop: `1px solid ${S.border}`,
|
||||
paddingTop: 14,
|
||||
}}
|
||||
>
|
||||
Run
|
||||
</span>
|
||||
<span>+</span>
|
||||
<span>Cowork</span>
|
||||
<span style={{ marginLeft: 8 }}>Opus 4.8</span>
|
||||
<span
|
||||
onClick={onRun}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
background: S.ctaBg,
|
||||
color: S.ctaText,
|
||||
borderRadius: 9,
|
||||
padding: '7px 15px',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Run
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 24, width: '100%', maxWidth: 600, textAlign: 'left' }}>
|
||||
<div style={{ fontSize: 13, color: S.muted, marginBottom: 8 }}>Ideas for you</div>
|
||||
{ideas.map((idea) => (
|
||||
<IdeaRow key={idea.id} idea={idea} onPick={() => onPickIdea(idea)} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div style={{ marginTop: 24, width: '100%', maxWidth: 600, textAlign: 'left' }}>
|
||||
<div style={{ fontSize: 13, color: S.muted, marginBottom: 8 }}>Ideas for you</div>
|
||||
{ideas.map((idea) => (
|
||||
<IdeaRow key={idea.id} idea={idea} onPick={() => onPickIdea(idea)} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,29 @@
|
||||
// Always-dark premium skin for the onboarding flow, matching Claude.ai onboarding.
|
||||
// Deliberately NOT the app's theme tokens: onboarding is always this dark, like Claude's.
|
||||
// Premium onboarding skin (Claude-onboarding aesthetic) that follows the user's light/dark theme.
|
||||
// Same layout/typography in both modes; only the palette flips.
|
||||
|
||||
export const ONBOARDING_SKIN = {
|
||||
import { useThemeMode } from '@/shared/styles/ThemeContext';
|
||||
|
||||
export interface OnboardingSkin {
|
||||
bg: string;
|
||||
surface: string;
|
||||
surfaceHover: string;
|
||||
text: string;
|
||||
muted: string;
|
||||
ghost: string;
|
||||
accent: string;
|
||||
border: string;
|
||||
borderStrong: string;
|
||||
ctaBg: string;
|
||||
ctaText: string;
|
||||
serif: string;
|
||||
sans: string;
|
||||
radius: number;
|
||||
}
|
||||
|
||||
const SERIF = '"Copernicus", "Tiempos Headline", Georgia, "Times New Roman", serif';
|
||||
const SANS = '"Styrene B", "Anthropic Sans", -apple-system, "SF Pro Text", system-ui, sans-serif';
|
||||
|
||||
const DARK: OnboardingSkin = {
|
||||
bg: '#1C1B19',
|
||||
surface: '#252420',
|
||||
surfaceHover: '#2B2A25',
|
||||
@@ -13,10 +35,31 @@ export const ONBOARDING_SKIN = {
|
||||
borderStrong: 'rgba(243,241,234,0.14)',
|
||||
ctaBg: '#F3F1EA',
|
||||
ctaText: '#1A1917',
|
||||
serif: '"Copernicus", "Tiempos Headline", Georgia, "Times New Roman", serif',
|
||||
sans: '"Styrene B", "Anthropic Sans", -apple-system, "SF Pro Text", system-ui, sans-serif',
|
||||
serif: SERIF,
|
||||
sans: SANS,
|
||||
radius: 16,
|
||||
} as const;
|
||||
};
|
||||
|
||||
// framer-motion cubic-bezier for entrances; matches the mock's --ease.
|
||||
const LIGHT: OnboardingSkin = {
|
||||
bg: '#F5F5F0',
|
||||
surface: '#FFFFFF',
|
||||
surfaceHover: '#F5F4ED',
|
||||
text: '#1A1A18',
|
||||
muted: '#73726C',
|
||||
ghost: 'rgba(115,114,108,0.65)',
|
||||
accent: '#C4633A',
|
||||
border: 'rgba(0,0,0,0.08)',
|
||||
borderStrong: 'rgba(0,0,0,0.14)',
|
||||
ctaBg: '#1A1A18',
|
||||
ctaText: '#F5F5F0',
|
||||
serif: SERIF,
|
||||
sans: SANS,
|
||||
radius: 16,
|
||||
};
|
||||
|
||||
export function useOnboardingSkin(): OnboardingSkin {
|
||||
return useThemeMode().mode === 'dark' ? DARK : LIGHT;
|
||||
}
|
||||
|
||||
// framer-motion cubic-bezier for entrances.
|
||||
export const ONBOARDING_EASE = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// UI-only here: real OAuth (integrations.tsx / POST /{tool_id}/oauth/start) wires in a follow step.
|
||||
|
||||
import React from 'react';
|
||||
import { ONBOARDING_SKIN as S } from '../onboardingSkin';
|
||||
import { useOnboardingSkin } from '../onboardingSkin';
|
||||
import { LineIcon } from '../OnboardingIcons';
|
||||
import { Heading, Sub, PrimaryButton, GhostLink } from '../OnboardingAtoms';
|
||||
import type { ConnectorOption } from '../onboardingFlowTypes';
|
||||
@@ -17,43 +17,46 @@ const Row: React.FC<{ option: ConnectorOption; connected: boolean; onConnect: ()
|
||||
option,
|
||||
connected,
|
||||
onConnect,
|
||||
}) => (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
background: S.surface,
|
||||
border: `1px solid ${S.border}`,
|
||||
borderRadius: 14,
|
||||
padding: '16px 18px',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: S.text, opacity: 0.9, display: 'flex' }}>
|
||||
<LineIcon name={option.icon} size={20} />
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 500 }}>{option.name}</div>
|
||||
<div style={{ fontSize: 12.5, color: S.muted, marginTop: 2 }}>{option.description}</div>
|
||||
</div>
|
||||
<span
|
||||
onClick={connected ? undefined : onConnect}
|
||||
}) => {
|
||||
const S = useOnboardingSkin();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: 13,
|
||||
color: connected ? S.muted : S.text,
|
||||
background: connected ? 'transparent' : S.surfaceHover,
|
||||
border: `1px solid ${connected ? S.border : S.borderStrong}`,
|
||||
borderRadius: 999,
|
||||
padding: '6px 15px',
|
||||
cursor: connected ? 'default' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
background: S.surface,
|
||||
border: `1px solid ${S.border}`,
|
||||
borderRadius: 14,
|
||||
padding: '16px 18px',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{connected ? 'Connected' : 'Connect'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
<span style={{ color: S.text, opacity: 0.9, display: 'flex' }}>
|
||||
<LineIcon name={option.icon} size={20} />
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 500 }}>{option.name}</div>
|
||||
<div style={{ fontSize: 12.5, color: S.muted, marginTop: 2 }}>{option.description}</div>
|
||||
</div>
|
||||
<span
|
||||
onClick={connected ? undefined : onConnect}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: 13,
|
||||
color: connected ? S.muted : S.text,
|
||||
background: connected ? 'transparent' : S.surfaceHover,
|
||||
border: `1px solid ${connected ? S.border : S.borderStrong}`,
|
||||
borderRadius: 999,
|
||||
padding: '6px 15px',
|
||||
cursor: connected ? 'default' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{connected ? 'Connected' : 'Connect'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConnectApps: React.FC<{
|
||||
connectedIds: string[];
|
||||
|
||||
@@ -1,60 +1,53 @@
|
||||
// D3: the personalize consent. One serif line types itself in, then a single Yes. "Yes" authorizes
|
||||
// the (later) background profiling read; copy states that plainly. This is a permission, not a checkbox.
|
||||
// D3: the personalize consent. The line reveals word-by-word (smooth, layout-stable, no jumpy
|
||||
// caret), then a single Yes. "Yes" authorizes the (later) background profiling read; copy says so.
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useReducedMotion } from '@/shared/hooks/useReducedMotion';
|
||||
import { ONBOARDING_SKIN as S } from '../onboardingSkin';
|
||||
import { useOnboardingSkin } from '../onboardingSkin';
|
||||
import { PrimaryButton, GhostLink } from '../OnboardingAtoms';
|
||||
|
||||
const LINE =
|
||||
"Want me to actually get you? Say yes and I'll take a quick look at whatever you connect, so everything I show is aimed at your world, not a generic demo.";
|
||||
|
||||
const WORD_STAGGER_S = 0.055;
|
||||
const WORD_DUR_S = 0.5;
|
||||
|
||||
export const PersonalizeConsent: React.FC<{ onConsent: (yes: boolean) => void }> = ({ onConsent }) => {
|
||||
const reduce = useReducedMotion();
|
||||
const [shown, setShown] = useState(reduce ? LINE.length : 0);
|
||||
const done = shown >= LINE.length;
|
||||
const S = useOnboardingSkin();
|
||||
const words = useMemo(() => LINE.split(' '), []);
|
||||
const [done, setDone] = useState(reduce);
|
||||
|
||||
useEffect(() => {
|
||||
if (reduce) { setShown(LINE.length); return; }
|
||||
setShown(0);
|
||||
let i = 0;
|
||||
const id = window.setInterval(() => {
|
||||
i += 1;
|
||||
setShown(i);
|
||||
if (i >= LINE.length) window.clearInterval(id);
|
||||
}, 16);
|
||||
return () => window.clearInterval(id);
|
||||
}, [reduce]);
|
||||
if (reduce) { setDone(true); return; }
|
||||
setDone(false);
|
||||
const totalMs = (words.length * WORD_STAGGER_S + WORD_DUR_S) * 1000;
|
||||
const t = window.setTimeout(() => setDone(true), totalMs);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [reduce, words.length]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: S.serif,
|
||||
fontWeight: 500,
|
||||
fontSize: 29,
|
||||
lineHeight: 1.4,
|
||||
maxWidth: 620,
|
||||
minHeight: '4.4em',
|
||||
color: S.text,
|
||||
}}
|
||||
>
|
||||
{LINE.slice(0, shown)}
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 2,
|
||||
height: '1.02em',
|
||||
background: S.accent,
|
||||
marginLeft: 3,
|
||||
verticalAlign: -3,
|
||||
animation: reduce ? undefined : 'onboardingCaretBlink 1s steps(1) infinite',
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontFamily: S.serif, fontWeight: 500, fontSize: 29, lineHeight: 1.4, maxWidth: 620, color: S.text }}>
|
||||
{words.map((w, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
opacity: reduce ? 1 : 0,
|
||||
animation: reduce ? undefined : `onboardingWordIn ${WORD_DUR_S}s cubic-bezier(0.16,1,0.3,1) forwards`,
|
||||
animationDelay: reduce ? undefined : `${i * WORD_STAGGER_S}s`,
|
||||
}}
|
||||
>
|
||||
{w}
|
||||
</span>
|
||||
{i < words.length - 1 ? ' ' : ''}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
marginTop: 34,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
@@ -67,7 +60,7 @@ export const PersonalizeConsent: React.FC<{ onConsent: (yes: boolean) => void }>
|
||||
<PrimaryButton onClick={() => onConsent(true)}>Yes, get to know me</PrimaryButton>
|
||||
<GhostLink style={{ marginTop: 2 }} onClick={() => onConsent(false)}>not now</GhostLink>
|
||||
</div>
|
||||
<style>{'@keyframes onboardingCaretBlink{50%{opacity:0}}'}</style>
|
||||
<style>{'@keyframes onboardingWordIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}'}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// D2: one field, the name. Claude single-control screen. Enter or Continue advances.
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ONBOARDING_SKIN as S } from '../onboardingSkin';
|
||||
import { useOnboardingSkin } from '../onboardingSkin';
|
||||
import { Heading, Sub, PrimaryButton, GhostLink } from '../OnboardingAtoms';
|
||||
|
||||
export const WhatShouldICallYou: React.FC<{
|
||||
@@ -9,6 +9,7 @@ export const WhatShouldICallYou: React.FC<{
|
||||
onContinue: (name: string) => void;
|
||||
onSkip: () => void;
|
||||
}> = ({ initialName = '', onContinue, onSkip }) => {
|
||||
const S = useOnboardingSkin();
|
||||
const [name, setName] = useState(initialName);
|
||||
const [focus, setFocus] = useState(false);
|
||||
const submit = () => onContinue(name.trim());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// D1: one tap on a persona card. The single personalization seed. "just show me" skips to payoff.
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ONBOARDING_SKIN as S } from '../onboardingSkin';
|
||||
import { useOnboardingSkin } from '../onboardingSkin';
|
||||
import { LineIcon } from '../OnboardingIcons';
|
||||
import { Heading, GhostLink } from '../OnboardingAtoms';
|
||||
import type { PersonaOption } from '../onboardingFlowTypes';
|
||||
@@ -13,6 +13,7 @@ const PERSONAS: PersonaOption[] = [
|
||||
];
|
||||
|
||||
const Card: React.FC<{ option: PersonaOption; onPick: () => void }> = ({ option, onPick }) => {
|
||||
const S = useOnboardingSkin();
|
||||
const [hover, setHover] = useState(false);
|
||||
return (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user