demobites 1.38.0 → 1.39.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "demobites",
3
- "version": "1.38.0",
3
+ "version": "1.39.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"
@@ -599,11 +599,13 @@ async function visibleTarget(selector, minY = 0, timeout = 15000) {
599
599
  // third and re-scan — instead of failing the whole take. Bounded so a
600
600
  // genuinely-unreachable target still ends the loop. This is the camera
601
601
  // following the subject, not a camera-path change.
602
+ // THE SCROLL LAW (founder's take of 2026-10-18 jumped instead of scrolling): the correction GLIDES, never
603
+ // jumps — the same eased scroll a `scroll` step gets, ~800 ms, toward the target at 40% of the viewport.
604
+ // scrollIntoViewIfNeeded + an instant scrollBy was a cut in the middle of a take.
602
605
  if (offVerticalEl && scrollCorrections < 3) {
603
606
  scrollCorrections++;
604
- await offVerticalEl.scrollIntoViewIfNeeded({ timeout: 2000 }).catch(() => {});
605
- await page.evaluate(() => window.scrollBy(0, -Math.round(window.innerHeight * 0.28))).catch(() => {});
606
- await page.waitForTimeout(450);
607
+ await glideIntoView(offVerticalEl, 800);
608
+ await page.waitForTimeout(300);
607
609
  continue;
608
610
  }
609
611
  await page.waitForTimeout(500);
@@ -764,6 +766,36 @@ async function glide(x, y) {
764
766
  await page.mouse.move(x, y).catch(() => {});
765
767
  }
766
768
 
769
+ /** Ease the page (or the target's own scrolling panel) so the element lands at 40% of the viewport. The same
770
+ * easing as smoothScroll, so a camera correction looks exactly like an authored scroll step. */
771
+ async function glideIntoView(el, ms = 800) {
772
+ await el.evaluate((node, m) => new Promise((res) => {
773
+ const scrolls = (n) => {
774
+ if (!n || n === document.body || n === document.documentElement) return false;
775
+ const cs = getComputedStyle(n);
776
+ return /(auto|scroll|overlay)/.test(cs.overflowY) && n.scrollHeight > n.clientHeight + 40;
777
+ };
778
+ let panel = null;
779
+ for (let p = node.parentElement; p; p = p.parentElement) { if (scrolls(p)) { panel = p; break; } }
780
+ const r = node.getBoundingClientRect();
781
+ const h = panel ? panel.clientHeight : window.innerHeight;
782
+ const topInView = panel ? r.top - panel.getBoundingClientRect().top : r.top;
783
+ const d = Math.round(topInView - h * 0.4);
784
+ if (Math.abs(d) < 2) return res();
785
+ const y0 = panel ? panel.scrollTop : window.scrollY;
786
+ const t0 = performance.now();
787
+ const done = setTimeout(res, m + 500); // never hang the take on a tab that stops painting
788
+ const step = (now) => {
789
+ const e = Math.min(1, (now - t0) / m);
790
+ const ease = e < 0.5 ? 2 * e * e : 1 - Math.pow(-2 * e + 2, 2) / 2;
791
+ if (panel) panel.scrollTo(0, y0 + d * ease);
792
+ else window.scrollTo(0, y0 + d * ease);
793
+ if (e < 1) requestAnimationFrame(step); else { clearTimeout(done); res(); }
794
+ };
795
+ requestAnimationFrame(step);
796
+ }), ms).catch(() => {});
797
+ }
798
+
767
799
  async function smoothScroll(dy, ms = 1400, within = null) {
768
800
  // `within` (2026-08-28, UC-studio lesson): full-viewport apps scroll INNER
769
801
  // panels, not the window — window.scrollTo is a no-op there. When set, it
@@ -148,14 +148,58 @@ const explore = (from, lookingFor, depth) => {
148
148
  for (const sc of res.screens.slice(0, 3)) if (sc.shot && fs.existsSync(sc.shot)) content.push({ type: "image", source: { type: "base64", media_type: "image/jpeg", data: fs.readFileSync(sc.shot).toString("base64") } });
149
149
  return content;
150
150
  };
151
+ // THE SCROLL LAW (founder's take of 2026-10-18 jumped instead of scrolling): the dry run reports where every
152
+ // click/hover/type target sat in the viewport (probe.mjs, the RESULT line). The geometry is kept with the camera
153
+ // steps it was measured on; when the planner hands in that same storyboard, a scroll step is placed before every
154
+ // target that sat below the fold (bottom > viewport − 120) or above it (top < 0), dy = top − 40% of the viewport,
155
+ // 900 ms, so the camera glides there instead of the correction jumping it into frame.
156
+ const FOLD_MARGIN = 120;
157
+ const FOLD_AT = 0.4;
158
+ let lastProbe = null; // { key, geometry: { steps: [...] } }
159
+ const cameraKey = (sb) => (sb?.steps || []).map((s) => [s.action, s.url || "", s.selector || "", typeof s.dy === "number" ? s.dy : ""].join("|")).join("\n");
151
160
  const probe = (sb) => {
152
161
  if (!sb || !Array.isArray(sb.steps)) return "storyboard with steps[] required";
153
162
  const file = path.join(dir, "probe.json");
154
163
  fs.writeFileSync(file, JSON.stringify(sb, null, 2) + "\n");
155
164
  const r = run("probe.mjs", [file]);
156
- const out = `${r.stdout || ""}${r.stderr ? `\n${r.stderr}` : ""}`.trim();
165
+ const all = (r.stdout || "").split("\n");
166
+ const last = all.filter((l) => l.startsWith("RESULT ")).pop();
167
+ let geometry = null; try { geometry = JSON.parse(last.slice(7)); } catch { /* an older probe, or it died */ }
168
+ lastProbe = geometry ? { key: cameraKey(sb), geometry } : null;
169
+ const text = all.filter((l) => !l.startsWith("RESULT ")).join("\n");
170
+ let out = `${text}${r.stderr ? `\n${r.stderr}` : ""}`.trim();
171
+ const below = foldScrolls(sb, geometry, true);
172
+ if (below.length) out += `\n\nBelow the fold: ${below.map((b) => `step ${b.step} (${b.selector}, top ${b.top} of ${b.viewport})`).join("; ")}. When you hand in this storyboard unchanged, a scroll step (dy ${below.map((b) => b.dy).join(", ")}, 900 ms) is placed before each of them for you; or add your own and probe again.`;
157
173
  return out.length > 12000 ? out.slice(0, 12000) + "\n… (cut)" : out || "(no report)";
158
174
  };
175
+ /** The scroll steps the geometry asks for. dryRun = true only lists them; otherwise they are spliced into sb.steps. */
176
+ const foldScrolls = (sb, geometry, dryRun = false) => {
177
+ const found = [];
178
+ if (!geometry || !Array.isArray(geometry.steps) || !Array.isArray(sb?.steps)) return found;
179
+ const byStep = new Map(geometry.steps.map((g) => [g.step, g]));
180
+ const steps = sb.steps;
181
+ let offset = 0; // what the inserted scrolls have already moved the page by, on the same screen
182
+ let lastUrl = null;
183
+ const outSteps = [];
184
+ for (const [i, s] of steps.entries()) {
185
+ const g = byStep.get(i + 1);
186
+ if (s.action === "goto") { offset = 0; lastUrl = null; }
187
+ if (g) {
188
+ if (lastUrl !== null && g.url !== lastUrl) offset = 0; // the probe navigated between the two targets
189
+ lastUrl = g.url;
190
+ const top = g.top - offset, bottom = g.bottom - offset;
191
+ if (bottom > g.viewport - FOLD_MARGIN || top < 0) {
192
+ const dy = Math.round(top - g.viewport * FOLD_AT);
193
+ found.push({ step: i + 1, selector: g.selector, top: g.top, viewport: g.viewport, dy });
194
+ if (!dryRun) outSteps.push({ action: "scroll", dy, ms: 900, frame: "wide", label: `bring ${String(g.selector).slice(0, 50)} into view`, inserted: "fold" });
195
+ offset += dy;
196
+ }
197
+ }
198
+ outSteps.push(s);
199
+ }
200
+ if (!dryRun && found.length) sb.steps = outSteps;
201
+ return found;
202
+ };
159
203
  const ACTIONS = new Set(["goto", "settle", "scroll", "click", "hover", "type", "expect"]);
160
204
  const validate = (sb) => {
161
205
  if (!sb || typeof sb !== "object") return "storyboard required";
@@ -169,11 +213,11 @@ const validate = (sb) => {
169
213
  if (s.action === "scroll" && typeof s.dy !== "number") return `${at}: scroll needs numeric dy`;
170
214
  if (s.frame !== undefined && s.frame !== "close" && s.frame !== "wide") return `${at}: frame must be close or wide`;
171
215
  }
172
- if (sb.steps.length > 14) return "more than 14 steps; 6 to 10 is the law";
216
+ if (sb.steps.length > 14) return "more than 14 steps; 5 to 8 is the law";
173
217
  // THE CAMERA'S CLOCK (founder 2026-10-15): the take may not run past 45 s. The estimate is the
174
218
  // camera's own (clock.mjs); the model gets the number and the cure.
175
219
  const est = estimateTake(sb.steps);
176
- if (est > 45) return `the camera would run this take about ${Math.round(est)} s; the law is 30 to 45. Cut words (6 to 11 a line) or steps (5 to 8 narrated); never pad, never add.`;
220
+ if (est > 45) return `the camera would run this take about ${Math.round(est)} s; the law is 30 to 45. Cut words or steps: one line per idea, 3 to 6 narrated lines, no floor on a line, silence on the clicks; never pad, never add.`;
177
221
  return null;
178
222
  };
179
223
 
@@ -304,13 +348,18 @@ const plan = async () => {
304
348
  if (capped()) out = CAP_TEXT();
305
349
  else {
306
350
  probes += 1; if (!firstProbeAt) firstProbeAt = Date.now();
307
- say(`Dry run of the storyboard, ${(input.storyboard?.steps || []).length} steps… (${probes} of ${CAP_PROBES})`);
351
+ say(`Walking the storyboard, ${(input.storyboard?.steps || []).length} steps…`);
308
352
  out = probe(input.storyboard);
309
353
  if (capped()) out += `\n\n${CAP_TEXT()}`;
310
354
  }
311
355
  }
312
356
  else if (u.name === "submit_storyboard") {
313
357
  say("Handing in the storyboard…");
358
+ // The fold scrolls go in before the clock is read, so the estimate is the take the camera will run.
359
+ if (lastProbe && input.storyboard && lastProbe.key === cameraKey(input.storyboard)) {
360
+ const added = foldScrolls(input.storyboard, lastProbe.geometry);
361
+ if (added.length) console.log(` Scroll steps placed before ${added.map((a) => `step ${a.step}`).join(", ")} (below the fold in the dry run).`);
362
+ }
314
363
  const bad = validate(input.storyboard);
315
364
  if (bad) out = `Not accepted: ${bad}`;
316
365
  else { storyboard = input.storyboard; creates = typeof input.creates === "string" ? input.creates : ""; if (typeof input.unsure === "string" && input.unsure.trim()) storyboard.unsure = input.unsure.trim().slice(0, 240); out = "Accepted."; }
@@ -8,6 +8,13 @@
8
8
  // report lists the visible controls on that page so the planner can pick the
9
9
  // real one, and the run stops there.
10
10
  //
11
+ // THE SCROLL LAW (founder's take of 2026-10-18 jumped instead of scrolling):
12
+ // every target reports its geometry back, the element's top and bottom in the
13
+ // viewport at the moment of the probe and the viewport's height, so the
14
+ // planner (plan.mjs) knows which targets sit below the fold and puts a scroll
15
+ // step before each of them. The last line of stdout is machine-readable:
16
+ // RESULT { passed, viewport, steps: [{ step, action, selector, top, bottom, viewport, url }] }
17
+ //
11
18
  // node scripts/probe.mjs <storyboard.json>
12
19
  import fs from "node:fs";
13
20
  import path from "node:path";
@@ -33,6 +40,9 @@ const ms = (v, d) => (typeof v !== "number" ? d : v < 60 ? v * 1000 : v);
33
40
  const lines = [];
34
41
  const out = (t) => { lines.push(t); console.log(t); };
35
42
  let failed = false;
43
+ const VIEWPORT = launchOpts.viewport.height;
44
+ const FOLD_MARGIN = 120;
45
+ const geometry = [];
36
46
 
37
47
  const visible = async (selector, minY) => {
38
48
  const loc = page.locator(selector);
@@ -42,12 +52,20 @@ const visible = async (selector, minY) => {
42
52
  for (let i = 0; i < Math.min(n, 12); i++) {
43
53
  const el = loc.nth(i);
44
54
  const box = await el.boundingBox().catch(() => null);
45
- if (box && box.width > 0 && box.height > 0 && box.y >= (minY || 0)) return el;
55
+ if (box && box.width > 0 && box.height > 0 && box.y >= (minY || 0)) return { el, box };
46
56
  }
47
57
  await page.waitForTimeout(250);
48
58
  }
49
59
  return null;
50
60
  };
61
+ /** Where the target sat when the probe reached it: top and bottom in the viewport, and the viewport's height. */
62
+ const place = (n, s, box) => {
63
+ const top = Math.round(box.y), bottom = Math.round(box.y + box.height);
64
+ const g = { step: n, action: s.action, selector: s.selector, top, bottom, viewport: VIEWPORT, url: page.url() };
65
+ geometry.push(g);
66
+ const where = bottom > VIEWPORT - FOLD_MARGIN ? " — BELOW THE FOLD: a scroll step goes before it" : top < 0 ? " — ABOVE THE VIEWPORT: a scroll step goes before it" : "";
67
+ return ` (top ${top}, bottom ${bottom} of ${VIEWPORT})${where}`;
68
+ };
51
69
  const controls = async () => page.evaluate(() => {
52
70
  const vis = (el) => { const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); return r.width > 0 && r.height > 0 && cs.visibility !== "hidden" && cs.display !== "none"; };
53
71
  const clean = (t) => (t || "").replace(/\s+/g, " ").trim();
@@ -86,7 +104,9 @@ for (const [i, s] of steps.entries()) {
86
104
  await page.waitForTimeout(600);
87
105
  out(`step ${n} scroll ${s.dy}: ok`);
88
106
  } else if (s.action === "click" || s.action === "hover" || s.action === "expect" || s.action === "type") {
89
- const el = await visible(s.selector, s.minY);
107
+ const hit = await visible(s.selector, s.minY);
108
+ const el = hit?.el ?? null;
109
+ const geo = hit ? place(n, s, hit.box) : "";
90
110
  if (!el) {
91
111
  out(`step ${n} ${s.action} ${JSON.stringify(s.selector)}: NOT FOUND on ${page.url()}`);
92
112
  out(` visible controls here:\n ${(await controls()).join("\n ")}`);
@@ -101,17 +121,17 @@ for (const [i, s] of steps.entries()) {
101
121
  if (s.waitLoad) await page.waitForLoadState("load").catch(() => {});
102
122
  let revealed = "";
103
123
  if (typeof s.reveals === "string" && s.reveals) revealed = (await visible(s.reveals, 0)) ? `, reveals ${JSON.stringify(s.reveals)} seen` : `, reveals ${JSON.stringify(s.reveals)} NOT SEEN`;
104
- out(`step ${n} click ${JSON.stringify(s.selector)}: ok${revealed} — now at ${page.url()}`);
124
+ out(`step ${n} click ${JSON.stringify(s.selector)}: ok${geo}${revealed} — now at ${page.url()}`);
105
125
  } else if (s.action === "type") {
106
126
  if (s.clear) await el.fill("");
107
127
  await el.click({ timeout: 8000 }).catch(() => {});
108
128
  await el.type(String(s.text), { delay: 10 });
109
129
  if (s.enter) { await page.keyboard.press("Enter"); await page.waitForTimeout(Math.min(ms(s.after, 1500), 4000)); }
110
- out(`step ${n} type into ${JSON.stringify(s.selector)}: ok — now at ${page.url()}`);
130
+ out(`step ${n} type into ${JSON.stringify(s.selector)}: ok${geo} — now at ${page.url()}`);
111
131
  } else {
112
132
  await el.hover().catch(() => {});
113
133
  await page.waitForTimeout(400);
114
- out(`step ${n} ${s.action === "click" ? "pointed at" : s.action} ${JSON.stringify(s.selector)}: ok`);
134
+ out(`step ${n} ${s.action === "click" ? "pointed at" : s.action} ${JSON.stringify(s.selector)}: ok${geo}`);
115
135
  }
116
136
  } else {
117
137
  out(`step ${n}: unknown action ${JSON.stringify(s.action)}`);
@@ -126,5 +146,6 @@ for (const [i, s] of steps.entries()) {
126
146
  }
127
147
  }
128
148
  out(failed ? "PROBE FAILED: fix the steps above and probe again." : `PROBE PASSED: ${steps.length} steps resolved.`);
149
+ console.log("RESULT " + JSON.stringify({ passed: !failed, viewport: VIEWPORT, steps: geometry }));
129
150
  await ctx.close();
130
151
  process.exit(failed ? 1 : 0);