demobites 1.4.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Ask your coding agent to record your product. Edit the result. Download the MP4.**
4
4
 
5
- Your agent drives a real browser on your machine and records the flow you describe. Connect a free DemoBites account to turn the take into an editable video, with text-to-speech, zooms, captions, and MP4 export included.
5
+ Your agent drives a real browser on your machine, records the flow you describe, and delivers the take into your free DemoBites account as an editable video, with text-to-speech, zooms, captions, and MP4 export included.
6
6
 
7
7
  **Sign in to your app locally. You do not hand DemoBites your app password or upload your recording browser profile.**
8
8
 
@@ -28,7 +28,7 @@ Before installing:
28
28
 
29
29
  - **Node.js 18 or newer** and npm.
30
30
  - **Google Chrome**, recommended. The recorder also supports Playwright's Chromium.
31
- - A **free DemoBites account** for the Studio workflow below.
31
+ - A **free DemoBites account**. The recorder works only when this project is connected to it.
32
32
 
33
33
  Run these commands from your project directory:
34
34
 
@@ -61,7 +61,7 @@ For Cursor or Codex, make the [recorder instructions](skill/SKILL.md) available
61
61
  1. **Describe the flow.** Give your agent the app URL and what you want to demonstrate.
62
62
  2. **Approve the storyboard.** Review the steps and estimated length before filming. If your app needs a login, sign in yourself in the recorder's local browser window.
63
63
  3. **Let it record.** The agent checks the flow and films it. Recording normally runs in the background.
64
- 4. **Review the raw take.** Open the review link printed in the terminal. Check the footage, then approve it to create a Bite, an editable video project.
64
+ 4. **Watch it arrive.** The take is delivered by itself and becomes a Bite, an editable video project. The terminal prints a link to your Demos grid in DemoBites, where the take's card shows it coming in. If your account is out of recording minutes, the take is kept and waits on the same grid until minutes are back.
65
65
  5. **Tweak and download.** In the Studio, change a sentence, generate its voice, adjust the zooms or timing, and export your MP4.
66
66
 
67
67
  The storyboard's narration is a starting point. DemoBites rewrites and fits it to the footage; you can edit the final wording in the Studio.
@@ -103,24 +103,18 @@ The recorder uses a separate, persistent Chrome profile at `.recorder/profile/`.
103
103
  | --- | --- |
104
104
  | Your app's browser session | Stays in the local recording profile; the standard local workflow does not upload that profile to DemoBites. |
105
105
  | DemoBites recorder key | Saved in `.recorder/config.json` and used to authenticate recorder requests to DemoBites. This is separate from your app login. |
106
- | Footage and recording metadata | Uploaded to DemoBites for review and processing, including the storyboard, narration intent, cursor events, and camera targets. |
106
+ | Footage and recording metadata | Uploaded to DemoBites for processing, including the storyboard, narration intent, cursor events, and camera targets. |
107
107
 
108
108
  Recording happens locally. Studio processing and editing use DemoBites online. Anything visible or typed into the recorded flow can appear in the footage or recording metadata, so use suitable demo data. Your coding agent's own data handling still follows its provider and your settings.
109
109
 
110
110
  Keep `.recorder/` and take directories out of version control. To disconnect the recorder, run `npx demobite logout`.
111
111
 
112
- ## Local output without a DemoBites account
113
-
114
- The repository also contains a [standalone recorder skill](recorder/SKILL.md) and local finishing scripts. These produce a local MP4; optional voiceover uses your own ElevenLabs key.
115
-
116
- This is an advanced, manual workflow. The default `npx demobite@latest` command installs the Studio-connected skill, not the standalone skill. The standalone finishing reference contains legacy instructions; use the standalone skill as the entry point rather than treating it as a second ending of the installed Studio skill.
117
-
118
112
  ## A few boundaries
119
113
 
120
114
  - The agentic recorder films **browser workflows**. It does not record native desktop apps or phone screens.
121
115
  - The Studio-connected skill is designed for short demos, usually **30–45 seconds**, with a **90-second maximum per Bite**. Split longer stories into separate videos.
122
116
  - Some sites block automated browsers. The workflow may need a human sign-in or verification step, and some sites may refuse recording.
123
- - Filming does not publish anything. You review the take before creating the Bite; public publishing is a separate action.
117
+ - Filming does not publish anything. You approve the storyboard before anything is filmed; the delivered Bite stays in your workspace, and public publishing is a separate action.
124
118
 
125
119
  ## Optional: Retake and management
126
120
 
@@ -134,7 +128,15 @@ npx demobite retake <biteId> --note "Export moved to the header"
134
128
 
135
129
  **Workspace rules.** A workspace admin can write standing rules for the recorder in plain words in the DemoBites settings tab Agentic Recorder Rules, one per line: what to mask, which pages never to open, which words to use. The agent reads them at the start of every take and applies them under its own filming laws; `npx demobite rules` prints them. Every take records the rules version it was filmed under.
136
130
 
137
- **Batches of briefs.** When a pull request in DemoBites produces approved briefs, your agent claims them, films one take per brief after you approve each storyboard, and delivers each take. A delivered take becomes a Bite in DemoBites by itself; there is no second review click for these takes. Nothing is published or shared.
131
+ **Batches of briefs.** When a pull request in DemoBites produces approved briefs, your agent claims them, films one take per brief after you approve each storyboard, and delivers each take. Like every take, a delivered take becomes a Bite in DemoBites by itself and shows up on your Demos grid. Nothing is published or shared.
132
+
133
+ **Update Radar.** A workflow on the Update Radar page (a scan of your merged pull requests, sorted into topics, written into briefs) shows a short record code once its briefs are approved. Run the command from the project where the recorder is installed, in your terminal or in your coding agent:
134
+
135
+ ```bash
136
+ npx demobite record <code>
137
+ ```
138
+
139
+ It lists the batch and its briefs in order, saves them under `.recorder/radar/<code>/`, and tells your agent what to do next, in four steps: it walks every brief against your repository and your running app and posts the refined briefs back (the Radar page marks them "Refined on your machine"), asks you every open question once in one message, shows you all the refined storyboards together for one yes, then films them in the background, in parallel (two or three at a time, each in its own browser profile), delivering each take as it lands. A failed take never stops the others. The workflow page shows each demo arriving; publishing stays a separate step there.
138
140
 
139
141
  The package also includes a DemoBites management MCP for releases and centers:
140
142
 
@@ -153,7 +155,6 @@ The default installer also attempts to register that MCP with Claude Code when t
153
155
  | `launcher/` | CLI setup, connection commands, and MCP registration. |
154
156
  | `skill/` | Studio-connected recorder instructions, login, upload, and Retake. |
155
157
  | `scripts/` | Shared browser recording, trimming, and timing calibration. |
156
- | `recorder/` | Standalone skill and local video finishing tools. |
157
158
 
158
159
  The same package is published under three names, `demobite`, `agentic-recorder` and `demobites`, always at the same version; `npx agentic-recorder@latest` and `npx demobites@latest` do exactly what `npx demobite@latest` does. Run it again to update the installed skill. For publishing instructions, see [RELEASING.md](RELEASING.md).
159
160
 
@@ -5,6 +5,8 @@
5
5
  // npx demobite@latest login connect this machine to DemoBites
6
6
  // npx demobite@latest mcp register the DemoBites management MCP
7
7
  // npx demobite@latest logout disconnect (revokes the key server-side)
8
+ // npx demobite record <code> UPDATE RADAR (1.6.0): list a workflow's approved briefs for the agent
9
+ // (1.7.0: the agent then refines, asks once, gets one yes, films in parallel)
8
10
  //
9
11
  // One front door (founder 2026-08-31): the user never chooses between the
10
12
  // recorder skill and the management MCP — bare `npx demobite` sets up both.
@@ -29,8 +31,74 @@ const arg = process.argv[2] ?? "";
29
31
  const ok = (m) => console.log(` ✓ ${m}`);
30
32
  const warn = (m) => console.log(` ! ${m}`);
31
33
 
34
+ // The skill's home and the project's key. The key lives in <cwd>/.recorder/config.json
35
+ // (login.mjs writes it there), so everything here is per-project.
36
+ const skillsDir = path.join(os.homedir(), ".claude", "skills");
37
+ const dest = path.join(skillsDir, "agentic-recorder");
38
+ const readCfg = () => {
39
+ try { return JSON.parse(fs.readFileSync(path.join(process.cwd(), ".recorder", "config.json"), "utf8")); }
40
+ catch { return null; }
41
+ };
42
+
32
43
  console.log(`\ndemobite v${pkg.version} — the DemoBites agentic recorder\n`);
33
44
 
45
+ // ── 0. record <code> (UPDATE RADAR, 1.6.0) ─────────────────────────────────
46
+ // The Update Radar page shows `npx demobite record <code>` next to a workflow
47
+ // whose briefs are approved. The person runs it in the terminal or in the
48
+ // coding agent where the recorder is installed. It runs NO setup: it checks
49
+ // the skill is installed, respects the auth gate (a missing key prints the
50
+ // login line and stops, nothing opens by itself), asks the server for the
51
+ // batch behind the code, writes .recorder/radar/<code>/bundle.json, prints
52
+ // the briefs in order and then the instruction for the agent. The text is
53
+ // addressed to the agent, plain, and never contains the api key.
54
+ if (arg === "record") {
55
+ const slug = String(process.argv[3] ?? "").trim().toLowerCase();
56
+ if (!slug || !/^[a-z0-9-]{1,32}$/.test(slug)) {
57
+ console.log("Usage: npx demobite record <code>\nThe code is the short one on the Update Radar page, like k3fx9q.");
58
+ process.exit(2);
59
+ }
60
+ const briefsScript = path.join(dest, "scripts", "briefs.mjs");
61
+ if (!fs.existsSync(path.join(dest, "SKILL.md")) || !fs.existsSync(briefsScript)) {
62
+ console.log("The agentic-recorder skill is not installed on this machine.\nRun `npx demobite` first (it installs the skill and the recorder), then run this command again.");
63
+ process.exit(1);
64
+ }
65
+ // An older installed skill does not know the record code. Refresh the
66
+ // skill's files from this package (the same copy bare `npx demobite`
67
+ // does; no npm install, so nothing slow happens here).
68
+ if (!fs.readFileSync(briefsScript, "utf8").includes("--slug") || !fs.existsSync(path.join(dest, "scripts", "batch.mjs"))) {
69
+ const copy = (from, to) => fs.copyFileSync(path.join(pkgRoot, from), path.join(dest, to));
70
+ fs.mkdirSync(path.join(dest, "scripts"), { recursive: true });
71
+ copy("skill/SKILL.md", "SKILL.md");
72
+ for (const f of fs.readdirSync(path.join(pkgRoot, "skill/scripts"))) copy(`skill/scripts/${f}`, `scripts/${f}`);
73
+ for (const f of fs.readdirSync(path.join(pkgRoot, "scripts"))) copy(`scripts/${f}`, `scripts/${f}`);
74
+ ok(`Skill files refreshed to v${pkg.version} → ${dest}`);
75
+ }
76
+ const cfg = readCfg();
77
+ if (!cfg?.api_key) {
78
+ console.log("This project is not connected to DemoBites yet.\nRun `npx demobite login` (it prints a link to approve in your browser), then run this command again.");
79
+ process.exit(1);
80
+ }
81
+ const r = spawnSync("node", [briefsScript, "list", "--slug", slug], {
82
+ stdio: "inherit",
83
+ cwd: process.cwd(),
84
+ env: { ...process.env, DEMOBITE_CLI: "1" },
85
+ });
86
+ if (r.status !== 0) process.exit(r.status ?? 1);
87
+ console.log(`
88
+ Follow the agentic-recorder skill (${path.join(dest, "SKILL.md").replace(os.homedir(), "~")}, section "Record a batch by slug"), in four steps:
89
+ 1. List: the batch above; the bundle is in .recorder/radar/${slug}/ (one draft per brief in briefs/).
90
+ 2. Refactor + questions: walk EVERY brief against the repository and the running app before anything films;
91
+ post each refined brief back (\`node scripts/briefs.mjs refine ${slug} <briefId>\`); ask every open
92
+ question in ONE message, then write the storyboards to .recorder/radar/${slug}/storyboards/<briefId>.json.
93
+ 3. One approval: show the refined storyboards together and ask one yes for the batch ("Film these N?").
94
+ 4. Film in the background: \`node scripts/batch.mjs run ${slug}\` films them in parallel, each on its own
95
+ profile, and delivers each take as it lands; relay its progress lines.
96
+ When the run ends, report per brief and end with the workflow page link printed above; the person watches
97
+ the demos arrive there.
98
+ `);
99
+ process.exit(0);
100
+ }
101
+
34
102
  // ── 1. Environment checks ──────────────────────────────────────────────────
35
103
  const nodeMajor = Number(process.versions.node.split(".")[0]);
36
104
  if (nodeMajor >= 18) ok(`Node ${process.versions.node}`);
@@ -52,8 +120,6 @@ if (hasBin("claude")) ok("Claude Code (drives the recorder; MCP registers automa
52
120
  else warn("Claude Code not found — using Cursor or Codex? They drive the recorder too; MCP setup prints below");
53
121
 
54
122
  // ── 2. Install / update the skill ──────────────────────────────────────────
55
- const skillsDir = path.join(os.homedir(), ".claude", "skills");
56
- const dest = path.join(skillsDir, "agentic-recorder");
57
123
  fs.mkdirSync(dest, { recursive: true });
58
124
  fs.mkdirSync(path.join(dest, "scripts"), { recursive: true });
59
125
  const copy = (from, to) => fs.copyFileSync(path.join(pkgRoot, from), path.join(dest, to));
@@ -101,7 +167,10 @@ if (!haveChrome) {
101
167
 
102
168
  // ── 3. Subcommands ─────────────────────────────────────────────────────────
103
169
  if (arg === "login" || arg === "logout") {
104
- const r = spawnSync("node", [path.join(dest, "scripts", "login.mjs"), ...(arg === "logout" ? ["--logout"] : [])], {
170
+ // `login <base>` forwards the base (dev, a preview) to login.mjs, which
171
+ // resolves it before config.base and the production default. Logout
172
+ // forwards nothing extra: the key's own home always wins there.
173
+ const r = spawnSync("node", [path.join(dest, "scripts", "login.mjs"), ...(arg === "logout" ? ["--logout"] : process.argv.slice(3))], {
105
174
  stdio: "inherit",
106
175
  cwd: process.cwd(),
107
176
  });
@@ -112,10 +181,6 @@ if (arg === "login" || arg === "logout") {
112
181
  // The key lives in <cwd>/.recorder/config.json (login.mjs writes it there),
113
182
  // so MCP registration is per-project too — `claude mcp add` default (local)
114
183
  // scope matches that exactly and keeps the key out of committable files.
115
- const readCfg = () => {
116
- try { return JSON.parse(fs.readFileSync(path.join(process.cwd(), ".recorder", "config.json"), "utf8")); }
117
- catch { return null; }
118
- };
119
184
  const registerMcp = (cfg, { quiet = false } = {}) => {
120
185
  const url = `${cfg.base ?? "https://app.demobites.com"}/api/mcp`;
121
186
  const header = `Authorization: Bearer ${cfg.api_key}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "demobites",
3
- "version": "1.4.0",
3
+ "version": "1.7.0",
4
4
  "description": "The DemoBites agentic recorder — you prompt, it films a real browser, and DemoBites turns the take into an editable demo bite.",
5
5
  "bin": {
6
6
  "demobites": "launcher/index.mjs"
@@ -271,12 +271,22 @@ if (!anchored) {
271
271
  }
272
272
  }
273
273
 
274
+ // A click pairs only with a change inside its CONSEQUENCE WINDOW. Bite 1115
275
+ // (2026-09-27): a click that toggled one small button changed no whole frame,
276
+ // so it was paired with the dialog that opened 5.4 s later; with two clicks
277
+ // the median was that mis-pair and the take was vetoed although the beacon
278
+ // had measured the clock to ~90 ms. A change later than CONSEQUENCE_S is not
279
+ // this click's consequence: the click is unpaired and left out.
280
+ const CONSEQUENCE_S = 1.5;
281
+ // The median can veto a take only from MIN_LATENCY_SAMPLES pairs; fewer are a
282
+ // warning, and a beacon- or anchor-measured clock is never vetoed by it.
283
+ const MIN_LATENCY_SAMPLES = 3;
274
284
  const latencies = [];
275
285
  for (const c of clicks) {
276
286
  const pred = tb.a * c.wall + tb.b;
277
- const after = scenes.filter((s) => s >= pred - 0.15);
287
+ const after = scenes.filter((s) => s >= pred - 0.15 && s <= pred + CONSEQUENCE_S);
278
288
  if (after.length === 0) {
279
- console.log(` ${c.label}: predicted ${pred.toFixed(2)}s, no visible change after it`);
289
+ console.log(` ${c.label}: predicted ${pred.toFixed(2)}s, no visible change within ${CONSEQUENCE_S}s (unpaired)`);
280
290
  continue;
281
291
  }
282
292
  const nearest = after.reduce((a, b) => (Math.abs(a - pred) <= Math.abs(b - pred) ? a : b));
@@ -285,7 +295,11 @@ for (const c of clicks) {
285
295
  }
286
296
  const sorted = [...latencies].sort((a, b) => a - b);
287
297
  const median = sorted.length ? sorted[Math.floor(sorted.length / 2)] : 0;
288
- if (!anchored && (median < -0.15 || median > 0.9)) {
298
+ const clockMeasured = anchored || beaconAnchored;
299
+ if (!clockMeasured && sorted.length < MIN_LATENCY_SAMPLES && (median < -0.15 || median > 0.9)) {
300
+ console.log(`WARNING: median click latency ${median.toFixed(3)}s from only ${sorted.length} paired click(s) — too few to veto the take; the clock stays an estimate. Judge the take on feel.`);
301
+ }
302
+ if (!clockMeasured && sorted.length >= MIN_LATENCY_SAMPLES && (median < -0.15 || median > 0.9)) {
289
303
  console.error(
290
304
  `VERIFY FAILED: median click latency ${median.toFixed(3)}s is outside [-0.15, +0.9]. ` +
291
305
  "The stamped timebase looks wrong for this take. Do not upload — investigate record_from.",
@@ -65,6 +65,10 @@ for (const [i, s] of STORYBOARD.steps.entries()) {
65
65
  if ((s.action === "click" || s.action === "hover") && !s.selector) { console.error(`${at}: ${s.action} needs selector`); process.exit(2); }
66
66
  if (s.action === "type" && (!s.selector || typeof s.text !== "string")) { console.error(`${at}: type needs selector and text`); process.exit(2); }
67
67
  if (s.action === "expect" && !s.selector) { console.error(`${at}: expect needs selector`); process.exit(2); }
68
+ // FRAMING (founder ruling 2026-09-27): `frame` is the director's word on the
69
+ // step's camera — "close" (the auto subject box) or "wide" (the full frame,
70
+ // the camera at 1.0). Absent = auto. Anything else is a typo, not a wish.
71
+ if (s.frame !== undefined && s.frame !== "close" && s.frame !== "wide") { console.error(`${at}: frame must be "close" or "wide" (got ${JSON.stringify(s.frame)})`); process.exit(2); }
68
72
  }
69
73
 
70
74
  const DIR = path.resolve(outArg);
@@ -72,7 +76,7 @@ const DIR = path.resolve(outArg);
72
76
  // RE-TAKE (founder 2026-09-02): the storyboard IS the bite's DNA. Keep a verbatim
73
77
  // copy in the take dir so upload.mjs can stage it as the recording recipe —
74
78
  // selectors, urls, typed text, hideCss — the wire manifest alone cannot re-film.
75
- const ENGINE_VERSION = "1.0.9";
79
+ const ENGINE_VERSION = "1.1.0";
76
80
  fs.mkdirSync(DIR, { recursive: true });
77
81
  // The copy is the RECIPE, not the transport: cdpWsUrl / storageStatePath are
78
82
  // per-take plumbing stamped by the cloud runner (a CDP url carries a session
@@ -125,7 +129,11 @@ const SUPERSAMPLE = 1;
125
129
  const VIEW = { width: DESIGN.width * SUPERSAMPLE, height: DESIGN.height * SUPERSAMPLE };
126
130
  // Persistent camera-browser profile: the human's signed-in sessions live here.
127
131
  // The auth checkpoint (SKILL.md) fills it; record only ever reads it.
128
- const profileDir = path.resolve(".recorder", "profile");
132
+ // PARALLEL TAKES (1.7.0, founder ruling 2026-09-26): a batch films several
133
+ // takes at once, each in its OWN profile directory seeded from this one, so
134
+ // no two Chromes fight over one profile lock. batch.mjs passes the directory
135
+ // in RECORDER_PROFILE; a single take never sets it and films on the profile.
136
+ const profileDir = path.resolve(process.env.RECORDER_PROFILE || path.join(".recorder", "profile"));
129
137
  fs.mkdirSync(profileDir, { recursive: true });
130
138
 
131
139
  // LAW (the video is the metronome, founder 2026-08-08): shots are as long as
@@ -406,6 +414,46 @@ const pushShot = (box, tStart, tEnd, label, extra) => {
406
414
  });
407
415
  };
408
416
 
417
+ // LAW (the camera obeys the script, founder 2026-09-27, bite 1117): a WIDE
418
+ // shot is the full frame — the camera at 1.0 — and the server honours it as a
419
+ // boundary: the previous close shot ENDS at its start, nothing tight is merged
420
+ // into it or held over it. What Enter revealed, a settle after a navigation,
421
+ // and any step the director framed `wide` are wide shots. Framing is OUR job,
422
+ // decided from the narration we wrote; the customer never thinks about zooms.
423
+ const FULL_FRAME = { x: 0, y: 0, width: VIEW.width, height: VIEW.height };
424
+ const pushWide = (tStart, tEnd, label, extra) =>
425
+ pushShot(FULL_FRAME, tStart, tEnd, label ?? "wide", { ...(extra ?? {}), wide: true });
426
+ // A wide shot pushed at the END of the step (after the narration hold), so it
427
+ // spans the whole landing — the line "you land on the report" plays over it.
428
+ let wideAfter = null; // { tStart, label, extra }
429
+ const wantsWide = (step) => step.frame === "wide";
430
+ const wantsClose = (step) => step.frame === "close";
431
+ // The page as a signature, so a submit that swaps the page IN PLACE (a search
432
+ // that re-renders a list, a client-side route change) counts as a landing
433
+ // even when the URL held still: url, title, first heading, text mass.
434
+ async function pageSignature() {
435
+ try {
436
+ return await page.evaluate(() => {
437
+ const h = [...document.querySelectorAll("h1, h2")].find((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
438
+ return {
439
+ url: location.href,
440
+ title: document.title,
441
+ heading: h ? (h.textContent || "").trim().slice(0, 120) : "",
442
+ mass: (document.body?.innerText ?? "").trim().length,
443
+ };
444
+ });
445
+ } catch { return null; }
446
+ }
447
+ /** Did the page change substantially between two signatures? A new URL, a
448
+ * new title or heading, or the text mass moving by more than a quarter. */
449
+ const pageChanged = (a, b) => {
450
+ if (!a || !b) return false;
451
+ if (a.url !== b.url) return true;
452
+ if (a.title !== b.title || a.heading !== b.heading) return true;
453
+ const base = Math.max(200, a.mass);
454
+ return Math.abs(b.mass - a.mass) / base > 0.25;
455
+ };
456
+
409
457
  // User-approved zoom (storyboard flow 2026-08-20, cloud planner + storyboard
410
458
  // page): a step may carry zoom {x,y,w,h} in PERCENT of the frame, adjusted by
411
459
  // the customer before filming. It overrides the auto shot box. zoom:null means
@@ -700,8 +748,13 @@ try {
700
748
  }
701
749
 
702
750
  let failure = null;
751
+ // A navigation just happened (goto, a click that changed the URL, Enter that
752
+ // changed the page) and no shot has framed the new page yet: the next bare
753
+ // settle is the landing and is framed wide (founder 2026-09-27).
754
+ let landingPending = false;
703
755
  try {
704
756
  for (const step of STORYBOARD.steps) {
757
+ wideAfter = null;
705
758
  const rec = {
706
759
  n: manifest.steps.length + 1,
707
760
  action: step.action,
@@ -791,23 +844,39 @@ try {
791
844
  openingBeatPending = true;
792
845
  }
793
846
  await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [cx, cy]);
847
+ // The new page is a landing: the next bare settle frames it wide. A
848
+ // goto framed `wide` frames it from the paint itself.
849
+ landingPending = true;
850
+ if (wantsWide(step)) wideAfter = { tStart: rec.nav ? rec.nav.to : manifest.record_from, label: step.label ?? "open the page", extra: { n: rec.n } };
794
851
  } else if (step.action === "settle") {
795
852
  // A settle can still carry the camera: `focus` names what to look at.
853
+ // A settle framed `wide`, or a bare settle right after a navigation
854
+ // (the landing), is a full-frame shot for its whole span, narration
855
+ // hold included — "and you land on the report itself" plays wide.
796
856
  const settleMs = ms(step.ms, DEFAULT_SETTLE_MS);
797
857
  const shotStart = t();
798
- await page.waitForTimeout(settleMs);
799
- if (step.focus) {
800
- const box = await visibleBox(step.focus, step.minY ?? 0, 4000);
801
- pushShot(userShotBox(step, box), shotStart, t(), step.label, { n: rec.n });
802
- } else if (step.zoom) {
803
- pushShot(userShotBox(step, null), shotStart, t(), step.label, { n: rec.n });
858
+ const bare = !step.focus && !step.zoom;
859
+ if (wantsWide(step) || (bare && landingPending && !wantsClose(step))) {
860
+ wideAfter = { tStart: shotStart, label: step.label ?? "settle, wide", extra: { n: rec.n } };
861
+ await page.waitForTimeout(settleMs);
862
+ } else {
863
+ await page.waitForTimeout(settleMs);
864
+ if (step.focus) {
865
+ const box = await visibleBox(step.focus, step.minY ?? 0, 4000);
866
+ pushShot(userShotBox(step, box), shotStart, t(), step.label, { n: rec.n });
867
+ } else if (step.zoom) {
868
+ pushShot(userShotBox(step, null), shotStart, t(), step.label, { n: rec.n });
869
+ }
804
870
  }
871
+ landingPending = false;
805
872
  } else if (step.action === "scroll") {
806
873
  // scrollTo distances are ALSO zoomed-space under CSS zoom (measured:
807
874
  // scrollTo(0,600) moves 300 design px) — storyboards speak design px.
808
875
  const shotStart = t();
809
876
  await smoothScroll(step.dy * SUPERSAMPLE, ms(step.ms, 1400), step.within ?? null);
810
- if (step.zoom) pushShot(userShotBox(step, null), shotStart, t(), step.label, { n: rec.n });
877
+ if (wantsWide(step)) wideAfter = { tStart: shotStart, label: step.label ?? "scroll, wide", extra: { n: rec.n } };
878
+ else if (step.zoom) pushShot(userShotBox(step, null), shotStart, t(), step.label, { n: rec.n });
879
+ landingPending = false;
811
880
  } else if (step.action === "click" || step.action === "hover") {
812
881
  const { box, el } = await visibleTarget(step.selector, step.minY ?? 0, step.action === "hover" ? 8000 : 15000);
813
882
  if (!box) {
@@ -867,6 +936,10 @@ try {
867
936
  },
868
937
  };
869
938
 
939
+ // The director's `frame: "wide"`: the whole step is one full-frame shot
940
+ // from arrival to the end of the beat; no subject box, no reveal shot.
941
+ const stepWide = wantsWide(step);
942
+ landingPending = false;
870
943
  if (step.action === "hover") {
871
944
  await page.waitForTimeout(ms(step.dwell, DEFAULT_HOVER_DWELL));
872
945
  // LAW (camera choreography, measured off the reference 2026-08-09):
@@ -876,13 +949,15 @@ try {
876
949
  // that, and the same ballistic motion read stiffer for it. The shot
877
950
  // begins just before ARRIVAL, so the previous frame holds still
878
951
  // while the cursor sweeps across it, then the camera reframes.
879
- pushShot(userShotBox(step, step.focus ? await visibleBox(step.focus, 0, 3000) : box), Math.max(shotStart, arrivalT - 0.3), t(), step.label, { n: rec.n, glide: { t_start: Math.round(shotStart * 100) / 100, t_end: Math.round(arrivalT * 100) / 100 } });
952
+ if (stepWide) wideAfter = { tStart: Math.max(shotStart, arrivalT - 0.3), label: step.label, extra: { n: rec.n } };
953
+ else pushShot(userShotBox(step, step.focus ? await visibleBox(step.focus, 0, 3000) : box), Math.max(shotStart, arrivalT - 0.3), t(), step.label, { n: rec.n, glide: { t_start: Math.round(shotStart * 100) / 100, t_end: Math.round(arrivalT * 100) / 100 } });
880
954
  } else {
881
955
  await page.waitForTimeout(ms(step.dwell, DEFAULT_CLICK_DWELL));
882
956
  await page.evaluate(([a, b]) => window.__recPulse?.(a, b), [x, y]);
883
957
  await page.waitForTimeout(220);
884
958
  rec.click_at = t();
885
959
  const clickEventIndex = mouseEvents.length;
960
+ const urlBeforeClick = page.url();
886
961
  pushMouse("click", x, y, "left");
887
962
  await page.mouse.click(x, y);
888
963
  if (step.waitLoad) {
@@ -893,7 +968,8 @@ try {
893
968
  }
894
969
  // Shot one: the control — beginning near ARRIVAL (see the camera
895
970
  // choreography law above), never spanning the approach glide.
896
- pushShot(userShotBox(step, box), Math.max(shotStart, arrivalT - 0.3), t() + 0.3, step.label, { n: rec.n, glide: { t_start: Math.round(shotStart * 100) / 100, t_end: Math.round(arrivalT * 100) / 100 } });
971
+ if (stepWide) wideAfter = { tStart: Math.max(shotStart, arrivalT - 0.3), label: step.label, extra: { n: rec.n } };
972
+ else pushShot(userShotBox(step, box), Math.max(shotStart, arrivalT - 0.3), t() + 0.3, step.label, { n: rec.n, glide: { t_start: Math.round(shotStart * 100) / 100, t_end: Math.round(arrivalT * 100) / 100 } });
897
973
  // LAW (press physics, founder 2026-08-09): a press has a down and an
898
974
  // up — but the up only exists if the clicked surface is still there.
899
975
  // A menu item or modal button that DESTROYS itself on click gets a
@@ -948,8 +1024,11 @@ try {
948
1024
  await page.waitForTimeout(Math.max(0, afterMs - early));
949
1025
  // Shot two: whatever the click opened. This is the shot that was
950
1026
  // missing on 2026-08-08, when the camera stayed on the button while
951
- // the dialog opened in the middle of the screen.
952
- if (step.reveals !== false) {
1027
+ // the dialog opened in the middle of the screen. A step framed wide
1028
+ // already frames its result; a click that changed the URL leaves the
1029
+ // landing to the settle that follows (framed wide there).
1030
+ if (page.url() !== urlBeforeClick) landingPending = true;
1031
+ if (step.reveals !== false && !stepWide) {
953
1032
  const opened = await revealedBox(step);
954
1033
  if (opened) {
955
1034
  rec.revealed = {
@@ -957,6 +1036,7 @@ try {
957
1036
  w: Math.round(opened.width / SUPERSAMPLE), h: Math.round(opened.height / SUPERSAMPLE),
958
1037
  };
959
1038
  pushShot(opened, rec.click_at + 0.35, t(), `${step.label ?? "click"}, result`, { n: rec.n, revealed: true });
1039
+ landingPending = false;
960
1040
  }
961
1041
  }
962
1042
  }
@@ -1006,6 +1086,10 @@ try {
1006
1086
  pushMouse("click", x, y, "left");
1007
1087
  await page.mouse.click(x, y);
1008
1088
  await page.waitForTimeout(380);
1089
+ // The page as it was when the field was taken: a search that filters
1090
+ // as you type has changed the page BEFORE Enter, and the reveal is
1091
+ // still what this beat's typing and Enter produced.
1092
+ const sigBefore = await pageSignature();
1009
1093
  if (step.clear) {
1010
1094
  await page.keyboard.press(process.platform === "darwin" ? "Meta+a" : "Control+a");
1011
1095
  await page.waitForTimeout(180);
@@ -1018,9 +1102,19 @@ try {
1018
1102
  await page.keyboard.type(ch);
1019
1103
  await page.waitForTimeout(34 + Math.random() * 70);
1020
1104
  }
1105
+ const typeWide = wantsWide(step);
1106
+ const glideWin = { t_start: Math.round(shotStart * 100) / 100, t_end: Math.round(arrivalT * 100) / 100 };
1107
+ landingPending = false;
1021
1108
  if (step.enter || step.submit) {
1022
1109
  // `enter` is the skill's spelling, `submit` the cloud planner's.
1023
1110
  await page.waitForTimeout(300);
1111
+ // LAW (Enter ends the close shot, founder 2026-09-27, bite 1117): the
1112
+ // field shot ends the moment BEFORE Enter — typing end plus the
1113
+ // breath above — never after the navigation waits. What Enter
1114
+ // reveals is framed on its own, below.
1115
+ const enterAt = t();
1116
+ rec.enter_at = enterAt;
1117
+ if (!typeWide) pushShot(userShotBox(step, box), Math.max(shotStart, arrivalT - 0.3), enterAt, step.label ?? "type", { n: rec.n, glide: glideWin });
1024
1118
  const urlBefore = page.url();
1025
1119
  await page.keyboard.press("Enter");
1026
1120
  // A submit usually navigates: re-arm the presenter layer on the new
@@ -1033,9 +1127,38 @@ try {
1033
1127
  await applyHideCss();
1034
1128
  await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [x, y]);
1035
1129
  }
1130
+ // LAW (what Enter revealed is a WIDE shot): a new URL or a page that
1131
+ // changed substantially is a landing — full frame from Enter + 0.35 s
1132
+ // to the end of the beat. A step framed wide is already wide. An
1133
+ // overlay that opened without the page changing (Enter opened a
1134
+ // dialog) is a revealed close shot, as after a click. `reveals:
1135
+ // false` keeps the camera where it is.
1136
+ if (!typeWide && step.reveals !== false) {
1137
+ await page.waitForTimeout(250);
1138
+ const sigAfter = await pageSignature();
1139
+ const navigated = page.url() !== urlBefore;
1140
+ if (navigated || pageChanged(sigBefore, sigAfter)) {
1141
+ rec.landed = { url: page.url(), navigated, heading: sigAfter?.heading ?? null };
1142
+ wideAfter = { tStart: enterAt + 0.35, label: `${step.label ?? "type"}, result`, extra: { n: rec.n, revealed: true } };
1143
+ process.stdout.write(`landing: step ${rec.n} Enter ${navigated ? "changed the URL" : "changed the page"} — framed wide\n`);
1144
+ } else {
1145
+ const opened = await revealedBox(step);
1146
+ if (opened) {
1147
+ rec.revealed = {
1148
+ x: Math.round(opened.x / SUPERSAMPLE), y: Math.round(opened.y / SUPERSAMPLE),
1149
+ w: Math.round(opened.width / SUPERSAMPLE), h: Math.round(opened.height / SUPERSAMPLE),
1150
+ };
1151
+ pushShot(opened, enterAt + 0.35, t() + 0.3, `${step.label ?? "type"}, result`, { n: rec.n, revealed: true });
1152
+ }
1153
+ }
1154
+ }
1155
+ if (typeWide) wideAfter = { tStart: Math.max(shotStart, arrivalT - 0.3), label: step.label ?? "type", extra: { n: rec.n } };
1156
+ } else if (typeWide) {
1157
+ wideAfter = { tStart: Math.max(shotStart, arrivalT - 0.3), label: step.label ?? "type", extra: { n: rec.n } };
1158
+ } else {
1159
+ // One shot: the field, from arrival through the typing.
1160
+ pushShot(userShotBox(step, box), Math.max(shotStart, arrivalT - 0.3), t() + 0.3, step.label ?? "type", { n: rec.n, glide: glideWin });
1036
1161
  }
1037
- // One shot: the field, from arrival through the typing.
1038
- pushShot(userShotBox(step, box), Math.max(shotStart, arrivalT - 0.3), t() + 0.3, step.label ?? "type", { n: rec.n, glide: { t_start: Math.round(shotStart * 100) / 100, t_end: Math.round(arrivalT * 100) / 100 } });
1039
1162
  currentCursor = await cursorUnderPoint();
1040
1163
  pushMouse("move", cx, cy);
1041
1164
  await page.waitForTimeout(ms(step.after, DEFAULT_CLICK_AFTER));
@@ -1053,6 +1176,12 @@ try {
1053
1176
  if (elapsed < need) await page.waitForTimeout(Math.round((need - elapsed) * 1000));
1054
1177
  }
1055
1178
  rec.t_end = t();
1179
+ // The wide shot spans the whole beat, narration hold included.
1180
+ if (wideAfter) {
1181
+ pushWide(wideAfter.tStart, rec.t_end, wideAfter.label, wideAfter.extra);
1182
+ rec.wide = true;
1183
+ wideAfter = null;
1184
+ }
1056
1185
  manifest.steps.push(rec);
1057
1186
  }
1058
1187
  // Closing beat so the last action breathes before the cut ends.