# Product-launch film with chapter scrubber

> A self-contained product-launch video — no .mp4, no player library — recreated as pure React scenes on a single rAF clock. A chapter track at the bottom (Hook · Just ask · Agent at work · Result · Integrations · Anywhere) doubles as a click-and-drag scrubber, with a live timestamp bubble riding the playhead. Autoplays, loops, and seeks.

- **Category:** Marketing
- **Source (best-in-class):** [Alpaca Labs launch film](https://claude.ai)
- **Tags:** launch, video, promo, film, hero, marketing, timeline, scrubber, chapters, scenes, border-beam, requestAnimationFrame, autoplay, product-tour
- **Dependencies:** react
- **Status:** stable
- **Live demo:** https://eito.me/ui/launch-film

## Problem

A launch page wants the feel of a slick promo video, but a real video file is a heavy download, blurs on retina, can't be themed or restyled after export, and needs a re-render for every copy tweak. This pattern authors the whole film as data: an ordered list of chapters, each with a duration, a timestamp, and a live React scene. One requestAnimationFrame clock advances a normalized playhead; the current chapter cross-fades in and animates from its own in-chapter progress, and the labelled chapter track below is a real scrubber you can click or drag to seek. Everything is crisp at any width, restyleable in code, and weighs a few kilobytes.

## When to use

- A landing hero or launch announcement wants an autoplaying, looping product tour without shipping (and re-exporting) a video file.
- You want the promo to stay pixel-crisp on retina and be restyleable/themeable in code rather than baked into a bitmap.
- The scenes should be real, inspectable UI (a composer, an agent turn, an integrations grid, a phone) that you can keep in sync with the product.
- You want a scrubbable chapter timeline — labelled segments a viewer can click or drag to jump between moments — not just a linear play bar.

## Code — snippet.tsx

```tsx
import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
} from "react";

/**
 * LaunchFilm — a product-launch promo "video" with no video file.
 *
 * The whole film is authored as DATA: an ordered list of chapters, each with a
 * label, a duration (seconds), a timestamp caption and a live React scene. A
 * single requestAnimationFrame clock advances a normalized playhead across the
 * total runtime; the chapter the playhead lands in is cross-faded in and handed
 * its own in-chapter progress (0→1) so each scene can animate itself. The
 * labelled chapter track along the bottom is a real scrubber — click or drag it
 * to seek — with a timestamp bubble that rides the playhead.
 *
 * Because it's React + CSS the whole thing stays crisp at any width (it scales
 * from one `width` prop, like a device mock), is restyleable/themeable in code,
 * and weighs a few kilobytes instead of a multi-megabyte bitmap movie.
 *
 * Deps: react only. Everything below is inline SVG + inline styles + one
 * injected <style> block for the keyframes, so the snippet is self-contained.
 */

/* ---- logical canvas (scaled to `width` by one transform) ------------------ */

const STAGE_W = 920;
const STAGE_H = 516;
const TRACK_H = 104;
const GAP = 14;
const CANVAS_W = STAGE_W;
const CANVAS_H = STAGE_H + GAP + TRACK_H;

/* ---- palette (light product UI on a soft stage) --------------------------- */

const c = {
  stage: "#f3f3f2",
  card: "#ffffff",
  ink: "#1c1c1a",
  sub: "#6b6b66",
  faint: "#9a9a94",
  line: "#ececea",
  line2: "#e2e2df",
  chip: "#f5f5f3",
  accent: "#2f6df6",
  accentSoft: "#eaf1ff",
  green: "#12a150",
  segIdle: "#e7e7e4",
  segActive: "#cfcfca",
  playhead: "#111110",
} as const;

const SANS =
  '"Archivo", "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif';
const MONO = 'ui-monospace, "SF Mono", Menlo, monospace';

const fmt = (s: number) => {
  const m = Math.floor(s / 60);
  const sec = Math.floor(s % 60);
  return `${m}:${sec.toString().padStart(2, "0")}`;
};

/* ---- shared bits ---------------------------------------------------------- */

/** The launch mark — a rounded sunburst asterisk. */
function Mark({ size = 22, color = c.ink }: { size?: number; color?: string }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
      {Array.from({ length: 8 }).map((_, i) => (
        <rect
          key={i}
          x="11"
          y="2.5"
          width="2"
          height="8"
          rx="1"
          fill={color}
          transform={`rotate(${i * 45} 12 12)`}
        />
      ))}
    </svg>
  );
}

/** A framed browser/app card the scenes sit inside. */
function Frame({
  children,
  style,
  pad = 0,
}: {
  children: ReactNode;
  style?: CSSProperties;
  pad?: number;
}) {
  return (
    <div
      style={{
        position: "absolute",
        inset: 0,
        borderRadius: 22,
        background: c.card,
        boxShadow: "0 26px 60px -30px rgba(20,20,18,0.45), 0 2px 0 rgba(20,20,18,0.02)",
        overflow: "hidden",
        padding: pad,
        ...style,
      }}
    >
      {children}
    </div>
  );
}

/** Reveal a block once its scene's progress passes `at` (0→1). */
function Reveal({
  p,
  at,
  children,
  y = 10,
  dur = 0.12,
}: {
  p: number;
  at: number;
  children: ReactNode;
  y?: number;
  dur?: number;
}) {
  const t = Math.max(0, Math.min(1, (p - at) / dur));
  const e = t * t * (3 - 2 * t); // smoothstep
  return (
    <div style={{ opacity: e, transform: `translateY(${(1 - e) * y}px)`, willChange: "opacity, transform" }}>
      {children}
    </div>
  );
}

/**
 * BorderBeam — a bright comet of light that travels around a rounded border,
 * over a hairline static edge (Jakub Antalik's beam, in a light-mode palette).
 *
 * A big conic-gradient layer sits behind the content and rotates; the content
 * card, inset by `border`, masks the centre so only a thin ring of the conic
 * shows — the rotating bright arc reads as a beam sweeping the perimeter. The
 * transparent stops of the conic reveal the faint static border underneath.
 */
function BorderBeam({
  children,
  radius = 18,
  border = 2,
  duration = 4.5,
  style,
}: {
  children: ReactNode;
  radius?: number;
  border?: number;
  duration?: number;
  style?: CSSProperties;
}) {
  return (
    <div style={{ position: "relative", borderRadius: radius, padding: border, overflow: "hidden", background: c.line2, ...style }}>
      <div
        className="lf-beam"
        style={{
          position: "absolute",
          top: "50%",
          left: "50%",
          width: 1100,
          height: 1100,
          transform: "translate(-50%, -50%)",
          animationDuration: `${duration}s`,
          background:
            "conic-gradient(from 0deg, transparent 0deg, transparent 258deg, rgba(168,85,247,0) 284deg, #a855f7 312deg, #3b82f6 338deg, #67e8f9 353deg, transparent 360deg)",
        }}
        aria-hidden="true"
      />
      <div style={{ position: "relative", zIndex: 1, borderRadius: radius - border, background: c.card, overflow: "hidden" }}>
        {children}
      </div>
    </div>
  );
}

/* ---- SCENES --------------------------------------------------------------- */
/* Each scene receives `p` — its own progress 0→1 within the chapter — so it can
   animate independently of the film clock. */

/** 01 · Hook — title card that resolves out of the mark. */
function SceneHook({ p }: { p: number }) {
  const rise = Math.min(1, p / 0.5);
  const e = rise * rise * (3 - 2 * rise);
  return (
    <Frame style={{ background: "radial-gradient(120% 80% at 50% 0%, #fafafa, #f0f0ee)" }}>
      <div
        style={{
          position: "absolute",
          inset: 0,
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
          gap: 22,
        }}
      >
        <div style={{ transform: `scale(${0.9 + e * 0.1})`, opacity: e }}>
          <Mark size={54} />
        </div>
        <div style={{ textAlign: "center", opacity: e, transform: `translateY(${(1 - e) * 12}px)` }}>
          <div style={{ fontFamily: SANS, fontSize: 40, fontWeight: 700, color: c.ink, letterSpacing: -1 }}>
            Meet Alpaca
          </div>
          <div style={{ fontFamily: SANS, fontSize: 18, color: c.sub, marginTop: 8 }}>
            The agent that just gets it done.
          </div>
        </div>
        <Reveal p={p} at={0.55} y={8}>
          <div
            style={{
              fontFamily: MONO,
              fontSize: 11,
              letterSpacing: 2,
              textTransform: "uppercase",
              color: c.faint,
              border: `1px solid ${c.line2}`,
              borderRadius: 999,
              padding: "7px 14px",
            }}
          >
            A 30-second tour
          </div>
        </Reveal>
      </div>
    </Frame>
  );
}

/** A macOS-style pointer cursor. */
function Cursor({ size = 26 }: { size?: number }) {
  return (
    <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true" style={{ filter: "drop-shadow(0 2px 3px rgba(0,0,0,0.35))" }}>
      <path d="M5 3 L5 19 L9.2 15 L12 21 L15 19.6 L12.2 13.8 L18 13.8 Z" fill="#fff" stroke="#1c1c1a" strokeWidth="1.3" strokeLinejoin="round" />
    </svg>
  );
}

/**
 * 02 · Just ask — the dark composer with the prompt typing itself in. Shot A
 * pushes into the textbox as it types; the pan + Shot B re-frame the SAME
 * composer onto its send button, which a cursor clicks (press + ripple + ✓).
 * The turn itself plays out in the next chapter, Agent at work.
 */

// Send-button centre within the 876×472 stage body (derived from the layout:
// a centred 620px composer with the 30px send button at its bottom-right).
// The close-up camera scales about this point, then translates it back to
// stage centre so the button lands in the middle of frame.
const SEND_X = 711;
const SEND_Y = 210;
const SEND_ZOOM = 2.9;
const BODY_CX = 438; // 876 / 2
const BODY_CY = 236; // 472 / 2

/** The composer card — shared verbatim by both shots so the cut reads as one continuous UI. */
function JustAskComposer({
  typed,
  done,
  sent,
  press,
}: {
  typed: string;
  done: boolean;
  sent: boolean;
  press: number;
}) {
  return (
    <div style={{ borderRadius: 18, border: `1px solid ${done || sent ? "rgba(127,242,238,0.45)" : "#2a3134"}`, background: "#14191b", boxShadow: done || sent ? "0 0 0 1px rgba(127,242,238,0.22), 0 16px 38px -20px rgba(0,0,0,0.8)" : "0 16px 38px -20px rgba(0,0,0,0.8)", transition: "border-color .2s, box-shadow .2s" }}>
      <div style={{ padding: "18px 20px" }}>
      <div style={{ fontFamily: SANS, fontSize: 16, color: typed ? "#eef4f4" : "#6b7678", minHeight: 22 }}>
        {typed || "Ask anything…"}
        {!done && (
          <span className="lf-caret" style={{ display: "inline-block", width: 2, height: 17, background: "#7ff2ee", marginLeft: 1, verticalAlign: -2 }} />
        )}
      </div>
      <div style={{ display: "flex", alignItems: "center", gap: 14, marginTop: 22, fontFamily: SANS, fontSize: 13, color: "#9ba4a6" }}>
        <span style={{ fontSize: 18, color: "#6b7678" }}>＋</span>
        <span>⌘</span>
        <span style={{ display: "flex", alignItems: "center", gap: 5 }}>
          <span style={{ color: "#7ff2ee" }}>✦</span> Super Computer
          <em style={{ color: "#7ff2ee", fontStyle: "normal", fontSize: 11, background: "rgba(127,242,238,0.1)", borderRadius: 6, padding: "1px 6px" }}>New</em>
        </span>
        <span style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
          <span style={{ width: 15, height: 15, display: "inline-flex" }} dangerouslySetInnerHTML={{ __html: CLAUDE_ICON }} />
          <span style={{ color: "#e8eeee" }}>Opus 4.7</span>
          <span style={{ color: "#6b7678", fontSize: 11 }}>▾</span>
        </span>
        <span
          style={{
            display: "inline-flex",
            alignItems: "center",
            justifyContent: "center",
            width: 30,
            height: 30,
            borderRadius: 999,
            background: done || sent ? "#7ff2ee" : "#2a3134",
            color: done || sent ? "#062a28" : "#6b7678",
            transition: "background .2s",
            transform: `scale(${press})`,
          }}
        >
          {sent ? "✓" : "↑"}
        </span>
      </div>
      </div>
    </div>
  );
}

/** Header + composer + quick cards — the full "Just ask" UI, without any camera. */
function JustAskBody({
  p,
  typed,
  done,
  sent,
  press,
}: {
  p: number;
  typed: string;
  done: boolean;
  sent: boolean;
  press: number;
}) {
  return (
    <div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", paddingTop: 46 }}>
      <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 6 }}>
        <Mark size={26} color="#eef4f4" />
        <div style={{ fontFamily: SANS, fontSize: 22, fontWeight: 600, color: "#eef4f4" }}>
          Where should we begin?
        </div>
      </div>

      {/* composer */}
      <div style={{ width: 620, marginTop: 26 }}>
        <JustAskComposer typed={typed} done={done} sent={sent} press={press} />
      </div>

      {/* quick cards */}
      <Reveal p={p} at={0.08} y={14}>
        <div style={{ display: "flex", gap: 14, marginTop: 20 }}>
          {[
            { t: "Create", s: "Tasks, Images, Docs" },
            { t: "Find", s: "Answers & Files" },
            { t: "Research", s: "Apps & Web" },
          ].map((q) => (
            <div key={q.t} style={{ width: 196, background: "#14191b", border: "1px solid #2a3134", borderRadius: 14, padding: "16px 18px", boxShadow: "0 8px 22px -18px rgba(0,0,0,0.8)" }}>
              <div style={{ fontSize: 16, color: "#6b7678" }}>◎</div>
              <div style={{ fontFamily: SANS, fontSize: 15, fontWeight: 600, color: "#eef4f4", marginTop: 22 }}>{q.t}</div>
              <div style={{ fontFamily: SANS, fontSize: 12.5, color: "#6b7678", marginTop: 3 }}>{q.s}</div>
            </div>
          ))}
        </div>
      </Reveal>
    </div>
  );
}

/* ---- the agent's turn, as film -------------------------------------------------
   Shot C plays one full turn à la agent-thinking-chat — orb + shimmer status,
   context-source stack, reel counter, blur-streamed answer — with every bit of
   motion re-derived from chapter progress so the turn scrubs cleanly. */

/* Inline SVG marks for tool calls, as in agent-thinking-chat: `mcp` is the Model
   Context Protocol glyph (currentColor, tints to the accent); `gmail` and
   `seal` are connected-app logos on a white badge. */
const TOOL_ICONS = {
  mcp: '<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24"><path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"/><path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"/></svg>',
  gmail:
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path fill="#4caf50" d="M45 16.2l-5 2.75-5 4.75L35 40h7c1.657 0 3-1.343 3-3V16.2z"/><path fill="#1e88e5" d="M3 16.2l3.614 1.71L13 23.7V40H6c-1.657 0-3-1.343-3-3V16.2z"/><polygon fill="#e53935" points="35,11.2 24,19.45 13,11.2 12,17 13,23.7 24,31.95 35,23.7 36,17"/><path fill="#c62828" d="M3 12.298V16.2l10 7.5V11.2L9.876 8.859C9.132 8.301 8.228 8 7.298 8 4.924 8 3 9.924 3 12.298z"/><path fill="#fbc02d" d="M45 12.298V16.2l-10 7.5V11.2l3.124-2.341C38.868 8.301 39.772 8 40.702 8 43.076 8 45 9.924 45 12.298z"/></svg>',
  seal: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="14 11 483 483"><rect width="512" height="512" fill="#F9F9F9"/><g transform="translate(-28 0)"><g transform="translate(58 190) rotate(-18)"><rect x="0" y="0" width="92" height="58" rx="7" fill="#F9F9F9" stroke="#0A0E14" stroke-width="8"/><path d="M6 8 L46 36 L86 8" fill="none" stroke="#0A0E14" stroke-width="5"/></g><path d="M385 280C420 272 437 244 443 212C450 180 470 178 480 198C488 214 479 231 464 243C482 238 503 242 508 258C513 276 495 290 476 294C459 298 444 296 432 291C427 315 416 337 397 351C389 357 379 354 376 345C372 333 379 314 392 298C386 293 383 287 385 280 Z" fill="#9BA4A6" stroke="#0A0E14" stroke-width="8"/><path d="M167 278C137 270 114 252 106 229C98 207 104 189 118 181C134 172 151 180 160 195C170 210 168 226 160 239C174 242 184 249 190 260C195 270 185 282 167 278 Z" fill="#9BA4A6" stroke="#0A0E14" stroke-width="8"/><path d="M162 194C175 165 196 148 222 148H332C357 148 377 165 390 194H367C351 194 340 201 330 214L317 231C309 241 299 246 286 246H276C263 246 253 241 245 231L232 214C222 201 211 194 195 194Z" fill="#0A0E14"/><rect x="226" y="106" width="128" height="92" rx="10" fill="#F9F9F9" stroke="#0A0E14" stroke-width="8"/><rect x="191" y="136" width="150" height="99" rx="12" fill="#F9F9F9" stroke="#0A0E14" stroke-width="8"/><path d="M199 146 L266 195 L333 146" fill="none" stroke="#0A0E14" stroke-width="8"/><path d="M156 194H204C218 194 228 200 237 212L250 229C258 240 268 245 281 245H291C304 245 314 240 322 229L335 212C344 200 354 194 368 194H402V317C402 353 376 375 340 375H205C169 375 143 353 143 317V214C143 203 148 197 156 194 Z" fill="#9BA4A6" stroke="#0A0E14" stroke-width="8"/><path d="M384 321C403 328 420 343 428 360C435 375 431 390 419 397C404 405 387 399 377 385C368 372 366 359 371 348C363 342 362 332 367 326C371 321 377 318 384 321 Z" fill="#9BA4A6" stroke="#0A0E14" stroke-width="8"/><ellipse cx="229" cy="277" rx="13" ry="23" fill="#0A0E14"/><ellipse cx="307" cy="277" rx="13" ry="23" fill="#0A0E14"/><circle cx="268" cy="304" r="9" fill="#0A0E14"/><path d="M252 322 Q268 338 284 322" fill="none" stroke="#0A0E14" stroke-width="5"/></g><circle cx="439" cy="115" r="11" fill="#C3FFFD"/></svg>',
} as const;

/* The reply/model avatar — the Opus mark, as in agent-thinking-chat. */
const CLAUDE_ICON =
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 257"><path fill="#D97757" d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"/></svg>';

const ASK_SOURCES: { name: string; tag: string; icon?: keyof typeof TOOL_ICONS }[] = [
  { name: "launch-plan.md", tag: "read" },
  { name: "Landing.tsx", tag: "read" },
  { name: "gmail · launch threads", tag: "mcp", icon: "gmail" },
  { name: "waitlist.csv", tag: "grep" },
  { name: "sealgate · call_tool", tag: "mcp", icon: "seal" },
  { name: "mcp · list_tools", tag: "mcp", icon: "mcp" },
];
const ASK_SCANNED = 64;
const ASK_ANSWER =
  "Done — your launch plan is drafted and the landing page is built. Three phases, waitlist wired up, launch-day checklist included. Want me to tighten the hero copy next?";
// shimmer line boundaries within shot C (0→1): last entry pins the end.
const ASK_LINES: { t: number; s: string }[] = [
  { t: 0.0, s: "Thinking…" },
  { t: 0.1, s: "Reading your message…" },
  { t: 0.2, s: "Planning the approach…" },
  { t: 0.34, s: "Collecting context…" },
  { t: 0.44, s: "Calling connected tools…" },
  { t: 0.54, s: "Reading relevant files…" },
  { t: 0.74, s: "Writing the answer…" },
];

/** Dotted orb avatar — ring rotation + core pulse driven by progress. */
function AskOrb({ q }: { q: number }) {
  const rot = q * 300;
  const pulse = 1 + 0.12 * Math.sin(q * 22);
  return (
    <span style={{ position: "relative", width: 26, height: 26, flex: "none", display: "inline-block" }}>
      {Array.from({ length: 8 }).map((_, i) => {
        const a = ((rot + i * 45) * Math.PI) / 180;
        return (
          <span
            key={i}
            style={{
              position: "absolute",
              left: 13 + 9.5 * Math.cos(a) - 1.75,
              top: 13 + 9.5 * Math.sin(a) - 1.75,
              width: 3.5,
              height: 3.5,
              borderRadius: 999,
              background: "#7ff2ee",
              opacity: 0.3 + 0.7 * (((i / 8 + q * 2) % 1 + 1) % 1),
            }}
          />
        );
      })}
      <span style={{ position: "absolute", left: 13 - 4 * pulse, top: 13 - 4 * pulse, width: 8 * pulse, height: 8 * pulse, borderRadius: 999, background: "#7ff2ee", boxShadow: "0 0 12px rgba(127,242,238,0.8)" }} />
    </span>
  );
}

/** Shimmer status lines — stacked, swapping with blur/rise; sheen sweeps with progress. */
function AskShimmer({ q }: { q: number }) {
  const longest = ASK_LINES.reduce((a, b) => (b.s.length > a.length ? b.s : a), "");
  const sheen = (q * 500) % 100;
  return (
    <span style={{ position: "relative", display: "inline-block", fontFamily: MONO, fontSize: 13 }}>
      <span style={{ visibility: "hidden", whiteSpace: "nowrap" }}>{longest}</span>
      {ASK_LINES.map((l, k) => {
        const t1 = k + 1 < ASK_LINES.length ? ASK_LINES[k + 1].t : 2;
        const enter = Math.max(0, Math.min(1, (q - l.t) / 0.05));
        const exit = Math.max(0, Math.min(1, (q - (t1 - 0.05)) / 0.05));
        const vis = enter * (1 - exit);
        const blur = (1 - enter + exit) * 2;
        return (
          <span
            key={l.s}
            style={{
              position: "absolute",
              top: 0,
              left: 0,
              right: 0,
              whiteSpace: "nowrap",
              opacity: vis,
              transform: `translateY(${(1 - enter) * 8 - exit * 8}px)`,
              filter: `blur(${blur}px)`,
              color: "transparent",
              backgroundImage: "linear-gradient(100deg,#7f8b8d 35%,#e0fffe 50%,#7f8b8d 65%)",
              backgroundSize: "220% 100%",
              backgroundPosition: `${sheen}% 0`,
              WebkitBackgroundClip: "text",
              backgroundClip: "text",
            }}
          >
            {l.s}
          </span>
        );
      })}
    </span>
  );
}

/** Sonner-style stack of context sources — newest on top, older ones receding
 * with the reference peek/scale/fade; hover spreads the stack into the
 * reserved headroom, exactly like agent-thinking-chat. */
function AskStack({ q }: { q: number }) {
  const [spread, setSpread] = useState(false);
  const appear = ASK_SOURCES.map((_, i) => 0.32 + i * 0.07);
  const visible = ASK_SOURCES.map((_, i) => i).filter((i) => q >= appear[i]);
  const shown = visible.slice(-4);
  return (
    <div style={{ paddingTop: 56 }} onMouseEnter={() => setSpread(true)} onMouseLeave={() => setSpread(false)}>
      <div style={{ position: "relative", height: 46 }}>
        {shown.map((idx, s) => {
          const depth = shown.length - 1 - s;
          const t = Math.max(0, Math.min(1, (q - appear[idx]) / 0.07));
          const e = t * t * (3 - 2 * t);
          const leaving = depth === 3;
          const fade = leaving ? Math.max(0, 1 - (q - appear[idx] - 0.12) * 7) : 1 - depth * 0.42;
          const item = ASK_SOURCES[idx];
          const rest = leaving
            ? `translateY(${-3 * 9}px) scale(${1 - 3 * 0.05})`
            : spread && depth > 0
              ? `translateY(calc(${-depth} * (100% + 8px))) scale(1)`
              : `translateY(${(1 - e) * 34 - depth * 9}px) scale(${1 - depth * 0.05})`;
          return (
            <div
              key={idx}
              style={{
                position: "absolute",
                left: 0,
                right: 0,
                bottom: 0,
                zIndex: leaving ? 0 : 3 - depth,
                transformOrigin: "50% 100%",
                opacity: (spread && depth > 0 ? 1 : e * fade),
                transform: rest,
                filter: `blur(${(spread && depth > 0 ? 0 : 1 - e) * 2}px)`,
                transition: "transform .35s cubic-bezier(0.22,1,0.36,1), opacity .35s cubic-bezier(0.22,1,0.36,1)",
              }}
            >
              <div style={{ display: "flex", alignItems: "center", gap: 8, background: "#0a0d0e", border: "1px solid rgba(195,255,253,0.15)", borderRadius: 8, padding: "8px 12px", boxShadow: "0 4px 16px rgba(0,0,0,0.4)" }}>
                {item.icon === "mcp" ? (
                  <span style={{ width: 18, height: 18, flex: "none", display: "inline-flex", color: "#7ff2ee" }}>
                    <span style={{ width: 18, height: 18, display: "inline-flex" }} dangerouslySetInnerHTML={{ __html: TOOL_ICONS.mcp }} />
                  </span>
                ) : item.icon ? (
                  <span style={{ width: 18, height: 18, flex: "none", display: "inline-flex", alignItems: "center", justifyContent: "center", overflow: "hidden", borderRadius: 5, background: "#fff", boxShadow: "inset 0 0 0 1px rgba(10,14,20,0.1)" }}>
                    <span style={{ width: 18, height: 18, display: "inline-flex" }} dangerouslySetInnerHTML={{ __html: TOOL_ICONS[item.icon] }} />
                  </span>
                ) : (
                  <span style={{ width: 6, height: 6, flex: "none", borderRadius: 999, background: "#3ddbd3", marginLeft: 6, marginRight: 6 }} />
                )}
                <span style={{ fontFamily: MONO, fontSize: 12, color: "#e8eeee", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{item.name}</span>
                <span style={{ marginLeft: "auto", fontFamily: MONO, fontSize: 9, letterSpacing: 1.5, textTransform: "uppercase", color: "rgba(127,242,238,0.7)", border: "1px solid rgba(127,242,238,0.15)", borderRadius: 4, padding: "2px 6px" }}>{item.tag}</span>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

/** Digit-reel counter — columns spin to their final digit with a vertical motion
 * blur that swells mid-spin and decays as each column lands, like the
 * reference. Render-driven (no rAF), so it scrubs cleanly. */
function AskReel({ value, q }: { value: number; q: number }) {
  const digits = String(value).split("");
  const CELL = 28;
  const SPINS = 4;
  const rawId = useId();
  const uid = `ask-reel-${rawId.replace(/[^a-zA-Z0-9]/g, "")}`;
  const col = (c: number) => {
    const t = Math.max(0, Math.min(1, (q - 0.32 - c * 0.09) / 0.5));
    const e = t * t * (3 - 2 * t);
    return { e, env: Math.sin(t * Math.PI) }; // 0→1→0 streak envelope
  };
  return (
    <span style={{ display: "inline-flex", alignItems: "center", height: CELL, color: "#7ff2ee" }}>
      <svg width={0} height={0} aria-hidden="true" style={{ position: "absolute" }}>
        <defs>
          {digits.map((_, c) => (
            <filter key={c} id={`${uid}-${c}`} x="-50%" y="-50%" width="200%" height="200%">
              <feGaussianBlur stdDeviation={`0 ${(3 * col(c).env).toFixed(2)}`} />
            </filter>
          ))}
        </defs>
      </svg>
      {digits.map((d, c) => (
        <span
          key={c}
          style={{
            position: "relative",
            height: CELL,
            width: 17,
            overflow: "hidden",
            WebkitMaskImage: "linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%)",
            maskImage: "linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%)",
          }}
        >
          <span
            style={{
              display: "flex",
              flexDirection: "column",
              transform: `translateY(${-col(c).e * (SPINS * 10 + Number(d)) * CELL}px)`,
              filter: `url(#${uid}-${c})`,
            }}
          >
            {Array.from({ length: SPINS * 10 + 10 }, (_, i) => (
              <span key={i} style={{ height: CELL, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: MONO, fontSize: 17, fontWeight: 600 }}>
                {i % 10}
              </span>
            ))}
          </span>
        </span>
      ))}
    </span>
  );
}

/** Word-by-word answer reveal through opacity + blur. */
function AskStream({ text, q }: { text: string; q: number }) {
  const words = text.split(" ");
  const f = Math.max(0, Math.min(1, (q - 0.78) / 0.2)) * words.length;
  return (
    <div style={{ fontFamily: SANS, fontSize: 13.5, lineHeight: 1.65, color: "#e8eeee" }}>
      {words.map((w, i) => {
        const r = Math.max(0, Math.min(1, (f - i) * 0.8));
        return (
          <span key={i} style={{ opacity: r, filter: `blur(${(1 - r) * 3}px)` }}>
            {w}{i < words.length - 1 ? " " : ""}
          </span>
        );
      })}
    </div>
  );
}

/** Shot C — the working card (orb + shimmer, stack, reel) with the streamed answer below. */
function AskWorking({ qc }: { qc: number }) {
  const entry = Math.min(1, qc / 0.08);
  const ee = entry * entry * (3 - 2 * entry);
  const answerIn = Math.max(0, Math.min(1, (qc - 0.74) / 0.08));
  return (
    <Frame style={{ background: "#0e1113" }}>
      <div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", opacity: ee, transform: `translateY(${(1 - ee) * 14}px) scale(${1.04 - ee * 0.04})` }}>
        {/* working card, framed by a travelling-light ring for the liquid-metal read */}
        <div style={{ position: "relative", width: 640, borderRadius: 16, padding: 2, overflow: "hidden", background: "#1b2124" }}>
          <div
            className="lf-beam"
            style={{
              position: "absolute",
              top: "50%",
              left: "50%",
              width: 1100,
              height: 1100,
              transform: "translate(-50%, -50%)",
              animationDuration: "6s",
              background:
                "conic-gradient(from 0deg, transparent 0deg, transparent 262deg, rgba(127,242,238,0) 288deg, rgba(127,242,238,0.95) 318deg, #a855f7 342deg, transparent 360deg)",
            }}
            aria-hidden="true"
          />
          <div style={{ position: "relative", zIndex: 1, borderRadius: 14, background: "#0a0d0e", padding: "14px 16px", overflow: "hidden" }}>
            <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
              <AskOrb q={qc} />
              <AskShimmer q={qc} />
              <span style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 7, fontFamily: MONO, fontSize: 10, letterSpacing: 1, color: "#6b7678", whiteSpace: "nowrap" }}>
                <AskReel value={ASK_SCANNED} q={qc} /> SCANNED
              </span>
            </div>
            <AskStack q={qc} />
          </div>
        </div>
        <div style={{ width: 640, marginTop: 14, display: "flex", gap: 10, opacity: answerIn }}>
          <span style={{ width: 26, height: 26, flex: "none", borderRadius: 999, border: "1px solid rgba(195,255,253,0.15)", background: "rgba(195,255,253,0.05)", color: "#eef4f4", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
            <span style={{ width: 15, height: 15, display: "inline-flex" }} dangerouslySetInnerHTML={{ __html: CLAUDE_ICON }} />
          </span>
          <div style={{ flex: 1, minWidth: 0 }}>
            <div style={{ fontFamily: MONO, fontSize: 10.5, letterSpacing: 0.5, color: "rgba(155,164,166,0.75)", marginBottom: 5 }}>Thought for 3s</div>
            <AskStream text={ASK_ANSWER} q={qc} />
          </div>
        </div>
      </div>
    </Frame>
  );
}

function SceneJustAsk({ p }: { p: number }) {
  const prompt = "Draft the launch plan and build the landing page.";
  const typeFrac = Math.max(0, Math.min(1, (p - 0.1) / 0.35));
  const chars = Math.floor(typeFrac * prompt.length);
  const typed = prompt.slice(0, chars);
  const done = chars >= prompt.length;

  const CUT = 0.5; // camera pans to the send-button close-up once typing is done
  const PANW = 0.12; // pan duration (chapter-p)

  /* ---- PAN · glide from the typing framing to the button close-up ----
     Same composer, one continuous camera move: the focus point travels from
     the prompt (370,175) to the send button while the zoom rides 1.6→2.9x.
     Endpoints match Shot A / Shot B exactly, so the joins are seamless. */
  const panT = Math.max(0, Math.min(1, (p - CUT) / PANW));
  if (panT > 0 && panT < 1) {
    const pe = panT * panT * (3 - 2 * panT);
    const fx = 370 + (SEND_X - 370) * pe;
    const fy = 175 + (SEND_Y - 175) * pe;
    const zoom = 1.6 * Math.pow(SEND_ZOOM / 1.6, pe);
    const tx = (BODY_CX - 370) + (370 - SEND_X) * pe;
    const ty = (BODY_CY - 175) + (175 - SEND_Y) * pe;
    return (
      <Frame style={{ background: "#0e1113" }}>
        <div style={{ position: "absolute", inset: 0, overflow: "hidden" }}>
          <div style={{ position: "absolute", inset: 0, transform: `translate(${tx}px, ${ty}px)` }}>
            <div style={{ position: "absolute", inset: 0, transform: `scale(${zoom})`, transformOrigin: `${fx}px ${fy}px`, willChange: "transform" }}>
              <JustAskBody p={1} typed={prompt} done sent={false} press={1} />
            </div>
          </div>
        </div>
      </Frame>
    );
  }

  /* ---- SHOT B · same composer, camera zoom-cut onto its send button ---- */
  if (p >= CUT + PANW) {
    const lp = (p - CUT - PANW) / (1 - CUT - PANW); // 0→1 within the button shot
    // cursor flies in to the button's new home (stage centre), then presses
    const approach = Math.min(1, lp / 0.34);
    const ae = approach * approach * (3 - 2 * approach);
    const offX = (1 - ae) * 150;
    const offY = (1 - ae) * 160;
    const contact = Math.max(0, Math.min(1, (lp - 0.32) / 0.1));
    const release = Math.max(0, Math.min(1, (lp - 0.44) / 0.16));
    const press = 1 - 0.1 * (contact - release);
    // ripple after the press lands
    const rp = Math.max(0, Math.min(1, (lp - 0.4) / 0.55));
    const sent = lp > 0.5;
    return (
      <Frame style={{ background: "#0e1113" }}>
        <div style={{ position: "absolute", inset: 0, overflow: "hidden" }}>
          {/* re-centre the zoom point so the send button lands mid-frame */}
          <div style={{ position: "absolute", inset: 0, transform: `translate(${BODY_CX - SEND_X}px, ${BODY_CY - SEND_Y}px)` }}>
            <div style={{ position: "absolute", inset: 0, transform: `scale(${SEND_ZOOM})`, transformOrigin: `${SEND_X}px ${SEND_Y}px`, willChange: "transform" }}>
              <JustAskBody p={1} typed={prompt} done sent={sent} press={press} />
            </div>
          </div>
          {/* ripple ring, in screen space over the button */}
          {rp > 0 && rp < 1 && (
            <div style={{ position: "absolute", left: BODY_CX, top: BODY_CY, width: 170, height: 170, borderRadius: 999, border: `3px solid ${c.accent}`, opacity: (1 - rp) * 0.55, transform: `translate(-50%, -50%) scale(${1 + rp * 1.5})` }} />
          )}
          {/* cursor */}
          <div style={{ position: "absolute", left: BODY_CX, top: BODY_CY, transform: `translate(${offX - 6}px, ${offY - 4}px) scale(${1 - contact * 0.12 + release * 0.12})` }}>
            <Cursor size={40} />
          </div>
        </div>
      </Frame>
    );
  }

  /* ---- SHOT A · composer, camera pushing into the textbox ----
     The camera drifts as it pushes: starts wide on the natural layout, then
     settles hard onto the typed prompt (focus 370,175 — near the prompt's
     middle, so the full text lands centred with a generous left margin).
     At 1.6x the send button slides out of the right edge; the pan then
     travels across the composer to re-discover it. */
  const ze = typeFrac * typeFrac * (3 - 2 * typeFrac); // smoothstep on typing
  const zoom = 1.08 + ze * 0.52; // up to ~1.6
  const driftX = (BODY_CX - 370) * ze;
  const driftY = (BODY_CY - 175) * ze;
  return (
    <Frame style={{ background: "#0e1113" }}>
      <div style={{ position: "absolute", inset: 0, transform: `translate(${driftX}px, ${driftY}px)` }}>
        <div style={{ position: "absolute", inset: 0, transform: `scale(${zoom})`, transformOrigin: "370px 175px", willChange: "transform" }}>
          <JustAskBody p={p} typed={typed} done={done} sent={false} press={1} />
        </div>
      </div>
    </Frame>
  );
}

/** 03 · Agent at work — the full agent turn on the new components: thinking orb
 * + shimmer status → context-source stack + reel counter → blur-streamed
 * answer. Progress-driven throughout, so it scrubs cleanly. */
function SceneAgent({ p }: { p: number }) {
  return <AskWorking qc={p} />;
}

/** 04 · Result — a desktop monitor showing an Artifacts gallery. */
function SceneResult({ p }: { p: number }) {
  const art = ["AI Landing Page", "Research Agent", "Code Assistant", "Meeting Notes", "Prompt Library", "Growth Plan"];
  return (
    <Frame style={{ background: "#efefed" }}>
      <div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
        {/* monitor */}
        <div style={{ width: 660, background: "#0e0e0d", borderRadius: 16, padding: 12, boxShadow: "0 30px 60px -30px rgba(0,0,0,0.5)" }}>
          <div style={{ background: c.card, borderRadius: 8, overflow: "hidden", height: 322, display: "flex" }}>
            {/* sidebar */}
            <div style={{ width: 150, borderRight: `1px solid ${c.line}`, padding: "14px 12px" }}>
              <div style={{ display: "flex", alignItems: "center", gap: 7 }}>
                <Mark size={13} />
                <span style={{ fontFamily: SANS, fontSize: 12, fontWeight: 600, color: c.ink }}>Alpaca</span>
              </div>
              {["New chat", "Projects", "Artifacts", "Apps"].map((s, i) => (
                <div key={s} style={{ fontFamily: SANS, fontSize: 11.5, color: i === 2 ? c.ink : c.faint, background: i === 2 ? c.chip : "transparent", borderRadius: 6, padding: "6px 8px", marginTop: i === 0 ? 14 : 2 }}>
                  {s}
                </div>
              ))}
            </div>
            {/* gallery */}
            <div style={{ flex: 1, padding: "16px 18px" }}>
              <div style={{ display: "flex", alignItems: "center" }}>
                <span style={{ fontFamily: SANS, fontSize: 14, fontWeight: 600, color: c.ink }}>Artifacts</span>
                <span style={{ marginLeft: "auto", fontFamily: SANS, fontSize: 11, color: "#fff", background: c.ink, borderRadius: 999, padding: "4px 12px" }}>New artifact</span>
              </div>
              <div style={{ marginTop: 14, display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 12 }}>
                {art.map((a, i) => {
                  const t = Math.max(0, Math.min(1, (p - 0.1 - i * 0.08) / 0.18));
                  const e = t * t * (3 - 2 * t);
                  return (
                    <div key={a} style={{ opacity: e, transform: `translateY(${(1 - e) * 10}px)` }}>
                      <div style={{ height: 66, borderRadius: 8, background: `linear-gradient(135deg, ${c.chip}, #fff)`, border: `1px solid ${c.line}` }} />
                      <div style={{ fontFamily: SANS, fontSize: 11, fontWeight: 600, color: c.ink, marginTop: 7 }}>{a}</div>
                      <div style={{ fontFamily: SANS, fontSize: 10, color: c.faint }}>2d ago</div>
                    </div>
                  );
                })}
              </div>
            </div>
          </div>
        </div>
        {/* stand */}
        <div style={{ width: 90, height: 30, background: "linear-gradient(#c9c9c5,#e6e6e2)", borderRadius: "0 0 8px 8px" }} />
        <div style={{ width: 190, height: 8, background: "#d2d2ce", borderRadius: 999, marginTop: 2 }} />
      </div>
    </Frame>
  );
}

/**
  * 05 · Integrations — two shots, one sentence: "Connect the tools your team
 * uses." Shot A is pure motion — two ticker rows of app tiles glide past in
 * opposite directions and wire themselves up (dots flip green, the counter
 * climbs to 8/8). Shot B lands the payoff: the grid, toggles flipping on,
 * with the MCP endpoint strip underneath.
 */
const INT_APPS: { n: string; d: string; g: string }[] = [
  { n: "X", d: "Social media management", g: "#111" },
  { n: "GitHub", d: "Streamline your dev workflow", g: "#24292f" },
  { n: "Slack", d: "Team communication", g: "#611f69" },
  { n: "Asana", d: "Project tasks & deadlines", g: "#f06a6a" },
  { n: "Zoom", d: "Meetings & webinars", g: "#2d8cff" },
  { n: "Trello", d: "Boards and cards", g: "#0079bf" },
  { n: "Figma", d: "Design & prototype", g: "#a259ff" },
  { n: "Notion", d: "Docs & databases", g: "#111" },
];

/** One app tile for the Shot A tickers — fixed 150px width so rows loop seamlessly. */
function TickerTile({ a, on }: { a: { n: string; d: string; g: string }; on: boolean }) {
  return (
    <div style={{ width: 150, flex: "none", display: "flex", alignItems: "center", gap: 10, background: c.card, border: `1px solid ${on ? c.accent : c.line}`, borderRadius: 14, padding: 10, boxShadow: on ? "0 10px 24px -16px rgba(59,90,246,0.5)" : "0 8px 20px -16px rgba(20,20,18,0.35)", transition: "border-color .2s, box-shadow .2s" }}>
      <div style={{ width: 34, height: 34, flex: "none", borderRadius: 9, background: a.g, color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: SANS, fontWeight: 700, fontSize: 15 }}>
        {a.n[0]}
      </div>
      <div style={{ minWidth: 0, flex: 1 }}>
        <div style={{ fontFamily: SANS, fontSize: 13, fontWeight: 600, color: c.ink, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{a.n}</div>
        <div style={{ display: "flex", alignItems: "center", gap: 5, marginTop: 3 }}>
          <span style={{ width: 7, height: 7, borderRadius: 999, background: on ? c.green : c.line2, transition: "background .2s" }} />
          <span style={{ fontFamily: MONO, fontSize: 9.5, letterSpacing: 1, color: on ? c.green : c.faint }}>{on ? "SYNCED" : "READY"}</span>
        </div>
      </div>
    </div>
  );
}

function SceneIntegrations({ p }: { p: number }) {
  const CUT = 0.5; // hard cut: shot A makes the promise, shot B shows the proof

  /* ---- SHOT B · "your team uses." — the grid, toggles flipping on ---- */
  if (p >= CUT) {
    const qb = (p - CUT) / (1 - CUT); // 0→1 within shot B
    const entry = Math.min(1, qb / 0.14);
    const ee = entry * entry * (3 - 2 * entry);
    const servers = ["Gmail", "Linear", "Postgres", "Stripe"];
    return (
      <Frame style={{ background: c.card }}>
        <div style={{ position: "absolute", inset: 0, overflow: "hidden", opacity: ee, transform: `scale(${1.05 - ee * 0.05})` }}>
          <div style={{ position: "absolute", top: 0, left: 0, right: 0, height: 60, background: "linear-gradient(90deg,#c7b3ff,#a9d4ff,#c9f0e4)" }} />
          <div style={{ position: "absolute", inset: 0, padding: "30px 54px 0" }}>
            <div style={{ fontFamily: MONO, fontSize: 11, letterSpacing: 2, color: c.faint }}>INTEGRATIONS</div>
            <div style={{ fontFamily: SANS, fontSize: 30, fontWeight: 700, color: c.ink, letterSpacing: -0.5, marginTop: 4 }}>your team uses.</div>
            <div style={{ marginTop: 12, display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px 34px" }}>
              {INT_APPS.map((a, i) => {
                const t = Math.max(0, Math.min(1, (qb - 0.1 - i * 0.07) / 0.16));
                const e = t * t * (3 - 2 * t);
                const on = qb > 0.12 + i * 0.07;
                return (
                  <div key={a.n} style={{ display: "flex", alignItems: "center", gap: 12, opacity: e, transform: `translateY(${(1 - e) * 10}px)` }}>
                    <div style={{ width: 34, height: 34, borderRadius: 9, background: a.g, color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", fontFamily: SANS, fontWeight: 700, fontSize: 14 }}>
                      {a.n[0]}
                    </div>
                    <div style={{ minWidth: 0 }}>
                      <div style={{ fontFamily: SANS, fontSize: 14, fontWeight: 600, color: c.ink }}>{a.n}</div>
                      <div style={{ fontFamily: SANS, fontSize: 11.5, color: c.faint, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{a.d}</div>
                    </div>
                    <div style={{ marginLeft: "auto", width: 22, height: 22, borderRadius: 999, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 13, color: on ? c.green : c.faint, border: `1px solid ${on ? "transparent" : c.line2}`, background: on ? "#e7f7ee" : "transparent", transition: "all .2s" }}>
                      {on ? "✓" : "+"}
                    </div>
                  </div>
                );
              })}
            </div>
            {/* MCP strip — the same endpoint, one click away */}
            <div style={{ marginTop: 12, border: `1px solid ${c.line}`, borderRadius: 12, padding: "9px 14px", display: "flex", alignItems: "center", gap: 10 }}>
              <span style={{ fontSize: 15 }}>🔌</span>
              <span style={{ fontFamily: MONO, fontSize: 12, color: c.ink }}>mcp.alpaca.dev/sse</span>
              <span style={{ marginLeft: "auto", fontFamily: SANS, fontSize: 11.5, fontWeight: 600, color: "#fff", background: c.ink, borderRadius: 8, padding: "5px 12px" }}>Connect</span>
            </div>
            <div style={{ marginTop: 8, display: "flex", gap: 6 }}>
              {servers.map((s, i) => {
                const t = Math.max(0, Math.min(1, (qb - 0.5 - i * 0.08) / 0.18));
                const e = t * t * (3 - 2 * t);
                return (
                  <span key={s} style={{ opacity: e, transform: `scale(${0.9 + e * 0.1})`, fontFamily: MONO, fontSize: 10.5, color: c.sub, background: c.chip, border: `1px solid ${c.line2}`, borderRadius: 999, padding: "4px 10px" }}>
                    {s} <span style={{ color: c.green }}>●</span>
                  </span>
                );
              })}
            </div>
          </div>
        </div>
      </Frame>
    );
  }

  /* ---- SHOT A · "Connect the tools" — tickers wiring themselves up ----
     Two rows glide past in opposite directions, driven by film time so the
     shot scrubs cleanly; each row travels exactly one set-width, looping
     seamlessly. Tiles flip to SYNCED one by one as the counter climbs. */
  const q = Math.min(1, p / 0.42);
  const qe = q * q * (3 - 2 * q);
  const connected = INT_APPS.filter((_, i) => q > 0.06 + i * 0.1).length;
  const push = 1 + 0.05 * Math.min(1, p / CUT); // lean into the cut
  const SET_W = INT_APPS.length * (150 + 12); // one loop of tiles
  const row = (dir: 1 | -1) => ({
    display: "flex" as const,
    gap: 12,
    width: "max-content",
    transform: `translateX(${dir === 1 ? -SET_W * qe : -SET_W * (1 - qe)}px)`,
    willChange: "transform" as const,
  });
  const copies = [0, 1, 2];
  return (
    <Frame style={{ background: "radial-gradient(110% 100% at 50% 0%, #f3f1ff 0%, #f4f4f3 55%, #efefed 100%)" }}>
      <div style={{ position: "absolute", inset: 0, overflow: "hidden", transform: `scale(${push})` }}>
        <div style={{ textAlign: "center", paddingTop: 52 }}>
          <Reveal p={p} at={0.02} y={12}>
            <div style={{ fontFamily: SANS, fontSize: 34, fontWeight: 700, color: c.ink, letterSpacing: -0.8 }}>Connect the tools</div>
          </Reveal>
          <Reveal p={p} at={0.08} y={8}>
            <div style={{ display: "inline-flex", alignItems: "center", gap: 7, marginTop: 10, fontFamily: MONO, fontSize: 11.5, letterSpacing: 1.5, color: c.sub, background: c.card, border: `1px solid ${c.line}`, borderRadius: 999, padding: "6px 14px" }}>
              <span style={{ width: 7, height: 7, borderRadius: 999, background: connected >= INT_APPS.length ? c.green : c.accent }} />
              {connected}/{INT_APPS.length} CONNECTED
            </div>
          </Reveal>
        </div>
        {/* row 1 · drifts left */}
        <div style={{ position: "absolute", top: 196, left: 0, right: 0, overflow: "hidden", opacity: Math.min(1, p / 0.06) }}>
          <div style={row(1)}>
            {copies.flatMap((cp) => INT_APPS.map((a, i) => <TickerTile key={`${cp}-${a.n}`} a={a} on={q > 0.06 + i * 0.1} />))}
          </div>
        </div>
        {/* row 2 · drifts right */}
        <div style={{ position: "absolute", top: 272, left: 0, right: 0, overflow: "hidden", opacity: Math.min(1, Math.max(0, (p - 0.04) / 0.06)) }}>
          <div style={row(-1)}>
            {copies.flatMap((cp) => INT_APPS.map((a, i) => <TickerTile key={`${cp}-${a.n}`} a={a} on={q > 0.06 + ((i + 4) % INT_APPS.length) * 0.1} />))}
          </div>
        </div>
        <div style={{ position: "absolute", bottom: 30, left: 0, right: 0, textAlign: "center", fontFamily: MONO, fontSize: 10.5, letterSpacing: 2, color: c.faint }}>
          ONE CLICK · NO CODE · EVERY TOOL
        </div>
      </div>
    </Frame>
  );
}

/** A small mono uppercase caption that sits above whichever device is on. */
function BeatCaption({ text }: { text: string }) {
  return (
    <div
      style={{
        fontFamily: MONO,
        fontSize: 11,
        letterSpacing: 2,
        textTransform: "uppercase",
        color: c.faint,
        border: `1px solid ${c.line2}`,
        background: c.card,
        borderRadius: 999,
        padding: "6px 14px",
        whiteSpace: "nowrap",
      }}
    >
      {text}
    </div>
  );
}

/**
  * 06 · Anywhere — the same app, everywhere. The scene runs in two beats: it opens
 * on the mobile app, then cross-fades to the identical Alpaca Labs app on a
 * desktop, so the "anywhere" promise is shown on both form factors.
 */
function SceneAnywhere({ p }: { p: number }) {
  const recents = ["Fix spacing on cards", "Agent response feels verbose", "Mobile runtime cleanup", "Improve retrieval accuracy"];

  // two beats: mobile for the first half, desktop for the second, cross-faded.
  const SWAP = 0.5;
  const mobileOpacity = 1 - Math.max(0, Math.min(1, (p - (SWAP - 0.05)) / 0.1));
  const desktopOpacity = Math.max(0, Math.min(1, (p - SWAP) / 0.1));

  // per-beat entry rise
  const mr = Math.min(1, p / 0.35);
  const me = mr * mr * (3 - 2 * mr);
  const dr = Math.max(0, Math.min(1, (p - SWAP) / 0.35));
  const de = dr * dr * (3 - 2 * dr);

  return (
    <Frame style={{ background: "#efefed" }}>
      {/* ---- beat A · mobile ---- */}
      <div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", gap: 16, alignItems: "center", justifyContent: "center", opacity: mobileOpacity }}>
        <BeatCaption text="On the go" />
        <div style={{ width: 224, height: 408, background: "#111", borderRadius: 38, padding: 8, boxShadow: "0 34px 70px -30px rgba(0,0,0,0.55)", opacity: me, transform: `translateY(${(1 - me) * 26}px)` }}>
          <div style={{ position: "relative", width: "100%", height: "100%", background: c.card, borderRadius: 32, overflow: "hidden", padding: "26px 18px 16px" }}>
            <div style={{ position: "absolute", top: 12, left: "50%", transform: "translateX(-50%)", width: 84, height: 20, background: "#111", borderRadius: 999 }} />
            <div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8 }}>
              <Mark size={16} />
              <span style={{ fontFamily: SANS, fontSize: 15, fontWeight: 700, color: c.ink }}>Alpaca Labs</span>
            </div>
            {["Projects", "Artifacts", "MCP"].map((s, i) => (
              <div key={s} style={{ display: "flex", alignItems: "center", fontFamily: SANS, fontSize: 12.5, color: c.sub, marginTop: i === 0 ? 16 : 8 }}>
                {s}
                {s === "MCP" && <span style={{ marginLeft: "auto", color: c.accent, fontSize: 10, fontWeight: 600 }}>New</span>}
              </div>
            ))}
            <div style={{ fontFamily: MONO, fontSize: 9.5, letterSpacing: 1, textTransform: "uppercase", color: c.faint, marginTop: 18 }}>Recents</div>
            {recents.map((r, i) => {
              const t = Math.max(0, Math.min(1, (p - 0.12 - i * 0.06) / 0.14));
              const re = t * t * (3 - 2 * t);
              return (
                <div key={r} style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 10, opacity: re, transform: `translateX(${(1 - re) * 8}px)` }}>
                  <span style={{ width: 6, height: 6, borderRadius: 999, background: i === 0 ? c.accent : c.line2 }} />
                  <span style={{ fontFamily: SANS, fontSize: 12, color: c.ink, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{r}</span>
                </div>
              );
            })}
            <div style={{ position: "absolute", left: 18, right: 18, bottom: 16, textAlign: "center", fontFamily: SANS, fontSize: 12.5, fontWeight: 600, color: "#fff", background: c.ink, borderRadius: 999, padding: "11px 0" }}>
              New task
            </div>
          </div>
        </div>
      </div>

      {/* ---- beat B · desktop ---- */}
      <div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", gap: 16, alignItems: "center", justifyContent: "center", opacity: desktopOpacity, pointerEvents: "none" }}>
        <BeatCaption text="At your desk" />
        <div style={{ display: "flex", flexDirection: "column", alignItems: "center", transform: `translateY(${(1 - de) * 24}px)` }}>
          {/* laptop screen */}
          <div style={{ width: 600, background: "#0e0e0d", borderRadius: 16, padding: 12, boxShadow: "0 30px 60px -30px rgba(0,0,0,0.5)" }}>
            <div style={{ background: c.card, borderRadius: 8, overflow: "hidden", height: 300, display: "flex" }}>
              {/* sidebar */}
              <div style={{ width: 152, borderRight: `1px solid ${c.line}`, padding: "14px 12px" }}>
                <div style={{ display: "flex", alignItems: "center", gap: 7 }}>
                  <Mark size={13} />
                  <span style={{ fontFamily: SANS, fontSize: 12, fontWeight: 700, color: c.ink }}>Alpaca Labs</span>
                </div>
                {["New task", "Projects", "Artifacts", "MCP"].map((s, i) => (
                  <div key={s} style={{ display: "flex", alignItems: "center", fontFamily: SANS, fontSize: 11.5, color: i === 0 ? c.ink : c.faint, background: i === 0 ? c.chip : "transparent", borderRadius: 6, padding: "6px 8px", marginTop: i === 0 ? 14 : 2 }}>
                    {s}
                    {s === "MCP" && <span style={{ marginLeft: "auto", color: c.accent, fontSize: 9, fontWeight: 600 }}>New</span>}
                  </div>
                ))}
              </div>
              {/* main */}
              <div style={{ flex: 1, padding: "16px 20px", display: "flex", flexDirection: "column" }}>
                <div style={{ fontFamily: MONO, fontSize: 9.5, letterSpacing: 1, textTransform: "uppercase", color: c.faint }}>Recents</div>
                <div style={{ marginTop: 12, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
                  {recents.map((r, i) => {
                    const t = Math.max(0, Math.min(1, (p - (SWAP + 0.06) - i * 0.05) / 0.14));
                    const re = t * t * (3 - 2 * t);
                    return (
                      <div key={r} style={{ opacity: re, transform: `translateY(${(1 - re) * 8}px)`, border: `1px solid ${c.line}`, borderRadius: 10, padding: "11px 12px" }}>
                        <div style={{ display: "flex", alignItems: "center", gap: 7 }}>
                          <span style={{ width: 6, height: 6, borderRadius: 999, background: i === 0 ? c.accent : c.line2 }} />
                          <span style={{ fontFamily: SANS, fontSize: 12, fontWeight: 600, color: c.ink, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{r}</span>
                        </div>
                        <div style={{ marginTop: 9, height: 5, width: `${70 - i * 8}%`, borderRadius: 4, background: c.line2 }} />
                      </div>
                    );
                  })}
                </div>
                {/* mini composer */}
                <div style={{ marginTop: "auto", display: "flex", alignItems: "center", gap: 10, border: `1px solid ${c.line2}`, borderRadius: 10, padding: "10px 12px" }}>
                  <span style={{ color: c.faint, fontSize: 15 }}>＋</span>
                  <span style={{ fontFamily: SANS, fontSize: 12, color: c.faint }}>Where should we begin?</span>
                  <span style={{ marginLeft: "auto", width: 22, height: 22, borderRadius: 999, background: c.accent, color: "#fff", display: "inline-flex", alignItems: "center", justifyContent: "center", fontSize: 12 }}>↑</span>
                </div>
              </div>
            </div>
          </div>
          {/* laptop base */}
          <div style={{ width: 664, height: 14, background: "linear-gradient(#cfcfca,#e8e8e4)", borderRadius: "0 0 12px 12px", boxShadow: "0 10px 20px -12px rgba(0,0,0,0.4)" }} />
          <div style={{ width: 96, height: 5, background: "#c4c4bf", borderRadius: "0 0 6px 6px" }} />
        </div>
      </div>
    </Frame>
  );
}

/* ---- the film ------------------------------------------------------------- */

export interface Chapter {
  id: string;
  label: string;
  /** seconds this chapter runs for */
  duration: number;
  /** receives 0→1 progress within this chapter */
  Scene: (props: { p: number }) => ReactNode;
}

export const DEFAULT_CHAPTERS: Chapter[] = [
  { id: "hook", label: "Hook", duration: 5, Scene: SceneHook },
  { id: "ask", label: "Just ask", duration: 5, Scene: SceneJustAsk },
  { id: "agent", label: "Agent at work", duration: 6, Scene: SceneAgent },
  { id: "result", label: "Result", duration: 6, Scene: SceneResult },
  { id: "integrations", label: "Integrations", duration: 8, Scene: SceneIntegrations },
  { id: "anywhere", label: "Anywhere", duration: 6, Scene: SceneAnywhere },
];

const KEYFRAMES = `
@keyframes lf-blink { 0%,49%{opacity:1} 50%,100%{opacity:0} }
.lf-caret { animation: lf-blink 1s step-end infinite; }
@keyframes lf-fade { from { opacity: 0 } to { opacity: 1 } }
@keyframes lf-spin { to { transform: translate(-50%, -50%) rotate(360deg) } }
.lf-beam { animation: lf-spin linear infinite; will-change: transform; }
`;

export interface LaunchFilmProps {
  /** Rendered width in px; the whole film scales from this. Default 920. */
  width?: number;
  chapters?: Chapter[];
  /** Autoplay on mount. Default true. */
  autoPlay?: boolean;
  /** Loop back to the start at the end. Default true. */
  loop?: boolean;
}

export default function LaunchFilm({
  width = STAGE_W,
  chapters = DEFAULT_CHAPTERS,
  autoPlay = true,
  loop = true,
}: LaunchFilmProps) {
  const total = chapters.reduce((s, ch) => s + ch.duration, 0);
  const scale = width / CANVAS_W;

  const [time, setTime] = useState(0);
  const [playing, setPlaying] = useState(autoPlay);
  const raf = useRef<number>();
  const last = useRef<number>();
  const trackRef = useRef<HTMLDivElement>(null);
  const dragging = useRef(false);

  // rAF clock
  useEffect(() => {
    if (!playing) {
      last.current = undefined;
      return;
    }
    const tick = (now: number) => {
      if (last.current != null) {
        const dt = (now - last.current) / 1000;
        setTime((prev) => {
          let next = prev + dt;
          if (next >= total) {
            if (loop) next %= total;
            else {
              next = total;
              setPlaying(false);
            }
          }
          return next;
        });
      }
      last.current = now;
      raf.current = requestAnimationFrame(tick);
    };
    raf.current = requestAnimationFrame(tick);
    return () => {
      if (raf.current) cancelAnimationFrame(raf.current);
      last.current = undefined;
    };
  }, [playing, total, loop]);

  // locate active chapter + in-chapter progress
  let acc = 0;
  let activeIndex = 0;
  let chapterProgress = 0;
  for (let i = 0; i < chapters.length; i++) {
    const end = acc + chapters[i].duration;
    if (time < end || i === chapters.length - 1) {
      activeIndex = i;
      chapterProgress = Math.min(1, (time - acc) / chapters[i].duration);
      break;
    }
    acc = end;
  }
  const Active = chapters[activeIndex].Scene;
  const playhead = Math.min(1, time / total); // 0→1 across the whole film

  // seeking
  const seekToClientX = useCallback(
    (clientX: number) => {
      const el = trackRef.current;
      if (!el) return;
      const rect = el.getBoundingClientRect();
      const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
      setTime(frac * total);
    },
    [total],
  );

  const onPointerDown = (e: React.PointerEvent) => {
    dragging.current = true;
    (e.target as Element).setPointerCapture?.(e.pointerId);
    seekToClientX(e.clientX);
  };
  const onPointerMove = (e: React.PointerEvent) => {
    if (dragging.current) seekToClientX(e.clientX);
  };
  const onPointerUp = () => {
    dragging.current = false;
  };

  return (
    <div style={{ width: CANVAS_W * scale, height: CANVAS_H * scale, fontFamily: SANS }}>
      <style>{KEYFRAMES}</style>
      <div
        style={{
          width: CANVAS_W,
          height: CANVAS_H,
          transform: `scale(${scale})`,
          transformOrigin: "top left",
          position: "relative",
        }}
      >
        {/* stage */}
        <div style={{ position: "absolute", top: 0, left: 0, width: STAGE_W, height: STAGE_H, background: c.stage, borderRadius: 24, overflow: "hidden" }}>
          <div style={{ position: "absolute", inset: 22 }}>
            {/* re-mount per chapter so the entry cross-fade replays */}
            <div key={chapters[activeIndex].id} style={{ position: "absolute", inset: 0, animation: "lf-fade 0.5s ease" }}>
              <Active p={chapterProgress} />
            </div>
          </div>

          {/* play / pause */}
          <button
            type="button"
            aria-label={playing ? "Pause" : "Play"}
            onClick={() => {
              if (!loop && time >= total) setTime(0);
              setPlaying((v) => !v);
            }}
            style={{
              position: "absolute",
              left: 16,
              bottom: 16,
              width: 40,
              height: 40,
              borderRadius: 999,
              border: "none",
              cursor: "pointer",
              background: "rgba(20,20,18,0.82)",
              color: "#fff",
              fontSize: 15,
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              backdropFilter: "blur(4px)",
            }}
          >
            {playing ? "❚❚" : "▶"}
          </button>
        </div>

        {/* chapter track / scrubber */}
        <div style={{ position: "absolute", left: 0, top: STAGE_H + GAP, width: STAGE_W, height: TRACK_H }}>
          {/* progress rail + playhead bubble */}
          <div style={{ position: "relative", height: 26 }}>
            <div style={{ position: "absolute", top: 12, left: 0, right: 0, height: 2, background: c.line2, borderRadius: 2 }} />
            <div style={{ position: "absolute", top: 12, left: 0, width: `${playhead * 100}%`, height: 2, background: c.playhead, borderRadius: 2 }} />
            {/* playhead + timestamp bubble */}
            <div style={{ position: "absolute", top: 0, left: `${playhead * 100}%`, transform: "translateX(-50%)", display: "flex", flexDirection: "column", alignItems: "center" }}>
              <div style={{ fontFamily: MONO, fontSize: 11, color: "#fff", background: c.playhead, borderRadius: 6, padding: "3px 8px", lineHeight: 1, whiteSpace: "nowrap" }}>
                {fmt(time)}
              </div>
              <div style={{ width: 11, height: 11, borderRadius: 999, background: c.playhead, marginTop: 3, boxShadow: "0 0 0 3px rgba(20,20,18,0.12)" }} />
            </div>
          </div>

          {/* labelled, draggable segments */}
          <div
            ref={trackRef}
            onPointerDown={onPointerDown}
            onPointerMove={onPointerMove}
            onPointerUp={onPointerUp}
            style={{ display: "flex", gap: 8, marginTop: 8, height: TRACK_H - 34, cursor: "pointer", touchAction: "none" }}
          >
            {chapters.map((ch, i) => (
              <div
                key={ch.id}
                style={{
                  flex: ch.duration,
                  borderRadius: 10,
                  background: i === activeIndex ? c.segActive : c.segIdle,
                  transition: "background .25s",
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                  textAlign: "center",
                  padding: "0 8px",
                }}
              >
                <span style={{ fontFamily: SANS, fontSize: 15, fontWeight: i === activeIndex ? 600 : 500, color: i === activeIndex ? c.ink : c.sub }}>
                  {ch.label}
                </span>
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}
```

## Related

- Machine-readable index (all patterns, with code): https://eito.me/ui/manifest.json
- Plain-HTML mirror: https://eito.me/ui/all.html
- Interactive gallery: https://eito.me/ui
