|
| 1 | +'use client' |
| 2 | + |
| 3 | +import { useEffect, useRef, useState } from 'react' |
| 4 | +import { cn } from '@sim/emcn' |
| 5 | +import { Blimp } from '@sim/emcn/icons' |
| 6 | +import { ArrowUp, Mic, Paperclip, Plus, Slash } from 'lucide-react' |
| 7 | +import { ThinkingLoader } from '@/app/(landing)/components/thinking-loader' |
| 8 | + |
| 9 | +const PROMPT = 'Build a workflow to schedule and publish posts to my X account.' |
| 10 | +const REPLY = |
| 11 | + 'On it — building a workflow with a schedule trigger, a drafting agent, and an X publish step.' |
| 12 | +const REPLY_WORDS = REPLY.split(' ') |
| 13 | + |
| 14 | +const TYPE_START_MS = 900 |
| 15 | +const TYPE_CHAR_MS = 40 |
| 16 | +const SEND_MS = 3800 |
| 17 | +const THINKING_DONE_MS = 6000 |
| 18 | +const REPLY_START_DELAY_MS = 420 |
| 19 | +const REPLY_WORD_MS = 95 |
| 20 | +const MERGE_MS = 9000 |
| 21 | +const LOOP_MS = 10400 |
| 22 | + |
| 23 | +/** Resting gap between the chat surface and the composer once the split settles. */ |
| 24 | +const SPLIT_GAP_PX = 10 |
| 25 | +/** Goo blur decay window — the liquid neck between the boxes thins and snaps over this span. */ |
| 26 | +const GOO_DECAY_MS = 850 |
| 27 | +/** Peak feGaussianBlur stdDeviation at the send beat. */ |
| 28 | +const GOO_MAX_BLUR = 16 |
| 29 | +/** Blur decay tick; stepped so the blur eases out on a (1-t)^2 curve. */ |
| 30 | +const GOO_STEP_MS = 40 |
| 31 | + |
| 32 | +/** |
| 33 | + * Chat-surface transition set while opening: geometry travels with the goo |
| 34 | + * split (500ms), the surface itself fades in fast (250ms), and the hairline |
| 35 | + * ring + elevation crisp in only after the liquid split settles (300ms fade, |
| 36 | + * 550ms delay). |
| 37 | + */ |
| 38 | +const CHAT_SURFACE_TRANSITION_OPEN = |
| 39 | + '[transition:height_500ms_ease-out,transform_500ms_ease-out,margin-bottom_500ms_ease-out,opacity_250ms_ease-out,box-shadow_300ms_ease-out_550ms]' |
| 40 | + |
| 41 | +/** |
| 42 | + * Chat-surface transition set while merging back: same geometry travel, but |
| 43 | + * the ring + elevation drop immediately and the surface fades out fast so the |
| 44 | + * goo ghost carries the collapse into the composer. |
| 45 | + */ |
| 46 | +const CHAT_SURFACE_TRANSITION_MERGE = |
| 47 | + '[transition:height_500ms_ease-out,transform_500ms_ease-out,margin-bottom_500ms_ease-out,opacity_250ms_ease-out,box-shadow_200ms_ease-out]' |
| 48 | + |
| 49 | +type BuildChatPhase = |
| 50 | + | 'idle' |
| 51 | + | 'typing' |
| 52 | + | 'thinking' |
| 53 | + | 'replyPreparing' |
| 54 | + | 'replying' |
| 55 | + | 'complete' |
| 56 | + | 'merging' |
| 57 | + |
| 58 | +/** |
| 59 | + * Decorative Build-card Mothership loop. It mirrors the studio choreography |
| 60 | + * with local timers and CSS transitions so the landing page avoids motion |
| 61 | + * dependencies while preserving the product-window feel. |
| 62 | + * |
| 63 | + * The composer is a permanent fixture; the send beat gooey-morphs the chat |
| 64 | + * surface out of it, and the loop's final beat gooey-merges it back down in. |
| 65 | + * An SVG goo filter (state-driven blur → alpha-contrast matrix → atop |
| 66 | + * composite) is applied to an aria-hidden ghost layer of two solid white |
| 67 | + * rects mirroring the real boxes (both measured with a ResizeObserver) — |
| 68 | + * never to the real UI, which stays crisp above it. The filtered silhouette |
| 69 | + * renders a liquid meniscus that necks and snaps as the boxes separate (and |
| 70 | + * re-welds as they merge); each morph's blur decays on a (1-t)^2 curve over |
| 71 | + * 850ms. The chat surface's ring + shadow crisp in only after the split and |
| 72 | + * drop instantly at the merge, so the ghost carries the collapse. |
| 73 | + */ |
| 74 | +export function BuildChatAnimation() { |
| 75 | + const [phase, setPhase] = useState<BuildChatPhase>('idle') |
| 76 | + const [typedChars, setTypedChars] = useState(0) |
| 77 | + const [revealedWords, setRevealedWords] = useState(0) |
| 78 | + const [reducedMotion, setReducedMotion] = useState(false) |
| 79 | + const [morphing, setMorphing] = useState(false) |
| 80 | + const [gooBlur, setGooBlur] = useState(0) |
| 81 | + const [chatContentH, setChatContentH] = useState(0) |
| 82 | + const [composerH, setComposerH] = useState(94) |
| 83 | + const chatContentRef = useRef<HTMLDivElement>(null) |
| 84 | + const composerRef = useRef<HTMLDivElement>(null) |
| 85 | + |
| 86 | + useEffect(() => { |
| 87 | + const content = chatContentRef.current |
| 88 | + const composer = composerRef.current |
| 89 | + if (!content || !composer) return |
| 90 | + const measure = () => { |
| 91 | + setChatContentH(content.offsetHeight) |
| 92 | + setComposerH(composer.offsetHeight) |
| 93 | + } |
| 94 | + measure() |
| 95 | + const observer = new ResizeObserver(measure) |
| 96 | + observer.observe(content) |
| 97 | + observer.observe(composer) |
| 98 | + return () => observer.disconnect() |
| 99 | + }, []) |
| 100 | + |
| 101 | + useEffect(() => { |
| 102 | + if (!morphing) return |
| 103 | + let progress = 0 |
| 104 | + const interval = setInterval(() => { |
| 105 | + progress += GOO_STEP_MS / GOO_DECAY_MS |
| 106 | + if (progress >= 1) { |
| 107 | + setGooBlur(0) |
| 108 | + setMorphing(false) |
| 109 | + clearInterval(interval) |
| 110 | + return |
| 111 | + } |
| 112 | + setGooBlur(GOO_MAX_BLUR * (1 - progress) ** 2) |
| 113 | + }, GOO_STEP_MS) |
| 114 | + return () => clearInterval(interval) |
| 115 | + }, [morphing]) |
| 116 | + |
| 117 | + useEffect(() => { |
| 118 | + const media = window.matchMedia('(prefers-reduced-motion: reduce)') |
| 119 | + const timeouts: ReturnType<typeof setTimeout>[] = [] |
| 120 | + const intervals: ReturnType<typeof setInterval>[] = [] |
| 121 | + |
| 122 | + const clearScheduled = () => { |
| 123 | + for (const timeout of timeouts) clearTimeout(timeout) |
| 124 | + for (const interval of intervals) clearInterval(interval) |
| 125 | + timeouts.length = 0 |
| 126 | + intervals.length = 0 |
| 127 | + } |
| 128 | + |
| 129 | + const showFinished = () => { |
| 130 | + setReducedMotion(true) |
| 131 | + setPhase('complete') |
| 132 | + setTypedChars(0) |
| 133 | + setRevealedWords(REPLY_WORDS.length) |
| 134 | + setMorphing(false) |
| 135 | + setGooBlur(0) |
| 136 | + } |
| 137 | + |
| 138 | + const startTyping = () => { |
| 139 | + setPhase('typing') |
| 140 | + const startedAt = performance.now() |
| 141 | + const interval = setInterval(() => { |
| 142 | + const elapsed = performance.now() - startedAt |
| 143 | + const nextChars = Math.min(Math.floor(elapsed / TYPE_CHAR_MS) + 1, PROMPT.length) |
| 144 | + setTypedChars(nextChars) |
| 145 | + if (nextChars >= PROMPT.length) clearInterval(interval) |
| 146 | + }, TYPE_CHAR_MS) |
| 147 | + intervals.push(interval) |
| 148 | + } |
| 149 | + |
| 150 | + const startReply = () => { |
| 151 | + setPhase('replying') |
| 152 | + const startedAt = performance.now() |
| 153 | + const interval = setInterval(() => { |
| 154 | + const elapsed = performance.now() - startedAt |
| 155 | + const nextWords = Math.min(Math.floor(elapsed / REPLY_WORD_MS) + 1, REPLY_WORDS.length) |
| 156 | + setRevealedWords(nextWords) |
| 157 | + if (nextWords >= REPLY_WORDS.length) { |
| 158 | + clearInterval(interval) |
| 159 | + setPhase('complete') |
| 160 | + } |
| 161 | + }, REPLY_WORD_MS) |
| 162 | + intervals.push(interval) |
| 163 | + } |
| 164 | + |
| 165 | + const runLoop = () => { |
| 166 | + clearScheduled() |
| 167 | + setReducedMotion(false) |
| 168 | + setPhase('idle') |
| 169 | + setTypedChars(0) |
| 170 | + setRevealedWords(0) |
| 171 | + setMorphing(false) |
| 172 | + setGooBlur(0) |
| 173 | + |
| 174 | + timeouts.push(setTimeout(startTyping, TYPE_START_MS)) |
| 175 | + timeouts.push( |
| 176 | + setTimeout(() => { |
| 177 | + setPhase('thinking') |
| 178 | + setTypedChars(0) |
| 179 | + setMorphing(true) |
| 180 | + setGooBlur(GOO_MAX_BLUR) |
| 181 | + }, SEND_MS) |
| 182 | + ) |
| 183 | + timeouts.push(setTimeout(() => setPhase('replyPreparing'), THINKING_DONE_MS)) |
| 184 | + timeouts.push(setTimeout(startReply, THINKING_DONE_MS + REPLY_START_DELAY_MS)) |
| 185 | + timeouts.push( |
| 186 | + setTimeout(() => { |
| 187 | + setPhase('merging') |
| 188 | + setMorphing(true) |
| 189 | + setGooBlur(GOO_MAX_BLUR) |
| 190 | + }, MERGE_MS) |
| 191 | + ) |
| 192 | + timeouts.push(setTimeout(runLoop, LOOP_MS)) |
| 193 | + } |
| 194 | + |
| 195 | + const syncMotionPreference = () => { |
| 196 | + clearScheduled() |
| 197 | + if (media.matches) { |
| 198 | + showFinished() |
| 199 | + return |
| 200 | + } |
| 201 | + runLoop() |
| 202 | + } |
| 203 | + |
| 204 | + syncMotionPreference() |
| 205 | + media.addEventListener('change', syncMotionPreference) |
| 206 | + return () => { |
| 207 | + media.removeEventListener('change', syncMotionPreference) |
| 208 | + clearScheduled() |
| 209 | + } |
| 210 | + }, []) |
| 211 | + |
| 212 | + const composerText = phase === 'typing' ? PROMPT.slice(0, typedChars) : '' |
| 213 | + const chatOpen = reducedMotion || !['idle', 'typing', 'merging'].includes(phase) |
| 214 | + const thinkingPhraseVisible = phase === 'thinking' |
| 215 | + const replyVisible = |
| 216 | + reducedMotion || phase === 'replyPreparing' || phase === 'replying' || phase === 'complete' |
| 217 | + const replyComplete = reducedMotion || phase === 'complete' |
| 218 | + const replyText = REPLY_WORDS.slice(0, reducedMotion ? REPLY_WORDS.length : revealedWords).join( |
| 219 | + ' ' |
| 220 | + ) |
| 221 | + |
| 222 | + return ( |
| 223 | + <div className='absolute inset-0 flex items-end justify-start p-10 max-sm:px-3 max-sm:py-0 max-lg:items-center max-lg:justify-center max-lg:px-8 max-lg:py-8'> |
| 224 | + <svg width='0' height='0' aria-hidden='true' className='absolute'> |
| 225 | + <defs> |
| 226 | + <filter id='build-callout-goo'> |
| 227 | + <feGaussianBlur in='SourceGraphic' stdDeviation={gooBlur} result='blur' /> |
| 228 | + <feColorMatrix |
| 229 | + in='blur' |
| 230 | + type='matrix' |
| 231 | + values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 19 -8' |
| 232 | + result='goo' |
| 233 | + /> |
| 234 | + <feComposite in='SourceGraphic' in2='goo' operator='atop' /> |
| 235 | + </filter> |
| 236 | + </defs> |
| 237 | + </svg> |
| 238 | + |
| 239 | + <div className='relative h-[310px] w-full max-w-[560px] max-sm:h-[270px] max-sm:max-w-[430px]'> |
| 240 | + {!reducedMotion && ( |
| 241 | + <div |
| 242 | + aria-hidden='true' |
| 243 | + className='pointer-events-none absolute inset-0 z-0 flex flex-col justify-end [filter:url(#build-callout-goo)_drop-shadow(0_1px_3px_rgba(28,40,64,0.10))]' |
| 244 | + > |
| 245 | + <div |
| 246 | + className={cn( |
| 247 | + 'w-full rounded-2xl bg-[var(--white)] transition-[height,transform,margin-bottom] duration-500 ease-out motion-reduce:transition-none', |
| 248 | + chatOpen ? 'translate-y-0' : 'translate-y-5' |
| 249 | + )} |
| 250 | + style={{ |
| 251 | + height: chatOpen ? chatContentH : 0, |
| 252 | + marginBottom: chatOpen ? SPLIT_GAP_PX : 0, |
| 253 | + }} |
| 254 | + /> |
| 255 | + <div className='w-full rounded-2xl bg-[var(--white)]' style={{ height: composerH }} /> |
| 256 | + </div> |
| 257 | + )} |
| 258 | + |
| 259 | + <div className='relative z-10 flex h-full flex-col justify-end'> |
| 260 | + <section |
| 261 | + className={cn( |
| 262 | + 'w-full overflow-hidden rounded-2xl bg-[var(--white)]', |
| 263 | + !reducedMotion && |
| 264 | + (chatOpen ? CHAT_SURFACE_TRANSITION_OPEN : CHAT_SURFACE_TRANSITION_MERGE), |
| 265 | + chatOpen |
| 266 | + ? 'translate-y-0 opacity-100 shadow-[0_24px_80px_color-mix(in_srgb,var(--text-primary)_14%,transparent),0_0_0_1px_var(--border-1)]' |
| 267 | + : 'translate-y-5 opacity-0 shadow-[0_24px_80px_transparent,0_0_0_1px_transparent]' |
| 268 | + )} |
| 269 | + style={{ |
| 270 | + height: chatOpen ? chatContentH : 0, |
| 271 | + marginBottom: chatOpen ? SPLIT_GAP_PX : 0, |
| 272 | + }} |
| 273 | + > |
| 274 | + <div ref={chatContentRef} className='flex flex-col gap-4 p-4 max-sm:gap-2.5 max-sm:p-3'> |
| 275 | + <div className='ml-auto max-w-[78%] rounded-lg bg-[var(--surface-5)] px-3 py-2 text-[15px] text-[var(--text-primary)] leading-[1.45] max-sm:max-w-[86%] max-sm:text-[12px] max-sm:leading-[1.35]'> |
| 276 | + {PROMPT} |
| 277 | + </div> |
| 278 | + |
| 279 | + <div className='relative min-h-[34px]'> |
| 280 | + <div |
| 281 | + className={cn( |
| 282 | + 'absolute inset-0 flex items-center transition-opacity duration-300 ease-out motion-reduce:transition-none', |
| 283 | + thinkingPhraseVisible ? 'opacity-100' : 'opacity-0' |
| 284 | + )} |
| 285 | + > |
| 286 | + <ThinkingLoader |
| 287 | + size={20} |
| 288 | + startVariant='corners' |
| 289 | + phase |
| 290 | + labelRatio={0.66} |
| 291 | + className='text-[var(--text-body)]' |
| 292 | + /> |
| 293 | + </div> |
| 294 | + |
| 295 | + <div |
| 296 | + className={cn( |
| 297 | + 'flex items-start gap-2.5 text-[15px] text-[var(--text-primary)] leading-[1.5] transition-opacity duration-300 ease-out motion-reduce:transition-none max-sm:text-[12px] max-sm:leading-[1.4]', |
| 298 | + replyVisible ? 'opacity-100' : 'opacity-0' |
| 299 | + )} |
| 300 | + > |
| 301 | + <span className='relative mt-0.5 size-[20px] shrink-0'> |
| 302 | + <span |
| 303 | + className={cn( |
| 304 | + 'absolute inset-0 flex items-center justify-center transition-opacity duration-200 ease-out motion-reduce:transition-none', |
| 305 | + replyComplete ? 'opacity-0' : 'opacity-100' |
| 306 | + )} |
| 307 | + > |
| 308 | + <ThinkingLoader size={20} startVariant='corners' /> |
| 309 | + </span> |
| 310 | + <span |
| 311 | + className={cn( |
| 312 | + 'absolute inset-0 flex items-center justify-center transition-opacity duration-200 ease-out motion-reduce:transition-none', |
| 313 | + replyComplete ? 'opacity-100' : 'opacity-0' |
| 314 | + )} |
| 315 | + > |
| 316 | + <Blimp className='size-[18px] text-[var(--text-icon)]' /> |
| 317 | + </span> |
| 318 | + </span> |
| 319 | + <p className='flex-1'>{replyText}</p> |
| 320 | + </div> |
| 321 | + </div> |
| 322 | + </div> |
| 323 | + </section> |
| 324 | + |
| 325 | + <div |
| 326 | + ref={composerRef} |
| 327 | + className='h-[94px] w-full rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 shadow-[0_18px_60px_color-mix(in_srgb,var(--text-primary)_12%,transparent)]' |
| 328 | + > |
| 329 | + <p |
| 330 | + className={cn( |
| 331 | + 'min-h-[42px] px-1.5 pt-1 text-[15px] leading-[1.35] max-sm:text-[13px]', |
| 332 | + composerText ? 'text-[var(--text-primary)]' : 'text-[var(--text-muted)]' |
| 333 | + )} |
| 334 | + > |
| 335 | + {composerText || 'Send message to Sim'} |
| 336 | + </p> |
| 337 | + <div className='flex items-center gap-1.5'> |
| 338 | + <span className='flex size-[28px] items-center justify-center rounded-full'> |
| 339 | + <Plus className='size-[16px] text-[var(--text-icon)]' /> |
| 340 | + </span> |
| 341 | + <span className='flex size-[28px] items-center justify-center rounded-full'> |
| 342 | + <Paperclip className='size-[16px] text-[var(--text-icon)]' /> |
| 343 | + </span> |
| 344 | + <span className='flex size-[28px] items-center justify-center rounded-full'> |
| 345 | + <Slash className='size-[16px] text-[var(--text-icon)]' /> |
| 346 | + </span> |
| 347 | + <span className='ml-auto flex items-center gap-1.5'> |
| 348 | + <span className='flex size-[28px] items-center justify-center rounded-full'> |
| 349 | + <Mic className='size-[16px] text-[var(--text-icon)]' /> |
| 350 | + </span> |
| 351 | + <span className='flex size-[28px] items-center justify-center rounded-full bg-[#808080]'> |
| 352 | + <ArrowUp className='size-[16px] text-[var(--surface-1)]' /> |
| 353 | + </span> |
| 354 | + </span> |
| 355 | + </div> |
| 356 | + </div> |
| 357 | + </div> |
| 358 | + </div> |
| 359 | + </div> |
| 360 | + ) |
| 361 | +} |