diff --git a/apps/web/src/components/hero/airship-mock/agent-timeline.tsx b/apps/web/src/components/hero/airship-mock/agent-timeline.tsx
new file mode 100644
index 0000000..637fe82
--- /dev/null
+++ b/apps/web/src/components/hero/airship-mock/agent-timeline.tsx
@@ -0,0 +1,33 @@
+import { MOCK_TOOL_CALLS } from "#/content/mock-page";
+
+/**
+ * The agent's tool calls, streaming.
+ *
+ * Rendered in Claude Code's own transcript grammar — a filled marker and a bold
+ * tool name for the call, an indented `⎿` elbow for its result — because that is
+ * exactly what airship's real overlay renders. Someone who has used the tool
+ * should recognise this before they read a word of it.
+ *
+ * Every row is present in the DOM from the start and revealed by the timeline;
+ * nothing is mounted or unmounted, so the transcript's height never jumps
+ * mid-stream.
+ */
+export function AgentTimeline() {
+ return (
+
+ {MOCK_TOOL_CALLS.map((call, index) => (
+
+
+ ●
+ {call.tool}
+ {call.args}
+
+
+ ⌋
+ {call.result}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/hero/airship-mock/bottom-bar.tsx b/apps/web/src/components/hero/airship-mock/bottom-bar.tsx
new file mode 100644
index 0000000..3c2ac98
--- /dev/null
+++ b/apps/web/src/components/hero/airship-mock/bottom-bar.tsx
@@ -0,0 +1,70 @@
+import { EditorGlyph } from "#/components/hero/airship-mock/editor-glyph";
+
+/**
+ * The floating bottom bar.
+ *
+ * Compact and understated on purpose: a small radius, a hairline border and a
+ * ring rather than a pill with a drop shadow. It is the one piece of chrome that
+ * is always on screen, and the version of it that announces itself gets tiring
+ * within a minute.
+ *
+ * Left to right: undo/redo, the tool group, inspect, the Edit|View toggle, and
+ * the pending-tweak counter — which is the only thing on the bar that moves.
+ */
+export function BottomBar() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Edit
+
+
+
+ View
+
+
+
+ {/*
+ Collapsed to zero width until the first tweak lands, so the bar has no
+ empty slot waiting to be filled — it simply grows.
+ */}
+
+
+ 1
+ 2
+
+
+
+ );
+}
diff --git a/apps/web/src/components/hero/airship-mock/composer-field.tsx b/apps/web/src/components/hero/airship-mock/composer-field.tsx
new file mode 100644
index 0000000..4c92542
--- /dev/null
+++ b/apps/web/src/components/hero/airship-mock/composer-field.tsx
@@ -0,0 +1,40 @@
+import { EditorGlyph } from "#/components/hero/airship-mock/editor-glyph";
+import { MOCK_PROMPT, MOCK_SELECTION } from "#/content/mock-page";
+
+/**
+ * Where you say what you want.
+ *
+ * One bordered box holding the selection chip, the prompt and the send button —
+ * which is the shape of airship's real composer, and the shape of the claim: the
+ * chip says *what*, the sentence says *what about it*, and that is the entire
+ * interface.
+ *
+ * The prompt is real text clipped from the right by the timeline rather than a
+ * string assembled by a timer. That keeps the typing honest under the pause
+ * button and under `prefers-reduced-motion`, and means it costs nothing when the
+ * hero is scrolled out of view.
+ */
+export function ComposerField() {
+ return (
+
+
+
+
+ {MOCK_SELECTION.tag}
+ ×
+
+
+
+
+ Describe the change…
+ {MOCK_PROMPT}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/hero/airship-mock/design-dock.tsx b/apps/web/src/components/hero/airship-mock/design-dock.tsx
new file mode 100644
index 0000000..62ff265
--- /dev/null
+++ b/apps/web/src/components/hero/airship-mock/design-dock.tsx
@@ -0,0 +1,214 @@
+import { AgentTimeline } from "#/components/hero/airship-mock/agent-timeline";
+import { ComposerField } from "#/components/hero/airship-mock/composer-field";
+import { DiffCard } from "#/components/hero/airship-mock/diff-card";
+import { EditorGlyph } from "#/components/hero/airship-mock/editor-glyph";
+import {
+ NumField,
+ PaintRow,
+ Segmented,
+ Select,
+ SwapValue,
+} from "#/components/hero/airship-mock/inspector-controls";
+import { InspectorSection } from "#/components/hero/airship-mock/inspector-section";
+import { MOCK_PROMPT, MOCK_SELECTION, MOCK_STATUS } from "#/content/mock-page";
+import { TWEAKS } from "#/content/timeline";
+
+/*
+ * The nine align cells, in the real panel's order: horizontal, vertical, then
+ * distribute-and-tidy.
+ */
+const ALIGN_GROUPS = [
+ ["alignLeft", "alignCenterH", "alignRight"],
+ ["alignTop", "alignCenterV", "alignBottom"],
+ ["distributeH", "distributeV", "tidy"],
+] as const;
+
+const TABS = [
+ { glyph: "logo", label: "Agent" },
+ { glyph: "design", label: "Edit" },
+ { glyph: "code", label: "CSS" },
+ { glyph: "layers", label: "DOM" },
+] as const;
+
+/**
+ * One dock, four tabs.
+ *
+ * ── A deliberate departure from the real editor ──────────────────────────────
+ * airship actually runs TWO docks: chat on the left at 340px, inspector on the
+ * right at 360px. This mock folds the chat in as an *Agent* tab instead.
+ *
+ * That is a simplification for the hero specifically, and it is worth being
+ * honest about. Two 200px panels flanking a 285px strip of page reads as clutter
+ * at this render scale, and it splits attention exactly where the page is trying
+ * to make one point. One panel with the agent in front says the same thing more
+ * quietly — and the Edit / CSS / DOM tabs beside it still show that a full
+ * inspector is part of the product.
+ *
+ * Everything INSIDE the tabs is transcribed 1:1 as usual. It is the arrangement
+ * that is editorial, not the components.
+ */
+export function DesignDock() {
+ return (
+
+
+
+
+ Airship
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ Empty and filled states stack in one relative box and cross-fade, so
+ neither ever reflows the dock.
+ */}
+
+
+
+ Ask airship to change anything
+
+ Pick an element first to scope it.
+
+
+
+ {/* Both tab bodies stacked; the timeline decides which is in front. */}
+
+
+
+
{MOCK_PROMPT}
+
+
+
+
+
+
+ {/* Both labels in one grid cell, so the row does not
+ re-wrap when "Applying…" becomes "Done". */}
+
+
+ {MOCK_STATUS.working}
+
+ {MOCK_STATUS.done}
+
+
+ );
+}
diff --git a/apps/web/src/components/hero/airship-mock/diff-card.tsx b/apps/web/src/components/hero/airship-mock/diff-card.tsx
new file mode 100644
index 0000000..df8dc9c
--- /dev/null
+++ b/apps/web/src/components/hero/airship-mock/diff-card.tsx
@@ -0,0 +1,29 @@
+import { MOCK_DIFF } from "#/content/mock-page";
+
+/**
+ * The diff the agent produced, before you accept it.
+ *
+ * Collapsed to `max-height: 0` rather than unmounted, so it can grow open on the
+ * timeline. The header carries the two things you check first — which file, and
+ * how much changed — and only then the body.
+ */
+export function DiffCard() {
+ return (
+
+
+ {MOCK_DIFF.file}
+ {MOCK_DIFF.stat}
+
+
+ {MOCK_DIFF.lines.map((line, index) => (
+
+ {line.text}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/hero/airship-mock/editor-glyph.tsx b/apps/web/src/components/hero/airship-mock/editor-glyph.tsx
new file mode 100644
index 0000000..d03ead2
--- /dev/null
+++ b/apps/web/src/components/hero/airship-mock/editor-glyph.tsx
@@ -0,0 +1,40 @@
+import { GLYPHS, type GlyphName } from "#/content/editor-glyphs";
+
+/**
+ * Every glyph in the hero's editor, rendered from one place.
+ *
+ * `aria-hidden` is not a prop: the entire hero is an illustration, and there is
+ * no glyph in it that carries meaning a screen reader should hear. Hard-coding
+ * it here means `noSvgWithoutTitle` is satisfied once rather than at twenty call
+ * sites, and it cannot be forgotten at the twenty-first.
+ *
+ * The body is injected as markup because the path data is vendored-shaped
+ * constant text; turning it into JSX elements would buy nothing and would let
+ * the formatter reflow coordinates that are meant to stay byte-stable.
+ */
+export function EditorGlyph({
+ className,
+ name,
+ size = 24,
+}: {
+ className?: string;
+ name: GlyphName;
+ size?: number;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/hero/airship-mock/inline-overlay.tsx b/apps/web/src/components/hero/airship-mock/inline-overlay.tsx
new file mode 100644
index 0000000..4f07f55
--- /dev/null
+++ b/apps/web/src/components/hero/airship-mock/inline-overlay.tsx
@@ -0,0 +1,36 @@
+import { BottomBar } from "#/components/hero/airship-mock/bottom-bar";
+import { DesignDock } from "#/components/hero/airship-mock/design-dock";
+import { MockPage } from "#/components/hero/airship-mock/mock-page";
+import { PickerOverlay } from "#/components/hero/airship-mock/picker-overlay";
+
+/**
+ * Airship in inline mode: the overlay injected straight into the running app.
+ *
+ * `.ap-mock` is authored at a fixed 1200×636 and shrunk with a single
+ * `transform: scale()`, so every number in hero-overlay.css is the real editor's
+ * number rather than a pre-divided one. It is also the scope the `--ap-*` editor
+ * palette is generated into, and — because it carries a transform — the
+ * containing block the docks position against.
+ *
+ * Order matters: the page paints first, the picker's chrome layer sits above it,
+ * and the docks sit above that. In the real overlay the same stack is enforced
+ * with z-index for the same reason — a selection outline drawn under a panel is
+ * a selection you cannot see.
+ *
+ * One dock, with the agent in front. The real editor runs two — chat left,
+ * inspector right — and folding them together is a deliberate simplification for
+ * the hero; see the note in design-dock.tsx. What is NOT negotiable is that the
+ * agent is the tab you land on: an earlier draft showed only the inspector and,
+ * without meaning to, argued that airship is a visual CSS editor that happens to
+ * write files.
+ */
+export function InlineOverlay() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/hero/airship-mock/inspector-controls.tsx b/apps/web/src/components/hero/airship-mock/inspector-controls.tsx
new file mode 100644
index 0000000..0fa9734
--- /dev/null
+++ b/apps/web/src/components/hero/airship-mock/inspector-controls.tsx
@@ -0,0 +1,161 @@
+import type { ReactNode } from "react";
+import { EditorGlyph } from "#/components/hero/airship-mock/editor-glyph";
+import type { GlyphName } from "#/content/editor-glyphs";
+
+/*
+ * The four control primitives the inspector is built from.
+ *
+ * All static: this is a picture of a panel, not a panel. Nothing here takes an
+ * onChange, and the values that appear to change during the loop are driven by
+ * CSS on `.ap-ctl-value`, not by state — which is what lets the whole animation
+ * survive `animation-play-state: paused` and `prefers-reduced-motion` without a
+ * single line of JavaScript.
+ */
+
+/**
+ * A number field. Borderless until touched; the glyph is also the scrub handle,
+ * which is why it is 20px and sits inside the field rather than beside it.
+ *
+ * `letter` renders a mono character (W, H, X, Y) where the real panel has no
+ * pictogram for the property — the letter IS the icon there.
+ */
+export function NumField({
+ className,
+ glyph,
+ letter,
+ suffix,
+ value,
+ valueClassName,
+}: {
+ className?: string;
+ glyph?: GlyphName;
+ letter?: string;
+ suffix?: string;
+ value: ReactNode;
+ valueClassName?: string;
+}) {
+ return (
+
+ );
+}
+
+/**
+ * A value that changes during the loop, rendered as both states at once.
+ *
+ * CSS cannot rewrite text, so the two readings are stacked in a single grid cell
+ * and cross-faded. The grid — rather than absolute positioning — is what makes
+ * the field size to the WIDER of the two ("9999", not "6"), so the panel does
+ * not reflow at the moment the value changes. That reflow is exactly the sort of
+ * thing that reads as a glitch rather than as an edit.
+ */
+export function SwapValue({
+ from,
+ name,
+ to,
+}: {
+ from: string;
+ name: string;
+ to: string;
+}) {
+ return (
+
+ {from}
+ {to}
+
+ );
+}
+
+/** A dropdown. Bordered at rest — it opens something, so it looks like it does. */
+export function Select({ value }: { value: string }) {
+ return (
+
+ {value}
+
+
+ );
+}
+
+/**
+ * A segmented control. Text options stay pills; an all-icon group becomes square
+ * 24px cells, because a row of icon pills reads as five separate buttons.
+ */
+export function Segmented({
+ activeIndex,
+ icons,
+ options,
+}: {
+ activeIndex: number;
+ icons?: readonly GlyphName[];
+ options?: readonly string[];
+}) {
+ const cell = (index: number) =>
+ index === activeIndex ? "ap-ctl-seg-btn ap-ctl-seg-on" : "ap-ctl-seg-btn";
+
+ // Two branches rather than one loop with a ternary inside. A shared loop needs
+ // `item as GlyphName` to satisfy the union, which discards exactly the check
+ // that makes the glyph names safe in the first place.
+ if (icons) {
+ return (
+
+ {icons.map((name, index) => (
+
+
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ {(options ?? []).map((label, index) => (
+
+ {label}
+
+ ))}
+
+ );
+}
+
+/** A colour swatch over a conic checkerboard, plus its hex and alpha fields. */
+export function PaintRow({
+ alpha,
+ hex,
+ hexClassName,
+ swatchClassName,
+}: {
+ alpha: string;
+ hex: ReactNode;
+ hexClassName?: string;
+ swatchClassName?: string;
+}) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/hero/airship-mock/inspector-section.tsx b/apps/web/src/components/hero/airship-mock/inspector-section.tsx
new file mode 100644
index 0000000..feefbeb
--- /dev/null
+++ b/apps/web/src/components/hero/airship-mock/inspector-section.tsx
@@ -0,0 +1,38 @@
+import type { ReactNode } from "react";
+import { EditorGlyph } from "#/components/hero/airship-mock/editor-glyph";
+
+/**
+ * One collapsible section of the inspector.
+ *
+ * The header puts its title first and its chevron last, so every arrow in the
+ * panel lines up in a single column down the right edge however long the title
+ * is. That is a deliberate choice in the real panel and it is the detail that
+ * makes a stack of twelve sections read as one control rather than twelve.
+ *
+ * A section with no `children` renders header-only — which is also what the real
+ * panel does for SOURCE and FILTERS, and what keeps this mock legible: at the
+ * hero's render scale, twelve expanded sections would be grey noise.
+ */
+export function InspectorSection({
+ children,
+ className,
+ label,
+}: {
+ children?: ReactNode;
+ className?: string;
+ label: string;
+}) {
+ return (
+
+
+ {label}
+
+
+ {children ?
{children}
: null}
+
+ );
+}
diff --git a/apps/web/src/components/hero/airship-mock/mock-page.tsx b/apps/web/src/components/hero/airship-mock/mock-page.tsx
new file mode 100644
index 0000000..ff478bc
--- /dev/null
+++ b/apps/web/src/components/hero/airship-mock/mock-page.tsx
@@ -0,0 +1,56 @@
+import { EditorGlyph } from "#/components/hero/airship-mock/editor-glyph";
+import { MOCK_PAGE } from "#/content/mock-page";
+
+/**
+ * The app being edited: this site, in miniature.
+ *
+ * Not a fictional product — `make run` points the CLI at apps/web, so the page
+ * inside the hero's browser window really is the page around it. The button the
+ * agent changes is the same button a reader can see a few hundred pixels above.
+ *
+ * `.ap-cta` carries the id the measuring effect looks for, and its radius and
+ * fill come from custom properties the timeline drives. Everything else here is
+ * scenery — it exists so the picker has somewhere plausible to hover.
+ */
+export function MockPage() {
+ return (
+
. This is a picture of a heading inside an
+ aria-hidden illustration; giving it a real heading element puts a
+ phantom entry in the document outline that no reader can reach.
+ */}
+
+ );
+}
diff --git a/apps/web/src/components/hero/airship-mock/picker-overlay.tsx b/apps/web/src/components/hero/airship-mock/picker-overlay.tsx
new file mode 100644
index 0000000..4b61b11
--- /dev/null
+++ b/apps/web/src/components/hero/airship-mock/picker-overlay.tsx
@@ -0,0 +1,41 @@
+import { MOCK_SELECTION } from "#/content/mock-page";
+
+const HANDLES = ["nw", "n", "ne", "e", "se", "s", "sw", "w"] as const;
+
+/**
+ * What the picker draws over the element under the cursor.
+ *
+ * Both boxes are absolutely positioned on a full-mock chrome layer and share one
+ * geometry, set by the measuring effect into `--pick-*`. That mirrors the real
+ * overlay, where hover and selection are two boxes on the same layer rather than
+ * one box that changes style — they need to be able to coexist, and they differ
+ * in more than colour: hover is 1.5px over a wash, selection is 2px with no fill
+ * and a white outer ring so it stays visible against a light app.
+ */
+export function PickerOverlay() {
+ const geometry = {
+ height: "var(--pick-h, 44px)",
+ left: "var(--pick-x, 56px)",
+ top: "var(--pick-y, 366px)",
+ width: "var(--pick-w, 148px)",
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/hero/browser-window.tsx b/apps/web/src/components/hero/browser-window.tsx
new file mode 100644
index 0000000..55109b2
--- /dev/null
+++ b/apps/web/src/components/hero/browser-window.tsx
@@ -0,0 +1,113 @@
+import type { ReactNode } from "react";
+
+/**
+ * The Safari window the app is served into.
+ *
+ * The URL reads `localhost:3000` — the dev server's port, not airship's. In
+ * inline mode the overlay is injected into the app's own page, so the address
+ * never changes; that is the claim the whole hero is making, and getting the
+ * port wrong here would quietly contradict it.
+ */
+export function BrowserWindow({ children }: { children: ReactNode }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ localhost:3000
+
+
+
+
+
+
+
+
+
+
{children}
+
+ );
+}
+
+function NavArrow({
+ className,
+ forward,
+}: {
+ className: string;
+ forward?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function ReloadIcon() {
+ return (
+
+ );
+}
+
+function ShareIcon() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/hero/desktop-backdrop.tsx b/apps/web/src/components/hero/desktop-backdrop.tsx
new file mode 100644
index 0000000..eebae91
--- /dev/null
+++ b/apps/web/src/components/hero/desktop-backdrop.tsx
@@ -0,0 +1,20 @@
+import type { ReactNode } from "react";
+
+/**
+ * The wallpaper the hero's window sits on: a full-bleed band, edge to edge.
+ *
+ * It used to carry a menu bar and a dock as well, and was boxed at 800px with
+ * rounded corners so the three together read as a screenshot of a Mac. Once the
+ * band spans the viewport that conceit stops working — chrome authored at 8.5px
+ * to be seen inside a shrunken desktop is simply small type at 1:1 — so the
+ * wallpaper now stands on its own and the window is the only thing on it.
+ *
+ * `overflow: clip` stays: nothing overhangs any more, but it is what guarantees
+ * a full-width band can never produce a horizontal scrollbar.
+ *
+ * The cursor is still rendered as a SIBLING of this element rather than as a
+ * child — see the comment in hero-stage.tsx, which owns that constraint.
+ */
+export function DesktopBackdrop({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
diff --git a/apps/web/src/components/hero/hero-stage.tsx b/apps/web/src/components/hero/hero-stage.tsx
new file mode 100644
index 0000000..cd6fbcb
--- /dev/null
+++ b/apps/web/src/components/hero/hero-stage.tsx
@@ -0,0 +1,131 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { InlineOverlay } from "#/components/hero/airship-mock/inline-overlay";
+import { BrowserWindow } from "#/components/hero/browser-window";
+import { DesktopBackdrop } from "#/components/hero/desktop-backdrop";
+import { MockCursor } from "#/components/hero/mock-cursor";
+import { cn } from "#/lib/cn";
+import { measureHero } from "#/lib/measure-hero";
+import { seekTo, useFrameScrub } from "#/lib/use-frame-scrub";
+import { useIsomorphicLayoutEffect } from "#/lib/use-isomorphic-layout-effect";
+
+/**
+ * The hero's animated still-life of the editor.
+ *
+ * `.mock-cursor` is a SIBLING of `.desktop-bg` rather than a child, and that is
+ * load-bearing: its `left`/`top` are percentages, and they must resolve against
+ * `.hero-visual` rather than against the desktop's padding box — which would
+ * offset every measured target by 80px.
+ *
+ * The whole thing is `aria-hidden`: it is an illustration of the product, and
+ * narrating a fake cursor moving across a fake inspector is noise. Everything it
+ * demonstrates is stated in prose in the sections below it.
+ */
+export function HeroStage() {
+ const [paused, setPaused] = useState(false);
+ const togglePaused = useCallback(() => setPaused((p) => !p), []);
+ const scrubFrame = useFrameScrub();
+ const stageRef = useRef(null);
+
+ /*
+ * The whole measuring layer: one effect, run before paint and again on every
+ * resize, rAF-debounced so a drag emits one measurement per frame rather than
+ * one per resize event.
+ *
+ * StrictMode's double-mount is fine — everything it writes is a custom
+ * property derived from the current layout, so the second run recomputes the
+ * same values.
+ */
+ useIsomorphicLayoutEffect(() => {
+ const hero = stageRef.current;
+ if (!hero) {
+ return;
+ }
+
+ let frame = 0;
+ const apply = () => measureHero(hero);
+ const onResize = () => {
+ cancelAnimationFrame(frame);
+ frame = requestAnimationFrame(apply);
+ };
+
+ apply();
+ window.addEventListener("resize", onResize);
+
+ return () => {
+ cancelAnimationFrame(frame);
+ window.removeEventListener("resize", onResize);
+ };
+ }, []);
+
+ /*
+ * Development only: `?frame=N` pins the loop. Runs in its own effect, after
+ * the measuring one, so the cursor's targets are measured before its track is
+ * seeked — otherwise a pinned frame would show the pointer at its fallback
+ * position rather than at the element it is supposed to be on.
+ */
+ useEffect(() => {
+ const hero = stageRef.current;
+ if (!hero || scrubFrame === null) {
+ return;
+ }
+ return seekTo(hero, scrubFrame);
+ }, [scrubFrame]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ Not aria-hidden: an infinite animation must be stoppable, and a control
+ the keyboard cannot reach does not count as stoppable.
+ */}
+
+
+ );
+}
+
+function PauseIcon() {
+ return (
+
+ );
+}
+
+function PlayIcon() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/hero/mock-cursor.tsx b/apps/web/src/components/hero/mock-cursor.tsx
new file mode 100644
index 0000000..f0e9699
--- /dev/null
+++ b/apps/web/src/components/hero/mock-cursor.tsx
@@ -0,0 +1,29 @@
+/**
+ * The pointer that drives the whole loop.
+ *
+ * A sibling of `.desktop-bg`, not a child: its `left`/`top` are percentages, and
+ * they have to resolve against `.hero-visual` — whose box matches the desktop
+ * exactly — rather than against the desktop's padding box, which would offset
+ * every measured target by the 80px side padding.
+ */
+export function MockCursor() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/lib/hero-measure.ts b/apps/web/src/lib/hero-measure.ts
new file mode 100644
index 0000000..820dc8d
--- /dev/null
+++ b/apps/web/src/lib/hero-measure.ts
@@ -0,0 +1,46 @@
+/*
+ * The pure maths behind the hero's measuring layer.
+ *
+ * Everything here is a function of numbers in and strings out — no DOM, no
+ * refs, no side effects. That split is not tidiness for its own sake: it keeps
+ * the effect in hero-stage.tsx down to about forty lines of orchestration.
+ */
+
+/** A rectangle, in the coordinate space of whatever measured it. */
+export interface Rect {
+ height: number;
+ left: number;
+ top: number;
+ width: number;
+}
+
+/**
+ * A point expressed as a percentage of a container.
+ *
+ * The cursor's `left`/`top` are percentages so it keeps its position when the
+ * hero is resized between measurements — a pixel offset would be silently wrong
+ * for the frame or two before the resize handler catches up.
+ */
+export function toPercent(
+ point: { x: number; y: number },
+ container: Rect
+): { x: string; y: string } {
+ /*
+ * Rounded to 3dp — about a thousandth of the stage, far below a device pixel.
+ *
+ * The precision is not cosmetic. These strings are written to custom
+ * properties that live `@keyframes` read, and in Chrome writing such a
+ * property restarts the animation. Full float precision meant every resize
+ * produced a different string for the same position, so `measureHero` could
+ * never tell "nothing moved" from "moved imperceptibly".
+ */
+ return {
+ x: `${(((point.x - container.left) / container.width) * 100).toFixed(3)}%`,
+ y: `${(((point.y - container.top) / container.height) * 100).toFixed(3)}%`,
+ };
+}
+
+/** The centre of a rect, in the same space the rect was measured in. */
+export function centerOf(rect: Rect): { x: number; y: number } {
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
+}
diff --git a/apps/web/src/lib/hero-scale.ts b/apps/web/src/lib/hero-scale.ts
new file mode 100644
index 0000000..ea928b1
--- /dev/null
+++ b/apps/web/src/lib/hero-scale.ts
@@ -0,0 +1,61 @@
+/*
+ * The hero mock's scale, resolved before first paint.
+ *
+ * `.ap-mock` is authored at a fixed 1200px and shrunk with `transform: scale()`
+ * to whatever width the browser window in the hero actually got. Only the
+ * measuring effect in hero-stage.tsx knows that width exactly — but it cannot
+ * run until React has hydrated, and the server-rendered HTML paints long before
+ * that. Whatever the CSS fallback says is therefore what a visitor sees first.
+ *
+ * A single fallback was fine when the stage was a fixed 800px card: one number
+ * was correct at every viewport. Once the band went full-bleed the window's
+ * width became a function of the viewport, no constant is right below the cap,
+ * and first paint was out by up to 66% — a visible snap on load.
+ *
+ * So: a tiny inlined script that runs before the first paint, computing the
+ * scale from the only input it needs (viewport width) and letting the real
+ * measurement refine it after hydration. The numbers below are
+ * transcribed from hero-desktop.css and hero-overlay.css; if the geometry there
+ * changes, it changes here.
+ */
+
+/** Author-space width of `.ap-mock` — see hero-overlay.css. */
+export const MOCK_WIDTH = 1200;
+
+/** `.browser-window`'s `max-width` — see hero-desktop.css. */
+export const WINDOW_MAX_WIDTH = 1040;
+
+/**
+ * `.desktop-bg`'s horizontal padding at each breakpoint, widest first — see the
+ * band rules in hero-desktop.css. The window is the band's width minus twice
+ * this, capped at WINDOW_MAX_WIDTH.
+ */
+export const BAND_PADDING_X = [
+ { padding: 12, upTo: 640 },
+ { padding: 16, upTo: 847 },
+ { padding: 48, upTo: Number.POSITIVE_INFINITY },
+] as const;
+
+/** The scale the mock should render at, for a given viewport content width. */
+export function heroScaleFor(viewportWidth: number): number {
+ const step =
+ BAND_PADDING_X.find((s) => viewportWidth <= s.upTo) ?? BAND_PADDING_X[2];
+ const content = Math.min(WINDOW_MAX_WIDTH, viewportWidth - step.padding * 2);
+ return Math.max(0, content) / MOCK_WIDTH;
+}
+
+/**
+ * Runs before first paint, inlined into the document head — see __root.tsx.
+ *
+ * Written onto rather than onto `.hero-visual`, because at the time this
+ * runs the document is still being parsed and the hero does not exist yet.
+ * Custom properties inherit, so the value reaches `.ap-mock` all the same, and
+ * the inline style `measureHero` later writes onto `.hero-visual` overrides it
+ * — the seed is the opening bid, the measurement is the final answer.
+ *
+ * `clientWidth`, not `innerWidth`: it excludes the scrollbar, which is what the
+ * band's own `width: 100%` will resolve against. Stringified verbatim into a
+ *