{
  "generatedFrom": "src/ui-patterns",
  "count": 10,
  "categories": [
    "Feedback",
    "Onboarding",
    "Decoration",
    "Marketing"
  ],
  "patterns": [
    {
      "slug": "agent-thinking-chat",
      "title": "Agent thinking chat",
      "category": "Feedback",
      "summary": "A live, interactive agent chat: a working composer (message field, model picker, attach, send) plays a full turn that narrates itself, with a thinking-orb avatar, shimmering thinking-state text, a Sonner-style stack of context sources (file reads and MCP tool calls), a spinning-reel counter inside a live liquid-metal border, and a blur-streamed answer under the chosen model's avatar.",
      "problem": "An agent that streams for ten seconds needs to show its work, not a bare spinner, and a demo of it should be a real chat you can drive, not a canned loop. This is a working surface: type a message or pick a starter chip, choose a model, attach files, hit send, and one turn plays end to end. It composes five best-in-class micro-interactions into one honest status surface: the thinking-orb avatar names the current activity, a shimmer line swaps through what it's doing ('Collecting context…' to 'Calling connected tools…' to 'Reading relevant files…'), a banner stack ticks up the sources it pulls in (file reads shown with a teal dot, interleaved with MCP tool calls that carry their own marks: the Model Context Protocol glyph, plus connected apps like Gmail and Sealgate), a reel counter spins to the file count it scanned, and the answer arrives word by word with a soft blur as a left-side assistant message under a 'Thought for Ns' line and the model you picked. The working card is framed by the liquid-metal ring, so the whole lifecycle (think, research, compose, answer) reads at a glance. Every part honours prefers-reduced-motion (it jumps straight to the finished answer) and reuses the glossary's own ThinkingOrb and LiquidMetal snippets rather than reinventing them.",
      "whenToUse": [
        "You want a real, drivable agent-chat surface for a demo or landing page, not a looping mock: a working composer with a model picker, attachments and send that plays a turn on submit.",
        "A chat or agent UI streams multi-step turns and you want the status to say what it's doing (thinking, researching, composing) not just 'loading'.",
        "You're showing a 'collecting context' / retrieval phase and want the sources it touches, file reads and MCP tool calls alike, to visibly accumulate with a live count.",
        "You want the composer, the working indicator, the streamed reply and a premium metal frame to feel like one coherent motion system."
      ],
      "source": {
        "name": "transitions.dev",
        "url": "https://transitions.dev/"
      },
      "tags": [
        "chat",
        "agent",
        "ai",
        "composer",
        "model-picker",
        "interactive",
        "thinking",
        "streaming",
        "shimmer",
        "context",
        "retrieval",
        "mcp",
        "tool-calls",
        "stack",
        "counter",
        "reel",
        "liquid-metal",
        "orb",
        "feedback",
        "animation"
      ],
      "dependencies": [
        "react",
        "tailwindcss"
      ],
      "status": "stable",
      "demoUrl": "https://eito.me/ui/agent-thinking-chat",
      "markdownUrl": "https://eito.me/ui/patterns/agent-thinking-chat.md",
      "code": "import {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport ThinkingOrb, { type OrbState } from \"../thinking-orbs/snippet\";\nimport LiquidMetal from \"../liquid-metal/snippet\";\n\n/**\n * AgentThinkingChat — a live agent-chat surface that narrates its own thinking.\n *\n * It is a real, interactive chat: type a message (or pick a starter), choose a\n * model, attach files, hit send — and the agent plays one full turn. That turn\n * stitches together five best-in-class micro-interactions so the whole\n * lifecycle of a streamed answer — think → research → compose → answer — reads\n * at a glance:\n *\n *   1. ThinkingOrb          the dotted status avatar, re-tuned per phase\n *                           (weaving while thinking, searching while\n *                           researching, composing while it writes).\n *   2. ThinkingStates       transitions.dev shimmer text that swaps one line\n *                           for the next (\"Collecting context…\" → \"Reading\n *                           relevant files…\") — the outgoing copy blurs up as\n *                           the incoming one rises from below.\n *   3. ToolCallStack        every tool call collapses into one stacked\n *                           surface — the latest call plus a count of the\n *                           rest; click to expand the full trace, newest first.\n *   4. SpinningReel         transitions.dev digit reel: a counter that spins\n *                           up to the number of files scanned, with a\n *                           vertical-only motion blur that decays per column.\n *   5. StreamingText        transitions.dev word-by-word reveal: the final\n *                           answer resolves through opacity + a small blur,\n *                           delivered as a left-side assistant message under\n *                           the chosen model's avatar and a \"Thought for Ns\".\n *\n * …with the working card framed by the LiquidMetal ring while the agent works.\n *\n * The shell around it — the composer (message field, attach, model picker,\n * send), the starter chips, the attachment chips and the Replay control — is a\n * working chat UI, not a mock: send interrupts a turn in flight, the model you\n * pick becomes the reply's avatar, and Replay re-plays the last prompt.\n *\n * Companion snippets: this composes the glossary's own `ThinkingOrb`\n * (thinking-orbs) and `LiquidMetal` (liquid-metal). Copy those two first, then\n * fix the two import paths at the top. Everything else — the four\n * transitions.dev interactions, the chat shell and the phase controller —\n * lives in this file.\n *\n * It honours prefers-reduced-motion by skipping straight to the finished\n * answer.\n *\n * Deps: react + tailwindcss (plus the two companion snippets).\n */\n\n/* ---- tunables (transitions.dev defaults) ---------------------------------- */\n\nconst THINK_HOLD = 1650; // how long each shimmer line holds before it swaps\nconst THINK_SWAP = 150;\n\nconst STREAM_GAP = 55; // ms between streamed words\nconst STREAM_FADE = 350;\n\nconst REEL_DUR = 1400;\nconst REEL_CELL = 28; // px per digit cell\nconst REEL_STAGGER = 90;\nconst REEL_SPIN_BLUR = 3;\nconst REEL_EASE = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/* turn timeline (ms) — the scripted phase durations */\nconst T_THINK = 2600; // think phase before research begins\nconst T_STEP = 620; // gap between each context banner\nconst T_LEAD = 350; // delay before the first banner appears\nconst T_TAIL = 500; // hold after the last banner before composing\nconst T_COMPOSE = 1100; // compose phase before the answer streams\n\nconst clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);\nconst cx = (...parts: (string | false | null | undefined)[]) =>\n  parts.filter(Boolean).join(\" \");\n\n/* ---- scripted turn -------------------------------------------------------- */\n\nexport interface ContextSource {\n  /** File / resource name shown in the stacked banner. */\n  name: string;\n  /** Short action tag, e.g. \"read\", \"grep\" or \"mcp\". */\n  tag: string;\n  /**\n   * Optional leading icon. Omit for a plain file read (a teal dot); set to\n   * \"mcp\" for a Model Context Protocol tool call (a mono accent glyph), or an\n   * app key (\"gmail\" / \"seal\") for a connected-tool call (a white app badge).\n   */\n  icon?: keyof typeof TOOL_ICONS;\n}\n\n/**\n * Inline SVG marks for MCP tool calls. `mcp` is the Model Context Protocol\n * glyph (drawn in currentColor so it tints to the accent); `gmail` and `seal`\n * are connected-app logos rendered on a white badge.\n */\nexport const TOOL_ICONS = {\n  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>',\n  gmail:\n    '<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>',\n  seal: '<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"14 11 483 483\"><style>.o{stroke:#0A0E14;stroke-width:8;stroke-linecap:round;stroke-linejoin:round}.n{stroke:#0A0E14;stroke-width:5;stroke-linecap:round;stroke-linejoin:round}</style><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\" class=\"o\"/><path d=\"M6 8 L46 36 L86 8\" fill=\"none\" class=\"n\"/></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\" class=\"o\"/><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\" class=\"o\"/><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\" class=\"o\"/><g><rect x=\"191\" y=\"136\" width=\"150\" height=\"99\" rx=\"12\" fill=\"#F9F9F9\" class=\"o\"/><path d=\"M199 146 L266 195 L333 146\" fill=\"none\" class=\"o\"/></g><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\" class=\"o\"/><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\" class=\"o\"/><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\" class=\"n\"/><path d=\"M207 309 H188 M209 321 L190 328\" fill=\"none\" class=\"n\"/><path d=\"M319 307 H336 M317 319 L334 325\" fill=\"none\" class=\"n\"/></g><circle cx=\"439\" cy=\"115\" r=\"11\" fill=\"#C3FFFD\"/></svg>',\n} as const;\n\nexport interface AgentTurn {\n  /** Shimmer lines shown while the agent \"thinks\" (before research). */\n  thinking: string[];\n  /** Shimmer lines shown while it \"researches\" / collects context. */\n  researching: string[];\n  /** Context sources that stack up during research. */\n  context: ContextSource[];\n  /** Number the reel spins to (e.g. files scanned). */\n  scanned: number;\n  /** The final answer, streamed word by word. */\n  answer: string;\n}\n\nexport const DEFAULT_TURN: AgentTurn = {\n  thinking: [\"Thinking…\", \"Reading your message…\", \"Planning the approach…\"],\n  researching: [\n    \"Collecting context…\",\n    \"Searching the codebase…\",\n    \"Calling connected tools…\",\n    \"Reading relevant files…\",\n    \"Connecting the pieces…\",\n  ],\n  context: [\n    { name: \"registry.ts\", tag: \"read\" },\n    { name: \"App.tsx\", tag: \"read\" },\n    { name: \"gmail · search\", tag: \"mcp\", icon: \"gmail\" },\n    { name: \"export-ui-patterns.ts\", tag: \"grep\" },\n    { name: \"sealgate · call_tool\", tag: \"mcp\", icon: \"seal\" },\n    { name: \"PatternDetail.tsx\", tag: \"read\" },\n    { name: \"mcp · list_tools\", tag: \"mcp\", icon: \"mcp\" },\n  ],\n  scanned: 128,\n  answer:\n    \"Drop a folder under src/ui-patterns/<slug>/ with three files — meta.ts, snippet.tsx and Demo.tsx. The registry auto-discovers it, so the /ui page picks it up with no wiring, and the build-time export re-derives the agent artifacts from the same folder.\",\n};\n\n/** The default prompt used when a turn plays without a typed message. */\nexport const DEFAULT_PROMPT = \"How do I add a new pattern to the /ui glossary?\";\n\n/** Starter chips shown in the empty state. */\nexport const EXAMPLES = [\n  \"Summarize the auth flow in this repo\",\n  \"Add a dark-mode toggle to the header\",\n  \"Find and fix the failing tests\",\n];\n\n/**\n * Selectable models for the composer's picker. Each `icon` is an inline SVG\n * (external images are blocked in copy-paste contexts). The picked model\n * becomes the reply's avatar.\n */\nexport interface ModelOption {\n  name: string;\n  icon: string;\n}\n\nexport const MODELS: ModelOption[] = [\n  {\n    name: \"Claude Opus 4.8\",\n    icon: '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"256\" height=\"257\" preserveAspectRatio=\"xMidYMid\" 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>',\n  },\n  {\n    name: \"GPT-5\",\n    icon: '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"256\" height=\"260\" preserveAspectRatio=\"xMidYMid\" viewBox=\"0 0 256 260\"><path fill=\"currentColor\" d=\"M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z\"/></svg>',\n  },\n  {\n    name: \"Grok 4\",\n    icon: '<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 1024 1024\" fill=\"currentColor\"><path d=\"M395.479 633.828L735.91 381.105C752.599 368.715 776.454 373.548 784.406 392.792C826.26 494.285 807.561 616.253 724.288 699.996C641.016 783.739 525.151 802.104 419.247 760.277L303.556 814.143C469.49 928.202 670.987 899.995 796.901 773.282C896.776 672.843 927.708 535.937 898.785 412.476L899.047 412.739C857.105 231.37 909.358 158.874 1016.4 10.6326C1018.93 7.11771 1021.47 3.60279 1024 0L883.144 141.651V141.212L395.392 633.916\"/><path d=\"M325.226 695.251C206.128 580.84 226.662 403.776 328.285 301.668C403.431 226.097 526.549 195.254 634.026 240.596L749.454 186.994C728.657 171.88 702.007 155.623 671.424 144.2C533.19 86.9942 367.693 115.465 255.323 228.382C147.234 337.081 113.244 504.215 171.613 646.833C215.216 753.423 143.739 828.818 71.7385 904.916C46.2237 931.893 20.6216 958.87 0 987.429L325.139 695.339\"/></svg>',\n  },\n];\n\ntype Phase = \"idle\" | \"thinking\" | \"research\" | \"composing\" | \"answer\";\n\nconst ORB_FOR: Record<Exclude<Phase, \"idle\" | \"answer\">, OrbState> = {\n  thinking: \"weaving\",\n  research: \"searching\",\n  composing: \"composing\",\n};\n\n/* ---- scoped styles (one <style> for every instance) ----------------------- */\n\nconst STYLE_ID = \"agent-thinking-chat-styles\";\nconst CSS = `\n.atc-root {\n  --think-hold: ${THINK_HOLD}ms;\n  --think-swap: ${THINK_SWAP}ms;\n  --think-distance: 8px;\n  --think-blur: 2px;\n  --think-shimmer: 2000ms;\n  --think-base: #7f8b8d;\n  --think-highlight: #e0fffe;\n  --think-ease: ease-in-out;\n\n  --stream-fade: ${STREAM_FADE}ms;\n  --stream-blur: 1px;\n  --stream-ease: cubic-bezier(0.22, 1, 0.36, 1);\n\n  --stack-open: 300ms;\n  --stack-ease: cubic-bezier(0.22, 1, 0.36, 1);\n\n  --reel-cell: ${REEL_CELL}px;\n}\n\n/* Thinking states -------------------------------------------------------- */\n.atc-root .t-think { position: relative; display: inline-block; text-align: left; }\n.atc-root .t-think-sizer { display: block; visibility: hidden; white-space: nowrap; }\n.atc-root .t-think-text {\n  position: absolute; top: 0; left: 0; right: 0; display: block;\n  color: var(--think-base); white-space: nowrap;\n  transform: translateY(0); filter: blur(0); opacity: 1;\n  transition:\n    transform var(--think-swap) var(--think-ease),\n    filter var(--think-swap) var(--think-ease),\n    opacity var(--think-swap) var(--think-ease);\n  will-change: transform, filter, opacity;\n}\n.atc-root .t-think-text::before {\n  content: attr(data-text); position: absolute; inset: 0; pointer-events: none;\n  background-image: linear-gradient(90deg,\n    transparent 0%, transparent 40%,\n    var(--think-highlight) 50%, transparent 60%, transparent 100%);\n  background-size: 400% 100%; background-repeat: no-repeat;\n  -webkit-background-clip: text; background-clip: text;\n  color: transparent; -webkit-text-fill-color: transparent;\n  animation: atc-think-shimmer var(--think-shimmer) linear infinite;\n}\n@keyframes atc-think-shimmer {\n  0% { background-position: 100% 0; }\n  100% { background-position: 0% 0; }\n}\n.atc-root .t-think-text.is-exit {\n  transform: translateY(calc(var(--think-distance) * -1));\n  filter: blur(var(--think-blur)); opacity: 0;\n}\n.atc-root .t-think-text.is-enter-start {\n  transition: none; transform: translateY(var(--think-distance));\n  filter: blur(var(--think-blur)); opacity: 0;\n}\n\n/* Streaming text --------------------------------------------------------- */\n.atc-root .t-stream-w {\n  opacity: 0; filter: blur(var(--stream-blur));\n  transition:\n    opacity var(--stream-fade) var(--stream-ease),\n    filter var(--stream-fade) var(--stream-ease);\n}\n.atc-root .t-stream-w.is-in { opacity: 1; filter: blur(0); }\n\n/* Tool-call stack -------------------------------------------------------- */\n.atc-root .t-tcs-enter { animation: atc-tcs-rise 260ms var(--stack-ease) both; }\n@keyframes atc-tcs-rise {\n  from { opacity: 0.4; transform: translateY(5px); }\n  to { opacity: 1; transform: none; }\n}\n.atc-root .t-tcs-panel { animation: atc-tcs-open var(--stack-open) var(--stack-ease) both; }\n@keyframes atc-tcs-open {\n  from { opacity: 0; transform: translateY(-4px); }\n  to { opacity: 1; transform: none; }\n}\n\n/* Spinning reel ---------------------------------------------------------- */\n.atc-root .t-reel { display: inline-flex; align-items: center; height: var(--reel-cell); font-variant-numeric: tabular-nums; }\n.atc-root .t-reel-col {\n  position: relative; height: var(--reel-cell); overflow: hidden;\n  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%);\n  mask-image: linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%);\n}\n.atc-root .t-reel-strip { display: flex; flex-direction: column; will-change: transform, filter; }\n.atc-root .t-reel-digit { height: var(--reel-cell); display: flex; align-items: center; justify-content: center; }\n\n@media (prefers-reduced-motion: reduce) {\n  .atc-root .t-think-text { transition: none !important; transform: none !important; filter: none !important; }\n  .atc-root .t-think-text::before { display: none !important; }\n  .atc-root .t-stream-w { transition: none !important; filter: none !important; opacity: 1 !important; }\n  .atc-root .t-tcs-enter, .atc-root .t-tcs-panel { animation: none !important; }\n  .atc-root .t-reel-strip { transition: none !important; filter: none !important; }\n}\n`;\n\nconst useScopedStyles = () => {\n  useEffect(() => {\n    if (typeof document === \"undefined\") return;\n    if (document.getElementById(STYLE_ID)) return;\n    const el = document.createElement(\"style\");\n    el.id = STYLE_ID;\n    el.textContent = CSS;\n    document.head.appendChild(el);\n  }, []);\n};\n\nconst usePrefersReducedMotion = () => {\n  // Read synchronously on first render so consumers (e.g. an autoStart turn\n  // scheduled from a mount effect) see the right value immediately, not after\n  // a post-mount media-query effect flips it.\n  const [reduce, setReduce] = useState(\n    () =>\n      typeof window !== \"undefined\" &&\n      !!window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches,\n  );\n  useEffect(() => {\n    const mq = window.matchMedia?.(\"(prefers-reduced-motion: reduce)\");\n    if (!mq) return;\n    const update = () => setReduce(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n  return reduce;\n};\n\n/* ---- 1. thinking states (shimmer swap) ------------------------------------ */\n\nconst ThinkingStates = ({ lines, reduce }: { lines: string[]; reduce: boolean }) => {\n  // Two absolutely-positioned line slots; we cross-swap between them so both\n  // halves of a transition animate at once, exactly like the transitions.dev\n  // reference (one copy exits up-and-blurred while the next rises from below).\n  const slots = [useRef<HTMLSpanElement>(null), useRef<HTMLSpanElement>(null)];\n  const active = useRef(0);\n  const idx = useRef(0);\n\n  const longest = useMemo(\n    () => lines.reduce((a, b) => (b.length > a.length ? b : a), \"\"),\n    [lines],\n  );\n\n  // Reset to the first line whenever the set of lines changes.\n  useEffect(() => {\n    idx.current = 0;\n    active.current = 0;\n    const a = slots[0].current;\n    const b = slots[1].current;\n    if (a) {\n      a.textContent = lines[0] ?? \"\";\n      a.dataset.text = lines[0] ?? \"\";\n      a.className = \"t-think-text\";\n    }\n    if (b) {\n      b.className = \"t-think-text\";\n      b.style.opacity = \"0\";\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [longest]);\n\n  useEffect(() => {\n    if (reduce || lines.length <= 1) return;\n    const timer = window.setInterval(() => {\n      idx.current = (idx.current + 1) % lines.length;\n      const next = lines[idx.current];\n      const outgoing = slots[active.current].current;\n      const incoming = slots[active.current ^ 1].current;\n      if (!outgoing || !incoming) return;\n\n      incoming.style.opacity = \"\";\n      incoming.textContent = next;\n      incoming.dataset.text = next;\n      incoming.className = \"t-think-text is-enter-start\";\n      // force a reflow so the enter-start transform is the starting point\n      void incoming.offsetHeight;\n      incoming.className = \"t-think-text\";\n      outgoing.className = \"t-think-text is-exit\";\n\n      active.current ^= 1;\n    }, THINK_HOLD);\n    return () => window.clearInterval(timer);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [longest, reduce]);\n\n  return (\n    <span className=\"t-think font-mono text-[13px] tracking-tight\" role=\"status\" aria-live=\"polite\">\n      <span className=\"t-think-sizer\" aria-hidden=\"true\">\n        {longest}\n      </span>\n      <span ref={slots[0]} className=\"t-think-text\" data-text={lines[0]}>\n        {lines[0]}\n      </span>\n      <span ref={slots[1]} className=\"t-think-text\" style={{ opacity: 0 }} aria-hidden=\"true\" />\n    </span>\n  );\n};\n\n/* ---- 2. streaming text ---------------------------------------------------- */\n\nconst StreamingText = ({\n  text,\n  run,\n  reduce,\n}: {\n  text: string;\n  run: boolean;\n  reduce: boolean;\n}) => {\n  const tokens = useMemo(() => text.split(/(\\s+)/), [text]);\n  const [revealed, setRevealed] = useState(0);\n\n  useEffect(() => {\n    if (!run) {\n      setRevealed(0);\n      return;\n    }\n    if (reduce) {\n      setRevealed(tokens.length);\n      return;\n    }\n    setRevealed(0);\n    const words = tokens\n      .map((t, i) => ({ t, i }))\n      .filter((o) => o.t.trim().length > 0);\n    const timers = words.map((o, k) =>\n      window.setTimeout(() => setRevealed(o.i + 1), k * STREAM_GAP),\n    );\n    return () => timers.forEach((t) => window.clearTimeout(t));\n  }, [run, reduce, tokens]);\n\n  return (\n    <p className=\"t-stream text-[13.5px] leading-relaxed text-edison-white/90\">\n      {tokens.map((tok, i) =>\n        tok.trim() === \"\" ? (\n          <span key={i}>{tok}</span>\n        ) : (\n          <span key={i} className={cx(\"t-stream-w\", i < revealed && \"is-in\")}>\n            {tok}\n          </span>\n        ),\n      )}\n    </p>\n  );\n};\n\n/* ---- 3. tool-call stack (one surface, click to expand) -------------------- */\n\n/** The leading mark for one tool call: MCP glyph, connected-app badge, or dot. */\nconst ToolIcon = ({ item }: { item: ContextSource }) =>\n  item.icon === \"mcp\" ? (\n    <span\n      aria-hidden=\"true\"\n      className=\"grid h-[18px] w-[18px] shrink-0 place-items-center text-edison-cyan [&_svg]:h-[18px] [&_svg]:w-[18px]\"\n      dangerouslySetInnerHTML={{ __html: TOOL_ICONS.mcp }}\n    />\n  ) : item.icon ? (\n    <span\n      aria-hidden=\"true\"\n      className=\"grid h-[18px] w-[18px] shrink-0 place-items-center overflow-hidden rounded-[5px] bg-white shadow-[inset_0_0_0_1px_rgba(10,14,20,0.1)] [&_svg]:h-[18px] [&_svg]:w-[18px]\"\n      dangerouslySetInnerHTML={{ __html: TOOL_ICONS[item.icon] }}\n    />\n  ) : (\n    <span aria-hidden=\"true\" className=\"inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-edison-cyan\" />\n  );\n\n/** The tag chip (read / grep / mcp), shared by the surface and the list rows. */\nconst ToolTag = ({ tag }: { tag: string }) => (\n  <span className=\"shrink-0 rounded border border-edison-cyan/15 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-[0.12em] text-edison-cyan/70\">\n    {tag}\n  </span>\n);\n\nconst StackGlyph = () => (\n  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n    <path d=\"M12 3l9 5-9 5-9-5 9-5Z\" />\n    <path d=\"M3 13l9 5 9-5\" />\n  </svg>\n);\n\n/**\n * Every tool call the agent makes collapses into a single stacked surface: the\n * latest call is shown with a count of the rest, and clicking expands the stack\n * into the full list of calls (newest first). One surface, the whole trace one\n * click away.\n */\nconst ToolCallStack = ({\n  items,\n  revealed,\n  reduce,\n  className,\n}: {\n  items: ContextSource[];\n  revealed: number;\n  reduce: boolean;\n  className?: string;\n}) => {\n  const [open, setOpen] = useState(false);\n  const panelId = useId();\n  const prev = useRef(0);\n  const [entered, setEntered] = useState(false);\n\n  // Flag the top surface for a one-shot rise whenever a newer call arrives.\n  useEffect(() => {\n    if (revealed > prev.current) {\n      setEntered(true);\n      const t = window.setTimeout(() => setEntered(false), 300);\n      prev.current = revealed;\n      return () => window.clearTimeout(t);\n    }\n    prev.current = revealed;\n  }, [revealed]);\n\n  if (revealed <= 0) return null;\n\n  const count = revealed;\n  const multiple = count > 1;\n  const latest = items[revealed - 1];\n  // newest first\n  const list = Array.from({ length: revealed }, (_, i) => items[revealed - 1 - i]);\n\n  return (\n    <div className={className}>\n      <div className=\"relative\">\n        {/* stacked peek edges behind the surface — the \"more underneath\" cue */}\n        {!open && multiple && (\n          <>\n            <div\n              aria-hidden=\"true\"\n              className=\"absolute inset-x-2 top-0 h-full rounded-lg border border-edison-cyan/10 bg-[#0a0d0e]\"\n              style={{ transform: \"translateY(5px)\", opacity: 0.5 }}\n            />\n            {count > 2 && (\n              <div\n                aria-hidden=\"true\"\n                className=\"absolute inset-x-4 top-0 h-full rounded-lg border border-edison-cyan/10 bg-[#0a0d0e]\"\n                style={{ transform: \"translateY(10px)\", opacity: 0.28 }}\n              />\n            )}\n          </>\n        )}\n\n        <div className=\"relative rounded-lg border border-edison-cyan/15 bg-[#0a0d0e] shadow-[0_4px_16px_rgba(0,0,0,0.4)]\">\n          {/* the single visible surface — click to expand / collapse */}\n          <button\n            type=\"button\"\n            onClick={() => setOpen((o) => !o)}\n            aria-expanded={open}\n            aria-controls={panelId}\n            aria-label={open ? \"Collapse tool calls\" : `Expand ${count} tool call${multiple ? \"s\" : \"\"}`}\n            className=\"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition hover:bg-edison-cyan/[0.04]\"\n          >\n            {open ? (\n              <>\n                <span className=\"grid h-[18px] w-[18px] shrink-0 place-items-center text-edison-cyan [&_svg]:h-[15px] [&_svg]:w-[15px]\">\n                  <StackGlyph />\n                </span>\n                <span className=\"font-mono text-[12px] text-edison-white/90\">\n                  {count} tool call{multiple ? \"s\" : \"\"}\n                </span>\n              </>\n            ) : (\n              <div\n                key={`${latest.name}-${revealed}`}\n                className={cx(\"flex min-w-0 flex-1 items-center gap-2\", !reduce && entered && \"t-tcs-enter\")}\n              >\n                <ToolIcon item={latest} />\n                <span className=\"truncate font-mono text-[12px] text-edison-white/90\">\n                  {latest.name}\n                </span>\n              </div>\n            )}\n            <span className=\"ml-auto flex shrink-0 items-center gap-2 pl-1\">\n              {!open &&\n                (multiple ? (\n                  <span className=\"rounded-full border border-edison-cyan/15 bg-edison-cyan/[0.06] px-1.5 py-0.5 font-mono text-[9px] tabular-nums text-edison-cyan/80\">\n                    {count}\n                  </span>\n                ) : (\n                  <ToolTag tag={latest.tag} />\n                ))}\n              <span className={cx(\"h-3 w-3 shrink-0 text-edison-grey transition-transform\", open && \"rotate-180\")}>\n                <ChevronIcon />\n              </span>\n            </span>\n          </button>\n\n          {/* expanded: the full trace, newest first */}\n          {open && (\n            <div\n              id={panelId}\n              role=\"region\"\n              aria-label=\"Tool call trace\"\n              tabIndex={0}\n              className=\"t-tcs-panel max-h-[176px] overflow-y-auto border-t border-edison-cyan/10 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-edison-cyan/40\"\n            >\n              {list.map((item, i) => (\n                <div\n                  key={`${item.name}-${i}`}\n                  className=\"flex items-center gap-2 px-3 py-2 [&:not(:last-child)]:border-b [&:not(:last-child)]:border-edison-cyan/[0.06]\"\n                >\n                  <ToolIcon item={item} />\n                  <span className=\"truncate font-mono text-[12px] text-edison-white/90\">\n                    {item.name}\n                  </span>\n                  <span className=\"ml-auto\">\n                    <ToolTag tag={item.tag} />\n                  </span>\n                </div>\n              ))}\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n};\n\n/* ---- 4. spinning reel ----------------------------------------------------- */\n\nconst SpinningReel = ({\n  value,\n  run,\n  reduce,\n  spins = 4,\n}: {\n  value: number;\n  run: boolean;\n  reduce: boolean;\n  spins?: number;\n}) => {\n  const digits = useMemo(() => String(Math.max(0, Math.round(value))).split(\"\"), [value]);\n  const rawId = useId();\n  const uid = useMemo(() => `atc-reel-${rawId.replace(/[^a-zA-Z0-9]/g, \"\")}`, [rawId]);\n  const strips = useRef<(HTMLSpanElement | null)[]>([]);\n  const blurs = useRef<(SVGFEGaussianBlurElement | null)[]>([]);\n\n  useEffect(() => {\n    if (!run) {\n      digits.forEach((_, c) => {\n        const s = strips.current[c];\n        if (s) {\n          s.style.transition = \"none\";\n          s.style.transform = \"translateY(0)\";\n        }\n        blurs.current[c]?.setAttribute(\"stdDeviation\", \"0 0\");\n      });\n      return;\n    }\n    if (reduce) {\n      digits.forEach((d, c) => {\n        const s = strips.current[c];\n        if (s) {\n          s.style.transition = \"none\";\n          s.style.transform = `translateY(${-Number(d) * REEL_CELL}px)`;\n        }\n      });\n      return;\n    }\n\n    const start = performance.now();\n    digits.forEach((d, c) => {\n      const s = strips.current[c];\n      if (!s) return;\n      s.style.transition = \"none\";\n      s.style.transform = \"translateY(0)\";\n      void s.offsetHeight; // reflow so the tween starts from the top\n      s.style.transition = `transform ${REEL_DUR}ms ${REEL_EASE} ${c * REEL_STAGGER}ms`;\n      s.style.transform = `translateY(${-(spins * 10 + Number(d)) * REEL_CELL}px)`;\n    });\n\n    let raf = 0;\n    const tick = (now: number) => {\n      let running = false;\n      digits.forEach((_, c) => {\n        const b = blurs.current[c];\n        if (!b) return;\n        const p = clamp01((now - start - c * REEL_STAGGER) / REEL_DUR);\n        const env = Math.sin(p * Math.PI); // 0 → 1 → 0 streak envelope\n        b.setAttribute(\"stdDeviation\", `0 ${(REEL_SPIN_BLUR * env).toFixed(2)}`);\n        if (p < 1) running = true;\n      });\n      if (running) raf = requestAnimationFrame(tick);\n      else digits.forEach((_, c) => blurs.current[c]?.setAttribute(\"stdDeviation\", \"0 0\"));\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [run, reduce, value]);\n\n  return (\n    <span className=\"t-reel text-edison-cyan\" aria-label={String(value)}>\n      <svg width=\"0\" height=\"0\" aria-hidden=\"true\" style={{ position: \"absolute\" }}>\n        <defs>\n          {digits.map((_, c) => (\n            <filter key={c} id={`${uid}-${c}`} x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\">\n              <feGaussianBlur\n                ref={(el) => {\n                  blurs.current[c] = el;\n                }}\n                stdDeviation=\"0 0\"\n              />\n            </filter>\n          ))}\n        </defs>\n      </svg>\n      {digits.map((_, c) => (\n        <span\n          key={c}\n          className=\"t-reel-col\"\n          style={{ width: Math.round(REEL_CELL * 0.62) }}\n        >\n          <span\n            ref={(el) => {\n              strips.current[c] = el;\n            }}\n            className=\"t-reel-strip\"\n            style={{ filter: `url(#${uid}-${c})` }}\n          >\n            {Array.from({ length: spins * 10 + 10 }, (_, i) => (\n              <span key={i} className=\"t-reel-digit font-mono text-[17px] font-semibold\">\n                {i % 10}\n              </span>\n            ))}\n          </span>\n        </span>\n      ))}\n    </span>\n  );\n};\n\n/* ---- inline composer icons ------------------------------------------------ */\n\nconst PaperclipIcon = () => (\n  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n    <path d=\"M21.44 11.05l-9.19 9.19a5 5 0 0 1-7.07-7.07l9.19-9.19a3 3 0 0 1 4.24 4.24l-9.2 9.19a1 1 0 0 1-1.41-1.41l8.49-8.49\" />\n  </svg>\n);\n\nconst SendIcon = () => (\n  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n    <path d=\"M12 19V5\" />\n    <path d=\"M6 11l6-6 6 6\" />\n  </svg>\n);\n\nconst ChevronIcon = () => (\n  <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n    <path d=\"M6 9l6 6 6-6\" />\n  </svg>\n);\n\nconst ReplayIcon = () => (\n  <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n    <path d=\"M13.5 8a5.5 5.5 0 1 1-1.6-3.9\" />\n    <path d=\"M13.5 2.5V5H11\" />\n  </svg>\n);\n\n/* ---- model picker --------------------------------------------------------- */\n\nconst ModelPicker = ({\n  models,\n  selected,\n  onSelect,\n}: {\n  models: ModelOption[];\n  selected: ModelOption;\n  onSelect: (m: ModelOption) => void;\n}) => {\n  const [open, setOpen] = useState(false);\n  const ref = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    if (!open) return;\n    const onDoc = (e: MouseEvent) => {\n      if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);\n    };\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") setOpen(false);\n    };\n    document.addEventListener(\"mousedown\", onDoc);\n    document.addEventListener(\"keydown\", onKey);\n    return () => {\n      document.removeEventListener(\"mousedown\", onDoc);\n      document.removeEventListener(\"keydown\", onKey);\n    };\n  }, [open]);\n\n  return (\n    <div ref={ref} className=\"relative\">\n      <button\n        type=\"button\"\n        aria-haspopup=\"listbox\"\n        aria-expanded={open}\n        onClick={() => setOpen((o) => !o)}\n        className=\"inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-[12.5px] font-medium text-edison-white transition hover:bg-edison-cyan/8\"\n      >\n        <span\n          aria-hidden=\"true\"\n          className=\"grid h-[15px] w-[15px] shrink-0 place-items-center text-edison-white [&_svg]:h-full [&_svg]:w-full\"\n          dangerouslySetInnerHTML={{ __html: selected.icon }}\n        />\n        <span>{selected.name}</span>\n        <span\n          className={cx(\n            \"h-[11px] w-[11px] text-edison-grey transition-transform\",\n            open && \"rotate-180\",\n          )}\n        >\n          <ChevronIcon />\n        </span>\n      </button>\n      {open && (\n        <div\n          role=\"listbox\"\n          aria-label=\"Model\"\n          className=\"absolute bottom-full right-0 z-20 mb-2 flex min-w-[184px] flex-col gap-0.5 rounded-xl border border-edison-cyan/15 bg-[#0a0d0e] p-1.5 shadow-[0_16px_40px_-16px_rgba(0,0,0,0.9)]\"\n        >\n          {models.map((m) => {\n            const active = m.name === selected.name;\n            return (\n              <button\n                key={m.name}\n                type=\"button\"\n                role=\"option\"\n                aria-selected={active}\n                onClick={() => {\n                  onSelect(m);\n                  setOpen(false);\n                }}\n                className={cx(\n                  \"flex items-center gap-2 rounded-lg px-2.5 py-2 text-left text-[12.5px] transition hover:bg-edison-cyan/8\",\n                  active ? \"text-edison-white\" : \"text-edison-grey hover:text-edison-white\",\n                )}\n              >\n                <span\n                  aria-hidden=\"true\"\n                  className=\"grid h-4 w-4 shrink-0 place-items-center text-edison-white [&_svg]:h-full [&_svg]:w-full\"\n                  dangerouslySetInnerHTML={{ __html: m.icon }}\n                />\n                <span>{m.name}</span>\n                <span className={cx(\"ml-auto text-edison-cyan\", active ? \"opacity-100\" : \"opacity-0\")}>\n                  ✓\n                </span>\n              </button>\n            );\n          })}\n        </div>\n      )}\n    </div>\n  );\n};\n\n/* ---- shell bits ----------------------------------------------------------- */\n\nconst Bubble = ({ children }: { children: ReactNode }) => (\n  <div className=\"flex justify-end\">\n    <div className=\"max-w-[80%] rounded-2xl rounded-br-md bg-edison-cyan/10 px-3.5 py-2 text-[13.5px] leading-relaxed text-edison-white\">\n      {children}\n    </div>\n  </div>\n);\n\n/* ---- main component ------------------------------------------------------- */\n\nexport interface AgentThinkingChatProps {\n  /** The scripted turn every prompt plays. */\n  turn?: AgentTurn;\n  /** Play the default turn once on mount instead of starting in the empty state. */\n  autoStart?: boolean;\n}\n\nconst AgentThinkingChat = ({ turn = DEFAULT_TURN, autoStart = false }: AgentThinkingChatProps) => {\n  useScopedStyles();\n  const reduce = usePrefersReducedMotion();\n\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [prompt, setPrompt] = useState(\"\");\n  const [revealed, setRevealed] = useState(0);\n  const [draft, setDraft] = useState(\"\");\n  const [attached, setAttached] = useState<string[]>([]);\n  const [model, setModel] = useState<ModelOption>(MODELS[0]);\n  // The model the current reply was generated with — captured at turn start so\n  // changing the picker afterwards doesn't retroactively swap an answer's avatar.\n  const [answerModel, setAnswerModel] = useState<ModelOption>(MODELS[0]);\n\n  const timers = useRef<number[]>([]);\n  const fileRef = useRef<HTMLInputElement>(null);\n  const modelRef = useRef(model);\n  modelRef.current = model;\n\n  const thoughtSecs = useMemo(() => {\n    const revealMs = T_THINK + T_LEAD + turn.context.length * T_STEP + T_TAIL + T_COMPOSE;\n    return Math.max(1, Math.round(revealMs / 1000));\n  }, [turn.context.length]);\n\n  const clearTimers = useCallback(() => {\n    timers.current.forEach((t) => window.clearTimeout(t));\n    timers.current = [];\n  }, []);\n\n  // Kick off one full agent turn for `text`. Interrupts any turn in flight.\n  const startTurn = useCallback(\n    (text: string) => {\n      clearTimers();\n      setPrompt(text);\n      setAttached([]);\n      setRevealed(0);\n      setAnswerModel(modelRef.current);\n\n      if (reduce) {\n        setRevealed(turn.context.length);\n        setPhase(\"answer\");\n        return;\n      }\n\n      setPhase(\"thinking\");\n      const at = (ms: number, fn: () => void) =>\n        timers.current.push(window.setTimeout(fn, ms));\n\n      at(T_THINK, () => setPhase(\"research\"));\n      turn.context.forEach((_, i) =>\n        at(T_THINK + T_LEAD + i * T_STEP, () => setRevealed(i + 1)),\n      );\n      const researchEnd = T_THINK + T_LEAD + turn.context.length * T_STEP + T_TAIL;\n      at(researchEnd, () => setPhase(\"composing\"));\n      at(researchEnd + T_COMPOSE, () => setPhase(\"answer\"));\n    },\n    [clearTimers, reduce, turn],\n  );\n\n  useEffect(() => () => clearTimers(), [clearTimers]);\n\n  // Optional auto-play (for gallery / showcase contexts).\n  const started = useRef(false);\n  useEffect(() => {\n    if (!autoStart || started.current) return;\n    started.current = true;\n    startTurn(DEFAULT_PROMPT);\n  }, [autoStart, startTurn]);\n\n  const handleSubmit = (e: React.FormEvent) => {\n    e.preventDefault();\n    const text = draft.trim();\n    if (!text) return;\n    setDraft(\"\");\n    startTurn(text);\n  };\n\n  const onFiles = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const names = Array.from(e.target.files ?? []).map((f) => f.name);\n    if (names.length) setAttached((prev) => [...prev, ...names]);\n    e.target.value = \"\";\n  };\n\n  const working = phase === \"thinking\" || phase === \"research\" || phase === \"composing\";\n  const lines =\n    phase === \"research\"\n      ? turn.researching\n      : phase === \"composing\"\n        ? [\"Writing the answer…\"]\n        : turn.thinking;\n\n  return (\n    <div className=\"atc-root w-full max-w-md\">\n      <div className=\"rounded-2xl border border-white/10 bg-[#050707] p-3.5 sm:p-4\">\n        {/* user prompt bubble (once a turn is under way) */}\n        {prompt && (\n          <div className=\"mb-3\">\n            <Bubble>{prompt}</Bubble>\n          </div>\n        )}\n\n        {/* the working card, framed by the liquid-metal ring */}\n        {working && (\n          <LiquidMetal\n            preset=\"chromatic\"\n            strength={1}\n            glow\n            radius={14}\n            ringWidth={2}\n            speed={1}\n            theme=\"dark\"\n            style={{ display: \"flex\", width: \"100%\" }}\n          >\n            <div className=\"w-full rounded-[14px] bg-[#0a0d0e] p-3.5\">\n              {/* status header: orb + shimmer line */}\n              <div className=\"flex items-center gap-2.5\">\n                <ThinkingOrb state={ORB_FOR[phase]} size={24} color=\"#7ff2ee\" theme=\"dark\" />\n                <ThinkingStates lines={lines} reduce={reduce} />\n\n                {/* reel: files scanned, spins once research starts */}\n                {(phase === \"research\" || phase === \"composing\") && (\n                  <span className=\"ml-auto flex items-center gap-1.5 whitespace-nowrap font-mono text-[10px] uppercase tracking-[0.1em] text-edison-grey/50\">\n                    <SpinningReel value={turn.scanned} run reduce={reduce} />\n                    scanned\n                  </span>\n                )}\n              </div>\n\n              {/* tool calls, collapsed into one expandable surface */}\n              {(phase === \"research\" || phase === \"composing\") && revealed > 0 && (\n                <ToolCallStack\n                  items={turn.context}\n                  revealed={revealed}\n                  reduce={reduce}\n                  className=\"mt-3\"\n                />\n              )}\n            </div>\n          </LiquidMetal>\n        )}\n\n        {/* the reply, as a plain assistant message on the left */}\n        {phase === \"answer\" && (\n          <div className=\"mt-3.5 flex items-start gap-2.5\">\n            <span\n              aria-hidden=\"true\"\n              className=\"mt-px grid h-[26px] w-[26px] shrink-0 place-items-center rounded-full border border-edison-cyan/15 bg-edison-cyan/5 text-edison-white [&_svg]:h-[15px] [&_svg]:w-[15px]\"\n              dangerouslySetInnerHTML={{ __html: answerModel.icon }}\n            />\n            <div className=\"min-w-0 flex-1\">\n              {turn.context.length > 0 && (\n                <ToolCallStack\n                  items={turn.context}\n                  revealed={turn.context.length}\n                  reduce={reduce}\n                  className=\"mb-3\"\n                />\n              )}\n              <div className=\"mb-1.5 font-mono text-[10.5px] tracking-wide text-edison-grey/60\">\n                Thought for {thoughtSecs}s\n              </div>\n              <StreamingText text={turn.answer} run reduce={reduce} />\n            </div>\n          </div>\n        )}\n\n        {/* attachment chips */}\n        {attached.length > 0 && (\n          <div className=\"mt-3 flex flex-wrap gap-1.5\">\n            {attached.map((name, i) => (\n              <span\n                key={`${name}-${i}`}\n                className=\"inline-flex items-center gap-1.5 rounded-lg border border-edison-cyan/15 bg-edison-cyan/[0.03] py-1 pl-2.5 pr-1.5 font-mono text-[11px] text-edison-white\"\n              >\n                {name}\n                <button\n                  type=\"button\"\n                  aria-label=\"Remove attachment\"\n                  onClick={() => setAttached((prev) => prev.filter((_, k) => k !== i))}\n                  className=\"grid h-4 w-4 place-items-center rounded text-edison-grey transition hover:bg-edison-cyan/8 hover:text-edison-cyan\"\n                >\n                  ✕\n                </button>\n              </span>\n            ))}\n          </div>\n        )}\n\n        {/* composer: message field, attach, model picker, send */}\n        <form\n          onSubmit={handleSubmit}\n          autoComplete=\"off\"\n          className=\"mt-3 flex flex-col gap-2.5 rounded-2xl border border-edison-cyan/12 bg-edison-cyan/[0.03] px-3.5 py-3 transition focus-within:border-edison-cyan/40\"\n        >\n          <input\n            type=\"file\"\n            ref={fileRef}\n            multiple\n            hidden\n            onChange={onFiles}\n          />\n          <input\n            className=\"w-full bg-transparent text-[14px] text-edison-white outline-none placeholder:text-edison-grey/50\"\n            value={draft}\n            onChange={(e) => setDraft(e.target.value)}\n            placeholder=\"Message the agent…\"\n            aria-label=\"Message the agent\"\n          />\n          <div className=\"flex items-center justify-between gap-2\">\n            <button\n              type=\"button\"\n              aria-label=\"Add attachment\"\n              onClick={() => fileRef.current?.click()}\n              className=\"grid h-[30px] w-[30px] place-items-center rounded-lg text-edison-grey transition hover:bg-edison-cyan/8 hover:text-edison-cyan [&_svg]:h-4 [&_svg]:w-4\"\n            >\n              <PaperclipIcon />\n            </button>\n            <div className=\"flex items-center gap-1.5\">\n              <ModelPicker models={MODELS} selected={model} onSelect={setModel} />\n              <button\n                type=\"submit\"\n                aria-label=\"Send message\"\n                className=\"grid h-8 w-8 place-items-center rounded-lg bg-edison-cyan text-[#04110f] transition hover:-translate-y-px [&_svg]:h-[15px] [&_svg]:w-[15px]\"\n              >\n                <SendIcon />\n              </button>\n            </div>\n          </div>\n        </form>\n\n        {/* empty-state starters */}\n        {phase === \"idle\" && !prompt && (\n          <div className=\"mt-3 flex flex-wrap gap-2\">\n            {EXAMPLES.map((ex) => (\n              <button\n                key={ex}\n                type=\"button\"\n                onClick={() => startTurn(ex)}\n                className=\"rounded-lg border border-edison-cyan/15 bg-edison-cyan/[0.03] px-3 py-1.5 text-left text-[12.5px] text-edison-grey transition hover:border-edison-cyan/40 hover:text-edison-white\"\n              >\n                {ex}\n              </button>\n            ))}\n          </div>\n        )}\n      </div>\n\n      {/* replay the last prompt */}\n      {prompt && (\n        <div className=\"mt-4 flex justify-center\">\n          <button\n            type=\"button\"\n            onClick={() => startTurn(prompt)}\n            className=\"inline-flex min-h-9 items-center gap-2 rounded-lg border border-edison-cyan/15 px-3.5 text-xs font-medium text-edison-grey/70 transition hover:border-edison-cyan/40 hover:text-edison-cyan [&_svg]:h-3.5 [&_svg]:w-3.5\"\n          >\n            <ReplayIcon />\n            Replay\n          </button>\n        </div>\n      )}\n    </div>\n  );\n};\n\nexport default AgentThinkingChat;"
    },
    {
      "slug": "mcp-cta",
      "title": "Connect-your-MCP-client CTA",
      "category": "Onboarding",
      "summary": "A client-picker “add it to your client” CTA: one copyable server URL, a dropdown of clients, and the least-effort install path for whichever one you pick.",
      "problem": "Every MCP client takes the same server URL in a different shape — a deeplink, a CLI line, or a paste-in prompt. One flat “copy this JSON” kills most installs. This CTA derives every path from the server name + URL and shows only the shortest one for the client you pick.",
      "whenToUse": [
        "You ship a remote (streamable-HTTP) MCP server and want fast, multi-client install.",
        "Clients differ in effort and the CTA should be honest about which is one-click.",
        "Auth is OAuth-in-client, so most clients can promise “just click Add”."
      ],
      "source": {
        "name": "daysurface.com",
        "url": "https://daysurface.com"
      },
      "tags": [
        "mcp",
        "cta",
        "onboarding",
        "install",
        "deeplink",
        "connect",
        "client-picker"
      ],
      "dependencies": [
        "react",
        "tailwindcss"
      ],
      "status": "stable",
      "demoUrl": "https://eito.me/ui/mcp-cta",
      "markdownUrl": "https://eito.me/ui/patterns/mcp-cta.md",
      "code": "import { useMemo, useRef, useState } from \"react\";\n\n/**\n * McpConnectWidget — the \"add it to your client\" CTA, ported from the\n * daysurface.com landing page (Edison \"Hackbox\" system).\n *\n * A single copyable MCP server URL up top, a client picker below, and one panel\n * per client that shows the least-effort install path for THAT client:\n *   - deeplink   → a one-click \"Add to <client>\" button (Claude, Cursor, VS Code, Goose)\n *   - page-only  → a link that opens the right page but prefills nothing\n *                  (ChatGPT); the URL is copied to the clipboard on the way out\n *   - command    → a copyable one-liner for the terminal (Claude Code)\n *   - prompt     → a paste-into-the-agent block (Cline, Zed, Windsurf, any client)\n * with a numbered click-path as the fallback under every deeplink.\n *\n * Every install target is DERIVED from serverName + mcpUrl, so onboarding a new\n * server is a two-field change. The effort classification is computed, never\n * hand-set, so a client that changes method reclassifies itself.\n *\n * Deps: react + tailwindcss only. Icons are inline SVG (no icon library); logos\n * on the real site are brand marks, swapped here for monochrome glyphs so the\n * file pastes anywhere. Palette is the Edison token set, inlined as arbitrary\n * values: accent #c3fffd · fg #fff · fg-muted #9ba4a6 · fg-subtle #5e6575 ·\n * surface #0a0a0a · border #383838.\n */\n\n/* ---- data model ------------------------------------------------------------ */\n\nexport type InstallMethod = \"deeplink\" | \"prompt\";\nexport type InstallEffort = \"one-click\" | \"command\" | \"page-only\" | \"prompt\";\n\nexport interface InstallTarget {\n  id: string;\n  name: string;\n  method: InstallMethod;\n  /** Deeplinks only. false = the link opens the right page but fills nothing in. */\n  prefills?: boolean;\n  /** Prompt targets: is `setupPrompt` a shell command or chat text? */\n  setupKind?: \"prompt\" | \"command\";\n  /** Prompt targets: the text to paste (or run). */\n  setupPrompt?: string;\n  /** The click-path: the whole flow, or the fallback under a deeplink. */\n  steps?: string[];\n  /** Optional note under the button / prompt. */\n  note?: string;\n}\n\nexport interface McpConnectWidgetProps {\n  /** Machine name used in deep links + CLI, e.g. \"daysurface\". */\n  serverName: string;\n  /** Streamable-HTTP endpoint, e.g. \"https://mcp.daysurface.com/mcp\". */\n  mcpUrl: string;\n  /** Label a client shows in its install dialog, e.g. \"DaySurface\". */\n  displayName?: string;\n  /** Override the client list; defaults to the standard set. */\n  targets?: InstallTarget[];\n  /** Which client is selected first. */\n  defaultId?: string;\n  /**\n   * Per-client logo URLs (SVG). Rendered as a currentColor-tinted CSS mask, so\n   * one asset reads correctly on dark rows and on the accent button alike.\n   * Any client without a URL falls back to a built-in monochrome glyph, so the\n   * component still works when copied without assets.\n   */\n  logos?: Record<string, string>;\n}\n\nconst effortMeta: Record<InstallEffort, { badge: string; stepsLabel: string }> = {\n  \"one-click\": { badge: \"1-click\", stepsLabel: \"If the link does not work\" },\n  command: { badge: \"command\", stepsLabel: \"If the command does not work\" },\n  \"page-only\": { badge: \"shortcut\", stepsLabel: \"Then, in ChatGPT\" },\n  prompt: { badge: \"prompt\", stepsLabel: \"Or add it by hand\" },\n};\n\nfunction effortOf(t: InstallTarget): InstallEffort {\n  if (t.method === \"deeplink\") return t.prefills === false ? \"page-only\" : \"one-click\";\n  if (t.setupKind === \"command\") return \"command\";\n  return \"prompt\";\n}\n\n/** UTF-8-safe base64 — plain btoa throws on any multibyte character. */\nconst toBase64 = (s: string) =>\n  btoa(Array.from(new TextEncoder().encode(s), (b) => String.fromCharCode(b)).join(\"\"));\n\n/** POSIX single-quote so a URL with `&`, spaces, etc. stays one shell argument. */\nconst shQuote = (s: string) => `'${s.replace(/'/g, `'\\\\''`)}'`;\n\n/** One-click install deep links. Formats verified against each client's docs. */\nfunction deepLink(id: string, mcpUrl: string, serverName: string, displayName: string): string | null {\n  switch (id) {\n    case \"claude\": {\n      const params = new URLSearchParams({\n        modal: \"add-custom-connector\",\n        connectorName: displayName,\n        connectorUrl: mcpUrl,\n      });\n      // Query must come BEFORE the hash fragment for Claude to read it.\n      return `https://claude.ai/new?${params.toString()}#settings/customize-connectors`;\n    }\n    case \"chatgpt\":\n      // ChatGPT has no deep link that opens or prefills the create form; custom\n      // MCP servers are added from the Plugins page (Add, then Create custom MCP server).\n      // The old #settings/Connectors?create-connector=true route targeted the\n      // removed Settings → Connectors flow. The widget copies the URL to the\n      // clipboard as it navigates.\n      return \"https://chatgpt.com/plugins\";\n    case \"cursor\": {\n      const config = toBase64(JSON.stringify({ url: mcpUrl }));\n      return `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(serverName)}&config=${encodeURIComponent(config)}`;\n    }\n    case \"vscode\": {\n      const config = encodeURIComponent(JSON.stringify({ type: \"http\", url: mcpUrl }));\n      return `https://vscode.dev/redirect/mcp/install?name=${encodeURIComponent(serverName)}&config=${config}`;\n    }\n    case \"goose\": {\n      const params = new URLSearchParams({\n        url: mcpUrl,\n        type: \"streamable_http\",\n        id: serverName,\n        name: serverName,\n        description: `${serverName} MCP server`,\n        timeout: \"300\",\n      });\n      return `goose://extension?${params.toString()}`;\n    }\n    default:\n      return null;\n  }\n}\n\n/** The standard client set, built from the server's two fields. */\nfunction defaultTargets(serverName: string, mcpUrl: string): InstallTarget[] {\n  // One prompt telling the agent to use ITS OWN config keys — they differ:\n  // VS Code \"servers\", Cursor/Cline \"mcpServers\", Zed \"context_servers\",\n  // Windsurf \"serverUrl\" not \"url\".\n  const agentPrompt = `Add a remote MCP server, then verify it connects.\n\n  Name:      ${serverName}\n  Endpoint:  ${mcpUrl}\n  Transport: streamable HTTP (remote, not stdio)\n  Auth:      OAuth in the browser, no key to paste\n\nUse THIS client's own config keys — they differ:\nVS Code \"servers\", Cursor/Cline \"mcpServers\",\nZed \"context_servers\", Windsurf \"serverUrl\" not \"url\".\nKeep existing servers. Show me the diff, then say\nwhat to click to finish sign-in.`;\n\n  return [\n    {\n      id: \"claude\",\n      name: \"Claude\",\n      method: \"deeplink\",\n      note: \"Opens Claude with the name and URL already filled in — just click Add.\",\n      steps: [\n        \"Open Claude → Settings → Connectors\",\n        \"Click “Add custom connector”\",\n        \"Paste the URL above, then click Add and complete sign-in\",\n      ],\n    },\n    {\n      id: \"chatgpt\",\n      name: \"ChatGPT\",\n      method: \"deeplink\",\n      prefills: false,\n      note: \"Copies the URL and opens ChatGPT Plugins.\",\n      steps: [\n        \"Add → Create custom MCP server\",\n        \"Paste the URL, Authentication → OAuth → Create as a plugin\",\n      ],\n    },\n    {\n      id: \"claude-code\",\n      name: \"Claude Code\",\n      method: \"prompt\",\n      setupKind: \"command\",\n      setupPrompt: `claude mcp add --transport http --scope user ${shQuote(serverName)} ${shQuote(mcpUrl)}`,\n      note: \"Run it in your terminal, then /mcp in a session to sign in.\",\n    },\n    {\n      id: \"cursor\",\n      name: \"Cursor\",\n      method: \"deeplink\",\n      note: \"Opens Cursor and adds the server. Not working? Copy the URL above and add it under Settings → MCP.\",\n    },\n    {\n      id: \"vscode\",\n      name: \"VS Code\",\n      method: \"deeplink\",\n      note: \"Opens VS Code and adds the server. Requires Copilot agent mode.\",\n    },\n    {\n      id: \"goose\",\n      name: \"Goose\",\n      method: \"deeplink\",\n      note: \"Opens Goose and adds the extension over streamable HTTP.\",\n    },\n    {\n      id: \"windsurf\",\n      name: \"Windsurf\",\n      method: \"prompt\",\n      setupPrompt: agentPrompt,\n      note: \"Windsurf uses “serverUrl”, not “url” — a snippet copied from another client will fail silently.\",\n    },\n    {\n      id: \"other\",\n      name: \"Any MCP client\",\n      method: \"prompt\",\n      setupPrompt: agentPrompt,\n      note: \"Anything that speaks MCP over streamable HTTP works — there is nothing to install.\",\n    },\n  ];\n}\n\n/* ---- inline glyphs (monochrome, tinted via currentColor) ------------------- */\n\nconst CopyIcon = ({ done }: { done: boolean }) =>\n  done ? (\n    <svg className=\"size-4\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n      <path d=\"M20 6 9 17l-5-5\" />\n    </svg>\n  ) : (\n    <svg className=\"size-4\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n      <rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" />\n      <path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\" />\n    </svg>\n  );\n\nconst glyphs: Record<string, JSX.Element> = {\n  claude: <svg viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><path d=\"M12 2c.5 3.6 1.7 4.8 5.3 5.3-3.6.5-4.8 1.7-5.3 5.3-.5-3.6-1.7-4.8-5.3-5.3C10.3 6.8 11.5 5.6 12 2Zm6 9.5c.3 2.2 1 2.9 3.2 3.2-2.2.3-2.9 1-3.2 3.2-.3-2.2-1-2.9-3.2-3.2 2.2-.3 2.9-1 3.2-3.2Zm-9.5 2c.24 1.8.85 2.4 2.65 2.65-1.8.25-2.4.85-2.65 2.65-.25-1.8-.85-2.4-2.65-2.65 1.8-.25 2.4-.85 2.65-2.65Z\" /></svg>,\n  chatgpt: <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" aria-hidden=\"true\"><path d=\"M12 3.2a4 4 0 0 1 3.8 2.8 4 4 0 0 1 1.9 6.7 4 4 0 0 1-3.8 5.3 4 4 0 0 1-7.6-1.1 4 4 0 0 1-1.9-6.7A4 4 0 0 1 8.2 4.3 4 4 0 0 1 12 3.2Z\" /><path d=\"M12 8.2 15.4 10v4L12 15.8 8.6 14v-4L12 8.2Z\" /></svg>,\n  cursor: <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\"><path d=\"m5 3 14 8.5-6 1.6L9.6 20 5 3Z\" /></svg>,\n  vscode: <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\"><path d=\"M8 6 2.5 12 8 18M16 6l5.5 6L16 18M12 3v18\" /></svg>,\n  goose: <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.9\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\"><path d=\"M8 4a4 4 0 0 1 4 4v5a5 5 0 0 1-5 5H6M12 8h4a3 3 0 0 1 0 6\" /><circle cx=\"7\" cy=\"6.5\" r=\"0.6\" fill=\"currentColor\" /></svg>,\n  \"claude-code\": <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\"><path d=\"m5 8 3 3-3 3M11 15h5\" /><rect x=\"2.5\" y=\"4.5\" width=\"19\" height=\"15\" rx=\"1.5\" /></svg>,\n  mcp: <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" aria-hidden=\"true\"><circle cx=\"6\" cy=\"12\" r=\"2.2\" /><circle cx=\"18\" cy=\"6\" r=\"2.2\" /><circle cx=\"18\" cy=\"18\" r=\"2.2\" /><path d=\"M8 11 16 7M8 13l8 4\" strokeLinecap=\"round\" /></svg>,\n};\n\n/** A client's mark: a tinted CSS mask of its real logo, or a built-in glyph. */\nconst ClientMark = ({\n  id,\n  logos,\n  className,\n}: {\n  id: string;\n  logos?: Record<string, string>;\n  className?: string;\n}) => {\n  const url = logos?.[id];\n  if (url) {\n    return (\n      <span\n        aria-hidden=\"true\"\n        className={className}\n        style={{\n          display: \"inline-block\",\n          backgroundColor: \"currentColor\",\n          WebkitMask: `url(\"${url}\") center / contain no-repeat`,\n          mask: `url(\"${url}\") center / contain no-repeat`,\n        }}\n      />\n    );\n  }\n  return <span className={className}>{glyphs[id] ?? glyphs.mcp}</span>;\n};\n\n/* ---- component ------------------------------------------------------------- */\n\nexport default function McpConnectWidget({\n  serverName,\n  mcpUrl,\n  displayName = serverName,\n  targets,\n  defaultId = \"claude\",\n  logos,\n}: McpConnectWidgetProps) {\n  const list = useMemo(() => targets ?? defaultTargets(serverName, mcpUrl), [targets, serverName, mcpUrl]);\n  const [selectedId, setSelectedId] = useState(defaultId);\n  const [open, setOpen] = useState(false);\n  const [copied, setCopied] = useState<string | null>(null);\n  const rootRef = useRef<HTMLDivElement>(null);\n\n  const selected = list.find((t) => t.id === selectedId) ?? list[0];\n\n  const copy = (key: string, text: string) => {\n    if (!navigator.clipboard) return;\n    navigator.clipboard\n      .writeText(text)\n      .then(() => {\n        setCopied(key);\n        setTimeout(() => setCopied((k) => (k === key ? null : k)), 1500);\n      })\n      .catch(() => {\n        /* permission denied / non-secure context — ignore */\n      });\n  };\n\n  // No clients configured (e.g. targets={[]}): render an empty state rather\n  // than dereferencing an undefined `selected`.\n  if (!selected) {\n    return (\n      <div className=\"w-full max-w-lg border-2 border-[#383838] bg-[#0a0a0a] p-4 text-sm text-[#9ba4a6]\">\n        No MCP clients configured.\n      </div>\n    );\n  }\n\n  const CopyBtn = ({ code, keyName, label }: { code: string; keyName: string; label: string }) => (\n    <button\n      type=\"button\"\n      onClick={() => copy(keyName, code)}\n      aria-label={label}\n      className={`grid min-h-11 w-11 shrink-0 place-items-center border-l border-[#383838] transition hover:bg-[#c3fffd]/10 ${copied === keyName ? \"bg-[#c3fffd]/10 text-[#c3fffd]\" : \"text-white/80 hover:text-[#c3fffd]\"}`}\n    >\n      <CopyIcon done={copied === keyName} />\n    </button>\n  );\n\n  return (\n    <div\n      ref={rootRef}\n      className=\"w-full min-w-0 max-w-lg border-2 border-[#c3fffd] bg-[#0a0a0a] p-3 text-left shadow-2xl shadow-black/40 sm:p-4\"\n    >\n      {/* MCP server URL — always copyable */}\n      <span className=\"font-mono text-[11px] uppercase tracking-widest text-[#5e6575]\">\n        MCP server URL\n      </span>\n      <div className=\"mt-1.5 flex items-stretch border border-[#383838] bg-black\">\n        <code className=\"min-w-0 flex-1 truncate px-3 py-2.5 font-mono text-xs text-[#c3fffd] sm:text-sm\" title={mcpUrl}>\n          {mcpUrl}\n        </code>\n        <CopyBtn code={mcpUrl} keyName=\"url\" label=\"Copy the server URL\" />\n      </div>\n\n      {/* Client picker */}\n      <div className=\"relative mt-4\">\n        <button\n          type=\"button\"\n          onClick={() => setOpen((v) => !v)}\n          aria-haspopup=\"listbox\"\n          aria-expanded={open}\n          className=\"flex min-h-11 w-full items-center gap-2.5 border border-[#383838] bg-black px-3 text-left transition hover:border-[#c3fffd]/60\"\n        >\n          <ClientMark id={selected.id} logos={logos} className=\"size-5 shrink-0 text-white [&_svg]:size-5\" />\n          <span className=\"min-w-0 flex-1 truncate text-sm font-medium text-white\">{selected.name}</span>\n          <svg className={`size-4 text-[#9ba4a6] transition-transform ${open ? \"rotate-180\" : \"\"}`} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" aria-hidden=\"true\">\n            <path d=\"M6 9l6 6 6-6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n          </svg>\n        </button>\n\n        {open && (\n          <ul\n            role=\"listbox\"\n            className=\"absolute z-20 mt-1 max-h-72 w-full overflow-y-auto border border-[#c3fffd]/50 bg-[#1c1c1c] shadow-2xl shadow-black/60\"\n          >\n            {list.map((t) => {\n              const effort = effortOf(t);\n              const isDeep = t.method === \"deeplink\" && t.prefills !== false;\n              return (\n                <li key={t.id} role=\"option\" aria-selected={t.id === selected.id}>\n                  <button\n                    type=\"button\"\n                    onClick={() => {\n                      setSelectedId(t.id);\n                      setOpen(false);\n                    }}\n                    className=\"flex min-h-11 w-full items-center gap-2.5 px-3 text-left text-sm text-[#9ba4a6] transition hover:bg-black hover:text-white\"\n                  >\n                    <ClientMark id={t.id} logos={logos} className=\"size-5 shrink-0 [&_svg]:size-5\" />\n                    <span className=\"flex-1\">{t.name}</span>\n                    <span className={`font-mono text-[10px] uppercase tracking-wider ${isDeep ? \"text-[#c3fffd]\" : \"text-[#5e6575]\"}`}>\n                      {effortMeta[effort].badge}\n                    </span>\n                  </button>\n                </li>\n              );\n            })}\n          </ul>\n        )}\n      </div>\n\n      {/* Panel for the selected client */}\n      <div className=\"mt-4\">\n        <Panel target={selected} mcpUrl={mcpUrl} displayName={displayName} serverName={serverName} logos={logos} copied={copied} copy={copy} />\n      </div>\n    </div>\n  );\n}\n\n/* ---- per-client panel ------------------------------------------------------ */\n\nfunction Panel({\n  target,\n  mcpUrl,\n  displayName,\n  serverName,\n  logos,\n  copied,\n  copy,\n}: {\n  target: InstallTarget;\n  mcpUrl: string;\n  displayName: string;\n  serverName: string;\n  logos?: Record<string, string>;\n  copied: string | null;\n  copy: (key: string, text: string) => void;\n}) {\n  const effort = effortOf(target);\n  const href = deepLink(target.id, mcpUrl, serverName, displayName);\n\n  // Deeplink → a button (+ fallback steps disclosure)\n  if (href) {\n    const pageOnly = effort === \"page-only\";\n    return (\n      <>\n        <a\n          href={href}\n          onClick={() => {\n            // Page-only links prefill nothing, so copy the URL on the way out.\n            if (pageOnly) copy(\"url\", mcpUrl);\n          }}\n          className=\"flex min-h-12 w-full items-center justify-center gap-2.5 bg-[#c3fffd] px-4 text-center font-semibold text-black transition hover:bg-[#7de6e2]\"\n        >\n          <ClientMark id={target.id} logos={logos} className=\"size-5 shrink-0 text-black [&_svg]:size-5\" />\n          {pageOnly ? `Open ${target.name} Plugins` : `Add to ${target.name}`}\n        </a>\n        {target.note && <p className=\"mt-2.5 text-xs leading-relaxed text-[#5e6575]\">{target.note}</p>}\n        {target.steps && target.steps.length > 0 && (\n          <details className=\"mt-3\" open={effort === \"page-only\"}>\n            <summary className=\"cursor-pointer list-none text-xs text-[#5e6575] transition hover:text-[#c3fffd]\">\n              <span className=\"underline decoration-dotted underline-offset-4\">\n                {effortMeta[effort].stepsLabel}\n              </span>\n            </summary>\n            <Steps steps={target.steps} />\n          </details>\n        )}\n      </>\n    );\n  }\n\n  // Prompt / command → copyable block\n  if (target.setupPrompt) {\n    const isCommand = target.setupKind === \"command\";\n    return (\n      <>\n        <p className=\"text-xs leading-relaxed text-[#5e6575]\">\n          {isCommand\n            ? `Run this in your terminal — it adds the server to ${target.name}.`\n            : `Paste this into ${target.name} — it will add the server for you.`}\n        </p>\n        <div className=\"mt-2 flex items-stretch border border-[#383838] bg-black\">\n          <pre className=\"max-h-64 flex-1 overflow-y-auto whitespace-pre-wrap break-words px-3 py-2.5 font-mono text-xs leading-relaxed text-[#9ba4a6]\">\n            {target.setupPrompt}\n          </pre>\n          <button\n            type=\"button\"\n            onClick={() => copy(`prompt-${target.id}`, target.setupPrompt!)}\n            aria-label={`Copy the ${target.name} setup ${isCommand ? \"command\" : \"prompt\"}`}\n            className={`grid shrink-0 place-items-center border-l border-[#383838] px-3 transition hover:bg-[#c3fffd]/10 ${copied === `prompt-${target.id}` ? \"bg-[#c3fffd]/10 text-[#c3fffd]\" : \"text-white/80 hover:text-[#c3fffd]\"}`}\n          >\n            <CopyIcon done={copied === `prompt-${target.id}`} />\n          </button>\n        </div>\n        {target.note && <p className=\"mt-2.5 text-xs leading-relaxed text-[#5e6575]\">{target.note}</p>}\n      </>\n    );\n  }\n\n  // Manual → numbered steps only\n  return <Steps steps={target.steps ?? []} />;\n}\n\nfunction Steps({ steps }: { steps: string[] }) {\n  return (\n    <ol className=\"mt-2.5 space-y-2.5\">\n      {steps.map((step, i) => (\n        <li key={i} className=\"flex gap-3 text-sm text-[#9ba4a6]\">\n          <span className=\"grid size-5 shrink-0 place-items-center border border-[#c3fffd]/50 font-mono text-[11px] text-[#c3fffd]\">\n            {i + 1}\n          </span>\n          <span className=\"leading-relaxed\">{step}</span>\n        </li>\n      ))}\n    </ol>\n  );\n}"
    },
    {
      "slug": "delivery-drone",
      "title": "Isometric delivery drone",
      "category": "Decoration",
      "summary": "A hand-faceted quadcopter that carries a glowing seal, with soft motion-blurred rotor discs and a gentle idle hover — a self-contained SVG hero prop you drop into any dark surface. Ships in three cuts: a high-facet isometric Classic, a lighter low-poly Raptor, and an overhead Courier view.",
      "problem": "A landing hero or empty state wants one memorable object rather than a generic stock render. This is a single inline SVG: the airframe is built from shaded facets so it reads as a real 3D-lit body, four radial-gradient discs stand in for spinning rotors, and the payload is a cyan seal lit by its own radial glow. It ships as a static asset (no WebGL, no 3D runtime, no per-frame work) wrapped by a tiny component that scales it to any width and adds an optional CSS hover-float which idles at 6s and switches itself off under prefers-reduced-motion. Three cuts are bundled — a ~1,300-facet isometric Classic, a ~40-facet low-poly Raptor at a fraction of the file size, and a top-down Courier with four spinning rotor discs — so you can trade fidelity, weight, and camera angle without leaving the pattern.",
      "whenToUse": [
        "A dark landing hero, 404, or empty state needs one characterful focal object instead of a stock illustration.",
        "You want a lightweight 'agent delivers the payload' motif — swap the carried seal for your own mark.",
        "The graphic must stay cheap and portable: one SVG asset, no 3D library, animation that respects reduced-motion."
      ],
      "source": {
        "name": "edison.watch",
        "url": "https://edison.watch"
      },
      "tags": [
        "drone",
        "quadcopter",
        "isometric",
        "svg",
        "illustration",
        "hero",
        "delivery",
        "seal",
        "faceted",
        "3d",
        "animation",
        "decoration",
        "raptor",
        "courier",
        "low-poly",
        "top-down",
        "variant"
      ],
      "dependencies": [
        "react"
      ],
      "status": "stable",
      "demoUrl": "https://eito.me/ui/delivery-drone",
      "markdownUrl": "https://eito.me/ui/patterns/delivery-drone.md",
      "code": "/**\n * DeliveryDrone — a self-contained isometric quadcopter illustration.\n *\n * The artwork itself is a single SVG file kept next to your other static\n * assets (default `/images/delivery-drone.svg`). This component is only the\n * thin wrapper around it: it scales the SVG to whatever width the parent\n * gives it, and adds an optional idle hover-float.\n *\n * Bring your own asset: drop the SVG into your `public/` (or point `src` at\n * wherever it lives). If you serve under a base path, prefix the URL with\n * `import.meta.env.BASE_URL`, e.g. `src={`${import.meta.env.BASE_URL}images/delivery-drone.svg`}`.\n *\n * Three airframes ship as separate assets. `delivery-drone.svg` and\n * `delivery-drone-raptor.svg` are the isometric \"Classic\" (high-facet) and\n * \"Raptor\" (low-poly) cuts; `delivery-drone-courier.svg` is an overhead\n * three-quarter view with four motion-blurred rotor discs. Point `src` at\n * whichever one suits the surface — the wrapper is identical for all three.\n *\n * The float is pure CSS so it costs nothing, and it silences itself under\n * `prefers-reduced-motion`. Sizing is driven entirely by the wrapper's\n * `className` (the image is `width:100%`), so `className=\"w-64\"` — or any\n * width — just works.\n */\n\ntype DeliveryDroneProps = {\n  /** URL of the drone SVG. Prefix with BASE_URL if you serve under a subpath. */\n  src?: string;\n  /** Alt text. Leave empty (default) to mark it decorative / aria-hidden. */\n  alt?: string;\n  /** Wrapper classes — this is where you set the width, e.g. \"w-64\". */\n  className?: string;\n  /** Gentle idle hover-float. Respects prefers-reduced-motion. Default: true. */\n  float?: boolean;\n};\n\nexport default function DeliveryDrone({\n  src = \"/images/delivery-drone.svg\",\n  alt = \"\",\n  className,\n  float = true,\n}: DeliveryDroneProps) {\n  const decorative = alt === \"\";\n  return (\n    <span className={className} style={{ display: \"inline-block\", lineHeight: 0 }}>\n      {float && (\n        <style>{`\n          @keyframes eito-drone-hover {\n            0%, 100% { transform: translateY(0); }\n            50%      { transform: translateY(-5%); }\n          }\n          @media (prefers-reduced-motion: reduce) {\n            .eito-drone--float { animation: none !important; }\n          }\n        `}</style>\n      )}\n      <img\n        src={src}\n        alt={alt}\n        aria-hidden={decorative || undefined}\n        draggable={false}\n        className={float ? \"eito-drone--float\" : undefined}\n        style={{\n          display: \"block\",\n          width: \"100%\",\n          height: \"auto\",\n          userSelect: \"none\",\n          ...(float\n            ? { animation: \"eito-drone-hover 6s ease-in-out infinite\", willChange: \"transform\" }\n            : null),\n        }}\n      />\n    </span>\n  );\n}"
    },
    {
      "slug": "liquid-metal",
      "title": "Liquid metal border",
      "category": "Decoration",
      "summary": "An animated WebGL liquid-metal ring you wrap around any button, chip or icon — flowing chrome that hugs the corner radius, with chromatic/silver/gold presets, a strength dial and a soft glow.",
      "problem": "A premium CTA wants to feel expensive without a bespoke asset per button. This wraps the child in a real-time metal border: a simplex-noise FBM field warps four sine bands, mapped through a five-stop metallic palette, so it reads as shifting liquid chrome rather than a static gradient. It stays cheap and portable — raw WebGL1 (no three.js, no filters), one shared GL context and one compiled shader behind every instance on the page, each wrapper owning only a 2D canvas that receives a drawImage copy. A CSS gradient-border mask clips the metal to a ring at any radius, overlays are pointer-events:none so the child stays fully interactive, and it pauses off-screen, when the tab is hidden, or under prefers-reduced-motion.",
      "whenToUse": [
        "A hero upgrade/CTA button or pricing chip needs to feel premium and alive.",
        "You want one metal treatment that reads on a pill button and a round icon alike, in light or dark mode.",
        "The effect must be self-contained and cheap to run many at once — no shaders library, no per-button WebGL context."
      ],
      "source": {
        "name": "metal.jakubantalik.com",
        "url": "https://metal.jakubantalik.com/"
      },
      "tags": [
        "metal",
        "chrome",
        "liquid",
        "webgl",
        "shader",
        "border",
        "button",
        "cta",
        "iridescent",
        "gradient",
        "animation",
        "decoration"
      ],
      "dependencies": [
        "react"
      ],
      "status": "stable",
      "demoUrl": "https://eito.me/ui/liquid-metal",
      "markdownUrl": "https://eito.me/ui/patterns/liquid-metal.md",
      "code": "import {\n  useEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type HTMLAttributes,\n  type ReactNode,\n} from \"react\";\n\n/**\n * LiquidMetal — wrap any element in an animated, real-time \"liquid metal\" ring.\n *\n * Drop it around a button, chip or icon and it draws a shimmering metallic\n * border that hugs the child's rounded corners, plus an optional soft glow.\n * The metal is a WebGL plasma shader (simplex-noise FBM warping four sine bands,\n * mapped through a five-stop metallic palette) — the same technique metal.fx\n * uses — so it reads as flowing chrome rather than a static gradient.\n *\n * It is deliberately self-contained:\n *   - No three.js, no filters, no runtime deps beyond React — raw WebGL1.\n *   - One shared GL context and one compiled program back *every* instance on\n *     the page; each wrapper only owns a cheap 2D canvas that receives a\n *     drawImage() copy per frame. Mounting fifty metal buttons still compiles\n *     the shader once and keeps one WebGL context.\n *   - The ring is a pure CSS \"gradient-border\" mask over the canvas, so it\n *     respects any border-radius and never touches the child's interactivity\n *     (pointer-events: none on every overlay).\n *   - It pauses itself when scrolled off-screen or when the tab is hidden, and\n *     honours prefers-reduced-motion by holding a single frame.\n *\n * Deps: react only.\n */\n\nexport type MetalPreset = \"chromatic\" | \"silver\" | \"gold\";\n\nexport const METAL_PRESETS: MetalPreset[] = [\"chromatic\", \"silver\", \"gold\"];\n\nexport interface LiquidMetalProps\n  extends Omit<HTMLAttributes<HTMLSpanElement>, \"children\"> {\n  /** The element the metal ring frames (a button, chip, icon…). */\n  children: ReactNode;\n  /** Palette. \"chromatic\" is an iridescent rainbow; the others are as named. */\n  preset?: MetalPreset;\n  /** \"auto\" follows the surrounding light/dark theme. */\n  theme?: \"auto\" | \"dark\" | \"light\";\n  /** Overall effect intensity, 0–1: scales the metal's opacity and its glow. */\n  strength?: number;\n  /** Corner radius of the child, in px. The ring nests just outside it. */\n  radius?: number;\n  /** Ring thickness in px. */\n  ringWidth?: number;\n  /** Draw a soft blurred halo behind the child (best on dark backgrounds). */\n  glow?: boolean;\n  /** Animation speed multiplier. */\n  speed?: number;\n  /** Freeze on the current frame. */\n  paused?: boolean;\n}\n\n/* ---- preset palettes (verbatim from metal.fx) ----------------------------- */\n\ninterface Mode {\n  colors: string[]; // 7 stops; the shader samples the first five\n  alphas: number[];\n  direction: number;\n  speed: number;\n  intensity: number;\n  scale: number;\n  distortion: number;\n  complexity: number;\n  blur: number;\n  vignette: number;\n  shaderOpacity: number;\n}\n\nconst PRESETS: Record<MetalPreset, { dark: Mode; light: Mode }> = {\n  chromatic: {\n    dark: {\n      colors: [\"#000000\", \"#aae8ff\", \"#c5fe9e\", \"#f7888d\", \"#0d0d0d\", \"#fffdc3\", \"#007cff\"],\n      alphas: [1, 1, 1, 1, 1, 1, 1],\n      direction: 80, speed: 1.2, intensity: 2, scale: 1.6, distortion: 0.3,\n      complexity: 0.68, blur: 1, vignette: 0.26, shaderOpacity: 1,\n    },\n    light: {\n      colors: [\"#ffffff\", \"#ffffff\", \"#ffffff\", \"#ffb3b3\", \"#adadad\", \"#f5ff70\", \"#007cff\"],\n      alphas: [1, 1, 1, 1, 1, 1, 1],\n      direction: 80, speed: 1.2, intensity: 2, scale: 2.5, distortion: 0.3,\n      complexity: 0.68, blur: 1, vignette: 0.24, shaderOpacity: 1,\n    },\n  },\n  silver: {\n    dark: {\n      colors: [\"#000000\", \"#dedede\", \"#747270\", \"#e5e5e5\", \"#0d0d0d\", \"#ffffff\", \"#e6e6e6\"],\n      alphas: [1, 1, 1, 1, 1, 1, 1],\n      direction: 80, speed: 1.2, intensity: 2, scale: 2.5, distortion: 0.3,\n      complexity: 0.68, blur: 1, vignette: 0.26, shaderOpacity: 0.88,\n    },\n    light: {\n      colors: [\"#f6f6f6\", \"#ffffff\", \"#ffffff\", \"#f7f7f7\", \"#c9c9c9\", \"#d0d0d0\", \"#d1d1d1\"],\n      alphas: [1, 1, 1, 1, 1, 1, 1],\n      direction: 80, speed: 1.2, intensity: 2, scale: 2.5, distortion: 0.3,\n      complexity: 0.68, blur: 1, vignette: 0.2, shaderOpacity: 1,\n    },\n  },\n  gold: {\n    dark: {\n      colors: [\"#000000\", \"#ffffff\", \"#ffffff\", \"#f7d488\", \"#0d0d0d\", \"#fffdc3\", \"#ffffff\"],\n      alphas: [1, 1, 1, 1, 1, 1, 1],\n      direction: 80, speed: 1.0, intensity: 2, scale: 2.5, distortion: 0.3,\n      complexity: 0.68, blur: 1, vignette: 0.26, shaderOpacity: 0.92,\n    },\n    light: {\n      colors: [\"#fff8e1\", \"#fffbe0\", \"#ffffff\", \"#fff6d6\", \"#d2c7a7\", \"#dcd2bc\", \"#f9f7e5\"],\n      alphas: [1, 1, 1, 1, 1, 1, 1],\n      direction: 80, speed: 1.2, intensity: 2, scale: 2.5, distortion: 0.3,\n      complexity: 0.68, blur: 1, vignette: 0.22, shaderOpacity: 1,\n    },\n  },\n};\n\nconst hexToRgb = (hex: string): [number, number, number] => {\n  const h = hex.replace(\"#\", \"\");\n  const n = parseInt(h.length === 3 ? h.replace(/(.)/g, \"$1$1\") : h, 16);\n  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];\n};\n\n/* ---- shaders (fragment verbatim from metal.fx) ---------------------------- */\n\nconst VERT = `attribute vec2 a_position;\nvoid main() { gl_Position = vec4(a_position, 0.0, 1.0); }`;\n\nconst FRAG = `precision highp float;\nuniform vec2 u_resolution;\nuniform float u_time;\nuniform vec3 u_color1, u_color2, u_color3, u_color4, u_color5;\nuniform float u_alpha1, u_alpha2, u_alpha3, u_alpha4, u_alpha5;\nuniform float u_intensity, u_scale, u_direction;\nuniform float u_distortion, u_complexity;\nuniform float u_vignette, u_blur, u_shaderOpacity;\n\nvec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }\nvec2 mod289v2(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }\nvec3 permute(vec3 x) { return mod289((x * 34.0 + 1.0) * x); }\n\nfloat snoise(vec2 v) {\n  const vec4 C = vec4(0.211324865405187, 0.366025403784439,\n                      -0.577350269189626, 0.024390243902439);\n  vec2 i = floor(v + dot(v, C.yy));\n  vec2 x0 = v - i + dot(i, C.xx);\n  vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);\n  vec4 x12 = x0.xyxy + C.xxzz;\n  x12.xy -= i1;\n  i = mod289v2(i);\n  vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));\n  vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);\n  m = m * m; m = m * m;\n  vec3 x_ = 2.0 * fract(p * C.www) - 1.0;\n  vec3 h = abs(x_) - 0.5;\n  vec3 ox = floor(x_ + 0.5);\n  vec3 a0 = x_ - ox;\n  m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);\n  vec3 g;\n  g.x = a0.x * x0.x + h.x * x0.y;\n  g.yz = a0.yz * x12.xz + h.yz * x12.yw;\n  return 130.0 * dot(m, g);\n}\n\nfloat fbm(vec2 p, float oct) {\n  float val = 0.0, amp = 0.5;\n  int n = int(oct);\n  for (int i = 0; i < 7; i++) {\n    if (i >= n) break;\n    val += amp * snoise(p);\n    p *= 2.0;\n    amp *= 0.5;\n  }\n  return val;\n}\nfloat nfbm(vec2 p) { return fbm(p, 3.0 + u_complexity * 4.0); }\n\nvec3 palette(float t) {\n  t = clamp(t, 0.0, 1.0);\n  t = t * t * (3.0 - 2.0 * t);\n  float k = 64.0;\n  float w1 = u_alpha1 * exp(-k * t * t);\n  float w2 = u_alpha2 * exp(-k * (t - 0.25) * (t - 0.25));\n  float w3 = u_alpha3 * exp(-k * (t - 0.5)  * (t - 0.5));\n  float w4 = u_alpha4 * exp(-k * (t - 0.75) * (t - 0.75));\n  float w5 = u_alpha5 * exp(-k * (t - 1.0)  * (t - 1.0));\n  float total = w1 + w2 + w3 + w4 + w5 + 0.0001;\n  return (u_color1 * w1 + u_color2 * w2 + u_color3 * w3 +\n          u_color4 * w4 + u_color5 * w5) / total;\n}\n\nvec2 warp(vec2 p, float t) {\n  float str = u_distortion * 2.0;\n  return vec2(\n    nfbm(p + vec2(t * 0.1, 0.0)),\n    nfbm(p + vec2(0.0, t * 0.12) + 5.0)\n  ) * str;\n}\n\nvec3 computeEffect(vec2 uv, float aspect, float t) {\n  vec2 p = (uv - 0.5) * u_scale;\n  p.x *= aspect;\n  p += vec2(cos(u_direction), sin(u_direction)) * t * 0.15;\n\n  float freq = 3.0 + u_complexity * 8.0;\n  float val = 0.0;\n  val += sin(p.x * freq + t);\n  val += sin(p.y * freq + t * 1.3);\n  val += sin((p.x + p.y) * freq * 0.7 + t * 0.7);\n  val += sin(length(p) * freq * 0.8 - t * 1.5);\n  vec2 w = warp(p, t);\n  val += (w.x + w.y) * u_distortion;\n  val = val * 0.2 * u_intensity + 0.5;\n  return palette(clamp(val, 0.0, 1.0));\n}\n\nvoid main() {\n  vec2 uv = gl_FragCoord.xy / u_resolution;\n  float aspect = u_resolution.x / u_resolution.y;\n  float t = u_time;\n\n  vec3 col;\n  if (u_blur < 0.01) {\n    col = computeEffect(uv, aspect, t);\n  } else {\n    float r = u_blur * 0.02;\n    col  = computeEffect(uv,                 aspect, t) * 0.4;\n    col += computeEffect(uv + vec2( r, 0.0), aspect, t) * 0.15;\n    col += computeEffect(uv + vec2(-r, 0.0), aspect, t) * 0.15;\n    col += computeEffect(uv + vec2(0.0,  r), aspect, t) * 0.15;\n    col += computeEffect(uv + vec2(0.0, -r), aspect, t) * 0.15;\n  }\n\n  col = pow(col, vec3(1.3));\n\n  float edgeDist = min(min(uv.x, 1.0 - uv.x), min(uv.y, 1.0 - uv.y));\n  float vigPx = 40.0 / min(u_resolution.x, u_resolution.y);\n  float vigRange = vigPx * (1.0 + u_vignette * 3.0);\n  float vig = edgeDist * edgeDist / (vigRange * vigRange);\n  vig = smoothstep(0.0, 1.0, vig);\n  col *= mix(1.0, vig, u_vignette);\n\n  gl_FragColor = vec4(col, u_shaderOpacity);\n}`;\n\n/* ---- shared WebGL renderer ------------------------------------------------ */\n/* One context + one program serve every LiquidMetal on the page. Each instance\n * owns only lightweight 2D canvases that receive a drawImage() copy per frame. */\n\ntype Uniforms = Record<string, WebGLUniformLocation | null>;\n\ninterface Instance {\n  targets: HTMLCanvasElement[]; // ring canvas, optional glow canvas (same size)\n  w: number; // device px\n  h: number;\n  mode: Mode;\n  strength: number;\n  speed: number;\n  paused: boolean;\n  visible: boolean;\n  elapsed: number;\n}\n\ninterface Shared {\n  gl: WebGLRenderingContext;\n  off: HTMLCanvasElement;\n  program: WebGLProgram;\n  u: Uniforms;\n}\n\nconst registry = new Set<Instance>();\nlet shared: Shared | null = null;\nlet raf = 0;\nlet lastNow = 0;\nlet glFailed = false; // WebGL unavailable — stop scheduling frames for good.\n\nconst compile = (gl: WebGLRenderingContext, type: number, src: string) => {\n  const s = gl.createShader(type)!;\n  gl.shaderSource(s, src);\n  gl.compileShader(s);\n  return s;\n};\n\nconst ensureShared = (): Shared | null => {\n  if (shared) return shared;\n  if (typeof document === \"undefined\") return null;\n  const off = document.createElement(\"canvas\");\n  off.width = 2;\n  off.height = 2;\n  const gl = off.getContext(\"webgl\", {\n    alpha: true,\n    premultipliedAlpha: false,\n    antialias: true,\n    preserveDrawingBuffer: true, // let drawImage() read the buffer after draw\n  });\n  if (!gl) return null;\n\n  const program = gl.createProgram()!;\n  gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERT));\n  gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAG));\n  gl.linkProgram(program);\n  gl.useProgram(program);\n\n  const buf = gl.createBuffer();\n  gl.bindBuffer(gl.ARRAY_BUFFER, buf);\n  // Fullscreen triangle.\n  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);\n  const loc = gl.getAttribLocation(program, \"a_position\");\n  gl.enableVertexAttribArray(loc);\n  gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);\n\n  const names = [\n    \"u_resolution\", \"u_time\", \"u_intensity\", \"u_scale\", \"u_direction\",\n    \"u_distortion\", \"u_complexity\", \"u_vignette\", \"u_blur\", \"u_shaderOpacity\",\n    \"u_color1\", \"u_color2\", \"u_color3\", \"u_color4\", \"u_color5\",\n    \"u_alpha1\", \"u_alpha2\", \"u_alpha3\", \"u_alpha4\", \"u_alpha5\",\n  ];\n  const u: Uniforms = {};\n  for (const name of names) u[name] = gl.getUniformLocation(program, name);\n\n  shared = { gl, off, program, u };\n  return shared;\n};\n\n// Render the shader on a buffer larger than the element and copy back only the\n// centre. The metal.fx vignette darkens the frame's edges — which is exactly\n// where a thin ring lives — so sampling the brighter interior keeps the ring\n// luminous instead of muddy. (metal.fx does the same with its 1.6× button scale.)\nconst OVERSCAN = 1.6;\n\nconst renderInstance = (s: Shared, inst: Instance) => {\n  const { gl, off, u } = s;\n  const { w, h, mode } = inst;\n  const bw = Math.max(2, Math.round(w * OVERSCAN));\n  const bh = Math.max(2, Math.round(h * OVERSCAN));\n  if (off.width !== bw || off.height !== bh) {\n    off.width = bw;\n    off.height = bh;\n  }\n  gl.viewport(0, 0, bw, bh);\n  gl.clearColor(0, 0, 0, 0);\n  gl.clear(gl.COLOR_BUFFER_BIT);\n\n  gl.uniform2f(u.u_resolution, bw, bh);\n  gl.uniform1f(u.u_time, inst.elapsed);\n  gl.uniform1f(u.u_intensity, mode.intensity);\n  gl.uniform1f(u.u_scale, mode.scale);\n  gl.uniform1f(u.u_direction, mode.direction);\n  gl.uniform1f(u.u_distortion, mode.distortion);\n  gl.uniform1f(u.u_complexity, mode.complexity);\n  gl.uniform1f(u.u_vignette, mode.vignette);\n  gl.uniform1f(u.u_blur, mode.blur);\n  gl.uniform1f(u.u_shaderOpacity, mode.shaderOpacity * inst.strength);\n  for (let i = 0; i < 5; i++) {\n    gl.uniform3fv(u[`u_color${i + 1}`], hexToRgb(mode.colors[i]));\n    gl.uniform1f(u[`u_alpha${i + 1}`], mode.alphas[i]);\n  }\n  gl.drawArrays(gl.TRIANGLES, 0, 3);\n\n  const sx = (bw - w) / 2;\n  const sy = (bh - h) / 2;\n  for (const canvas of inst.targets) {\n    if (canvas.width !== w || canvas.height !== h) {\n      canvas.width = w;\n      canvas.height = h;\n    }\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) continue;\n    ctx.clearRect(0, 0, w, h);\n    ctx.drawImage(off, sx, sy, w, h, 0, 0, w, h);\n  }\n};\n\nconst tick = (now: number) => {\n  const s = ensureShared();\n  if (!s) {\n    // No WebGL: give up instead of rescheduling forever on an idle registry.\n    glFailed = true;\n    raf = 0;\n    return;\n  }\n  // Cap dt so a long rAF stall (a backgrounded tab) doesn't jump the shader\n  // clock forward in a single frame when it resumes.\n  const dt = lastNow ? Math.min((now - lastNow) / 1000, 0.05) : 0;\n  lastNow = now;\n  for (const inst of registry) {\n    if (!inst.visible) continue;\n    if (!inst.paused) inst.elapsed += dt * inst.speed;\n    renderInstance(s, inst);\n  }\n  raf = registry.size ? requestAnimationFrame(tick) : 0;\n};\n\nconst register = (inst: Instance) => {\n  registry.add(inst);\n  if (!raf && !glFailed) {\n    lastNow = 0;\n    raf = requestAnimationFrame(tick);\n  }\n};\n\nconst unregister = (inst: Instance) => {\n  registry.delete(inst);\n  if (registry.size === 0 && raf) {\n    cancelAnimationFrame(raf);\n    raf = 0;\n  }\n};\n\n/* ---- theme + reduced-motion helpers --------------------------------------- */\n\nconst resolveTheme = (el: HTMLElement | null): \"dark\" | \"light\" => {\n  for (let node = el; node; node = node.parentElement) {\n    const attr = node.getAttribute(\"data-theme\");\n    if (attr === \"dark\" || attr === \"light\") return attr;\n    if (node.classList.contains(\"dark\")) return \"dark\";\n    if (node.classList.contains(\"light\")) return \"light\";\n  }\n  if (typeof window !== \"undefined\" && window.matchMedia) {\n    return window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\";\n  }\n  return \"dark\";\n};\n\n/* ---- component ------------------------------------------------------------ */\n\nconst LiquidMetal = ({\n  children,\n  preset = \"chromatic\",\n  theme = \"auto\",\n  strength = 1,\n  radius = 12,\n  ringWidth = 2,\n  glow = true,\n  speed = 1,\n  paused = false,\n  className,\n  style,\n  ...rest\n}: LiquidMetalProps) => {\n  const wrapRef = useRef<HTMLSpanElement>(null);\n  const ringRef = useRef<HTMLCanvasElement>(null);\n  const glowRef = useRef<HTMLCanvasElement>(null);\n  const elapsedRef = useRef(0); // survives instance re-creation so pause freezes\n  const [resolved, setResolved] = useState<\"dark\" | \"light\">(\"dark\");\n  const [reduce, setReduce] = useState(false);\n\n  const clampStrength = Math.max(0, Math.min(1, strength));\n\n  // Follow the surrounding theme when asked to.\n  useEffect(() => {\n    if (theme !== \"auto\") {\n      setResolved(theme);\n      return;\n    }\n    const el = wrapRef.current;\n    const update = () => setResolved(resolveTheme(el));\n    update();\n    const mq = window.matchMedia?.(\"(prefers-color-scheme: dark)\");\n    mq?.addEventListener?.(\"change\", update);\n    const mo = new MutationObserver(update);\n    for (let node = el?.parentElement; node; node = node.parentElement) {\n      mo.observe(node, { attributes: true, attributeFilter: [\"class\", \"data-theme\"] });\n    }\n    return () => {\n      mq?.removeEventListener?.(\"change\", update);\n      mo.disconnect();\n    };\n  }, [theme]);\n\n  // Live-track prefers-reduced-motion.\n  useEffect(() => {\n    const mq = window.matchMedia?.(\"(prefers-reduced-motion: reduce)\");\n    if (!mq) return;\n    const update = () => setReduce(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  const mode = PRESETS[preset][resolved];\n\n  // One registry entry drives the ring (+ optional glow) canvases.\n  useEffect(() => {\n    const wrap = wrapRef.current;\n    const ring = ringRef.current;\n    if (!wrap || !ring) return;\n\n    const dpr = Math.min(2, typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1);\n    const targets = glow && glowRef.current ? [ring, glowRef.current] : [ring];\n\n    const inst: Instance = {\n      targets,\n      w: 2,\n      h: 2,\n      mode,\n      strength: clampStrength,\n      speed,\n      paused: paused || reduce,\n      visible: true,\n      elapsed: elapsedRef.current || (reduce ? 0.6 : 0),\n    };\n\n    const measure = () => {\n      const rect = wrap.getBoundingClientRect();\n      inst.w = Math.max(2, Math.round(rect.width * dpr));\n      inst.h = Math.max(2, Math.round(rect.height * dpr));\n    };\n    measure();\n\n    register(inst);\n\n    // Viewport and tab visibility are tracked separately: the instance only\n    // renders while on-screen AND the tab is foregrounded, so returning to a\n    // tab whose wrapper is scrolled away keeps it paused.\n    let onScreen = true;\n    const syncVisible = () => {\n      inst.visible = onScreen && !document.hidden;\n    };\n    const ro = new ResizeObserver(measure);\n    ro.observe(wrap);\n    const io = new IntersectionObserver(\n      ([e]) => {\n        onScreen = e.isIntersecting;\n        syncVisible();\n      },\n      { threshold: 0 },\n    );\n    io.observe(wrap);\n    document.addEventListener(\"visibilitychange\", syncVisible);\n\n    return () => {\n      elapsedRef.current = inst.elapsed; // preserve the clock for the next instance\n      unregister(inst);\n      ro.disconnect();\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", syncVisible);\n    };\n    // Re-create the instance whenever anything about how it draws changes.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [preset, resolved, clampStrength, speed, paused, reduce, glow, ringWidth]);\n\n  const outerRadius = radius + ringWidth;\n\n  // The \"gradient-border\" mask: show the canvas only in the padding band, so\n  // the metal reads as a ring that respects the corner radius.\n  const ringMask: CSSProperties = {\n    padding: ringWidth,\n    borderRadius: outerRadius,\n    boxSizing: \"border-box\",\n    WebkitMaskImage: \"linear-gradient(#000 0 0), linear-gradient(#000 0 0)\",\n    WebkitMaskClip: \"content-box, border-box\",\n    WebkitMaskComposite: \"xor\",\n    maskImage: \"linear-gradient(#000 0 0), linear-gradient(#000 0 0)\",\n    maskClip: \"content-box, border-box\",\n    maskComposite: \"exclude\",\n  };\n\n  const canvasFill: CSSProperties = {\n    position: \"absolute\",\n    inset: 0,\n    width: \"100%\",\n    height: \"100%\",\n    display: \"block\",\n  };\n\n  const glowSpread = ringWidth * 3;\n\n  return (\n    <span\n      ref={wrapRef}\n      className={className}\n      style={{\n        position: \"relative\",\n        display: \"inline-flex\",\n        padding: ringWidth,\n        borderRadius: outerRadius,\n        ...style,\n      }}\n      {...rest}\n    >\n      {glow && (\n        <span\n          aria-hidden=\"true\"\n          style={{\n            position: \"absolute\",\n            inset: -glowSpread,\n            borderRadius: outerRadius + glowSpread,\n            filter: `blur(${Math.max(6, ringWidth * 4)}px)`,\n            opacity: 0.55 * clampStrength,\n            pointerEvents: \"none\",\n            zIndex: 0,\n          }}\n        >\n          <canvas ref={glowRef} style={canvasFill} />\n        </span>\n      )}\n\n      <span aria-hidden=\"true\" style={{ position: \"absolute\", inset: 0, pointerEvents: \"none\", zIndex: 1, ...ringMask }}>\n        <canvas ref={ringRef} style={canvasFill} />\n      </span>\n\n      {/* Content wrapper. `flex: 1` + minWidth: 0 lets it fill a stretched\n          outer (a caller passing width/flex) instead of shrink-wrapping, so a\n          full-width child sits flush inside the ring; for the default content-\n          sized outer it still hugs the content. */}\n      <span style={{ position: \"relative\", zIndex: 2, display: \"inline-flex\", flex: \"1 1 auto\", minWidth: 0, borderRadius: radius }}>\n        {children}\n      </span>\n    </span>\n  );\n};\n\nexport default LiquidMetal;"
    },
    {
      "slug": "iphone-mock",
      "title": "Photoreal iPhone app mockup",
      "category": "Marketing",
      "summary": "A pixel-honest iPhone 15 Pro hardware frame — titanium rail, Dynamic Island, physical buttons, iOS status bar and home indicator — with a real ChatGPT-for-iOS chat screen dropped inside. Drop any screen in and it scales crisply from one width prop.",
      "problem": "Screenshots of a mobile app look flat and un-shippable in a hero or a pitch; the credibility comes from the phone around them — the titanium edge, the Dynamic Island, the 9:41 status bar. Maintaining that chrome as a bitmap means re-exporting every time the screen changes. This mock authors the whole device once in a fixed logical iOS-point canvas and scales it as a single CSS transform, so every corner radius, button and status glyph stays pixel-crisp at any size and the app screen is live React you can restyle, theme, or animate.",
      "whenToUse": [
        "A landing hero or pitch deck needs a mobile app to look like it's really running on a phone.",
        "You want the device chrome (frame, Dynamic Island, status bar) crisp at any width from a single number, not a fixed-resolution PNG.",
        "The screen inside should be live, themeable UI (light/dark, real text, typing indicator) rather than a flat screenshot."
      ],
      "source": {
        "name": "ChatGPT for iOS",
        "url": "https://openai.com/chatgpt/mobile/"
      },
      "tags": [
        "iphone",
        "mockup",
        "device-frame",
        "mobile",
        "chatgpt",
        "ios",
        "dynamic-island",
        "hero",
        "marketing",
        "svg"
      ],
      "dependencies": [
        "react"
      ],
      "status": "stable",
      "demoUrl": "https://eito.me/ui/iphone-mock",
      "markdownUrl": "https://eito.me/ui/patterns/iphone-mock.md",
      "code": "import type { CSSProperties, ReactNode } from \"react\";\nimport { useId } from \"react\";\n\n/**\n * IPhoneMock — a photoreal iPhone 15 Pro hardware frame you can drop any screen\n * into, shipped with a pixel-honest ChatGPT-for-iOS chat screen as the sample\n * payload.\n *\n * The whole device is authored once in a fixed logical resolution (a 430 × 920\n * \"point\" canvas that mirrors an iPhone 15 Pro's proportions) and then scaled as\n * a single unit with a CSS transform. That is the trick that keeps it crisp and\n * simple: corner radii, the titanium rail, the Dynamic Island, the status-bar\n * glyphs and every message bubble are laid out in real iOS points, so nothing\n * has to be re-tuned when you change `width` — you set one number and the entire\n * phone scales with sub-pixel fidelity.\n *\n * Realism lives in the details that read subconsciously:\n *   - a brushed-titanium rail drawn with a horizontal metal gradient plus an\n *     inner hairline and a bright top edge (not a flat grey border),\n *   - a true pill Dynamic Island with the front-camera lens sunk into it,\n *   - the four physical buttons (Action, Volume +/−, Side) as metal nubs,\n *   - the exact iOS status bar (9:41, signal, Wi-Fi, battery) and home indicator,\n *   - ChatGPT's real chat idiom: user turns as right-aligned grey bubbles,\n *     assistant turns as full-width unbubbled text, and a pill composer.\n *\n * Deps: react only. Everything is inline SVG + inline styles, so the snippet is\n * self-contained and theme-swaps (light/dark) from a single token object.\n */\n\n/* ---- device geometry (logical iOS points) --------------------------------- */\n\nconst DEVICE_W = 430;\nconst DEVICE_H = 920;\nconst DEVICE_RADIUS = 68;\nconst FRAME = 14; // titanium rail + bezel, uniform\nconst SCREEN_RADIUS = 55;\nconst BEZEL_RING = 3; // black glass ring between the titanium frame and the screen\nconst SCREEN_W = DEVICE_W - (FRAME + BEZEL_RING) * 2; // 396 — the actual visible viewport\nconst SCREEN_H = DEVICE_H - (FRAME + BEZEL_RING) * 2; // 886\n\n/* ---- app colour tokens ----------------------------------------------------- */\n\nexport type Scheme = \"light\" | \"dark\";\n\ninterface AppTokens {\n  canvas: string; // screen background\n  ink: string; // primary text\n  sub: string; // secondary text / icons\n  faint: string; // hairlines\n  bubble: string; // user bubble fill\n  bubbleInk: string; // user bubble text\n  composer: string; // composer pill fill\n  composerBorder: string;\n  accent: string; // send / active\n  statusInk: string; // status-bar glyphs (over canvas)\n}\n\nconst TOKENS: Record<Scheme, AppTokens> = {\n  light: {\n    canvas: \"#ffffff\",\n    ink: \"#0d0d0d\",\n    sub: \"#8e8ea0\",\n    faint: \"#ececf1\",\n    bubble: \"#f4f4f4\",\n    bubbleInk: \"#0d0d0d\",\n    composer: \"#ffffff\",\n    composerBorder: \"#e5e5e5\",\n    accent: \"#0d0d0d\",\n    statusInk: \"#0d0d0d\",\n  },\n  dark: {\n    canvas: \"#212121\",\n    ink: \"#ececec\",\n    sub: \"#9a9a9a\",\n    faint: \"#ffffff1a\",\n    bubble: \"#303030\",\n    bubbleInk: \"#ececec\",\n    composer: \"#2f2f2f\",\n    composerBorder: \"#ffffff14\",\n    accent: \"#ffffff\",\n    statusInk: \"#ffffff\",\n  },\n};\n\n/* ---- conversation model ---------------------------------------------------- */\n\nexport interface Turn {\n  role: \"user\" | \"assistant\";\n  text: string;\n  /** Render the assistant turn as an animated typing indicator. */\n  typing?: boolean;\n}\n\n/** The sample script shown in the demo — swap freely, the shell renders any. */\nexport const sampleConversation: Turn[] = [\n  { role: \"user\", text: \"Explain quantum entanglement like I'm five 🧠\" },\n  {\n    role: \"assistant\",\n    text: \"Imagine two magic coins. You flip them into separate boxes and mail one to the Moon. The instant you peek at yours and see heads, you *know* the other is tails — no matter how far apart they are. The coins were linked from the start. That linked-ness is entanglement.\",\n  },\n  { role: \"user\", text: \"Whoa. So can it send messages faster than light? 🚀\" },\n  {\n    role: \"assistant\",\n    text: \"Nope — and that's the beautiful catch. You only get a random result until you compare notes over a normal (slower-than-light) channel. So the universe stays safe, but the correlation is still spooky.\",\n  },\n  { role: \"assistant\", text: \"\", typing: true },\n];\n\n/* ---- brand + system glyphs (inline SVG, no assets) ------------------------- */\n\n/** OpenAI \"blossom\" knot — stylised so the snippet ships with no external asset. */\nfunction ChatGPTMark({ size = 22, color = \"currentColor\" }: { size?: number; color?: string }) {\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 256 260\" aria-hidden=\"true\">\n      <path\n        fill={color}\n        d=\"M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z\"\n      />\n    </svg>\n  );\n}\n\n/* iOS status-bar glyphs, drawn to the real proportions. */\n\nfunction SignalGlyph({ color }: { color: string }) {\n  // Four ascending cellular bars.\n  return (\n    <svg width=\"18\" height=\"12\" viewBox=\"0 0 18 12\" aria-hidden=\"true\">\n      {[0, 1, 2, 3].map((i) => (\n        <rect\n          key={i}\n          x={i * 4.6}\n          y={9 - i * 3}\n          width=\"3\"\n          height={3 + i * 3}\n          rx=\"1\"\n          fill={color}\n        />\n      ))}\n    </svg>\n  );\n}\n\nfunction WifiGlyph({ color }: { color: string }) {\n  return (\n    <svg width=\"17\" height=\"12\" viewBox=\"0 0 17 12\" aria-hidden=\"true\" fill=\"none\">\n      <path d=\"M8.5 2C5.2 2 2.2 3.3 0 5.4l1.6 1.7C3.4 5.4 5.8 4.4 8.5 4.4s5.1 1 6.9 2.7L17 5.4C14.8 3.3 11.8 2 8.5 2Z\" fill={color} />\n      <path d=\"M8.5 6.4c-1.9 0-3.6.7-4.9 2l1.7 1.7c.8-.8 2-1.3 3.2-1.3s2.4.5 3.2 1.3l1.7-1.7c-1.3-1.3-3-2-4.9-2Z\" fill={color} />\n      <circle cx=\"8.5\" cy=\"11\" r=\"1.4\" fill={color} />\n    </svg>\n  );\n}\n\nfunction BatteryGlyph({ color }: { color: string }) {\n  return (\n    <svg width=\"28\" height=\"13\" viewBox=\"0 0 28 13\" aria-hidden=\"true\">\n      <rect x=\"0.5\" y=\"0.5\" width=\"23\" height=\"12\" rx=\"3.5\" fill=\"none\" stroke={color} strokeOpacity=\"0.4\" />\n      <rect x=\"2\" y=\"2\" width=\"18\" height=\"9\" rx=\"2\" fill={color} />\n      <rect x=\"25\" y=\"4\" width=\"2\" height=\"5\" rx=\"1\" fill={color} fillOpacity=\"0.4\" />\n    </svg>\n  );\n}\n\n/* Nav + composer glyphs. */\n\nfunction SidebarGlyph({ color }: { color: string }) {\n  return (\n    <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\">\n      <rect x=\"3\" y=\"4.5\" width=\"18\" height=\"15\" rx=\"3.2\" stroke={color} strokeWidth=\"1.7\" />\n      <line x1=\"9\" y1=\"4.5\" x2=\"9\" y2=\"19.5\" stroke={color} strokeWidth=\"1.7\" />\n    </svg>\n  );\n}\n\nfunction ComposeGlyph({ color }: { color: string }) {\n  return (\n    <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\">\n      <path\n        d=\"M4 20h4l10.5-10.5a2.12 2.12 0 0 0-3-3L5 17v3Z\"\n        stroke={color}\n        strokeWidth=\"1.7\"\n        strokeLinejoin=\"round\"\n      />\n      <path d=\"M13.5 6.5l3 3\" stroke={color} strokeWidth=\"1.7\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nfunction ChevronDown({ color }: { color: string }) {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\">\n      <path d=\"M4 6l4 4 4-4\" stroke={color} strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nfunction PlusGlyph({ color }: { color: string }) {\n  return (\n    <svg width=\"22\" height=\"22\" viewBox=\"0 0 22 22\" fill=\"none\" aria-hidden=\"true\">\n      <line x1=\"11\" y1=\"5.5\" x2=\"11\" y2=\"16.5\" stroke={color} strokeWidth=\"2\" strokeLinecap=\"round\" />\n      <line x1=\"5.5\" y1=\"11\" x2=\"16.5\" y2=\"11\" stroke={color} strokeWidth=\"2\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nfunction MicGlyph({ color }: { color: string }) {\n  return (\n    <svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\">\n      <rect x=\"9\" y=\"3\" width=\"6\" height=\"11\" rx=\"3\" fill={color} />\n      <path d=\"M6 11a6 6 0 0 0 12 0M12 17v3\" stroke={color} strokeWidth=\"1.8\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nfunction WaveformGlyph({ color }: { color: string }) {\n  // The \"voice mode\" audio button — stacked bars inside a filled disc.\n  return (\n    <svg width=\"18\" height=\"18\" viewBox=\"0 0 18 18\" aria-hidden=\"true\">\n      {[\n        [3, 6, 6],\n        [6.5, 3, 12],\n        [10, 5, 8],\n        [13.5, 7, 4],\n      ].map(([x, y, h], i) => (\n        <rect key={i} x={x} y={y} width=\"1.8\" height={h} rx=\"0.9\" fill={color} />\n      ))}\n    </svg>\n  );\n}\n\n/* ---- screen: ChatGPT for iOS ---------------------------------------------- */\n\nexport interface ChatGPTScreenProps {\n  scheme?: Scheme;\n  conversation?: Turn[];\n  /** Header model label, e.g. \"ChatGPT 5\" or \"ChatGPT 4o\". */\n  model?: string;\n  /** Status-bar clock. iOS marketing shots always read 9:41. */\n  time?: string;\n}\n\n/**\n * A faithful ChatGPT-for-iOS screen: iOS status bar, the model-name nav bar,\n * a full-width assistant / right-bubbled user thread, and the pill composer.\n * Sized to the phone's inner screen (402 × 892 pt) so it drops straight into\n * IPhoneMock, but it renders standalone too.\n */\nexport function ChatGPTScreen({\n  scheme = \"light\",\n  conversation = sampleConversation,\n  model = \"ChatGPT 5\",\n  time = \"9:41\",\n}: ChatGPTScreenProps) {\n  const t = TOKENS[scheme];\n  const styleId = useId().replace(/[:]/g, \"\");\n\n  return (\n    <div\n      style={{\n        width: SCREEN_W,\n        height: SCREEN_H,\n        background: t.canvas,\n        color: t.ink,\n        display: \"flex\",\n        flexDirection: \"column\",\n        fontFamily:\n          '-apple-system, BlinkMacSystemFont, \"SF Pro Text\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif',\n        WebkitFontSmoothing: \"antialiased\",\n        overflow: \"hidden\",\n      }}\n    >\n      <style>{`\n        @keyframes ${styleId}-blink { 0%,60%,100%{opacity:.25} 30%{opacity:1} }\n        .${styleId}-dot { animation: ${styleId}-blink 1.3s infinite ease-in-out; }\n      `}</style>\n\n      {/* Status bar — 9:41 · signal · Wi-Fi · battery */}\n      <div\n        style={{\n          height: 54,\n          flexShrink: 0,\n          display: \"flex\",\n          alignItems: \"center\",\n          justifyContent: \"space-between\",\n          padding: \"0 30px\",\n          paddingTop: 8,\n        }}\n      >\n        <span style={{ fontSize: 17, fontWeight: 600, letterSpacing: 0.2, color: t.statusInk }}>\n          {time}\n        </span>\n        <div style={{ display: \"flex\", alignItems: \"center\", gap: 7 }}>\n          <SignalGlyph color={t.statusInk} />\n          <WifiGlyph color={t.statusInk} />\n          <BatteryGlyph color={t.statusInk} />\n        </div>\n      </div>\n\n      {/* Nav bar — sidebar · model name (tappable) · new chat */}\n      <div\n        style={{\n          height: 46,\n          flexShrink: 0,\n          display: \"grid\",\n          gridTemplateColumns: \"44px 1fr 44px\",\n          alignItems: \"center\",\n          padding: \"0 12px\",\n        }}\n      >\n        <SidebarGlyph color={t.ink} />\n        <button\n          type=\"button\"\n          style={{\n            justifySelf: \"center\",\n            display: \"inline-flex\",\n            alignItems: \"center\",\n            gap: 4,\n            border: \"none\",\n            background: \"transparent\",\n            color: t.ink,\n            fontSize: 17,\n            fontWeight: 600,\n            cursor: \"pointer\",\n            padding: 0,\n          }}\n        >\n          {model}\n          <span style={{ opacity: 0.4 }}>\n            <ChevronDown color={t.ink} />\n          </span>\n        </button>\n        <span style={{ justifySelf: \"end\" }}>\n          <ComposeGlyph color={t.ink} />\n        </span>\n      </div>\n\n      {/* Thread */}\n      <div\n        style={{\n          flex: 1,\n          overflow: \"hidden\",\n          display: \"flex\",\n          flexDirection: \"column\",\n          gap: 18,\n          padding: \"14px 18px 8px\",\n        }}\n      >\n        {conversation.map((turn, i) =>\n          turn.role === \"user\" ? (\n            <div key={i} style={{ display: \"flex\", justifyContent: \"flex-end\" }}>\n              <div\n                style={{\n                  maxWidth: \"78%\",\n                  background: t.bubble,\n                  color: t.bubbleInk,\n                  padding: \"11px 16px\",\n                  borderRadius: 22,\n                  borderBottomRightRadius: 8,\n                  fontSize: 16.5,\n                  lineHeight: 1.4,\n                }}\n              >\n                {turn.text}\n              </div>\n            </div>\n          ) : (\n            <div key={i} style={{ display: \"flex\", gap: 10, alignItems: \"flex-start\" }}>\n              <div\n                style={{\n                  flexShrink: 0,\n                  width: 30,\n                  height: 30,\n                  borderRadius: \"50%\",\n                  border: `1px solid ${t.faint}`,\n                  display: \"flex\",\n                  alignItems: \"center\",\n                  justifyContent: \"center\",\n                  color: t.ink,\n                }}\n              >\n                <ChatGPTMark size={19} color={t.ink} />\n              </div>\n              {turn.typing ? (\n                <div style={{ display: \"flex\", gap: 5, paddingTop: 12 }}>\n                  {[0, 1, 2].map((d) => (\n                    <span\n                      key={d}\n                      className={`${styleId}-dot`}\n                      style={{\n                        width: 7,\n                        height: 7,\n                        borderRadius: \"50%\",\n                        background: t.sub,\n                        animationDelay: `${d * 0.18}s`,\n                      }}\n                    />\n                  ))}\n                </div>\n              ) : (\n                <div style={{ fontSize: 16.5, lineHeight: 1.5, color: t.ink, paddingTop: 3 }}>\n                  {renderRich(turn.text)}\n                </div>\n              )}\n            </div>\n          ),\n        )}\n      </div>\n\n      {/* Composer — plus · placeholder · mic · voice */}\n      <div style={{ flexShrink: 0, padding: \"6px 14px 8px\" }}>\n        <div\n          style={{\n            display: \"flex\",\n            alignItems: \"center\",\n            gap: 10,\n            background: t.composer,\n            border: `1px solid ${t.composerBorder}`,\n            borderRadius: 26,\n            padding: \"8px 10px 8px 12px\",\n            boxShadow: scheme === \"light\" ? \"0 1px 2px rgba(0,0,0,0.04)\" : \"none\",\n          }}\n        >\n          <PlusGlyph color={t.sub} />\n          <span style={{ flex: 1, fontSize: 16.5, color: t.sub }}>Ask anything</span>\n          <MicGlyph color={t.ink} />\n          <span\n            style={{\n              width: 34,\n              height: 34,\n              borderRadius: \"50%\",\n              background: t.accent,\n              display: \"inline-flex\",\n              alignItems: \"center\",\n              justifyContent: \"center\",\n              flexShrink: 0,\n            }}\n          >\n            <WaveformGlyph color={t.canvas} />\n          </span>\n        </div>\n      </div>\n\n      {/* Home indicator */}\n      <div style={{ height: 26, flexShrink: 0, display: \"flex\", justifyContent: \"center\", alignItems: \"center\" }}>\n        <div style={{ width: 138, height: 5, borderRadius: 3, background: t.ink, opacity: 0.85 }} />\n      </div>\n    </div>\n  );\n}\n\n/** Minimal *italic* renderer so the sample copy can carry light emphasis. */\nfunction renderRich(text: string): ReactNode {\n  return text.split(/(\\*[^*]+\\*)/g).map((chunk, i) =>\n    chunk.startsWith(\"*\") && chunk.endsWith(\"*\") ? (\n      <em key={i}>{chunk.slice(1, -1)}</em>\n    ) : (\n      <span key={i}>{chunk}</span>\n    ),\n  );\n}\n\n/* ---- hardware: the iPhone 15 Pro frame ------------------------------------ */\n\n/** A physical side button drawn as a titanium nub. */\nfunction SideButton({\n  side,\n  top,\n  height,\n}: {\n  side: \"left\" | \"right\";\n  top: number;\n  height: number;\n}) {\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        top,\n        height,\n        width: 3,\n        [side]: -1,\n        borderRadius: 2,\n        background:\n          side === \"left\"\n            ? \"linear-gradient(90deg,#3a3a3d,#6b6b70 60%,#48484b)\"\n            : \"linear-gradient(90deg,#48484b,#6b6b70 40%,#3a3a3d)\",\n        boxShadow: \"inset 0 1px 1px rgba(255,255,255,0.25)\",\n      }}\n    />\n  );\n}\n\nexport interface IPhoneMockProps {\n  /** Rendered width in CSS px. The whole device scales from here. Default 300. */\n  width?: number;\n  /** Screen payload. Defaults to the bundled ChatGPT screen. */\n  children?: ReactNode;\n  /** Which titanium finish the frame reads as. */\n  titanium?: \"natural\" | \"black\" | \"blue\";\n  style?: CSSProperties;\n}\n\nconst RAILS: Record<NonNullable<IPhoneMockProps[\"titanium\"]>, string> = {\n  // Horizontal metal gradient: dark edge → bright specular → dark edge.\n  natural: \"linear-gradient(90deg,#2c2c2e 0%,#8b8b90 12%,#5a5a5f 30%,#3d3d40 50%,#5a5a5f 70%,#9a9a9f 88%,#2c2c2e 100%)\",\n  black: \"linear-gradient(90deg,#141416 0%,#4a4a4d 12%,#2a2a2c 30%,#151517 50%,#2a2a2c 70%,#525256 88%,#131315 100%)\",\n  blue: \"linear-gradient(90deg,#2a3340 0%,#7d8a9c 12%,#495260 30%,#333c48 50%,#495260 70%,#8792a4 88%,#28313d 100%)\",\n};\n\n/**\n * The device frame. Everything inside is authored in the 430 × 920 logical\n * canvas and scaled together via a single transform, so the phone stays crisp\n * at any `width` and the caller only ever thinks in one number.\n */\nexport default function IPhoneMock({\n  width = 300,\n  children,\n  titanium = \"natural\",\n  style,\n}: IPhoneMockProps) {\n  const scale = width / DEVICE_W;\n\n  return (\n    <div\n      style={{\n        width: DEVICE_W * scale,\n        height: DEVICE_H * scale,\n        ...style,\n      }}\n    >\n      <div\n        style={{\n          width: DEVICE_W,\n          height: DEVICE_H,\n          transform: `scale(${scale})`,\n          transformOrigin: \"top left\",\n          position: \"relative\",\n        }}\n      >\n        {/* Physical buttons — behind the metal so they read as extrusions */}\n        <SideButton side=\"left\" top={196} height={30} /> {/* Action button */}\n        <SideButton side=\"left\" top={250} height={62} /> {/* Volume up */}\n        <SideButton side=\"left\" top={330} height={62} /> {/* Volume down */}\n        <SideButton side=\"right\" top={286} height={104} /> {/* Side button */}\n\n        {/* Titanium frame */}\n        <div\n          style={{\n            position: \"absolute\",\n            inset: 0,\n            borderRadius: DEVICE_RADIUS,\n            background: RAILS[titanium],\n            padding: FRAME,\n            boxShadow: [\n              \"0 30px 60px -15px rgba(0,0,0,0.55)\", // cast shadow\n              \"0 4px 12px rgba(0,0,0,0.4)\",\n              \"inset 0 0 0 1px rgba(255,255,255,0.18)\", // bright rail top-line\n              \"inset 0 -2px 6px rgba(0,0,0,0.5)\",\n            ].join(\",\"),\n            boxSizing: \"border-box\",\n          }}\n        >\n          {/* Black bezel ring — the sliver of glass surround before the screen */}\n          <div\n            style={{\n              width: \"100%\",\n              height: \"100%\",\n              borderRadius: SCREEN_RADIUS + 2,\n              background: \"#000\",\n              padding: 3,\n              boxSizing: \"border-box\",\n              boxShadow: \"inset 0 0 0 1px rgba(0,0,0,0.9)\",\n            }}\n          >\n            {/* Screen — clips the app content */}\n            <div\n              style={{\n                position: \"relative\",\n                width: \"100%\",\n                height: \"100%\",\n                borderRadius: SCREEN_RADIUS,\n                overflow: \"hidden\",\n                background: \"#000\",\n              }}\n            >\n              {children ?? <ChatGPTScreen />}\n\n              {/* Dynamic Island — sits above the app, with a sunken lens */}\n              <div\n                style={{\n                  position: \"absolute\",\n                  top: 12,\n                  left: \"50%\",\n                  transform: \"translateX(-50%)\",\n                  width: 118,\n                  height: 34,\n                  borderRadius: 20,\n                  background: \"#000\",\n                  display: \"flex\",\n                  alignItems: \"center\",\n                  justifyContent: \"flex-end\",\n                  paddingRight: 12,\n                  boxShadow: \"inset 0 0 2px rgba(255,255,255,0.06)\",\n                  pointerEvents: \"none\",\n                }}\n              >\n                <div\n                  style={{\n                    width: 11,\n                    height: 11,\n                    borderRadius: \"50%\",\n                    background:\n                      \"radial-gradient(circle at 35% 30%, #2b3a4a 0%, #0a0f16 55%, #000 100%)\",\n                    boxShadow: \"inset 0 0 1px rgba(120,170,220,0.5)\",\n                  }}\n                />\n              </div>\n\n              {/* Screen glare — a soft diagonal sheen over the glass */}\n              <div\n                aria-hidden=\"true\"\n                style={{\n                  position: \"absolute\",\n                  inset: 0,\n                  borderRadius: SCREEN_RADIUS,\n                  background:\n                    \"linear-gradient(125deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0) 22%, rgba(255,255,255,0) 100%)\",\n                  pointerEvents: \"none\",\n                }}\n              />\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}"
    },
    {
      "slug": "macbook-mock",
      "title": "Photoreal MacBook app mockup",
      "category": "Marketing",
      "summary": "A pixel-honest 14\" MacBook Pro hardware frame — brushed-aluminium lid, camera notch, tapered base with the finger-groove, and a soft contact shadow — with a real Claude-for-desktop chat screen dropped inside. Drop any screen in and it scales crisply from one width prop.",
      "problem": "A desktop app screenshot looks flat in a hero or a pitch; the credibility comes from the machine around it — the aluminium lid, the notch, the traffic-light window chrome. Maintaining that chrome as a bitmap means re-exporting every time the screen changes. This mock authors the whole laptop once in a fixed logical-point canvas and scales it as a single CSS transform, so every corner radius, the notch and the base taper stay pixel-crisp at any size and the app screen is live React you can restyle, theme, or animate.",
      "whenToUse": [
        "A landing hero or pitch deck needs a web/desktop app to look like it's really running on a Mac.",
        "You want the device chrome (lid, notch, base, window traffic lights) crisp at any width from a single number, not a fixed-resolution PNG.",
        "The screen inside should be live, themeable UI (light/dark, real text, typing indicator) rather than a flat screenshot."
      ],
      "source": {
        "name": "Claude for Mac",
        "url": "https://claude.ai/download"
      },
      "tags": [
        "macbook",
        "mockup",
        "device-frame",
        "laptop",
        "desktop",
        "claude",
        "macos",
        "notch",
        "hero",
        "marketing",
        "svg"
      ],
      "dependencies": [
        "react"
      ],
      "status": "stable",
      "demoUrl": "https://eito.me/ui/macbook-mock",
      "markdownUrl": "https://eito.me/ui/patterns/macbook-mock.md",
      "code": "import type { CSSProperties, ReactNode } from \"react\";\nimport { useId } from \"react\";\n\n/**\n * MacBookMock — a photoreal MacBook Pro hardware frame you can drop any screen\n * into, shipped with a pixel-honest Claude-desktop chat screen as the sample\n * payload.\n *\n * Same trick as its iPhone sibling: the whole laptop is authored once in a\n * fixed logical canvas (a 1118 × 704 \"point\" space that mirrors a 14\" MacBook\n * Pro seen head-on) and then scaled as a single unit with one CSS transform.\n * The aluminium lid, the black-glass bezel, the camera notch, the tapered base\n * with its finger-groove and every pixel of the app are laid out in real\n * points, so nothing is re-tuned when you change `width` — you set one number\n * and the entire machine scales with sub-pixel fidelity.\n *\n * Realism lives in the details that read subconsciously:\n *   - a brushed-aluminium lid rim and a vertically-lit tapered base (not a flat\n *     grey box), with a soft contact shadow on the ground beneath it,\n *   - the real MacBook Pro camera notch dipping into the top of the display,\n *   - a macOS window shell: traffic-light buttons, a warm sidebar, a model\n *     selector, and the exact Claude chat idiom (right-aligned user bubbles,\n *     full-width Claude turns behind the sunburst mark, and the pill composer),\n *   - two chassis finishes (Silver / Space Black) and a light/dark app theme,\n *     both live React — no bitmaps.\n *\n * Deps: react only. Everything is inline SVG + inline styles, so the snippet is\n * self-contained and theme-swaps from a single token object.\n */\n\n/* ---- device geometry (logical points, 14\" MacBook Pro head-on) ------------ */\n\nconst DISPLAY_W = 1000;\nconst DISPLAY_H = 640; // ~16:10.24\nconst BEZEL = 13; // black-glass border around the display\nconst RIM = 6; // thin aluminium edge outside the glass\nconst INSET = BEZEL + RIM; // 19\nconst LID_W = DISPLAY_W + INSET * 2; // 1038\nconst LID_H = DISPLAY_H + INSET * 2; // 678\nconst LID_RADIUS = 26;\nconst SCREEN_RADIUS = 8;\nconst NOTCH_W = 190;\nconst NOTCH_H = 20;\nconst BASE_H = 26;\nconst BASE_OVERHANG = 40; // base flares this far past the lid on each side\nconst BASE_W = LID_W + BASE_OVERHANG * 2; // 1118\n\nconst DEVICE_W = BASE_W; // 1118\nconst DEVICE_H = LID_H + BASE_H; // 704\n\n/* ---- app colour tokens (Claude desktop) ----------------------------------- */\n\nexport type Scheme = \"light\" | \"dark\";\n\ninterface AppTokens {\n  canvas: string; // main chat background\n  sidebar: string; // sidebar fill\n  ink: string; // primary text\n  sub: string; // secondary text / icons\n  faint: string; // hairlines / borders\n  active: string; // active sidebar row\n  bubble: string; // user bubble fill\n  bubbleInk: string;\n  composer: string; // composer surface\n  composerBorder: string;\n  accent: string; // Claude clay / send button\n  accentInk: string; // glyph on the accent\n}\n\nconst TOKENS: Record<Scheme, AppTokens> = {\n  light: {\n    canvas: \"#faf9f5\",\n    sidebar: \"#f2f0e9\",\n    ink: \"#262624\",\n    sub: \"#8a897f\",\n    faint: \"#e7e3d8\",\n    active: \"#e7e2d5\",\n    bubble: \"#f0e8d9\",\n    bubbleInk: \"#33322d\",\n    composer: \"#ffffff\",\n    composerBorder: \"#e5e0d3\",\n    accent: \"#d97757\",\n    accentInk: \"#ffffff\",\n  },\n  dark: {\n    canvas: \"#262624\",\n    sidebar: \"#1f1e1d\",\n    ink: \"#f5f4ee\",\n    sub: \"#9d9b90\",\n    faint: \"#ffffff14\",\n    active: \"#35332e\",\n    bubble: \"#37342e\",\n    bubbleInk: \"#f0ede4\",\n    composer: \"#30302e\",\n    composerBorder: \"#ffffff1f\",\n    accent: \"#d97757\",\n    accentInk: \"#fdf6f1\",\n  },\n};\n\n/* ---- conversation model ---------------------------------------------------- */\n\nexport interface Turn {\n  role: \"user\" | \"assistant\";\n  /** Supports **bold**, *italic*, and blank-line paragraphs / single newlines. */\n  text: string;\n  /** Render the assistant turn as an animated typing indicator. */\n  typing?: boolean;\n}\n\n/** The sample script shown in the demo — swap freely, the shell renders any. */\nexport const sampleConversation: Turn[] = [\n  {\n    role: \"user\",\n    text: \"Draft a warm but concise out-of-office reply — I'm away Aug 27 – Sep 8. ✈️\",\n  },\n  {\n    role: \"assistant\",\n    text: \"Here's one that stays friendly without over-explaining:\\n\\n**Subject:** Out of office until Sep 8\\n\\nThanks for your note! I'm away until **September 8** with limited access to email, so I may be slow to reply. I'll get back to you as soon as I'm back at my desk.\\n\\nFor anything urgent, please reach out to my team at *team@example.com*.\\n\\nBest,\\nEito\",\n  },\n  { role: \"user\", text: \"Love it. Make the tone a touch more formal.\" },\n  { role: \"assistant\", text: \"\", typing: true },\n];\n\nconst RECENTS = [\n  \"Out-of-office reply\",\n  \"Refactor auth middleware\",\n  \"Kyoto trip itinerary\",\n  \"Explain diffusion models\",\n  \"Weekly newsletter draft\",\n];\n\n/* ---- brand + system glyphs (inline SVG, no assets) ------------------------- */\n\n/** Claude \"sunburst\" mark — radiating rounded rays, drawn from a single loop. */\nfunction ClaudeBurst({ size = 20, color = \"#d97757\" }: { size?: number; color?: string }) {\n  const rays = 12;\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n      <g fill={color}>\n        {Array.from({ length: rays }).map((_, i) => (\n          <rect\n            key={i}\n            x={11.05}\n            y={1.8}\n            width={1.9}\n            height={9.2}\n            rx={0.95}\n            transform={`rotate(${(i * 360) / rays} 12 12)`}\n          />\n        ))}\n      </g>\n    </svg>\n  );\n}\n\nfunction EditGlyph({ color }: { color: string }) {\n  return (\n    <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\">\n      <path\n        d=\"M4 20h4L18.5 9.5a2.12 2.12 0 0 0-3-3L5 17v3Z\"\n        stroke={color}\n        strokeWidth=\"1.7\"\n        strokeLinejoin=\"round\"\n      />\n      <path d=\"M13.5 6.5l3 3\" stroke={color} strokeWidth=\"1.7\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nfunction SidebarGlyph({ color }: { color: string }) {\n  return (\n    <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\">\n      <rect x=\"3\" y=\"4.5\" width=\"18\" height=\"15\" rx=\"3.2\" stroke={color} strokeWidth=\"1.7\" />\n      <line x1=\"9\" y1=\"4.5\" x2=\"9\" y2=\"19.5\" stroke={color} strokeWidth=\"1.7\" />\n    </svg>\n  );\n}\n\nfunction SearchGlyph({ color }: { color: string }) {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\">\n      <circle cx=\"11\" cy=\"11\" r=\"6.5\" stroke={color} strokeWidth=\"1.7\" />\n      <line x1=\"16\" y1=\"16\" x2=\"20.5\" y2=\"20.5\" stroke={color} strokeWidth=\"1.7\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nfunction ChevronDown({ color, size = 14 }: { color: string; size?: number }) {\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\">\n      <path d=\"M4 6l4 4 4-4\" stroke={color} strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nfunction PlusGlyph({ color }: { color: string }) {\n  return (\n    <svg width=\"20\" height=\"20\" viewBox=\"0 0 22 22\" fill=\"none\" aria-hidden=\"true\">\n      <line x1=\"11\" y1=\"5\" x2=\"11\" y2=\"17\" stroke={color} strokeWidth=\"1.9\" strokeLinecap=\"round\" />\n      <line x1=\"5\" y1=\"11\" x2=\"17\" y2=\"11\" stroke={color} strokeWidth=\"1.9\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\nfunction ArrowUp({ color }: { color: string }) {\n  return (\n    <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\">\n      <line x1=\"12\" y1=\"19\" x2=\"12\" y2=\"6\" stroke={color} strokeWidth=\"2.2\" strokeLinecap=\"round\" />\n      <path d=\"M6 12l6-6 6 6\" stroke={color} strokeWidth=\"2.2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nfunction ShareGlyph({ color }: { color: string }) {\n  return (\n    <svg width=\"17\" height=\"17\" viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\">\n      <circle cx=\"18\" cy=\"5\" r=\"2.6\" stroke={color} strokeWidth=\"1.6\" />\n      <circle cx=\"6\" cy=\"12\" r=\"2.6\" stroke={color} strokeWidth=\"1.6\" />\n      <circle cx=\"18\" cy=\"19\" r=\"2.6\" stroke={color} strokeWidth=\"1.6\" />\n      <path d=\"M8.3 10.7l7.4-4.3M8.3 13.3l7.4 4.3\" stroke={color} strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n\n/* ---- screen: Claude for desktop ------------------------------------------- */\n\nexport interface ClaudeScreenProps {\n  scheme?: Scheme;\n  conversation?: Turn[];\n  /** Model label shown in the header + composer, e.g. \"Claude Opus 4.5\". */\n  model?: string;\n  /** Signed-in display name, shown in the sidebar footer. */\n  user?: string;\n}\n\n/**\n * A faithful Claude-desktop screen: the macOS traffic lights, a warm sidebar\n * with recents and an account footer, a model-selector header, the centred\n * chat column (right-bubbled user turns, full-width Claude turns behind the\n * sunburst), and the pill composer. Sized to the laptop's inner display\n * (1000 × 640 pt) so it drops straight into MacBookMock, but renders standalone.\n */\nexport function ClaudeScreen({\n  scheme = \"light\",\n  conversation = sampleConversation,\n  model = \"Claude Opus 4.5\",\n  user = \"Eito Miyamura\",\n}: ClaudeScreenProps) {\n  const t = TOKENS[scheme];\n  const styleId = useId().replace(/[:]/g, \"\");\n\n  return (\n    <div\n      style={{\n        width: DISPLAY_W,\n        height: DISPLAY_H,\n        background: t.canvas,\n        color: t.ink,\n        display: \"flex\",\n        fontFamily:\n          '-apple-system, BlinkMacSystemFont, \"SF Pro Text\", \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif',\n        WebkitFontSmoothing: \"antialiased\",\n        overflow: \"hidden\",\n      }}\n    >\n      <style>{`\n        @keyframes ${styleId}-blink { 0%,60%,100%{opacity:.25} 30%{opacity:1} }\n        .${styleId}-dot { animation: ${styleId}-blink 1.3s infinite ease-in-out; }\n      `}</style>\n\n      {/* ---- Sidebar ---- */}\n      <aside\n        style={{\n          width: 236,\n          flexShrink: 0,\n          background: t.sidebar,\n          borderRight: `1px solid ${t.faint}`,\n          display: \"flex\",\n          flexDirection: \"column\",\n          padding: \"0 10px\",\n        }}\n      >\n        {/* traffic lights + collapse */}\n        <div\n          style={{\n            height: 44,\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"space-between\",\n            paddingLeft: 4,\n          }}\n        >\n          <div style={{ display: \"flex\", gap: 8 }}>\n            {[\"#ff5f57\", \"#febc2e\", \"#28c840\"].map((c) => (\n              <span\n                key={c}\n                style={{\n                  width: 12,\n                  height: 12,\n                  borderRadius: \"50%\",\n                  background: c,\n                  boxShadow: \"inset 0 0 0 0.5px rgba(0,0,0,0.18)\",\n                }}\n              />\n            ))}\n          </div>\n          <span style={{ opacity: 0.55 }}>\n            <SidebarGlyph color={t.sub} />\n          </span>\n        </div>\n\n        {/* new chat */}\n        <button\n          type=\"button\"\n          style={{\n            display: \"flex\",\n            alignItems: \"center\",\n            gap: 9,\n            width: \"100%\",\n            border: \"none\",\n            background: \"transparent\",\n            color: t.accent,\n            fontSize: 14.5,\n            fontWeight: 600,\n            padding: \"9px 8px\",\n            borderRadius: 9,\n            cursor: \"pointer\",\n          }}\n        >\n          <EditGlyph color={t.accent} />\n          New chat\n        </button>\n\n        {/* search */}\n        <div\n          style={{\n            display: \"flex\",\n            alignItems: \"center\",\n            gap: 8,\n            padding: \"8px 9px\",\n            margin: \"2px 0 10px\",\n            borderRadius: 9,\n            color: t.sub,\n            fontSize: 13.5,\n          }}\n        >\n          <SearchGlyph color={t.sub} />\n          <span style={{ flex: 1 }}>Search chats</span>\n          <span style={{ fontSize: 11.5, opacity: 0.7 }}>⌘K</span>\n        </div>\n\n        {/* recents */}\n        <div\n          style={{\n            fontSize: 11,\n            fontWeight: 600,\n            letterSpacing: 0.4,\n            textTransform: \"uppercase\",\n            color: t.sub,\n            padding: \"0 9px 6px\",\n          }}\n        >\n          Recents\n        </div>\n        <nav style={{ display: \"flex\", flexDirection: \"column\", gap: 1 }}>\n          {RECENTS.map((label, i) => (\n            <div\n              key={label}\n              style={{\n                display: \"flex\",\n                alignItems: \"center\",\n                gap: 8,\n                padding: \"8px 9px\",\n                borderRadius: 8,\n                fontSize: 13.5,\n                color: t.ink,\n                background: i === 0 ? t.active : \"transparent\",\n                fontWeight: i === 0 ? 500 : 400,\n                whiteSpace: \"nowrap\",\n                overflow: \"hidden\",\n                textOverflow: \"ellipsis\",\n              }}\n            >\n              {label}\n            </div>\n          ))}\n        </nav>\n\n        <div style={{ flex: 1 }} />\n\n        {/* account footer */}\n        <div\n          style={{\n            display: \"flex\",\n            alignItems: \"center\",\n            gap: 9,\n            padding: \"9px 8px\",\n            marginBottom: 10,\n            borderRadius: 10,\n          }}\n        >\n          <span\n            style={{\n              width: 28,\n              height: 28,\n              borderRadius: \"50%\",\n              background: \"linear-gradient(135deg,#d97757,#c15f3c)\",\n              color: \"#fff\",\n              display: \"flex\",\n              alignItems: \"center\",\n              justifyContent: \"center\",\n              fontSize: 12,\n              fontWeight: 600,\n              flexShrink: 0,\n            }}\n          >\n            EM\n          </span>\n          <div style={{ flex: 1, minWidth: 0 }}>\n            <div\n              style={{\n                fontSize: 13,\n                fontWeight: 600,\n                color: t.ink,\n                whiteSpace: \"nowrap\",\n                overflow: \"hidden\",\n                textOverflow: \"ellipsis\",\n              }}\n            >\n              {user}\n            </div>\n            <div style={{ fontSize: 11.5, color: t.sub }}>Max plan</div>\n          </div>\n          <ChevronDown color={t.sub} />\n        </div>\n      </aside>\n\n      {/* ---- Main ---- */}\n      <main style={{ flex: 1, display: \"flex\", flexDirection: \"column\", minWidth: 0 }}>\n        {/* header — model selector + share */}\n        <header\n          style={{\n            height: 52,\n            flexShrink: 0,\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"space-between\",\n            padding: \"0 20px\",\n          }}\n        >\n          <button\n            type=\"button\"\n            style={{\n              display: \"inline-flex\",\n              alignItems: \"center\",\n              gap: 6,\n              border: \"none\",\n              background: \"transparent\",\n              color: t.ink,\n              fontSize: 15.5,\n              fontWeight: 600,\n              cursor: \"pointer\",\n              padding: \"6px 8px\",\n              borderRadius: 8,\n            }}\n          >\n            {model}\n            <span style={{ opacity: 0.45 }}>\n              <ChevronDown color={t.ink} />\n            </span>\n          </button>\n          <span style={{ opacity: 0.7 }}>\n            <ShareGlyph color={t.sub} />\n          </span>\n        </header>\n\n        {/* thread — centred column */}\n        <div style={{ flex: 1, overflow: \"hidden\", display: \"flex\", justifyContent: \"center\" }}>\n          <div\n            style={{\n              width: \"100%\",\n              maxWidth: 640,\n              display: \"flex\",\n              flexDirection: \"column\",\n              gap: 22,\n              padding: \"10px 24px 4px\",\n            }}\n          >\n            {conversation.map((turn, i) =>\n              turn.role === \"user\" ? (\n                <div key={i} style={{ display: \"flex\", justifyContent: \"flex-end\" }}>\n                  <div\n                    style={{\n                      maxWidth: \"82%\",\n                      background: t.bubble,\n                      color: t.bubbleInk,\n                      padding: \"11px 16px\",\n                      borderRadius: 18,\n                      borderBottomRightRadius: 6,\n                      fontSize: 15,\n                      lineHeight: 1.5,\n                    }}\n                  >\n                    {turn.text}\n                  </div>\n                </div>\n              ) : (\n                <div key={i} style={{ display: \"flex\", gap: 12, alignItems: \"flex-start\" }}>\n                  <div\n                    style={{\n                      flexShrink: 0,\n                      width: 28,\n                      height: 28,\n                      borderRadius: \"50%\",\n                      border: `1px solid ${t.faint}`,\n                      display: \"flex\",\n                      alignItems: \"center\",\n                      justifyContent: \"center\",\n                      background: scheme === \"light\" ? \"#fff\" : \"#ffffff08\",\n                    }}\n                  >\n                    <ClaudeBurst size={17} color={t.accent} />\n                  </div>\n                  {turn.typing ? (\n                    <div style={{ display: \"flex\", gap: 5, paddingTop: 11 }}>\n                      {[0, 1, 2].map((d) => (\n                        <span\n                          key={d}\n                          className={`${styleId}-dot`}\n                          style={{\n                            width: 7,\n                            height: 7,\n                            borderRadius: \"50%\",\n                            background: t.sub,\n                            animationDelay: `${d * 0.18}s`,\n                          }}\n                        />\n                      ))}\n                    </div>\n                  ) : (\n                    <div style={{ flex: 1, fontSize: 15, lineHeight: 1.6, color: t.ink, paddingTop: 3 }}>\n                      {renderRich(turn.text)}\n                    </div>\n                  )}\n                </div>\n              ),\n            )}\n          </div>\n        </div>\n\n        {/* composer */}\n        <div style={{ flexShrink: 0, display: \"flex\", justifyContent: \"center\", padding: \"6px 24px 8px\" }}>\n          <div style={{ width: \"100%\", maxWidth: 700 }}>\n            <div\n              style={{\n                background: t.composer,\n                border: `1px solid ${t.composerBorder}`,\n                borderRadius: 22,\n                padding: \"12px 12px 10px 16px\",\n                boxShadow: scheme === \"light\" ? \"0 2px 8px rgba(60,50,30,0.06)\" : \"none\",\n              }}\n            >\n              <div style={{ fontSize: 15, color: t.sub, padding: \"2px 0 12px\" }}>Reply to Claude…</div>\n              <div style={{ display: \"flex\", alignItems: \"center\", justifyContent: \"space-between\" }}>\n                <span\n                  style={{\n                    width: 30,\n                    height: 30,\n                    borderRadius: \"50%\",\n                    border: `1px solid ${t.faint}`,\n                    display: \"inline-flex\",\n                    alignItems: \"center\",\n                    justifyContent: \"center\",\n                  }}\n                >\n                  <PlusGlyph color={t.sub} />\n                </span>\n                <div style={{ display: \"flex\", alignItems: \"center\", gap: 12 }}>\n                  <span style={{ display: \"inline-flex\", alignItems: \"center\", gap: 4, fontSize: 13, color: t.sub }}>\n                    {model.replace(\"Claude \", \"\")}\n                    <ChevronDown color={t.sub} size={13} />\n                  </span>\n                  <span\n                    style={{\n                      width: 32,\n                      height: 32,\n                      borderRadius: \"50%\",\n                      background: t.accent,\n                      display: \"inline-flex\",\n                      alignItems: \"center\",\n                      justifyContent: \"center\",\n                      flexShrink: 0,\n                    }}\n                  >\n                    <ArrowUp color={t.accentInk} />\n                  </span>\n                </div>\n              </div>\n            </div>\n            <div style={{ textAlign: \"center\", fontSize: 11.5, color: t.sub, paddingTop: 7 }}>\n              Claude can make mistakes. Please double-check responses.\n            </div>\n          </div>\n        </div>\n      </main>\n    </div>\n  );\n}\n\n/** Minimal renderer: blank-line paragraphs, single newlines, **bold**, *italic*. */\nfunction renderRich(text: string): ReactNode {\n  return text.split(\"\\n\\n\").map((block, bi) => (\n    <p key={bi} style={{ margin: bi === 0 ? 0 : \"12px 0 0\" }}>\n      {block.split(\"\\n\").map((line, li) => (\n        <span key={li}>\n          {li > 0 && <br />}\n          {renderInline(line, `${bi}-${li}`)}\n        </span>\n      ))}\n    </p>\n  ));\n}\n\nfunction renderInline(text: string, keyBase: string): ReactNode[] {\n  return text\n    .split(/(\\*\\*[^*]+\\*\\*|\\*[^*]+\\*)/g)\n    .filter(Boolean)\n    .map((chunk, i) => {\n      const key = `${keyBase}-${i}`;\n      if (chunk.startsWith(\"**\") && chunk.endsWith(\"**\"))\n        return <strong key={key}>{chunk.slice(2, -2)}</strong>;\n      if (chunk.startsWith(\"*\") && chunk.endsWith(\"*\")) return <em key={key}>{chunk.slice(1, -1)}</em>;\n      return <span key={key}>{chunk}</span>;\n    });\n}\n\n/* ---- hardware: the MacBook Pro frame -------------------------------------- */\n\nexport interface MacBookMockProps {\n  /** Rendered width in CSS px. The whole laptop scales from here. Default 720. */\n  width?: number;\n  /** Screen payload. Defaults to the bundled Claude screen. */\n  children?: ReactNode;\n  /** Aluminium finish the chassis reads as. */\n  chassis?: \"silver\" | \"spaceblack\";\n  style?: CSSProperties;\n}\n\ninterface ChassisTokens {\n  lid: string; // lid rim gradient\n  baseTop: string; // base gradient top stop\n  baseMid: string;\n  baseBot: string;\n  edge: string; // hairline edge\n}\n\nconst CHASSIS: Record<NonNullable<MacBookMockProps[\"chassis\"]>, ChassisTokens> = {\n  silver: {\n    lid: \"linear-gradient(180deg,#e2e2e5,#c6c6ca 55%,#b4b4b8)\",\n    baseTop: \"#ececed\",\n    baseMid: \"#cfcfd3\",\n    baseBot: \"#a8a8ad\",\n    edge: \"rgba(255,255,255,0.6)\",\n  },\n  spaceblack: {\n    lid: \"linear-gradient(180deg,#48484b,#37373a 55%,#2c2c2e)\",\n    baseTop: \"#54545a\",\n    baseMid: \"#3c3c40\",\n    baseBot: \"#242427\",\n    edge: \"rgba(255,255,255,0.14)\",\n  },\n};\n\n/**\n * The device frame. Everything inside is authored in the 1118 × 704 logical\n * canvas and scaled together via a single transform, so the laptop stays crisp\n * at any `width` and the caller only ever thinks in one number.\n */\nexport default function MacBookMock({\n  width = 720,\n  children,\n  chassis = \"silver\",\n  style,\n}: MacBookMockProps) {\n  const scale = width / DEVICE_W;\n  const gid = useId().replace(/[:]/g, \"\");\n  const c = CHASSIS[chassis];\n\n  // Base outline: a shallow tapered wedge with rounded bottom corners and a\n  // finger-groove scooped from the top-centre front edge.\n  const bw = BASE_W;\n  const cx = bw / 2;\n  const notchR = 54;\n  const notchDepth = 11;\n  const topInset = 46;\n  const br = 11;\n  const basePath = [\n    `M ${topInset} 1`,\n    `L ${cx - notchR} 1`,\n    `Q ${cx} ${1 + notchDepth} ${cx + notchR} 1`,\n    `L ${bw - topInset} 1`,\n    `L ${bw - 1} ${BASE_H - br}`,\n    `Q ${bw - 1} ${BASE_H} ${bw - 1 - br} ${BASE_H}`,\n    `L ${br + 1} ${BASE_H}`,\n    `Q 1 ${BASE_H} 1 ${BASE_H - br}`,\n    \"Z\",\n  ].join(\" \");\n\n  return (\n    <div style={{ width: DEVICE_W * scale, height: DEVICE_H * scale, ...style }}>\n      <div\n        style={{\n          width: DEVICE_W,\n          height: DEVICE_H,\n          transform: `scale(${scale})`,\n          transformOrigin: \"top left\",\n          position: \"relative\",\n        }}\n      >\n        {/* contact shadow on the ground */}\n        <div\n          aria-hidden=\"true\"\n          style={{\n            position: \"absolute\",\n            left: DEVICE_W * 0.07,\n            bottom: -14,\n            width: DEVICE_W * 0.86,\n            height: 30,\n            borderRadius: \"50%\",\n            background: \"radial-gradient(ellipse at center, rgba(0,0,0,0.42), rgba(0,0,0,0) 72%)\",\n            filter: \"blur(6px)\",\n          }}\n        />\n\n        {/* base / deck front */}\n        <svg\n          width={BASE_W}\n          height={BASE_H}\n          viewBox={`0 0 ${BASE_W} ${BASE_H}`}\n          style={{ position: \"absolute\", left: 0, top: LID_H }}\n          aria-hidden=\"true\"\n        >\n          <defs>\n            <linearGradient id={`${gid}-base`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n              <stop offset=\"0\" stopColor={c.baseTop} />\n              <stop offset=\"0.4\" stopColor={c.baseMid} />\n              <stop offset=\"1\" stopColor={c.baseBot} />\n            </linearGradient>\n          </defs>\n          <path d={basePath} fill={`url(#${gid}-base)`} />\n          {/* bright top hairline + groove shading */}\n          <path\n            d={`M ${topInset} 1 L ${cx - notchR} 1 Q ${cx} ${1 + notchDepth} ${cx + notchR} 1 L ${bw - topInset} 1`}\n            fill=\"none\"\n            stroke={c.edge}\n            strokeWidth=\"1.2\"\n          />\n        </svg>\n\n        {/* lid */}\n        <div\n          style={{\n            position: \"absolute\",\n            left: BASE_OVERHANG,\n            top: 0,\n            width: LID_W,\n            height: LID_H,\n            borderRadius: LID_RADIUS,\n            background: c.lid,\n            padding: RIM,\n            boxSizing: \"border-box\",\n            boxShadow: [\n              \"0 22px 48px -14px rgba(0,0,0,0.5)\",\n              \"inset 0 1px 0 rgba(255,255,255,0.35)\",\n              \"inset 0 0 0 0.5px rgba(0,0,0,0.25)\",\n            ].join(\",\"),\n          }}\n        >\n          {/* black-glass bezel */}\n          <div\n            style={{\n              width: \"100%\",\n              height: \"100%\",\n              borderRadius: LID_RADIUS - RIM,\n              background: \"#050505\",\n              padding: BEZEL,\n              boxSizing: \"border-box\",\n              boxShadow: \"inset 0 0 0 1px rgba(0,0,0,0.9)\",\n            }}\n          >\n            {/* display — clips the app */}\n            <div\n              style={{\n                position: \"relative\",\n                width: \"100%\",\n                height: \"100%\",\n                borderRadius: SCREEN_RADIUS,\n                overflow: \"hidden\",\n                background: \"#000\",\n              }}\n            >\n              {children ?? <ClaudeScreen />}\n\n              {/* camera notch */}\n              <div\n                style={{\n                  position: \"absolute\",\n                  top: 0,\n                  left: \"50%\",\n                  transform: \"translateX(-50%)\",\n                  width: NOTCH_W,\n                  height: NOTCH_H,\n                  background: \"#050505\",\n                  borderBottomLeftRadius: 11,\n                  borderBottomRightRadius: 11,\n                  display: \"flex\",\n                  alignItems: \"center\",\n                  justifyContent: \"center\",\n                  pointerEvents: \"none\",\n                }}\n              >\n                <span\n                  style={{\n                    width: 6,\n                    height: 6,\n                    borderRadius: \"50%\",\n                    background: \"radial-gradient(circle at 35% 30%, #223, #000 70%)\",\n                    boxShadow: \"inset 0 0 1px rgba(120,150,200,0.5)\",\n                  }}\n                />\n              </div>\n\n              {/* glass glare */}\n              <div\n                aria-hidden=\"true\"\n                style={{\n                  position: \"absolute\",\n                  inset: 0,\n                  borderRadius: SCREEN_RADIUS,\n                  background:\n                    \"linear-gradient(120deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0) 24%, rgba(255,255,255,0) 100%)\",\n                  pointerEvents: \"none\",\n                }}\n              />\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}"
    },
    {
      "slug": "launch-film",
      "title": "Product-launch film with chapter scrubber",
      "category": "Marketing",
      "summary": "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.",
      "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.",
      "whenToUse": [
        "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."
      ],
      "source": {
        "name": "Alpaca Labs launch film",
        "url": "https://claude.ai"
      },
      "tags": [
        "launch",
        "video",
        "promo",
        "film",
        "hero",
        "marketing",
        "timeline",
        "scrubber",
        "chapters",
        "scenes",
        "border-beam",
        "requestAnimationFrame",
        "autoplay",
        "product-tour"
      ],
      "dependencies": [
        "react"
      ],
      "status": "stable",
      "demoUrl": "https://eito.me/ui/launch-film",
      "markdownUrl": "https://eito.me/ui/patterns/launch-film.md",
      "code": "import {\n  useCallback,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type CSSProperties,\n  type ReactNode,\n} from \"react\";\n\n/**\n * LaunchFilm — a product-launch promo \"video\" with no video file.\n *\n * The whole film is authored as DATA: an ordered list of chapters, each with a\n * label, a duration (seconds), a timestamp caption and a live React scene. A\n * single requestAnimationFrame clock advances a normalized playhead across the\n * total runtime; the chapter the playhead lands in is cross-faded in and handed\n * its own in-chapter progress (0→1) so each scene can animate itself. The\n * labelled chapter track along the bottom is a real scrubber — click or drag it\n * to seek — with a timestamp bubble that rides the playhead.\n *\n * Because it's React + CSS the whole thing stays crisp at any width (it scales\n * from one `width` prop, like a device mock), is restyleable/themeable in code,\n * and weighs a few kilobytes instead of a multi-megabyte bitmap movie.\n *\n * Deps: react only. Everything below is inline SVG + inline styles + one\n * injected <style> block for the keyframes, so the snippet is self-contained.\n */\n\n/* ---- logical canvas (scaled to `width` by one transform) ------------------ */\n\nconst STAGE_W = 920;\nconst STAGE_H = 516;\nconst TRACK_H = 104;\nconst GAP = 14;\nconst CANVAS_W = STAGE_W;\nconst CANVAS_H = STAGE_H + GAP + TRACK_H;\n\n/* ---- palette (light product UI on a soft stage) --------------------------- */\n\nconst c = {\n  stage: \"#f3f3f2\",\n  card: \"#ffffff\",\n  ink: \"#1c1c1a\",\n  sub: \"#6b6b66\",\n  faint: \"#9a9a94\",\n  line: \"#ececea\",\n  line2: \"#e2e2df\",\n  chip: \"#f5f5f3\",\n  accent: \"#2f6df6\",\n  accentSoft: \"#eaf1ff\",\n  green: \"#12a150\",\n  segIdle: \"#e7e7e4\",\n  segActive: \"#cfcfca\",\n  playhead: \"#111110\",\n} as const;\n\nconst SANS =\n  '\"Archivo\", \"Inter\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif';\nconst MONO = 'ui-monospace, \"SF Mono\", Menlo, monospace';\n\nconst fmt = (s: number) => {\n  const m = Math.floor(s / 60);\n  const sec = Math.floor(s % 60);\n  return `${m}:${sec.toString().padStart(2, \"0\")}`;\n};\n\n/* ---- shared bits ---------------------------------------------------------- */\n\n/** The launch mark — a rounded sunburst asterisk. */\nfunction Mark({ size = 22, color = c.ink }: { size?: number; color?: string }) {\n  return (\n    <svg width={size} height={size} viewBox=\"0 0 24 24\" fill=\"none\" aria-hidden=\"true\">\n      {Array.from({ length: 8 }).map((_, i) => (\n        <rect\n          key={i}\n          x=\"11\"\n          y=\"2.5\"\n          width=\"2\"\n          height=\"8\"\n          rx=\"1\"\n          fill={color}\n          transform={`rotate(${i * 45} 12 12)`}\n        />\n      ))}\n    </svg>\n  );\n}\n\n/** A framed browser/app card the scenes sit inside. */\nfunction Frame({\n  children,\n  style,\n  pad = 0,\n}: {\n  children: ReactNode;\n  style?: CSSProperties;\n  pad?: number;\n}) {\n  return (\n    <div\n      style={{\n        position: \"absolute\",\n        inset: 0,\n        borderRadius: 22,\n        background: c.card,\n        boxShadow: \"0 26px 60px -30px rgba(20,20,18,0.45), 0 2px 0 rgba(20,20,18,0.02)\",\n        overflow: \"hidden\",\n        padding: pad,\n        ...style,\n      }}\n    >\n      {children}\n    </div>\n  );\n}\n\n/** Reveal a block once its scene's progress passes `at` (0→1). */\nfunction Reveal({\n  p,\n  at,\n  children,\n  y = 10,\n  dur = 0.12,\n}: {\n  p: number;\n  at: number;\n  children: ReactNode;\n  y?: number;\n  dur?: number;\n}) {\n  const t = Math.max(0, Math.min(1, (p - at) / dur));\n  const e = t * t * (3 - 2 * t); // smoothstep\n  return (\n    <div style={{ opacity: e, transform: `translateY(${(1 - e) * y}px)`, willChange: \"opacity, transform\" }}>\n      {children}\n    </div>\n  );\n}\n\n/**\n * BorderBeam — a bright comet of light that travels around a rounded border,\n * over a hairline static edge (Jakub Antalik's beam, in a light-mode palette).\n *\n * A big conic-gradient layer sits behind the content and rotates; the content\n * card, inset by `border`, masks the centre so only a thin ring of the conic\n * shows — the rotating bright arc reads as a beam sweeping the perimeter. The\n * transparent stops of the conic reveal the faint static border underneath.\n */\nfunction BorderBeam({\n  children,\n  radius = 18,\n  border = 2,\n  duration = 4.5,\n  style,\n}: {\n  children: ReactNode;\n  radius?: number;\n  border?: number;\n  duration?: number;\n  style?: CSSProperties;\n}) {\n  return (\n    <div style={{ position: \"relative\", borderRadius: radius, padding: border, overflow: \"hidden\", background: c.line2, ...style }}>\n      <div\n        className=\"lf-beam\"\n        style={{\n          position: \"absolute\",\n          top: \"50%\",\n          left: \"50%\",\n          width: 1100,\n          height: 1100,\n          transform: \"translate(-50%, -50%)\",\n          animationDuration: `${duration}s`,\n          background:\n            \"conic-gradient(from 0deg, transparent 0deg, transparent 258deg, rgba(168,85,247,0) 284deg, #a855f7 312deg, #3b82f6 338deg, #67e8f9 353deg, transparent 360deg)\",\n        }}\n        aria-hidden=\"true\"\n      />\n      <div style={{ position: \"relative\", zIndex: 1, borderRadius: radius - border, background: c.card, overflow: \"hidden\" }}>\n        {children}\n      </div>\n    </div>\n  );\n}\n\n/* ---- SCENES --------------------------------------------------------------- */\n/* Each scene receives `p` — its own progress 0→1 within the chapter — so it can\n   animate independently of the film clock. */\n\n/** 01 · Hook — title card that resolves out of the mark. */\nfunction SceneHook({ p }: { p: number }) {\n  const rise = Math.min(1, p / 0.5);\n  const e = rise * rise * (3 - 2 * rise);\n  return (\n    <Frame style={{ background: \"radial-gradient(120% 80% at 50% 0%, #fafafa, #f0f0ee)\" }}>\n      <div\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          display: \"flex\",\n          flexDirection: \"column\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n          gap: 22,\n        }}\n      >\n        <div style={{ transform: `scale(${0.9 + e * 0.1})`, opacity: e }}>\n          <Mark size={54} />\n        </div>\n        <div style={{ textAlign: \"center\", opacity: e, transform: `translateY(${(1 - e) * 12}px)` }}>\n          <div style={{ fontFamily: SANS, fontSize: 40, fontWeight: 700, color: c.ink, letterSpacing: -1 }}>\n            Meet Alpaca\n          </div>\n          <div style={{ fontFamily: SANS, fontSize: 18, color: c.sub, marginTop: 8 }}>\n            The agent that just gets it done.\n          </div>\n        </div>\n        <Reveal p={p} at={0.55} y={8}>\n          <div\n            style={{\n              fontFamily: MONO,\n              fontSize: 11,\n              letterSpacing: 2,\n              textTransform: \"uppercase\",\n              color: c.faint,\n              border: `1px solid ${c.line2}`,\n              borderRadius: 999,\n              padding: \"7px 14px\",\n            }}\n          >\n            A 30-second tour\n          </div>\n        </Reveal>\n      </div>\n    </Frame>\n  );\n}\n\n/** A macOS-style pointer cursor. */\nfunction Cursor({ size = 26 }: { size?: number }) {\n  return (\n    <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))\" }}>\n      <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\" />\n    </svg>\n  );\n}\n\n/**\n * 02 · Just ask — the dark composer with the prompt typing itself in. Shot A\n * pushes into the textbox as it types; the pan + Shot B re-frame the SAME\n * composer onto its send button, which a cursor clicks (press + ripple + ✓).\n * The turn itself plays out in the next chapter, Agent at work.\n */\n\n// Send-button centre within the 876×472 stage body (derived from the layout:\n// a centred 620px composer with the 30px send button at its bottom-right).\n// The close-up camera scales about this point, then translates it back to\n// stage centre so the button lands in the middle of frame.\nconst SEND_X = 711;\nconst SEND_Y = 210;\nconst SEND_ZOOM = 2.9;\nconst BODY_CX = 438; // 876 / 2\nconst BODY_CY = 236; // 472 / 2\n\n/** The composer card — shared verbatim by both shots so the cut reads as one continuous UI. */\nfunction JustAskComposer({\n  typed,\n  done,\n  sent,\n  press,\n}: {\n  typed: string;\n  done: boolean;\n  sent: boolean;\n  press: number;\n}) {\n  return (\n    <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\" }}>\n      <div style={{ padding: \"18px 20px\" }}>\n      <div style={{ fontFamily: SANS, fontSize: 16, color: typed ? \"#eef4f4\" : \"#6b7678\", minHeight: 22 }}>\n        {typed || \"Ask anything…\"}\n        {!done && (\n          <span className=\"lf-caret\" style={{ display: \"inline-block\", width: 2, height: 17, background: \"#7ff2ee\", marginLeft: 1, verticalAlign: -2 }} />\n        )}\n      </div>\n      <div style={{ display: \"flex\", alignItems: \"center\", gap: 14, marginTop: 22, fontFamily: SANS, fontSize: 13, color: \"#9ba4a6\" }}>\n        <span style={{ fontSize: 18, color: \"#6b7678\" }}>＋</span>\n        <span>⌘</span>\n        <span style={{ display: \"flex\", alignItems: \"center\", gap: 5 }}>\n          <span style={{ color: \"#7ff2ee\" }}>✦</span> Super Computer\n          <em style={{ color: \"#7ff2ee\", fontStyle: \"normal\", fontSize: 11, background: \"rgba(127,242,238,0.1)\", borderRadius: 6, padding: \"1px 6px\" }}>New</em>\n        </span>\n        <span style={{ marginLeft: \"auto\", display: \"flex\", alignItems: \"center\", gap: 6 }}>\n          <span style={{ width: 15, height: 15, display: \"inline-flex\" }} dangerouslySetInnerHTML={{ __html: CLAUDE_ICON }} />\n          <span style={{ color: \"#e8eeee\" }}>Opus 4.7</span>\n          <span style={{ color: \"#6b7678\", fontSize: 11 }}>▾</span>\n        </span>\n        <span\n          style={{\n            display: \"inline-flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n            width: 30,\n            height: 30,\n            borderRadius: 999,\n            background: done || sent ? \"#7ff2ee\" : \"#2a3134\",\n            color: done || sent ? \"#062a28\" : \"#6b7678\",\n            transition: \"background .2s\",\n            transform: `scale(${press})`,\n          }}\n        >\n          {sent ? \"✓\" : \"↑\"}\n        </span>\n      </div>\n      </div>\n    </div>\n  );\n}\n\n/** Header + composer + quick cards — the full \"Just ask\" UI, without any camera. */\nfunction JustAskBody({\n  p,\n  typed,\n  done,\n  sent,\n  press,\n}: {\n  p: number;\n  typed: string;\n  done: boolean;\n  sent: boolean;\n  press: number;\n}) {\n  return (\n    <div style={{ position: \"absolute\", inset: 0, display: \"flex\", flexDirection: \"column\", alignItems: \"center\", paddingTop: 46 }}>\n      <div style={{ display: \"flex\", flexDirection: \"column\", alignItems: \"center\", gap: 6 }}>\n        <Mark size={26} color=\"#eef4f4\" />\n        <div style={{ fontFamily: SANS, fontSize: 22, fontWeight: 600, color: \"#eef4f4\" }}>\n          Where should we begin?\n        </div>\n      </div>\n\n      {/* composer */}\n      <div style={{ width: 620, marginTop: 26 }}>\n        <JustAskComposer typed={typed} done={done} sent={sent} press={press} />\n      </div>\n\n      {/* quick cards */}\n      <Reveal p={p} at={0.08} y={14}>\n        <div style={{ display: \"flex\", gap: 14, marginTop: 20 }}>\n          {[\n            { t: \"Create\", s: \"Tasks, Images, Docs\" },\n            { t: \"Find\", s: \"Answers & Files\" },\n            { t: \"Research\", s: \"Apps & Web\" },\n          ].map((q) => (\n            <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)\" }}>\n              <div style={{ fontSize: 16, color: \"#6b7678\" }}>◎</div>\n              <div style={{ fontFamily: SANS, fontSize: 15, fontWeight: 600, color: \"#eef4f4\", marginTop: 22 }}>{q.t}</div>\n              <div style={{ fontFamily: SANS, fontSize: 12.5, color: \"#6b7678\", marginTop: 3 }}>{q.s}</div>\n            </div>\n          ))}\n        </div>\n      </Reveal>\n    </div>\n  );\n}\n\n/* ---- the agent's turn, as film -------------------------------------------------\n   Shot C plays one full turn à la agent-thinking-chat — orb + shimmer status,\n   context-source stack, reel counter, blur-streamed answer — with every bit of\n   motion re-derived from chapter progress so the turn scrubs cleanly. */\n\n/* Inline SVG marks for tool calls, as in agent-thinking-chat: `mcp` is the Model\n   Context Protocol glyph (currentColor, tints to the accent); `gmail` and\n   `seal` are connected-app logos on a white badge. */\nconst TOOL_ICONS = {\n  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>',\n  gmail:\n    '<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>',\n  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>',\n} as const;\n\n/* The reply/model avatar — the Opus mark, as in agent-thinking-chat. */\nconst CLAUDE_ICON =\n  '<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>';\n\nconst ASK_SOURCES: { name: string; tag: string; icon?: keyof typeof TOOL_ICONS }[] = [\n  { name: \"launch-plan.md\", tag: \"read\" },\n  { name: \"Landing.tsx\", tag: \"read\" },\n  { name: \"gmail · launch threads\", tag: \"mcp\", icon: \"gmail\" },\n  { name: \"waitlist.csv\", tag: \"grep\" },\n  { name: \"sealgate · call_tool\", tag: \"mcp\", icon: \"seal\" },\n  { name: \"mcp · list_tools\", tag: \"mcp\", icon: \"mcp\" },\n];\nconst ASK_SCANNED = 64;\nconst ASK_ANSWER =\n  \"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?\";\n// shimmer line boundaries within shot C (0→1): last entry pins the end.\nconst ASK_LINES: { t: number; s: string }[] = [\n  { t: 0.0, s: \"Thinking…\" },\n  { t: 0.1, s: \"Reading your message…\" },\n  { t: 0.2, s: \"Planning the approach…\" },\n  { t: 0.34, s: \"Collecting context…\" },\n  { t: 0.44, s: \"Calling connected tools…\" },\n  { t: 0.54, s: \"Reading relevant files…\" },\n  { t: 0.74, s: \"Writing the answer…\" },\n];\n\n/** Dotted orb avatar — ring rotation + core pulse driven by progress. */\nfunction AskOrb({ q }: { q: number }) {\n  const rot = q * 300;\n  const pulse = 1 + 0.12 * Math.sin(q * 22);\n  return (\n    <span style={{ position: \"relative\", width: 26, height: 26, flex: \"none\", display: \"inline-block\" }}>\n      {Array.from({ length: 8 }).map((_, i) => {\n        const a = ((rot + i * 45) * Math.PI) / 180;\n        return (\n          <span\n            key={i}\n            style={{\n              position: \"absolute\",\n              left: 13 + 9.5 * Math.cos(a) - 1.75,\n              top: 13 + 9.5 * Math.sin(a) - 1.75,\n              width: 3.5,\n              height: 3.5,\n              borderRadius: 999,\n              background: \"#7ff2ee\",\n              opacity: 0.3 + 0.7 * (((i / 8 + q * 2) % 1 + 1) % 1),\n            }}\n          />\n        );\n      })}\n      <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)\" }} />\n    </span>\n  );\n}\n\n/** Shimmer status lines — stacked, swapping with blur/rise; sheen sweeps with progress. */\nfunction AskShimmer({ q }: { q: number }) {\n  const longest = ASK_LINES.reduce((a, b) => (b.s.length > a.length ? b.s : a), \"\");\n  const sheen = (q * 500) % 100;\n  return (\n    <span style={{ position: \"relative\", display: \"inline-block\", fontFamily: MONO, fontSize: 13 }}>\n      <span style={{ visibility: \"hidden\", whiteSpace: \"nowrap\" }}>{longest}</span>\n      {ASK_LINES.map((l, k) => {\n        const t1 = k + 1 < ASK_LINES.length ? ASK_LINES[k + 1].t : 2;\n        const enter = Math.max(0, Math.min(1, (q - l.t) / 0.05));\n        const exit = Math.max(0, Math.min(1, (q - (t1 - 0.05)) / 0.05));\n        const vis = enter * (1 - exit);\n        const blur = (1 - enter + exit) * 2;\n        return (\n          <span\n            key={l.s}\n            style={{\n              position: \"absolute\",\n              top: 0,\n              left: 0,\n              right: 0,\n              whiteSpace: \"nowrap\",\n              opacity: vis,\n              transform: `translateY(${(1 - enter) * 8 - exit * 8}px)`,\n              filter: `blur(${blur}px)`,\n              color: \"transparent\",\n              backgroundImage: \"linear-gradient(100deg,#7f8b8d 35%,#e0fffe 50%,#7f8b8d 65%)\",\n              backgroundSize: \"220% 100%\",\n              backgroundPosition: `${sheen}% 0`,\n              WebkitBackgroundClip: \"text\",\n              backgroundClip: \"text\",\n            }}\n          >\n            {l.s}\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n\n/** Sonner-style stack of context sources — newest on top, older ones receding\n * with the reference peek/scale/fade; hover spreads the stack into the\n * reserved headroom, exactly like agent-thinking-chat. */\nfunction AskStack({ q }: { q: number }) {\n  const [spread, setSpread] = useState(false);\n  const appear = ASK_SOURCES.map((_, i) => 0.32 + i * 0.07);\n  const visible = ASK_SOURCES.map((_, i) => i).filter((i) => q >= appear[i]);\n  const shown = visible.slice(-4);\n  return (\n    <div style={{ paddingTop: 56 }} onMouseEnter={() => setSpread(true)} onMouseLeave={() => setSpread(false)}>\n      <div style={{ position: \"relative\", height: 46 }}>\n        {shown.map((idx, s) => {\n          const depth = shown.length - 1 - s;\n          const t = Math.max(0, Math.min(1, (q - appear[idx]) / 0.07));\n          const e = t * t * (3 - 2 * t);\n          const leaving = depth === 3;\n          const fade = leaving ? Math.max(0, 1 - (q - appear[idx] - 0.12) * 7) : 1 - depth * 0.42;\n          const item = ASK_SOURCES[idx];\n          const rest = leaving\n            ? `translateY(${-3 * 9}px) scale(${1 - 3 * 0.05})`\n            : spread && depth > 0\n              ? `translateY(calc(${-depth} * (100% + 8px))) scale(1)`\n              : `translateY(${(1 - e) * 34 - depth * 9}px) scale(${1 - depth * 0.05})`;\n          return (\n            <div\n              key={idx}\n              style={{\n                position: \"absolute\",\n                left: 0,\n                right: 0,\n                bottom: 0,\n                zIndex: leaving ? 0 : 3 - depth,\n                transformOrigin: \"50% 100%\",\n                opacity: (spread && depth > 0 ? 1 : e * fade),\n                transform: rest,\n                filter: `blur(${(spread && depth > 0 ? 0 : 1 - e) * 2}px)`,\n                transition: \"transform .35s cubic-bezier(0.22,1,0.36,1), opacity .35s cubic-bezier(0.22,1,0.36,1)\",\n              }}\n            >\n              <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)\" }}>\n                {item.icon === \"mcp\" ? (\n                  <span style={{ width: 18, height: 18, flex: \"none\", display: \"inline-flex\", color: \"#7ff2ee\" }}>\n                    <span style={{ width: 18, height: 18, display: \"inline-flex\" }} dangerouslySetInnerHTML={{ __html: TOOL_ICONS.mcp }} />\n                  </span>\n                ) : item.icon ? (\n                  <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)\" }}>\n                    <span style={{ width: 18, height: 18, display: \"inline-flex\" }} dangerouslySetInnerHTML={{ __html: TOOL_ICONS[item.icon] }} />\n                  </span>\n                ) : (\n                  <span style={{ width: 6, height: 6, flex: \"none\", borderRadius: 999, background: \"#3ddbd3\", marginLeft: 6, marginRight: 6 }} />\n                )}\n                <span style={{ fontFamily: MONO, fontSize: 12, color: \"#e8eeee\", whiteSpace: \"nowrap\", overflow: \"hidden\", textOverflow: \"ellipsis\" }}>{item.name}</span>\n                <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>\n              </div>\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\n/** Digit-reel counter — columns spin to their final digit with a vertical motion\n * blur that swells mid-spin and decays as each column lands, like the\n * reference. Render-driven (no rAF), so it scrubs cleanly. */\nfunction AskReel({ value, q }: { value: number; q: number }) {\n  const digits = String(value).split(\"\");\n  const CELL = 28;\n  const SPINS = 4;\n  const rawId = useId();\n  const uid = `ask-reel-${rawId.replace(/[^a-zA-Z0-9]/g, \"\")}`;\n  const col = (c: number) => {\n    const t = Math.max(0, Math.min(1, (q - 0.32 - c * 0.09) / 0.5));\n    const e = t * t * (3 - 2 * t);\n    return { e, env: Math.sin(t * Math.PI) }; // 0→1→0 streak envelope\n  };\n  return (\n    <span style={{ display: \"inline-flex\", alignItems: \"center\", height: CELL, color: \"#7ff2ee\" }}>\n      <svg width={0} height={0} aria-hidden=\"true\" style={{ position: \"absolute\" }}>\n        <defs>\n          {digits.map((_, c) => (\n            <filter key={c} id={`${uid}-${c}`} x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\">\n              <feGaussianBlur stdDeviation={`0 ${(3 * col(c).env).toFixed(2)}`} />\n            </filter>\n          ))}\n        </defs>\n      </svg>\n      {digits.map((d, c) => (\n        <span\n          key={c}\n          style={{\n            position: \"relative\",\n            height: CELL,\n            width: 17,\n            overflow: \"hidden\",\n            WebkitMaskImage: \"linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%)\",\n            maskImage: \"linear-gradient(to bottom, transparent 0%, #000 22%, #000 78%, transparent 100%)\",\n          }}\n        >\n          <span\n            style={{\n              display: \"flex\",\n              flexDirection: \"column\",\n              transform: `translateY(${-col(c).e * (SPINS * 10 + Number(d)) * CELL}px)`,\n              filter: `url(#${uid}-${c})`,\n            }}\n          >\n            {Array.from({ length: SPINS * 10 + 10 }, (_, i) => (\n              <span key={i} style={{ height: CELL, display: \"flex\", alignItems: \"center\", justifyContent: \"center\", fontFamily: MONO, fontSize: 17, fontWeight: 600 }}>\n                {i % 10}\n              </span>\n            ))}\n          </span>\n        </span>\n      ))}\n    </span>\n  );\n}\n\n/** Word-by-word answer reveal through opacity + blur. */\nfunction AskStream({ text, q }: { text: string; q: number }) {\n  const words = text.split(\" \");\n  const f = Math.max(0, Math.min(1, (q - 0.78) / 0.2)) * words.length;\n  return (\n    <div style={{ fontFamily: SANS, fontSize: 13.5, lineHeight: 1.65, color: \"#e8eeee\" }}>\n      {words.map((w, i) => {\n        const r = Math.max(0, Math.min(1, (f - i) * 0.8));\n        return (\n          <span key={i} style={{ opacity: r, filter: `blur(${(1 - r) * 3}px)` }}>\n            {w}{i < words.length - 1 ? \" \" : \"\"}\n          </span>\n        );\n      })}\n    </div>\n  );\n}\n\n/** Shot C — the working card (orb + shimmer, stack, reel) with the streamed answer below. */\nfunction AskWorking({ qc }: { qc: number }) {\n  const entry = Math.min(1, qc / 0.08);\n  const ee = entry * entry * (3 - 2 * entry);\n  const answerIn = Math.max(0, Math.min(1, (qc - 0.74) / 0.08));\n  return (\n    <Frame style={{ background: \"#0e1113\" }}>\n      <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})` }}>\n        {/* working card, framed by a travelling-light ring for the liquid-metal read */}\n        <div style={{ position: \"relative\", width: 640, borderRadius: 16, padding: 2, overflow: \"hidden\", background: \"#1b2124\" }}>\n          <div\n            className=\"lf-beam\"\n            style={{\n              position: \"absolute\",\n              top: \"50%\",\n              left: \"50%\",\n              width: 1100,\n              height: 1100,\n              transform: \"translate(-50%, -50%)\",\n              animationDuration: \"6s\",\n              background:\n                \"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)\",\n            }}\n            aria-hidden=\"true\"\n          />\n          <div style={{ position: \"relative\", zIndex: 1, borderRadius: 14, background: \"#0a0d0e\", padding: \"14px 16px\", overflow: \"hidden\" }}>\n            <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n              <AskOrb q={qc} />\n              <AskShimmer q={qc} />\n              <span style={{ marginLeft: \"auto\", display: \"flex\", alignItems: \"center\", gap: 7, fontFamily: MONO, fontSize: 10, letterSpacing: 1, color: \"#6b7678\", whiteSpace: \"nowrap\" }}>\n                <AskReel value={ASK_SCANNED} q={qc} /> SCANNED\n              </span>\n            </div>\n            <AskStack q={qc} />\n          </div>\n        </div>\n        <div style={{ width: 640, marginTop: 14, display: \"flex\", gap: 10, opacity: answerIn }}>\n          <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\" }}>\n            <span style={{ width: 15, height: 15, display: \"inline-flex\" }} dangerouslySetInnerHTML={{ __html: CLAUDE_ICON }} />\n          </span>\n          <div style={{ flex: 1, minWidth: 0 }}>\n            <div style={{ fontFamily: MONO, fontSize: 10.5, letterSpacing: 0.5, color: \"rgba(155,164,166,0.75)\", marginBottom: 5 }}>Thought for 3s</div>\n            <AskStream text={ASK_ANSWER} q={qc} />\n          </div>\n        </div>\n      </div>\n    </Frame>\n  );\n}\n\nfunction SceneJustAsk({ p }: { p: number }) {\n  const prompt = \"Draft the launch plan and build the landing page.\";\n  const typeFrac = Math.max(0, Math.min(1, (p - 0.1) / 0.35));\n  const chars = Math.floor(typeFrac * prompt.length);\n  const typed = prompt.slice(0, chars);\n  const done = chars >= prompt.length;\n\n  const CUT = 0.5; // camera pans to the send-button close-up once typing is done\n  const PANW = 0.12; // pan duration (chapter-p)\n\n  /* ---- PAN · glide from the typing framing to the button close-up ----\n     Same composer, one continuous camera move: the focus point travels from\n     the prompt (370,175) to the send button while the zoom rides 1.6→2.9x.\n     Endpoints match Shot A / Shot B exactly, so the joins are seamless. */\n  const panT = Math.max(0, Math.min(1, (p - CUT) / PANW));\n  if (panT > 0 && panT < 1) {\n    const pe = panT * panT * (3 - 2 * panT);\n    const fx = 370 + (SEND_X - 370) * pe;\n    const fy = 175 + (SEND_Y - 175) * pe;\n    const zoom = 1.6 * Math.pow(SEND_ZOOM / 1.6, pe);\n    const tx = (BODY_CX - 370) + (370 - SEND_X) * pe;\n    const ty = (BODY_CY - 175) + (175 - SEND_Y) * pe;\n    return (\n      <Frame style={{ background: \"#0e1113\" }}>\n        <div style={{ position: \"absolute\", inset: 0, overflow: \"hidden\" }}>\n          <div style={{ position: \"absolute\", inset: 0, transform: `translate(${tx}px, ${ty}px)` }}>\n            <div style={{ position: \"absolute\", inset: 0, transform: `scale(${zoom})`, transformOrigin: `${fx}px ${fy}px`, willChange: \"transform\" }}>\n              <JustAskBody p={1} typed={prompt} done sent={false} press={1} />\n            </div>\n          </div>\n        </div>\n      </Frame>\n    );\n  }\n\n  /* ---- SHOT B · same composer, camera zoom-cut onto its send button ---- */\n  if (p >= CUT + PANW) {\n    const lp = (p - CUT - PANW) / (1 - CUT - PANW); // 0→1 within the button shot\n    // cursor flies in to the button's new home (stage centre), then presses\n    const approach = Math.min(1, lp / 0.34);\n    const ae = approach * approach * (3 - 2 * approach);\n    const offX = (1 - ae) * 150;\n    const offY = (1 - ae) * 160;\n    const contact = Math.max(0, Math.min(1, (lp - 0.32) / 0.1));\n    const release = Math.max(0, Math.min(1, (lp - 0.44) / 0.16));\n    const press = 1 - 0.1 * (contact - release);\n    // ripple after the press lands\n    const rp = Math.max(0, Math.min(1, (lp - 0.4) / 0.55));\n    const sent = lp > 0.5;\n    return (\n      <Frame style={{ background: \"#0e1113\" }}>\n        <div style={{ position: \"absolute\", inset: 0, overflow: \"hidden\" }}>\n          {/* re-centre the zoom point so the send button lands mid-frame */}\n          <div style={{ position: \"absolute\", inset: 0, transform: `translate(${BODY_CX - SEND_X}px, ${BODY_CY - SEND_Y}px)` }}>\n            <div style={{ position: \"absolute\", inset: 0, transform: `scale(${SEND_ZOOM})`, transformOrigin: `${SEND_X}px ${SEND_Y}px`, willChange: \"transform\" }}>\n              <JustAskBody p={1} typed={prompt} done sent={sent} press={press} />\n            </div>\n          </div>\n          {/* ripple ring, in screen space over the button */}\n          {rp > 0 && rp < 1 && (\n            <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})` }} />\n          )}\n          {/* cursor */}\n          <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})` }}>\n            <Cursor size={40} />\n          </div>\n        </div>\n      </Frame>\n    );\n  }\n\n  /* ---- SHOT A · composer, camera pushing into the textbox ----\n     The camera drifts as it pushes: starts wide on the natural layout, then\n     settles hard onto the typed prompt (focus 370,175 — near the prompt's\n     middle, so the full text lands centred with a generous left margin).\n     At 1.6x the send button slides out of the right edge; the pan then\n     travels across the composer to re-discover it. */\n  const ze = typeFrac * typeFrac * (3 - 2 * typeFrac); // smoothstep on typing\n  const zoom = 1.08 + ze * 0.52; // up to ~1.6\n  const driftX = (BODY_CX - 370) * ze;\n  const driftY = (BODY_CY - 175) * ze;\n  return (\n    <Frame style={{ background: \"#0e1113\" }}>\n      <div style={{ position: \"absolute\", inset: 0, transform: `translate(${driftX}px, ${driftY}px)` }}>\n        <div style={{ position: \"absolute\", inset: 0, transform: `scale(${zoom})`, transformOrigin: \"370px 175px\", willChange: \"transform\" }}>\n          <JustAskBody p={p} typed={typed} done={done} sent={false} press={1} />\n        </div>\n      </div>\n    </Frame>\n  );\n}\n\n/** 03 · Agent at work — the full agent turn on the new components: thinking orb\n * + shimmer status → context-source stack + reel counter → blur-streamed\n * answer. Progress-driven throughout, so it scrubs cleanly. */\nfunction SceneAgent({ p }: { p: number }) {\n  return <AskWorking qc={p} />;\n}\n\n/** 04 · Result — a desktop monitor showing an Artifacts gallery. */\nfunction SceneResult({ p }: { p: number }) {\n  const art = [\"AI Landing Page\", \"Research Agent\", \"Code Assistant\", \"Meeting Notes\", \"Prompt Library\", \"Growth Plan\"];\n  return (\n    <Frame style={{ background: \"#efefed\" }}>\n      <div style={{ position: \"absolute\", inset: 0, display: \"flex\", flexDirection: \"column\", alignItems: \"center\", justifyContent: \"center\" }}>\n        {/* monitor */}\n        <div style={{ width: 660, background: \"#0e0e0d\", borderRadius: 16, padding: 12, boxShadow: \"0 30px 60px -30px rgba(0,0,0,0.5)\" }}>\n          <div style={{ background: c.card, borderRadius: 8, overflow: \"hidden\", height: 322, display: \"flex\" }}>\n            {/* sidebar */}\n            <div style={{ width: 150, borderRight: `1px solid ${c.line}`, padding: \"14px 12px\" }}>\n              <div style={{ display: \"flex\", alignItems: \"center\", gap: 7 }}>\n                <Mark size={13} />\n                <span style={{ fontFamily: SANS, fontSize: 12, fontWeight: 600, color: c.ink }}>Alpaca</span>\n              </div>\n              {[\"New chat\", \"Projects\", \"Artifacts\", \"Apps\"].map((s, i) => (\n                <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 }}>\n                  {s}\n                </div>\n              ))}\n            </div>\n            {/* gallery */}\n            <div style={{ flex: 1, padding: \"16px 18px\" }}>\n              <div style={{ display: \"flex\", alignItems: \"center\" }}>\n                <span style={{ fontFamily: SANS, fontSize: 14, fontWeight: 600, color: c.ink }}>Artifacts</span>\n                <span style={{ marginLeft: \"auto\", fontFamily: SANS, fontSize: 11, color: \"#fff\", background: c.ink, borderRadius: 999, padding: \"4px 12px\" }}>New artifact</span>\n              </div>\n              <div style={{ marginTop: 14, display: \"grid\", gridTemplateColumns: \"1fr 1fr 1fr\", gap: 12 }}>\n                {art.map((a, i) => {\n                  const t = Math.max(0, Math.min(1, (p - 0.1 - i * 0.08) / 0.18));\n                  const e = t * t * (3 - 2 * t);\n                  return (\n                    <div key={a} style={{ opacity: e, transform: `translateY(${(1 - e) * 10}px)` }}>\n                      <div style={{ height: 66, borderRadius: 8, background: `linear-gradient(135deg, ${c.chip}, #fff)`, border: `1px solid ${c.line}` }} />\n                      <div style={{ fontFamily: SANS, fontSize: 11, fontWeight: 600, color: c.ink, marginTop: 7 }}>{a}</div>\n                      <div style={{ fontFamily: SANS, fontSize: 10, color: c.faint }}>2d ago</div>\n                    </div>\n                  );\n                })}\n              </div>\n            </div>\n          </div>\n        </div>\n        {/* stand */}\n        <div style={{ width: 90, height: 30, background: \"linear-gradient(#c9c9c5,#e6e6e2)\", borderRadius: \"0 0 8px 8px\" }} />\n        <div style={{ width: 190, height: 8, background: \"#d2d2ce\", borderRadius: 999, marginTop: 2 }} />\n      </div>\n    </Frame>\n  );\n}\n\n/**\n  * 05 · Integrations — two shots, one sentence: \"Connect the tools your team\n * uses.\" Shot A is pure motion — two ticker rows of app tiles glide past in\n * opposite directions and wire themselves up (dots flip green, the counter\n * climbs to 8/8). Shot B lands the payoff: the grid, toggles flipping on,\n * with the MCP endpoint strip underneath.\n */\nconst INT_APPS: { n: string; d: string; g: string }[] = [\n  { n: \"X\", d: \"Social media management\", g: \"#111\" },\n  { n: \"GitHub\", d: \"Streamline your dev workflow\", g: \"#24292f\" },\n  { n: \"Slack\", d: \"Team communication\", g: \"#611f69\" },\n  { n: \"Asana\", d: \"Project tasks & deadlines\", g: \"#f06a6a\" },\n  { n: \"Zoom\", d: \"Meetings & webinars\", g: \"#2d8cff\" },\n  { n: \"Trello\", d: \"Boards and cards\", g: \"#0079bf\" },\n  { n: \"Figma\", d: \"Design & prototype\", g: \"#a259ff\" },\n  { n: \"Notion\", d: \"Docs & databases\", g: \"#111\" },\n];\n\n/** One app tile for the Shot A tickers — fixed 150px width so rows loop seamlessly. */\nfunction TickerTile({ a, on }: { a: { n: string; d: string; g: string }; on: boolean }) {\n  return (\n    <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\" }}>\n      <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 }}>\n        {a.n[0]}\n      </div>\n      <div style={{ minWidth: 0, flex: 1 }}>\n        <div style={{ fontFamily: SANS, fontSize: 13, fontWeight: 600, color: c.ink, whiteSpace: \"nowrap\", overflow: \"hidden\", textOverflow: \"ellipsis\" }}>{a.n}</div>\n        <div style={{ display: \"flex\", alignItems: \"center\", gap: 5, marginTop: 3 }}>\n          <span style={{ width: 7, height: 7, borderRadius: 999, background: on ? c.green : c.line2, transition: \"background .2s\" }} />\n          <span style={{ fontFamily: MONO, fontSize: 9.5, letterSpacing: 1, color: on ? c.green : c.faint }}>{on ? \"SYNCED\" : \"READY\"}</span>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction SceneIntegrations({ p }: { p: number }) {\n  const CUT = 0.5; // hard cut: shot A makes the promise, shot B shows the proof\n\n  /* ---- SHOT B · \"your team uses.\" — the grid, toggles flipping on ---- */\n  if (p >= CUT) {\n    const qb = (p - CUT) / (1 - CUT); // 0→1 within shot B\n    const entry = Math.min(1, qb / 0.14);\n    const ee = entry * entry * (3 - 2 * entry);\n    const servers = [\"Gmail\", \"Linear\", \"Postgres\", \"Stripe\"];\n    return (\n      <Frame style={{ background: c.card }}>\n        <div style={{ position: \"absolute\", inset: 0, overflow: \"hidden\", opacity: ee, transform: `scale(${1.05 - ee * 0.05})` }}>\n          <div style={{ position: \"absolute\", top: 0, left: 0, right: 0, height: 60, background: \"linear-gradient(90deg,#c7b3ff,#a9d4ff,#c9f0e4)\" }} />\n          <div style={{ position: \"absolute\", inset: 0, padding: \"30px 54px 0\" }}>\n            <div style={{ fontFamily: MONO, fontSize: 11, letterSpacing: 2, color: c.faint }}>INTEGRATIONS</div>\n            <div style={{ fontFamily: SANS, fontSize: 30, fontWeight: 700, color: c.ink, letterSpacing: -0.5, marginTop: 4 }}>your team uses.</div>\n            <div style={{ marginTop: 12, display: \"grid\", gridTemplateColumns: \"1fr 1fr\", gap: \"10px 34px\" }}>\n              {INT_APPS.map((a, i) => {\n                const t = Math.max(0, Math.min(1, (qb - 0.1 - i * 0.07) / 0.16));\n                const e = t * t * (3 - 2 * t);\n                const on = qb > 0.12 + i * 0.07;\n                return (\n                  <div key={a.n} style={{ display: \"flex\", alignItems: \"center\", gap: 12, opacity: e, transform: `translateY(${(1 - e) * 10}px)` }}>\n                    <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 }}>\n                      {a.n[0]}\n                    </div>\n                    <div style={{ minWidth: 0 }}>\n                      <div style={{ fontFamily: SANS, fontSize: 14, fontWeight: 600, color: c.ink }}>{a.n}</div>\n                      <div style={{ fontFamily: SANS, fontSize: 11.5, color: c.faint, whiteSpace: \"nowrap\", overflow: \"hidden\", textOverflow: \"ellipsis\" }}>{a.d}</div>\n                    </div>\n                    <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\" }}>\n                      {on ? \"✓\" : \"+\"}\n                    </div>\n                  </div>\n                );\n              })}\n            </div>\n            {/* MCP strip — the same endpoint, one click away */}\n            <div style={{ marginTop: 12, border: `1px solid ${c.line}`, borderRadius: 12, padding: \"9px 14px\", display: \"flex\", alignItems: \"center\", gap: 10 }}>\n              <span style={{ fontSize: 15 }}>🔌</span>\n              <span style={{ fontFamily: MONO, fontSize: 12, color: c.ink }}>mcp.alpaca.dev/sse</span>\n              <span style={{ marginLeft: \"auto\", fontFamily: SANS, fontSize: 11.5, fontWeight: 600, color: \"#fff\", background: c.ink, borderRadius: 8, padding: \"5px 12px\" }}>Connect</span>\n            </div>\n            <div style={{ marginTop: 8, display: \"flex\", gap: 6 }}>\n              {servers.map((s, i) => {\n                const t = Math.max(0, Math.min(1, (qb - 0.5 - i * 0.08) / 0.18));\n                const e = t * t * (3 - 2 * t);\n                return (\n                  <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\" }}>\n                    {s} <span style={{ color: c.green }}>●</span>\n                  </span>\n                );\n              })}\n            </div>\n          </div>\n        </div>\n      </Frame>\n    );\n  }\n\n  /* ---- SHOT A · \"Connect the tools\" — tickers wiring themselves up ----\n     Two rows glide past in opposite directions, driven by film time so the\n     shot scrubs cleanly; each row travels exactly one set-width, looping\n     seamlessly. Tiles flip to SYNCED one by one as the counter climbs. */\n  const q = Math.min(1, p / 0.42);\n  const qe = q * q * (3 - 2 * q);\n  const connected = INT_APPS.filter((_, i) => q > 0.06 + i * 0.1).length;\n  const push = 1 + 0.05 * Math.min(1, p / CUT); // lean into the cut\n  const SET_W = INT_APPS.length * (150 + 12); // one loop of tiles\n  const row = (dir: 1 | -1) => ({\n    display: \"flex\" as const,\n    gap: 12,\n    width: \"max-content\",\n    transform: `translateX(${dir === 1 ? -SET_W * qe : -SET_W * (1 - qe)}px)`,\n    willChange: \"transform\" as const,\n  });\n  const copies = [0, 1, 2];\n  return (\n    <Frame style={{ background: \"radial-gradient(110% 100% at 50% 0%, #f3f1ff 0%, #f4f4f3 55%, #efefed 100%)\" }}>\n      <div style={{ position: \"absolute\", inset: 0, overflow: \"hidden\", transform: `scale(${push})` }}>\n        <div style={{ textAlign: \"center\", paddingTop: 52 }}>\n          <Reveal p={p} at={0.02} y={12}>\n            <div style={{ fontFamily: SANS, fontSize: 34, fontWeight: 700, color: c.ink, letterSpacing: -0.8 }}>Connect the tools</div>\n          </Reveal>\n          <Reveal p={p} at={0.08} y={8}>\n            <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\" }}>\n              <span style={{ width: 7, height: 7, borderRadius: 999, background: connected >= INT_APPS.length ? c.green : c.accent }} />\n              {connected}/{INT_APPS.length} CONNECTED\n            </div>\n          </Reveal>\n        </div>\n        {/* row 1 · drifts left */}\n        <div style={{ position: \"absolute\", top: 196, left: 0, right: 0, overflow: \"hidden\", opacity: Math.min(1, p / 0.06) }}>\n          <div style={row(1)}>\n            {copies.flatMap((cp) => INT_APPS.map((a, i) => <TickerTile key={`${cp}-${a.n}`} a={a} on={q > 0.06 + i * 0.1} />))}\n          </div>\n        </div>\n        {/* row 2 · drifts right */}\n        <div style={{ position: \"absolute\", top: 272, left: 0, right: 0, overflow: \"hidden\", opacity: Math.min(1, Math.max(0, (p - 0.04) / 0.06)) }}>\n          <div style={row(-1)}>\n            {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} />))}\n          </div>\n        </div>\n        <div style={{ position: \"absolute\", bottom: 30, left: 0, right: 0, textAlign: \"center\", fontFamily: MONO, fontSize: 10.5, letterSpacing: 2, color: c.faint }}>\n          ONE CLICK · NO CODE · EVERY TOOL\n        </div>\n      </div>\n    </Frame>\n  );\n}\n\n/** A small mono uppercase caption that sits above whichever device is on. */\nfunction BeatCaption({ text }: { text: string }) {\n  return (\n    <div\n      style={{\n        fontFamily: MONO,\n        fontSize: 11,\n        letterSpacing: 2,\n        textTransform: \"uppercase\",\n        color: c.faint,\n        border: `1px solid ${c.line2}`,\n        background: c.card,\n        borderRadius: 999,\n        padding: \"6px 14px\",\n        whiteSpace: \"nowrap\",\n      }}\n    >\n      {text}\n    </div>\n  );\n}\n\n/**\n  * 06 · Anywhere — the same app, everywhere. The scene runs in two beats: it opens\n * on the mobile app, then cross-fades to the identical Alpaca Labs app on a\n * desktop, so the \"anywhere\" promise is shown on both form factors.\n */\nfunction SceneAnywhere({ p }: { p: number }) {\n  const recents = [\"Fix spacing on cards\", \"Agent response feels verbose\", \"Mobile runtime cleanup\", \"Improve retrieval accuracy\"];\n\n  // two beats: mobile for the first half, desktop for the second, cross-faded.\n  const SWAP = 0.5;\n  const mobileOpacity = 1 - Math.max(0, Math.min(1, (p - (SWAP - 0.05)) / 0.1));\n  const desktopOpacity = Math.max(0, Math.min(1, (p - SWAP) / 0.1));\n\n  // per-beat entry rise\n  const mr = Math.min(1, p / 0.35);\n  const me = mr * mr * (3 - 2 * mr);\n  const dr = Math.max(0, Math.min(1, (p - SWAP) / 0.35));\n  const de = dr * dr * (3 - 2 * dr);\n\n  return (\n    <Frame style={{ background: \"#efefed\" }}>\n      {/* ---- beat A · mobile ---- */}\n      <div style={{ position: \"absolute\", inset: 0, display: \"flex\", flexDirection: \"column\", gap: 16, alignItems: \"center\", justifyContent: \"center\", opacity: mobileOpacity }}>\n        <BeatCaption text=\"On the go\" />\n        <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)` }}>\n          <div style={{ position: \"relative\", width: \"100%\", height: \"100%\", background: c.card, borderRadius: 32, overflow: \"hidden\", padding: \"26px 18px 16px\" }}>\n            <div style={{ position: \"absolute\", top: 12, left: \"50%\", transform: \"translateX(-50%)\", width: 84, height: 20, background: \"#111\", borderRadius: 999 }} />\n            <div style={{ display: \"flex\", alignItems: \"center\", gap: 8, marginTop: 8 }}>\n              <Mark size={16} />\n              <span style={{ fontFamily: SANS, fontSize: 15, fontWeight: 700, color: c.ink }}>Alpaca Labs</span>\n            </div>\n            {[\"Projects\", \"Artifacts\", \"MCP\"].map((s, i) => (\n              <div key={s} style={{ display: \"flex\", alignItems: \"center\", fontFamily: SANS, fontSize: 12.5, color: c.sub, marginTop: i === 0 ? 16 : 8 }}>\n                {s}\n                {s === \"MCP\" && <span style={{ marginLeft: \"auto\", color: c.accent, fontSize: 10, fontWeight: 600 }}>New</span>}\n              </div>\n            ))}\n            <div style={{ fontFamily: MONO, fontSize: 9.5, letterSpacing: 1, textTransform: \"uppercase\", color: c.faint, marginTop: 18 }}>Recents</div>\n            {recents.map((r, i) => {\n              const t = Math.max(0, Math.min(1, (p - 0.12 - i * 0.06) / 0.14));\n              const re = t * t * (3 - 2 * t);\n              return (\n                <div key={r} style={{ display: \"flex\", alignItems: \"center\", gap: 8, marginTop: 10, opacity: re, transform: `translateX(${(1 - re) * 8}px)` }}>\n                  <span style={{ width: 6, height: 6, borderRadius: 999, background: i === 0 ? c.accent : c.line2 }} />\n                  <span style={{ fontFamily: SANS, fontSize: 12, color: c.ink, whiteSpace: \"nowrap\", overflow: \"hidden\", textOverflow: \"ellipsis\" }}>{r}</span>\n                </div>\n              );\n            })}\n            <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\" }}>\n              New task\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {/* ---- beat B · desktop ---- */}\n      <div style={{ position: \"absolute\", inset: 0, display: \"flex\", flexDirection: \"column\", gap: 16, alignItems: \"center\", justifyContent: \"center\", opacity: desktopOpacity, pointerEvents: \"none\" }}>\n        <BeatCaption text=\"At your desk\" />\n        <div style={{ display: \"flex\", flexDirection: \"column\", alignItems: \"center\", transform: `translateY(${(1 - de) * 24}px)` }}>\n          {/* laptop screen */}\n          <div style={{ width: 600, background: \"#0e0e0d\", borderRadius: 16, padding: 12, boxShadow: \"0 30px 60px -30px rgba(0,0,0,0.5)\" }}>\n            <div style={{ background: c.card, borderRadius: 8, overflow: \"hidden\", height: 300, display: \"flex\" }}>\n              {/* sidebar */}\n              <div style={{ width: 152, borderRight: `1px solid ${c.line}`, padding: \"14px 12px\" }}>\n                <div style={{ display: \"flex\", alignItems: \"center\", gap: 7 }}>\n                  <Mark size={13} />\n                  <span style={{ fontFamily: SANS, fontSize: 12, fontWeight: 700, color: c.ink }}>Alpaca Labs</span>\n                </div>\n                {[\"New task\", \"Projects\", \"Artifacts\", \"MCP\"].map((s, i) => (\n                  <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 }}>\n                    {s}\n                    {s === \"MCP\" && <span style={{ marginLeft: \"auto\", color: c.accent, fontSize: 9, fontWeight: 600 }}>New</span>}\n                  </div>\n                ))}\n              </div>\n              {/* main */}\n              <div style={{ flex: 1, padding: \"16px 20px\", display: \"flex\", flexDirection: \"column\" }}>\n                <div style={{ fontFamily: MONO, fontSize: 9.5, letterSpacing: 1, textTransform: \"uppercase\", color: c.faint }}>Recents</div>\n                <div style={{ marginTop: 12, display: \"grid\", gridTemplateColumns: \"1fr 1fr\", gap: 10 }}>\n                  {recents.map((r, i) => {\n                    const t = Math.max(0, Math.min(1, (p - (SWAP + 0.06) - i * 0.05) / 0.14));\n                    const re = t * t * (3 - 2 * t);\n                    return (\n                      <div key={r} style={{ opacity: re, transform: `translateY(${(1 - re) * 8}px)`, border: `1px solid ${c.line}`, borderRadius: 10, padding: \"11px 12px\" }}>\n                        <div style={{ display: \"flex\", alignItems: \"center\", gap: 7 }}>\n                          <span style={{ width: 6, height: 6, borderRadius: 999, background: i === 0 ? c.accent : c.line2 }} />\n                          <span style={{ fontFamily: SANS, fontSize: 12, fontWeight: 600, color: c.ink, whiteSpace: \"nowrap\", overflow: \"hidden\", textOverflow: \"ellipsis\" }}>{r}</span>\n                        </div>\n                        <div style={{ marginTop: 9, height: 5, width: `${70 - i * 8}%`, borderRadius: 4, background: c.line2 }} />\n                      </div>\n                    );\n                  })}\n                </div>\n                {/* mini composer */}\n                <div style={{ marginTop: \"auto\", display: \"flex\", alignItems: \"center\", gap: 10, border: `1px solid ${c.line2}`, borderRadius: 10, padding: \"10px 12px\" }}>\n                  <span style={{ color: c.faint, fontSize: 15 }}>＋</span>\n                  <span style={{ fontFamily: SANS, fontSize: 12, color: c.faint }}>Where should we begin?</span>\n                  <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>\n                </div>\n              </div>\n            </div>\n          </div>\n          {/* laptop base */}\n          <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)\" }} />\n          <div style={{ width: 96, height: 5, background: \"#c4c4bf\", borderRadius: \"0 0 6px 6px\" }} />\n        </div>\n      </div>\n    </Frame>\n  );\n}\n\n/* ---- the film ------------------------------------------------------------- */\n\nexport interface Chapter {\n  id: string;\n  label: string;\n  /** seconds this chapter runs for */\n  duration: number;\n  /** receives 0→1 progress within this chapter */\n  Scene: (props: { p: number }) => ReactNode;\n}\n\nexport const DEFAULT_CHAPTERS: Chapter[] = [\n  { id: \"hook\", label: \"Hook\", duration: 5, Scene: SceneHook },\n  { id: \"ask\", label: \"Just ask\", duration: 5, Scene: SceneJustAsk },\n  { id: \"agent\", label: \"Agent at work\", duration: 6, Scene: SceneAgent },\n  { id: \"result\", label: \"Result\", duration: 6, Scene: SceneResult },\n  { id: \"integrations\", label: \"Integrations\", duration: 8, Scene: SceneIntegrations },\n  { id: \"anywhere\", label: \"Anywhere\", duration: 6, Scene: SceneAnywhere },\n];\n\nconst KEYFRAMES = `\n@keyframes lf-blink { 0%,49%{opacity:1} 50%,100%{opacity:0} }\n.lf-caret { animation: lf-blink 1s step-end infinite; }\n@keyframes lf-fade { from { opacity: 0 } to { opacity: 1 } }\n@keyframes lf-spin { to { transform: translate(-50%, -50%) rotate(360deg) } }\n.lf-beam { animation: lf-spin linear infinite; will-change: transform; }\n`;\n\nexport interface LaunchFilmProps {\n  /** Rendered width in px; the whole film scales from this. Default 920. */\n  width?: number;\n  chapters?: Chapter[];\n  /** Autoplay on mount. Default true. */\n  autoPlay?: boolean;\n  /** Loop back to the start at the end. Default true. */\n  loop?: boolean;\n}\n\nexport default function LaunchFilm({\n  width = STAGE_W,\n  chapters = DEFAULT_CHAPTERS,\n  autoPlay = true,\n  loop = true,\n}: LaunchFilmProps) {\n  const total = chapters.reduce((s, ch) => s + ch.duration, 0);\n  const scale = width / CANVAS_W;\n\n  const [time, setTime] = useState(0);\n  const [playing, setPlaying] = useState(autoPlay);\n  const raf = useRef<number>();\n  const last = useRef<number>();\n  const trackRef = useRef<HTMLDivElement>(null);\n  const dragging = useRef(false);\n\n  // rAF clock\n  useEffect(() => {\n    if (!playing) {\n      last.current = undefined;\n      return;\n    }\n    const tick = (now: number) => {\n      if (last.current != null) {\n        const dt = (now - last.current) / 1000;\n        setTime((prev) => {\n          let next = prev + dt;\n          if (next >= total) {\n            if (loop) next %= total;\n            else {\n              next = total;\n              setPlaying(false);\n            }\n          }\n          return next;\n        });\n      }\n      last.current = now;\n      raf.current = requestAnimationFrame(tick);\n    };\n    raf.current = requestAnimationFrame(tick);\n    return () => {\n      if (raf.current) cancelAnimationFrame(raf.current);\n      last.current = undefined;\n    };\n  }, [playing, total, loop]);\n\n  // locate active chapter + in-chapter progress\n  let acc = 0;\n  let activeIndex = 0;\n  let chapterProgress = 0;\n  for (let i = 0; i < chapters.length; i++) {\n    const end = acc + chapters[i].duration;\n    if (time < end || i === chapters.length - 1) {\n      activeIndex = i;\n      chapterProgress = Math.min(1, (time - acc) / chapters[i].duration);\n      break;\n    }\n    acc = end;\n  }\n  const Active = chapters[activeIndex].Scene;\n  const playhead = Math.min(1, time / total); // 0→1 across the whole film\n\n  // seeking\n  const seekToClientX = useCallback(\n    (clientX: number) => {\n      const el = trackRef.current;\n      if (!el) return;\n      const rect = el.getBoundingClientRect();\n      const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));\n      setTime(frac * total);\n    },\n    [total],\n  );\n\n  const onPointerDown = (e: React.PointerEvent) => {\n    dragging.current = true;\n    (e.target as Element).setPointerCapture?.(e.pointerId);\n    seekToClientX(e.clientX);\n  };\n  const onPointerMove = (e: React.PointerEvent) => {\n    if (dragging.current) seekToClientX(e.clientX);\n  };\n  const onPointerUp = () => {\n    dragging.current = false;\n  };\n\n  return (\n    <div style={{ width: CANVAS_W * scale, height: CANVAS_H * scale, fontFamily: SANS }}>\n      <style>{KEYFRAMES}</style>\n      <div\n        style={{\n          width: CANVAS_W,\n          height: CANVAS_H,\n          transform: `scale(${scale})`,\n          transformOrigin: \"top left\",\n          position: \"relative\",\n        }}\n      >\n        {/* stage */}\n        <div style={{ position: \"absolute\", top: 0, left: 0, width: STAGE_W, height: STAGE_H, background: c.stage, borderRadius: 24, overflow: \"hidden\" }}>\n          <div style={{ position: \"absolute\", inset: 22 }}>\n            {/* re-mount per chapter so the entry cross-fade replays */}\n            <div key={chapters[activeIndex].id} style={{ position: \"absolute\", inset: 0, animation: \"lf-fade 0.5s ease\" }}>\n              <Active p={chapterProgress} />\n            </div>\n          </div>\n\n          {/* play / pause */}\n          <button\n            type=\"button\"\n            aria-label={playing ? \"Pause\" : \"Play\"}\n            onClick={() => {\n              if (!loop && time >= total) setTime(0);\n              setPlaying((v) => !v);\n            }}\n            style={{\n              position: \"absolute\",\n              left: 16,\n              bottom: 16,\n              width: 40,\n              height: 40,\n              borderRadius: 999,\n              border: \"none\",\n              cursor: \"pointer\",\n              background: \"rgba(20,20,18,0.82)\",\n              color: \"#fff\",\n              fontSize: 15,\n              display: \"flex\",\n              alignItems: \"center\",\n              justifyContent: \"center\",\n              backdropFilter: \"blur(4px)\",\n            }}\n          >\n            {playing ? \"❚❚\" : \"▶\"}\n          </button>\n        </div>\n\n        {/* chapter track / scrubber */}\n        <div style={{ position: \"absolute\", left: 0, top: STAGE_H + GAP, width: STAGE_W, height: TRACK_H }}>\n          {/* progress rail + playhead bubble */}\n          <div style={{ position: \"relative\", height: 26 }}>\n            <div style={{ position: \"absolute\", top: 12, left: 0, right: 0, height: 2, background: c.line2, borderRadius: 2 }} />\n            <div style={{ position: \"absolute\", top: 12, left: 0, width: `${playhead * 100}%`, height: 2, background: c.playhead, borderRadius: 2 }} />\n            {/* playhead + timestamp bubble */}\n            <div style={{ position: \"absolute\", top: 0, left: `${playhead * 100}%`, transform: \"translateX(-50%)\", display: \"flex\", flexDirection: \"column\", alignItems: \"center\" }}>\n              <div style={{ fontFamily: MONO, fontSize: 11, color: \"#fff\", background: c.playhead, borderRadius: 6, padding: \"3px 8px\", lineHeight: 1, whiteSpace: \"nowrap\" }}>\n                {fmt(time)}\n              </div>\n              <div style={{ width: 11, height: 11, borderRadius: 999, background: c.playhead, marginTop: 3, boxShadow: \"0 0 0 3px rgba(20,20,18,0.12)\" }} />\n            </div>\n          </div>\n\n          {/* labelled, draggable segments */}\n          <div\n            ref={trackRef}\n            onPointerDown={onPointerDown}\n            onPointerMove={onPointerMove}\n            onPointerUp={onPointerUp}\n            style={{ display: \"flex\", gap: 8, marginTop: 8, height: TRACK_H - 34, cursor: \"pointer\", touchAction: \"none\" }}\n          >\n            {chapters.map((ch, i) => (\n              <div\n                key={ch.id}\n                style={{\n                  flex: ch.duration,\n                  borderRadius: 10,\n                  background: i === activeIndex ? c.segActive : c.segIdle,\n                  transition: \"background .25s\",\n                  display: \"flex\",\n                  alignItems: \"center\",\n                  justifyContent: \"center\",\n                  textAlign: \"center\",\n                  padding: \"0 8px\",\n                }}\n              >\n                <span style={{ fontFamily: SANS, fontSize: 15, fontWeight: i === activeIndex ? 600 : 500, color: i === activeIndex ? c.ink : c.sub }}>\n                  {ch.label}\n                </span>\n              </div>\n            ))}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}"
    },
    {
      "slug": "sealgate-film",
      "title": "SealGate launch film",
      "category": "Marketing",
      "summary": "A SealGate product film on the launch-film engine, in the sealgate.ai brand system: write a policy in plain English, watch the SealGate agent collect context, write the policy and dock it into the gate, then one continuous camera move pushes into Claude and ChatGPT and pulls back as their tool calls pass through the gate (one allowed, one blocked), then every integration and the same enforcement on iPhone and MacBook.",
      "problem": "A security product is hard to show: the value is a tool call that did not happen. This film makes the enforcement visible by following one request end to end: the policy is written, compiled into guardrails, and then a real-looking chat in Claude or ChatGPT fires a tool call that travels into the SealGate gate. The gate works like an airlock: the entry door opens, the call is zoomed up and scanned, and the exit door opens onto Gmail, or the gate turns red and the call stays inside. Built from real UI (ChatShell, IPhoneMock, MacBookMock) on one rAF clock, so it stays crisp, scrubbable and editable in code.",
      "whenToUse": [
        "A security or governance product needs a launch video that shows an action being allowed and another being blocked.",
        "You want the same story told across real chat clients (Claude, ChatGPT) and devices (phone and laptop) without recording screens.",
        "The film should follow a brand system exactly (palette budget, typefaces, Hackbox tags) and stay editable after launch."
      ],
      "source": {
        "name": "sealgate.ai",
        "url": "https://sealgate.ai"
      },
      "tags": [
        "launch",
        "video",
        "film",
        "sealgate",
        "security",
        "policy",
        "airlock",
        "gate",
        "chat",
        "claude",
        "chatgpt",
        "integrations",
        "iphone",
        "macbook",
        "scrubber",
        "requestAnimationFrame"
      ],
      "dependencies": [
        "react",
        "tailwindcss",
        "chat-shell",
        "iphone-mock",
        "macbook-mock"
      ],
      "status": "stable",
      "demoUrl": "https://eito.me/ui/sealgate-film",
      "markdownUrl": "https://eito.me/ui/patterns/sealgate-film.md",
      "code": "import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from \"react\";\nimport ChatShell, { chatThemes, type ChatTheme, type Turn as ChatTurn } from \"../chat-shell/snippet\";\nimport IPhoneMock, { ChatGPTScreen } from \"../iphone-mock/snippet\";\nimport MacBookMock, { ClaudeScreen } from \"../macbook-mock/snippet\";\nimport { INTEGRATIONS, type Integration } from \"./integrations\";\nimport wordmarkSvg from \"./assets/sealgate-wordmark.svg?raw\";\nimport logomarkSvg from \"./assets/sealgate-logomark.svg?raw\";\n\n/**\n * SealGateFilm: a SealGate launch film built on the LaunchFilm engine (one rAF\n * clock, data-driven chapters, a draggable chapter scrubber) and dressed in the\n * sealgate.ai brand system: black stage, Core Cyan as the single signal, Archivo\n * Medium as the default face, JetBrains Mono for data, Hackbox tags at 40% x 10%.\n *\n * The story, one chapter each:\n *   1. SealGate           wordmark + the landing-page headline\n *   2. Write a policy     a policy composer types itself; the camera pushes in and\n *                         zoom-cuts onto the send button (LaunchFilm's \"Just ask\")\n *   3-5. one continuous world with one camera (chapter cuts are invisible):\n *      Agent at work      the SealGate agent collects context (status line,\n *                         scanned reel, a stack of sources), writes the policy,\n *                         drops into the gate as its seal and docks the policy\n *                         as the gate's POLICY tag\n *      Ask in any AI      the camera pushes into Claude and ChatGPT (ChatShell)\n *                         as the team prompts\n *      Airlock            the camera pulls back and both calls travel to Gmail\n *                         through the gate: entry door opens, the call is\n *                         scanned, then the exit door opens or the call is held\n *   6. Integrations       every agent and data source listed on sealgate.ai\n *   7. Any device         the same enforcement on iPhone (ChatGPT) and MacBook\n *                         (Claude), using IPhoneMock + MacBookMock\n *   8. Start free         end card + CTA\n *\n * Every animation reads the film clock, so pausing or scrubbing freezes the\n * exact frame. Reuses three sibling patterns (chat-shell, iphone-mock,\n * macbook-mock); ChatShell needs Tailwind. Brand SVGs are imported with Vite's\n * `?raw` and inlined as data URIs.\n */\n\nconst STAGE_W = 920;\nconst STAGE_H = 516;\nconst TRACK_H = 96;\nconst GAP = 12;\nconst CANVAS_W = STAGE_W;\nconst CANVAS_H = STAGE_H + GAP + TRACK_H;\nconst INSET = 22;\nconst BOX_W = STAGE_W - INSET * 2; // 876\nconst BOX_H = STAGE_H - INSET * 2; // 472\n\nconst c = {\n  black: \"#000000\",\n  panel: \"#05070a\",\n  panel2: \"#1C1C1C\",\n  line: \"#383838\",\n  line2: \"#555555\",\n  grey: \"#9BA4A6\",\n  dim: \"#8F8F8F\",\n  white: \"#F9F9F9\",\n  cyan: \"#C3FFFD\",\n  green: \"#00C781\",\n  red: \"#FF3B4D\",\n} as const;\n\nconst SANS = '\"Archivo\", ui-sans-serif, sans-serif';\nconst MONO = '\"JetBrains Mono\", ui-monospace, Menlo, monospace';\nconst FONTS = \"@import url('https://fonts.googleapis.com/css2?family=Archivo:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');\";\n\nconst BASE = (import.meta as { env?: { BASE_URL?: string } }).env?.BASE_URL ?? \"/\";\nconst uri = (svg: string) => `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;\nconst WORDMARK = uri(wordmarkSvg);\nconst LOGOMARK = uri(logomarkSvg);\n\nconst clamp01 = (v: number) => Math.max(0, Math.min(1, v));\nconst ease = (t: number) => t * t * (3 - 2 * t);\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst seg = (t: number, a: number, b: number) => clamp01((t - a) / (b - a));\nconst at = (p: number, start: number, dur: number) => ease(clamp01((p - start) / dur));\nconst pulse = (t: number, a: number, b: number) => Math.min(seg(t, a - 0.06, a), 1 - seg(t, b, b + 0.06));\nconst fmt = (s: number) => `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, \"0\")}`;\n\n/** The SealGate wordmark inline, sized by height (it replaces the name in text). */\nfunction Wordmark({ h, style }: { h: number; style?: CSSProperties }) {\n  return <img src={WORDMARK} alt=\"SealGate\" style={{ height: h, width: h * (2241.77 / 391), display: \"inline-block\", verticalAlign: \"middle\", ...style }} />;\n}\n\n/* ---- integrations as images ---------------------------------------------- */\n\nconst byName = (name: string) => INTEGRATIONS.find((i) => i.name === name) as Integration;\n\n/** A brand tile as a standalone SVG (for <img>/<image> and ChatShell icons). */\nfunction tileSvg(it: Integration) {\n  if (it.raw) return `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"${it.viewBox}\">${it.body}</svg>`;\n  return `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><rect width=\"24\" height=\"24\" rx=\"5\" fill=\"${it.color}\"/><svg x=\"4.2\" y=\"4.2\" width=\"15.6\" height=\"15.6\" viewBox=\"${it.viewBox}\">${it.body}</svg></svg>`;\n}\nconst GMAIL = uri(tileSvg(byName(\"Gmail\")));\n\n/** Chrome's mark, drawn inline (three 120° sectors around the blue core). */\nconst CHROME = uri(\n  `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 48 48\"><path fill=\"#EA4335\" d=\"M24 24L4.95 13A22 22 0 0 1 43.05 13Z\"/><path fill=\"#FBBC04\" d=\"M24 24L43.05 13A22 22 0 0 1 24 46Z\"/><path fill=\"#34A853\" d=\"M24 24L24 46A22 22 0 0 1 4.95 13Z\"/><circle cx=\"24\" cy=\"24\" r=\"10\" fill=\"#fff\"/><circle cx=\"24\" cy=\"24\" r=\"8\" fill=\"#4285F4\"/></svg>`,\n);\nconst CLAUDE_TILE = uri(tileSvg(byName(\"Claude Desktop\")));\nconst CHATGPT_TILE = uri(tileSvg(byName(\"ChatGPT\")));\nconst GROKBOT = `${BASE}icons/agents/grokbot.svg`;\n\nfunction Tile({ it, size }: { it: Integration; size: number }) {\n  return <img src={uri(tileSvg(it))} alt={it.name} width={size} height={size} style={{ display: \"block\", outline: `1px solid ${c.line}`, outlineOffset: -1, borderRadius: size * 0.21 }} />;\n}\n\n/* ---- brand primitives ----------------------------------------------------- */\n\nfunction Scene({ children }: { children: ReactNode }) {\n  return <div style={{ position: \"absolute\", inset: 0, background: c.black, border: `1px solid ${c.line}`, overflow: \"hidden\" }}>{children}</div>;\n}\n\nfunction Label({ children, color = c.grey }: { children: ReactNode; color?: string }) {\n  return <div style={{ fontFamily: MONO, fontSize: 11, letterSpacing: 2, textTransform: \"uppercase\", color }}>{children}</div>;\n}\n\nfunction Rise({ e, y = 10, children, style }: { e: number; y?: number; children: ReactNode; style?: CSSProperties }) {\n  return <div style={{ opacity: e, transform: `translateY(${(1 - e) * y}px)`, ...style }}>{children}</div>;\n}\n\n/** Mono label + Archivo Light headline, as section heads read on sealgate.ai. */\nfunction Heading({ p, label, title, center }: { p: number; label?: string; title: string; center?: boolean }) {\n  return (\n    <Rise e={at(p, 0, 0.14)} style={{ textAlign: center ? \"center\" : \"left\" }}>\n      {label && <Label color={c.cyan}>{label}</Label>}\n      <div style={{ fontFamily: SANS, fontSize: 28, fontWeight: 500, color: c.white, marginTop: label ? 8 : 0, letterSpacing: -0.6 }}>{title}</div>\n    </Rise>\n  );\n}\n\n/* ---- 01 · SealGate -------------------------------------------------------- */\n\nfunction SceneHook({ p }: { p: number }) {\n  return (\n    <Scene>\n      <div style={{ position: \"absolute\", left: 0, right: 0, top: 138, display: \"flex\", flexDirection: \"column\", alignItems: \"center\" }}>\n        <Rise e={at(p, 0.1, 0.25)}>\n          <img src={WORDMARK} alt=\"SealGate\" style={{ height: 58, display: \"block\" }} />\n        </Rise>\n        <Rise e={at(p, 0.3, 0.25)}>\n          <div style={{ fontFamily: SANS, fontSize: 32, fontWeight: 500, color: c.white, marginTop: 36, letterSpacing: -0.8 }}>\n            Connect &amp; Govern how agents interact with your data\n          </div>\n        </Rise>\n      </div>\n    </Scene>\n  );\n}\n\n/* ---- 02 · Write a policy -------------------------------------------------- */\n/* The composer draws itself in, the heading rises word by word and the agents\n   the rule covers spring onto it. While the rule types, the camera tracks the\n   caret; each key phrase is underlined as it lands and pops a matching chip.\n   The camera then pushes onto the send button, pulls back on the press, and\n   the rule lifts off as a message that lands exactly where the next chapter's\n   agent picks it up. */\n\nconst COMP = { x: 128, y: 190, w: 620, h: 124 };\nconst SEND = { x: COMP.x + COMP.w - 18 - 17, y: COMP.y + COMP.h - 16 - 17 };\nconst TYPE = { from: 0.95, to: 2.85 };\nconst TERMS = [\n  { text: \"email\", chip: \"gmail.send_email\" },\n  { text: \"customer data\", chip: \"pii\" },\n  { text: \"outside the company\", chip: \"external domains\" },\n];\nconst HEADLINE = \"What should your agents never do?\";\n\n/** The composer's send button: outlined until the rule is typed, then a Core\n    Cyan fill springs out from its centre; the press dips it and throws a ring. */\nfunction SendButton({ fill, press }: { fill: number; press: number }) {\n  const size = 34;\n  const dip = press > 0 ? 1 - 0.12 * Math.sin(Math.min(1, press * 2.5) * Math.PI) : 1;\n  const f = fill > 0 ? spring(fill) : 0;\n  return (\n    <div style={{ position: \"absolute\", left: SEND.x - COMP.x - size / 2, top: SEND.y - COMP.y - size / 2, width: size, height: size, transform: `scale(${dip})` }}>\n      {press > 0 && press < 1 && (\n        <div style={{ position: \"absolute\", inset: 0, borderRadius: 8, border: `1.5px solid ${c.cyan}`, transform: `scale(${1 + press * 1.1})`, opacity: 1 - press }} />\n      )}\n      <div style={{ position: \"absolute\", inset: 0, borderRadius: 8, border: `1px solid ${fill > 0 ? c.cyan : c.line2}` }} />\n      <div style={{ position: \"absolute\", inset: 0, borderRadius: 8, background: c.cyan, transform: `scale(${f.toFixed(3)})` }} />\n      <svg width={16} height={16} viewBox=\"0 0 24 24\" fill=\"none\" stroke={fill > 0.4 ? c.black : c.dim} strokeWidth={2.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\" style={{ position: \"absolute\", left: 9, top: 9, transform: `translateY(${(-3 * Math.sin(Math.min(1, press * 2) * Math.PI)).toFixed(2)}px)` }}>\n        <path d=\"M12 19V5M5 12l7-7 7 7\" />\n      </svg>\n    </div>\n  );\n}\n\nconst typedAt = (t: number) => Math.floor(seg(t, TYPE.from, TYPE.to) * POLICY_PROMPT.length);\nconst termDone = (text: string) => TYPE.from + ((POLICY_PROMPT.indexOf(text) + text.length) / POLICY_PROMPT.length) * (TYPE.to - TYPE.from);\n\n/** The rule as typed so far, key phrases underlined in cyan as they complete. */\nfunction TypedRule({ chars, t }: { chars: number; t: number }) {\n  const out: ReactNode[] = [];\n  let i = 0;\n  for (const term of TERMS) {\n    const k = POLICY_PROMPT.indexOf(term.text);\n    out.push(POLICY_PROMPT.slice(i, Math.min(k, chars)));\n    if (chars <= k) return <>{out}</>;\n    const u = ease(seg(t, termDone(term.text), termDone(term.text) + 0.25));\n    out.push(\n      <span key={term.text} style={{ position: \"relative\", color: u > 0.5 ? c.cyan : c.white }}>\n        {POLICY_PROMPT.slice(k, Math.min(k + term.text.length, chars))}\n        <span style={{ position: \"absolute\", left: 0, bottom: -3, height: 2, width: `${u * 100}%`, background: c.cyan }} />\n      </span>,\n    );\n    i = k + term.text.length;\n  }\n  out.push(POLICY_PROMPT.slice(i, chars));\n  return <>{out}</>;\n}\n\n/** The sent rule as a message bubble, shared with the agent chapter so the\n    hand-off lands on the same pixels. */\nconst BUBBLE_W = 330;\nfunction Bubble({ style }: { style?: CSSProperties }) {\n  return (\n    <div style={{ position: \"absolute\", width: BUBBLE_W, boxSizing: \"border-box\", border: `1px solid ${c.line2}`, background: c.panel2, padding: \"8px 12px\", fontFamily: SANS, fontWeight: 500, fontSize: 12.5, lineHeight: \"17px\", color: c.white, transformOrigin: \"0 0\", ...style }}>\n      {POLICY_PROMPT}\n    </div>\n  );\n}\n\nfunction policyCam(t: number): Pose {\n  const home: Pose = { s: 1, x: COMP.x + COMP.w / 2, y: 236 };\n  // a gentle drift that follows the caret without losing the start of the line\n  const track: Pose = { s: 1.2, x: COMP.x + COMP.w / 2 + lerp(-50, 50, seg(t, TYPE.from, TYPE.to)), y: COMP.y + 40 };\n  const send: Pose = { s: 2.1, x: SEND.x - 60, y: SEND.y - 10 };\n  if (t < 0.9) return mix({ ...home, s: 0.92 }, home, ease(seg(t, 0, 0.9)));\n  if (t < 3.0) return mix(home, track, ease(seg(t, 0.9, 1.6)));\n  if (t < 3.6) return mix(track, send, ease(seg(t, 3.0, 3.4)));\n  return mix(send, P_WIDE, ease(seg(t, 3.6, 4.35)));\n}\n\nfunction ScenePolicy({ p }: { p: number }) {\n  const t = p * 5;\n  const chars = typedAt(t);\n  const done = chars >= POLICY_PROMPT.length;\n  const caretOn = Math.floor(t * 2.6) % 2 === 0;\n  const cam = policyCam(t);\n\n  const draw = ease(seg(t, 0.15, 0.85));\n  const perim = 2 * (COMP.w + COMP.h);\n  const beam = 150;\n  const collapse = ease(seg(t, 3.7, 4.2));\n  const lift = seg(t, 3.72, 4.6);\n\n  // the bubble flies in screen space, from the composer text to the agent chapter's spot\n  const f = ease(lift);\n  const fromX = BOX_W / 2 + cam.s * (COMP.x + 20 - cam.x);\n  const fromY = BOX_H / 2 + cam.s * (COMP.y + 16 - cam.y);\n  const toX = BOX_W / 2 + P_NET.s * (RULE_AT.x - P_NET.x);\n  const toY = BOX_H / 2 + P_NET.s * (RULE_AT.y - P_NET.y);\n  const bx = lerp(fromX, toX, f);\n  const by = lerp(fromY, toY, f) - Math.sin(f * Math.PI) * 26;\n  const bs = lerp(cam.s * 1.15, P_NET.s, f);\n  const brand = at(t, 0.05, 0.4);\n\n  return (\n    <Scene>\n      <div style={{ position: \"absolute\", inset: 0, transformOrigin: \"0 0\", transform: `translate(${(BOX_W / 2 - cam.s * cam.x).toFixed(1)}px, ${(BOX_H / 2 - cam.s * cam.y).toFixed(1)}px) scale(${cam.s.toFixed(3)})` }}>\n        <div style={{ position: \"absolute\", left: COMP.x, top: 102, width: COMP.w, opacity: 1 - collapse, transform: `translateY(${(-collapse * 16).toFixed(1)}px)` }}>\n          <div style={{ display: \"flex\", alignItems: \"center\", gap: 10, opacity: brand, transform: `translateX(${((1 - brand) * -14).toFixed(1)}px)` }}>\n            <Wordmark h={22} />\n          </div>\n          <div style={{ fontFamily: SANS, fontSize: 24, fontWeight: 500, color: c.white, marginTop: 14, display: \"flex\", flexWrap: \"wrap\", columnGap: 7 }}>\n            {HEADLINE.split(\" \").map((word, i) => {\n              const e = at(t, 0.2 + i * 0.07, 0.45);\n              return (\n                <span key={i} style={{ display: \"inline-block\", overflow: \"hidden\", paddingBottom: 3 }}>\n                  <span style={{ display: \"inline-block\", transform: `translateY(${((1 - e) * 110).toFixed(1)}%)` }}>{word}</span>\n                </span>\n              );\n            })}\n          </div>\n        </div>\n        <div style={{ position: \"absolute\", left: COMP.x, top: COMP.y, width: COMP.w, height: COMP.h, transformOrigin: \"50% 0\", transform: `translateY(${(collapse * 18).toFixed(1)}px) scaleY(${(1 - collapse * 0.35).toFixed(3)})`, opacity: 1 - collapse }}>\n          <div style={{ position: \"absolute\", inset: 0, background: c.panel, opacity: draw }} />\n          <svg width={COMP.w} height={COMP.h} style={{ position: \"absolute\", left: 0, top: 0, overflow: \"visible\" }} aria-hidden=\"true\">\n            <rect x={0.5} y={0.5} width={COMP.w - 1} height={COMP.h - 1} fill=\"none\" stroke={c.line2} strokeWidth={1} strokeDasharray={perim} strokeDashoffset={perim * (1 - draw)} />\n            {draw >= 1 && <rect x={0} y={0} width={COMP.w} height={COMP.h} fill=\"none\" stroke={c.cyan} strokeWidth={2} strokeDasharray={`${beam} ${perim - beam}`} strokeDashoffset={-perim * (t - 0.85) * 0.32} />}\n          </svg>\n          <div style={{ position: \"relative\", padding: \"22px 20px 0\", fontFamily: SANS, fontWeight: 500, fontSize: 16, color: chars ? c.white : c.dim, minHeight: 22, opacity: draw * (1 - seg(t, 3.72, 3.85)) }}>\n            {chars ? <TypedRule chars={chars} t={t} /> : \"Describe a rule in plain English…\"}\n            {!done && t > 0.6 && <span style={{ display: \"inline-block\", width: 2, height: 17, background: c.cyan, marginLeft: 1, verticalAlign: -2, opacity: caretOn ? 1 : 0 }} />}\n          </div>\n          {/* the agents this rule applies to, then the phrases it picked out */}\n          <div style={{ position: \"absolute\", left: 18, right: 70, bottom: 20, display: \"flex\", alignItems: \"center\", gap: 8 }}>\n            <div style={{ display: \"flex\", marginRight: 6 }}>\n              {[CLAUDE_TILE, CHATGPT_TILE, GROKBOT].map((src, i) => {\n                const e = seg(t, 0.6 + i * 0.1, 1.0 + i * 0.1);\n                return <img key={src} src={src} alt=\"\" width={22} height={22} style={{ display: \"block\", borderRadius: 5, marginLeft: i ? -5 : 0, boxShadow: `0 0 0 2px ${c.panel}, 0 0 0 3px ${c.line2}`, position: \"relative\", zIndex: 3 - i, opacity: clamp01(e * 3), transform: `scale(${e > 0 ? spring(e).toFixed(3) : 0})` }} />;\n              })}\n            </div>\n            {TERMS.map((term) => {\n              const e = seg(t, termDone(term.text) + 0.1, termDone(term.text) + 0.45);\n              if (e <= 0) return null;\n              const sp = spring(e);\n              return (\n                <span key={term.chip} style={{ fontFamily: MONO, fontSize: 11, color: c.cyan, border: `1px solid ${c.cyan}`, padding: \"3px 7px\", whiteSpace: \"nowrap\", opacity: clamp01(e * 3), transform: `translateY(${((1 - sp) * 10).toFixed(1)}px) scale(${(0.8 + 0.2 * sp).toFixed(3)})` }}>\n                  {term.chip}\n                </span>\n              );\n            })}\n          </div>\n          <SendButton fill={seg(t, 3.05, 3.4)} press={seg(t, 3.45, 3.95)} />\n        </div>\n      </div>\n      {lift > 0 && <Bubble style={{ left: bx, top: by, transform: `scale(${bs.toFixed(3)})`, opacity: clamp01(lift * 6) }} />}\n    </Scene>\n  );\n}\n\n/* ---- 04 · Ask in any AI (intent-based prompting) -------------------------- */\n\nconst theme = (id: \"claude\" | \"chatgpt\"): ChatTheme => ({ ...chatThemes[id], logo: `${BASE}icons/agents/${id}.svg` });\nconst CLAUDE = theme(\"claude\");\nconst CHATGPT = theme(\"chatgpt\");\n\nconst ASK_CLAUDE = \"Send the Q3 board summary to the leadership team.\";\nconst ASK_GPT = \"Email our customer list to my personal Gmail.\";\n\nfunction claudeTurns(stage: number): ChatTurn[] {\n  const t: ChatTurn[] = [];\n  if (stage >= 1) t.push({ role: \"user\", text: ASK_CLAUDE });\n  if (stage >= 2) t.push({ role: \"tool\", name: \"gmail.send_email\", label: \"Sending email to leadership@acme.com\", icon: GMAIL });\n  if (stage >= 3) t.push({ role: \"assistant\", text: \"Sent to leadership@acme.com. SealGate checked it against company policy.\" });\n  return t;\n}\nfunction gptTurns(stage: number): ChatTurn[] {\n  const t: ChatTurn[] = [];\n  if (stage >= 1) t.push({ role: \"user\", text: ASK_GPT });\n  if (stage >= 2) t.push({ role: \"reason\", seconds: 4 }, { role: \"tool\", name: \"gmail.send_email\", label: \"Sending email via Gmail\" });\n  if (stage >= 3) t.push({ role: \"assistant\", text: \"SealGate blocked this: customer data can't be emailed outside the company.\" });\n  return t;\n}\n\nfunction Window({ theme: th, turns, width, scale, body }: { theme: ChatTheme; turns: ChatTurn[]; width: number; scale: number; body: number }) {\n  return (\n    <div style={{ width: width * scale, transformOrigin: \"0 0\" }}>\n      <div style={{ width, transform: `scale(${scale})`, transformOrigin: \"0 0\" }}>\n        <ChatShell theme={th} conversation={turns} bodyHeight={body} />\n      </div>\n    </div>\n  );\n}\n\n/* ---- 05 · Airlock --------------------------------------------------------- */\n/* The SealGate gate as an airlock, geometry and stage timings ported from the\n   sealgate.ai hero: the call reaches the entry door, the door opens, the camera\n   zooms the call up to a readable card and scans it, then the exit door opens\n   onto Gmail, or the gate turns red and the call stays inside. */\n\nconst ST = { arrive: 0.18, enter: 0.26, zoomIn: 0.33, scanEnd: 0.56, zoomOut: 0.7, small: 0.76, leave: 0.84, done: 1, blockFade: 0.84 };\nconst AG = { x: 480, y: 280, size: 196 };\nconst AL = AG.x - AG.size / 2;\nconst AR = AG.x + AG.size / 2;\nconst AT = AG.y - AG.size / 2;\nconst AB = AG.y + AG.size / 2;\nconst A_DOOR = 30;\nconst A_SEAL = 64;\nconst A_SEAL_Y = AT + AG.size * 0.1 + 10 + A_SEAL / 2;\nconst A_IN = (AL + AG.x - A_SEAL / 2) / 2;\nconst A_OUT = (AR + AG.x + A_SEAL / 2) / 2;\nconst A_ZW = AG.size - 12;\nconst A_ZH = 62;\nconst A_ZY = AB - 8 - A_ZH / 2;\nconst A_PKT = 9;\nconst SRC = [\n  { x: 292, y: 192 },\n  { x: 292, y: 372 },\n];\nconst DEST = { x: 776, y: AG.y, size: 60 };\n\ninterface Call {\n  t: number;\n  src: number;\n  blocked: boolean;\n  to: string;\n  attach?: string;\n}\n\nfunction ADoor({ x, open, alert }: { x: number; open: number; alert: boolean }) {\n  const h = A_DOOR / 2;\n  const f = h * open;\n  return (\n    <g stroke={alert ? c.red : c.cyan} strokeWidth={2}>\n      <line x1={x} y1={AT} x2={x} y2={AG.y - h} />\n      <line x1={x} y1={AG.y + h} x2={x} y2={AB} />\n      <line x1={x} y1={AG.y - h - f} x2={x} y2={AG.y - f} strokeWidth={4} />\n      <line x1={x} y1={AG.y + f} x2={x} y2={AG.y + h + f} strokeWidth={4} />\n    </g>\n  );\n}\n\n/**\n * The gate. `draw` traces the frame in (0 -> 1) while the policy is being built,\n * `tag` shows the docked POLICY tag, `wiring` fades in the lanes and Gmail.\n */\nfunction Airlock({ call, done, draw = 1, tag = 1, wiring = 1, seal = 1, lanes = [1, 1] }: { call?: Call; done: boolean[]; draw?: number; tag?: number; wiring?: number; seal?: number; lanes?: number[] }) {\n  const t = call?.t ?? 0;\n  const f = !!call && call.blocked && t >= ST.scanEnd;\n  const ok = !!call && !call.blocked && t >= ST.scanEnd;\n  const inOpen = call ? pulse(t, ST.arrive - 0.02, ST.enter) : 0;\n  const outOpen = call && !f ? pulse(t, ST.small - 0.02, ST.leave) : 0;\n  const zoom = call ? (t < ST.zoomOut ? ease(seg(t, ST.enter, ST.zoomIn)) : 1 - ease(seg(t, ST.zoomOut, ST.small))) : 0;\n\n  let px = 0;\n  let py = AG.y;\n  if (call) {\n    const s = SRC[call.src];\n    const doorIn = AL - 6;\n    const doorOut = AR + 6;\n    if (t < ST.arrive) {\n      const u = seg(t, 0, ST.arrive);\n      px = lerp(s.x, doorIn, u);\n      py = lerp(s.y, AG.y, u);\n    } else if (t < ST.enter) px = lerp(doorIn, A_IN, ease(seg(t, ST.arrive, ST.enter)));\n    else if (f || t < ST.small) px = f ? A_IN : A_OUT;\n    else if (t < ST.leave) px = lerp(A_OUT, doorOut, ease(seg(t, ST.small, ST.leave)));\n    else px = lerp(doorOut, DEST.x - DEST.size / 2 - 6, seg(t, ST.leave, ST.done));\n  }\n  const base = t < ST.zoomOut || f ? A_IN : A_OUT;\n  const cx = lerp(base, AG.x, zoom);\n  const w = lerp(A_PKT, A_ZW, zoom);\n  const h = lerp(A_PKT, A_ZH, zoom);\n  const cy = lerp(AG.y, A_ZY, zoom);\n  const fade = f ? 1 - seg(t, ST.small, ST.blockFade) * 0.6 : 1;\n  const detail = clamp01((zoom - 0.6) / 0.4);\n  const inGate = t >= ST.enter && (f || t < ST.small);\n  const visible = !!call && t > 0.02 && t < 0.985;\n  const scanning = zoom > 0.95 && t < ST.scanEnd;\n  const scanY = lerp(A_ZY - A_ZH / 2 + 4, A_ZY + A_ZH / 2 - 4, 0.5 - 0.5 * Math.cos(seg(t, ST.zoomIn, ST.scanEnd) * Math.PI * 2));\n  const ink = f ? c.red : ok ? c.cyan : c.white;\n  const hot = f || done[1];\n  const x1 = AR + 6;\n  const x2 = DEST.x - DEST.size / 2 - 6;\n  const mx = (x1 + x2) / 2;\n\n  return (\n    <svg width={BOX_W} height={BOX_H} style={{ position: \"absolute\", left: 0, top: 0 }} fill=\"none\">\n      <defs>\n        <marker id=\"sgf-arrow\" markerWidth=\"8\" markerHeight=\"8\" refX=\"6\" refY=\"3\" orient=\"auto\">\n          <path d=\"M0,0 L6,3 L0,6 Z\" fill={c.cyan} />\n        </marker>\n      </defs>\n      {SRC.map((s, i) => {\n        const d = lanes[i] ?? 1;\n        if (d <= 0) return null;\n        const tx = lerp(s.x, AL - 6, d);\n        const ty = lerp(s.y, AG.y, d);\n        return (\n          <g key={i}>\n            <line x1={s.x} y1={s.y} x2={tx} y2={ty} stroke={c.cyan} strokeOpacity={call?.src === i ? 1 : 0.45} strokeWidth={1.5} strokeDasharray=\"4 4\" />\n            {d < 1 && <rect x={tx - 3} y={ty - 3} width={6} height={6} fill={c.cyan} />}\n          </g>\n        );\n      })}\n      <g opacity={wiring}>\n      <line x1={x1} y1={AG.y} x2={x2} y2={AG.y} stroke={hot && !ok ? c.red : c.cyan} strokeOpacity={0.8} strokeWidth={1.5} strokeDasharray={hot && !ok ? \"4 4\" : undefined} markerEnd={hot && !ok ? undefined : \"url(#sgf-arrow)\"} />\n      {hot && !ok && (\n        <g>\n          <rect x={mx - 10} y={AG.y - 10} width={20} height={20} fill={c.black} stroke={c.red} strokeWidth={1.5} />\n          <path d={`M${mx - 4.5},${AG.y - 4.5} L${mx + 4.5},${AG.y + 4.5} M${mx + 4.5},${AG.y - 4.5} L${mx - 4.5},${AG.y + 4.5}`} stroke={c.red} strokeWidth={1.5} />\n        </g>\n      )}\n      <image href={GMAIL} x={DEST.x - DEST.size / 2} y={DEST.y - DEST.size / 2} width={DEST.size} height={DEST.size} />\n      <text x={DEST.x} y={DEST.y + DEST.size / 2 + 22} textAnchor=\"middle\" fill={c.grey} fontSize={11} letterSpacing={2} style={{ fontFamily: MONO }}>GMAIL</text>\n      </g>\n      {draw < 1 ? (\n        <rect x={AL} y={AT} width={AG.size} height={AG.size} stroke={c.cyan} strokeWidth={2} strokeDasharray={AG.size * 4} strokeDashoffset={AG.size * 4 * (1 - draw)} />\n      ) : (\n        <>\n          <g stroke={f ? c.red : c.cyan} strokeWidth={2}>\n            <line x1={AL} y1={AT} x2={AR} y2={AT} />\n            <line x1={AL} y1={AB} x2={AR} y2={AB} />\n          </g>\n          <ADoor x={AL} open={inOpen} alert={f} />\n          <ADoor x={AR} open={outOpen} alert={f} />\n        </>\n      )}\n      {tag > 0 && (\n        <g opacity={tag}>\n          <rect x={AL + 1} y={AT + 1} width={AG.size * 0.4} height={AG.size * 0.1} fill={f ? c.red : c.cyan} />\n          <text x={AL + 8} y={AT + 1 + AG.size * 0.1 - 6} fill={c.black} fontSize={9} fontWeight={500} letterSpacing={3} style={{ fontFamily: MONO }}>POLICY</text>\n        </g>\n      )}\n      <rect x={AG.x - A_SEAL / 2} y={A_SEAL_Y - A_SEAL / 2} width={A_SEAL} height={A_SEAL} fill={c.black} stroke={c.red} strokeWidth={2} strokeOpacity={f ? 1 : 0} />\n      <image href={LOGOMARK} x={AG.x - 22} y={A_SEAL_Y - 22} width={44} height={44} opacity={seal} />\n      {visible && call && (\n        <g opacity={fade}>\n          <rect\n            x={(inGate ? cx : px) - (inGate ? w : A_PKT) / 2}\n            y={(inGate ? cy : py) - (inGate ? h : A_PKT) / 2}\n            width={inGate ? w : A_PKT}\n            height={inGate ? h : A_PKT}\n            fill={zoom > 0.05 ? c.black : f ? c.red : c.cyan}\n            stroke={ink}\n            strokeWidth={zoom > 0.05 ? 1.5 : 0}\n          />\n          {detail > 0 && (\n            <g opacity={detail}>\n              <image href={GMAIL} x={AG.x - A_ZW / 2 + 8} y={A_ZY - 13} width={26} height={26} />\n              <g fontSize={9.5} style={{ fontFamily: MONO }}>\n                <text x={AG.x - A_ZW / 2 + 42} y={call.attach ? A_ZY - 11 : A_ZY - 4} fill={ink}>gmail.send_email</text>\n                <text x={AG.x - A_ZW / 2 + 42} y={call.attach ? A_ZY + 3 : A_ZY + 11} fill={ink} textDecoration={f ? \"line-through\" : undefined}>\n                  to: {call.to}\n                </text>\n                {call.attach && (\n                  <text x={AG.x - A_ZW / 2 + 42} y={A_ZY + 17} fill={ink} textDecoration={f ? \"line-through\" : undefined}>\n                    + {call.attach}\n                  </text>\n                )}\n              </g>\n            </g>\n          )}\n          {scanning && <line x1={AG.x - A_ZW / 2 + 2} y1={scanY} x2={AG.x + A_ZW / 2 - 2} y2={scanY} stroke={c.cyan} strokeOpacity={0.7} />}\n        </g>\n      )}\n      {f && (\n        <text x={AG.x} y={AB + 26} textAnchor=\"middle\" fill={c.red} fontSize={11} letterSpacing={2} style={{ fontFamily: MONO }}>\n          BLOCKED · EXTERNAL DOMAIN + PII\n        </text>\n      )}\n      {ok && (\n        <text x={AG.x} y={AB + 26} textAnchor=\"middle\" fill={c.green} fontSize={11} letterSpacing={2} style={{ fontFamily: MONO }}>\n          ALLOWED · INTERNAL RECIPIENT\n        </text>\n      )}\n    </svg>\n  );\n}\n\n/* ---- 03-05 · one continuous world ---------------------------------------- */\n/* Chapters 3, 4 and 5 are windows onto ONE scene driven by a single world\n   clock `w` (seconds) and one camera, so the chapter cuts are invisible:\n     0-5s   the SealGate agent works the rule (status line, scanned reel,\n            a stack of the context sources it collects), resolves it into\n            policy code, its avatar drops into the gate as the seal and the\n            policy card docks as the gate's POLICY tag\n     5-10s  the camera pushes into Claude and ChatGPT as the team prompts\n     10-17s the camera pulls back and both tool calls run through the gate */\n\nconst POLICY_PROMPT = \"Never let agents email customer data outside the company.\";\nconst CORE = { x: AG.x, y: A_SEAL_Y };\n\ntype Pose = { s: number; x: number; y: number };\nconst P_NET: Pose = { s: 1.25, x: 480, y: 118 };\nconst P_WIDE: Pose = { s: 1, x: BOX_W / 2, y: BOX_H / 2 };\nconst P_CHAT: Pose = { s: 1.3, x: 326, y: 282 };\nconst P_CLAUDE: Pose = { s: 1.85, x: 176, y: 196 };\nconst P_GPT: Pose = { s: 1.85, x: 176, y: 376 };\nconst mix = (a: Pose, b: Pose, t: number): Pose => ({ s: lerp(a.s, b.s, t), x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t) });\n\n/** A whip pan: slow out, very fast middle, slow in. */\nconst whip = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);\n/** Back-out spring for entrances (overshoots then settles). */\nconst spring = (t: number) => {\n  const k = 1.7;\n  const u = t - 1;\n  return 1 + (k + 1) * u * u * u + k * u * u;\n};\n\nfunction camera(w: number): Pose {\n  if (w < 3.4) return P_NET;\n  if (w < 4.6) return mix(P_NET, P_WIDE, ease(seg(w, 3.4, 4.6)));\n  if (w < 5.5) return mix(P_WIDE, P_CLAUDE, ease(seg(w, 4.6, 5.5)));\n  if (w < 6.55) return P_CLAUDE;\n  if (w < 6.95) return mix(P_CLAUDE, P_GPT, whip(seg(w, 6.55, 6.95)));\n  if (w < 8.0) return P_GPT;\n  if (w < 8.7) return mix(P_GPT, P_CHAT, ease(seg(w, 8.0, 8.7)));\n  if (w < 9.4) return P_CHAT;\n  return mix(P_CHAT, P_WIDE, ease(seg(w, 9.4, 10.5)));\n}\n\ntype Rect = { x: number; y: number; w: number; h: number };\nconst mixRect = (a: Rect, b: Rect, t: number): Rect => ({ x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t), w: lerp(a.w, b.w, t), h: lerp(a.h, b.h, t) });\nconst WORK: Rect = { x: 270, y: 66, w: 420, h: 116 };\nconst CARD_TAG: Rect = { x: AL + 1, y: AT + 1, w: AG.size * 0.4, h: AG.size * 0.1 };\nconst ORB = { x: WORK.x + 24, y: WORK.y + 22, size: 20 };\n\n/* ---- the agent at work (after the agent-thinking-chat pattern) ----------- */\n/* Rebuilt on the film clock so it scrubs: a thinking avatar, a shimmering\n   status line, a spinning-reel \"scanned\" counter and a Sonner-style stack of\n   the context sources it pulls in (file reads and MCP tool calls). */\n\ntype Source = { kind: \"file\" | \"mcp\"; label: string; icon?: string; tag?: string };\nconst SOURCES: Source[] = [\n  { kind: \"file\", label: \"policies/data-handling.md\" },\n  { kind: \"mcp\", label: \"gmail · list_labels\", icon: GMAIL },\n  { kind: \"mcp\", label: 'gdrive · search(\"customers\")', icon: uri(tileSvg(byName(\"Google Drive\"))) },\n  { kind: \"file\", label: \"customers.csv\", tag: \"pii\" },\n  { kind: \"mcp\", label: \"slack · list_channels\", icon: uri(tileSvg(byName(\"Slack\"))) },\n  { kind: \"mcp\", label: \"sealgate · list_policies\", icon: LOGOMARK },\n];\nconst SOURCE_AT = (i: number) => 1.0 + i * 0.32;\nconst SCANNED = 214;\n\nfunction statusLine(w: number) {\n  if (w < 1.0) return \"Thinking…\";\n  if (w < 1.9) return \"Collecting context…\";\n  if (w < 2.6) return \"Calling connected tools…\";\n  return \"Writing policy…\";\n}\n\n/** Shimmer without a gradient: a highlight sweeps across the characters. */\nfunction Shimmer({ text, w }: { text: string; w: number }) {\n  const sweep = ((w * 0.9) % 1) * (text.length + 8) - 4;\n  return (\n    <span style={{ fontFamily: MONO, fontSize: 12.5 }}>\n      {[...text].map((ch, i) => {\n        const d = Math.abs(i - sweep);\n        return (\n          <span key={i} style={{ color: d < 3 ? c.white : c.grey, opacity: d < 3 ? 1 : 0.75 }}>\n            {ch}\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n\n/** Spinning-reel counter: each digit rolls continuously and blurs while fast. */\nfunction Reel({ value, speed }: { value: number; speed: number }) {\n  const cell = 18;\n  const digits = String(SCANNED).length;\n  return (\n    <span style={{ display: \"inline-flex\", fontFamily: MONO, fontSize: 15, fontWeight: 500, color: c.cyan, height: cell, overflow: \"hidden\" }}>\n      {Array.from({ length: digits }, (_, k) => {\n        const place = 10 ** (digits - 1 - k);\n        const roll = (value / place) % 10;\n        return (\n          <span key={k} style={{ display: \"inline-block\", width: 11, textAlign: \"center\", position: \"relative\", height: cell }}>\n            <span style={{ position: \"absolute\", left: 0, right: 0, top: 0, transform: `translateY(${-roll * cell}px)`, filter: speed > 0.05 ? `blur(${Math.min(2.5, speed * (place === 1 ? 3 : 1))}px)` : undefined }}>\n              {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map((n, j) => (\n                <span key={j} style={{ display: \"block\", height: cell, lineHeight: `${cell}px` }}>{n}</span>\n              ))}\n            </span>\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n\nfunction SourceRow({ s, count }: { s: Source; count: number }) {\n  return (\n    <div style={{ display: \"flex\", alignItems: \"center\", gap: 10, height: 36, padding: \"0 12px\", border: `1px solid ${c.line}`, background: c.panel }}>\n      {s.kind === \"file\" ? <span style={{ width: 6, height: 6, background: c.cyan }} /> : <img src={s.icon} alt=\"\" width={16} height={16} style={{ borderRadius: 3 }} />}\n      <span style={{ fontFamily: MONO, fontSize: 12, color: c.white }}>{s.label}</span>\n      {s.tag && <span style={{ fontFamily: MONO, fontSize: 10, color: c.black, background: c.cyan, padding: \"1px 6px\" }}>{s.tag}</span>}\n      <span style={{ marginLeft: \"auto\", width: 20, height: 20, border: `1px solid ${c.line2}`, borderRadius: 10, display: \"flex\", alignItems: \"center\", justifyContent: \"center\", fontFamily: MONO, fontSize: 10, color: c.grey }}>{count}</span>\n    </div>\n  );\n}\n\n/** The working card: status + reel header over a stack of collected sources. */\nfunction WorkCard({ w }: { w: number }) {\n  const show = seg(w, 0.35, 0.65) * (1 - seg(w, 2.85, 3.1));\n  if (show <= 0) return null;\n  const e = ease(seg(w, 0.9, 2.7));\n  const speed = w > 0.9 && w < 2.7 ? 6 * e * (1 - e) : 0;\n  return (\n    <div style={{ position: \"absolute\", left: WORK.x, top: WORK.y, width: WORK.w, height: WORK.h, border: `1px solid ${c.cyan}`, background: c.black, opacity: show, zIndex: 1, transform: `translateY(${(1 - seg(w, 0.35, 0.65)) * 10}px)` }}>\n      <div style={{ position: \"absolute\", left: 0, top: 0, width: \"40%\", height: 3, background: c.cyan }} />\n      <div style={{ display: \"flex\", alignItems: \"center\", height: 44, padding: \"0 14px 0 46px\" }}>\n        <Shimmer text={statusLine(w)} w={w} />\n        <span style={{ marginLeft: \"auto\", display: \"flex\", alignItems: \"center\", gap: 8 }}>\n          <Reel value={SCANNED * e} speed={speed} />\n          <Label>scanned</Label>\n        </span>\n      </div>\n      <div style={{ position: \"relative\", margin: \"14px 12px 0\", height: 48 }}>\n        {SOURCES.map((s, i) => {\n          const enter = ease(seg(w, SOURCE_AT(i), SOURCE_AT(i) + 0.2));\n          if (enter <= 0) return null;\n          // continuous depth: how far newer rows have pushed this one back\n          const depth = SOURCES.reduce((d, _, j) => (j > i ? d + ease(seg(w, SOURCE_AT(j), SOURCE_AT(j) + 0.2)) : d), 0);\n          if (depth > 2.5) return null;\n          const y = (1 - enter) * 14 - depth * 7;\n          const sc = 1 - depth * 0.05;\n          const op = enter * clamp01(1 - depth * 0.35);\n          return (\n            <div key={s.label} style={{ position: \"absolute\", left: 0, right: 0, top: 8, transform: `translateY(${y}px) scale(${sc})`, transformOrigin: \"50% 0\", zIndex: 10 - Math.round(depth), opacity: op }}>\n              <SourceRow s={s} count={i + 1} />\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\n/** The agent's avatar: the SealGate mark, orbited by a dashed ring while it\n    works; when the card resolves it flies down to become the gate's seal. */\nfunction AgentOrb({ w }: { w: number }) {\n  if (w < 0.35 || w >= 4.05) return null;\n  const fly = ease(seg(w, 3.1, 4.0));\n  const x = lerp(ORB.x, CORE.x, fly);\n  const y = lerp(ORB.y, CORE.y, fly);\n  const size = lerp(ORB.size, 44, fly);\n  const working = 1 - seg(w, 2.9, 3.2);\n  return (\n    <div style={{ position: \"absolute\", left: x - size / 2, top: y - size / 2, width: size, height: size, opacity: seg(w, 0.35, 0.65), zIndex: 2 }}>\n      <svg width={size + 16} height={size + 16} style={{ position: \"absolute\", left: -8, top: -8, transform: `rotate(${w * 140}deg)`, opacity: working }} fill=\"none\">\n        <circle cx={(size + 16) / 2} cy={(size + 16) / 2} r={size / 2 + 6} stroke={c.cyan} strokeWidth={1.2} strokeDasharray=\"3 5\" />\n      </svg>\n      <img src={LOGOMARK} alt=\"\" width={size} height={size} style={{ display: \"block\", transform: `scale(${1 + 0.06 * Math.sin(w * 9) * working})` }} />\n    </div>\n  );\n}\n\n/** The sent rule, as the user's message above the working card. It is already\n    in place at w=0: the policy chapter flew it here. */\nconst RULE_AT = { x: WORK.x + WORK.w - BUBBLE_W, y: 18 };\nfunction RuleBubble({ w }: { w: number }) {\n  const e = 1 - seg(w, 2.9, 3.3);\n  if (e <= 0) return null;\n  return <Bubble style={{ left: RULE_AT.x, top: RULE_AT.y, opacity: e }} />;\n}\n\n/** The compiled policy: appears where the working card was, then docks as the gate tag. */\nfunction PolicyCard({ w }: { w: number }) {\n  if (w < 2.85 || w >= 4.4) return null;\n  const dock = ease(seg(w, 3.6, 4.4));\n  const r = mixRect({ ...WORK, h: 112 }, CARD_TAG, dock);\n  const code = seg(w, 2.95, 3.15) * (1 - seg(w, 3.6, 3.85));\n  const lines = Math.floor(seg(w, 3.0, 3.45) * 2 + 0.001);\n  return (\n    <div style={{ position: \"absolute\", left: r.x, top: r.y, width: r.w, height: r.h, opacity: seg(w, 2.85, 3.05), border: `1px solid ${c.cyan}`, background: dock > 0.6 ? c.cyan : c.panel, overflow: \"hidden\", zIndex: 3 }}>\n      <div style={{ position: \"absolute\", left: 0, top: 0, width: \"40%\", height: 18, background: c.cyan, fontFamily: MONO, fontSize: 9, fontWeight: 500, letterSpacing: 3, color: c.black, padding: \"4px 8px\", opacity: 1 - dock }}>POLICY</div>\n      <div style={{ position: \"absolute\", left: 12, right: 8, top: 30, fontFamily: MONO, fontSize: 11, lineHeight: \"20px\", opacity: code, whiteSpace: \"pre\" }}>\n        <div style={{ color: c.grey }}>\n          <span style={{ color: c.cyan }}>export default policy</span>([\n        </div>\n        <div style={{ opacity: lines > 0 ? 1 : 0, color: c.grey }}>\n          {\"  \"}<span style={{ color: \"#ff4b5c\" }}>block</span>(<span style={{ color: c.white }}>\"gmail.send_email\"</span>).<span style={{ color: c.cyan }}>to</span>(<span style={{ color: c.cyan }}>notIn</span>(<span style={{ color: c.white }}>org.domains</span>)),\n        </div>\n        <div style={{ opacity: lines > 1 ? 1 : 0, color: c.grey }}>\n          {\"  \"}<span style={{ color: \"#f2b545\" }}>quarantine</span>({\"{ \"}<span style={{ color: c.cyan }}>data: </span><span style={{ color: c.white }}>\"pii\"</span>{\" }\"}),\n        </div>\n        <div style={{ color: c.grey }}>])</div>\n      </div>\n    </div>\n  );\n}\n\n/* ---- the prompting beat: typed in close-up, sent into the thread ------- */\n\nconst WIN = [\n  { x: 40, y: 110, w: 252, h: 164 },\n  { x: 40, y: 290, w: 252, h: 164 },\n];\nconst CAPSULE = [\n  { theme: CLAUDE, text: ASK_CLAUDE, type: [5.35, 6.1], send: 6.15, bg: \"#ffffff\", edge: \"#e5e0d6\", ink: \"#2d2a26\" },\n  { theme: CHATGPT, text: ASK_GPT, type: [7.0, 7.7], send: 7.75, bg: \"#303030\", edge: \"#4a4a4a\", ink: \"#ececec\" },\n];\n\n/** The composer, blown up: the prompt types itself, then the pill flies up\n    into the thread and becomes the user's message. */\nfunction PromptCapsule({ i, w }: { i: number; w: number }) {\n  const cfg = CAPSULE[i];\n  const win = WIN[i];\n  const appear = ease(seg(w, cfg.type[0] - 0.25, cfg.type[0]));\n  const fly = ease(seg(w, cfg.send, cfg.send + 0.3));\n  if (appear <= 0 || fly >= 1) return null;\n  const typed = Math.floor(seg(w, cfg.type[0], cfg.type[1]) * cfg.text.length);\n  const ready = typed >= cfg.text.length;\n  const from = { x: win.x + win.w / 2, y: win.y + win.h - 26 };\n  const to = { x: win.x + win.w - 58, y: win.y + 42 };\n  const x = lerp(from.x, to.x, fly);\n  const y = lerp(from.y, to.y, fly) - (1 - appear) * 6;\n  const sc = lerp(1, 0.55, fly);\n  return (\n    <div style={{ position: \"absolute\", left: x - 118, top: y - 17, width: 236, height: 34, transform: `scale(${sc})`, opacity: appear * (1 - fly * 0.9), zIndex: 6 }}>\n      <div style={{ position: \"absolute\", inset: 0, borderRadius: 17, background: cfg.bg, border: `1px solid ${cfg.edge}`, boxShadow: `0 0 0 ${1 + (1 - fly) * 2}px rgba(195,255,253,${0.5 * (1 - fly)}), 0 10px 24px -12px rgba(0,0,0,0.7)`, display: \"flex\", alignItems: \"center\", gap: 7, padding: \"0 5px 0 10px\" }}>\n        <span style={{ width: 12, height: 12, flex: \"none\", background: cfg.theme.accent === \"#ffffff\" ? cfg.ink : cfg.theme.accent, WebkitMask: `url(\"${cfg.theme.logo}\") center / contain no-repeat`, mask: `url(\"${cfg.theme.logo}\") center / contain no-repeat` }} />\n        <span style={{ flex: 1, minWidth: 0, whiteSpace: \"nowrap\", overflow: \"hidden\", fontFamily: \"system-ui, sans-serif\", fontSize: 9.5, color: cfg.ink, direction: \"rtl\", textAlign: \"left\" }}>\n          <bdi>\n            {cfg.text.slice(0, typed)}\n            {!ready && <span style={{ display: \"inline-block\", width: 1.5, height: 11, background: cfg.ink, marginLeft: 1, verticalAlign: -2, opacity: Math.floor(w * 8) % 2 ? 0 : 1 }} />}\n          </bdi>\n        </span>\n        <span style={{ width: 22, height: 22, flex: \"none\", borderRadius: 11, display: \"flex\", alignItems: \"center\", justifyContent: \"center\", background: ready ? cfg.theme.accent : \"transparent\", border: ready ? \"none\" : `1px solid ${cfg.edge}`, transform: `scale(${w > cfg.send - 0.06 && w < cfg.send + 0.08 ? 0.85 : 1})` }}>\n          <svg width={10} height={10} viewBox=\"0 0 24 24\" fill=\"none\" stroke={ready ? cfg.theme.accentFg : cfg.ink} strokeWidth={3} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\">\n            <path d=\"M12 19V5M5 12l7-7 7 7\" />\n          </svg>\n        </span>\n      </div>\n    </div>\n  );\n}\n\n/** A cyan flash around a window the moment its tool call fires. */\nfunction Flash({ i, at: t0, w }: { i: number; at: number; w: number }) {\n  const u = seg(w, t0, t0 + 0.55);\n  if (u <= 0 || u >= 1) return null;\n  const win = WIN[i];\n  return <div style={{ position: \"absolute\", left: win.x, top: win.y, width: win.w, height: win.h, border: `2px solid ${c.cyan}`, borderRadius: 8, transform: `scale(${1 + u * 0.06})`, opacity: 1 - u, zIndex: 5 }} />;\n}\n\n/** How fast the camera is moving, in screen px per frame-ish, for motion blur. */\nfunction camBlur(w: number) {\n  if (w < 6.55 || w > 6.95) return 0; // only the whip pan smears\n  const a = camera(w);\n  const b = camera(w + 0.01);\n  const px = Math.hypot((b.x - a.x) * a.s, (b.y - a.y) * a.s) + Math.abs(b.s - a.s) * 300;\n  return Math.min(5, Math.max(0, (px - 2.5) * 0.45));\n}\n\nfunction World({ w }: { w: number }) {\n  const cam = camera(w);\n  const call1 = { t: seg(w, 11.0, 13.0), src: 0, blocked: false, to: \"leadership@acme.com\" };\n  const call2 = {\n    t: w < 15.2 ? lerp(0, ST.scanEnd, seg(w, 13.2, 15.2)) : lerp(ST.scanEnd, 0.8, seg(w, 15.2, 17)),\n    src: 1,\n    blocked: true,\n    to: \"me@gmail.com\",\n    attach: \"customers.csv\",\n  };\n  const call: Call | undefined = w < 11 ? undefined : w < 13.1 ? call1 : w >= 13.2 ? call2 : undefined;\n  const done = [w > 13, w > 15.2];\n  const cStage = (w > 6.4 ? 1 : 0) + (w > 8.5 ? 1 : 0) + (done[0] ? 1 : 0);\n  const gStage = (w > 8.0 ? 1 : 0) + (w > 8.8 ? 1 : 0) + (w > 16.3 ? 1 : 0);\n  const enter = [seg(w, 4.6, 5.3), seg(w, 4.75, 5.45)];\n  const winStyle = (i: number): CSSProperties => {\n    const e = enter[i];\n    const sp = e > 0 ? spring(e) : 0;\n    return { position: \"absolute\", left: WIN[i].x, top: WIN[i].y, opacity: clamp01(e * 2.5), transform: `translateX(${((1 - sp) * -90).toFixed(1)}px) scale(${(0.92 + 0.08 * sp).toFixed(3)})`, transformOrigin: \"0 50%\" };\n  };\n  const blur = camBlur(w);\n  const netLabel = seg(w, 0.2, 0.6) * (1 - seg(w, 4.0, 4.5));\n  const chatLabel = seg(w, 5.3, 5.7) * (1 - seg(w, 8.0, 8.3));\n  const gateHead = seg(w, 10.3, 10.9);\n  return (\n    <Scene>\n      <div style={{ position: \"absolute\", left: 0, top: 0, width: BOX_W, height: BOX_H, transformOrigin: \"0 0\", transform: `translate(${(BOX_W / 2 - cam.s * cam.x).toFixed(1)}px, ${(BOX_H / 2 - cam.s * cam.y).toFixed(1)}px) scale(${cam.s.toFixed(3)})`, filter: blur > 0.2 ? `blur(${blur.toFixed(2)}px)` : undefined }}>\n        <div style={winStyle(0)}>\n          <Window theme={CLAUDE} turns={claudeTurns(cStage)} width={420} scale={0.6} body={170} />\n        </div>\n        <div style={winStyle(1)}>\n          <Window theme={CHATGPT} turns={gptTurns(gStage)} width={420} scale={0.6} body={170} />\n        </div>\n        <Flash i={0} at={8.5} w={w} />\n        <Flash i={1} at={8.8} w={w} />\n        <PromptCapsule i={0} w={w} />\n        <PromptCapsule i={1} w={w} />\n        <Airlock\n          call={call}\n          done={done}\n          draw={w < 3.2 ? 0 : seg(w, 3.2, 4.2)}\n          tag={w >= 4.4 ? 1 : 0}\n          wiring={seg(w, 10.0, 10.8)}\n          seal={seg(w, 3.98, 4.05)}\n          lanes={[ease(seg(w, 8.55, 9.5)), ease(seg(w, 8.85, 9.8))]}\n        />\n        <RuleBubble w={w} />\n        <WorkCard w={w} />\n        <PolicyCard w={w} />\n        <AgentOrb w={w} />\n      </div>\n      <div style={{ position: \"absolute\", left: 40, top: 24, opacity: netLabel }}>\n        <div style={{ display: \"flex\", alignItems: \"center\", gap: 10 }}>\n          <Wordmark h={22} />\n        </div>\n      </div>\n      <div style={{ position: \"absolute\", left: 30, top: 170, width: 165, opacity: chatLabel, transform: `translateX(${(1 - chatLabel) * -12}px)` }}>\n        <Label color={c.cyan}>Intent-based prompting</Label>\n        <div style={{ fontFamily: SANS, fontWeight: 500, fontSize: 22, lineHeight: 1.2, color: c.white, marginTop: 10, letterSpacing: -0.4 }}>Your team asks in Claude or ChatGPT.</div>\n      </div>\n      <div style={{ position: \"absolute\", left: 40, top: 26, opacity: gateHead }}>\n        <div style={{ fontFamily: SANS, fontWeight: 500, fontSize: 28, color: c.white, letterSpacing: -0.6 }}>Intent-based access control</div>\n      </div>\n    </Scene>\n  );\n}\n\nconst SceneNetwork = ({ p }: { p: number }) => <World w={p * 5} />;\nconst SceneAsk = ({ p }: { p: number }) => <World w={5 + p * 5} />;\nconst SceneGate = ({ p }: { p: number }) => <World w={10 + p * 7} />;\n\n/* ---- 06 · Integrations ---------------------------------------------------- */\n\n/* A tilted two-ring orbit around SealGate: agents on the outer ring, your data\n   on the inner one, counter-rotating. Icons fly in from off-frame onto their\n   ring, depth comes from the ring tilt (front = larger + brighter), cyan pulses\n   run agent -> SealGate -> data along the spokes, and a spotlight walks through\n   a few marks, lifting each one with its name. */\nconst ORBIT = { x: BOX_W / 2, y: 288 };\nconst RINGS = {\n  agent: { rx: 372, ry: 142, spin: 0.9 },\n  data: { rx: 214, ry: 80, spin: -1.3, phase: -Math.PI / 2 + 0.3 },\n};\nconst SPOTLIGHT = [\"Claude Desktop\", \"ChatGPT\", \"Gmail\", \"Codex CLI\", \"Cursor\", \"Grokbot\", \"Muse\"];\n/** Agents held on the front arc of the ring for the whole spin, in order of\n    increasing angle (right to left along the bottom of the ellipse). */\nconst FRONT = [\"Cursor\", \"Codex CLI\", \"Claude Desktop\", \"ChatGPT\", \"Grokbot\", \"Muse\"];\nconst FRONT_STEP = 0.28;\n\n/** Agent-ring angle at p=0: FRONT spaced on an arc that is centred on the front\n    (angle pi/2) at mid-scene, the rest spread evenly over the back. */\nfunction agentAngle(name: string, rest: string[]) {\n  const mid = Math.PI / 2 - RINGS.agent.spin / 2;\n  const f = FRONT.indexOf(name);\n  if (f >= 0) return mid + (f - (FRONT.length - 1) / 2) * FRONT_STEP;\n  const half = ((FRONT.length - 1) / 2) * FRONT_STEP + FRONT_STEP;\n  const span = Math.PI * 2 - half * 2;\n  return mid + half + ((rest.indexOf(name) + 0.5) / rest.length) * span;\n}\n\nfunction SceneIntegrations({ p }: { p: number }) {\n  const agents = INTEGRATIONS.filter((i) => i.group === \"agent\");\n  const data = INTEGRATIONS.filter((i) => i.group === \"data\");\n  const spotIdx = p > 0.36 ? Math.floor((p - 0.36) / 0.09) : -1;\n  const spot = spotIdx >= 0 && spotIdx < SPOTLIGHT.length ? SPOTLIGHT[spotIdx] : \"\";\n  const spotE = spot ? Math.sin(clamp01(((p - 0.36) % 0.09) / 0.09) * Math.PI) : 0;\n\n  const rest = agents.filter((it) => !FRONT.includes(it.name)).map((it) => it.name);\n  const items = [\n    ...agents.map((it, i) => ({ it, ring: RINGS.agent, a0: agentAngle(it.name, rest), k: i, dir: -1 })),\n    ...data.map((it, i) => ({ it, ring: RINGS.data, a0: RINGS.data.phase + (i / data.length) * Math.PI * 2, k: agents.length + i, dir: 1 })),\n  ].map(({ it, ring, a0, k, dir }) => {\n    const a = a0 + p * ring.spin;\n    const e = at(p, 0.02 + k * 0.012, 0.18);\n    const depth = (Math.sin(a) + 1) / 2; // 0 back .. 1 front\n    const far = 2.3 - e * 1.3; // fly in from outside the ring\n    const x = ORBIT.x + Math.cos(a) * ring.rx * far;\n    const y = ORBIT.y + Math.sin(a) * ring.ry * far;\n    const lift = it.name === spot ? spotE : 0;\n    return { it, x, y, e, depth, lift, dir, k, size: 30 + depth * 14 + lift * 16 };\n  });\n  const flow = seg(p, 0.3, 0.42);\n\n  return (\n    <Scene>\n      <div style={{ position: \"absolute\", left: 60, top: 28, right: 60, display: \"flex\", alignItems: \"flex-end\", zIndex: 5 }}>\n        <Heading p={p} title=\"Any agent, Any integration\" />\n      </div>\n      <svg width={BOX_W} height={BOX_H} style={{ position: \"absolute\", left: 0, top: 0 }} fill=\"none\">\n        {([\"agent\", \"data\"] as const).map((r) => (\n          <ellipse key={r} cx={ORBIT.x} cy={ORBIT.y} rx={RINGS[r].rx} ry={RINGS[r].ry} stroke={c.line} strokeDasharray=\"2 6\" opacity={seg(p, 0.05, 0.3)} />\n        ))}\n        {items.map((d) => {\n          // pulses: agents stream in to SealGate, SealGate streams out to data\n          const u = (p * 2.4 + d.k * 0.29) % 1;\n          const from = d.dir < 0 ? { x: d.x, y: d.y } : ORBIT;\n          const to = d.dir < 0 ? ORBIT : { x: d.x, y: d.y };\n          return (\n            <g key={d.it.name} opacity={d.e}>\n              <line x1={ORBIT.x} y1={ORBIT.y} x2={d.x} y2={d.y} stroke={c.cyan} strokeOpacity={0.08 + d.depth * 0.12 + d.lift * 0.5} strokeWidth={1} />\n              {flow > 0 && <rect x={lerp(from.x, to.x, u) - 2} y={lerp(from.y, to.y, u) - 2} width={4} height={4} fill={c.cyan} opacity={flow * (0.35 + d.depth * 0.65) * Math.sin(u * Math.PI)} />}\n            </g>\n          );\n        })}\n      </svg>\n      {/* SealGate at the hub */}\n      <div style={{ position: \"absolute\", left: ORBIT.x - 36, top: ORBIT.y - 36, width: 72, height: 72, display: \"flex\", alignItems: \"center\", justifyContent: \"center\", zIndex: 2, opacity: at(p, 0, 0.15) }}>\n        <div style={{ position: \"absolute\", inset: 0, border: `1.5px solid ${c.cyan}`, transform: `scale(${1 + ((p * 3) % 1) * 0.8})`, opacity: flow * (1 - ((p * 3) % 1)) }} />\n        <img src={LOGOMARK} alt=\"SealGate\" width={56} height={56} style={{ display: \"block\" }} />\n      </div>\n      {[...items]\n        .sort((a, b) => a.depth + a.lift - (b.depth + b.lift))\n        .map((d) => (\n          <div\n            key={d.it.name}\n            style={{\n              position: \"absolute\",\n              left: d.x - d.size / 2,\n              top: d.y - d.size / 2,\n              zIndex: d.depth > 0.5 || d.lift > 0 ? 3 : 1,\n              opacity: d.e * (0.45 + d.depth * 0.55 + d.lift * 0.45),\n              filter: d.lift > 0.2 ? undefined : `brightness(${0.7 + d.depth * 0.3})`,\n            }}\n          >\n            <Tile it={d.it} size={d.size} />\n            {d.lift > 0.05 && (\n              <div style={{ position: \"absolute\", left: \"50%\", top: d.size + 6, transform: \"translateX(-50%)\", whiteSpace: \"nowrap\", fontFamily: MONO, fontSize: 11, color: c.cyan, opacity: d.lift }}>\n                {d.it.name}\n              </div>\n            )}\n          </div>\n        ))}\n    </Scene>\n  );\n}\n\n/** SealGate's Chrome extension: a Chrome window strip with the SealGate icon\n    pinned in the toolbar and its popover open over a ChatGPT tab. */\nfunction ChromeExtension({ popover }: { popover: number }) {\n  const ui = { tabs: \"#1b1c1f\", bar: \"#35363a\", omni: \"#202124\", text: \"#e8eaed\", sub: \"#9aa0a6\", edge: \"#3c4043\" };\n  return (\n    <div style={{ width: 186 }}>\n      <div style={{ display: \"flex\", alignItems: \"center\", gap: 8, marginBottom: 10 }}>\n        <img src={CHROME} alt=\"Chrome\" width={20} height={20} />\n        <Label>Chrome extension</Label>\n      </div>\n      <div style={{ border: `1px solid ${ui.edge}`, borderRadius: 10, overflow: \"hidden\", background: ui.omni, boxShadow: \"0 18px 40px -18px rgba(0,0,0,0.8)\" }}>\n        <div style={{ height: 28, background: ui.tabs, display: \"flex\", alignItems: \"flex-end\", padding: \"0 8px\" }}>\n          <div style={{ display: \"flex\", alignItems: \"center\", gap: 6, background: ui.bar, borderRadius: \"7px 7px 0 0\", padding: \"5px 10px\", fontFamily: \"system-ui, sans-serif\", fontSize: 10.5, color: ui.text }}>\n            <img src={CHATGPT_TILE} alt=\"\" width={12} height={12} style={{ borderRadius: 3 }} />\n            ChatGPT\n          </div>\n        </div>\n        <div style={{ height: 36, background: ui.bar, display: \"flex\", alignItems: \"center\", gap: 8, padding: \"0 8px\" }}>\n          <div style={{ flex: 1, height: 24, borderRadius: 12, background: ui.omni, display: \"flex\", alignItems: \"center\", padding: \"0 10px\", fontFamily: \"system-ui, sans-serif\", fontSize: 10.5, color: ui.sub }}>chatgpt.com</div>\n          <div style={{ width: 26, height: 26, borderRadius: 13, display: \"flex\", alignItems: \"center\", justifyContent: \"center\", background: `rgba(195,255,253,${0.12 + popover * 0.1})`, boxShadow: `0 0 0 1.5px rgba(195,255,253,${popover})` }}>\n            <img src={LOGOMARK} alt=\"SealGate\" width={16} height={16} />\n          </div>\n        </div>\n        <div style={{ height: popover * 66, overflow: \"hidden\" }}>\n          <div style={{ padding: \"12px 12px\", display: \"flex\", alignItems: \"center\", gap: 10, borderTop: `1px solid ${ui.edge}` }}>\n            <img src={LOGOMARK} alt=\"\" width={24} height={24} />\n            <div>\n              <Wordmark h={11} style={{ display: \"block\" }} />\n              <div style={{ fontFamily: MONO, fontSize: 10, color: c.cyan, marginTop: 6 }}>protecting · 2 rules</div>\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\n/* ---- 07 · Any device ------------------------------------------------------ */\n\nfunction SceneDevices({ p }: { p: number }) {\n  const phone = at(p, 0.05, 0.18);\n  const mac = at(p, 0.15, 0.18);\n  const phoneTurns = [\n    { role: \"user\" as const, text: ASK_GPT },\n    ...(p > 0.45\n      ? [{ role: \"assistant\" as const, text: \"SealGate blocked this. Sending customer data outside the company isn't allowed by your organisation's policy.\" }]\n      : [{ role: \"assistant\" as const, text: \"\", typing: true }]),\n  ];\n  const macTurns = [\n    { role: \"user\" as const, text: ASK_CLAUDE },\n    ...(p > 0.6\n      ? [{ role: \"assistant\" as const, text: \"Sent to **leadership@acme.com**. SealGate checked the email against company policy and logged the action to your account.\" }]\n      : [{ role: \"assistant\" as const, text: \"\", typing: true }]),\n  ];\n  return (\n    <Scene>\n      <div style={{ position: \"absolute\", left: 0, right: 0, top: 28 }}>\n        <Heading p={p} label=\"Secure AI on any device\" title=\"Secure agents on any device, anywhere.\" center />\n      </div>\n      <Rise e={phone} y={24} style={{ position: \"absolute\", left: 36, top: 102 }}>\n        <IPhoneMock width={166} titanium=\"black\">\n          <ChatGPTScreen scheme=\"dark\" conversation={phoneTurns} />\n        </IPhoneMock>\n      </Rise>\n      <Rise e={mac} y={24} style={{ position: \"absolute\", left: 218, top: 150 }}>\n        <MacBookMock width={440} chassis=\"spaceblack\">\n          <ClaudeScreen scheme=\"dark\" conversation={macTurns} />\n        </MacBookMock>\n      </Rise>\n      <Rise e={at(p, 0.3, 0.18)} y={24} style={{ position: \"absolute\", left: 670, top: 186, zIndex: 2 }}>\n        <ChromeExtension popover={at(p, 0.48, 0.14)} />\n      </Rise>\n    </Scene>\n  );\n}\n\n/* ---- 08 · Start free ------------------------------------------------------ */\n\nfunction SceneEnd({ p }: { p: number }) {\n  return (\n    <Scene>\n      <div style={{ position: \"absolute\", left: 0, right: 0, top: 104, display: \"flex\", flexDirection: \"column\", alignItems: \"center\" }}>\n        <Rise e={at(p, 0, 0.2)}>\n          <img src={WORDMARK} alt=\"SealGate\" style={{ height: 52, display: \"block\" }} />\n        </Rise>\n        <Rise e={at(p, 0.15, 0.2)}>\n          <div style={{ fontFamily: SANS, fontSize: 32, fontWeight: 500, color: c.white, letterSpacing: -0.8, marginTop: 40 }}>\n            Secure your agents without slowing them down.\n          </div>\n        </Rise>\n        <Rise e={at(p, 0.3, 0.2)} style={{ display: \"flex\", justifyContent: \"center\", gap: 12, marginTop: 34 }}>\n          <div style={{ background: c.cyan, color: c.black, fontFamily: SANS, fontSize: 15, fontWeight: 500, padding: \"12px 22px\", borderRadius: 2 }}>Start free</div>\n          <div style={{ border: `1px solid ${c.line2}`, color: c.white, fontFamily: SANS, fontWeight: 500, fontSize: 15, padding: \"12px 22px\", borderRadius: 2 }}>Book a demo</div>\n        </Rise>\n        <Rise e={at(p, 0.42, 0.2)}>\n          <div style={{ marginTop: 26 }}>\n            <Label>sealgate.ai</Label>\n          </div>\n        </Rise>\n      </div>\n    </Scene>\n  );\n}\n\n/* ---- the film engine ------------------------------------------------------ */\n\nexport interface Chapter {\n  id: string;\n  label: ReactNode;\n  duration: number;\n  Scene: (props: { p: number }) => ReactNode;\n}\n\nexport const SEALGATE_CHAPTERS: Chapter[] = [\n  { id: \"hook\", label: <Wordmark h={11} />, duration: 3, Scene: SceneHook },\n  { id: \"policy\", label: \"Write a policy\", duration: 5, Scene: ScenePolicy },\n  { id: \"agent\", label: \"Agent at work\", duration: 5, Scene: SceneNetwork },\n  { id: \"intent\", label: \"Ask in any AI\", duration: 5, Scene: SceneAsk },\n  { id: \"airlock\", label: \"Airlock\", duration: 7, Scene: SceneGate },\n  { id: \"integrations\", label: \"Integrations\", duration: 5, Scene: SceneIntegrations },\n  { id: \"devices\", label: \"Any device\", duration: 5, Scene: SceneDevices },\n  { id: \"end\", label: \"Start\", duration: 3, Scene: SceneEnd },\n]; // 38s\n\nconst FADE = 0.35; // seconds of true cross-fade between chapters\n\nexport default function SealGateFilm({ width = STAGE_W, chapters = SEALGATE_CHAPTERS, autoPlay = true, loop = true }: { width?: number; chapters?: Chapter[]; autoPlay?: boolean; loop?: boolean }) {\n  const total = chapters.reduce((s, ch) => s + ch.duration, 0);\n  const scale = width / CANVAS_W;\n  const [time, setTime] = useState(0);\n  const [playing, setPlaying] = useState(autoPlay);\n  const last = useRef<number>();\n  const trackRef = useRef<HTMLDivElement>(null);\n  const dragging = useRef(false);\n\n  useEffect(() => {\n    if (!playing) return;\n    let raf = 0;\n    const tick = (now: number) => {\n      if (last.current != null) {\n        const dt = (now - last.current) / 1000;\n        setTime((prev) => {\n          const next = prev + dt;\n          if (next < total) return next;\n          if (loop) return next % total;\n          setPlaying(false);\n          return total;\n        });\n      }\n      last.current = now;\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => {\n      cancelAnimationFrame(raf);\n      last.current = undefined;\n    };\n  }, [playing, total, loop]);\n\n  const starts: number[] = [];\n  chapters.reduce((acc, ch) => (starts.push(acc), acc + ch.duration), 0);\n  let active = chapters.length - 1;\n  for (let i = 0; i < chapters.length; i++) if (time < starts[i] + chapters[i].duration) { active = i; break; }\n  const local = time - starts[active];\n  const progress = clamp01(local / chapters[active].duration);\n  const prev = active > 0 && local < FADE ? active - 1 : -1;\n  const fadeIn = prev >= 0 ? local / FADE : 1;\n  const playhead = clamp01(time / total);\n\n  const seek = useCallback((clientX: number) => {\n    const el = trackRef.current;\n    if (!el) return;\n    const r = el.getBoundingClientRect();\n    setTime(clamp01((clientX - r.left) / r.width) * total * 0.9999);\n  }, [total]);\n\n  const ActiveScene = chapters[active].Scene;\n  const PrevScene = prev >= 0 ? chapters[prev].Scene : null;\n\n  return (\n    <div style={{ width: CANVAS_W * scale, height: CANVAS_H * scale, fontFamily: SANS }}>\n      <style>{FONTS}</style>\n      <div style={{ width: CANVAS_W, height: CANVAS_H, transform: `scale(${scale})`, transformOrigin: \"top left\", position: \"relative\" }}>\n        <div style={{ position: \"absolute\", left: 0, top: 0, width: STAGE_W, height: STAGE_H, background: c.black, border: `1px solid ${c.line}`, overflow: \"hidden\" }}>\n          <div style={{ position: \"absolute\", inset: INSET - 1 }}>\n            {PrevScene && (\n              <div style={{ position: \"absolute\", inset: 0 }}>\n                <PrevScene p={1} />\n              </div>\n            )}\n            <div style={{ position: \"absolute\", inset: 0, opacity: fadeIn }}>\n              <ActiveScene p={progress} />\n            </div>\n          </div>\n          <button\n            type=\"button\"\n            aria-label={playing ? \"Pause\" : \"Play\"}\n            onClick={() => {\n              if (!loop && time >= total) setTime(0);\n              setPlaying((v) => !v);\n            }}\n            style={{ position: \"absolute\", left: INSET + 12, bottom: INSET + 12, width: 34, height: 34, border: `1px solid ${c.line2}`, background: c.black, color: c.cyan, cursor: \"pointer\", fontSize: 12, borderRadius: 2 }}\n          >\n            {playing ? \"❚❚\" : \"▶\"}\n          </button>\n        </div>\n\n        <div style={{ position: \"absolute\", left: 0, top: STAGE_H + GAP, width: STAGE_W, height: TRACK_H }}>\n          <div style={{ position: \"relative\", height: 26 }}>\n            <div style={{ position: \"absolute\", top: 12, left: 0, right: 0, height: 1, background: c.line2 }} />\n            <div style={{ position: \"absolute\", top: 11, left: 0, width: `${playhead * 100}%`, height: 3, background: c.cyan }} />\n            <div style={{ position: \"absolute\", top: 0, left: `${playhead * 100}%`, transform: \"translateX(-50%)\", display: \"flex\", flexDirection: \"column\", alignItems: \"center\" }}>\n              <div style={{ fontFamily: MONO, fontSize: 11, color: c.black, background: c.cyan, padding: \"3px 7px\", lineHeight: 1 }}>{fmt(time)}</div>\n              <div style={{ width: 2, height: 10, background: c.cyan }} />\n            </div>\n          </div>\n          <div\n            ref={trackRef}\n            role=\"slider\"\n            tabIndex={0}\n            aria-label=\"Film position\"\n            aria-valuemin={0}\n            aria-valuemax={total}\n            aria-valuenow={Math.round(time)}\n            onKeyDown={(e) => {\n              if (e.key === \"ArrowRight\") setTime((t) => Math.min(total * 0.9999, t + 1));\n              if (e.key === \"ArrowLeft\") setTime((t) => Math.max(0, t - 1));\n              if (e.key === \" \") { e.preventDefault(); setPlaying((v) => !v); }\n            }}\n            onPointerDown={(e) => { dragging.current = true; (e.target as Element).setPointerCapture?.(e.pointerId); seek(e.clientX); }}\n            onPointerMove={(e) => dragging.current && seek(e.clientX)}\n            onPointerUp={() => { dragging.current = false; }}\n            style={{ display: \"flex\", gap: 6, marginTop: 6, height: TRACK_H - 32, cursor: \"pointer\", touchAction: \"none\", outline: \"none\" }}\n          >\n            {chapters.map((ch, i) => (\n              <div\n                key={ch.id}\n                style={{\n                  flex: ch.duration,\n                  position: \"relative\",\n                  border: `1px solid ${i === active ? c.cyan : c.line}`,\n                  background: c.black,\n                  display: \"flex\",\n                  alignItems: \"center\",\n                  justifyContent: \"center\",\n                }}\n              >\n                {i === active && <div style={{ position: \"absolute\", left: -1, top: -1, width: \"40%\", height: 6, background: c.cyan }} />}\n                <span style={{ fontFamily: MONO, fontSize: 12, letterSpacing: 0.5, color: i === active ? c.white : c.grey }}>{ch.label}</span>\n              </div>\n            ))}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}"
    },
    {
      "slug": "chat-shell",
      "title": "Themeable chat-client shell",
      "category": "Marketing",
      "summary": "One chat mock that reskins to look like Claude, ChatGPT, Goose, or VS Code — canvas, chrome, bubbles, accent, avatar and serif-vs-sans all swap from a single theme token set.",
      "problem": "A landing page that shows a product living “inside your chat client” needs the mock to read as that client — but maintaining four separate chat UIs is four times the markup. This shell carries the whole client identity in a small token set (spread onto the root as CSS custom properties); every element reads the tokens via var(), so switching client is a one-object swap with no re-render and no reflow.",
      "whenToUse": [
        "You demo something embedded in Claude / ChatGPT / Copilot and want the frame to look native to each.",
        "A client toggle should reskin the mock instantly, at first paint, with no flash of the wrong theme.",
        "You want conversation content (bubbles, assistant lines, tool-call chips) decoupled from the client skin."
      ],
      "source": {
        "name": "daysurface.com",
        "url": "https://daysurface.com"
      },
      "tags": [
        "chat",
        "mock",
        "hero",
        "theme",
        "tokens",
        "css-variables",
        "claude",
        "chatgpt",
        "marketing"
      ],
      "dependencies": [
        "react",
        "tailwindcss"
      ],
      "status": "stable",
      "demoUrl": "https://eito.me/ui/chat-shell",
      "markdownUrl": "https://eito.me/ui/patterns/chat-shell.md",
      "code": "import type { CSSProperties, ReactNode } from \"react\";\n\n/**\n * ChatShell — a themeable chat-client mock, ported from the daysurface.com\n * landing page hero (its four-client chat toggle).\n *\n * The idea worth copying: ONE chat component that reskins to look like Claude,\n * ChatGPT, Goose, or VS Code. The entire client identity — canvas, chrome,\n * bubble colour, accent, avatar mark, serif-vs-sans, name, composer\n * placeholder — is carried in a small `ChatTheme` token set. On the real site\n * those tokens are CSS custom properties keyed off `<html data-client>`, so\n * selecting a client anywhere on the page re-skins every chat frame in CSS at\n * first paint, with no JS in the swap.\n *\n * This snippet keeps that shape: the theme is spread onto the shell root as\n * `--chat-*` custom properties, and every nested element reads them via\n * `var(--chat-*)`. Swap the theme object and the whole shell changes identity.\n *\n * A conversation is a list of turns (`user` bubble · `reason` summary line ·\n * `assistant` line · `tool` call chip), so the same shell renders any script.\n * The assistant line uses the theme's `sayFont` (serif for Claude, sans\n * elsewhere) — the small tell that makes each client read as itself.\n *\n * Deps: react + tailwindcss only. Each client's real brand SVG is supplied as a\n * URL (theme.logo) and painted via CSS `mask`, so it tints to the accent and\n * two instances never collide on the ids inside a multi-path logo (VS Code).\n */\n\n/* ---- theme model ----------------------------------------------------------- */\n\n/**\n * How a client draws a completed tool call. Each real client has its own idiom,\n * so this is part of the theme, not a shared chip:\n *   - timeline (Claude) muted activity row — the client mark (or the tool's own\n *                    app icon) + a sans muted label (\"Verifying available\n *                    tools and resources\", \"Gmail status\"), no border\n *   - task (ChatGPT) borderless activity title (\"Curating Inbox…\") + spinner icon,\n *                    sans muted — the human-readable task, not the raw tool id\n *   - pill           bordered mono pill, accent glyph, trailing ✓\n *   - bare           borderless muted \"Used <name>\" line, no status mark\n *   - card (Goose)   bordered card: filled-accent check disc · name · chevron\n *   - row  (VS Code) bordered row, \"Ran <name>\" (name in a link colour) · chevron\n */\nexport interface ToolCallStyle {\n  variant: \"timeline\" | \"task\" | \"pill\" | \"bare\" | \"card\" | \"row\";\n  /** Verb prefix before the tool name, e.g. \"Used\" / \"Ran\". */\n  verb?: string;\n  /** Trailing status mark: an inline ✓ (Claude) or a filled disc (Goose). */\n  check?: \"inline\" | \"disc\";\n  /** Trailing disclosure chevron (Goose, VS Code). */\n  chevron?: boolean;\n  /** Override the tool-name colour — VS Code's link blue, distinct from accent. */\n  nameColor?: string;\n}\n\nexport interface ChatTheme {\n  /** Client id, also the React key when mapping presets. */\n  id: string;\n  /** Display name in the header pill. */\n  name: string;\n  /** Page/message canvas. */\n  canvas: string;\n  /** Header + composer chrome surface. */\n  chrome: string;\n  /** Hairline borders. */\n  border: string;\n  /** Primary text. */\n  text: string;\n  /** Muted text (placeholder, secondary). */\n  muted: string;\n  /** Brand accent — avatar tint, send button, tool-chip icon. */\n  accent: string;\n  /** Foreground drawn ON the accent (send-button arrow). */\n  accentFg: string;\n  /** User bubble fill. */\n  bubble: string;\n  /** User bubble text (defaults to `text`). */\n  bubbleText?: string;\n  /** Assistant copy font — a serif here is the Claude tell. */\n  sayFont: string;\n  /** Composer placeholder line. */\n  placeholder: string;\n  /**\n   * URL of the client's brand SVG (e.g. the real Claude / OpenAI / Goose /\n   * VS Code mark). Rendered via CSS `mask`, so it's tinted to a single colour\n   * (the accent) and never collides on ids the way inline multi-path logos do.\n   */\n  logo: string;\n  /** Whether assistant turns show the avatar mark. ChatGPT/Goose render none. */\n  assistantAvatar: boolean;\n  /** How this client renders a tool call — each has its own idiom. */\n  toolCall: ToolCallStyle;\n  /**\n   * Phrasing this client puts before a reasoning duration, e.g. ChatGPT's\n   * \"Worked for\" (→ \"Worked for 14s\") or Claude's \"Thought for\". Absent means\n   * this client shows no reasoning summary, and `reason` turns are dropped for\n   * it — the same one-conversation-many-idioms trick `label` uses on tool calls.\n   */\n  reasonVerb?: string;\n}\n\n/**\n * The four presets reproduced from the DaySurface hero. Their `logo` paths are\n * root-relative to THIS site's `/icons` library, so copied verbatim into another\n * app the masked marks resolve against that app's root and render blank. When\n * reusing these presets elsewhere, point `logo` at your own asset URLs (the demo\n * prefixes `import.meta.env.BASE_URL` to resolve them under any base path).\n */\nexport const chatThemes: Record<string, ChatTheme> = {\n  claude: {\n    id: \"claude\",\n    name: \"Claude\",\n    canvas: \"#f5f1e8\",\n    chrome: \"#faf8f2\",\n    border: \"#e5e0d6\",\n    text: \"#2d2a26\",\n    muted: \"#9a9286\",\n    accent: \"#c96442\",\n    accentFg: \"#ffffff\",\n    bubble: \"#e7e1d4\",\n    sayFont: \"Georgia, 'Times New Roman', serif\",\n    placeholder: \"Reply to Claude…\",\n    logo: \"/icons/agents/claude.svg\",\n    assistantAvatar: true,\n    toolCall: { variant: \"timeline\" },\n  },\n  chatgpt: {\n    id: \"chatgpt\",\n    name: \"ChatGPT\",\n    canvas: \"#212121\",\n    chrome: \"#303030\",\n    border: \"#3a3a3a\",\n    text: \"#ececec\",\n    muted: \"#b4b4b4\",\n    accent: \"#ffffff\", // current ChatGPT mark is near-white, not the legacy teal\n    accentFg: \"#0d0d0d\",\n    bubble: \"#2f4a7a\", // current ChatGPT user bubble is blue\n    bubbleText: \"#ffffff\",\n    sayFont: \"system-ui, sans-serif\",\n    placeholder: \"Ask ChatGPT\",\n    logo: \"/icons/agents/chatgpt.svg\",\n    assistantAvatar: false, // ChatGPT replies are plain full-width text, no avatar\n    toolCall: { variant: \"task\", chevron: true },\n    reasonVerb: \"Worked for\", // collapsed \"Worked for 14s ⌄\" reasoning summary\n  },\n  goose: {\n    id: \"goose\",\n    name: \"goose\",\n    canvas: \"#22252a\",\n    chrome: \"#3f434b\",\n    border: \"#474e57\",\n    text: \"#e3e6ea\",\n    muted: \"#878787\",\n    accent: \"#13bbaf\",\n    accentFg: \"#10221f\",\n    bubble: \"#ffffff\",\n    bubbleText: \"#22252a\",\n    sayFont: \"system-ui, sans-serif\",\n    placeholder: \"Ask goose anything…\",\n    logo: \"/icons/agents/goose.svg\",\n    assistantAvatar: false, // Goose replies are plain text + timestamp, no avatar\n    toolCall: { variant: \"card\", check: \"disc\", chevron: true },\n  },\n  vscode: {\n    id: \"vscode\",\n    name: \"VS Code\",\n    canvas: \"#1f1f1f\",\n    chrome: \"#181818\",\n    border: \"#3c3c3c\",\n    text: \"#cccccc\",\n    muted: \"#858585\",\n    accent: \"#0078d4\",\n    accentFg: \"#ffffff\",\n    bubble: \"#2b2b2b\",\n    sayFont: \"system-ui, sans-serif\",\n    placeholder: \"Ask Copilot\",\n    logo: \"/icons/agents/vscode.svg\",\n    assistantAvatar: true,\n    toolCall: { variant: \"row\", verb: \"Ran\", chevron: true, nameColor: \"#4daafc\" },\n  },\n};\n\n/* ---- conversation model ---------------------------------------------------- */\n\nexport type Turn =\n  | { role: \"user\"; text: string }\n  | { role: \"assistant\"; text: string }\n  // A client's collapsed reasoning summary (\"Worked for 14s\"). `seconds` is the\n  // duration; the verb (\"Worked for\") is the client's, from `theme.reasonVerb`,\n  // so one turn reads natively per client (and vanishes where there's no verb).\n  | { role: \"reason\"; seconds: number }\n  // `name` is the raw tool id (shown by Goose/VS Code); `label` is the human\n  // activity title Claude/ChatGPT show instead (\"Curating Inbox…\"). `icon` is an\n  // optional full-colour app-icon URL the Claude timeline uses in place of the\n  // client mark (e.g. a Gmail mark for a Gmail tool).\n  | { role: \"tool\"; name: string; label?: string; icon?: string; ok?: boolean };\n\ntype ToolTurn = Extract<Turn, { role: \"tool\" }>;\n\nexport interface ChatShellProps {\n  theme: ChatTheme;\n  conversation: Turn[];\n  /** Fixed body height so a client swap never reflows the surrounding layout. */\n  bodyHeight?: number;\n  className?: string;\n}\n\n/* ---- marks ----------------------------------------------------------------- */\n\n/**\n * The client's real brand mark — the SVG at `logo`, painted through a CSS mask\n * so it renders as a crisp single-colour silhouette in `color` (default: the\n * accent). Masking (vs. an <img>) lets currentColor logos show on dark canvases\n * and avoids id clashes between two instances of a multi-path logo.\n */\nfunction Mark({ logo, size = 20, color = \"var(--chat-accent)\" }: { logo: string; size?: number; color?: string }) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"inline-block shrink-0\"\n      style={{\n        width: size,\n        height: size,\n        background: color,\n        WebkitMask: `url(\"${logo}\") center / contain no-repeat`,\n        mask: `url(\"${logo}\") center / contain no-repeat`,\n      }}\n    />\n  );\n}\n\n/* ---- tool calls ------------------------------------------------------------ */\n\n/** The \"run / return\" glyph every client puts before a tool name. */\nconst RunGlyph = ({ color }: { color: string }) => (\n  <svg\n    className=\"size-3 shrink-0\"\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    stroke={color}\n    strokeWidth={2}\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n    aria-hidden=\"true\"\n  >\n    <path d=\"M7 8l4 4-4 4m6 0h6\" />\n  </svg>\n);\n\n/** Trailing disclosure chevron (Goose / VS Code). */\nconst DiscloseChevron = () => (\n  <svg\n    className=\"size-3.5 rotate-90 shrink-0\"\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    stroke=\"var(--chat-muted)\"\n    strokeWidth={2}\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n    aria-hidden=\"true\"\n  >\n    <path d=\"M9 6l6 6-6 6\" />\n  </svg>\n);\n\n/** Down caret (collapse/expand affordance) — header model pill, ChatGPT task chip and reasoning line. Inherits colour via currentColor. */\nconst CaretDown = ({ className = \"size-3.5 shrink-0\" }: { className?: string }) => (\n  <svg\n    className={className}\n    viewBox=\"0 0 24 24\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeWidth={2}\n    strokeLinecap=\"round\"\n    strokeLinejoin=\"round\"\n    aria-hidden=\"true\"\n  >\n    <path d=\"M6 9l6 6 6-6\" />\n  </svg>\n);\n\n/**\n * A completed tool call, drawn in the current client's idiom (theme.toolCall).\n * This is the part that must NOT be uniform — each client renders it differently.\n */\nfunction ToolCall({ turn, style }: { turn: ToolTurn; style: ToolCallStyle }) {\n  const label = style.verb ? `${style.verb} ${turn.name}` : turn.name;\n\n  // The Claude \"timeline\" variant is drawn as connected nodes by the shell\n  // (TimelineNode / Row), not here — it needs cross-row rail state.\n\n  if (style.variant === \"task\") {\n    // ChatGPT: borderless activity title (\"Curating Inbox…\" / \"Called tool\") in\n    // sans muted, an activity glyph before it and a disclosure chevron after.\n    return (\n      <div className=\"inline-flex items-center gap-2 text-sm\" style={{ color: \"var(--chat-muted)\" }}>\n        {/* ChatGPT's tool-invocation \"plug\" glyph */}\n        <svg\n          className=\"size-4 shrink-0\"\n          viewBox=\"32 40 96 100\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={8}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          aria-hidden=\"true\"\n        >\n          <path d=\"M 98 106 C 109.32 104.74, 120.65 103.48, 125 97 C 130.16 89.32, 125.52 74.29, 118 64 C 110.51 53.74, 100.17 48.19, 89 47 C 77.64 45.79, 65.42 49.10, 56 56 C 46.84 62.72, 40.33 72.83, 38 84 C 35.65 95.25, 37.54 107.56, 44 117 C 50.73 126.84, 62.43 133.56, 74 135 C 85.42 136.42, 96.71 132.71, 108 129\" />\n          <path d=\"M 65 92 C 64.40 94.05, 63.79 96.10, 64 98 C 64.39 101.57, 67.62 104.63, 71 107 C 74.19 109.24, 77.52 110.87, 81 111 C 83.98 111.11, 87.08 110.13, 90 109 C 92.41 108.07, 94.71 107.03, 97 106\" />\n          <path d=\"M 98 105 C 99.20 102.06, 100.41 99.12, 101 96 C 101.50 93.37, 101.56 90.60, 101 88 C 100.37 85.09, 98.96 82.39, 97 80 C 95.11 77.70, 92.70 75.70, 90 75 C 87.80 74.43, 85.40 74.71, 83 75\" />\n          <path d=\"M 65 92 L 83 75\" />\n          <path d=\"M 65 92 L 60 85\" />\n          <path d=\"M 83 75 L 76 69\" />\n        </svg>\n        <span className=\"min-w-0 truncate\">{turn.label ?? \"Called tool\"}</span>\n        {style.chevron && <CaretDown />}\n      </div>\n    );\n  }\n\n  if (style.variant === \"bare\") {\n    // ChatGPT: borderless muted line, \"Used <name>\", no status mark.\n    return (\n      <div\n        className=\"inline-flex items-center gap-1.5 font-mono text-[11px]\"\n        style={{ color: \"var(--chat-muted)\" }}\n      >\n        <RunGlyph color=\"currentColor\" />\n        {label}\n      </div>\n    );\n  }\n\n  if (style.variant === \"card\") {\n    // Goose: bordered card — filled-accent check disc · name · chevron.\n    return (\n      <div\n        className=\"rounded-lg border\"\n        style={{ borderColor: \"var(--chat-border)\", background: \"var(--chat-canvas)\" }}\n      >\n        <div className=\"flex items-center gap-2 px-3 py-2\">\n          {/* Success disc — suppressed on a failed call (turn.ok === false) so a\n              failure never reads as done, matching the pill variant's trailing ✓. */}\n          {turn.ok !== false && style.check === \"disc\" && (\n            <span\n              className=\"grid size-4 shrink-0 place-items-center rounded-full\"\n              style={{ background: \"var(--chat-accent)\", color: \"var(--chat-accent-fg)\" }}\n            >\n              <svg\n                className=\"size-2.5\"\n                viewBox=\"0 0 24 24\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth={3.5}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                aria-hidden=\"true\"\n              >\n                <path d=\"M20 6 9 17l-5-5\" />\n              </svg>\n            </span>\n          )}\n          <span className=\"min-w-0 flex-1 truncate font-mono text-xs\" style={{ color: \"var(--chat-text)\" }}>\n            {turn.name}\n          </span>\n          {style.chevron && <DiscloseChevron />}\n        </div>\n      </div>\n    );\n  }\n\n  if (style.variant === \"row\") {\n    // VS Code: bordered row, \"Ran <name>\" (name in a link colour) · chevron.\n    return (\n      <div\n        className=\"flex items-center gap-1.5 rounded border px-2 py-1 text-[11px]\"\n        style={{ borderColor: \"var(--chat-border)\", background: \"var(--chat-canvas)\", color: \"var(--chat-muted)\" }}\n      >\n        <RunGlyph color=\"var(--chat-muted)\" />\n        <span className=\"min-w-0 flex-1 truncate font-mono\">\n          {style.verb ? `${style.verb} ` : \"\"}\n          <span style={{ color: style.nameColor ?? \"var(--chat-accent)\" }}>{turn.name}</span>\n        </span>\n        {style.chevron && <DiscloseChevron />}\n      </div>\n    );\n  }\n\n  // Claude (pill): bordered mono pill, accent glyph, trailing ✓.\n  return (\n    <div\n      className=\"inline-flex items-center gap-1.5 rounded-lg border px-2 py-1 font-mono text-[11px]\"\n      style={{ background: \"var(--chat-chrome)\", borderColor: \"var(--chat-border)\", color: \"var(--chat-muted)\" }}\n    >\n      <RunGlyph color=\"var(--chat-accent)\" />\n      {turn.name}\n      {turn.ok !== false && style.check === \"inline\" && (\n        <span style={{ color: \"var(--chat-accent)\" }}>✓</span>\n      )}\n    </div>\n  );\n}\n\n/* ---- turns ----------------------------------------------------------------- */\n\n/** A rail colour derived from the muted token, kept faint like a thread line. */\nconst RAIL = \"color-mix(in srgb, var(--chat-muted) 55%, transparent)\";\n\n/**\n * One node on Claude's activity timeline: a left icon on a vertical rail (drawn\n * above/below as `railTop`/`railBottom` say, so consecutive nodes read as one\n * connected thread) plus the row's content. The icon sits on a canvas-coloured\n * chip so the rail \"breaks\" cleanly at each node.\n */\nfunction TimelineNode({\n  icon,\n  railTop,\n  railBottom,\n  children,\n}: {\n  icon: ReactNode;\n  railTop: boolean;\n  railBottom: boolean;\n  children: ReactNode;\n}) {\n  return (\n    // Flush rows (no gap) with a min-height give the rail room to run unbroken\n    // from one node to the next; the icon chip punches a hole at each node.\n    <div className=\"flex min-h-[44px] gap-2.5\">\n      <div className=\"relative flex w-5 shrink-0 items-center justify-center self-stretch\">\n        {railTop && (\n          <span className=\"absolute left-1/2 top-0 h-1/2 w-px -translate-x-1/2\" style={{ background: RAIL }} />\n        )}\n        {railBottom && (\n          <span className=\"absolute bottom-0 left-1/2 h-1/2 w-px -translate-x-1/2\" style={{ background: RAIL }} />\n        )}\n        <span\n          className=\"relative z-10 grid place-items-center rounded-sm p-0.5\"\n          style={{ background: \"var(--chat-canvas)\" }}\n        >\n          {icon}\n        </span>\n      </div>\n      <div className=\"min-w-0 flex-1 self-center\">{children}</div>\n    </div>\n  );\n}\n\n/**\n * A client's collapsed reasoning summary — ChatGPT's \"Worked for 14s ⌄\" row: a\n * muted, clickable-looking line with a trailing caret. Reskinned by the theme\n * tokens like everything else; the verb comes from the theme, the seconds from\n * the turn. Only rendered for clients with a `reasonVerb` (filtered upstream).\n */\nfunction ReasonLine({ verb, seconds }: { verb: string; seconds: number }) {\n  return (\n    <div className=\"inline-flex items-center gap-1 text-sm\" style={{ color: \"var(--chat-muted)\" }}>\n      <span>{`${verb} ${seconds}s`}</span>\n      <CaretDown />\n    </div>\n  );\n}\n\nfunction Row({\n  turn,\n  theme,\n  timeline,\n  railTop,\n  railBottom,\n}: {\n  turn: Turn;\n  theme: ChatTheme;\n  timeline: boolean;\n  railTop: boolean;\n  railBottom: boolean;\n}) {\n  if (turn.role === \"user\") {\n    return (\n      <div className={`flex justify-end ${timeline ? \"py-1\" : \"\"}`}>\n        <p\n          className=\"max-w-[80%] rounded-2xl px-4 py-2 text-sm\"\n          style={{ background: \"var(--chat-bubble)\", color: \"var(--chat-bubble-text)\" }}\n        >\n          {turn.text}\n        </p>\n      </div>\n    );\n  }\n\n  // Reasoning summary. `reason` turns only reach here for a non-timeline client\n  // with a `reasonVerb` (the rest are filtered out upstream), so the else-branch\n  // never renders in practice — it's here to narrow `reasonVerb` to a string\n  // (avoiding a non-null assertion), not as a fallback.\n  if (turn.role === \"reason\") {\n    return theme.reasonVerb ? <ReasonLine verb={theme.reasonVerb} seconds={turn.seconds} /> : null;\n  }\n\n  // Claude activity timeline: assistant + tool turns are connected rail nodes.\n  if (timeline) {\n    const isTool = turn.role === \"tool\";\n    const icon =\n      isTool && turn.icon ? (\n        <img src={turn.icon} alt=\"\" aria-hidden=\"true\" className=\"size-[18px] shrink-0 rounded-sm\" />\n      ) : (\n        <Mark logo={theme.logo} size={isTool ? 18 : 20} />\n      );\n    return (\n      <TimelineNode icon={icon} railTop={railTop} railBottom={railBottom}>\n        {isTool ? (\n          <span className=\"block min-w-0 truncate text-[15px]\" style={{ color: \"var(--chat-muted)\" }}>\n            {turn.label ?? turn.name}\n          </span>\n        ) : (\n          <p\n            className=\"text-[15px] leading-relaxed\"\n            style={{ fontFamily: \"var(--chat-say-font)\", color: \"var(--chat-text)\" }}\n          >\n            {turn.text}\n          </p>\n        )}\n      </TimelineNode>\n    );\n  }\n\n  if (turn.role === \"tool\") {\n    return <ToolCall turn={turn} style={theme.toolCall} />;\n  }\n\n  // assistant: serif/sans copy, no bubble — avatar only when the client shows one\n  return (\n    <div className=\"flex gap-2.5\">\n      {theme.assistantAvatar && (\n        <span className=\"mt-0.5\">\n          <Mark logo={theme.logo} />\n        </span>\n      )}\n      <p\n        className=\"min-w-0 flex-1 text-[15px] leading-relaxed\"\n        style={{ fontFamily: \"var(--chat-say-font)\", color: \"var(--chat-text)\" }}\n      >\n        {turn.text}\n      </p>\n    </div>\n  );\n}\n\n/** Timeline nodes are the rows with a left icon — assistant lines and tool calls. */\nconst isTimelineNode = (turn: Turn | undefined) =>\n  turn?.role === \"assistant\" || turn?.role === \"tool\";\n\n/* ---- shell ----------------------------------------------------------------- */\n\nexport default function ChatShell({\n  theme,\n  conversation,\n  bodyHeight = 268,\n  className = \"\",\n}: ChatShellProps) {\n  // Claude renders tool calls as a connected activity rail (see TimelineNode).\n  const timeline = theme.toolCall.variant === \"timeline\";\n\n  // Drop reasoning summaries up front for any client that won't render them as\n  // a rail node — verbless clients (no summary at all) AND timeline clients\n  // (a `ReasonLine` isn't a timeline node, so leaving one between two nodes\n  // would break Claude's connected rail). Only a non-timeline client with a\n  // `reasonVerb` keeps its `reason` turns.\n  const turns =\n    theme.reasonVerb && !timeline ? conversation : conversation.filter((t) => t.role !== \"reason\");\n\n  // The theme becomes CSS custom properties on the shell root; every nested\n  // element reads them via var(). Reskinning is a one-object swap.\n  const vars = {\n    \"--chat-canvas\": theme.canvas,\n    \"--chat-chrome\": theme.chrome,\n    \"--chat-border\": theme.border,\n    \"--chat-text\": theme.text,\n    \"--chat-muted\": theme.muted,\n    \"--chat-accent\": theme.accent,\n    \"--chat-accent-fg\": theme.accentFg,\n    \"--chat-bubble\": theme.bubble,\n    \"--chat-bubble-text\": theme.bubbleText ?? theme.text,\n    \"--chat-say-font\": theme.sayFont,\n  } as CSSProperties;\n\n  return (\n    <div\n      className={`w-full max-w-md overflow-hidden rounded-xl border shadow-2xl shadow-black/40 ${className}`}\n      style={{\n        ...vars,\n        background: \"var(--chat-canvas)\",\n        borderColor: \"var(--chat-border)\",\n        color: \"var(--chat-text)\",\n      }}\n    >\n      {/* header: model pill */}\n      <div\n        className=\"flex items-center gap-2 border-b px-3.5 py-2.5\"\n        style={{ background: \"var(--chat-chrome)\", borderColor: \"var(--chat-border)\" }}\n      >\n        <span\n          className=\"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium\"\n          style={{ borderColor: \"var(--chat-border)\", color: \"var(--chat-text)\" }}\n        >\n          <Mark logo={theme.logo} size={14} />\n          {theme.name}\n          <span style={{ color: \"var(--chat-muted)\" }}>\n            <CaretDown className=\"size-3\" />\n          </span>\n        </span>\n      </div>\n\n      {/* conversation — fixed height, scrolls internally so a swap never reflows.\n          In timeline mode rows sit flush (their own py) so the rail is unbroken;\n          otherwise turns are spaced. */}\n      <div\n        className={`overflow-y-auto px-3.5 py-3.5 ${timeline ? \"\" : \"space-y-3.5\"}`}\n        style={{ height: bodyHeight }}\n      >\n        {turns.map((turn, i) => (\n          <Row\n            key={i}\n            turn={turn}\n            theme={theme}\n            timeline={timeline}\n            railTop={timeline && isTimelineNode(turn) && isTimelineNode(turns[i - 1])}\n            railBottom={timeline && isTimelineNode(turn) && isTimelineNode(turns[i + 1])}\n          />\n        ))}\n      </div>\n\n      {/* composer */}\n      <div className=\"px-3.5 pb-3.5\">\n        <div\n          className=\"flex items-center gap-2 rounded-2xl border px-3.5 py-2.5\"\n          style={{ background: \"var(--chat-chrome)\", borderColor: \"var(--chat-border)\" }}\n        >\n          <span className=\"min-w-0 flex-1 truncate text-sm\" style={{ color: \"var(--chat-muted)\" }}>\n            {theme.placeholder}\n          </span>\n          <span\n            className=\"grid size-7 shrink-0 place-items-center rounded-full\"\n            style={{ background: \"var(--chat-accent)\", color: \"var(--chat-accent-fg)\" }}\n          >\n            <svg\n              className=\"size-4\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={2.5}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              aria-hidden=\"true\"\n            >\n              <path d=\"M12 19V5M5 12l7-7 7 7\" />\n            </svg>\n          </span>\n        </div>\n      </div>\n    </div>\n  );\n}"
    },
    {
      "slug": "thinking-orbs",
      "title": "Thinking orbs (agent status)",
      "category": "Feedback",
      "summary": "Dotted, animated thought-orbs that show what an agent is doing — searching, solving, listening, connecting and more. One <canvas> of filled arcs, nine hand-tuned states, avatar and inline sizes, auto light/dark.",
      "problem": "A generic spinner tells the user something is happening but never what. Agent UIs stream for seconds at a time — searching, then reasoning, then wiring up results — and the status indicator should say which. This authors nine distinct activities as points moving on a slowly-rotating unit sphere, drawn as plain 2D canvas arcs (no WebGL, no SVG filters, no ctx.filter) so it renders identically in every browser and stays cheap enough to run many at once. It reads the surrounding theme, honours prefers-reduced-motion, and pauses itself off-screen or when the tab is hidden.",
      "whenToUse": [
        "A chat or agent UI needs an avatar/loading state that names the activity, not just 'loading'.",
        "You want one indicator that reads at 64px as an avatar and at 20px inline in a status line.",
        "The effect must be portable and self-contained — no shaders, no filter support, no runtime deps beyond React."
      ],
      "source": {
        "name": "orbs.jakubantalik.com",
        "url": "https://orbs.jakubantalik.com/"
      },
      "tags": [
        "orb",
        "thinking",
        "loading",
        "spinner",
        "agent",
        "ai",
        "status",
        "canvas",
        "animation",
        "avatar",
        "feedback"
      ],
      "dependencies": [
        "react"
      ],
      "status": "stable",
      "demoUrl": "https://eito.me/ui/thinking-orbs",
      "markdownUrl": "https://eito.me/ui/patterns/thinking-orbs.md",
      "code": "import {\n  useEffect,\n  useRef,\n  useState,\n  type CanvasHTMLAttributes,\n} from \"react\";\n\n/**\n * ThinkingOrb — a dotted, animated \"thought-orb\" status indicator for AI and\n * agent UIs. Drop one next to a streaming message to show *what* the agent is\n * doing: searching, solving, listening, connecting, and so on.\n *\n * The whole thing is one <canvas> drawing filled arcs — no WebGL, no SVG\n * filters, no `ctx.filter` — so it renders pixel-identically in every browser\n * and stays cheap enough to run several at once. Nine hand-tuned states are\n * built from one primitive: points distributed on (or around) a slowly-rotating\n * unit sphere, projected orthographically, then depth-shaded so dots on the far\n * side read dimmer and smaller. Each state just decides where the dots live and\n * how they move.\n *\n * It behaves, too: it honours `prefers-reduced-motion` (one static frame), and\n * pauses itself when scrolled off-screen or when the tab is hidden.\n *\n * Deps: react only.\n */\n\nexport type OrbState =\n  | \"working\"\n  | \"searching\"\n  | \"solving\"\n  | \"listening\"\n  | \"connecting\"\n  | \"weaving\"\n  | \"composing\"\n  | \"breathing\"\n  | \"shaping\";\n\nexport interface ThinkingOrbProps\n  extends Omit<CanvasHTMLAttributes<HTMLCanvasElement>, \"children\"> {\n  /** Which agent activity to animate. */\n  state?: OrbState;\n  /** Rendered CSS size in px (square). 64 for avatars, 20 for inline. */\n  size?: number;\n  /** Dot colour source. \"auto\" follows the surrounding theme. */\n  theme?: \"auto\" | \"dark\" | \"light\";\n  /** Explicit dot colour; wins over `theme`. Any CSS colour string. */\n  color?: string;\n  /** Animation speed multiplier. */\n  speed?: number;\n  /** Freeze on the current frame. */\n  paused?: boolean;\n}\n\nexport const ORB_STATES: OrbState[] = [\n  \"working\",\n  \"searching\",\n  \"solving\",\n  \"listening\",\n  \"connecting\",\n  \"weaving\",\n  \"composing\",\n  \"breathing\",\n  \"shaping\",\n];\n\nconst ARIA: Record<OrbState, string> = {\n  working: \"Working\",\n  searching: \"Searching\",\n  solving: \"Solving\",\n  listening: \"Listening\",\n  connecting: \"Connecting\",\n  weaving: \"Weaving\",\n  composing: \"Composing\",\n  breathing: \"Thinking\",\n  shaping: \"Shaping\",\n};\n\n/* ---- tiny math kit -------------------------------------------------------- */\n\ntype Pt = [x: number, y: number, z: number, boost?: number];\n\nconst TAU = Math.PI * 2;\nconst clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);\nconst smooth = (v: number) => {\n  const t = clamp01(v);\n  return t * t * (3 - 2 * t);\n};\n\n/** Rotate a unit vector: `ay` spins about Y, then `ax` tilts about X. */\nconst rot = (x: number, y: number, z: number, ay: number, ax: number): Pt => {\n  const x1 = x * Math.cos(ay) + z * Math.sin(ay);\n  const z1 = -x * Math.sin(ay) + z * Math.cos(ay);\n  const y2 = y * Math.cos(ax) - z1 * Math.sin(ax);\n  const z2 = y * Math.sin(ax) + z1 * Math.cos(ax);\n  return [x1, y2, z2];\n};\n\n/** Evenly-spread points on a unit sphere (Fibonacci lattice). */\nconst fibSphere = (n: number): Pt[] => {\n  const pts: Pt[] = [];\n  const ga = Math.PI * (3 - Math.sqrt(5));\n  for (let i = 0; i < n; i++) {\n    const y = 1 - (i / (n - 1)) * 2;\n    const r = Math.sqrt(Math.max(0, 1 - y * y));\n    const th = ga * i;\n    pts.push([Math.cos(th) * r, y, Math.sin(th) * r]);\n  }\n  return pts;\n};\n\n/** Deterministic PRNG so \"random\" layouts are stable across frames. */\nconst mulberry32 = (seed: number) => () => {\n  seed |= 0;\n  seed = (seed + 0x6d2b79f5) | 0;\n  let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);\n  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n};\n\n/* ---- per-state dot fields ------------------------------------------------- */\n/* Each returns points in rotated unit-sphere space; an optional 4th value\n * boosts a dot's size/opacity (used for highlights like the scan band). */\n\nconst fields: Record<OrbState, (t: number, n: number) => Pt[]> = {\n  working: (t, n) => {\n    const pts: Pt[] = [];\n    const orbits = [0.55, -0.65, 0.08];\n    const per = Math.round(n / orbits.length);\n    orbits.forEach((tilt, o) => {\n      for (let i = 0; i < per; i++) {\n        const a = (i / per) * TAU + t * 1.7 + o * 2.1;\n        pts.push(rot(Math.cos(a), 0, Math.sin(a), t * 0.5, tilt));\n      }\n    });\n    return pts;\n  },\n\n  searching: (t, n) => {\n    const scan = Math.sin(t * 0.9);\n    return fibSphere(n).map(([x, y, z]) => {\n      const p = rot(x, y, z, t * 0.35, 0.35);\n      const d = Math.abs(p[0] - scan);\n      p[3] = d < 0.2 ? 1 + (1 - d / 0.2) * 1.5 : 1;\n      return p;\n    });\n  },\n\n  solving: (t, n) => {\n    const lats = [-0.6, -0.3, 0, 0.3, 0.6];\n    const per = Math.round(n / lats.length);\n    const cycle = (t * 0.5) % 1;\n    const solved = smooth((cycle - 0.55) / 0.35);\n    const jitter = (1 - solved) * 0.9;\n    const rng = mulberry32(99);\n    const pts: Pt[] = [];\n    lats.forEach((l) => {\n      const r = Math.sqrt(Math.max(0, 1 - l * l));\n      for (let i = 0; i < per; i++) {\n        const off = (rng() - 0.5) * TAU * jitter;\n        const a = (i / per) * TAU + off + t * 0.2;\n        pts.push(rot(Math.cos(a) * r, l, Math.sin(a) * r, t * 0.3, 0.3));\n      }\n    });\n    return pts;\n  },\n\n  listening: (t, n) => {\n    const lats = [-0.62, -0.32, 0, 0.32, 0.62];\n    const per = Math.round(n / lats.length);\n    const pts: Pt[] = [];\n    lats.forEach((l) => {\n      const base = Math.sqrt(Math.max(0, 1 - l * l));\n      for (let i = 0; i < per; i++) {\n        const a = (i / per) * TAU;\n        const wave = 1 + 0.16 * Math.sin(a * 3 - t * 3 + l * 4);\n        pts.push(\n          rot(Math.cos(a) * base * wave, l, Math.sin(a) * base * wave, t * 0.25, 0.3),\n        );\n      }\n    });\n    return pts;\n  },\n\n  connecting: (t, n) => {\n    return fibSphere(Math.round(n * 0.6)).map(([x, y, z]) => {\n      const p = rot(x, y, z, t * 0.2, 0.4);\n      p[3] = 1.25;\n      return p;\n    });\n  },\n\n  weaving: (t, n) => {\n    const per = Math.round(n / 3);\n    const pts: Pt[] = [];\n    for (let s = 0; s < 3; s++) {\n      const phase = (s * TAU) / 3;\n      for (let i = 0; i < per; i++) {\n        const u = i / per;\n        const lon = u * TAU * 2;\n        const lat = Math.sin(u * TAU * 3 + phase + t * 1.2) * 0.7;\n        const r = Math.cos(lat);\n        pts.push(rot(Math.cos(lon) * r, Math.sin(lat), Math.sin(lon) * r, t * 0.4, 0.25));\n      }\n    }\n    return pts;\n  },\n\n  composing: (t, n) => {\n    const rows = [-0.14, -0.05, 0.05, 0.14];\n    const per = Math.round(n / rows.length);\n    const pts: Pt[] = [];\n    rows.forEach((v) => {\n      for (let i = 0; i < per; i++) {\n        const u = i / per;\n        const lon = u * TAU;\n        const lat = v + 0.3 * Math.sin(u * TAU * 2 - t * 2.2);\n        const r = Math.sqrt(Math.max(0, 1 - lat * lat));\n        pts.push(rot(Math.cos(lon) * r, lat, Math.sin(lon) * r, t * 0.3, 0.16));\n      }\n    });\n    return pts;\n  },\n\n  breathing: (t, n) => {\n    const pts: Pt[] = [];\n    [1, 0.6].forEach((scale, ring) => {\n      for (let i = 0; i < n; i++) {\n        const a = (i / n) * TAU;\n        const morph = 1 + 0.09 * Math.sin(3 * a + t * 1.3 + ring);\n        const rad = (1 + 0.1 * Math.sin(t * 1.2)) * scale * morph;\n        pts.push(rot(Math.cos(a) * rad, Math.sin(a) * rad, 0, t * 0.25, 1.1));\n      }\n    });\n    return pts;\n  },\n\n  shaping: (t, n) => {\n    const shapes = [0, 3, 4]; // circle (0 sides), triangle, square\n    const tt = t * 0.45;\n    const idx = Math.floor(tt) % shapes.length;\n    const f = smooth(tt % 1);\n    const spin = t * 0.15;\n    const shapePt = (sides: number, u: number): [number, number] => {\n      if (sides === 0) return [Math.cos(u * TAU), Math.sin(u * TAU)];\n      const seg = u * sides;\n      const k = Math.floor(seg);\n      const g = seg - k;\n      const va = -Math.PI / 2 + (k / sides) * TAU;\n      const vb = -Math.PI / 2 + ((k + 1) / sides) * TAU;\n      const scl = 1.12; // fatten polygons so they fill the same disc as the circle\n      return [\n        (Math.cos(va) + (Math.cos(vb) - Math.cos(va)) * g) * scl,\n        (Math.sin(va) + (Math.sin(vb) - Math.sin(va)) * g) * scl,\n      ];\n    };\n    const from = shapes[idx];\n    const to = shapes[(idx + 1) % shapes.length];\n    const pts: Pt[] = [];\n    for (let i = 0; i < n; i++) {\n      const u = i / n;\n      const a = shapePt(from, u);\n      const b = shapePt(to, u);\n      const x = a[0] + (b[0] - a[0]) * f;\n      const y = a[1] + (b[1] - a[1]) * f;\n      // 2D outline: spin in-plane, keep dots uniformly lit (z fixed at front).\n      pts.push([x * Math.cos(spin) - y * Math.sin(spin), x * Math.sin(spin) + y * Math.cos(spin), 0.7]);\n    }\n    return pts;\n  },\n};\n\n/* ---- theme resolution ----------------------------------------------------- */\n\nconst resolveTheme = (el: HTMLElement | null): \"dark\" | \"light\" => {\n  for (let node = el; node; node = node.parentElement) {\n    const attr = node.getAttribute(\"data-theme\");\n    if (attr === \"dark\" || attr === \"light\") return attr;\n    if (node.classList.contains(\"dark\")) return \"dark\";\n    if (node.classList.contains(\"light\")) return \"light\";\n  }\n  if (typeof window !== \"undefined\" && window.matchMedia) {\n    return window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\";\n  }\n  return \"dark\";\n};\n\n/* ---- component ------------------------------------------------------------ */\n\nconst ThinkingOrb = ({\n  state = \"working\",\n  size = 64,\n  theme = \"auto\",\n  color,\n  speed = 1,\n  paused = false,\n  style,\n  ...rest\n}: ThinkingOrbProps) => {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const elapsedRef = useRef(0);\n  const [resolved, setResolved] = useState<\"dark\" | \"light\">(\"dark\");\n  const [reduce, setReduce] = useState(false);\n\n  // Track the effective theme when following the surroundings.\n  useEffect(() => {\n    if (theme !== \"auto\") {\n      setResolved(theme);\n      return;\n    }\n    const el = canvasRef.current;\n    const update = () => setResolved(resolveTheme(el));\n    update();\n    const mq = window.matchMedia?.(\"(prefers-color-scheme: dark)\");\n    mq?.addEventListener?.(\"change\", update);\n    const mo = new MutationObserver(update);\n    for (let node = el?.parentElement; node; node = node.parentElement) {\n      mo.observe(node, { attributes: true, attributeFilter: [\"class\", \"data-theme\"] });\n    }\n    return () => {\n      mq?.removeEventListener?.(\"change\", update);\n      mo.disconnect();\n    };\n  }, [theme]);\n\n  // Re-evaluate the reduced-motion preference live, so toggling it at the OS\n  // level stops or starts the animation without a remount.\n  useEffect(() => {\n    const mq = window.matchMedia?.(\"(prefers-reduced-motion: reduce)\");\n    if (!mq) return;\n    const update = () => setReduce(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!canvas || !ctx) return;\n\n    const dpr = Math.min(2, typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1);\n    canvas.width = Math.round(size * dpr);\n    canvas.height = Math.round(size * dpr);\n\n    const dot = color ?? (resolved === \"dark\" ? \"#eaf0f7\" : \"#14171b\");\n    const link = color ?? (resolved === \"dark\" ? \"#eaf0f7\" : \"#14171b\");\n    const big = size >= 40;\n    const n = big ? 132 : 46;\n    const dotR = Math.max(0.7, size * (big ? 0.026 : 0.036));\n    const cx = size / 2;\n    const cy = size / 2;\n    const R = size * 0.42;\n\n    const draw = (t: number) => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, size, size);\n      ctx.fillStyle = dot;\n\n      const pts = fields[state](t, n);\n\n      // \"connecting\" wires nearest neighbours together before the dots land.\n      if (state === \"connecting\") {\n        const prog = (t * 0.12) % 1;\n        const shown = Math.floor(smooth(prog) * pts.length);\n        ctx.strokeStyle = link;\n        ctx.lineWidth = Math.max(0.4, size * 0.006);\n        for (let i = 0; i < Math.min(shown, pts.length); i++) {\n          let best = -1;\n          let bd = Infinity;\n          for (let j = 0; j < pts.length; j++) {\n            if (j === i) continue;\n            const dx = pts[i][0] - pts[j][0];\n            const dy = pts[i][1] - pts[j][1];\n            const dz = pts[i][2] - pts[j][2];\n            const d = dx * dx + dy * dy + dz * dz;\n            if (d < bd) {\n              bd = d;\n              best = j;\n            }\n          }\n          if (best < 0) continue;\n          const a = pts[i];\n          const b = pts[best];\n          ctx.globalAlpha = 0.28 * clamp01((a[2] + b[2]) / 2 + 1);\n          ctx.beginPath();\n          ctx.moveTo(cx + a[0] * R, cy - a[1] * R);\n          ctx.lineTo(cx + b[0] * R, cy - b[1] * R);\n          ctx.stroke();\n        }\n      }\n\n      // Depth-sort so near dots paint over far ones, then shade by depth.\n      pts.sort((a, b) => a[2] - b[2]);\n      for (const [x, y, z, boost = 1] of pts) {\n        const depth = (z + 1) / 2;\n        const r = dotR * (0.45 + 0.55 * depth) * boost;\n        ctx.globalAlpha = clamp01((0.18 + 0.82 * depth) * Math.min(1, boost));\n        ctx.beginPath();\n        ctx.arc(cx + x * R, cy - y * R, Math.max(0.1, r), 0, TAU);\n        ctx.fill();\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    // Reduced motion / paused: draw a single frame and schedule nothing. Both\n    // freeze at whatever time has already elapsed (a fresh mount sits at 0),\n    // so pausing holds the current frame instead of jumping back to the start.\n    if (reduce) {\n      draw(elapsedRef.current || 0.6);\n      return;\n    }\n    if (paused) {\n      draw(elapsedRef.current);\n      return;\n    }\n\n    // Advance a persistent clock by real elapsed time each frame, and actually\n    // stop scheduling frames while off-screen or backgrounded — not just skip\n    // the paint — so idle orbs cost nothing. The loop restarts from where it\n    // left off when the orb returns to view or the tab regains focus.\n    let raf = 0;\n    let running = false;\n    let onScreen = true;\n    let last = performance.now();\n\n    const frame = (now: number) => {\n      elapsedRef.current += ((now - last) / 1000) * speed;\n      last = now;\n      draw(elapsedRef.current);\n      raf = requestAnimationFrame(frame);\n    };\n    const startLoop = () => {\n      if (running) return;\n      running = true;\n      last = performance.now();\n      raf = requestAnimationFrame(frame);\n    };\n    const stopLoop = () => {\n      running = false;\n      cancelAnimationFrame(raf);\n    };\n\n    const io = new IntersectionObserver(\n      ([entry]) => {\n        onScreen = entry.isIntersecting;\n        if (onScreen && !document.hidden) startLoop();\n        else stopLoop();\n      },\n      { threshold: 0 },\n    );\n    io.observe(canvas);\n\n    const onVisibility = () => {\n      if (!document.hidden && onScreen) startLoop();\n      else stopLoop();\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    if (!document.hidden) startLoop();\n\n    return () => {\n      stopLoop();\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, [state, size, color, resolved, speed, paused, reduce]);\n\n  return (\n    <canvas\n      ref={canvasRef}\n      role=\"img\"\n      aria-label={rest[\"aria-label\"] ?? ARIA[state]}\n      style={{ width: size, height: size, display: \"block\", ...style }}\n      {...rest}\n    />\n  );\n};\n\nexport default ThinkingOrb;"
    }
  ]
}
