demobite 1.5.0 → 1.7.1

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
@@ -130,6 +130,14 @@ npx demobite retake <biteId> --note "Export moved to the header"
130
130
 
131
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
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.
140
+
133
141
  The package also includes a DemoBites management MCP for releases and centers:
134
142
 
135
143
  ```bash
@@ -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": "demobite",
3
- "version": "1.5.0",
3
+ "version": "1.7.1",
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
  "demobite": "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,53 @@ 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
+ // Every click keeps its close shot this long past the click (founder
431
+ // 2026-09-29). The export FINISHES a pull-out at the shot's end, so a close
432
+ // shot ending 0.3 s after the click started pulling out ~0.7 s BEFORE it: the
433
+ // last click of bite 1110 (the pause switch) played at 1.12x. 1.1 s puts the
434
+ // whole pull-out after the click lands. A click framed wide (option 2) hands
435
+ // over to its wide landing at the same moment.
436
+ const LANDING_PULL_OUT = 1.1;
437
+ const wantsClose = (step) => step.frame === "close";
438
+ // The page as a signature, so a submit that swaps the page IN PLACE (a search
439
+ // that re-renders a list, a client-side route change) counts as a landing
440
+ // even when the URL held still: url, title, first heading, text mass.
441
+ async function pageSignature() {
442
+ try {
443
+ return await page.evaluate(() => {
444
+ const h = [...document.querySelectorAll("h1, h2")].find((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
445
+ return {
446
+ url: location.href,
447
+ title: document.title,
448
+ heading: h ? (h.textContent || "").trim().slice(0, 120) : "",
449
+ mass: (document.body?.innerText ?? "").trim().length,
450
+ };
451
+ });
452
+ } catch { return null; }
453
+ }
454
+ /** Did the page change substantially between two signatures? A new URL, a
455
+ * new title or heading, or the text mass moving by more than a quarter. */
456
+ const pageChanged = (a, b) => {
457
+ if (!a || !b) return false;
458
+ if (a.url !== b.url) return true;
459
+ if (a.title !== b.title || a.heading !== b.heading) return true;
460
+ const base = Math.max(200, a.mass);
461
+ return Math.abs(b.mass - a.mass) / base > 0.25;
462
+ };
463
+
409
464
  // User-approved zoom (storyboard flow 2026-08-20, cloud planner + storyboard
410
465
  // page): a step may carry zoom {x,y,w,h} in PERCENT of the frame, adjusted by
411
466
  // the customer before filming. It overrides the auto shot box. zoom:null means
@@ -700,8 +755,13 @@ try {
700
755
  }
701
756
 
702
757
  let failure = null;
758
+ // A navigation just happened (goto, a click that changed the URL, Enter that
759
+ // changed the page) and no shot has framed the new page yet: the next bare
760
+ // settle is the landing and is framed wide (founder 2026-09-27).
761
+ let landingPending = false;
703
762
  try {
704
763
  for (const step of STORYBOARD.steps) {
764
+ wideAfter = null;
705
765
  const rec = {
706
766
  n: manifest.steps.length + 1,
707
767
  action: step.action,
@@ -791,23 +851,39 @@ try {
791
851
  openingBeatPending = true;
792
852
  }
793
853
  await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [cx, cy]);
854
+ // The new page is a landing: the next bare settle frames it wide. A
855
+ // goto framed `wide` frames it from the paint itself.
856
+ landingPending = true;
857
+ if (wantsWide(step)) wideAfter = { tStart: rec.nav ? rec.nav.to : manifest.record_from, label: step.label ?? "open the page", extra: { n: rec.n } };
794
858
  } else if (step.action === "settle") {
795
859
  // A settle can still carry the camera: `focus` names what to look at.
860
+ // A settle framed `wide`, or a bare settle right after a navigation
861
+ // (the landing), is a full-frame shot for its whole span, narration
862
+ // hold included — "and you land on the report itself" plays wide.
796
863
  const settleMs = ms(step.ms, DEFAULT_SETTLE_MS);
797
864
  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 });
865
+ const bare = !step.focus && !step.zoom;
866
+ if (wantsWide(step) || (bare && landingPending && !wantsClose(step))) {
867
+ wideAfter = { tStart: shotStart, label: step.label ?? "settle, wide", extra: { n: rec.n } };
868
+ await page.waitForTimeout(settleMs);
869
+ } else {
870
+ await page.waitForTimeout(settleMs);
871
+ if (step.focus) {
872
+ const box = await visibleBox(step.focus, step.minY ?? 0, 4000);
873
+ pushShot(userShotBox(step, box), shotStart, t(), step.label, { n: rec.n });
874
+ } else if (step.zoom) {
875
+ pushShot(userShotBox(step, null), shotStart, t(), step.label, { n: rec.n });
876
+ }
804
877
  }
878
+ landingPending = false;
805
879
  } else if (step.action === "scroll") {
806
880
  // scrollTo distances are ALSO zoomed-space under CSS zoom (measured:
807
881
  // scrollTo(0,600) moves 300 design px) — storyboards speak design px.
808
882
  const shotStart = t();
809
883
  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 });
884
+ if (wantsWide(step)) wideAfter = { tStart: shotStart, label: step.label ?? "scroll, wide", extra: { n: rec.n } };
885
+ else if (step.zoom) pushShot(userShotBox(step, null), shotStart, t(), step.label, { n: rec.n });
886
+ landingPending = false;
811
887
  } else if (step.action === "click" || step.action === "hover") {
812
888
  const { box, el } = await visibleTarget(step.selector, step.minY ?? 0, step.action === "hover" ? 8000 : 15000);
813
889
  if (!box) {
@@ -867,6 +943,10 @@ try {
867
943
  },
868
944
  };
869
945
 
946
+ // The director's `frame: "wide"`: the whole step is one full-frame shot
947
+ // from arrival to the end of the beat; no subject box, no reveal shot.
948
+ const stepWide = wantsWide(step);
949
+ landingPending = false;
870
950
  if (step.action === "hover") {
871
951
  await page.waitForTimeout(ms(step.dwell, DEFAULT_HOVER_DWELL));
872
952
  // LAW (camera choreography, measured off the reference 2026-08-09):
@@ -876,13 +956,15 @@ try {
876
956
  // that, and the same ballistic motion read stiffer for it. The shot
877
957
  // begins just before ARRIVAL, so the previous frame holds still
878
958
  // 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 } });
959
+ if (stepWide) wideAfter = { tStart: Math.max(shotStart, arrivalT - 0.3), label: step.label, extra: { n: rec.n } };
960
+ 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
961
  } else {
881
962
  await page.waitForTimeout(ms(step.dwell, DEFAULT_CLICK_DWELL));
882
963
  await page.evaluate(([a, b]) => window.__recPulse?.(a, b), [x, y]);
883
964
  await page.waitForTimeout(220);
884
965
  rec.click_at = t();
885
966
  const clickEventIndex = mouseEvents.length;
967
+ const urlBeforeClick = page.url();
886
968
  pushMouse("click", x, y, "left");
887
969
  await page.mouse.click(x, y);
888
970
  if (step.waitLoad) {
@@ -892,8 +974,14 @@ try {
892
974
  await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [cx, cy]);
893
975
  }
894
976
  // Shot one: the control — beginning near ARRIVAL (see the camera
895
- // 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 } });
977
+ // choreography law above), never spanning the approach glide. A click
978
+ // framed `wide` (its line is about where it leads) is STILL filmed
979
+ // close on the click; the landing after it is the wide shot. Before
980
+ // 2026-09-29 the whole step went wide from arrival, so the viewer saw
981
+ // a tiny cursor click a tiny item (bite 1117: all three clicks).
982
+ const closeEnd = Math.max(t() + 0.3, rec.click_at + LANDING_PULL_OUT);
983
+ pushShot(userShotBox(step, box), Math.max(shotStart, arrivalT - 0.3), closeEnd, step.label, { n: rec.n, glide: { t_start: Math.round(shotStart * 100) / 100, t_end: Math.round(arrivalT * 100) / 100 } });
984
+ if (stepWide) wideAfter = { tStart: closeEnd, label: `${step.label ?? "click"}, landing`, extra: { n: rec.n, revealed: true } };
897
985
  // LAW (press physics, founder 2026-08-09): a press has a down and an
898
986
  // up — but the up only exists if the clicked surface is still there.
899
987
  // A menu item or modal button that DESTROYS itself on click gets a
@@ -948,8 +1036,11 @@ try {
948
1036
  await page.waitForTimeout(Math.max(0, afterMs - early));
949
1037
  // Shot two: whatever the click opened. This is the shot that was
950
1038
  // 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) {
1039
+ // the dialog opened in the middle of the screen. A step framed wide
1040
+ // already frames its result; a click that changed the URL leaves the
1041
+ // landing to the settle that follows (framed wide there).
1042
+ if (page.url() !== urlBeforeClick) landingPending = true;
1043
+ if (step.reveals !== false && !stepWide) {
953
1044
  const opened = await revealedBox(step);
954
1045
  if (opened) {
955
1046
  rec.revealed = {
@@ -957,6 +1048,7 @@ try {
957
1048
  w: Math.round(opened.width / SUPERSAMPLE), h: Math.round(opened.height / SUPERSAMPLE),
958
1049
  };
959
1050
  pushShot(opened, rec.click_at + 0.35, t(), `${step.label ?? "click"}, result`, { n: rec.n, revealed: true });
1051
+ landingPending = false;
960
1052
  }
961
1053
  }
962
1054
  }
@@ -1006,6 +1098,10 @@ try {
1006
1098
  pushMouse("click", x, y, "left");
1007
1099
  await page.mouse.click(x, y);
1008
1100
  await page.waitForTimeout(380);
1101
+ // The page as it was when the field was taken: a search that filters
1102
+ // as you type has changed the page BEFORE Enter, and the reveal is
1103
+ // still what this beat's typing and Enter produced.
1104
+ const sigBefore = await pageSignature();
1009
1105
  if (step.clear) {
1010
1106
  await page.keyboard.press(process.platform === "darwin" ? "Meta+a" : "Control+a");
1011
1107
  await page.waitForTimeout(180);
@@ -1018,9 +1114,19 @@ try {
1018
1114
  await page.keyboard.type(ch);
1019
1115
  await page.waitForTimeout(34 + Math.random() * 70);
1020
1116
  }
1117
+ const typeWide = wantsWide(step);
1118
+ const glideWin = { t_start: Math.round(shotStart * 100) / 100, t_end: Math.round(arrivalT * 100) / 100 };
1119
+ landingPending = false;
1021
1120
  if (step.enter || step.submit) {
1022
1121
  // `enter` is the skill's spelling, `submit` the cloud planner's.
1023
1122
  await page.waitForTimeout(300);
1123
+ // LAW (Enter ends the close shot, founder 2026-09-27, bite 1117): the
1124
+ // field shot ends the moment BEFORE Enter — typing end plus the
1125
+ // breath above — never after the navigation waits. What Enter
1126
+ // reveals is framed on its own, below.
1127
+ const enterAt = t();
1128
+ rec.enter_at = enterAt;
1129
+ if (!typeWide) pushShot(userShotBox(step, box), Math.max(shotStart, arrivalT - 0.3), enterAt, step.label ?? "type", { n: rec.n, glide: glideWin });
1024
1130
  const urlBefore = page.url();
1025
1131
  await page.keyboard.press("Enter");
1026
1132
  // A submit usually navigates: re-arm the presenter layer on the new
@@ -1033,9 +1139,38 @@ try {
1033
1139
  await applyHideCss();
1034
1140
  await page.evaluate(([a, b]) => window.__recSetCursor?.(a, b), [x, y]);
1035
1141
  }
1142
+ // LAW (what Enter revealed is a WIDE shot): a new URL or a page that
1143
+ // changed substantially is a landing — full frame from Enter + 0.35 s
1144
+ // to the end of the beat. A step framed wide is already wide. An
1145
+ // overlay that opened without the page changing (Enter opened a
1146
+ // dialog) is a revealed close shot, as after a click. `reveals:
1147
+ // false` keeps the camera where it is.
1148
+ if (!typeWide && step.reveals !== false) {
1149
+ await page.waitForTimeout(250);
1150
+ const sigAfter = await pageSignature();
1151
+ const navigated = page.url() !== urlBefore;
1152
+ if (navigated || pageChanged(sigBefore, sigAfter)) {
1153
+ rec.landed = { url: page.url(), navigated, heading: sigAfter?.heading ?? null };
1154
+ wideAfter = { tStart: enterAt + 0.35, label: `${step.label ?? "type"}, result`, extra: { n: rec.n, revealed: true } };
1155
+ process.stdout.write(`landing: step ${rec.n} Enter ${navigated ? "changed the URL" : "changed the page"} — framed wide\n`);
1156
+ } else {
1157
+ const opened = await revealedBox(step);
1158
+ if (opened) {
1159
+ rec.revealed = {
1160
+ x: Math.round(opened.x / SUPERSAMPLE), y: Math.round(opened.y / SUPERSAMPLE),
1161
+ w: Math.round(opened.width / SUPERSAMPLE), h: Math.round(opened.height / SUPERSAMPLE),
1162
+ };
1163
+ pushShot(opened, enterAt + 0.35, t() + 0.3, `${step.label ?? "type"}, result`, { n: rec.n, revealed: true });
1164
+ }
1165
+ }
1166
+ }
1167
+ if (typeWide) wideAfter = { tStart: Math.max(shotStart, arrivalT - 0.3), label: step.label ?? "type", extra: { n: rec.n } };
1168
+ } else if (typeWide) {
1169
+ wideAfter = { tStart: Math.max(shotStart, arrivalT - 0.3), label: step.label ?? "type", extra: { n: rec.n } };
1170
+ } else {
1171
+ // One shot: the field, from arrival through the typing.
1172
+ pushShot(userShotBox(step, box), Math.max(shotStart, arrivalT - 0.3), t() + 0.3, step.label ?? "type", { n: rec.n, glide: glideWin });
1036
1173
  }
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
1174
  currentCursor = await cursorUnderPoint();
1040
1175
  pushMouse("move", cx, cy);
1041
1176
  await page.waitForTimeout(ms(step.after, DEFAULT_CLICK_AFTER));
@@ -1053,6 +1188,12 @@ try {
1053
1188
  if (elapsed < need) await page.waitForTimeout(Math.round((need - elapsed) * 1000));
1054
1189
  }
1055
1190
  rec.t_end = t();
1191
+ // The wide shot spans the whole beat, narration hold included.
1192
+ if (wideAfter) {
1193
+ pushWide(wideAfter.tStart, rec.t_end, wideAfter.label, wideAfter.extra);
1194
+ rec.wide = true;
1195
+ wideAfter = null;
1196
+ }
1056
1197
  manifest.steps.push(rec);
1057
1198
  }
1058
1199
  // Closing beat so the last action breathes before the cut ends.