You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@magpie.apache.org by po...@apache.org on 2026/09/13 18:12:56 UTC

(magpie-site) 02/04: feat(landing): replace the install demo with an animated security story

This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch docs/navigation-overhaul
in repository https://gitbox.apache.org/repos/asf/magpie-site.git

commit 675d35c252c1e2110dc2401b3d2986f57ed03f2b
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Sun Sep 13 20:02:44 2026 +0200

    feat(landing): replace the install demo with an animated security story
    
    The landing page's install demo showed the mechanics of adding the
    marketplace and little else. Replace it with a scripted, autoplaying
    story that follows one CVE end to end: adopt the security family, set
    the sandbox and privacy layers, triage a report, reply to the reporter,
    move the issue across the tracker board, open the fix, publish the
    advisory.
    
    The demo is built from a declarative script (`script.ts`) played back by
    a timeline hook, with one component per scene — terminal, mail, board,
    interlude, vulnogram, published — plus a genie badge for the moments the
    agent acts on its own. Playback has play/pause/replay controls and links
    through to the security docs.
    
    Generated-by: Claude Opus 5
    Claude-Session: https://claude.ai/code/session_01C4rtzQ7PiuAjxrPcXCW2eh
---
 src/components/landing/ImmersiveGradientHero.tsx   |   4 +-
 src/components/landing/InstallDemo.README.md       | 310 ----------
 src/components/landing/InstallDemo.tsx             | 263 --------
 src/components/landing/security-story/Genie.tsx    | 112 ++++
 .../landing/security-story/SecurityStoryDemo.tsx   | 235 +++++++
 .../landing/security-story/scenes/BoardScene.tsx   | 125 ++++
 .../security-story/scenes/InterludeScene.tsx       |  81 +++
 .../landing/security-story/scenes/MailScene.tsx    |  90 +++
 .../security-story/scenes/PublishedScene.tsx       |  82 +++
 .../security-story/scenes/TerminalScene.tsx        |  87 +++
 .../security-story/scenes/VulnogramScene.tsx       |  93 +++
 src/components/landing/security-story/script.ts    | 677 +++++++++++++++++++++
 src/components/landing/security-story/timeline.ts  |  74 +++
 .../landing/security-story/useTimeline.ts          | 125 ++++
 14 files changed, 1783 insertions(+), 575 deletions(-)

diff --git a/src/components/landing/ImmersiveGradientHero.tsx b/src/components/landing/ImmersiveGradientHero.tsx
index 08326b6..f409ecc 100644
--- a/src/components/landing/ImmersiveGradientHero.tsx
+++ b/src/components/landing/ImmersiveGradientHero.tsx
@@ -10,7 +10,7 @@ import { ShimmerButton } from "@/ui/components/ui/shimmer-button";
 import { BlurFade } from "@/ui/components/ui/blur-fade";
 import { withBase } from "@/ui/lib/utils";
 import { SiteFooter } from "./SiteFooter";
-import { InstallDemo } from "./InstallDemo";
+import { SecurityStoryDemo } from "./security-story/SecurityStoryDemo";
 import skillCounts from "@/data/skill-counts.json";
 import toolsData from "@/data/tools.json";
 import community from "@/data/community.json";
@@ -904,7 +904,7 @@ function ImmersiveGradientHero() {
         </div>
       </div>
       <div className="flex h-24 w-full flex-none items-start bg-gradient-to-b from-brand-600 to-brand-50" />
-      <InstallDemo />
+      <SecurityStoryDemo />
       <div id="why-magpie" className="flex w-full flex-col items-center bg-default-background px-8 pt-20 pb-16 mobile:px-4 mobile:pt-12">
         <BlurFade inView className="flex flex-col items-center gap-4 max-w-[660px] pb-12 mobile:pb-8">
           <span className="font-['Inter'] text-[38px] font-[700] leading-[44px] text-default-font text-center -tracking-[0.035em] mobile:font-['Jost'] mobile:text-[28px] mobile:font-[400] mobile:leading-[34px] mobile:tracking-normal">
diff --git a/src/components/landing/InstallDemo.README.md b/src/components/landing/InstallDemo.README.md
deleted file mode 100644
index 3bb22ca..0000000
--- a/src/components/landing/InstallDemo.README.md
+++ /dev/null
@@ -1,310 +0,0 @@
-<!--
- Licensed to the Apache Software Foundation (ASF) under one
- or more contributor license agreements.  See the NOTICE file
- distributed with this work for additional information
- regarding copyright ownership.  The ASF licenses this file
- to you under the Apache License, Version 2.0 (the
- "License"); you may not use this file except in compliance
- with the License.  You may obtain a copy of the License at
-
-   http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing,
- software distributed under the License is distributed on an
- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- KIND, either express or implied.  See the License for the
- specific language governing permissions and limitations
- under the License.
--->
-
-# Recording the landing-page install demo
-
-The landing page reserves a 16:9 frame in the **See it in action** band
-(`InstallDemo.tsx`, section `#see-it-in-action`) for a recording that shows
-Magpie being installed **from the `apache/magpie` marketplace** and then used
-across skill families. Until that recording exists the frame renders a
-placeholder.
-
-This page is the shooting script and the production settings. Follow it end to
-end and the result drops into the page with a five-line edit.
-
-- [What the animation has to show](#what-the-animation-has-to-show)
-- [Script](#script)
-- [Recording setup](#recording-setup)
-- [Producing the asset](#producing-the-asset)
-- [Accessibility — non-negotiable](#accessibility--non-negotiable)
-- [Wiring it into the page](#wiring-it-into-the-page)
-- [Review checklist](#review-checklist)
-- [Upstream sources](#upstream-sources)
-
-## What the animation has to show
-
-Three beats, in this order. They are also the three cards rendered under the
-frame in `InstallDemo.tsx` (`STORYBOARD`) — **if you change the recording,
-change those cards to match.**
-
-| # | Beat | The point a viewer should take away |
-|---|---|---|
-| 01 | Add the marketplace | One command inside the agent they already run. `apache/magpie` *is* the marketplace — no vendor directory, no account, nothing written into their project. |
-| 02 | Install only the families you need | One plugin per family. You take `magpie-pr-management` without taking `magpie-security`, and every family you add costs context in every session. |
-| 03 | Run a skill | Every skill is called as `/<plugin>:<skill>` — the family plugin is the prefix, so what you can call is exactly what you installed. The agent proposes; the human confirms before anything leaves the machine. |
-
-Beat 03 is the one that sells the project — budget the most screen time for it
-and make sure the confirmation prompt is legible.
-
-Two things that must be unmistakable on screen, because they are the whole
-argument for the marketplace path over the snapshot install:
-
-1. **Nothing is committed.** The repository is untouched — no lock file, no
-   config, no diff. If you can show a clean `git status` at the end without
-   padding the runtime, do.
-2. **Families are opt-in and separate.** The plugin list after beat 02 should
-   show the two or three plugins you chose, *not* all ten and *not* the
-   all-in-one `magpie` plugin.
-
-### Scope and length
-
-- **Target 75–110 seconds.** The install itself is two commands; the time
-  belongs to beat 03.
-- One continuous session in one agent, in one terminal. No cuts between
-  windows, no editor, no browser.
-- Record in **Claude Code**, because per-family plugins are Claude Code-only
-  today — the recommended install is the one worth showing. The all-in-one
-  plugin on Codex / VS Code / Gemini is covered by the caption under the
-  storyboard cards; it does not need its own footage.
-- Demo against a **throwaway repository you own** — see
-  [Recording setup](#recording-setup).
-
-## Script
-
-Type these in this order. Bracketed notes are direction, not things to type.
-
-```text
-# ── 01 · Add the marketplace ────────────────────────────────────────────────
-$ cd ~/demo/my-project
-$ claude
-> /plugin marketplace add apache/magpie
-  [ hold ~2 s on the confirmation that the marketplace was added ]
-
-# ── 02 · Install only the families you need ─────────────────────────────────
-> /plugin install magpie-setup@apache-magpie
-> /plugin install magpie-pr-management@apache-magpie
-> /plugin
-  [ hold ~3 s on the plugin list: exactly these two, installed and enabled ]
-  [ do NOT scroll through the other eight — the point is that you skipped them ]
-
-# ── 03 · Run a skill ────────────────────────────────────────────────────────
-> /magpie-pr-management:pr-management-triage
-  [ note the prefix on screen: the plugin from beat 02, then the skill ]
-  [ the agent reads the queue and PROPOSES labels / assignees ]
-  [ hold on the confirmation prompt — this is the money shot ]
-  [ answer, show the applied result ]
-> /magpie-utilities:list-skills
-  [ OPTIONAL, only if it fits the runtime: the live index of what is installed,
-    every entry in <plugin>:<skill> form — install magpie-utilities in beat 02
-    if you want this closer ]
-> exit
-$ git status          [ optional closer: "nothing to commit, working tree clean" ]
-```
-
-`magpie-setup` is the family to always install — it carries the
-secure-isolation skills, `/magpie-setup:setup-isolated-setup-install` among
-them — so keep it in beat 02 even though the skill you run in beat 03 comes
-from another family. That pairing *is* the message: two families,
-independently chosen.
-
-If a different family reads better for your project, swap the one in beats 02
-and 03 — but keep the shape (marketplace → pick families → run one), keep the
-`<plugin>:<skill>` form on screen, and keep the `STORYBOARD` cards in step with
-whatever you record. Real invocations, one per family:
-
-| Install | Then run |
-|---|---|
-| `magpie-setup@apache-magpie` | `/magpie-setup:setup-isolated-setup-install` |
-| `magpie-pr-management@apache-magpie` | `/magpie-pr-management:pr-management-code-review` |
-| `magpie-issue@apache-magpie` | `/magpie-issue:issue-triage` |
-| `magpie-release-management@apache-magpie` | `/magpie-release-management:release-rc-cut` |
-| `magpie-repo-health@apache-magpie` | `/magpie-repo-health:dependency-audit` |
-| `magpie-utilities@apache-magpie` | `/magpie-utilities:list-skills` |
-
-These are the same six rendered on the page (`ACROSS_FAMILIES` in
-`InstallDemo.tsx`, which also lists `magpie-security`). Skill names come from
-`skills/` in `apache/magpie` — check there before adding one.
-
-Note the two different separators, and do not mix them up: `@` selects the
-marketplace when **installing** a plugin, `:` selects the plugin when
-**running** a skill.
-
-### Things to avoid on camera
-
-- **Do not demo the `security` family.** Those skills operate on private
-  security trackers and pre-disclosure reports.
-- **Do not demo the all-in-one `magpie` plugin.** It installs all 74 skills and
-  ~21.7k always-on tokens per session; the page argues against it as a default.
-- Real credentials, tokens, `gh auth` output, `~/.ssh` paths, private
-  hostnames — check the terminal title bar and the status line, not just the
-  body.
-- Any private tracker, embargoed advisory, or non-public issue content.
-- A repo you do not own — the recording is published under the ASF's name.
-- Long silent stretches. Trim them with `--speed` at render time, not by
-  hand-editing the cast.
-
-## Recording setup
-
-Use a purpose-built demo shell, not your daily one.
-
-```bash
-# A clean, reproducible prompt and environment
-env -i HOME="$HOME" TERM=xterm-256color PS1='$ ' bash --norc --noprofile
-```
-
-| Setting | Value | Why |
-|---|---|---|
-| Terminal size | **100 × 28** columns × rows | Fills 16:9 without letterboxing; wider wraps badly on mobile. |
-| Font | JetBrains Mono / Fira Code, 14 pt | Legible when the frame scales to ~340 px on phones. |
-| Theme | Dark, high contrast (the player background is `#0f1117`) | Matches the frame; light themes glare against the brand-50 band. |
-| Prompt | Bare `$` | A personal prompt leaks hostname and paths, and dates the recording. |
-| Typing | Real typing, unhurried | Instant-paste reads as fake; frantic typing is unreadable. |
-
-Claude Code redraws a full-screen TUI, so record the raw session with
-[asciinema](https://asciinema.org) — a text cast, reviewable in a PR and
-re-renderable later:
-
-```bash
-asciinema rec --cols 100 --rows 28 --idle-time-limit 2 magpie-marketplace-install.cast
-```
-
-`--idle-time-limit 2` caps every pause at two seconds, which removes most of
-the dead air while the agent thinks. Watch the rendered output once before
-publishing: a TUI that repaints heavily can produce a jumpy cast, and that is
-easier to fix by re-recording than by post-processing.
-
-## Producing the asset
-
-Two supported outputs. **Prefer the animated SVG** — it is text, it diffs, it
-stays sharp at any size, and it needs no video pipeline.
-
-### Option A — animated SVG (preferred)
-
-```bash
-# https://github.com/asciinema/agg
-agg --font-family "JetBrains Mono" \
-    --font-size 14 \
-    --theme asciinema \
-    --speed 1.4 \
-    --idle-time-limit 2 \
-    magpie-marketplace-install.cast \
-    magpie-marketplace-install.svg
-```
-
-Check the result is **under 2 MB**. If it is not, shorten the session — do not
-drop the frame rate; the text goes muddy. A TUI-heavy cast can blow past 2 MB
-easily, which is the usual reason to fall back to Option B.
-
-### Option B — video (when the SVG is too large)
-
-```bash
-agg --speed 1.4 magpie-marketplace-install.cast magpie-marketplace-install.gif
-ffmpeg -i magpie-marketplace-install.gif -c:v libvpx-vp9 -crf 34 -b:v 0 -an \
-       magpie-marketplace-install.webm
-ffmpeg -i magpie-marketplace-install.webm -frames:v 1 \
-       magpie-marketplace-install-poster.png
-```
-
-Ship the `.webm` plus the poster. There is no audio track and the player is
-muted, so never add a soundtrack — the page autoplays it.
-
-### Where the files go
-
-```text
-public/demo/magpie-marketplace-install.svg     # or .webm + -poster.png
-public/demo/magpie-marketplace-install.txt     # transcript (see below)
-```
-
-Keep the raw `.cast` in the PR description or attached to the issue — it is the
-source, but it does not need to ship in `public/`.
-
-## Accessibility — non-negotiable
-
-The animation autoplays and loops, so it must not be the only way to get the
-information:
-
-1. **Transcript.** Write the session out as plain text to
-   `public/demo/magpie-marketplace-install.txt` — the commands and the key
-   parts of the output. `asciinema cat <cast> > <txt>` gets you a first draft;
-   hand-trim the control sequences, which a TUI recording will be full of. The
-   player links it, and `DemoAsset.transcript` is a required field precisely so
-   this cannot be skipped.
-2. **The storyboard cards stay.** They render in both states and carry the same
-   three beats in text. Do not remove them once the recording lands.
-3. **No flashing.** Nothing should flash more than three times per second —
-   watch for spinner-heavy agent output and repeated full-screen repaints.
-4. **Contrast.** Terminal foreground against `#0f1117` at 4.5:1 or better.
-
-## Wiring it into the page
-
-Edit the `DEMO` constant near the top of
-[`InstallDemo.tsx`](./InstallDemo.tsx) — that is the whole change:
-
-```tsx
-const DEMO: DemoAsset | null = {
-  kind: "svg",
-  src: "/demo/magpie-marketplace-install.svg",
-  transcript: "/demo/magpie-marketplace-install.txt",
-  durationLabel: "1 min 20 s",
-};
-```
-
-For the video variant:
-
-```tsx
-const DEMO: DemoAsset | null = {
-  kind: "video",
-  src: "/demo/magpie-marketplace-install.webm",
-  poster: "/demo/magpie-marketplace-install-poster.png",
-  transcript: "/demo/magpie-marketplace-install.txt",
-  durationLabel: "1 min 20 s",
-};
-```
-
-Paths are site-relative; `withBase()` is applied for you, so do not prefix them.
-
-## Review checklist
-
-Before opening the PR:
-
-- [ ] `npx astro build` is clean.
-- [ ] The frame holds its 16:9 box at 1440 px, 768 px, and 375 px wide — the
-      page does not scroll sideways at any of them.
-- [ ] No credential, token, private hostname, or private tracker content is
-      visible in any frame **or in the transcript**.
-- [ ] The plugin list on screen shows only the families that were installed on
-      purpose — no all-in-one `magpie` plugin, no `magpie-security`.
-- [ ] The transcript exists and its link resolves.
-- [ ] Every skill invocation on screen is in `/<plugin>:<skill>` form — a bare
-      `/pr-management-triage` means the recording was made against a snapshot
-      install, not the marketplace, and has to be redone.
-- [ ] `STORYBOARD` and `ACROSS_FAMILIES` in `InstallDemo.tsx` still match what
-      the recording shows.
-- [ ] Asset size: SVG under 2 MB, or WebM under 5 MB.
-- [ ] The raw `.cast` is attached to the PR.
-
-## Upstream sources
-
-The install flow shown here is defined in `apache/magpie`, not in this repo.
-When those pages change, this recording and the `STORYBOARD` cards are what
-goes stale:
-
-- `docs/quick-start.md` — the two-command marketplace install, the family
-  table, and the namespacing rule (`/<plugin>:<skill>`).
-- `docs/setup/marketplaces.md` — the full reference: every supported agent,
-  all-in-one vs per-family, the Claude Code-only note behind the caption under
-  the storyboard cards, and versioning.
-- `assets/quickstart/README.md` — the capture checklist for the still
-  screenshots on those pages. Its conventions (crop tight, no secrets, one
-  family per shot) are the same ones applied here.
-
-The landing section currently links to `/docs/setup/readme`. Once
-`docs/quick-start.md` lands upstream and the next docs sync picks it up,
-retarget that link to `/docs/quick-start` — there is a `TODO` on it in
-`InstallDemo.tsx`.
diff --git a/src/components/landing/InstallDemo.tsx b/src/components/landing/InstallDemo.tsx
deleted file mode 100644
index c2f78a7..0000000
--- a/src/components/landing/InstallDemo.tsx
+++ /dev/null
@@ -1,263 +0,0 @@
-"use client";
-
-import { ArrowUpRight, Film, Layers, Package, Play } from "lucide-react";
-import { BlurFade } from "@/ui/components/ui/blur-fade";
-import { withBase } from "@/ui/lib/utils";
-
-// ── Swapping the placeholder for the real animation ────────────────────────
-//
-// This section renders a placeholder until the demo is recorded. To wire the
-// finished asset in, drop the files under `public/demo/` and fill in DEMO
-// below — nothing else on the page changes.
-//
-//   const DEMO: DemoAsset | null = {
-//     kind: "svg",                                  // "svg" | "video"
-//     src: "/demo/magpie-marketplace-install.svg",
-//     poster: "/demo/magpie-marketplace-install-poster.png", // "video" only
-//     transcript: "/demo/magpie-marketplace-install.txt",    // a11y fallback
-//     durationLabel: "1 min 20 s",
-//   };
-//
-// The recording guide — what to show, which tool to use, terminal settings,
-// encoding, and the accessibility requirements — lives next to this file:
-//   src/components/landing/InstallDemo.README.md
-//
-type DemoAsset = {
-  /** "svg" renders an animated SVG via <img>; "video" renders <video>. */
-  kind: "svg" | "video";
-  /** Site-relative path under `public/`, e.g. "/demo/magpie-marketplace-install.svg". */
-  src: string;
-  /** Still frame shown before a video plays. Ignored for "svg". */
-  poster?: string;
-  /** Plain-text transcript of the session — required, it is the a11y fallback. */
-  transcript: string;
-  /** Human-readable run length, shown next to the player. */
-  durationLabel?: string;
-};
-
-const DEMO: DemoAsset | null = null;
-
-// The three beats the recording has to hit, in order. They double as the
-// shooting script for whoever records it (see InstallDemo.README.md § Script)
-// and as chapter captions once the animation is in place — so keep the two in
-// sync: if the recording changes, change these.
-const STORYBOARD = [
-  {
-    n: "01",
-    icon: Package,
-    title: "Add the marketplace",
-    cmd: "/plugin marketplace add apache/magpie",
-    desc: "One command inside the agent you already use. The apache/magpie repository is the marketplace — no vendor directory, no account, and nothing written into your project.",
-  },
-  {
-    n: "02",
-    icon: Layers,
-    title: "Install only the families you need",
-    cmd: "/plugin install magpie-pr-management@apache-magpie",
-    desc: "Ten families, one plugin each. Take magpie-setup plus whatever matches a problem you have today — every family you add costs context in every session, which is why there is no install-everything plugin.",
-  },
-  {
-    n: "03",
-    icon: Play,
-    title: "Run a skill",
-    cmd: "/magpie-pr-management:pr-management-triage",
-    desc: "Every skill is called as <plugin>:<skill> — the family plugin you installed is the prefix. Ask in plain language or call one by name; the agent proposes, and you confirm before anything leaves the machine.",
-  },
-];
-
-// One invocation per family, in the marketplace naming scheme
-// `/<plugin>:<skill>`. These are real skill names from apache/magpie — check
-// them against `skills/` there before editing, and keep the prefix equal to
-// the family plugin that provides the skill.
-const ACROSS_FAMILIES = [
-  { family: "setup", cmd: "/magpie-setup:setup-isolated-setup-install" },
-  { family: "pr-management", cmd: "/magpie-pr-management:pr-management-code-review" },
-  { family: "issue", cmd: "/magpie-issue:issue-triage" },
-  { family: "security", cmd: "/magpie-security:security-issue-triage" },
-  { family: "release-management", cmd: "/magpie-release-management:release-rc-cut" },
-  { family: "repo-health", cmd: "/magpie-repo-health:dependency-audit" },
-];
-
-const GUIDE_URL =
-  "https://github.com/apache/magpie-site/blob/main/src/components/landing/InstallDemo.README.md";
-
-function Placeholder() {
-  return (
-    <div className="flex h-full w-full flex-col items-center justify-center gap-4 rounded-2xl border-2 border-dashed border-brand-200 bg-default-background px-8 py-10 text-center mobile:px-5 mobile:py-6">
-      <div className="flex h-14 w-14 flex-none items-center justify-center rounded-full bg-brand-100 mobile:h-11 mobile:w-11">
-        <Film className="text-heading-2 font-heading-2 text-brand-700" />
-      </div>
-      <div className="flex flex-col items-center gap-1.5">
-        <span className="text-heading-3 font-heading-3 text-default-font">
-          Demo animation — not recorded yet
-        </span>
-        <span className="max-w-[520px] text-body font-body text-subtext-color">
-          This frame is reserved for a recording of the marketplace install —
-          the three steps below, in one agent session. The storyboard, tooling,
-          and encoding settings are written down; the recording is the only
-          missing piece.
-        </span>
-      </div>
-      <a
-        href={GUIDE_URL}
-        target="_blank"
-        rel="noreferrer"
-        className="inline-flex items-center gap-1.5 rounded-md border border-solid border-brand-200 bg-brand-50 px-3 py-1.5 text-caption font-caption text-brand-700 hover:border-brand-300 hover:bg-brand-100"
-      >
-        Read the recording guide
-        <ArrowUpRight className="size-3.5" />
-      </a>
-    </div>
-  );
-}
-
-function Player({ demo }: { demo: DemoAsset }) {
-  const label =
-    "Screen recording: installing Apache Magpie from the apache/magpie marketplace and running a skill from an installed family";
-  return (
-    <figure className="m-0 flex h-full w-full flex-col">
-      {demo.kind === "svg" ? (
-        <img
-          src={withBase(demo.src)}
-          alt={label}
-          className="h-full w-full rounded-2xl border border-solid border-neutral-200 bg-[#0f1117] object-contain shadow-md"
-        />
-      ) : (
-        // Muted + playsInline so it can autoplay on mobile Safari; controls
-        // stay on so the animation can be paused and scrubbed.
-        <video
-          className="h-full w-full rounded-2xl border border-solid border-neutral-200 bg-[#0f1117] object-contain shadow-md"
-          src={withBase(demo.src)}
-          poster={demo.poster ? withBase(demo.poster) : undefined}
-          aria-label={label}
-          controls
-          autoPlay
-          loop
-          muted
-          playsInline
-        />
-      )}
-      <figcaption className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1 pt-3 text-caption font-caption text-subtext-color">
-        {demo.durationLabel ? <span>{demo.durationLabel}</span> : null}
-        {demo.durationLabel ? <span aria-hidden="true">·</span> : null}
-        <a
-          href={withBase(demo.transcript)}
-          target="_blank"
-          rel="noreferrer"
-          className="inline-flex items-center gap-1 text-brand-700 hover:text-brand-800"
-        >
-          Read the transcript
-          <ArrowUpRight className="size-3.5" />
-        </a>
-      </figcaption>
-    </figure>
-  );
-}
-
-/**
- * "See it in action" — the install-and-use demo band, directly under the hero.
- *
- * Renders the recorded animation when DEMO is set, and a placeholder with the
- * shooting script when it is not. The storyboard below the frame is shown in
- * both states, so the section still explains the install → families → skill
- * flow to a reader who never presses play.
- */
-export function InstallDemo() {
-  return (
-    <div
-      id="see-it-in-action"
-      className="flex w-full flex-col items-center border-b border-solid border-brand-100 bg-brand-50 px-8 py-24 mobile:px-4 mobile:py-14"
-    >
-      <BlurFade
-        inView
-        className="flex flex-col items-center gap-4 max-w-[680px] pb-10 mobile:pb-8"
-      >
-        <span className="font-['Inter'] text-[38px] font-[700] leading-[44px] text-default-font text-center -tracking-[0.035em] mobile:font-['Jost'] mobile:text-[28px] mobile:font-[400] mobile:leading-[34px] mobile:tracking-normal">
-          See it in action
-        </span>
-        <span className="text-body font-body text-subtext-color text-center">
-          Install Magpie from your agent's own marketplace, switch on only the
-          skill families you need, and run one. Two commands, and nothing is
-          committed to your repository.
-        </span>
-      </BlurFade>
-      <div className="flex w-full max-w-[980px] flex-col items-center gap-10 mobile:gap-8">
-        <div className="aspect-[16/9] w-full">
-          {DEMO ? <Player demo={DEMO} /> : <Placeholder />}
-        </div>
-        <ol className="m-0 grid w-full list-none auto-rows-fr grid-cols-3 items-stretch gap-4 p-0 mobile:grid-cols-1">
-          {STORYBOARD.map((step) => (
-            <li
-              key={step.n}
-              className="flex h-full flex-col items-start gap-3 rounded-2xl border border-solid border-neutral-200 bg-default-background px-5 py-5 shadow-sm"
-            >
-              <div className="flex w-full items-center gap-3">
-                <div className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-brand-100">
-                  <step.icon className="text-body-bold font-body-bold text-brand-700" />
-                </div>
-                <span className="text-body-bold font-body-bold text-default-font">
-                  {step.title}
-                </span>
-                <span className="ml-auto text-caption font-caption text-neutral-400">
-                  {step.n}
-                </span>
-              </div>
-              <code className="w-full overflow-x-auto rounded-md border border-solid border-neutral-200 bg-neutral-50 px-2.5 py-1.5 font-mono text-caption text-brand-700">
-                {step.cmd}
-              </code>
-              <span className="text-caption font-caption text-subtext-color">
-                {step.desc}
-              </span>
-            </li>
-          ))}
-        </ol>
-        <div className="flex w-full flex-col gap-4 rounded-2xl border border-solid border-brand-100 bg-default-background px-6 py-6 mobile:px-4 mobile:py-5">
-          <div className="flex flex-col gap-1">
-            <span className="text-body-bold font-body-bold text-default-font">
-              One install, every family you took
-            </span>
-            <span className="text-caption font-caption text-subtext-color">
-              The prefix is the family plugin that provides the skill, so what
-              you can call is exactly what you chose to install.{" "}
-              <span className="font-mono">/magpie-utilities:list-skills</span>{" "}
-              prints the live list.
-            </span>
-          </div>
-          <ul className="m-0 grid list-none grid-cols-2 gap-x-6 gap-y-2 p-0 mobile:grid-cols-1">
-            {ACROSS_FAMILIES.map((item) => (
-              <li
-                key={item.cmd}
-                className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5 border-b border-solid border-neutral-100 py-1.5"
-              >
-                <code className="font-mono text-caption text-brand-700">
-                  {item.cmd}
-                </code>
-                <span className="text-caption font-caption text-subtext-color">
-                  {item.family}
-                </span>
-              </li>
-            ))}
-          </ul>
-        </div>
-        <p className="m-0 max-w-[720px] text-center text-caption font-caption text-subtext-color">
-          Every agent adds the same marketplace — the{" "}
-          <span className="font-mono">apache/magpie</span> repository. Codex
-          CLI, VS&nbsp;Code / Copilot and Gemini CLI install the same
-          per-family plugins as Claude Code; only the command to add the
-          marketplace differs.
-        </p>
-        <a
-          href={withBase("/docs/quick-start")}
-          target="_blank"
-          rel="noreferrer"
-          className="inline-flex items-center gap-1.5 rounded-md border border-solid border-brand-200 bg-default-background px-3 py-1.5 text-caption font-caption text-brand-700 hover:border-brand-300 hover:bg-brand-100"
-        >
-          Follow the written install guide instead
-          <ArrowUpRight className="size-3.5" />
-        </a>
-      </div>
-    </div>
-  );
-}
-
-export default InstallDemo;
diff --git a/src/components/landing/security-story/Genie.tsx b/src/components/landing/security-story/Genie.tsx
new file mode 100644
index 0000000..bc44495
--- /dev/null
+++ b/src/components/landing/security-story/Genie.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+/**
+ * The Magpie genie: the bird coming out of the lamp, shown whenever Magpie does
+ * something the maintainer would otherwise have done by hand.
+ *
+ * It is a badge, not a mascot animation — it pops, says what it did and what
+ * that would have cost, and gets out of the way. Drawn inline so it needs no
+ * asset and follows the page's colours.
+ */
+export function GenieMark({ className = "" }: { className?: string }) {
+  return (
+    <svg viewBox="0 0 48 48" aria-hidden="true" className={className}>
+      {/* the wisp it rides in on */}
+      <path
+        d="M12 44c-3-4 1-7 5-8 5-1 9 1 13-1"
+        fill="none"
+        stroke="currentColor"
+        strokeWidth="2"
+        strokeLinecap="round"
+        opacity="0.35"
+      />
+      <path
+        d="M16 46c6-1 10-3 16-3"
+        fill="none"
+        stroke="currentColor"
+        strokeWidth="2"
+        strokeLinecap="round"
+        opacity="0.2"
+      />
+      {/* long magpie tail */}
+      <path
+        d="M30 30c6 3 10 8 12 14-6-2-11-5-15-9"
+        fill="currentColor"
+        opacity="0.55"
+      />
+      {/* body */}
+      <ellipse cx="25" cy="24" rx="10" ry="11" fill="currentColor" />
+      {/* wing */}
+      <path d="M22 18c6 1 9 5 10 11-5 1-9-2-11-6z" fill="currentColor" opacity="0.35" />
+      {/* head */}
+      <circle cx="21" cy="12" r="6" fill="currentColor" />
+      {/* beak */}
+      <path d="M15 11l-7 2 7 3z" fill="currentColor" />
+      {/* eye */}
+      <circle cx="22" cy="11" r="1.6" fill="#fff" />
+      {/* sparkle, because it is magic */}
+      <path
+        d="M38 8l1.6 4.4L44 14l-4.4 1.6L38 20l-1.6-4.4L32 14l4.4-1.6z"
+        fill="currentColor"
+        opacity="0.8"
+      />
+    </svg>
+  );
+}
+
+/**
+ * The pop-in. `phase` runs 0→1 across the moment: it springs in, holds, and
+ * fades. Derived from beat progress like everything else, so it is correct when
+ * the reader jumps into the middle of a chapter.
+ */
+export function GenieBadge({
+  did,
+  month,
+  phase,
+}: {
+  did: string;
+  month: string;
+  phase: number;
+}) {
+  // Quick in, quick out, long hold: three-quarters of the window is the badge
+  // simply sitting there being read.
+  const entering = Math.min(1, phase / 0.12);
+  const leaving = phase > 0.88 ? (phase - 0.88) / 0.12 : 0;
+  const opacity = entering * (1 - leaving);
+  // A little overshoot on the way in reads as "popped", not "faded".
+  const scale = 0.7 + 0.36 * entering - 0.06 * Math.max(0, entering - 0.88) * 8;
+  const lift = (1 - entering) * 10 + leaving * 8;
+
+  return (
+    // Centred horizontally and sitting low in the frame: it is the thing to look
+    // at while Magpie works — hence the dimmed scene behind it — but it should
+    // not cover the scene it is talking about.
+    <div className="pointer-events-none absolute inset-0 z-20 flex items-end justify-center px-4 pb-8 mobile:pb-4">
+      <div
+        className="absolute inset-0 bg-neutral-900"
+        style={{ opacity: opacity * 0.28 }}
+      />
+      <div
+        className="relative w-[min(34rem,100%)]"
+        style={{
+          opacity,
+          transform: `translateY(${lift}px) scale(${scale})`,
+        }}
+      >
+      <div className="flex items-center gap-4 rounded-2xl border border-solid border-brand-200 bg-default-background/95 px-4 py-3 shadow-2xl backdrop-blur-sm">
+        <GenieMark className="size-14 flex-none text-brand-600 mobile:size-10" />
+        <div className="min-w-0">
+          <div className="truncate text-caption font-caption text-subtext-color">
+            Magpie did that — {did}
+          </div>
+          {/* The month is the only figure. A per-act saving would be a claim
+              about one run that nobody can check. */}
+          <div className="truncate text-body-bold font-body-bold text-brand-700">
+            {month} last month
+          </div>
+        </div>
+      </div>
+      </div>
+    </div>
+  );
+}
diff --git a/src/components/landing/security-story/SecurityStoryDemo.tsx b/src/components/landing/security-story/SecurityStoryDemo.tsx
new file mode 100644
index 0000000..8f0d0b4
--- /dev/null
+++ b/src/components/landing/security-story/SecurityStoryDemo.tsx
@@ -0,0 +1,235 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { ArrowUpRight, Pause, Play, RotateCcw } from "lucide-react";
+import { BlurFade } from "@/ui/components/ui/blur-fade";
+import { withBase } from "@/ui/lib/utils";
+import { SCRIPT, transcript } from "./script";
+import { chapterStart, totalMs, useTimeline } from "./useTimeline";
+
+const TOTAL_MS = totalMs(SCRIPT);
+import { TerminalScene } from "./scenes/TerminalScene";
+import { MailScene } from "./scenes/MailScene";
+import { BoardScene } from "./scenes/BoardScene";
+import { VulnogramScene } from "./scenes/VulnogramScene";
+import { PublishedScene } from "./scenes/PublishedScene";
+import { InterludeScene } from "./scenes/InterludeScene";
+import { GenieBadge } from "./Genie";
+import type { Beat } from "./script";
+
+function Scene({ beat, progress }: { beat: Beat; progress: number }) {
+  switch (beat.scene) {
+    case "terminal":
+      return <TerminalScene beat={beat} progress={progress} />;
+    case "mail":
+      return <MailScene beat={beat} progress={progress} />;
+    case "board":
+      return <BoardScene beat={beat} progress={progress} />;
+    case "interlude":
+      return <InterludeScene beat={beat} progress={progress} />;
+    case "vulnogram":
+      return <VulnogramScene beat={beat} progress={progress} />;
+    case "published":
+      return <PublishedScene beat={beat} progress={progress} />;
+  }
+}
+
+function SecurityStoryDemo() {
+  const {
+    cursor,
+    elapsed,
+    running,
+    reduced,
+    finished,
+    setInView,
+    setHovered,
+    togglePaused,
+    jumpTo,
+    replay,
+  } = useTimeline(SCRIPT, TOTAL_MS);
+
+  const frameRef = useRef<HTMLDivElement | null>(null);
+
+  // Start when the band is actually on screen, stop when it is not: an
+  // animation nobody is looking at should not be running.
+  useEffect(() => {
+    const node = frameRef.current;
+    if (!node || typeof IntersectionObserver === "undefined") return undefined;
+    const observer = new IntersectionObserver(
+      ([entry]) => setInView(entry.isIntersecting),
+      { threshold: 0.4 },
+    );
+    observer.observe(node);
+    return () => observer.disconnect();
+  }, [setInView]);
+
+  const story = transcript();
+
+  return (
+    <div
+      id="see-it-in-action"
+      className="flex w-full scroll-mt-16 flex-col items-center bg-default-background px-8 pt-20 pb-16 mobile:px-4 mobile:pt-12"
+    >
+      <BlurFade inView className="flex max-w-[760px] flex-col items-center gap-4 pb-8">
+        <span className="font-['Inter'] text-[38px] font-[700] leading-[44px] -tracking-[0.035em] text-default-font text-center mobile:font-['Jost'] mobile:text-[28px] mobile:font-[400] mobile:leading-[34px] mobile:tracking-normal">
+          See it in action
+        </span>
+        <span className="text-body font-body text-subtext-color text-center">
+          One security report, start to finish: installed from the marketplace,
+          triaged against the project's history, fixed, released, and published as
+          a CVE. Every message that leaves the project is drafted by the agent and
+          sent by a person.
+        </span>
+      </BlurFade>
+
+      {/* Reduced motion gets the whole story as stills rather than nothing. */}
+      {reduced ? (
+        <StaticStory />
+      ) : (
+        <div
+          ref={frameRef}
+          className="flex w-full max-w-[960px] flex-col gap-3"
+          onMouseEnter={() => setHovered(true)}
+          onMouseLeave={() => setHovered(false)}
+          onFocus={() => setHovered(true)}
+          onBlur={() => setHovered(false)}
+        >
+          <div className="relative aspect-video w-full overflow-hidden rounded-2xl border border-solid border-neutral-200 shadow-sm mobile:aspect-[4/3]">
+            <Scene beat={cursor.beat} progress={cursor.progress} />
+            <GenieMoment beat={cursor.beat} progress={cursor.progress} />
+          </div>
+
+          <div className="flex items-center gap-2 mobile:flex-wrap">
+            <button
+              type="button"
+              onClick={finished ? replay : togglePaused}
+              aria-label={finished ? "Replay" : running ? "Pause" : "Play"}
+              className="inline-flex flex-none items-center gap-1.5 rounded-md border border-solid border-neutral-200 bg-default-background px-2.5 py-1.5 text-caption font-caption text-default-font hover:bg-neutral-100"
+            >
+              {finished ? (
+                <RotateCcw className="size-3.5" />
+              ) : running ? (
+                <Pause className="size-3.5" />
+              ) : (
+                <Play className="size-3.5" />
+              )}
+              {finished ? "Replay" : running ? "Pause" : "Play"}
+            </button>
+
+            <ol className="flex min-w-0 flex-1 items-center gap-1 mobile:flex-wrap">
+              {SCRIPT.map((chapter, i) => {
+                const active = i === cursor.chapterIndex;
+                const seen = i < cursor.chapterIndex;
+                return (
+                  <li key={chapter.id} className="min-w-0 flex-1">
+                    <button
+                      type="button"
+                      onClick={() => jumpTo(chapterStart(SCRIPT, i))}
+                      aria-current={active ? "step" : undefined}
+                      className={`w-full truncate rounded-md px-2 py-1.5 text-caption font-caption transition-colors ${
+                        active
+                          ? "bg-brand-600 text-white"
+                          : seen
+                            ? "bg-brand-50 text-brand-700 hover:bg-brand-100"
+                            : "text-subtext-color hover:bg-neutral-100"
+                      }`}
+                    >
+                      {i + 1}. {chapter.label}
+                    </button>
+                  </li>
+                );
+              })}
+            </ol>
+          </div>
+
+          <div
+            className="h-0.5 w-full overflow-hidden rounded-full bg-neutral-200"
+            role="progressbar"
+            aria-valuemin={0}
+            aria-valuemax={100}
+            aria-valuenow={Math.round((elapsed / TOTAL_MS) * 100)}
+          >
+            <div
+              className="h-full bg-brand-600"
+              style={{ width: `${(elapsed / TOTAL_MS) * 100}%` }}
+            />
+          </div>
+        </div>
+      )}
+
+      <details className="mt-6 w-full max-w-[960px] rounded-lg border border-solid border-neutral-200 bg-default-background px-4 py-3">
+        <summary className="cursor-pointer text-caption font-caption text-brand-700">
+          Read the whole thing as text
+        </summary>
+        <div className="mt-3 flex flex-col gap-4">
+          {story.map((chapter) => (
+            <section key={chapter.chapter}>
+              <h3 className="text-body-bold font-body-bold text-default-font">
+                {chapter.chapter}
+              </h3>
+              <pre className="mt-1 overflow-x-auto whitespace-pre-wrap font-mono text-caption text-subtext-color">
+                {chapter.lines.join("\n")}
+              </pre>
+            </section>
+          ))}
+        </div>
+      </details>
+
+      <a
+        href={withBase("/docs/security/readme")}
+        className="mt-5 inline-flex items-center gap-1.5 rounded-md border border-solid border-brand-200 bg-default-background px-3 py-1.5 text-caption font-caption text-brand-700 hover:border-brand-300 hover:bg-brand-100"
+      >
+        How the security family actually works
+        <ArrowUpRight className="size-3.5" />
+      </a>
+
+      <p className="mt-3 max-w-[760px] text-center text-caption font-caption text-subtext-color">
+        Example App is a fictional project, and the CVE identifier is a
+        placeholder. The workflow is real; the vulnerability is not.
+      </p>
+    </div>
+  );
+}
+
+/**
+ * Shows the genie for a window around `genie.at`. Derived from beat progress
+ * like everything else, so jumping into the middle of a beat gets it right.
+ */
+function GenieMoment({ beat, progress }: { beat: Beat; progress: number }) {
+  const genie = beat.genie;
+  if (!genie) return null;
+  // Half the beat. The genie is the argument the demo is making, so it gets
+  // read-it-twice time rather than a glimpse. Every `at` is <= 0.5 so the full
+  // window fits inside its beat and the fade-out is never cut off by the scene
+  // change — asserted in the checks.
+  const window = 0.5;
+  const phase = (progress - genie.at) / window;
+  if (phase < 0 || phase > 1) return null;
+  return (
+<GenieBadge did={genie.did} month={genie.month} phase={phase} />
+  );
+}
+
+/** The story as stills — what a reader with reduced motion gets. */
+function StaticStory() {
+  return (
+    <div className="flex w-full max-w-[960px] flex-col gap-3">
+      {SCRIPT.map((chapter) => {
+        const beat = chapter.beats[chapter.beats.length - 1];
+        return (
+          <div key={chapter.id} className="flex flex-col gap-1">
+            <span className="text-caption font-caption text-subtext-color">
+              {chapter.label}
+            </span>
+            <div className="relative aspect-video w-full overflow-hidden rounded-2xl border border-solid border-neutral-200 mobile:aspect-[4/3]">
+              <Scene beat={beat} progress={1} />
+            </div>
+          </div>
+        );
+      })}
+    </div>
+  );
+}
+
+export { SecurityStoryDemo };
+export default SecurityStoryDemo;
diff --git a/src/components/landing/security-story/scenes/BoardScene.tsx b/src/components/landing/security-story/scenes/BoardScene.tsx
new file mode 100644
index 0000000..6e1f561
--- /dev/null
+++ b/src/components/landing/security-story/scenes/BoardScene.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import type { Beat, BoardCard } from "../script";
+
+/**
+ * The tracker board. One card travels; everything else holds still, so the eye
+ * has exactly one thing to follow. Column position is interpolated from
+ * `progress`, which is why a chapter jump lands the card mid-column correctly.
+ */
+function easeInOut(t: number) {
+  return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
+}
+
+/** Which column a card occupies at `progress`, as a fractional index. */
+function columnAt(card: BoardCard, progress: number) {
+  if (card.from === card.to) return card.from;
+  const legs = card.to - card.from;
+  // Cards move in discrete hops, one per event, with a pause on each column.
+  const scaled = easeInOut(Math.min(1, progress / 0.92)) * legs;
+  return card.from + scaled;
+}
+
+export function BoardScene({
+  beat,
+  progress,
+}: {
+  beat: Extract<Beat, { scene: "board" }>;
+  progress: number;
+}) {
+  const columns = beat.columns;
+  const eventIndex = Math.min(
+    beat.events.length - 1,
+    Math.floor((progress / 0.92) * beat.events.length),
+  );
+
+  return (
+    <div className="flex h-full w-full flex-col bg-neutral-50 px-6 py-5 mobile:px-3 mobile:py-3">
+      <div className="mb-3 flex flex-none items-center justify-between gap-3">
+        <span className="truncate text-caption font-caption text-subtext-color">
+          {beat.title}
+        </span>
+        <span className="truncate rounded-md bg-default-background px-2 py-1 text-caption font-caption text-brand-700">
+          {beat.events[eventIndex]}
+        </span>
+      </div>
+
+      <div className="grid min-h-0 flex-1 grid-cols-5 gap-2 mobile:grid-cols-3 mobile:overflow-x-auto">
+        {columns.map((column, ci) => {
+          const here = beat.cards.filter(
+            (card) =>
+              Math.round(columnAt(card, progress)) === ci &&
+              !(card.exitAt !== undefined && progress > card.exitAt + 0.12),
+          );
+          return (
+            <div
+              key={column}
+              className="flex min-w-0 flex-col rounded-lg border border-solid border-neutral-200 bg-default-background/60"
+            >
+              <div className="flex flex-none items-center justify-between gap-1 border-b border-solid border-neutral-200 px-2 py-1.5">
+                <span className="truncate text-caption font-caption text-default-font">
+                  {column}
+                </span>
+                <span className="flex-none rounded-full bg-neutral-100 px-1.5 text-caption font-caption text-subtext-color">
+                  {here.length}
+                </span>
+              </div>
+              <div className="flex min-h-0 flex-1 flex-col gap-1.5 p-1.5">
+                {here.map((card) => (
+                  <Card
+                    key={card.id}
+                    card={card}
+                    moving={card.from !== card.to}
+                    leaving={card.exitAt !== undefined && progress > card.exitAt}
+                  />
+                ))}
+              </div>
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
+
+function Card({
+  card,
+  moving,
+  leaving,
+}: {
+  card: BoardCard;
+  moving: boolean;
+  /** Closed and on its way off the board — the work is finished. */
+  leaving?: boolean;
+}) {
+  return (
+    <div
+      className={`rounded-md border border-solid px-2 py-1.5 transition-all duration-500 ${
+        leaving ? "-translate-y-2 scale-95 opacity-0" : ""
+      } ${
+        card.muted
+          ? "border-neutral-200 bg-neutral-50 opacity-60"
+          : moving
+            ? "border-brand-300 bg-default-background shadow-md"
+            : "border-neutral-200 bg-default-background"
+      }`}
+    >
+      <div className="flex items-center gap-1.5">
+        <span className="font-mono text-caption text-subtext-color">{card.id}</span>
+        {card.severity === "high" && (
+          <span className="rounded-full bg-red-50 px-1.5 text-caption font-caption text-red-700">
+            high
+          </span>
+        )}
+        {card.severity === "none" && (
+          <span className="rounded-full bg-neutral-100 px-1.5 text-caption font-caption text-subtext-color">
+            closed
+          </span>
+        )}
+      </div>
+      <div className="mt-0.5 line-clamp-3 text-caption font-caption text-default-font">
+        {card.title}
+      </div>
+    </div>
+  );
+}
diff --git a/src/components/landing/security-story/scenes/InterludeScene.tsx b/src/components/landing/security-story/scenes/InterludeScene.tsx
new file mode 100644
index 0000000..12f5f5e
--- /dev/null
+++ b/src/components/landing/security-story/scenes/InterludeScene.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import type { Beat } from "../script";
+import { GenieMark } from "../Genie";
+
+/**
+ * The interlude: the genie centre stage instead of in the corner. Used where
+ * Magpie produces a whole artefact on its own — the fix PR — so the board pauses
+ * and the thing it made materialises.
+ */
+export function InterludeScene({
+  beat,
+  progress,
+}: {
+  beat: Extract<Beat, { scene: "interlude" }>;
+  progress: number;
+}) {
+  const genieIn = Math.min(1, progress / 0.18);
+  const cardIn = Math.min(1, Math.max(0, (progress - 0.22) / 0.25));
+  const linesShown = Math.ceil(
+    beat.card.lines.length * Math.min(1, Math.max(0, (progress - 0.4) / 0.3)),
+  );
+  const savedIn = progress > 0.72;
+
+  return (
+    <div className="flex h-full w-full flex-col items-center justify-center gap-4 bg-gradient-to-b from-brand-50 to-default-background px-6 py-5 mobile:px-3 mobile:py-3">
+      <div
+        className="flex flex-col items-center gap-1"
+        style={{
+          opacity: genieIn,
+          transform: `translateY(${(1 - genieIn) * 12}px) scale(${0.8 + 0.2 * genieIn})`,
+        }}
+      >
+        <GenieMark className="size-14 text-brand-600" />
+        <span className="text-center text-caption font-caption text-brand-700">
+          {beat.title}
+        </span>
+      </div>
+
+      <div
+        className="w-[min(30rem,100%)] rounded-xl border border-solid border-brand-200 bg-default-background p-3 shadow-md transition-none"
+        style={{
+          opacity: cardIn,
+          transform: `translateY(${(1 - cardIn) * 10}px)`,
+        }}
+      >
+        <div className="flex items-center gap-2">
+          <span className="rounded-full bg-brand-50 px-2 py-0.5 text-caption font-caption text-brand-700">
+            {beat.card.kind}
+          </span>
+          <span className="truncate font-mono text-caption text-subtext-color">
+            {beat.card.id}
+          </span>
+        </div>
+        <div className="mt-1 text-body font-body text-default-font">
+          {beat.card.title}
+        </div>
+        <div className="mt-2 flex flex-col gap-0.5">
+          {beat.card.lines.slice(0, linesShown).map((line, i) => (
+            <div key={i} className="truncate font-mono text-caption text-subtext-color">
+              {line}
+            </div>
+          ))}
+        </div>
+      </div>
+
+      <div
+        className={`flex flex-col items-center gap-0.5 text-center transition-opacity duration-500 ${
+          savedIn ? "opacity-100" : "opacity-0"
+        }`}
+      >
+        <span className="text-caption font-caption text-subtext-color">
+          {beat.did}
+        </span>
+        <span className="text-body-bold font-body-bold text-brand-700">
+          {beat.month} last month
+        </span>
+      </div>
+    </div>
+  );
+}
diff --git a/src/components/landing/security-story/scenes/MailScene.tsx b/src/components/landing/security-story/scenes/MailScene.tsx
new file mode 100644
index 0000000..c0e184f
--- /dev/null
+++ b/src/components/landing/security-story/scenes/MailScene.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import { Check, Send } from "lucide-react";
+import type { Beat } from "../script";
+
+/**
+ * Deliberately the visual opposite of the terminal: light paper, and a
+ * plain-text monospace body with literal URLs — no rewritten links, no tracking
+ * pixel — which is what the project asks of reporter-facing mail. The contrast
+ * is what makes the two "a human pressed Send" beats land.
+ *
+ * The body is NOT typed out. It arrives whole, in one flash, because that is
+ * the actual claim: the maintainer did not write this. Typing it would sell the
+ * opposite story — a machine at a keyboard, taking just as long.
+ */
+export function MailScene({
+  beat,
+  progress,
+}: {
+  beat: Extract<Beat, { scene: "mail" }>;
+  progress: number;
+}) {
+  // The whole draft lands at once, just after the genie: a brief flash, then it
+  // is simply there. Send lands at 80%, and the rest of the beat holds on
+  // "Sent" so the reader can take it in.
+  const arrived = progress > 0.1;
+  const flash = arrived && progress < 0.22;
+  const pressing = progress > 0.78 && progress <= 0.84;
+  const sent = progress > 0.84;
+
+  return (
+    <div className="relative flex h-full w-full flex-col bg-neutral-50 px-6 py-5 mobile:px-3 mobile:py-3">
+      <Ribbon />
+      <div className="mb-3 flex-none text-caption font-caption text-subtext-color">
+        {beat.title}
+      </div>
+
+      <div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-solid border-neutral-200 bg-default-background">
+        <div className="flex-none border-b border-solid border-neutral-200 px-4 py-2">
+          {beat.fields.map((f) => (
+            <div key={f.label} className="flex gap-2 text-caption font-caption">
+              <span className="w-16 flex-none text-subtext-color">{f.label}</span>
+              <span className="truncate font-mono text-default-font">{f.value}</span>
+            </div>
+          ))}
+        </div>
+
+        <div
+          className={`min-h-0 flex-1 overflow-hidden px-4 py-3 font-mono text-caption leading-relaxed text-default-font transition-all duration-200 ${
+            arrived ? "opacity-100" : "opacity-0"
+          } ${flash ? "bg-brand-50" : "bg-transparent"}`}
+        >
+          {(arrived ? beat.body : []).map((line, i) => (
+            <div key={i} className="whitespace-pre-wrap">
+              {line === "" ? " " : line}
+            </div>
+          ))}
+        </div>
+
+        <div className="flex flex-none items-center gap-3 border-t border-solid border-neutral-200 px-4 py-2">
+          {sent ? (
+            <span className="inline-flex items-center gap-1.5 rounded-md bg-emerald-50 px-3 py-1.5 text-caption font-caption text-emerald-700">
+              <Check className="size-3.5" /> Sent
+            </span>
+          ) : (
+            <span
+              className={`inline-flex items-center gap-1.5 rounded-md bg-brand-600 px-3 py-1.5 text-caption font-caption text-white transition-transform ${
+                pressing ? "scale-95 brightness-90" : ""
+              }`}
+            >
+              <Send className="size-3.5" /> Send
+            </span>
+          )}
+          <span className="truncate text-caption font-caption text-subtext-color">
+            {beat.gate}
+          </span>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+/** Marks the fabricated scenes, so a screenshot cannot read as a real advisory. */
+export function Ribbon() {
+  return (
+    <div className="pointer-events-none absolute right-3 top-3 z-10 rounded-full border border-solid border-neutral-300 bg-neutral-100/90 px-2 py-0.5 text-caption font-caption text-subtext-color">
+      Illustration · fictional project
+    </div>
+  );
+}
diff --git a/src/components/landing/security-story/scenes/PublishedScene.tsx b/src/components/landing/security-story/scenes/PublishedScene.tsx
new file mode 100644
index 0000000..befa040
--- /dev/null
+++ b/src/components/landing/security-story/scenes/PublishedScene.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { Check, Mail } from "lucide-react";
+import { PROJECT, type Beat } from "../script";
+import { Ribbon } from "./MailScene";
+
+/** The closing frame: the advisory entry beside the announcement that went out. */
+export function PublishedScene({
+  beat,
+  progress,
+}: {
+  beat: Extract<Beat, { scene: "published" }>;
+  progress: number;
+}) {
+  const advisoryIn = progress > 0.1;
+  const mailIn = progress > 0.35;
+  const summaryIn = progress > 0.6;
+
+  return (
+    <div className="relative flex h-full w-full flex-col bg-neutral-50 px-6 py-5 mobile:px-3 mobile:py-3">
+      <Ribbon />
+      <div className="mb-3 flex-none text-caption font-caption text-subtext-color">
+        {beat.title}
+      </div>
+
+      <div className="grid min-h-0 flex-1 grid-cols-2 gap-3 mobile:grid-cols-1">
+        <div
+          className={`flex min-h-0 flex-col overflow-hidden rounded-lg border border-solid border-neutral-200 bg-default-background transition-all duration-500 ${
+            advisoryIn ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0"
+          }`}
+        >
+          <div className="flex-none border-b border-solid border-neutral-200 px-3 py-2 text-caption font-caption text-default-font">
+            Advisory database entry
+          </div>
+          <div className="flex min-h-0 flex-1 flex-col gap-1.5 p-3">
+            {beat.advisory.map((field) => (
+              <div key={field.label} className="flex gap-2">
+                <span className="w-20 flex-none text-caption font-caption text-subtext-color">
+                  {field.label}
+                </span>
+                <span className="truncate font-mono text-caption text-default-font">
+                  {field.value}
+                </span>
+              </div>
+            ))}
+          </div>
+        </div>
+
+        <div
+          className={`flex min-h-0 flex-col overflow-hidden rounded-lg border border-solid border-neutral-200 bg-default-background transition-all duration-500 ${
+            mailIn ? "translate-y-0 opacity-100" : "translate-y-2 opacity-0"
+          }`}
+        >
+          <div className="flex flex-none items-center gap-2 border-b border-solid border-neutral-200 px-3 py-2">
+            <Mail className="size-3.5 text-subtext-color" />
+            <span className="truncate text-caption font-caption text-default-font">
+              {PROJECT.announceList}
+            </span>
+            <span className="ml-auto inline-flex flex-none items-center gap-1 rounded-md bg-emerald-50 px-2 py-0.5 text-caption font-caption text-emerald-700">
+              <Check className="size-3" /> Sent
+            </span>
+          </div>
+          <div className="min-h-0 flex-1 p-3 font-mono text-caption leading-relaxed text-default-font">
+            <div className="truncate">[{PROJECT.cve}] Path traversal in</div>
+            <div className="truncate">{PROJECT.name} archive extractor</div>
+            <div className="mt-2 text-subtext-color">
+              Fixed in {PROJECT.fixVersion} · credit R. Chen
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <div
+        className={`mt-3 flex-none text-center text-caption font-caption text-default-font transition-opacity duration-500 ${
+          summaryIn ? "opacity-100" : "opacity-0"
+        }`}
+      >
+        {beat.summary}
+      </div>
+    </div>
+  );
+}
diff --git a/src/components/landing/security-story/scenes/TerminalScene.tsx b/src/components/landing/security-story/scenes/TerminalScene.tsx
new file mode 100644
index 0000000..d415121
--- /dev/null
+++ b/src/components/landing/security-story/scenes/TerminalScene.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import type { Beat, TerminalLine } from "../script";
+
+/**
+ * Lines reveal in order across the beat, the last one typing out character by
+ * character. The scene holds no timers: how much is on screen is a function of
+ * `progress` alone, so a chapter jump lands mid-sentence correctly.
+ */
+function reveal(lines: TerminalLine[], progress: number) {
+  // Reserve the last slice of the beat for reading the final line.
+  const scaled = Math.min(1, progress / 0.88) * lines.length;
+  const complete = Math.floor(scaled);
+  const typingFraction = scaled - complete;
+  return { complete, typingFraction };
+}
+
+const TONE: Record<TerminalLine["kind"], string> = {
+  cmd: "text-white",
+  ask: "text-white",
+  out: "text-neutral-400",
+  note: "text-emerald-300",
+  gate: "",
+};
+
+export function TerminalScene({
+  beat,
+  progress,
+}: {
+  beat: Extract<Beat, { scene: "terminal" }>;
+  progress: number;
+}) {
+  const { complete, typingFraction } = reveal(beat.lines, progress);
+
+  return (
+    <div className="flex h-full w-full flex-col bg-neutral-900 px-6 py-5 font-mono mobile:px-3 mobile:py-3">
+      <div className="mb-3 flex-none text-caption font-caption text-neutral-500">
+        {beat.title}
+      </div>
+      <div className="flex min-h-0 flex-1 flex-col gap-1 overflow-hidden text-caption leading-relaxed">
+        {beat.lines.map((line, i) => {
+          if (i > complete) return null;
+          const typing = i === complete;
+          const text =
+            typing && line.kind !== "gate"
+              ? line.text.slice(0, Math.ceil(line.text.length * typingFraction))
+              : line.text;
+
+          if (line.kind === "gate") {
+            // The confirmation gate is the point of the whole demo, so it is
+            // the one thing that gets a box rather than a line.
+            const answered = !typing || typingFraction > 0.55;
+            return (
+              <div
+                key={i}
+                className="mt-1 rounded-md border border-solid border-brand-400/60 bg-brand-600/15 px-3 py-2"
+              >
+                <div className="text-brand-200">{line.text}</div>
+                <div className="text-neutral-400">
+                  {answered ? (
+                    <>
+                      <span className="text-neutral-500">›</span>{" "}
+                      <span className="text-white">{line.answer}</span>{" "}
+                      <span className="text-emerald-300">— confirmed by a human</span>
+                    </>
+                  ) : (
+                    <span className="text-neutral-500">› _</span>
+                  )}
+                </div>
+              </div>
+            );
+          }
+
+          return (
+            <div key={i} className={`whitespace-pre-wrap ${TONE[line.kind]}`}>
+              {(line.kind === "cmd" || line.kind === "ask") && (
+                <span className="text-brand-300">› </span>
+              )}
+              {text}
+              {typing && <span className="text-brand-300">▌</span>}
+            </div>
+          );
+        })}
+      </div>
+    </div>
+  );
+}
diff --git a/src/components/landing/security-story/scenes/VulnogramScene.tsx b/src/components/landing/security-story/scenes/VulnogramScene.tsx
new file mode 100644
index 0000000..509ff51
--- /dev/null
+++ b/src/components/landing/security-story/scenes/VulnogramScene.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { Send } from "lucide-react";
+import type { Beat } from "../script";
+import { Ribbon } from "./MailScene";
+
+/**
+ * The CVE record. Like the mail, it is NOT filled in field by field: the whole
+ * record lands at once, because the claim is that nobody sat and typed it. The
+ * JSON preview appears with it, to say this is a structured record going to a
+ * registry rather than a form for its own sake.
+ */
+export function VulnogramScene({
+  beat,
+  progress,
+}: {
+  beat: Extract<Beat, { scene: "vulnogram" }>;
+  progress: number;
+}) {
+  const arrived = progress > 0.12;
+  const flash = arrived && progress < 0.24;
+  const pressing = progress > 0.8 && progress <= 0.86;
+  const submitted = progress > 0.86;
+
+  return (
+    <div className="relative flex h-full w-full flex-col bg-neutral-50 px-6 py-5 mobile:px-3 mobile:py-3">
+      <Ribbon />
+      <div className="mb-3 flex-none text-caption font-caption text-subtext-color">
+        {beat.title}
+      </div>
+
+      <div className="grid min-h-0 flex-1 grid-cols-2 gap-3 mobile:grid-cols-1">
+        <div
+          className={`flex min-h-0 flex-col gap-1.5 overflow-hidden rounded-lg border border-solid p-3 transition-all duration-200 ${
+            flash
+              ? "border-brand-300 bg-brand-50"
+              : "border-neutral-200 bg-default-background"
+          }`}
+        >
+          {beat.fields.map((field) => (
+            <div
+              key={field.label}
+              className={`flex gap-2 transition-opacity duration-200 ${
+                arrived ? "opacity-100" : "opacity-0"
+              }`}
+            >
+              <span className="w-24 flex-none text-caption font-caption text-subtext-color">
+                {field.label}
+              </span>
+              <span className="truncate font-mono text-caption text-default-font">
+                {field.value}
+              </span>
+            </div>
+          ))}
+        </div>
+
+        <div className="min-h-0 overflow-hidden rounded-lg bg-neutral-900 p-3 font-mono text-caption leading-relaxed text-neutral-400 mobile:hidden">
+          <div className="text-neutral-500">{"{"}</div>
+          {(arrived ? beat.fields : []).map((field) => (
+            <div key={field.label} className="truncate pl-3">
+              <span className="text-brand-300">
+                "{field.label.toLowerCase().replace(/\s+/g, "_")}"
+              </span>
+              <span className="text-neutral-500">: </span>
+              <span className="text-emerald-300">"{field.value}"</span>
+              <span className="text-neutral-500">,</span>
+            </div>
+          ))}
+          <div className="text-neutral-500">{"}"}</div>
+        </div>
+      </div>
+
+      <div className="mt-3 flex flex-none items-center gap-3">
+        {submitted ? (
+          <span className="rounded-md bg-emerald-50 px-3 py-1.5 text-caption font-caption text-emerald-700">
+            Submitted to the CVE program
+          </span>
+        ) : (
+          <span
+            className={`inline-flex items-center gap-1.5 rounded-md bg-brand-600 px-3 py-1.5 text-caption font-caption text-white transition-transform ${
+              pressing ? "scale-95 brightness-90" : ""
+            }`}
+          >
+            <Send className="size-3.5" /> Send
+          </span>
+        )}
+        <span className="truncate text-caption font-caption text-subtext-color">
+          {beat.gate}
+        </span>
+      </div>
+    </div>
+  );
+}
diff --git a/src/components/landing/security-story/script.ts b/src/components/landing/security-story/script.ts
new file mode 100644
index 0000000..7198a67
--- /dev/null
+++ b/src/components/landing/security-story/script.ts
@@ -0,0 +1,677 @@
+// The security-story demo, as data.
+//
+// Everything the animation shows lives here: the scenes are pure renderers of
+// whatever this script points at, and the transcript below the frame is derived
+// from this same constant — so the two can never drift. Retiming the demo means
+// editing `ms`; rewording it means editing this file and nothing else.
+//
+// The project is fictional and must stay that way. `example-org/example-app`
+// with the RFC 2606 reserved domains, and a CVE id in placeholder form that
+// cannot collide with a real identifier. See
+// docs/superpowers/specs/2026-09-13-security-story-demo-design.md.
+
+export const PROJECT = {
+  org: "example-org",
+  repo: "example-app",
+  name: "Example App",
+  tracker: "example-org/example-app-security",
+  securityList: "security@example.org",
+  announceList: "announce@example.org",
+  reporter: "r.chen@example.com",
+  cve: "CVE-2026-XXXXX",
+  fixVersion: "3.2.1",
+  fixPr: 8317,
+} as const;
+
+export type SceneKind =
+  | "terminal"
+  | "mail"
+  | "board"
+  | "interlude"
+  | "vulnogram"
+  | "published";
+
+/**
+ * A moment where Magpie does the work a maintainer would otherwise have done by
+ * hand. The genie pops in, says what it just did and what it cost instead, and
+ * leaves. `at` is a point in the beat's own progress, so it stays put when the
+ * beat is retimed.
+ */
+export type Genie = {
+  at: number;
+  did: string;
+  /** What this kind of work added up to across last month. Deliberately the
+   *  only figure: a per-act saving would be a claim about one run that nobody
+   *  can check, where a month of the same work is the thing a team feels. */
+  month: string;
+};
+
+/** One line in a terminal scene. `kind` picks how it is drawn, not what it says. */
+export type TerminalLine =
+  /** A command the human typed — a slash command of the agent or `/magpie-setup`. */
+  | { kind: "cmd"; text: string }
+  /** What the human actually asks for the rest of the time: plain language. The
+   *  skills are not a command vocabulary anybody has to learn. */
+  | { kind: "ask"; text: string }
+  /** Agent or tool output. */
+  | { kind: "out"; text: string }
+  /** Output worth noticing — a result, a count, a verdict. */
+  | { kind: "note"; text: string }
+  /** A confirmation the human has to answer before anything leaves the machine. */
+  | { kind: "gate"; text: string; answer: string };
+
+export type MailField = { label: string; value: string };
+
+export type BoardCard = {
+  id: string;
+  title: string;
+  severity?: "high" | "none";
+  /** Column index the card starts this beat in. */
+  from: number;
+  /** Column index it ends in. Equal to `from` means it does not move. */
+  to: number;
+  muted?: boolean;
+  /** Progress at which the card leaves the board — closed, done with. */
+  exitAt?: number;
+};
+
+type BeatBase = { ms: number; genie?: Genie };
+
+export type Beat = BeatBase &
+  (
+    | { scene: "terminal"; title: string; lines: TerminalLine[] }
+    | {
+        scene: "mail";
+        title: string;
+        fields: MailField[];
+        body: string[];
+        /** Caption under the Send button, e.g. who is sending and why. */
+        gate: string;
+      }
+    | {
+        scene: "board";
+        title: string;
+        columns: string[];
+        cards: BoardCard[];
+        /** Event captions that fade in across the beat, evenly spaced. */
+        events: string[];
+      }
+    | {
+        /** The genie centre stage: Magpie doing something on its own. */
+        scene: "interlude";
+        title: string;
+        did: string;
+        month: string;
+        /** The artefact it produced, drawn as a card. */
+        card: { kind: string; id: string; title: string; lines: string[] };
+      }
+    | {
+        scene: "vulnogram";
+        title: string;
+        fields: MailField[];
+        gate: string;
+      }
+    | {
+        scene: "published";
+        title: string;
+        advisory: MailField[];
+        summary: string;
+      }
+  );
+
+export type Chapter = { id: string; label: string; beats: Beat[] };
+
+const BOARD_COLUMNS = [
+  "Reported",
+  "Triaged",
+  "Fix in progress",
+  "Fix ready",
+  "Released",
+];
+
+export const SCRIPT: Chapter[] = [
+  {
+    id: "install",
+    label: "Install",
+    beats: [
+      {
+        ms: 8_000,
+        scene: "terminal",
+        title: "Add the marketplace, take the families you need",
+        lines: [
+          { kind: "cmd", text: "/plugin marketplace add apache/magpie" },
+          { kind: "out", text: "Added marketplace apache-magpie (12 plugins)" },
+          { kind: "cmd", text: "/plugin install magpie-setup@apache-magpie" },
+          { kind: "cmd", text: "/plugin install magpie-security@apache-magpie" },
+          { kind: "cmd", text: "/plugin install magpie-agent-guard@apache-magpie" },
+          { kind: "out", text: "Installed: magpie-setup, magpie-security, magpie-agent-guard" },
+          {
+            kind: "note",
+            text: "Nothing committed — the install is yours, on this machine.",
+          },
+        ],
+      },
+    ],
+  },
+  {
+    id: "setup",
+    label: "Set up",
+    beats: [
+      {
+        ms: 9_000,
+        scene: "terminal",
+        title: "The one command worth memorising",
+        lines: [
+          { kind: "cmd", text: "/magpie-setup" },
+          { kind: "out", text: `Detected ${PROJECT.org}/${PROJECT.repo}` },
+          { kind: "out", text: `Tracker ${PROJECT.tracker} · list ${PROJECT.securityList}` },
+          {
+            kind: "gate",
+            text: "Write this to .magpie-local/ (gitignored)?",
+            answer: "y",
+          },
+          { kind: "note", text: "Recommended next, and each is its own step:" },
+          { kind: "out", text: "  · isolate this agent      · install the action guard" },
+          { kind: "out", text: "  · prepare the security model" },
+        ],
+      },
+    ],
+  },
+  {
+    id: "isolate-guard",
+    label: "Isolate & guard",
+    beats: [
+      {
+        ms: 10_000,
+        genie: {
+          at: 0.5,
+          did: "Wrote the sandbox profile and wired the guard hook",
+          month: "6 h of setup, across the team",
+        },
+        scene: "terminal",
+        title: "Two layers about what the agent can reach and what it can run",
+        lines: [
+          { kind: "ask", text: "set up isolation and the action guard" },
+          {
+            kind: "out",
+            text: "Sandbox: Seatbelt — Bash subprocesses see only the paths you allow",
+          },
+          {
+            kind: "gate",
+            text: "Apply 3 settings changes and 1 sudo step?",
+            answer: "y",
+          },
+          { kind: "out", text: "~/.ssh, ~/.aws and your tokens are out of reach" },
+          {
+            kind: "out",
+            text: "magpie-agent-guard: PreToolUse hook now inspects every shell command",
+          },
+          {
+            kind: "note",
+            text: "✗ denied: force-push to a protected branch. The guard refuses — no prompt to click through.",
+          },
+        ],
+      },
+    ],
+  },
+  {
+    id: "privacy",
+    label: "Privacy",
+    beats: [
+      {
+        ms: 8_000,
+        genie: {
+          at: 0.48,
+          did: "Pinned the approved models and wired redaction before any fetch",
+          month: "3 h of privacy review",
+        },
+        scene: "terminal",
+        title: "The layer that is about other people's data, not your machine",
+        lines: [
+          { kind: "ask", text: "set up privacy for this project" },
+          {
+            kind: "out",
+            text: "Approved-LLM gate: a private-list fetch refuses unless every model in the stack is approved",
+          },
+          {
+            kind: "out",
+            text: "PII redaction: names and addresses → N-a3f9d2, E-7c1b04 before a model sees them",
+          },
+          {
+            kind: "note",
+            text: "The mapping that reverses it stays on this machine and is never sent anywhere.",
+          },
+          {
+            kind: "gate",
+            text: "Record the approved model registry for this project?",
+            answer: "y",
+          },
+          {
+            kind: "note",
+            text: "Sandboxes stop the wrong command. This stops the right command exporting someone else's mail.",
+          },
+        ],
+      },
+    ],
+  },
+  {
+    id: "security",
+    label: "Security",
+    beats: [
+      {
+        ms: 7_000,
+        genie: {
+          at: 0.46,
+          did: "Drafted the security model and wired the tracker conventions",
+          month: "5 h of policy-wrangling",
+        },
+        scene: "terminal",
+        title: "Tell the security family what this project is",
+        lines: [
+          { kind: "ask", text: "prepare the security model for this project" },
+          { kind: "out", text: "Read the code, the docs and 2 years of advisories" },
+          {
+            kind: "out",
+            text: "Drafted: trust boundaries · what is in scope · what is not",
+          },
+          {
+            kind: "gate",
+            text: "Open a PR adding SECURITY.md and the tracker conventions?",
+            answer: "y",
+          },
+          {
+            kind: "note",
+            text: "A written model is what lets the next report be routed without a meeting.",
+          },
+        ],
+      },
+    ],
+  },
+  {
+    id: "import",
+    label: "Import",
+    beats: [
+      {
+        ms: 8_000,
+        genie: {
+          at: 0.48,
+          did: "Read the list and opened both issues",
+          month: "6 h of copy-paste",
+        },
+        scene: "terminal",
+        title: "Ask for exactly the window you care about",
+        lines: [
+          {
+            kind: "ask",
+            text: "sync the security issues that arrived in the last 2 days",
+          },
+          { kind: "out", text: `${PROJECT.securityList} — 2 unimported reports` },
+          {
+            kind: "out",
+            text: '  1. "path traversal in archive extractor"  — r.chen@example.com',
+          },
+          {
+            kind: "out",
+            text: '  2. "login page is slow"                   — anon@example.com',
+          },
+          {
+            kind: "gate",
+            text: `Open 2 issues in ${PROJECT.tracker}?`,
+            answer: "y",
+          },
+          {
+            kind: "note",
+            text: "Opened #41 and #42. Identifiers are public; contents stay private.",
+          },
+        ],
+      },
+    ],
+  },
+  {
+    id: "triage",
+    label: "Triage",
+    beats: [
+      {
+        ms: 10_000,
+        genie: {
+          at: 0.5,
+          did: "Checked 148 past issues for duplicates",
+          month: "9 h of searching",
+        },
+        scene: "terminal",
+        title: "Classify, check the history, agree a disposition",
+        lines: [
+          { kind: "ask", text: "triage the two new reports" },
+          { kind: "out", text: "#41 archive extractor — path traversal on member names" },
+          { kind: "note", text: "Severity: high · CWE-22 · affects 3.0.0 – 3.2.0" },
+          {
+            kind: "note",
+            text: "Related to #12 (fixed 2024) — different code path, not a duplicate.",
+          },
+          {
+            kind: "out",
+            text: "Team thread: 3 replies, disposition agreed — accept, fix in 3.2.1",
+          },
+          { kind: "out", text: "#42 login page is slow — no security impact" },
+          {
+            kind: "gate",
+            text: "Close #42 as not-a-vulnerability, with a reply?",
+            answer: "y",
+          },
+        ],
+      },
+      {
+        ms: 7_000,
+        genie: {
+          at: 0.18,
+          did: "Drafted the reply, with the history it just checked",
+          month: "4 h of writing",
+        },
+        scene: "mail",
+        title: "Reply to the reporter — written in an instant, sent by a human",
+        fields: [
+          { label: "To", value: PROJECT.reporter },
+          { label: "From", value: PROJECT.securityList },
+          { label: "Subject", value: "Re: path traversal in archive extractor" },
+        ],
+        body: [
+          "Hello,",
+          "",
+          "Thank you for the report. We have confirmed the issue and are",
+          "tracking it privately; it is not related to the 2024 report you",
+          "may have seen, which touched a different code path.",
+          "",
+          "We will request a CVE and let you know before the advisory is",
+          "published, so you can check the credit line reads the way you",
+          "want it to.",
+          "",
+          "Please keep the details private until then.",
+          "",
+          `-- the ${PROJECT.name} security team`,
+        ],
+        gate: "Plain text, real links, no tracking. Magpie wrote it; you send it.",
+      },
+    ],
+  },
+  {
+    id: "board",
+    label: "Board",
+    beats: [
+      {
+        ms: 6_000,
+        scene: "board",
+        title: "The same issue, on the tracker board",
+        columns: BOARD_COLUMNS,
+        cards: [
+          {
+            id: "#41",
+            title: "Path traversal in archive extractor",
+            severity: "high",
+            from: 0,
+            to: 2,
+          },
+          {
+            id: "#42",
+            title: "Login page is slow — closed, not a vulnerability",
+            severity: "none",
+            from: 0,
+            to: 0,
+            muted: true,
+          },
+        ],
+        events: ["Triaged — severity high, assigned", "Picked up for a fix"],
+      },
+      {
+        ms: 7_000,
+        scene: "interlude",
+        title: "…and the fix opens itself",
+        did: "Opened the fix PR on a private branch, with the test that proves it",
+        month: "7 h of fix-shepherding",
+        card: {
+          kind: "Pull request",
+          id: `${PROJECT.org}/${PROJECT.repo}#${PROJECT.fixPr}`,
+          title: "Normalise archive member paths before extraction",
+          lines: [
+            "+ 24  −3   src/extract.ts",
+            "+ 61  −0   test/extract_traversal_test.ts",
+            "private branch · linked to #41 · awaiting review",
+          ],
+        },
+      },
+      {
+        ms: 6_000,
+        scene: "board",
+        title: "Reviewed, merged, released",
+        columns: BOARD_COLUMNS,
+        cards: [
+          {
+            id: "#41",
+            title: "Path traversal in archive extractor",
+            severity: "high",
+            from: 2,
+            to: 4,
+          },
+          {
+            id: "#42",
+            title: "Login page is slow — closed, not a vulnerability",
+            severity: "none",
+            from: 0,
+            to: 0,
+            muted: true,
+          },
+        ],
+        events: [
+          "Reviewed and merged",
+          `${PROJECT.name} ${PROJECT.fixVersion} released`,
+        ],
+      },
+    ],
+  },
+  {
+    id: "learn",
+    label: "Learn",
+    beats: [
+      {
+        ms: 8_000,
+        genie: {
+          at: 0.46,
+          did: "Turned two dispositions into a sharper security model",
+          month: "4 h of model upkeep nobody ever gets to",
+        },
+        scene: "terminal",
+        title: "The model learns from what you just decided",
+        lines: [
+          {
+            kind: "ask",
+            text: "update the security model with what we learned from #41 and #42",
+          },
+          { kind: "out", text: "Reading both dispositions and the fix that landed" },
+          {
+            kind: "note",
+            text: "#41 accepted → archive extraction is now a named trust boundary",
+          },
+          {
+            kind: "note",
+            text: "#42 rejected → page latency is stated out of scope, so the next one routes itself",
+          },
+          {
+            kind: "gate",
+            text: "Open a PR updating SECURITY.md with both?",
+            answer: "y",
+          },
+          {
+            kind: "note",
+            text: "Every report makes the model sharper instead of leaving it staler.",
+          },
+        ],
+      },
+    ],
+  },
+  {
+    id: "publish",
+    label: "Publish",
+    beats: [
+      {
+        ms: 8_000,
+        genie: {
+          at: 0.44,
+          did: "Filled the CVE record from the issue and the fix",
+          month: "3 h of form-filling",
+        },
+        scene: "vulnogram",
+        title: "The CVE record",
+        fields: [
+          { label: "CVE ID", value: PROJECT.cve },
+          { label: "Product", value: `${PROJECT.name} (${PROJECT.org})` },
+          { label: "Affected", value: "3.0.0 – 3.2.0" },
+          { label: "Fixed in", value: PROJECT.fixVersion },
+          { label: "CWE", value: "CWE-22 — path traversal" },
+          { label: "CVSS", value: "7.5 (high)" },
+          { label: "Credit", value: "R. Chen" },
+          { label: "References", value: `${PROJECT.org}/${PROJECT.repo}#${PROJECT.fixPr}` },
+        ],
+        gate: "Submit the record to the CVE program?",
+      },
+      {
+        ms: 8_000,
+        genie: {
+          at: 0.18,
+          did: "Drafted the advisory from the record and the fix",
+          month: "3 h of writing",
+        },
+        scene: "mail",
+        title: "Announce it — written in an instant, sent by a human",
+        fields: [
+          { label: "To", value: PROJECT.announceList },
+          { label: "From", value: PROJECT.securityList },
+          {
+            label: "Subject",
+            value: `[${PROJECT.cve}] Path traversal in ${PROJECT.name} archive extractor`,
+          },
+        ],
+        body: [
+          `${PROJECT.cve}: path traversal in the ${PROJECT.name} archive extractor`,
+          "",
+          "Severity: high (CVSS 7.5, CWE-22)",
+          "Affected:  3.0.0 through 3.2.0",
+          `Fixed in:  ${PROJECT.fixVersion}`,
+          "",
+          "An archive member name was joined to the extraction directory",
+          "without normalisation, so a crafted archive could write outside",
+          "it. Upgrade to 3.2.1. There is no workaround.",
+          "",
+          "Credit: R. Chen, who reported it privately and followed the",
+          "embargo throughout.",
+          "",
+          `https://example.org/security/${PROJECT.cve}`,
+        ],
+        gate: "The last gate in the story — and a person is still the one pressing it.",
+      },
+      {
+        ms: 6_000,
+        genie: {
+          at: 0.28,
+          did: "Closed #41 — advisory out, nothing left to chase",
+          month: "31 h across 12 reports",
+        },
+        scene: "board",
+        title: "Closed — and off the board",
+        columns: BOARD_COLUMNS,
+        cards: [
+          {
+            id: "#41",
+            title: "Path traversal in archive extractor — closed",
+            severity: "high",
+            from: 4,
+            to: 4,
+            exitAt: 0.45,
+          },
+          {
+            id: "#42",
+            title: "Login page is slow — closed, not a vulnerability",
+            severity: "none",
+            from: 0,
+            to: 0,
+            muted: true,
+            exitAt: 0.6,
+          },
+        ],
+        events: [
+          `${PROJECT.cve} published — closing #41`,
+          "Board clear. Nothing left open.",
+        ],
+      },
+      {
+        ms: 4_000,
+        scene: "published",
+        title: "Published",
+        advisory: [
+          { label: "ID", value: PROJECT.cve },
+          { label: "Published", value: "2026-09-13" },
+          { label: "Severity", value: "7.5 HIGH" },
+          { label: "Weakness", value: "CWE-22" },
+          { label: "Status", value: "Analyzed" },
+        ],
+        summary:
+          "One report, one fix, one release, one advisory — and every message that left the project was written by Magpie and sent by a person.",
+      },
+    ],
+  },
+];
+
+/**
+ * The story as plain text, derived from the script above so it cannot drift
+ * from what the animation shows. This is the accessible fallback: it is what a
+ * screen reader, a search engine, and a reader with reduced motion or no
+ * JavaScript get.
+ */
+export function transcript(): { chapter: string; lines: string[] }[] {
+  return SCRIPT.map((chapter) => {
+    const lines: string[] = [];
+    for (const beat of chapter.beats) {
+      lines.push(beat.title);
+      if (beat.genie)
+        lines.push(
+          `Magpie did that: ${beat.genie.did} — ${beat.genie.month} last month.`,
+        );
+      switch (beat.scene) {
+        case "terminal":
+          for (const line of beat.lines) {
+            if (line.kind === "cmd") lines.push(`$ ${line.text}`);
+            else if (line.kind === "ask") lines.push(`You: ${line.text}`);
+            else if (line.kind === "gate")
+              lines.push(`${line.text} — answered "${line.answer}"`);
+            else lines.push(line.text);
+          }
+          break;
+        case "mail":
+          for (const f of beat.fields) lines.push(`${f.label}: ${f.value}`);
+          lines.push(...beat.body);
+          lines.push(beat.gate);
+          break;
+        case "board":
+          lines.push(`Columns: ${beat.columns.join(" → ")}`);
+          for (const c of beat.cards)
+            lines.push(
+              `${c.id} ${c.title} — ${beat.columns[c.from]} to ${beat.columns[c.to]}`,
+            );
+          lines.push(...beat.events);
+          break;
+        case "vulnogram":
+          for (const f of beat.fields) lines.push(`${f.label}: ${f.value}`);
+          lines.push(beat.gate);
+          break;
+        case "interlude":
+          lines.push(`${beat.did} — ${beat.month} last month.`);
+          lines.push(`${beat.card.kind} ${beat.card.id}: ${beat.card.title}`);
+          lines.push(...beat.card.lines);
+          break;
+        case "published":
+          for (const f of beat.advisory) lines.push(`${f.label}: ${f.value}`);
+          lines.push(beat.summary);
+          break;
+      }
+    }
+    return { chapter: chapter.label, lines };
+  });
+}
diff --git a/src/components/landing/security-story/timeline.ts b/src/components/landing/security-story/timeline.ts
new file mode 100644
index 0000000..98d3b11
--- /dev/null
+++ b/src/components/landing/security-story/timeline.ts
@@ -0,0 +1,74 @@
+// The cursor maths, kept free of React so it is a plain module: importable by a
+// test runner (this repo has none yet) and runnable directly by node's type
+// stripping. See useTimeline.ts for the clock that drives it.
+import type { Beat, Chapter } from "./script";
+
+/**
+ * A beat's content plays over its own `ms`; then every beat holds this long on
+ * its finished state before the story moves on. Kept out of `ms` so the hold is
+ * a beat of silence rather than a slower performance — folding it into `ms`
+ * would just stretch the typing.
+ */
+export const BEAT_HOLD_MS = 1_000;
+
+/** Total run length of a script, holds included. */
+export function totalMs(script: Chapter[]): number {
+  return script.reduce(
+    (sum, chapter) =>
+      sum + chapter.beats.reduce((s, b) => s + b.ms + BEAT_HOLD_MS, 0),
+    0,
+  );
+}
+
+export type Cursor = {
+  chapterIndex: number;
+  beatIndex: number;
+  /** 0→1 through the current beat. */
+  progress: number;
+  beat: Beat;
+};
+
+/**
+ * Where the story is at `elapsed` milliseconds.
+ *
+ * Pure, and the reason the rest of this component is simple: because any
+ * elapsed value maps straight onto a renderable cursor, jumping to a chapter is
+ * just assigning `elapsed`. No scene needs to know how to fast-forward itself.
+ *
+ * Exported on its own so it can be unit-tested the moment this repo grows a
+ * test runner; it has none today.
+ */
+export function cursorAt(script: Chapter[], elapsed: number): Cursor {
+  let remaining = Math.max(0, elapsed);
+  for (let c = 0; c < script.length; c += 1) {
+    const beats = script[c].beats;
+    for (let b = 0; b < beats.length; b += 1) {
+      const beat = beats[b];
+      const span = beat.ms + BEAT_HOLD_MS;
+      if (remaining < span) {
+        return {
+          chapterIndex: c,
+          beatIndex: b,
+          // Clamped, so the trailing hold sits on the finished frame rather
+          // than running the scene past its end.
+          progress: beat.ms === 0 ? 1 : Math.min(1, remaining / beat.ms),
+          beat,
+        };
+      }
+      remaining -= span;
+    }
+  }
+  // Past the end: hold the final frame.
+  const c = script.length - 1;
+  const b = script[c].beats.length - 1;
+  return { chapterIndex: c, beatIndex: b, progress: 1, beat: script[c].beats[b] };
+}
+
+/** Elapsed time at which a chapter starts — what a chapter jump assigns. */
+export function chapterStart(script: Chapter[], chapterIndex: number): number {
+  let ms = 0;
+  for (let c = 0; c < chapterIndex; c += 1)
+    for (const beat of script[c].beats) ms += beat.ms + BEAT_HOLD_MS;
+  return ms;
+}
+
diff --git a/src/components/landing/security-story/useTimeline.ts b/src/components/landing/security-story/useTimeline.ts
new file mode 100644
index 0000000..3051e82
--- /dev/null
+++ b/src/components/landing/security-story/useTimeline.ts
@@ -0,0 +1,125 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { Chapter } from "./script";
+import { cursorAt } from "./timeline";
+
+export { cursorAt, chapterStart, totalMs, BEAT_HOLD_MS } from "./timeline";
+export type { Cursor } from "./timeline";
+
+function prefersReducedMotion(): boolean {
+  if (typeof window === "undefined" || !window.matchMedia) return false;
+  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+}
+
+export function useTimeline(script: Chapter[], totalMs: number) {
+  const [elapsed, setElapsed] = useState(0);
+  const [running, setRunning] = useState(false);
+  // Set once on mount rather than read during render, so the server-rendered
+  // markup and the first client render agree.
+  const [reduced, setReduced] = useState(false);
+
+  // Inputs the rAF loop reads without wanting to restart when they change.
+  const elapsedRef = useRef(0);
+  const frameRef = useRef<number | null>(null);
+  const lastRef = useRef<number | null>(null);
+  const pausedByUser = useRef(false);
+  const inView = useRef(false);
+
+  useEffect(() => setReduced(prefersReducedMotion()), []);
+
+  const apply = useCallback((ms: number) => {
+    elapsedRef.current = ms;
+    setElapsed(ms);
+  }, []);
+
+  // The clock. One loop for the whole component: scenes derive everything they
+  // draw from `elapsed`, so nothing else needs a timer.
+  useEffect(() => {
+    if (!running || reduced) return undefined;
+    const tick = (now: number) => {
+      const last = lastRef.current;
+      lastRef.current = now;
+      if (last !== null) {
+        const next = elapsedRef.current + (now - last);
+        if (next >= totalMs) {
+          apply(totalMs);
+          setRunning(false);
+          lastRef.current = null;
+          return;
+        }
+        apply(next);
+      }
+      frameRef.current = requestAnimationFrame(tick);
+    };
+    frameRef.current = requestAnimationFrame(tick);
+    return () => {
+      if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
+      frameRef.current = null;
+      lastRef.current = null;
+    };
+  }, [running, reduced, totalMs, apply]);
+
+  // A hidden tab should not burn frames.
+  useEffect(() => {
+    const onVisibility = () => {
+      if (document.hidden) setRunning(false);
+      else if (!pausedByUser.current && inView.current && elapsedRef.current < totalMs)
+        setRunning(true);
+    };
+    document.addEventListener("visibilitychange", onVisibility);
+    return () => document.removeEventListener("visibilitychange", onVisibility);
+  }, [totalMs]);
+
+  const setInView = useCallback(
+    (visible: boolean) => {
+      inView.current = visible;
+      if (reduced) return;
+      if (visible && !pausedByUser.current && elapsedRef.current < totalMs)
+        setRunning(true);
+      else if (!visible) setRunning(false);
+    },
+    [reduced, totalMs],
+  );
+
+  /** Hover and focus pause; leaving resumes, unless the reader paused on purpose. */
+  const setHovered = useCallback(
+    (hovered: boolean) => {
+      if (reduced) return;
+      if (hovered) setRunning(false);
+      else if (!pausedByUser.current && inView.current && elapsedRef.current < totalMs)
+        setRunning(true);
+    },
+    [reduced, totalMs],
+  );
+
+  const togglePaused = useCallback(() => {
+    pausedByUser.current = !pausedByUser.current;
+    setRunning(!pausedByUser.current && elapsedRef.current < totalMs);
+  }, [totalMs]);
+
+  const jumpTo = useCallback(
+    (ms: number) => {
+      pausedByUser.current = false;
+      apply(Math.min(Math.max(0, ms), totalMs));
+      if (!reduced) setRunning(true);
+    },
+    [apply, reduced, totalMs],
+  );
+
+  const replay = useCallback(() => jumpTo(0), [jumpTo]);
+
+  return {
+    cursor: cursorAt(script, elapsed),
+    elapsed,
+    running,
+    reduced,
+    finished: elapsed >= totalMs,
+    pausedByUser: pausedByUser.current,
+    setInView,
+    setHovered,
+    togglePaused,
+    jumpTo,
+    replay,
+  };
+}