[eric] onboarding: theme-aware skin (follows light/dark), remove progress dots, smooth word-fade consent (no janky typewriter)

This commit is contained in:
ciregenz
2026-06-30 23:21:12 -07:00
parent 9d32d7338d
commit 391df21b0d
9 changed files with 249 additions and 238 deletions
@@ -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