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 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,
+ };
+}