diff --git a/.tours/01-primary-user-flow.tour b/.tours/01-primary-user-flow.tour
index cc1303e..02fee71 100644
--- a/.tours/01-primary-user-flow.tour
+++ b/.tours/01-primary-user-flow.tour
@@ -82,21 +82,21 @@
},
{
"file": "src/Walkthrough.jsx",
- "line": 94,
+ "line": 101,
"pattern": "export const Walkthrough = ",
"title": "12 — One frame at a time, as a pure function",
"description": "Remotion calls this once per frame number. Which step is active, how far the camera has eased, where the pointer has glided, how full the progress bar is — all computed from the frame number alone. No state, no effects, no fetching. That purity is what makes a render reproducible and lets the opening-frame probe compare frame 0 with frame 4."
},
{
"file": "src/Walkthrough.jsx",
- "line": 172,
+ "line": 181,
"pattern": "opacity: prevImg \\? fadeIn : 1",
"title": "13 — The one guard not to simplify away",
"description": "`opacity: prevImg ? fadeIn : 1`. The fade is a CROSS-fade and only means anything with the previous step's still underneath. Step 0 has no previous step, so an unguarded ramp faded the first frame up from this container's white — every clip opened on a 0.37 second flash and a looping README GIF re-flashed every loop. That is defect D2. `npm run probe:opening` exists to keep it fixed."
},
{
"file": "src/Walkthrough.jsx",
- "line": 188,
+ "line": 197,
"pattern": "width: WT_W \\* progress",
"title": "14 — And out the other side",
"description": "Caption lower-third above, progress bar here, and the frame is done. `remotion render` collects these into an MP4; the README's ffmpeg palette command turns that into the GIF you paste into a README. You have now followed the whole path."
diff --git a/README.md b/README.md
index 2e8d4c3..3c5ee70 100644
--- a/README.md
+++ b/README.md
@@ -1381,6 +1381,48 @@ Re-capturing requires an explicitly selected current spec ID in `COLLAB_ONLY` an
its intended application running; a bare capture command is refused. Render: `node run-remotion.mjs render src/index.js WTC-NRsolo`
/ `WTC-NRsync` / `WTC-NRfresh` / `WTC-NRdeepDive`.
+## Real-world example: Node Foyer (fail-closed asserts, FOYER-V3 R1)
+
+[Node Foyer](https://github.com/HomenShum/node-foyer) is a portfolio wall that probes each
+hosted product's own public files and shows one honest state per repo. Its three walkthroughs
+(`walkthrough.foyer.specs.mjs`, captured by `walkthrough.foyer.mjs`) go one step past every
+other spec in this repo: **every `cap` op carries an `assert`** — checked against the live DOM
+immediately before the screenshot, not after — so a capture that would have shown a wrong or
+stale state aborts instead of shipping (fail-closed, same `zz-fail.png` contract as
+`walkthrough.mjs`, applied to a *claim* rather than only to a crash). Each capture also reads the
+wall's own `foyer-build-sha` at the first and last frame and discards the run if the served build
+moved mid-capture — see `walkthrough.foyer.mjs`'s `assertHolds` and `freshBuildSha`.
+
+Node Foyer · the wall (production, 1440x900)
+
+
+
+Judge: `fix-then-publish`, 20/22 (`gemini-3.6-flash`).
+
+
+
+Node Foyer · the phone sheet (production, 390x844)
+
+
+
+Judge: `fix-then-publish`, 22/22 (`gemini-3.6-flash`).
+
+
+
+Node Foyer · the honest fallback (built preview, no ledger URL)
+
+
+
+Judge: `rework` (2 of 3 sampled runs; scores 13/8/6 out of 22 — this repo's own documented judge
+variance, see "The instrument is noisy" above). The honest defect underneath the noise is real
+and repeats across runs: a `goto` between the wall and a raw JSON response has no in-app element
+to click, so `cursor_truth`/`state_coverage` score low every time. Fabricating a click here would
+violate STORYBOARD.md's own rule against claiming an interaction the frame does not show; the
+judge's own suggestion — a terminal/curl panel showing the actual fetch — is a real fix for a
+follow-up round, not this one.
+
+
+
## Designing for specific stacks
What's worth *showing* in a walkthrough differs by architecture — a single-cursor
diff --git a/assets/feature-foyer-FYagent.gif b/assets/feature-foyer-FYagent.gif
new file mode 100644
index 0000000..a592bb5
Binary files /dev/null and b/assets/feature-foyer-FYagent.gif differ
diff --git a/assets/feature-foyer-FYphone.gif b/assets/feature-foyer-FYphone.gif
new file mode 100644
index 0000000..d34acac
Binary files /dev/null and b/assets/feature-foyer-FYphone.gif differ
diff --git a/assets/feature-foyer-FYwall.gif b/assets/feature-foyer-FYwall.gif
new file mode 100644
index 0000000..963fd63
Binary files /dev/null and b/assets/feature-foyer-FYwall.gif differ
diff --git a/docs/START_HERE.md b/docs/START_HERE.md
index 8b99653..17bd1c9 100644
--- a/docs/START_HERE.md
+++ b/docs/START_HERE.md
@@ -204,7 +204,7 @@ emits something that passes for evidence.** The same reasoning added the
**File:** `iterate.mjs`
**Symbol:** the top-level round loop — `iterate.mjs:62` (`for (let r = 1; r <= rounds`)
-**Called by:** `npm run iterate` — `package.json:43` (`"iterate": "node iterate.mjs"`) — and
+**Called by:** `npm run iterate` — `package.json:45` (`"iterate": "node iterate.mjs"`) — and
nothing else. No script and no other file in this repository spawns it.
**Calls next:** `iterate.mjs:72` (`judge-rubric.mjs`) → Google Gemini
@@ -285,7 +285,7 @@ contain `_`.
**Output** — a composition registry the Remotion CLI and studio read.
**Failure behavior** — a composition whose steps array is empty still registers, with
`durationInFrames` clamped to 1 by the `Math.max(1, …)`; the renderer paints a blank
-frame rather than crashing — `src/Walkthrough.jsx:97` (`if (!steps.length) return`).
+frame rather than crashing — `src/Walkthrough.jsx:104` (`if (!steps.length) return`).
**Next** — Step 6, the file this registry reads.
---
@@ -331,9 +331,9 @@ its spoken narration. That is the only other thing that edits generated data.
## Step 7 — Rendering: one function turns a step list into every frame
**File:** `src/Walkthrough.jsx`
-**Symbol:** `Walkthrough` — `src/Walkthrough.jsx:94` (`export const Walkthrough`)
+**Symbol:** `Walkthrough` — `src/Walkthrough.jsx:101` (`export const Walkthrough`)
**Called by:** Remotion, once per frame, via the `Composition` in Step 5
-**Calls next:** `src/Walkthrough.jsx:49` (`const burstFrame`), `src/Walkthrough.jsx:34` (`const camTarget`), plus `Pointer` and `Ripple`
+**Calls next:** `src/Walkthrough.jsx:56` (`const burstFrame`), `src/Walkthrough.jsx:41` (`const camTarget`), plus `Pointer` and `Ripple`
**Why this exists**
This is the whole visual language of the product in one component: which captured
@@ -356,9 +356,9 @@ export const Walkthrough = ({ wt }) => {
**Input** — one walkthrough object as the `wt` prop.
**Output** — the JSX for exactly one frame.
-**Failure behavior** — an empty step list returns a plain dark frame — `src/Walkthrough.jsx:97` (`if (!steps.length) return`) — instead
+**Failure behavior** — an empty step list returns a plain dark frame — `src/Walkthrough.jsx:104` (`if (!steps.length) return`) — instead
of throwing. A missing PNG surfaces as a Remotion asset error naming the file.
-**The one bug fixed here that you must not undo** — `src/Walkthrough.jsx:172` (`opacity: prevImg ? fadeIn : 1`): the still is drawn with
+**The one bug fixed here that you must not undo** — `src/Walkthrough.jsx:181` (`opacity: prevImg ? fadeIn : 1`): the still is drawn with
`opacity: prevImg ? fadeIn : 1`. The fade is a *cross*-fade and only means anything
with the previous step underneath. On step 0 there is no previous step, so an
unguarded ramp faded the first frame up from the container's white — every clip opened
diff --git a/package.json b/package.json
index 4b9b317..38a27d0 100644
--- a/package.json
+++ b/package.json
@@ -30,7 +30,9 @@
"capture:roomos": "node walkthrough.roomos.mjs",
"capture:visual": "node walkthrough.visual.mjs",
"capture:solo": "node walkthrough.solo-founder.mjs",
+ "capture:foyer": "node walkthrough.foyer.mjs",
"studio": "node run-remotion.mjs studio src/index.js",
+ "studio:foyer": "node run-remotion.mjs studio src/foyer-index.js",
"studio:roomos": "node run-remotion.mjs studio src/roomos-index.js",
"render": "node run-remotion.mjs render src/index.js",
"render:example": "node run-remotion.mjs render src/index.js WT-NodeRoom out/example.mp4 --concurrency=2",
diff --git a/public/wt/FYagent/00.png b/public/wt/FYagent/00.png
new file mode 100644
index 0000000..77a02ea
Binary files /dev/null and b/public/wt/FYagent/00.png differ
diff --git a/public/wt/FYagent/01.png b/public/wt/FYagent/01.png
new file mode 100644
index 0000000..54fc825
Binary files /dev/null and b/public/wt/FYagent/01.png differ
diff --git a/public/wt/FYagent/02.png b/public/wt/FYagent/02.png
new file mode 100644
index 0000000..3b42c35
Binary files /dev/null and b/public/wt/FYagent/02.png differ
diff --git a/public/wt/FYagent/capture.json b/public/wt/FYagent/capture.json
new file mode 100644
index 0000000..d5e378c
--- /dev/null
+++ b/public/wt/FYagent/capture.json
@@ -0,0 +1,44 @@
+{
+ "id": "FYagent",
+ "repo": "node-foyer",
+ "title": "Node Foyer — the honest fallback",
+ "demoUrl": "http://127.0.0.1:5270/",
+ "captureKind": "preview",
+ "capturedAt": "2026-09-13T01:39:33.590Z",
+ "captureBuildSha": "8a0fedd48d04e582a094f10f53f4fd49c48a7c31",
+ "capturePromoted": false,
+ "snapshot": {
+ "source": "file",
+ "sha256": "a122eb091348d68bb014c84056778c466f57877e67bba95fd519f58cf4ac3df0",
+ "generatedAt": "2026-09-12T22:29:43.131Z"
+ },
+ "storyboard": {
+ "premise": "The ledger URL an agent would read the wall from is unset in this build — no dev, no prod Convex.",
+ "question": "Does the wall degrade to the committed snapshot file honestly, or does it hang, blank, or lie about where its data came from?",
+ "axis": "ledger-backed snapshot (production) vs file-fallback snapshot (this preview, no ledger URL at all)",
+ "conflict": "no VITE_CONVEX_URL — the exact condition an agent hits before any ledger is configured",
+ "evidence": "the wall root's own data-foyer-snapshot-source=\"file\" attribute, and the same two machine-readable contract files production serves",
+ "verdict": "the wall renders the committed snapshot and says so on its own root node; /.well-known/agent-ui.json and /api/apps.json are still served from this build, unchanged",
+ "exit": "an agent with no ledger configured gets a working wall and an honest source attribute — never a blank screen or a silent lie"
+ },
+ "frames": [
+ {
+ "path": "wt/FYagent/00.png",
+ "sha256": "c86b8a0bcfeb059c3c4516c2ba89b041df63948f4be09adf185af8a831168622",
+ "caption": "Picture an agent-workspace harness opening this build with no ledger URL configured: the wall falls back to the committed snapshot file, and says so on its own root node — not a blank screen, not a silent lie.",
+ "asserted": "testid:foyer-wall[data-foyer-snapshot-source]=\"file\""
+ },
+ {
+ "path": "wt/FYagent/01.png",
+ "sha256": "09b801426a393b96bce83bd9125dd43b0fec9d0aff2354287505572e15146ec6",
+ "caption": "That agent fetches this fixed URL directly — no browser click, no ledger: the same build still serves its machine-readable contract file.",
+ "asserted": "css:pre text matches"
+ },
+ {
+ "path": "wt/FYagent/02.png",
+ "sha256": "1b662b828e986e6d02f3d2709c24dfe8cac3876af702c796b6072eca9ab2a842",
+ "caption": "And the plain apps.json an agent-workspace harness already knows how to read, from the exact same build.",
+ "asserted": "css:pre text matches"
+ }
+ ]
+}
diff --git a/public/wt/FYphone/00.png b/public/wt/FYphone/00.png
new file mode 100644
index 0000000..fdc2ebd
Binary files /dev/null and b/public/wt/FYphone/00.png differ
diff --git a/public/wt/FYphone/01.png b/public/wt/FYphone/01.png
new file mode 100644
index 0000000..4eb8b1f
Binary files /dev/null and b/public/wt/FYphone/01.png differ
diff --git a/public/wt/FYphone/02.png b/public/wt/FYphone/02.png
new file mode 100644
index 0000000..6c2ff1e
Binary files /dev/null and b/public/wt/FYphone/02.png differ
diff --git a/public/wt/FYphone/03.png b/public/wt/FYphone/03.png
new file mode 100644
index 0000000..a76f2a0
Binary files /dev/null and b/public/wt/FYphone/03.png differ
diff --git a/public/wt/FYphone/04.png b/public/wt/FYphone/04.png
new file mode 100644
index 0000000..a76f2a0
Binary files /dev/null and b/public/wt/FYphone/04.png differ
diff --git a/public/wt/FYphone/capture.json b/public/wt/FYphone/capture.json
new file mode 100644
index 0000000..0b13490
--- /dev/null
+++ b/public/wt/FYphone/capture.json
@@ -0,0 +1,56 @@
+{
+ "id": "FYphone",
+ "repo": "node-foyer",
+ "title": "Node Foyer — the phone sheet",
+ "demoUrl": "https://node-foyer.vercel.app/",
+ "captureKind": "production",
+ "capturedAt": "2026-09-13T01:37:32.139Z",
+ "captureBuildSha": "ec2a44e19b304ab4ca2aa181f8722908a0329b5a",
+ "capturePromoted": false,
+ "snapshot": {
+ "source": "ledger",
+ "sha256": "274ab988a64bfede8dc6e430018b3d5db82312927541f12d5e5327ea17a2c0bf",
+ "generatedAt": "2026-09-13T01:12:41.554Z"
+ },
+ "storyboard": {
+ "premise": "At phone width there is no hover, so the same apparatus a desktop reviewer sees on mouseover has to become a real, tappable dialog.",
+ "question": "Is the bottom sheet an actual modal — focus trapped, background inert, closes cleanly — or a menu that only looks like one?",
+ "axis": "desktop hover apparatus vs mobile tap-opened sheet: same ApparatusBody, two entry points",
+ "conflict": "a long product name (NodeBenchBoilerplate) has to wrap without breaking mid-word inside a 390px card",
+ "evidence": "the sheet's role=\"dialog\", its backdrop over the now-inert wall, the same [data-probe-row] apparatus, and a 44px Open target",
+ "verdict": "the name soft-hyphenates cleanly, Details opens a real dialog with the wall inert behind it, Open stays a full 44px target, and Close returns focus to Details",
+ "exit": "the mobile sheet is not a stripped-down view — it is the same evidence, reachable by tap instead of hover"
+ },
+ "frames": [
+ {
+ "path": "wt/FYphone/00.png",
+ "sha256": "42dab25cb328790de3b7a5bbca1ab5ea6a74c253ef63793507f1e903cc8f63c8",
+ "caption": "Long product names soft-hyphenate instead of breaking mid-word, even in a 390px card.",
+ "asserted": "css:[data-testid=\"foyer-card-NodeBenchBoilerplate\"] .foyer-card__name[aria-label]=\"NodeBenchBoilerplate\""
+ },
+ {
+ "path": "wt/FYphone/01.png",
+ "sha256": "8dc1c06dd1bc03a3ca03af626febc62efe8595b4fc9ef9289ca5bab76cea7217",
+ "caption": "No hover on a phone — Details is the real, tappable path to the same apparatus.",
+ "asserted": "css:[data-testid=\"foyer-card-NodeRoom\"] button.foyer-details-btn visible"
+ },
+ {
+ "path": "wt/FYphone/02.png",
+ "sha256": "57e780a2702c65b25ecf2e8080e6d92b05a74b5093af44bee5c11e4496d2639c",
+ "caption": "The sheet opens as a real dialog over a backdrop — the wall behind it is now inert, not just visually dimmed.",
+ "asserted": "css:.foyer-sheet-backdrop visible"
+ },
+ {
+ "path": "wt/FYphone/03.png",
+ "sha256": "e520c45e0cde011aafd5704ab1d56da0cfcbf8bb4fb7272fba7ce1cfca5fded0",
+ "caption": "Open stays a full 44px target on the card face, never a text sliver.",
+ "asserted": "css:[data-testid=\"foyer-card-NodeRoom\"] a[href] visible"
+ },
+ {
+ "path": "wt/FYphone/04.png",
+ "sha256": "e520c45e0cde011aafd5704ab1d56da0cfcbf8bb4fb7272fba7ce1cfca5fded0",
+ "caption": "Close returned focus to Details — nothing is left stranded on body.",
+ "asserted": "css:[data-testid=\"foyer-card-NodeRoom\"] button.foyer-details-btn focused"
+ }
+ ]
+}
diff --git a/public/wt/FYwall/00.png b/public/wt/FYwall/00.png
new file mode 100644
index 0000000..578afea
Binary files /dev/null and b/public/wt/FYwall/00.png differ
diff --git a/public/wt/FYwall/01.png b/public/wt/FYwall/01.png
new file mode 100644
index 0000000..578afea
Binary files /dev/null and b/public/wt/FYwall/01.png differ
diff --git a/public/wt/FYwall/02.png b/public/wt/FYwall/02.png
new file mode 100644
index 0000000..578afea
Binary files /dev/null and b/public/wt/FYwall/02.png differ
diff --git a/public/wt/FYwall/03.png b/public/wt/FYwall/03.png
new file mode 100644
index 0000000..578afea
Binary files /dev/null and b/public/wt/FYwall/03.png differ
diff --git a/public/wt/FYwall/04.png b/public/wt/FYwall/04.png
new file mode 100644
index 0000000..c208133
Binary files /dev/null and b/public/wt/FYwall/04.png differ
diff --git a/public/wt/FYwall/05.png b/public/wt/FYwall/05.png
new file mode 100644
index 0000000..33e7a19
Binary files /dev/null and b/public/wt/FYwall/05.png differ
diff --git a/public/wt/FYwall/06.png b/public/wt/FYwall/06.png
new file mode 100644
index 0000000..e303d4a
Binary files /dev/null and b/public/wt/FYwall/06.png differ
diff --git a/public/wt/FYwall/capture.json b/public/wt/FYwall/capture.json
new file mode 100644
index 0000000..483550e
--- /dev/null
+++ b/public/wt/FYwall/capture.json
@@ -0,0 +1,68 @@
+{
+ "id": "FYwall",
+ "repo": "node-foyer",
+ "title": "Node Foyer — the wall",
+ "demoUrl": "https://node-foyer.vercel.app/",
+ "captureKind": "production",
+ "capturedAt": "2026-09-13T01:37:26.530Z",
+ "captureBuildSha": "ec2a44e19b304ab4ca2aa181f8722908a0329b5a",
+ "capturePromoted": false,
+ "snapshot": {
+ "source": "ledger",
+ "sha256": "274ab988a64bfede8dc6e430018b3d5db82312927541f12d5e5327ea17a2c0bf",
+ "generatedAt": "2026-09-13T01:12:41.554Z"
+ },
+ "storyboard": {
+ "premise": "Node Foyer claims to read every product's own public files, live, on every sweep, and never invent a state.",
+ "question": "Does the grid actually reflect what each product currently serves, and does the apparatus behind every colour hold a real probe?",
+ "axis": "verified (two layers agree) vs reachable-only (answered, nothing to compare) vs unknown (never answered)",
+ "conflict": "a fixture adapter (__fixture_dead) wired to a URL that always fails, run on every sweep so the honest-failure path is exercised, not assumed",
+ "evidence": "hover apparatus: probe URL, HTTP status, fetch time, remote Date header, sha256, the per-layer match line, and the stated reason",
+ "verdict": "22 cards, one honest state each; the Foyer's own card is verified on both its frontend and backend layers; the dead fixture stays UNKNOWN with its tried URL on screen",
+ "exit": "trust the pill colour because the apparatus behind it is inspectable on this same screen, not because the pill says so"
+ },
+ "frames": [
+ {
+ "path": "wt/FYwall/00.png",
+ "sha256": "708bfd30e1d6100339c189d6350d386cda564e3f67cd2b0c4455e6bc790b7727",
+ "caption": "Every push to main deploys both layers; this wall reads back what each product actually serves, right now.",
+ "asserted": "css:.foyer-header__line visible"
+ },
+ {
+ "path": "wt/FYwall/01.png",
+ "sha256": "708bfd30e1d6100339c189d6350d386cda564e3f67cd2b0c4455e6bc790b7727",
+ "caption": "22 repos, one honest card each — hosted products probed live, everything else marked registry-only.",
+ "asserted": "css:[data-testid^=\"foyer-card-\"] count=22"
+ },
+ {
+ "path": "wt/FYwall/02.png",
+ "sha256": "708bfd30e1d6100339c189d6350d386cda564e3f67cd2b0c4455e6bc790b7727",
+ "caption": "Same colour rule for both: NodeProof only answered (amber, reachable) — NodeVoice's two layers agreed (green, verified).",
+ "asserted": "css:[data-testid=\"foyer-card-NodeVoice\"] .foyer-pill[data-state]=\"verified\""
+ },
+ {
+ "path": "wt/FYwall/03.png",
+ "sha256": "708bfd30e1d6100339c189d6350d386cda564e3f67cd2b0c4455e6bc790b7727",
+ "caption": "\"same state for 11 sweeps\" — the ledger's own stability count, not a claim about one lucky probe.",
+ "asserted": "testid:foyer-card-NodeRoom[data-foyer-stable-sweeps]=\"12\""
+ },
+ {
+ "path": "wt/FYwall/04.png",
+ "sha256": "28a3a3e7d40949872ae29d754e8bb3355e93f4e9c79a2ccedf6eb867aa0d6312",
+ "caption": "Hover reveals the apparatus: the exact URL probed, its HTTP status, when it answered, and the sha256 of what it returned.",
+ "asserted": "testid:foyer-apparatus-NodeRoom visible"
+ },
+ {
+ "path": "wt/FYwall/05.png",
+ "sha256": "2ecd6456275032d5daa53424e2bd3a9ad95b202658f328d4c6a7d1e5b777c199",
+ "caption": "The dead fixture stays UNKNOWN, forever — its apparatus shows the exact URL it tried and failed, never a guess.",
+ "asserted": "testid:foyer-apparatus-__fixture_dead text matches"
+ },
+ {
+ "path": "wt/FYwall/06.png",
+ "sha256": "16faeaeea0203dee218abafff8f068e5b95d2b4ec4906889a8fc9592a45a4433",
+ "caption": "The Foyer probes itself, too: frontend and backend agree on the same build sha, so its own card is verified on both layers.",
+ "asserted": "testid:foyer-apparatus-node-foyer text matches"
+ }
+ ]
+}
diff --git a/src/FoyerRoot.jsx b/src/FoyerRoot.jsx
new file mode 100644
index 0000000..9563a5c
--- /dev/null
+++ b/src/FoyerRoot.jsx
@@ -0,0 +1,28 @@
+import React from "react";
+import { Composition } from "remotion";
+import { Walkthrough, WT_FPS, WT_W, WT_H, wtDuration } from "./Walkthrough.jsx";
+import { FOYER_WALKTHROUGHS } from "./walkthrough.foyer.data.js";
+
+// FOYER-V3 R1: the three Node Foyer walkthroughs (FYwall, FYphone, FYagent), each its own
+// composition ("WT-", same convention as src/Root.jsx) so run-remotion.mjs can render
+// one at a time — `remotion render src/foyer-index.js WT-FYwall out/foyer-FYwall.mp4`.
+// Reuses Walkthrough.jsx unchanged: each entry's `captureViewport` (written by
+// walkthrough.foyer.mjs) drives Walkthrough.jsx's geometryFor so the phone (390x844) and
+// wall/agent (1440x900) captures each get their own aspect ratio instead of the historical
+// 1280x800 default.
+export const FoyerRoot = () => (
+ <>
+ {FOYER_WALKTHROUGHS.map((w) => (
+
+ ))}
+ >
+);
diff --git a/src/Walkthrough.jsx b/src/Walkthrough.jsx
index d77dc92..b38bdc9 100644
--- a/src/Walkthrough.jsx
+++ b/src/Walkthrough.jsx
@@ -6,10 +6,19 @@ export const WT_W = 1920;
export const WT_H = 1080;
const FONT = '"Inter", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif';
-const IMG_W = 1360;
-const CAP_VW = 1280; // capture viewport CSS width
-const IMG_H = Math.round(IMG_W * 800 / CAP_VW); // preserve 1280x800 aspect
-const SX = IMG_W / 1280, SY = IMG_H / 800; // cursor coord -> displayed-image px
+export const IMG_W = 1360;
+// Capture geometry per walkthrough (round-tripped from wip/slidelang-capture-viewport-20260912,
+// 5088aaa): a spec captured at a viewport other than the historical 1280x800 default (a 1440x900
+// desktop wall, a 390x844 phone) must scale its cursor coordinates and camera math against ITS
+// OWN aspect ratio, not the hardcoded one. `wt.captureViewport` (if present) replaces the default;
+// geometryFor derives the displayed image height and both per-axis scale factors from it once
+// per render instead of baking 1280x800 into a module constant every walkthrough shares.
+const DEFAULT_CAPTURE = { width: 1280, height: 800 };
+const geometryFor = (wt) => {
+ const capture = wt.captureViewport || DEFAULT_CAPTURE;
+ const imgH = Math.round(IMG_W * capture.height / capture.width);
+ return { imgH, sx: IMG_W / capture.width, sy: imgH / capture.height };
+};
// CHROMELESS BY DEFAULT. The fake browser window, the traffic lights, the title bar and the
// "Step n / n" header were decoration that cost the thing being demonstrated most of the screen:
@@ -22,8 +31,6 @@ const SX = IMG_W / 1280, SY = IMG_H / 800; // cursor coord -> displayed-
//
// `chrome: true` in a walkthrough spec opts back in, so the older NodeRoom and NodeSlide cuts keep
// the look they were storyboarded for.
-const FILL = Math.min(WT_W / IMG_W, WT_H / IMG_H); // scale that fits the capture to the canvas
-
// Per-step "camera": zoom toward the click on action steps; pull back, gently
// zoomed + centered, on the result/loading states (the result is scrolled to
// the viewport centre at capture time). Pan/glide between steps (Arcade-style).
@@ -31,10 +38,10 @@ const FILL = Math.min(WT_W / IMG_W, WT_H / IMG_H); // scale that fits the capt
// 12% costs nothing. Full-bleed product pages lose their left margin at 1.14 —
// mid-sentence — so a spec can flatten the camera with `scales: {...}`.
const ACTION_SCALE = 1.36, RESULT_SCALE = 1.14, OPEN_SCALE = 1.04;
-const camTarget = (step, sc) =>
+const camTarget = (step, sc, geometry) =>
step.cursor
- ? { s: sc.action, fx: step.cursor.x * SX, fy: step.cursor.y * SY }
- : { s: sc.result, fx: IMG_W / 2, fy: IMG_H / 2 };
+ ? { s: sc.action, fx: step.cursor.x * geometry.sx, fy: step.cursor.y * geometry.sy }
+ : { s: sc.result, fx: IMG_W / 2, fy: geometry.imgH / 2 };
const scalesOf = (wt) => ({
action: wt.scales?.action ?? ACTION_SCALE,
@@ -95,6 +102,8 @@ export const Walkthrough = ({ wt }) => {
const frame = useCurrentFrame();
const steps = wt.steps || [];
if (!steps.length) return ;
+ const geometry = geometryFor(wt);
+ const fill = Math.min(WT_W / IMG_W, WT_H / geometry.imgH); // scale that fits the capture to the canvas
const starts = [];
let acc = 0;
@@ -109,15 +118,15 @@ export const Walkthrough = ({ wt }) => {
// ---- Camera: ease from previous target to this step's target (pre-move delay
// then a gentle glide), so the eye registers context before the camera moves.
const sc = scalesOf(wt);
- const tgt = camTarget(cur, sc);
- const prevTgt = i > 0 ? camTarget(prev, sc) : { s: sc.open, fx: IMG_W / 2, fy: IMG_H / 2 };
+ const tgt = camTarget(cur, sc, geometry);
+ const prevTgt = i > 0 ? camTarget(prev, sc, geometry) : { s: sc.open, fx: IMG_W / 2, fy: geometry.imgH / 2 };
const ct = interpolate(lf, [6, 26], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.inOut(Easing.cubic) });
const s = prevTgt.s + (tgt.s - prevTgt.s) * ct;
const fx = prevTgt.fx + (tgt.fx - prevTgt.fx) * ct;
const fy = prevTgt.fy + (tgt.fy - prevTgt.fy) * ct;
- let tx = IMG_W / 2 - fx * s, ty = IMG_H / 2 - fy * s;
+ let tx = IMG_W / 2 - fx * s, ty = geometry.imgH / 2 - fy * s;
tx = Math.min(0, Math.max(IMG_W - IMG_W * s, tx)); // keep the scaled image covering the frame
- ty = Math.min(0, Math.max(IMG_H - IMG_H * s, ty));
+ ty = Math.min(0, Math.max(geometry.imgH - geometry.imgH * s, ty));
// ---- Pointer glide (in image-space; the camera scales it along with the UI).
// Spring rather than cubic interpolate: stiffness 400 / damping 45 / clamped is the
@@ -125,8 +134,8 @@ export const Walkthrough = ({ wt }) => {
// it accelerates and settles like a hand, where a symmetric cubic reads as a tween.
let cursor = null, cursorOp = 0;
if (cur.cursor) {
- const c = { x: cur.cursor.x * SX, y: cur.cursor.y * SY };
- const from = prev && prev.cursor ? { x: prev.cursor.x * SX, y: prev.cursor.y * SY } : c;
+ const c = { x: cur.cursor.x * geometry.sx, y: cur.cursor.y * geometry.sy };
+ const from = prev && prev.cursor ? { x: prev.cursor.x * geometry.sx, y: prev.cursor.y * geometry.sy } : c;
const t = spring({ frame: lf, fps: WT_FPS, durationInFrames: 18, config: { stiffness: 400, damping: 45, mass: 1 }, overshootClamping: true });
cursor = { x: from.x + (c.x - from.x) * t, y: from.y + (c.y - from.y) * t };
cursorOp = interpolate(lf, [0, 8], [prev && prev.cursor ? 1 : 0, 1], { extrapolateRight: "clamp" });
@@ -140,9 +149,9 @@ export const Walkthrough = ({ wt }) => {
const progress = (starts[i] + Math.min(lf, cur.hold || 60)) / total;
const framed = wt.chrome === true;
- const fit = framed ? 1 : FILL;
+ const fit = framed ? 1 : fill;
const winLeft = framed ? (WT_W - IMG_W) / 2 : (WT_W - IMG_W * fit) / 2;
- const winTop = framed ? 70 : (WT_H - IMG_H * fit) / 2;
+ const winTop = framed ? 70 : (WT_H - geometry.imgH * fit) / 2;
return (
@@ -161,9 +170,9 @@ export const Walkthrough = ({ wt }) => {
{/* The capture itself. Chromeless it is scaled to fill; overflow clips the zoomed camera. */}
{framed && }
-
+
{/* Camera: zoom + pan toward the active region */}
-
+
{prevImg && }
{/* `fadeIn` is a CROSS-fade: it only means anything with the previous step's still
underneath. Step 0 has no previous step, so an unguarded ramp faded the first
diff --git a/src/foyer-index.js b/src/foyer-index.js
new file mode 100644
index 0000000..5b34fb5
--- /dev/null
+++ b/src/foyer-index.js
@@ -0,0 +1,4 @@
+import { registerRoot } from "remotion";
+import { FoyerRoot } from "./FoyerRoot.jsx";
+
+registerRoot(FoyerRoot);
diff --git a/src/walkthrough.foyer.data.js b/src/walkthrough.foyer.data.js
new file mode 100644
index 0000000..8261b7d
--- /dev/null
+++ b/src/walkthrough.foyer.data.js
@@ -0,0 +1,43 @@
+// AUTO-GENERATED by walkthrough.foyer.mjs — do not edit by hand.
+export const FOYER_WALKTHROUGHS = [
+ {
+ "id": "FYagent",
+ "title": "Node Foyer — the honest fallback",
+ "accent": "#3f7a5c",
+ "scales": {
+ "action": 1,
+ "result": 1,
+ "open": 1
+ },
+ "captureViewport": {
+ "width": 1440,
+ "height": 900
+ },
+ "steps": [
+ {
+ "img": "wt/FYagent/00.png",
+ "caption": "Picture an agent-workspace harness opening this build with no ledger URL configured: the wall falls back to the committed snapshot file, and says so on its own root node — not a blank screen, not a silent lie.",
+ "cursor": {
+ "x": 720,
+ "y": 22
+ },
+ "click": false,
+ "hold": 130
+ },
+ {
+ "img": "wt/FYagent/01.png",
+ "caption": "That agent fetches this fixed URL directly — no browser click, no ledger: the same build still serves its machine-readable contract file.",
+ "cursor": null,
+ "click": false,
+ "hold": 120
+ },
+ {
+ "img": "wt/FYagent/02.png",
+ "caption": "And the plain apps.json an agent-workspace harness already knows how to read, from the exact same build.",
+ "cursor": null,
+ "click": false,
+ "hold": 120
+ }
+ ]
+ }
+];
diff --git a/walkthrough.foyer.mjs b/walkthrough.foyer.mjs
new file mode 100644
index 0000000..879edd3
--- /dev/null
+++ b/walkthrough.foyer.mjs
@@ -0,0 +1,263 @@
+// FOYER-V3 R1 capturer — Node Foyer's own three walkthroughs (FYwall, FYphone, FYagent).
+// Single-pane, adapted from walkthrough.mjs (single-pane capture loop, per-spec retries,
+// fail-closed forensics) with the selector resolver widened to walkthrough.visual.mjs's
+// btn:/link:/aria:/placeholder:/text:/css: plus walkthrough.collab.mjs's testid: prefix.
+//
+// NEW here (not in any existing capturer): every `cap` op MUST carry an `assert` that is
+// checked immediately BEFORE the screenshot and must hold, or the run aborts (fail-closed,
+// same zz-fail.png contract as walkthrough.mjs) — see walkthrough.foyer.specs.mjs for the
+// assert shape. This capturer also reads node-foyer's own build provenance at the first and
+// last frame (the FOYER-V3 council ruling: bind each capture to the live foyer-build-sha,
+// discard the run if it moved mid-capture, and record whether that sha is already promoted)
+// and writes a full per-capture record (WalkthroughCapture minus the render-stage fields:
+// poster/gif/mp4/judge/e2e are added later, once the frames are rendered and sealed).
+//
+// DEMO_URL=https://node-foyer.vercel.app node walkthrough.foyer.mjs
+// FOYER_ONLY=FYwall DEMO_URL=https://node-foyer.vercel.app node walkthrough.foyer.mjs
+// NODE_FOYER_REPO=../node-foyer node walkthrough.foyer.mjs # for reading PROMOTED.md
+import { chromium } from "playwright";
+import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from "node:fs";
+import { createHash } from "node:crypto";
+import { fileURLToPath } from "node:url";
+import { dirname, join, resolve } from "node:path";
+import { FOYER_SPECS } from "./walkthrough.foyer.specs.mjs";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const PUB = join(__dirname, "public", "wt");
+const NODE_FOYER_REPO = resolve(__dirname, process.env.NODE_FOYER_REPO || "../node-foyer");
+const PROMOTED_PATH = join(NODE_FOYER_REPO, "docs", "campaign", "PROMOTED.md");
+
+// No fallback BASE (unlike walkthrough.mjs's http://127.0.0.1:8502 default): FYwall/FYphone
+// point at production and FYagent at a local built preview, and getting that wrong silently
+// (a leftover dev-server default) is exactly the kind of mistake this contract exists to catch.
+const BASE = process.env.DEMO_URL;
+if (!BASE) {
+ console.error(
+ "DEMO_URL is required (no default) — e.g.:\n" +
+ " DEMO_URL=https://node-foyer.vercel.app node walkthrough.foyer.mjs\n" +
+ "Each spec also carries its own `url` (production for FYwall/FYphone, the local built\n" +
+ "preview for FYagent); DEMO_URL is the fallback for a spec that omits one.",
+ );
+ process.exit(1);
+}
+
+const sleep = (p, ms) => p.waitForTimeout(ms);
+const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
+
+// Page-scoped selector resolver — testid:/btn:/link:/aria:/placeholder:/text:/css:, else raw
+// css. Returns the FULL locator (may match more than one element) — every call site below
+// narrows with `.first()` itself, except the `count` assert, which needs the unnarrowed set.
+const locAll = (p, sel) => {
+ if (sel.startsWith("testid:")) return p.getByTestId(sel.slice(7));
+ if (sel.startsWith("btn:")) return p.getByRole("button", { name: new RegExp(sel.slice(4), "i") });
+ if (sel.startsWith("link:")) return p.getByRole("link", { name: new RegExp(sel.slice(5), "i") });
+ if (sel.startsWith("aria:")) return p.locator(`[aria-label="${sel.slice(5).replace(/"/g, '\\"')}"]`);
+ if (sel.startsWith("placeholder:")) return p.getByPlaceholder(sel.slice(12), { exact: true });
+ if (sel.startsWith("text:")) return p.getByText(sel.slice(5), { exact: false });
+ if (sel.startsWith("css:")) return p.locator(sel.slice(4));
+ return p.locator(sel);
+};
+const loc = (p, sel) => locAll(p, sel).first();
+
+// Viewport-relative center of an element (CSS px, clamped) — where the cursor points.
+const cursorOf = async (p, sel, vw, vh) => {
+ if (!sel) return null;
+ try {
+ const el = loc(p, sel);
+ await el.scrollIntoViewIfNeeded({ timeout: 4000 }).catch(() => {});
+ const box = await el.evaluate((n) => {
+ const r = n.getBoundingClientRect();
+ return { x: r.left + r.width / 2, y: r.top + Math.min(r.height / 2, 22) };
+ });
+ return { x: Math.max(8, Math.min(vw - 8, Math.round(box.x))), y: Math.max(8, Math.min(vh - 8, Math.round(box.y))) };
+ } catch { return null; }
+};
+
+// The fail-closed proof gate: every `cap` op's `assert` is checked right before the
+// screenshot. Returns a short human-readable string for capture.json's frames[].asserted
+// on success; throws (caller aborts the spec, keeps zz-fail.png) on failure.
+const assertHolds = async (p, a) => {
+ if (a.count !== undefined) {
+ const n = await locAll(p, a.sel).count();
+ if (n !== a.count) throw new Error(`assert failed: ${a.sel} count=${n}, expected ${a.count}`);
+ return `${a.sel} count=${a.count}`;
+ }
+ const L = loc(p, a.sel);
+ if (a.focused) {
+ const isFocused = await L.evaluate((n) => n === document.activeElement).catch(() => false);
+ if (!isFocused) throw new Error(`assert failed: ${a.sel} is not the focused element`);
+ return `${a.sel} focused`;
+ }
+ const visible = await L.first().isVisible().catch(() => false);
+ if (a.visible !== false && !visible) throw new Error(`assert failed: ${a.sel} is not visible`);
+ if (a.attr) {
+ const val = await L.first().getAttribute(a.attr);
+ if (a.equals !== undefined && val !== a.equals)
+ throw new Error(`assert failed: ${a.sel}[${a.attr}] = ${JSON.stringify(val)}, expected ${JSON.stringify(a.equals)}`);
+ if (a.matches !== undefined && !new RegExp(a.matches).test(val ?? ""))
+ throw new Error(`assert failed: ${a.sel}[${a.attr}] = ${JSON.stringify(val)} does not match /${a.matches}/`);
+ return `${a.sel}[${a.attr}]=${JSON.stringify(val)}`;
+ }
+ if (a.equals !== undefined || a.matches !== undefined) {
+ const text = (await L.first().innerText().catch(() => "")).trim();
+ if (a.equals !== undefined && text !== a.equals)
+ throw new Error(`assert failed: ${a.sel} text=${JSON.stringify(text.slice(0, 80))}, expected ${JSON.stringify(a.equals)}`);
+ if (a.matches !== undefined && !new RegExp(a.matches).test(text))
+ throw new Error(`assert failed: ${a.sel} text does not match /${a.matches}/: ${JSON.stringify(text.slice(0, 160))}`);
+ return `${a.sel} text matches`;
+ }
+ return `${a.sel} visible`;
+};
+
+const doAct = async (p, a, baseUrl) => {
+ if (a.act === "hover") await loc(p, a.sel).hover();
+ else if (a.act === "click") await loc(p, a.sel).click();
+ else if (a.act === "goto") await p.goto(new URL(a.url, baseUrl).toString(), { waitUntil: "domcontentloaded" });
+ else if (a.act === "key") await p.keyboard.press(a.value);
+ else if (a.act === "sleep") await sleep(p, a.ms);
+ else throw new Error(`unknown act "${a.act}" — valid: hover, click, goto, key, sleep`);
+ await sleep(p, a.settle ?? 300);
+};
+
+// The served build's own sha, read via a same-origin fetch of "/" rather than the CURRENT
+// page's DOM — FYagent's last frame is a raw JSON response page with no tag at all,
+// and a locator miss there is not the build moving, it is the capture having navigated away
+// from the wall on purpose. A fresh fetch works from any page on the same origin (CSP's
+// connect-src 'self' allows it) and is what "did the served build change mid-capture" means.
+const freshBuildSha = async (page) => {
+ try {
+ const html = await page.evaluate(() => fetch("/", { cache: "no-store" }).then((r) => r.text()));
+ return html.match(/ {
+ const buildSha = await freshBuildSha(page);
+ const wall = page.getByTestId("foyer-wall");
+ const source = await wall.getAttribute("data-foyer-snapshot-source").catch(() => null);
+ const snapshotSha256 = await wall.getAttribute("data-foyer-snapshot-sha256").catch(() => null);
+ const generatedAt = await wall.getAttribute("data-foyer-snapshot-generated-at").catch(() => null);
+ return { buildSha, source, snapshotSha256, generatedAt };
+};
+
+const run = async () => {
+ const ONLY = process.env.FOYER_ONLY ? process.env.FOYER_ONLY.split(",").map((s) => s.trim()) : null;
+ const invalid = ONLY?.filter((id) => !FOYER_SPECS.some((s) => s.id === id));
+ if (invalid?.length) throw new Error(`Unknown FOYER_ONLY selector(s): ${invalid.join(", ")}. Choose: ${FOYER_SPECS.map((s) => s.id).join(", ")}`);
+ const specs = ONLY ? FOYER_SPECS.filter((s) => ONLY.includes(s.id)) : FOYER_SPECS;
+
+ const promotedText = existsSync(PROMOTED_PATH) ? readFileSync(PROMOTED_PATH, "utf8") : "";
+ if (!promotedText) console.log(`(no PROMOTED.md found at ${PROMOTED_PATH} — capturePromoted will be recorded false for every capture)`);
+
+ const browser = await chromium.launch({ headless: true });
+ const rendererOut = [];
+ try {
+ for (const spec of specs) {
+ const dir = join(PUB, spec.id);
+ const maxAttempts = 1 + (spec.retries || 0);
+ let renderSteps = [];
+ let frames = [];
+ let provenanceFirst, buildShaLast;
+ let capturedAt;
+
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ rmSync(dir, { recursive: true, force: true });
+ mkdirSync(dir, { recursive: true });
+ const page = await browser.newPage({ viewport: { width: spec.vw, height: spec.vh }, deviceScaleFactor: 2 });
+ page.setDefaultTimeout(60000);
+ const baseUrl = spec.url || BASE;
+ await page.goto(baseUrl, { waitUntil: "networkidle" });
+ await sleep(page, 1000);
+ provenanceFirst = await readProvenance(page);
+
+ renderSteps = [];
+ frames = [];
+ let n = 0;
+ try {
+ const capOps = spec.steps.filter((op) => op.cap);
+ let capIndex = 0;
+ for (const op of spec.steps) {
+ if (op.cap) {
+ capIndex++;
+ const isLast = capIndex === capOps.length;
+ const asserted = await assertHolds(page, op.assert);
+ const cur = await cursorOf(page, op.cursor, spec.vw, spec.vh);
+ await sleep(page, 250);
+ const fn = String(n).padStart(2, "0") + ".png";
+ const path = join(dir, fn);
+ await page.screenshot({ path });
+ const bytes = readFileSync(path);
+ frames.push({ path: `wt/${spec.id}/${fn}`, sha256: sha256(bytes), caption: op.cap, asserted });
+ renderSteps.push({ img: `wt/${spec.id}/${fn}`, caption: op.cap, cursor: cur, click: !!op.click, hold: op.hold || 60 });
+ console.log(` ${spec.id} cap ${n}: ${op.cap}`);
+ n++;
+ if (isLast) buildShaLast = await freshBuildSha(page);
+ } else {
+ await doAct(page, op, baseUrl);
+ }
+ }
+ if (provenanceFirst.buildSha !== buildShaLast) {
+ throw new Error(
+ `foyer-build-sha moved mid-capture (${provenanceFirst.buildSha} -> ${buildShaLast}); ` +
+ `discarding this run rather than binding a capture to two different builds`,
+ );
+ }
+ capturedAt = new Date().toISOString();
+ await page.close().catch(() => {});
+ break; // attempt succeeded
+ } catch (e) {
+ await page.screenshot({ path: join(dir, "zz-fail.png") }).catch(() => {});
+ const bodyText = await page.evaluate(() => document.body.innerText.replace(/\s+/g, " ").slice(0, 200)).catch(() => "(unreadable)");
+ console.log(`${spec.id} attempt ${attempt}/${maxAttempts} err: ${e.message.split("\n")[0]}`);
+ console.log(` fail-state: ${bodyText}`);
+ await page.close().catch(() => {});
+ if (attempt === maxAttempts) throw e;
+ console.log(` retrying ${spec.id} in a fresh page`);
+ }
+ }
+
+ const capturePromoted = !!provenanceFirst.buildSha && promotedText.includes(provenanceFirst.buildSha);
+ const capture = {
+ id: spec.id,
+ repo: spec.repo,
+ title: spec.title,
+ demoUrl: spec.url,
+ captureKind: spec.captureKind,
+ capturedAt,
+ captureBuildSha: provenanceFirst.buildSha,
+ capturePromoted,
+ snapshot: {
+ source: provenanceFirst.source,
+ sha256: provenanceFirst.snapshotSha256,
+ generatedAt: provenanceFirst.generatedAt,
+ },
+ storyboard: spec.storyboard,
+ frames,
+ // poster / gif / mp4 / judge / e2e are filled in at the render+seal stage,
+ // once the frames above have an MP4 and a judge verdict to report.
+ };
+ writeFileSync(join(dir, "capture.json"), JSON.stringify(capture, null, 2) + "\n");
+ console.log(` ${spec.id}: wrote ${join(dir, "capture.json")} (buildSha=${provenanceFirst.buildSha}, promoted=${capturePromoted})`);
+
+ rendererOut.push({
+ id: spec.id,
+ title: spec.title,
+ accent: spec.accent,
+ scales: spec.scales,
+ captureViewport: { width: spec.vw, height: spec.vh },
+ steps: renderSteps,
+ });
+ }
+ } finally {
+ await browser.close();
+ }
+
+ const data = "// AUTO-GENERATED by walkthrough.foyer.mjs — do not edit by hand.\n" +
+ "export const FOYER_WALKTHROUGHS = " + JSON.stringify(rendererOut, null, 2) + ";\n";
+ writeFileSync(join(__dirname, "src", "walkthrough.foyer.data.js"), data);
+ console.log("WALKTHROUGH_FOYER_CAPTURE_DONE — wrote src/walkthrough.foyer.data.js");
+};
+run().catch((e) => { console.error(e); process.exit(1); });
diff --git a/walkthrough.foyer.specs.mjs b/walkthrough.foyer.specs.mjs
new file mode 100644
index 0000000..181c292
--- /dev/null
+++ b/walkthrough.foyer.specs.mjs
@@ -0,0 +1,199 @@
+// FOYER-V3 R1 walkthrough specs — Node Foyer's own three surfaces.
+// Consumed by walkthrough.foyer.mjs. Each spec carries the seven STORYBOARD.md
+// beats as ONE `storyboard` object (matches node-foyer's WalkthroughCapture.storyboard
+// type exactly: premise, question, axis, conflict, evidence, verdict, exit) plus an
+// ORDERED list of cap/act ops:
+//
+// { cap, cursor?, click?, hold?, assert } -> CAPTURE a clean frame. `assert` is
+// checked right before the screenshot and MUST hold (fail-closed: the run
+// aborts and keeps zz-fail.png — see walkthrough.foyer.mjs). Shape:
+// { sel, count? | focused? | visible?, attr?, equals?, matches? }
+// - count: locator(sel).count() === count
+// - focused: locator(sel) === document.activeElement
+// - attr+equals/matches: that attribute's value
+// - no attr, equals/matches: the element's innerText
+// - otherwise: locator(sel).first() is visible
+// { act, sel?, url?, value?, ms? } -> PERFORM an action (hover/click/goto/
+// key/sleep — see walkthrough.foyer.mjs's doAct).
+//
+// Selectors (page-scoped resolver, adapted from walkthrough.collab.mjs's `testid:`
+// and walkthrough.visual.mjs's btn:/link:/aria:/placeholder:/text:/css:):
+// testid: btn: link: aria: