demobites 1.5.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 +8 -0
- package/launcher/index.mjs +72 -7
- package/package.json +1 -1
- package/scripts/calibrate.mjs +17 -3
- package/scripts/record.mjs +144 -15
- package/skill/SKILL.md +94 -5
- package/skill/scripts/batch.mjs +328 -0
- package/skill/scripts/briefs.mjs +163 -9
- package/skill/scripts/cleanup.mjs +2 -1
- package/skill/scripts/manifest.mjs +13 -1
- package/skill/scripts/retake.mjs +72 -14
- package/skill/scripts/stage-wait.mjs +119 -0
- package/skill/scripts/status.mjs +12 -2
- package/skill/scripts/upload.mjs +11 -3
- package/skill/scripts/vocab.mjs +2 -1
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
|
package/launcher/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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
package/scripts/calibrate.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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.",
|
package/scripts/record.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
799
|
-
if (step
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
} else
|
|
803
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|