diff --git a/marketing/animations/.gitignore b/marketing/animations/.gitignore
new file mode 100644
index 00000000..b665b878
--- /dev/null
+++ b/marketing/animations/.gitignore
@@ -0,0 +1,4 @@
+frames/
+# and as a symlink: a worktree that shares the primary checkout's install has
+# node_modules as a link, which `node_modules/` does not match.
+node_modules
diff --git a/marketing/animations/artifactory.html b/marketing/animations/artifactory.html
new file mode 100644
index 00000000..9ad4dcc2
--- /dev/null
+++ b/marketing/animations/artifactory.html
@@ -0,0 +1,268 @@
+
+
+
+
+GraphCode — a note for whoever comes next
+
+
+
+
+
graphcode · artifactory
+
A note for whoever comes next
+
+
+
Fixer
+
Reviewer
+
Auditor
+
+
node send
+
+
three days later
+
+
+
Artifactory
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/marketing/animations/build.sh b/marketing/animations/build.sh
new file mode 100755
index 00000000..39ba2765
--- /dev/null
+++ b/marketing/animations/build.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# Renders every animation to a GIF (for X) and an MP4 (for anywhere that takes video).
+#
+# ./build.sh all figures
+# ./build.sh pass-one-vs-pass-two
+#
+# Frames are stepped through window.__seek, so a re-run of an unchanged file produces
+# byte-identical output — the GIF can never drift from the HTML it came from.
+set -euo pipefail
+cd "$(dirname "$0")"
+
+[ -d node_modules/playwright-core ] || npm i --silent playwright-core
+
+CAPTURE_FPS=25 # what the frames are stepped at, and the MP4's rate
+GIF_FPS=12.5 # halved for the GIF: same motion, half the palette work
+WIDTH=1200
+HEIGHT=675
+
+figures=("$@")
+if [ ${#figures[@]} -eq 0 ]; then
+ figures=(loop-learns loops-talk bundle-travels fresh-identity)
+fi
+
+mkdir -p out
+for name in "${figures[@]}"; do
+ name="${name%.html}"
+ echo "── $name"
+ node render.mjs "$name.html" "frames/$name" "$CAPTURE_FPS" "$WIDTH" "$HEIGHT"
+
+ ffmpeg -loglevel error -y -framerate "$CAPTURE_FPS" -i "frames/$name/f%04d.png" \
+ -c:v libx264 -pix_fmt yuv420p -crf 18 -movflags +faststart \
+ -vf "scale=1600:-2:flags=lanczos" "out/$name.mp4"
+
+ # Two passes: a palette built from the whole clip, then applied. One-pass GIFs band
+ # badly on these flat dark panels.
+ ffmpeg -loglevel error -y -framerate "$CAPTURE_FPS" -i "frames/$name/f%04d.png" \
+ -vf "fps=$GIF_FPS,scale=1100:-1:flags=lanczos,palettegen=max_colors=192:stats_mode=diff" \
+ -y "frames/$name/palette.png"
+ ffmpeg -loglevel error -y -framerate "$CAPTURE_FPS" -i "frames/$name/f%04d.png" \
+ -i "frames/$name/palette.png" \
+ -lavfi "fps=$GIF_FPS,scale=1100:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=3:diff_mode=rectangle" \
+ -loop 0 "out/$name.gif"
+
+ # A still for anywhere that won't take motion, taken from the held final frame.
+ cp "frames/$name/$(ls "frames/$name" | grep '^f' | tail -1)" "out/$name.png"
+
+ printf ' %-34s %s\n' "$name.gif" "$(du -h "out/$name.gif" | cut -f1)"
+ printf ' %-34s %s\n' "$name.mp4" "$(du -h "out/$name.mp4" | cut -f1)"
+done
diff --git a/marketing/animations/out/artifactory.gif b/marketing/animations/out/artifactory.gif
new file mode 100644
index 00000000..9d2fcde2
Binary files /dev/null and b/marketing/animations/out/artifactory.gif differ
diff --git a/marketing/animations/out/artifactory.mp4 b/marketing/animations/out/artifactory.mp4
new file mode 100644
index 00000000..2c2ef4f6
Binary files /dev/null and b/marketing/animations/out/artifactory.mp4 differ
diff --git a/marketing/animations/out/artifactory.png b/marketing/animations/out/artifactory.png
new file mode 100644
index 00000000..3e925c0c
Binary files /dev/null and b/marketing/animations/out/artifactory.png differ
diff --git a/marketing/animations/package-lock.json b/marketing/animations/package-lock.json
new file mode 100644
index 00000000..230946d3
--- /dev/null
+++ b/marketing/animations/package-lock.json
@@ -0,0 +1,28 @@
+{
+ "name": "animations",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "animations",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "playwright-core": "^1.62.1"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ }
+ }
+}
diff --git a/marketing/animations/package.json b/marketing/animations/package.json
new file mode 100644
index 00000000..a8cb721d
--- /dev/null
+++ b/marketing/animations/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "animations",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "type": "module",
+ "dependencies": {
+ "playwright-core": "^1.62.1"
+ }
+}
diff --git a/marketing/animations/probe.mjs b/marketing/animations/probe.mjs
new file mode 100644
index 00000000..c86daa58
--- /dev/null
+++ b/marketing/animations/probe.mjs
@@ -0,0 +1,17 @@
+import { chromium } from 'playwright-core';
+import { resolve } from 'node:path';
+const [file, ...times] = process.argv.slice(2);
+const browser = await chromium.launch({ channel: 'chrome' });
+const page = await browser.newPage({ viewport:{width:1200,height:675}, deviceScaleFactor:2, colorScheme:'dark' });
+const errs = [];
+page.on('pageerror', e => errs.push(String(e)));
+page.on('console', m => { if (m.type()==='error') errs.push(m.text()); });
+await page.goto('file://' + resolve(file));
+await page.waitForFunction(() => typeof window.__seek === 'function');
+await page.evaluate(() => window.__pause());
+for (const t of times) {
+ await page.evaluate(v => window.__seek(v), Number(t));
+ await page.screenshot({ path: `probe-${t}.png` });
+}
+await browser.close();
+console.log(errs.length ? 'ERRORS:\n' + errs.join('\n') : 'no console errors');
diff --git a/marketing/animations/render.mjs b/marketing/animations/render.mjs
new file mode 100644
index 00000000..7dfbbe27
--- /dev/null
+++ b/marketing/animations/render.mjs
@@ -0,0 +1,42 @@
+// Frame-capture for the article animations.
+//
+// node render.mjs [fps] [width] [height]
+//
+// Each animation exposes `window.__seek(t)` and `window.__duration`, so frames are
+// stepped rather than recorded in real time: the capture is deterministic and the GIF
+// can never drift from the HTML it came from.
+import { chromium } from 'playwright-core';
+import { mkdir, rm } from 'node:fs/promises';
+import { resolve } from 'node:path';
+
+const [file, outDir, fpsArg, wArg, hArg] = process.argv.slice(2);
+if (!file || !outDir) {
+ console.error('usage: node render.mjs [fps] [width] [height]');
+ process.exit(1);
+}
+
+const fps = Number(fpsArg ?? 25);
+const width = Number(wArg ?? 1200);
+const height = Number(hArg ?? 760);
+
+await rm(outDir, { recursive: true, force: true });
+await mkdir(outDir, { recursive: true });
+
+const browser = await chromium.launch({ channel: 'chrome' });
+const page = await browser.newPage({
+ viewport: { width, height },
+ deviceScaleFactor: 2,
+ colorScheme: 'dark',
+});
+await page.goto('file://' + resolve(file));
+await page.waitForFunction(() => typeof window.__seek === 'function');
+await page.evaluate(() => window.__pause());
+
+const duration = await page.evaluate(() => window.__duration);
+const frames = Math.round(duration * fps);
+for (let i = 0; i < frames; i++) {
+ await page.evaluate(t => window.__seek(t), i / fps);
+ await page.screenshot({ path: `${outDir}/f${String(i).padStart(4, '0')}.png` });
+}
+await browser.close();
+console.log(`${frames} frames @ ${fps}fps (${duration}s) → ${outDir}`);