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) + +Node Foyer's wall: the header's provenance line, all 22 cards, a verified card (NodeVoice) beside a reachable-only card (NodeProof), a stable-sweeps count, the NodeRoom apparatus on hover (probe URL, HTTP status, sha256), the dead fixture staying UNKNOWN with its tried URL, and the Foyer's own card verified on both its frontend and backend layers + +Judge: `fix-then-publish`, 20/22 (`gemini-3.6-flash`). + +
+ +
Node Foyer · the phone sheet (production, 390x844) + +Node Foyer at phone width: a soft-hyphenated long product name, the Details button opening a real dialog with the wall inert behind it, a 44px Open target, and Close returning focus to Details + +Judge: `fix-then-publish`, 22/22 (`gemini-3.6-flash`). + +
+ +
Node Foyer · the honest fallback (built preview, no ledger URL) + +Node Foyer built with no VITE_CONVEX_URL: the wall root's own data-foyer-snapshot-source attribute says file, then the same build's /.well-known/agent-ui.json and /api/apps.json contract files rendered raw + +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: