[eric] onboarding: streaming feel (shared StreamingText, thinking->stream on payoff), simplify task beat (cut 2 redundant meta-lines + lighter card); DRY consent onto StreamingText

This commit is contained in:
ciregenz
2026-07-01 00:37:04 -07:00
parent b0bfdd146c
commit 902d6c085e
5 changed files with 121 additions and 72 deletions
@@ -38,24 +38,28 @@ export const OnboardingFlow: React.FC<{ onExit: () => void }> = ({ onExit }) =>
const onPayoff = step === 'greet' || step === 'task' || step === 'more';
const floor = useMemo(() => demoPayoff(persona), [persona]);
// Personalized payoff from the persona (cheap LLM); swaps in over the floor when ready.
const suggest = useOnboardingSuggest(useCase, name, onPayoff);
// Personalized payoff from the persona (cheap LLM); status drives the thinking -> stream feel.
const { result: suggest, status: suggestStatus } = useOnboardingSuggest(useCase, name, onPayoff);
// Deeper: background read-only profiling (only if they consented); trumps the persona suggestion.
const profile = useOnboardingProfile(name, consent, onPayoff);
const profileReady = !!(profile && profile.observation.trim() && profile.options.length > 0);
// Merge into { insight, hero, more[] }. Priority: real-data profile > persona-generated > floor.
const content = useMemo(() => {
if (profile && profile.observation.trim() && profile.options.length > 0) {
if (profile && profileReady) {
const opts = profile.options.map((o, i) => ({ id: `p${i}`, icon: IDEA_ICONS[i % IDEA_ICONS.length], label: o.label, prompt: o.prompt }));
return { insight: profile.observation, hero: opts[0], more: opts.slice(1) };
}
if (suggest && suggest.insight.trim() && suggest.task.trim() && suggest.options.length > 0) {
if (suggest && suggestStatus === 'ready') {
const opts = suggest.options.map((o, i) => ({ id: `s${i}`, icon: IDEA_ICONS[i % IDEA_ICONS.length], label: o.label, prompt: o.prompt }));
const hero: PayoffIdea = { id: 'hero', icon: 'sun', label: opts[0].label, prompt: suggest.task };
const hero: PayoffIdea = { id: 'hero', icon: opts[0].icon, label: opts[0].label, prompt: suggest.task };
return { insight: suggest.insight, hero, more: opts };
}
return floor;
}, [profile, suggest, floor]);
}, [profile, profileReady, suggest, suggestStatus, floor]);
// Still generating (no result yet): the task beat shows a brief "thinking" instead of the floor.
const generating = onPayoff && !profileReady && suggestStatus === 'loading';
// Tapping a task ends onboarding by DOING it: hand the prompt to the dashboard (it spawns the agent
// with its own proven path), then close the overlay so the user watches it run.
@@ -103,6 +107,7 @@ export const OnboardingFlow: React.FC<{ onExit: () => void }> = ({ onExit }) =>
<PayoffTask
insight={content.insight}
hero={content.hero}
generating={generating}
onRun={(prompt: string) => launch(prompt)}
onMore={() => setStep('more')}
/>
@@ -0,0 +1,48 @@
// Reveals text word-by-word (a gentle rise + fade), so generated lines feel like they stream in the
// way Claude's responses do. Reduced-motion shows it all at once. Shared by the consent + payoff.
import React, { useEffect, useMemo, useState } from 'react';
import { useReducedMotion } from '@/shared/hooks/useReducedMotion';
const WORD_STAGGER_S = 0.05;
const WORD_DUR_S = 0.45;
export const StreamingText: React.FC<{
text: string;
style?: React.CSSProperties;
onDone?: () => void;
}> = ({ text, style, onDone }) => {
const reduce = useReducedMotion();
const words = useMemo(() => text.split(' '), [text]);
const [done, setDone] = useState(false);
useEffect(() => {
setDone(false);
if (reduce) { setDone(true); onDone?.(); return; }
const totalMs = (words.length * WORD_STAGGER_S + WORD_DUR_S) * 1000;
const t = window.setTimeout(() => { setDone(true); onDone?.(); }, totalMs);
return () => window.clearTimeout(t);
// onDone intentionally excluded: callers pass fresh closures; keying on text is enough.
}, [text, reduce, words.length]);
return (
<span style={style}>
{words.map((w, i) => (
<React.Fragment key={i}>
<span
style={{
display: 'inline-block',
opacity: reduce || done ? 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>
))}
<style>{'@keyframes onboardingWordIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}'}</style>
</span>
);
};
@@ -1,41 +1,66 @@
// Payoff beat 2: the insight + THE one task, as a single tappable card. Tap it and a real agent runs
// it (0 -> 1). A quiet "or something else" leads to the alternatives beat.
// Payoff beat 2: a short streamed insight + THE one task as a single tappable card (tap = run, 0->1).
// Minimal: no meta-labels stacked around the card. A quiet "or something else" leads to alternatives.
// While the LLM is still generating, a brief thinking state holds the space so nothing pops in raw.
import React, { useState } from 'react';
import { useOnboardingSkin } from '../onboardingSkin';
import { LineIcon } from '../OnboardingIcons';
import { Heading, GhostLink } from '../OnboardingAtoms';
import { GhostLink } from '../OnboardingAtoms';
import { StreamingText } from '../StreamingText';
import type { PayoffIdea } from '../onboardingFlowTypes';
const Thinking: React.FC = () => {
const S = useOnboardingSkin();
return (
<div style={{ display: 'flex', gap: 6, height: 40, alignItems: 'center' }}>
{[0, 1, 2].map((i) => (
<span
key={i}
style={{
width: 7, height: 7, borderRadius: '50%', background: S.muted,
animation: 'onboardingThink 1.2s ease-in-out infinite', animationDelay: `${i * 0.18}s`,
}}
/>
))}
<style>{'@keyframes onboardingThink{0%,100%{opacity:.25;transform:translateY(0)}50%{opacity:1;transform:translateY(-3px)}}'}</style>
</div>
);
};
export const PayoffTask: React.FC<{
insight: string;
hero: PayoffIdea;
generating: boolean;
onRun: (prompt: string) => void;
onMore: () => void;
}> = ({ insight, hero, onRun, onMore }) => {
}> = ({ insight, hero, generating, onRun, onMore }) => {
const S = useOnboardingSkin();
const [hover, setHover] = useState(false);
if (generating) return <Thinking />;
return (
<>
<Heading>{insight}</Heading>
<div style={{ marginTop: 14, fontSize: 15, color: S.muted }}>Here&#39;s the one I&#39;d start with:</div>
<StreamingText
text={insight}
style={{ fontFamily: S.serif, fontWeight: 500, fontSize: 30, lineHeight: 1.2, letterSpacing: '-0.005em', color: S.text, maxWidth: 620 }}
/>
<div
onClick={() => onRun(hero.prompt)}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
marginTop: 22,
marginTop: 32,
width: '100%',
maxWidth: 520,
maxWidth: 500,
display: 'flex',
alignItems: 'center',
gap: 16,
background: hover ? S.surfaceHover : S.surface,
border: `1px solid ${hover ? S.borderStrong : S.border}`,
borderRadius: S.radius,
padding: '22px 24px',
padding: '20px 22px',
cursor: 'pointer',
textAlign: 'left',
transform: hover ? 'translateY(-2px)' : 'none',
@@ -43,16 +68,15 @@ export const PayoffTask: React.FC<{
}}
>
<span style={{ color: S.accent, display: 'flex', flexShrink: 0 }}>
<LineIcon name={hero.icon} size={26} />
<LineIcon name={hero.icon} size={24} />
</span>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 17, fontWeight: 550 }}>{hero.label}</div>
<div style={{ marginTop: 4, fontSize: 13.5, color: S.muted, lineHeight: 1.4 }}>{hero.prompt}</div>
<div style={{ fontSize: 16, fontWeight: 550 }}>{hero.label}</div>
<div style={{ marginTop: 3, fontSize: 13, color: S.muted, lineHeight: 1.4 }}>{hero.prompt}</div>
</div>
<span style={{ marginLeft: 'auto', color: S.muted, flexShrink: 0 }}>&rarr;</span>
</div>
<div style={{ marginTop: 14, fontSize: 12.5, color: S.muted }}>a real agent goes and does this, live</div>
<GhostLink onClick={onMore}>or something else</GhostLink>
</>
);
@@ -1,63 +1,28 @@
// D3: the personalize consent. Short question reveals word-by-word, then a small explainer + Yes.
// "Yes" authorizes the (later) background profiling read; the sub-line says exactly what that means.
// D3: the personalize consent. The short question streams in (shared StreamingText), then a small
// explainer + Yes. "Yes" authorizes the (later) background profiling read; the sub-line says so.
import React, { useEffect, useMemo, useState } from 'react';
import { useReducedMotion } from '@/shared/hooks/useReducedMotion';
import React, { useState } from 'react';
import { useOnboardingSkin } from '../onboardingSkin';
import { PrimaryButton, GhostLink } from '../OnboardingAtoms';
import { StreamingText } from '../StreamingText';
const LINE = 'Want me to make this yours?';
const SUB = "I'll take a quick look at what you connect, nothing else.";
const WORD_STAGGER_S = 0.06;
const WORD_DUR_S = 0.5;
export const PersonalizeConsent: React.FC<{ onConsent: (yes: boolean) => void }> = ({ onConsent }) => {
const reduce = useReducedMotion();
const S = useOnboardingSkin();
const words = useMemo(() => LINE.split(' '), []);
const [done, setDone] = useState(reduce);
useEffect(() => {
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]);
const [done, setDone] = useState(false);
return (
<>
<div style={{ fontFamily: S.serif, fontWeight: 500, fontSize: 33, lineHeight: 1.25, 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,
fontSize: 15,
color: S.muted,
opacity: done ? 1 : 0,
transition: 'opacity .5s ease',
}}
>
<StreamingText
text={LINE}
onDone={() => setDone(true)}
style={{ fontFamily: S.serif, fontWeight: 500, fontSize: 33, lineHeight: 1.25, color: S.text }}
/>
<div style={{ marginTop: 14, fontSize: 15, color: S.muted, opacity: done ? 1 : 0, transition: 'opacity .5s ease' }}>
{SUB}
</div>
<div
style={{
marginTop: 30,
@@ -73,7 +38,6 @@ 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 onboardingWordIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}'}</style>
</>
);
};
@@ -1,6 +1,6 @@
// Generates the payoff content (insight + task + 4 options) from the user's persona via the cheap
// free-trial LLM. Progressive: returns null until it lands, so the payoff shows its static floor
// first and swaps to the personalized version when ready. Fail-open on any miss.
// LLM. Exposes status so the payoff can show a brief "thinking" state, then stream the result in.
// Fail-open: on any miss the status goes 'failed' and the caller shows its static floor.
import { useEffect, useState } from 'react';
import { API_BASE } from '@/shared/config';
@@ -11,12 +11,16 @@ export interface SuggestDto {
options: { label: string; prompt: string }[];
}
export function useOnboardingSuggest(persona: string, name: string, active: boolean): SuggestDto | null {
export type SuggestStatus = 'idle' | 'loading' | 'ready' | 'failed';
export function useOnboardingSuggest(persona: string, name: string, active: boolean): { result: SuggestDto | null; status: SuggestStatus } {
const [result, setResult] = useState<SuggestDto | null>(null);
const [status, setStatus] = useState<SuggestStatus>('idle');
useEffect(() => {
if (!active || !persona) return;
let cancelled = false;
setStatus('loading');
fetch(`${API_BASE}/agents/onboarding-suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -24,13 +28,17 @@ export function useOnboardingSuggest(persona: string, name: string, active: bool
})
.then((r) => (r.ok ? r.json() : null))
.then((data: SuggestDto | null) => {
if (!cancelled && data && data.insight?.trim() && data.task?.trim() && Array.isArray(data.options) && data.options.length > 0) {
if (cancelled) return;
if (data && data.insight?.trim() && data.task?.trim() && Array.isArray(data.options) && data.options.length > 0) {
setResult(data);
setStatus('ready');
} else {
setStatus('failed');
}
})
.catch(() => { /* fail-open: keep the floor */ });
.catch(() => { if (!cancelled) setStatus('failed'); });
return () => { cancelled = true; };
}, [active, persona, name]);
return result;
return { result, status };
}