[eric] onboarding: slower sequential welcome reveal (title -> greeting -> chips) + cursor bursts in with radiating orange spark lines

This commit is contained in:
ciregenz
2026-06-14 01:43:13 -07:00
parent 58b4774620
commit ba850e5fdc
2 changed files with 63 additions and 28 deletions
@@ -62,12 +62,34 @@ const IS_WIN = typeof navigator !== 'undefined' && navigator.userAgent.includes(
// before the next step's instant write lands.
const WIN_EASE_MS = 420;
// A little spark when the cursor pops into existence: short orange lines shoot out from the
// tip and fade. Re-keyed on each pop so it replays. One-shot per mount (no infinite loop).
const BURST_SPOKES = 8;
const CursorBurst: React.FC<{ color: string }> = ({ color }) => (
<>
{Array.from({ length: BURST_SPOKES }).map((_, i) => (
<div
key={i}
style={{ position: 'absolute', left: 3, top: 3, transform: `rotate(${(i / BURST_SPOKES) * 360}deg)` }}
>
<motion.div
initial={{ y: -1, opacity: 0.95, scaleY: 0.35 }}
animate={{ y: -15, opacity: 0, scaleY: 1 }}
transition={{ duration: 0.45, ease: 'easeOut' }}
style={{ position: 'absolute', left: -1, top: 0, width: 2, height: 8, borderRadius: 2, background: color }}
/>
</div>
))}
</>
);
const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
const c = useClaudeTokens();
const controls = useAnimationControls();
const storePos = useCursorPosition();
const posRef = useRef({ x: 0, y: 0 });
const [visible, setVisible] = useState(false);
const [burstKey, setBurstKey] = useState(0);
const [popup, setPopup] = useState<PopupState | null>(null);
const [multiChoice, setMultiChoice] = useState<MultiChoiceState | null>(null);
@@ -97,6 +119,7 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
writePos(from.x, from.y, true);
controls.set({ x: from.x, y: from.y, opacity: 0, scale: 0.3 });
setVisible(true);
setBurstKey((k) => k + 1); // replay the orange spark on each pop
// A little "pop!": scale overshoots past 1 then settles, while opacity snaps in.
await controls.start({
opacity: 1,
@@ -308,22 +331,28 @@ const AgenticCursor = forwardRef<AgenticCursorHandle>((_props, ref) => {
}}
>
{visible && (
<motion.div
animate={{
scale: [1, 1.04, 1],
}}
transition={{
duration: 1.8,
repeat: Infinity,
ease: 'easeInOut',
}}
style={{
transform: 'translate(-2px, -2px)',
filter: `drop-shadow(0 0 6px ${c.accent.primary}cc) drop-shadow(0 0 14px ${c.accent.primary}55)`,
}}
>
<CursorArrow color={c.accent.primary} />
</motion.div>
<>
{/* Orange spark behind the arrow, replays each pop via burstKey. */}
<div key={burstKey} style={{ position: 'absolute', top: 0, left: 0 }}>
<CursorBurst color={c.accent.primary} />
</div>
<motion.div
animate={{
scale: [1, 1.04, 1],
}}
transition={{
duration: 1.8,
repeat: Infinity,
ease: 'easeInOut',
}}
style={{
transform: 'translate(-2px, -2px)',
filter: `drop-shadow(0 0 6px ${c.accent.primary}cc) drop-shadow(0 0 14px ${c.accent.primary}55)`,
}}
>
<CursorArrow color={c.accent.primary} />
</motion.div>
</>
)}
</motion.div>
@@ -8,19 +8,23 @@ import { STARTER_CATEGORIES } from '@/shared/starterCategories';
const GREETING = "Hi, I'm your AI team. What do you want done?";
// One-shot typewriter for a fixed string (no infinite loop; stops at the end).
function useTypewriter(text: string, speedMs = 26): { shown: string; done: boolean } {
// One-shot typewriter for a fixed string (no infinite loop; stops at the end). startDelayMs
// holds the start so the header title can stream first (sequential reveal).
function useTypewriter(text: string, speedMs = 45, startDelayMs = 0): { shown: string; done: boolean } {
const [shown, setShown] = React.useState('');
React.useEffect(() => {
setShown('');
let i = 0;
const id = window.setInterval(() => {
i += 1;
setShown(text.slice(0, i));
if (i >= text.length) window.clearInterval(id);
}, speedMs);
return () => window.clearInterval(id);
}, [text, speedMs]);
let interval: number | undefined;
const startTimer = window.setTimeout(() => {
let i = 0;
interval = window.setInterval(() => {
i += 1;
setShown(text.slice(0, i));
if (i >= text.length) window.clearInterval(interval);
}, speedMs);
}, startDelayMs);
return () => { window.clearTimeout(startTimer); if (interval) window.clearInterval(interval); };
}, [text, speedMs, startDelayMs]);
return { shown, done: shown.length >= text.length };
}
@@ -32,7 +36,9 @@ const WelcomeQuickReplies: React.FC<{
onPick: (prompt: string) => void;
onPickBuilder: (prompt: string) => void;
}> = ({ c, onPick, onPickBuilder }) => {
const { shown: greeting, done: greetingDone } = useTypewriter(GREETING);
// Slow + delayed so it reads as a sequence: card pops, the header title streams, THEN
// the greeting types, THEN the chips pop in.
const { shown: greeting, done: greetingDone } = useTypewriter(GREETING, 46, 650);
const [expanded, setExpanded] = React.useState<string | null>(null);
const currentCategory = STARTER_CATEGORIES.find((cat) => cat.id === expanded);
const isAppBuilder = currentCategory?.target === 'app-builder';
@@ -82,7 +88,7 @@ const WelcomeQuickReplies: React.FC<{
onClick={() => setExpanded(cat.id)}
initial={{ opacity: 0, scale: 0.82 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: 'spring', stiffness: 520, damping: 24, delay: 0.08 + i * 0.07 }}
transition={{ type: 'spring', stiffness: 420, damping: 22, delay: 0.18 + i * 0.14 }}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 14px',