# SealGate launch film

> 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.

- **Category:** Marketing
- **Source (best-in-class):** [sealgate.ai](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
- **Live demo:** https://eito.me/ui/sealgate-film

## 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.

## When to use

- 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.

## Code — snippet.tsx

```tsx
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import ChatShell, { chatThemes, type ChatTheme, type Turn as ChatTurn } from "../chat-shell/snippet";
import IPhoneMock, { ChatGPTScreen } from "../iphone-mock/snippet";
import MacBookMock, { ClaudeScreen } from "../macbook-mock/snippet";
import { INTEGRATIONS, type Integration } from "./integrations";
import wordmarkSvg from "./assets/sealgate-wordmark.svg?raw";
import logomarkSvg from "./assets/sealgate-logomark.svg?raw";

/**
 * SealGateFilm: a SealGate launch film built on the LaunchFilm engine (one rAF
 * clock, data-driven chapters, a draggable chapter scrubber) and dressed in the
 * sealgate.ai brand system: black stage, Core Cyan as the single signal, Archivo
 * Medium as the default face, JetBrains Mono for data, Hackbox tags at 40% x 10%.
 *
 * The story, one chapter each:
 *   1. SealGate           wordmark + the landing-page headline
 *   2. Write a policy     a policy composer types itself; the camera pushes in and
 *                         zoom-cuts onto the send button (LaunchFilm's "Just ask")
 *   3-5. one continuous world with one camera (chapter cuts are invisible):
 *      Agent at work      the SealGate agent collects context (status line,
 *                         scanned reel, a stack of sources), writes the policy,
 *                         drops into the gate as its seal and docks the policy
 *                         as the gate's POLICY tag
 *      Ask in any AI      the camera pushes into Claude and ChatGPT (ChatShell)
 *                         as the team prompts
 *      Airlock            the camera pulls back and both calls travel to Gmail
 *                         through the gate: entry door opens, the call is
 *                         scanned, then the exit door opens or the call is held
 *   6. Integrations       every agent and data source listed on sealgate.ai
 *   7. Any device         the same enforcement on iPhone (ChatGPT) and MacBook
 *                         (Claude), using IPhoneMock + MacBookMock
 *   8. Start free         end card + CTA
 *
 * Every animation reads the film clock, so pausing or scrubbing freezes the
 * exact frame. Reuses three sibling patterns (chat-shell, iphone-mock,
 * macbook-mock); ChatShell needs Tailwind. Brand SVGs are imported with Vite's
 * `?raw` and inlined as data URIs.
 */

const STAGE_W = 920;
const STAGE_H = 516;
const TRACK_H = 96;
const GAP = 12;
const CANVAS_W = STAGE_W;
const CANVAS_H = STAGE_H + GAP + TRACK_H;
const INSET = 22;
const BOX_W = STAGE_W - INSET * 2; // 876
const BOX_H = STAGE_H - INSET * 2; // 472

const c = {
  black: "#000000",
  panel: "#05070a",
  panel2: "#1C1C1C",
  line: "#383838",
  line2: "#555555",
  grey: "#9BA4A6",
  dim: "#8F8F8F",
  white: "#F9F9F9",
  cyan: "#C3FFFD",
  green: "#00C781",
  red: "#FF3B4D",
} as const;

const SANS = '"Archivo", ui-sans-serif, sans-serif';
const MONO = '"JetBrains Mono", ui-monospace, Menlo, monospace';
const FONTS = "@import url('https://fonts.googleapis.com/css2?family=Archivo:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');";

const BASE = (import.meta as { env?: { BASE_URL?: string } }).env?.BASE_URL ?? "/";
const uri = (svg: string) => `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
const WORDMARK = uri(wordmarkSvg);
const LOGOMARK = uri(logomarkSvg);

const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
const ease = (t: number) => t * t * (3 - 2 * t);
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const seg = (t: number, a: number, b: number) => clamp01((t - a) / (b - a));
const at = (p: number, start: number, dur: number) => ease(clamp01((p - start) / dur));
const pulse = (t: number, a: number, b: number) => Math.min(seg(t, a - 0.06, a), 1 - seg(t, b, b + 0.06));
const fmt = (s: number) => `${Math.floor(s / 60)}:${Math.floor(s % 60).toString().padStart(2, "0")}`;

/** The SealGate wordmark inline, sized by height (it replaces the name in text). */
function Wordmark({ h, style }: { h: number; style?: CSSProperties }) {
  return <img src={WORDMARK} alt="SealGate" style={{ height: h, width: h * (2241.77 / 391), display: "inline-block", verticalAlign: "middle", ...style }} />;
}

/* ---- integrations as images ---------------------------------------------- */

const byName = (name: string) => INTEGRATIONS.find((i) => i.name === name) as Integration;

/** A brand tile as a standalone SVG (for <img>/<image> and ChatShell icons). */
function tileSvg(it: Integration) {
  if (it.raw) return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${it.viewBox}">${it.body}</svg>`;
  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>`;
}
const GMAIL = uri(tileSvg(byName("Gmail")));

/** Chrome's mark, drawn inline (three 120° sectors around the blue core). */
const CHROME = uri(
  `<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>`,
);
const CLAUDE_TILE = uri(tileSvg(byName("Claude Desktop")));
const CHATGPT_TILE = uri(tileSvg(byName("ChatGPT")));
const GROKBOT = `${BASE}icons/agents/grokbot.svg`;

function Tile({ it, size }: { it: Integration; size: number }) {
  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 }} />;
}

/* ---- brand primitives ----------------------------------------------------- */

function Scene({ children }: { children: ReactNode }) {
  return <div style={{ position: "absolute", inset: 0, background: c.black, border: `1px solid ${c.line}`, overflow: "hidden" }}>{children}</div>;
}

function Label({ children, color = c.grey }: { children: ReactNode; color?: string }) {
  return <div style={{ fontFamily: MONO, fontSize: 11, letterSpacing: 2, textTransform: "uppercase", color }}>{children}</div>;
}

function Rise({ e, y = 10, children, style }: { e: number; y?: number; children: ReactNode; style?: CSSProperties }) {
  return <div style={{ opacity: e, transform: `translateY(${(1 - e) * y}px)`, ...style }}>{children}</div>;
}

/** Mono label + Archivo Light headline, as section heads read on sealgate.ai. */
function Heading({ p, label, title, center }: { p: number; label?: string; title: string; center?: boolean }) {
  return (
    <Rise e={at(p, 0, 0.14)} style={{ textAlign: center ? "center" : "left" }}>
      {label && <Label color={c.cyan}>{label}</Label>}
      <div style={{ fontFamily: SANS, fontSize: 28, fontWeight: 500, color: c.white, marginTop: label ? 8 : 0, letterSpacing: -0.6 }}>{title}</div>
    </Rise>
  );
}

/* ---- 01 · SealGate -------------------------------------------------------- */

function SceneHook({ p }: { p: number }) {
  return (
    <Scene>
      <div style={{ position: "absolute", left: 0, right: 0, top: 138, display: "flex", flexDirection: "column", alignItems: "center" }}>
        <Rise e={at(p, 0.1, 0.25)}>
          <img src={WORDMARK} alt="SealGate" style={{ height: 58, display: "block" }} />
        </Rise>
        <Rise e={at(p, 0.3, 0.25)}>
          <div style={{ fontFamily: SANS, fontSize: 32, fontWeight: 500, color: c.white, marginTop: 36, letterSpacing: -0.8 }}>
            Connect &amp; Govern how agents interact with your data
          </div>
        </Rise>
      </div>
    </Scene>
  );
}

/* ---- 02 · Write a policy -------------------------------------------------- */
/* The composer draws itself in, the heading rises word by word and the agents
   the rule covers spring onto it. While the rule types, the camera tracks the
   caret; each key phrase is underlined as it lands and pops a matching chip.
   The camera then pushes onto the send button, pulls back on the press, and
   the rule lifts off as a message that lands exactly where the next chapter's
   agent picks it up. */

const COMP = { x: 128, y: 190, w: 620, h: 124 };
const SEND = { x: COMP.x + COMP.w - 18 - 17, y: COMP.y + COMP.h - 16 - 17 };
const TYPE = { from: 0.95, to: 2.85 };
const TERMS = [
  { text: "email", chip: "gmail.send_email" },
  { text: "customer data", chip: "pii" },
  { text: "outside the company", chip: "external domains" },
];
const HEADLINE = "What should your agents never do?";

/** The composer's send button: outlined until the rule is typed, then a Core
    Cyan fill springs out from its centre; the press dips it and throws a ring. */
function SendButton({ fill, press }: { fill: number; press: number }) {
  const size = 34;
  const dip = press > 0 ? 1 - 0.12 * Math.sin(Math.min(1, press * 2.5) * Math.PI) : 1;
  const f = fill > 0 ? spring(fill) : 0;
  return (
    <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})` }}>
      {press > 0 && press < 1 && (
        <div style={{ position: "absolute", inset: 0, borderRadius: 8, border: `1.5px solid ${c.cyan}`, transform: `scale(${1 + press * 1.1})`, opacity: 1 - press }} />
      )}
      <div style={{ position: "absolute", inset: 0, borderRadius: 8, border: `1px solid ${fill > 0 ? c.cyan : c.line2}` }} />
      <div style={{ position: "absolute", inset: 0, borderRadius: 8, background: c.cyan, transform: `scale(${f.toFixed(3)})` }} />
      <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)` }}>
        <path d="M12 19V5M5 12l7-7 7 7" />
      </svg>
    </div>
  );
}

const typedAt = (t: number) => Math.floor(seg(t, TYPE.from, TYPE.to) * POLICY_PROMPT.length);
const termDone = (text: string) => TYPE.from + ((POLICY_PROMPT.indexOf(text) + text.length) / POLICY_PROMPT.length) * (TYPE.to - TYPE.from);

/** The rule as typed so far, key phrases underlined in cyan as they complete. */
function TypedRule({ chars, t }: { chars: number; t: number }) {
  const out: ReactNode[] = [];
  let i = 0;
  for (const term of TERMS) {
    const k = POLICY_PROMPT.indexOf(term.text);
    out.push(POLICY_PROMPT.slice(i, Math.min(k, chars)));
    if (chars <= k) return <>{out}</>;
    const u = ease(seg(t, termDone(term.text), termDone(term.text) + 0.25));
    out.push(
      <span key={term.text} style={{ position: "relative", color: u > 0.5 ? c.cyan : c.white }}>
        {POLICY_PROMPT.slice(k, Math.min(k + term.text.length, chars))}
        <span style={{ position: "absolute", left: 0, bottom: -3, height: 2, width: `${u * 100}%`, background: c.cyan }} />
      </span>,
    );
    i = k + term.text.length;
  }
  out.push(POLICY_PROMPT.slice(i, chars));
  return <>{out}</>;
}

/** The sent rule as a message bubble, shared with the agent chapter so the
    hand-off lands on the same pixels. */
const BUBBLE_W = 330;
function Bubble({ style }: { style?: CSSProperties }) {
  return (
    <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 }}>
      {POLICY_PROMPT}
    </div>
  );
}

function policyCam(t: number): Pose {
  const home: Pose = { s: 1, x: COMP.x + COMP.w / 2, y: 236 };
  // a gentle drift that follows the caret without losing the start of the line
  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 };
  const send: Pose = { s: 2.1, x: SEND.x - 60, y: SEND.y - 10 };
  if (t < 0.9) return mix({ ...home, s: 0.92 }, home, ease(seg(t, 0, 0.9)));
  if (t < 3.0) return mix(home, track, ease(seg(t, 0.9, 1.6)));
  if (t < 3.6) return mix(track, send, ease(seg(t, 3.0, 3.4)));
  return mix(send, P_WIDE, ease(seg(t, 3.6, 4.35)));
}

function ScenePolicy({ p }: { p: number }) {
  const t = p * 5;
  const chars = typedAt(t);
  const done = chars >= POLICY_PROMPT.length;
  const caretOn = Math.floor(t * 2.6) % 2 === 0;
  const cam = policyCam(t);

  const draw = ease(seg(t, 0.15, 0.85));
  const perim = 2 * (COMP.w + COMP.h);
  const beam = 150;
  const collapse = ease(seg(t, 3.7, 4.2));
  const lift = seg(t, 3.72, 4.6);

  // the bubble flies in screen space, from the composer text to the agent chapter's spot
  const f = ease(lift);
  const fromX = BOX_W / 2 + cam.s * (COMP.x + 20 - cam.x);
  const fromY = BOX_H / 2 + cam.s * (COMP.y + 16 - cam.y);
  const toX = BOX_W / 2 + P_NET.s * (RULE_AT.x - P_NET.x);
  const toY = BOX_H / 2 + P_NET.s * (RULE_AT.y - P_NET.y);
  const bx = lerp(fromX, toX, f);
  const by = lerp(fromY, toY, f) - Math.sin(f * Math.PI) * 26;
  const bs = lerp(cam.s * 1.15, P_NET.s, f);
  const brand = at(t, 0.05, 0.4);

  return (
    <Scene>
      <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)})` }}>
        <div style={{ position: "absolute", left: COMP.x, top: 102, width: COMP.w, opacity: 1 - collapse, transform: `translateY(${(-collapse * 16).toFixed(1)}px)` }}>
          <div style={{ display: "flex", alignItems: "center", gap: 10, opacity: brand, transform: `translateX(${((1 - brand) * -14).toFixed(1)}px)` }}>
            <Wordmark h={22} />
          </div>
          <div style={{ fontFamily: SANS, fontSize: 24, fontWeight: 500, color: c.white, marginTop: 14, display: "flex", flexWrap: "wrap", columnGap: 7 }}>
            {HEADLINE.split(" ").map((word, i) => {
              const e = at(t, 0.2 + i * 0.07, 0.45);
              return (
                <span key={i} style={{ display: "inline-block", overflow: "hidden", paddingBottom: 3 }}>
                  <span style={{ display: "inline-block", transform: `translateY(${((1 - e) * 110).toFixed(1)}%)` }}>{word}</span>
                </span>
              );
            })}
          </div>
        </div>
        <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 }}>
          <div style={{ position: "absolute", inset: 0, background: c.panel, opacity: draw }} />
          <svg width={COMP.w} height={COMP.h} style={{ position: "absolute", left: 0, top: 0, overflow: "visible" }} aria-hidden="true">
            <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)} />
            {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} />}
          </svg>
          <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)) }}>
            {chars ? <TypedRule chars={chars} t={t} /> : "Describe a rule in plain English…"}
            {!done && t > 0.6 && <span style={{ display: "inline-block", width: 2, height: 17, background: c.cyan, marginLeft: 1, verticalAlign: -2, opacity: caretOn ? 1 : 0 }} />}
          </div>
          {/* the agents this rule applies to, then the phrases it picked out */}
          <div style={{ position: "absolute", left: 18, right: 70, bottom: 20, display: "flex", alignItems: "center", gap: 8 }}>
            <div style={{ display: "flex", marginRight: 6 }}>
              {[CLAUDE_TILE, CHATGPT_TILE, GROKBOT].map((src, i) => {
                const e = seg(t, 0.6 + i * 0.1, 1.0 + i * 0.1);
                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})` }} />;
              })}
            </div>
            {TERMS.map((term) => {
              const e = seg(t, termDone(term.text) + 0.1, termDone(term.text) + 0.45);
              if (e <= 0) return null;
              const sp = spring(e);
              return (
                <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)})` }}>
                  {term.chip}
                </span>
              );
            })}
          </div>
          <SendButton fill={seg(t, 3.05, 3.4)} press={seg(t, 3.45, 3.95)} />
        </div>
      </div>
      {lift > 0 && <Bubble style={{ left: bx, top: by, transform: `scale(${bs.toFixed(3)})`, opacity: clamp01(lift * 6) }} />}
    </Scene>
  );
}

/* ---- 04 · Ask in any AI (intent-based prompting) -------------------------- */

const theme = (id: "claude" | "chatgpt"): ChatTheme => ({ ...chatThemes[id], logo: `${BASE}icons/agents/${id}.svg` });
const CLAUDE = theme("claude");
const CHATGPT = theme("chatgpt");

const ASK_CLAUDE = "Send the Q3 board summary to the leadership team.";
const ASK_GPT = "Email our customer list to my personal Gmail.";

function claudeTurns(stage: number): ChatTurn[] {
  const t: ChatTurn[] = [];
  if (stage >= 1) t.push({ role: "user", text: ASK_CLAUDE });
  if (stage >= 2) t.push({ role: "tool", name: "gmail.send_email", label: "Sending email to leadership@acme.com", icon: GMAIL });
  if (stage >= 3) t.push({ role: "assistant", text: "Sent to leadership@acme.com. SealGate checked it against company policy." });
  return t;
}
function gptTurns(stage: number): ChatTurn[] {
  const t: ChatTurn[] = [];
  if (stage >= 1) t.push({ role: "user", text: ASK_GPT });
  if (stage >= 2) t.push({ role: "reason", seconds: 4 }, { role: "tool", name: "gmail.send_email", label: "Sending email via Gmail" });
  if (stage >= 3) t.push({ role: "assistant", text: "SealGate blocked this: customer data can't be emailed outside the company." });
  return t;
}

function Window({ theme: th, turns, width, scale, body }: { theme: ChatTheme; turns: ChatTurn[]; width: number; scale: number; body: number }) {
  return (
    <div style={{ width: width * scale, transformOrigin: "0 0" }}>
      <div style={{ width, transform: `scale(${scale})`, transformOrigin: "0 0" }}>
        <ChatShell theme={th} conversation={turns} bodyHeight={body} />
      </div>
    </div>
  );
}

/* ---- 05 · Airlock --------------------------------------------------------- */
/* The SealGate gate as an airlock, geometry and stage timings ported from the
   sealgate.ai hero: the call reaches the entry door, the door opens, the camera
   zooms the call up to a readable card and scans it, then the exit door opens
   onto Gmail, or the gate turns red and the call stays inside. */

const 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 };
const AG = { x: 480, y: 280, size: 196 };
const AL = AG.x - AG.size / 2;
const AR = AG.x + AG.size / 2;
const AT = AG.y - AG.size / 2;
const AB = AG.y + AG.size / 2;
const A_DOOR = 30;
const A_SEAL = 64;
const A_SEAL_Y = AT + AG.size * 0.1 + 10 + A_SEAL / 2;
const A_IN = (AL + AG.x - A_SEAL / 2) / 2;
const A_OUT = (AR + AG.x + A_SEAL / 2) / 2;
const A_ZW = AG.size - 12;
const A_ZH = 62;
const A_ZY = AB - 8 - A_ZH / 2;
const A_PKT = 9;
const SRC = [
  { x: 292, y: 192 },
  { x: 292, y: 372 },
];
const DEST = { x: 776, y: AG.y, size: 60 };

interface Call {
  t: number;
  src: number;
  blocked: boolean;
  to: string;
  attach?: string;
}

function ADoor({ x, open, alert }: { x: number; open: number; alert: boolean }) {
  const h = A_DOOR / 2;
  const f = h * open;
  return (
    <g stroke={alert ? c.red : c.cyan} strokeWidth={2}>
      <line x1={x} y1={AT} x2={x} y2={AG.y - h} />
      <line x1={x} y1={AG.y + h} x2={x} y2={AB} />
      <line x1={x} y1={AG.y - h - f} x2={x} y2={AG.y - f} strokeWidth={4} />
      <line x1={x} y1={AG.y + f} x2={x} y2={AG.y + h + f} strokeWidth={4} />
    </g>
  );
}

/**
 * The gate. `draw` traces the frame in (0 -> 1) while the policy is being built,
 * `tag` shows the docked POLICY tag, `wiring` fades in the lanes and Gmail.
 */
function 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[] }) {
  const t = call?.t ?? 0;
  const f = !!call && call.blocked && t >= ST.scanEnd;
  const ok = !!call && !call.blocked && t >= ST.scanEnd;
  const inOpen = call ? pulse(t, ST.arrive - 0.02, ST.enter) : 0;
  const outOpen = call && !f ? pulse(t, ST.small - 0.02, ST.leave) : 0;
  const zoom = call ? (t < ST.zoomOut ? ease(seg(t, ST.enter, ST.zoomIn)) : 1 - ease(seg(t, ST.zoomOut, ST.small))) : 0;

  let px = 0;
  let py = AG.y;
  if (call) {
    const s = SRC[call.src];
    const doorIn = AL - 6;
    const doorOut = AR + 6;
    if (t < ST.arrive) {
      const u = seg(t, 0, ST.arrive);
      px = lerp(s.x, doorIn, u);
      py = lerp(s.y, AG.y, u);
    } else if (t < ST.enter) px = lerp(doorIn, A_IN, ease(seg(t, ST.arrive, ST.enter)));
    else if (f || t < ST.small) px = f ? A_IN : A_OUT;
    else if (t < ST.leave) px = lerp(A_OUT, doorOut, ease(seg(t, ST.small, ST.leave)));
    else px = lerp(doorOut, DEST.x - DEST.size / 2 - 6, seg(t, ST.leave, ST.done));
  }
  const base = t < ST.zoomOut || f ? A_IN : A_OUT;
  const cx = lerp(base, AG.x, zoom);
  const w = lerp(A_PKT, A_ZW, zoom);
  const h = lerp(A_PKT, A_ZH, zoom);
  const cy = lerp(AG.y, A_ZY, zoom);
  const fade = f ? 1 - seg(t, ST.small, ST.blockFade) * 0.6 : 1;
  const detail = clamp01((zoom - 0.6) / 0.4);
  const inGate = t >= ST.enter && (f || t < ST.small);
  const visible = !!call && t > 0.02 && t < 0.985;
  const scanning = zoom > 0.95 && t < ST.scanEnd;
  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));
  const ink = f ? c.red : ok ? c.cyan : c.white;
  const hot = f || done[1];
  const x1 = AR + 6;
  const x2 = DEST.x - DEST.size / 2 - 6;
  const mx = (x1 + x2) / 2;

  return (
    <svg width={BOX_W} height={BOX_H} style={{ position: "absolute", left: 0, top: 0 }} fill="none">
      <defs>
        <marker id="sgf-arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
          <path d="M0,0 L6,3 L0,6 Z" fill={c.cyan} />
        </marker>
      </defs>
      {SRC.map((s, i) => {
        const d = lanes[i] ?? 1;
        if (d <= 0) return null;
        const tx = lerp(s.x, AL - 6, d);
        const ty = lerp(s.y, AG.y, d);
        return (
          <g key={i}>
            <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" />
            {d < 1 && <rect x={tx - 3} y={ty - 3} width={6} height={6} fill={c.cyan} />}
          </g>
        );
      })}
      <g opacity={wiring}>
      <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)"} />
      {hot && !ok && (
        <g>
          <rect x={mx - 10} y={AG.y - 10} width={20} height={20} fill={c.black} stroke={c.red} strokeWidth={1.5} />
          <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} />
        </g>
      )}
      <image href={GMAIL} x={DEST.x - DEST.size / 2} y={DEST.y - DEST.size / 2} width={DEST.size} height={DEST.size} />
      <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>
      </g>
      {draw < 1 ? (
        <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)} />
      ) : (
        <>
          <g stroke={f ? c.red : c.cyan} strokeWidth={2}>
            <line x1={AL} y1={AT} x2={AR} y2={AT} />
            <line x1={AL} y1={AB} x2={AR} y2={AB} />
          </g>
          <ADoor x={AL} open={inOpen} alert={f} />
          <ADoor x={AR} open={outOpen} alert={f} />
        </>
      )}
      {tag > 0 && (
        <g opacity={tag}>
          <rect x={AL + 1} y={AT + 1} width={AG.size * 0.4} height={AG.size * 0.1} fill={f ? c.red : c.cyan} />
          <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>
        </g>
      )}
      <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} />
      <image href={LOGOMARK} x={AG.x - 22} y={A_SEAL_Y - 22} width={44} height={44} opacity={seal} />
      {visible && call && (
        <g opacity={fade}>
          <rect
            x={(inGate ? cx : px) - (inGate ? w : A_PKT) / 2}
            y={(inGate ? cy : py) - (inGate ? h : A_PKT) / 2}
            width={inGate ? w : A_PKT}
            height={inGate ? h : A_PKT}
            fill={zoom > 0.05 ? c.black : f ? c.red : c.cyan}
            stroke={ink}
            strokeWidth={zoom > 0.05 ? 1.5 : 0}
          />
          {detail > 0 && (
            <g opacity={detail}>
              <image href={GMAIL} x={AG.x - A_ZW / 2 + 8} y={A_ZY - 13} width={26} height={26} />
              <g fontSize={9.5} style={{ fontFamily: MONO }}>
                <text x={AG.x - A_ZW / 2 + 42} y={call.attach ? A_ZY - 11 : A_ZY - 4} fill={ink}>gmail.send_email</text>
                <text x={AG.x - A_ZW / 2 + 42} y={call.attach ? A_ZY + 3 : A_ZY + 11} fill={ink} textDecoration={f ? "line-through" : undefined}>
                  to: {call.to}
                </text>
                {call.attach && (
                  <text x={AG.x - A_ZW / 2 + 42} y={A_ZY + 17} fill={ink} textDecoration={f ? "line-through" : undefined}>
                    + {call.attach}
                  </text>
                )}
              </g>
            </g>
          )}
          {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} />}
        </g>
      )}
      {f && (
        <text x={AG.x} y={AB + 26} textAnchor="middle" fill={c.red} fontSize={11} letterSpacing={2} style={{ fontFamily: MONO }}>
          BLOCKED · EXTERNAL DOMAIN + PII
        </text>
      )}
      {ok && (
        <text x={AG.x} y={AB + 26} textAnchor="middle" fill={c.green} fontSize={11} letterSpacing={2} style={{ fontFamily: MONO }}>
          ALLOWED · INTERNAL RECIPIENT
        </text>
      )}
    </svg>
  );
}

/* ---- 03-05 · one continuous world ---------------------------------------- */
/* Chapters 3, 4 and 5 are windows onto ONE scene driven by a single world
   clock `w` (seconds) and one camera, so the chapter cuts are invisible:
     0-5s   the SealGate agent works the rule (status line, scanned reel,
            a stack of the context sources it collects), resolves it into
            policy code, its avatar drops into the gate as the seal and the
            policy card docks as the gate's POLICY tag
     5-10s  the camera pushes into Claude and ChatGPT as the team prompts
     10-17s the camera pulls back and both tool calls run through the gate */

const POLICY_PROMPT = "Never let agents email customer data outside the company.";
const CORE = { x: AG.x, y: A_SEAL_Y };

type Pose = { s: number; x: number; y: number };
const P_NET: Pose = { s: 1.25, x: 480, y: 118 };
const P_WIDE: Pose = { s: 1, x: BOX_W / 2, y: BOX_H / 2 };
const P_CHAT: Pose = { s: 1.3, x: 326, y: 282 };
const P_CLAUDE: Pose = { s: 1.85, x: 176, y: 196 };
const P_GPT: Pose = { s: 1.85, x: 176, y: 376 };
const 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) });

/** A whip pan: slow out, very fast middle, slow in. */
const whip = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
/** Back-out spring for entrances (overshoots then settles). */
const spring = (t: number) => {
  const k = 1.7;
  const u = t - 1;
  return 1 + (k + 1) * u * u * u + k * u * u;
};

function camera(w: number): Pose {
  if (w < 3.4) return P_NET;
  if (w < 4.6) return mix(P_NET, P_WIDE, ease(seg(w, 3.4, 4.6)));
  if (w < 5.5) return mix(P_WIDE, P_CLAUDE, ease(seg(w, 4.6, 5.5)));
  if (w < 6.55) return P_CLAUDE;
  if (w < 6.95) return mix(P_CLAUDE, P_GPT, whip(seg(w, 6.55, 6.95)));
  if (w < 8.0) return P_GPT;
  if (w < 8.7) return mix(P_GPT, P_CHAT, ease(seg(w, 8.0, 8.7)));
  if (w < 9.4) return P_CHAT;
  return mix(P_CHAT, P_WIDE, ease(seg(w, 9.4, 10.5)));
}

type Rect = { x: number; y: number; w: number; h: number };
const 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) });
const WORK: Rect = { x: 270, y: 66, w: 420, h: 116 };
const CARD_TAG: Rect = { x: AL + 1, y: AT + 1, w: AG.size * 0.4, h: AG.size * 0.1 };
const ORB = { x: WORK.x + 24, y: WORK.y + 22, size: 20 };

/* ---- the agent at work (after the agent-thinking-chat pattern) ----------- */
/* Rebuilt on the film clock so it scrubs: a thinking avatar, a shimmering
   status line, a spinning-reel "scanned" counter and a Sonner-style stack of
   the context sources it pulls in (file reads and MCP tool calls). */

type Source = { kind: "file" | "mcp"; label: string; icon?: string; tag?: string };
const SOURCES: Source[] = [
  { kind: "file", label: "policies/data-handling.md" },
  { kind: "mcp", label: "gmail · list_labels", icon: GMAIL },
  { kind: "mcp", label: 'gdrive · search("customers")', icon: uri(tileSvg(byName("Google Drive"))) },
  { kind: "file", label: "customers.csv", tag: "pii" },
  { kind: "mcp", label: "slack · list_channels", icon: uri(tileSvg(byName("Slack"))) },
  { kind: "mcp", label: "sealgate · list_policies", icon: LOGOMARK },
];
const SOURCE_AT = (i: number) => 1.0 + i * 0.32;
const SCANNED = 214;

function statusLine(w: number) {
  if (w < 1.0) return "Thinking…";
  if (w < 1.9) return "Collecting context…";
  if (w < 2.6) return "Calling connected tools…";
  return "Writing policy…";
}

/** Shimmer without a gradient: a highlight sweeps across the characters. */
function Shimmer({ text, w }: { text: string; w: number }) {
  const sweep = ((w * 0.9) % 1) * (text.length + 8) - 4;
  return (
    <span style={{ fontFamily: MONO, fontSize: 12.5 }}>
      {[...text].map((ch, i) => {
        const d = Math.abs(i - sweep);
        return (
          <span key={i} style={{ color: d < 3 ? c.white : c.grey, opacity: d < 3 ? 1 : 0.75 }}>
            {ch}
          </span>
        );
      })}
    </span>
  );
}

/** Spinning-reel counter: each digit rolls continuously and blurs while fast. */
function Reel({ value, speed }: { value: number; speed: number }) {
  const cell = 18;
  const digits = String(SCANNED).length;
  return (
    <span style={{ display: "inline-flex", fontFamily: MONO, fontSize: 15, fontWeight: 500, color: c.cyan, height: cell, overflow: "hidden" }}>
      {Array.from({ length: digits }, (_, k) => {
        const place = 10 ** (digits - 1 - k);
        const roll = (value / place) % 10;
        return (
          <span key={k} style={{ display: "inline-block", width: 11, textAlign: "center", position: "relative", height: cell }}>
            <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 }}>
              {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map((n, j) => (
                <span key={j} style={{ display: "block", height: cell, lineHeight: `${cell}px` }}>{n}</span>
              ))}
            </span>
          </span>
        );
      })}
    </span>
  );
}

function SourceRow({ s, count }: { s: Source; count: number }) {
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 10, height: 36, padding: "0 12px", border: `1px solid ${c.line}`, background: c.panel }}>
      {s.kind === "file" ? <span style={{ width: 6, height: 6, background: c.cyan }} /> : <img src={s.icon} alt="" width={16} height={16} style={{ borderRadius: 3 }} />}
      <span style={{ fontFamily: MONO, fontSize: 12, color: c.white }}>{s.label}</span>
      {s.tag && <span style={{ fontFamily: MONO, fontSize: 10, color: c.black, background: c.cyan, padding: "1px 6px" }}>{s.tag}</span>}
      <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>
    </div>
  );
}

/** The working card: status + reel header over a stack of collected sources. */
function WorkCard({ w }: { w: number }) {
  const show = seg(w, 0.35, 0.65) * (1 - seg(w, 2.85, 3.1));
  if (show <= 0) return null;
  const e = ease(seg(w, 0.9, 2.7));
  const speed = w > 0.9 && w < 2.7 ? 6 * e * (1 - e) : 0;
  return (
    <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)` }}>
      <div style={{ position: "absolute", left: 0, top: 0, width: "40%", height: 3, background: c.cyan }} />
      <div style={{ display: "flex", alignItems: "center", height: 44, padding: "0 14px 0 46px" }}>
        <Shimmer text={statusLine(w)} w={w} />
        <span style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 8 }}>
          <Reel value={SCANNED * e} speed={speed} />
          <Label>scanned</Label>
        </span>
      </div>
      <div style={{ position: "relative", margin: "14px 12px 0", height: 48 }}>
        {SOURCES.map((s, i) => {
          const enter = ease(seg(w, SOURCE_AT(i), SOURCE_AT(i) + 0.2));
          if (enter <= 0) return null;
          // continuous depth: how far newer rows have pushed this one back
          const depth = SOURCES.reduce((d, _, j) => (j > i ? d + ease(seg(w, SOURCE_AT(j), SOURCE_AT(j) + 0.2)) : d), 0);
          if (depth > 2.5) return null;
          const y = (1 - enter) * 14 - depth * 7;
          const sc = 1 - depth * 0.05;
          const op = enter * clamp01(1 - depth * 0.35);
          return (
            <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 }}>
              <SourceRow s={s} count={i + 1} />
            </div>
          );
        })}
      </div>
    </div>
  );
}

/** The agent's avatar: the SealGate mark, orbited by a dashed ring while it
    works; when the card resolves it flies down to become the gate's seal. */
function AgentOrb({ w }: { w: number }) {
  if (w < 0.35 || w >= 4.05) return null;
  const fly = ease(seg(w, 3.1, 4.0));
  const x = lerp(ORB.x, CORE.x, fly);
  const y = lerp(ORB.y, CORE.y, fly);
  const size = lerp(ORB.size, 44, fly);
  const working = 1 - seg(w, 2.9, 3.2);
  return (
    <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 }}>
      <svg width={size + 16} height={size + 16} style={{ position: "absolute", left: -8, top: -8, transform: `rotate(${w * 140}deg)`, opacity: working }} fill="none">
        <circle cx={(size + 16) / 2} cy={(size + 16) / 2} r={size / 2 + 6} stroke={c.cyan} strokeWidth={1.2} strokeDasharray="3 5" />
      </svg>
      <img src={LOGOMARK} alt="" width={size} height={size} style={{ display: "block", transform: `scale(${1 + 0.06 * Math.sin(w * 9) * working})` }} />
    </div>
  );
}

/** The sent rule, as the user's message above the working card. It is already
    in place at w=0: the policy chapter flew it here. */
const RULE_AT = { x: WORK.x + WORK.w - BUBBLE_W, y: 18 };
function RuleBubble({ w }: { w: number }) {
  const e = 1 - seg(w, 2.9, 3.3);
  if (e <= 0) return null;
  return <Bubble style={{ left: RULE_AT.x, top: RULE_AT.y, opacity: e }} />;
}

/** The compiled policy: appears where the working card was, then docks as the gate tag. */
function PolicyCard({ w }: { w: number }) {
  if (w < 2.85 || w >= 4.4) return null;
  const dock = ease(seg(w, 3.6, 4.4));
  const r = mixRect({ ...WORK, h: 112 }, CARD_TAG, dock);
  const code = seg(w, 2.95, 3.15) * (1 - seg(w, 3.6, 3.85));
  const lines = Math.floor(seg(w, 3.0, 3.45) * 2 + 0.001);
  return (
    <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 }}>
      <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>
      <div style={{ position: "absolute", left: 12, right: 8, top: 30, fontFamily: MONO, fontSize: 11, lineHeight: "20px", opacity: code, whiteSpace: "pre" }}>
        <div style={{ color: c.grey }}>
          <span style={{ color: c.cyan }}>export default policy</span>([
        </div>
        <div style={{ opacity: lines > 0 ? 1 : 0, color: c.grey }}>
          {"  "}<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>)),
        </div>
        <div style={{ opacity: lines > 1 ? 1 : 0, color: c.grey }}>
          {"  "}<span style={{ color: "#f2b545" }}>quarantine</span>({"{ "}<span style={{ color: c.cyan }}>data: </span><span style={{ color: c.white }}>"pii"</span>{" }"}),
        </div>
        <div style={{ color: c.grey }}>])</div>
      </div>
    </div>
  );
}

/* ---- the prompting beat: typed in close-up, sent into the thread ------- */

const WIN = [
  { x: 40, y: 110, w: 252, h: 164 },
  { x: 40, y: 290, w: 252, h: 164 },
];
const CAPSULE = [
  { theme: CLAUDE, text: ASK_CLAUDE, type: [5.35, 6.1], send: 6.15, bg: "#ffffff", edge: "#e5e0d6", ink: "#2d2a26" },
  { theme: CHATGPT, text: ASK_GPT, type: [7.0, 7.7], send: 7.75, bg: "#303030", edge: "#4a4a4a", ink: "#ececec" },
];

/** The composer, blown up: the prompt types itself, then the pill flies up
    into the thread and becomes the user's message. */
function PromptCapsule({ i, w }: { i: number; w: number }) {
  const cfg = CAPSULE[i];
  const win = WIN[i];
  const appear = ease(seg(w, cfg.type[0] - 0.25, cfg.type[0]));
  const fly = ease(seg(w, cfg.send, cfg.send + 0.3));
  if (appear <= 0 || fly >= 1) return null;
  const typed = Math.floor(seg(w, cfg.type[0], cfg.type[1]) * cfg.text.length);
  const ready = typed >= cfg.text.length;
  const from = { x: win.x + win.w / 2, y: win.y + win.h - 26 };
  const to = { x: win.x + win.w - 58, y: win.y + 42 };
  const x = lerp(from.x, to.x, fly);
  const y = lerp(from.y, to.y, fly) - (1 - appear) * 6;
  const sc = lerp(1, 0.55, fly);
  return (
    <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 }}>
      <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" }}>
        <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` }} />
        <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" }}>
          <bdi>
            {cfg.text.slice(0, typed)}
            {!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 }} />}
          </bdi>
        </span>
        <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})` }}>
          <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">
            <path d="M12 19V5M5 12l7-7 7 7" />
          </svg>
        </span>
      </div>
    </div>
  );
}

/** A cyan flash around a window the moment its tool call fires. */
function Flash({ i, at: t0, w }: { i: number; at: number; w: number }) {
  const u = seg(w, t0, t0 + 0.55);
  if (u <= 0 || u >= 1) return null;
  const win = WIN[i];
  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 }} />;
}

/** How fast the camera is moving, in screen px per frame-ish, for motion blur. */
function camBlur(w: number) {
  if (w < 6.55 || w > 6.95) return 0; // only the whip pan smears
  const a = camera(w);
  const b = camera(w + 0.01);
  const px = Math.hypot((b.x - a.x) * a.s, (b.y - a.y) * a.s) + Math.abs(b.s - a.s) * 300;
  return Math.min(5, Math.max(0, (px - 2.5) * 0.45));
}

function World({ w }: { w: number }) {
  const cam = camera(w);
  const call1 = { t: seg(w, 11.0, 13.0), src: 0, blocked: false, to: "leadership@acme.com" };
  const call2 = {
    t: w < 15.2 ? lerp(0, ST.scanEnd, seg(w, 13.2, 15.2)) : lerp(ST.scanEnd, 0.8, seg(w, 15.2, 17)),
    src: 1,
    blocked: true,
    to: "me@gmail.com",
    attach: "customers.csv",
  };
  const call: Call | undefined = w < 11 ? undefined : w < 13.1 ? call1 : w >= 13.2 ? call2 : undefined;
  const done = [w > 13, w > 15.2];
  const cStage = (w > 6.4 ? 1 : 0) + (w > 8.5 ? 1 : 0) + (done[0] ? 1 : 0);
  const gStage = (w > 8.0 ? 1 : 0) + (w > 8.8 ? 1 : 0) + (w > 16.3 ? 1 : 0);
  const enter = [seg(w, 4.6, 5.3), seg(w, 4.75, 5.45)];
  const winStyle = (i: number): CSSProperties => {
    const e = enter[i];
    const sp = e > 0 ? spring(e) : 0;
    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%" };
  };
  const blur = camBlur(w);
  const netLabel = seg(w, 0.2, 0.6) * (1 - seg(w, 4.0, 4.5));
  const chatLabel = seg(w, 5.3, 5.7) * (1 - seg(w, 8.0, 8.3));
  const gateHead = seg(w, 10.3, 10.9);
  return (
    <Scene>
      <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 }}>
        <div style={winStyle(0)}>
          <Window theme={CLAUDE} turns={claudeTurns(cStage)} width={420} scale={0.6} body={170} />
        </div>
        <div style={winStyle(1)}>
          <Window theme={CHATGPT} turns={gptTurns(gStage)} width={420} scale={0.6} body={170} />
        </div>
        <Flash i={0} at={8.5} w={w} />
        <Flash i={1} at={8.8} w={w} />
        <PromptCapsule i={0} w={w} />
        <PromptCapsule i={1} w={w} />
        <Airlock
          call={call}
          done={done}
          draw={w < 3.2 ? 0 : seg(w, 3.2, 4.2)}
          tag={w >= 4.4 ? 1 : 0}
          wiring={seg(w, 10.0, 10.8)}
          seal={seg(w, 3.98, 4.05)}
          lanes={[ease(seg(w, 8.55, 9.5)), ease(seg(w, 8.85, 9.8))]}
        />
        <RuleBubble w={w} />
        <WorkCard w={w} />
        <PolicyCard w={w} />
        <AgentOrb w={w} />
      </div>
      <div style={{ position: "absolute", left: 40, top: 24, opacity: netLabel }}>
        <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
          <Wordmark h={22} />
        </div>
      </div>
      <div style={{ position: "absolute", left: 30, top: 170, width: 165, opacity: chatLabel, transform: `translateX(${(1 - chatLabel) * -12}px)` }}>
        <Label color={c.cyan}>Intent-based prompting</Label>
        <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>
      </div>
      <div style={{ position: "absolute", left: 40, top: 26, opacity: gateHead }}>
        <div style={{ fontFamily: SANS, fontWeight: 500, fontSize: 28, color: c.white, letterSpacing: -0.6 }}>Intent-based access control</div>
      </div>
    </Scene>
  );
}

const SceneNetwork = ({ p }: { p: number }) => <World w={p * 5} />;
const SceneAsk = ({ p }: { p: number }) => <World w={5 + p * 5} />;
const SceneGate = ({ p }: { p: number }) => <World w={10 + p * 7} />;

/* ---- 06 · Integrations ---------------------------------------------------- */

/* A tilted two-ring orbit around SealGate: agents on the outer ring, your data
   on the inner one, counter-rotating. Icons fly in from off-frame onto their
   ring, depth comes from the ring tilt (front = larger + brighter), cyan pulses
   run agent -> SealGate -> data along the spokes, and a spotlight walks through
   a few marks, lifting each one with its name. */
const ORBIT = { x: BOX_W / 2, y: 288 };
const RINGS = {
  agent: { rx: 372, ry: 142, spin: 0.9 },
  data: { rx: 214, ry: 80, spin: -1.3, phase: -Math.PI / 2 + 0.3 },
};
const SPOTLIGHT = ["Claude Desktop", "ChatGPT", "Gmail", "Codex CLI", "Cursor", "Grokbot", "Muse"];
/** Agents held on the front arc of the ring for the whole spin, in order of
    increasing angle (right to left along the bottom of the ellipse). */
const FRONT = ["Cursor", "Codex CLI", "Claude Desktop", "ChatGPT", "Grokbot", "Muse"];
const FRONT_STEP = 0.28;

/** Agent-ring angle at p=0: FRONT spaced on an arc that is centred on the front
    (angle pi/2) at mid-scene, the rest spread evenly over the back. */
function agentAngle(name: string, rest: string[]) {
  const mid = Math.PI / 2 - RINGS.agent.spin / 2;
  const f = FRONT.indexOf(name);
  if (f >= 0) return mid + (f - (FRONT.length - 1) / 2) * FRONT_STEP;
  const half = ((FRONT.length - 1) / 2) * FRONT_STEP + FRONT_STEP;
  const span = Math.PI * 2 - half * 2;
  return mid + half + ((rest.indexOf(name) + 0.5) / rest.length) * span;
}

function SceneIntegrations({ p }: { p: number }) {
  const agents = INTEGRATIONS.filter((i) => i.group === "agent");
  const data = INTEGRATIONS.filter((i) => i.group === "data");
  const spotIdx = p > 0.36 ? Math.floor((p - 0.36) / 0.09) : -1;
  const spot = spotIdx >= 0 && spotIdx < SPOTLIGHT.length ? SPOTLIGHT[spotIdx] : "";
  const spotE = spot ? Math.sin(clamp01(((p - 0.36) % 0.09) / 0.09) * Math.PI) : 0;

  const rest = agents.filter((it) => !FRONT.includes(it.name)).map((it) => it.name);
  const items = [
    ...agents.map((it, i) => ({ it, ring: RINGS.agent, a0: agentAngle(it.name, rest), k: i, dir: -1 })),
    ...data.map((it, i) => ({ it, ring: RINGS.data, a0: RINGS.data.phase + (i / data.length) * Math.PI * 2, k: agents.length + i, dir: 1 })),
  ].map(({ it, ring, a0, k, dir }) => {
    const a = a0 + p * ring.spin;
    const e = at(p, 0.02 + k * 0.012, 0.18);
    const depth = (Math.sin(a) + 1) / 2; // 0 back .. 1 front
    const far = 2.3 - e * 1.3; // fly in from outside the ring
    const x = ORBIT.x + Math.cos(a) * ring.rx * far;
    const y = ORBIT.y + Math.sin(a) * ring.ry * far;
    const lift = it.name === spot ? spotE : 0;
    return { it, x, y, e, depth, lift, dir, k, size: 30 + depth * 14 + lift * 16 };
  });
  const flow = seg(p, 0.3, 0.42);

  return (
    <Scene>
      <div style={{ position: "absolute", left: 60, top: 28, right: 60, display: "flex", alignItems: "flex-end", zIndex: 5 }}>
        <Heading p={p} title="Any agent, Any integration" />
      </div>
      <svg width={BOX_W} height={BOX_H} style={{ position: "absolute", left: 0, top: 0 }} fill="none">
        {(["agent", "data"] as const).map((r) => (
          <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)} />
        ))}
        {items.map((d) => {
          // pulses: agents stream in to SealGate, SealGate streams out to data
          const u = (p * 2.4 + d.k * 0.29) % 1;
          const from = d.dir < 0 ? { x: d.x, y: d.y } : ORBIT;
          const to = d.dir < 0 ? ORBIT : { x: d.x, y: d.y };
          return (
            <g key={d.it.name} opacity={d.e}>
              <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} />
              {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)} />}
            </g>
          );
        })}
      </svg>
      {/* SealGate at the hub */}
      <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) }}>
        <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)) }} />
        <img src={LOGOMARK} alt="SealGate" width={56} height={56} style={{ display: "block" }} />
      </div>
      {[...items]
        .sort((a, b) => a.depth + a.lift - (b.depth + b.lift))
        .map((d) => (
          <div
            key={d.it.name}
            style={{
              position: "absolute",
              left: d.x - d.size / 2,
              top: d.y - d.size / 2,
              zIndex: d.depth > 0.5 || d.lift > 0 ? 3 : 1,
              opacity: d.e * (0.45 + d.depth * 0.55 + d.lift * 0.45),
              filter: d.lift > 0.2 ? undefined : `brightness(${0.7 + d.depth * 0.3})`,
            }}
          >
            <Tile it={d.it} size={d.size} />
            {d.lift > 0.05 && (
              <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 }}>
                {d.it.name}
              </div>
            )}
          </div>
        ))}
    </Scene>
  );
}

/** SealGate's Chrome extension: a Chrome window strip with the SealGate icon
    pinned in the toolbar and its popover open over a ChatGPT tab. */
function ChromeExtension({ popover }: { popover: number }) {
  const ui = { tabs: "#1b1c1f", bar: "#35363a", omni: "#202124", text: "#e8eaed", sub: "#9aa0a6", edge: "#3c4043" };
  return (
    <div style={{ width: 186 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10 }}>
        <img src={CHROME} alt="Chrome" width={20} height={20} />
        <Label>Chrome extension</Label>
      </div>
      <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)" }}>
        <div style={{ height: 28, background: ui.tabs, display: "flex", alignItems: "flex-end", padding: "0 8px" }}>
          <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 }}>
            <img src={CHATGPT_TILE} alt="" width={12} height={12} style={{ borderRadius: 3 }} />
            ChatGPT
          </div>
        </div>
        <div style={{ height: 36, background: ui.bar, display: "flex", alignItems: "center", gap: 8, padding: "0 8px" }}>
          <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>
          <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})` }}>
            <img src={LOGOMARK} alt="SealGate" width={16} height={16} />
          </div>
        </div>
        <div style={{ height: popover * 66, overflow: "hidden" }}>
          <div style={{ padding: "12px 12px", display: "flex", alignItems: "center", gap: 10, borderTop: `1px solid ${ui.edge}` }}>
            <img src={LOGOMARK} alt="" width={24} height={24} />
            <div>
              <Wordmark h={11} style={{ display: "block" }} />
              <div style={{ fontFamily: MONO, fontSize: 10, color: c.cyan, marginTop: 6 }}>protecting · 2 rules</div>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

/* ---- 07 · Any device ------------------------------------------------------ */

function SceneDevices({ p }: { p: number }) {
  const phone = at(p, 0.05, 0.18);
  const mac = at(p, 0.15, 0.18);
  const phoneTurns = [
    { role: "user" as const, text: ASK_GPT },
    ...(p > 0.45
      ? [{ role: "assistant" as const, text: "SealGate blocked this. Sending customer data outside the company isn't allowed by your organisation's policy." }]
      : [{ role: "assistant" as const, text: "", typing: true }]),
  ];
  const macTurns = [
    { role: "user" as const, text: ASK_CLAUDE },
    ...(p > 0.6
      ? [{ role: "assistant" as const, text: "Sent to **leadership@acme.com**. SealGate checked the email against company policy and logged the action to your account." }]
      : [{ role: "assistant" as const, text: "", typing: true }]),
  ];
  return (
    <Scene>
      <div style={{ position: "absolute", left: 0, right: 0, top: 28 }}>
        <Heading p={p} label="Secure AI on any device" title="Secure agents on any device, anywhere." center />
      </div>
      <Rise e={phone} y={24} style={{ position: "absolute", left: 36, top: 102 }}>
        <IPhoneMock width={166} titanium="black">
          <ChatGPTScreen scheme="dark" conversation={phoneTurns} />
        </IPhoneMock>
      </Rise>
      <Rise e={mac} y={24} style={{ position: "absolute", left: 218, top: 150 }}>
        <MacBookMock width={440} chassis="spaceblack">
          <ClaudeScreen scheme="dark" conversation={macTurns} />
        </MacBookMock>
      </Rise>
      <Rise e={at(p, 0.3, 0.18)} y={24} style={{ position: "absolute", left: 670, top: 186, zIndex: 2 }}>
        <ChromeExtension popover={at(p, 0.48, 0.14)} />
      </Rise>
    </Scene>
  );
}

/* ---- 08 · Start free ------------------------------------------------------ */

function SceneEnd({ p }: { p: number }) {
  return (
    <Scene>
      <div style={{ position: "absolute", left: 0, right: 0, top: 104, display: "flex", flexDirection: "column", alignItems: "center" }}>
        <Rise e={at(p, 0, 0.2)}>
          <img src={WORDMARK} alt="SealGate" style={{ height: 52, display: "block" }} />
        </Rise>
        <Rise e={at(p, 0.15, 0.2)}>
          <div style={{ fontFamily: SANS, fontSize: 32, fontWeight: 500, color: c.white, letterSpacing: -0.8, marginTop: 40 }}>
            Secure your agents without slowing them down.
          </div>
        </Rise>
        <Rise e={at(p, 0.3, 0.2)} style={{ display: "flex", justifyContent: "center", gap: 12, marginTop: 34 }}>
          <div style={{ background: c.cyan, color: c.black, fontFamily: SANS, fontSize: 15, fontWeight: 500, padding: "12px 22px", borderRadius: 2 }}>Start free</div>
          <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>
        </Rise>
        <Rise e={at(p, 0.42, 0.2)}>
          <div style={{ marginTop: 26 }}>
            <Label>sealgate.ai</Label>
          </div>
        </Rise>
      </div>
    </Scene>
  );
}

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

export interface Chapter {
  id: string;
  label: ReactNode;
  duration: number;
  Scene: (props: { p: number }) => ReactNode;
}

export const SEALGATE_CHAPTERS: Chapter[] = [
  { id: "hook", label: <Wordmark h={11} />, duration: 3, Scene: SceneHook },
  { id: "policy", label: "Write a policy", duration: 5, Scene: ScenePolicy },
  { id: "agent", label: "Agent at work", duration: 5, Scene: SceneNetwork },
  { id: "intent", label: "Ask in any AI", duration: 5, Scene: SceneAsk },
  { id: "airlock", label: "Airlock", duration: 7, Scene: SceneGate },
  { id: "integrations", label: "Integrations", duration: 5, Scene: SceneIntegrations },
  { id: "devices", label: "Any device", duration: 5, Scene: SceneDevices },
  { id: "end", label: "Start", duration: 3, Scene: SceneEnd },
]; // 38s

const FADE = 0.35; // seconds of true cross-fade between chapters

export default function SealGateFilm({ width = STAGE_W, chapters = SEALGATE_CHAPTERS, autoPlay = true, loop = true }: { width?: number; chapters?: Chapter[]; autoPlay?: boolean; loop?: boolean }) {
  const total = chapters.reduce((s, ch) => s + ch.duration, 0);
  const scale = width / CANVAS_W;
  const [time, setTime] = useState(0);
  const [playing, setPlaying] = useState(autoPlay);
  const last = useRef<number>();
  const trackRef = useRef<HTMLDivElement>(null);
  const dragging = useRef(false);

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

  const starts: number[] = [];
  chapters.reduce((acc, ch) => (starts.push(acc), acc + ch.duration), 0);
  let active = chapters.length - 1;
  for (let i = 0; i < chapters.length; i++) if (time < starts[i] + chapters[i].duration) { active = i; break; }
  const local = time - starts[active];
  const progress = clamp01(local / chapters[active].duration);
  const prev = active > 0 && local < FADE ? active - 1 : -1;
  const fadeIn = prev >= 0 ? local / FADE : 1;
  const playhead = clamp01(time / total);

  const seek = useCallback((clientX: number) => {
    const el = trackRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    setTime(clamp01((clientX - r.left) / r.width) * total * 0.9999);
  }, [total]);

  const ActiveScene = chapters[active].Scene;
  const PrevScene = prev >= 0 ? chapters[prev].Scene : null;

  return (
    <div style={{ width: CANVAS_W * scale, height: CANVAS_H * scale, fontFamily: SANS }}>
      <style>{FONTS}</style>
      <div style={{ width: CANVAS_W, height: CANVAS_H, transform: `scale(${scale})`, transformOrigin: "top left", position: "relative" }}>
        <div style={{ position: "absolute", left: 0, top: 0, width: STAGE_W, height: STAGE_H, background: c.black, border: `1px solid ${c.line}`, overflow: "hidden" }}>
          <div style={{ position: "absolute", inset: INSET - 1 }}>
            {PrevScene && (
              <div style={{ position: "absolute", inset: 0 }}>
                <PrevScene p={1} />
              </div>
            )}
            <div style={{ position: "absolute", inset: 0, opacity: fadeIn }}>
              <ActiveScene p={progress} />
            </div>
          </div>
          <button
            type="button"
            aria-label={playing ? "Pause" : "Play"}
            onClick={() => {
              if (!loop && time >= total) setTime(0);
              setPlaying((v) => !v);
            }}
            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 }}
          >
            {playing ? "❚❚" : "▶"}
          </button>
        </div>

        <div style={{ position: "absolute", left: 0, top: STAGE_H + GAP, width: STAGE_W, height: TRACK_H }}>
          <div style={{ position: "relative", height: 26 }}>
            <div style={{ position: "absolute", top: 12, left: 0, right: 0, height: 1, background: c.line2 }} />
            <div style={{ position: "absolute", top: 11, left: 0, width: `${playhead * 100}%`, height: 3, background: c.cyan }} />
            <div style={{ position: "absolute", top: 0, left: `${playhead * 100}%`, transform: "translateX(-50%)", display: "flex", flexDirection: "column", alignItems: "center" }}>
              <div style={{ fontFamily: MONO, fontSize: 11, color: c.black, background: c.cyan, padding: "3px 7px", lineHeight: 1 }}>{fmt(time)}</div>
              <div style={{ width: 2, height: 10, background: c.cyan }} />
            </div>
          </div>
          <div
            ref={trackRef}
            role="slider"
            tabIndex={0}
            aria-label="Film position"
            aria-valuemin={0}
            aria-valuemax={total}
            aria-valuenow={Math.round(time)}
            onKeyDown={(e) => {
              if (e.key === "ArrowRight") setTime((t) => Math.min(total * 0.9999, t + 1));
              if (e.key === "ArrowLeft") setTime((t) => Math.max(0, t - 1));
              if (e.key === " ") { e.preventDefault(); setPlaying((v) => !v); }
            }}
            onPointerDown={(e) => { dragging.current = true; (e.target as Element).setPointerCapture?.(e.pointerId); seek(e.clientX); }}
            onPointerMove={(e) => dragging.current && seek(e.clientX)}
            onPointerUp={() => { dragging.current = false; }}
            style={{ display: "flex", gap: 6, marginTop: 6, height: TRACK_H - 32, cursor: "pointer", touchAction: "none", outline: "none" }}
          >
            {chapters.map((ch, i) => (
              <div
                key={ch.id}
                style={{
                  flex: ch.duration,
                  position: "relative",
                  border: `1px solid ${i === active ? c.cyan : c.line}`,
                  background: c.black,
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                }}
              >
                {i === active && <div style={{ position: "absolute", left: -1, top: -1, width: "40%", height: 6, background: c.cyan }} />}
                <span style={{ fontFamily: MONO, fontSize: 12, letterSpacing: 0.5, color: i === active ? c.white : c.grey }}>{ch.label}</span>
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
}
```

## Related

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