demobite 1.14.1 → 1.16.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
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// THE CAMERA'S CLOCK (2026-10-15). What a storyboard step really costs on the camera, so the
|
|
2
|
+
// planner's total is the take's total. Numbers from record.mjs: estimateSpeech = words / 2.6 + 0.4;
|
|
3
|
+
// the hold is speech + 0.5 s breath measured from arrival; a click or hover spends ~1 s on cursor
|
|
4
|
+
// flight and dwell before arrival; the first narrated beat carries STARTUP_LEAD_SLACK_S = 2.5;
|
|
5
|
+
// the take closes on CLOSING_BEAT_MS = 2200. Keep the two in step.
|
|
6
|
+
export const CLOSING_S = 2.2;
|
|
7
|
+
const ms = (v, fallback) => (v == null ? fallback : v < 60 ? v * 1000 : v);
|
|
8
|
+
const speech = (text) => { const n = String(text ?? "").trim().split(/\s+/).filter(Boolean).length; return n ? n / 2.6 + 0.4 + 0.5 : 0; };
|
|
9
|
+
export const secondsOf = (s, firstNarrated) => {
|
|
10
|
+
const text = typeof s.narration === "string" ? s.narration : s.narration?.text ?? "";
|
|
11
|
+
const say = speech(text);
|
|
12
|
+
const slack = say && firstNarrated ? 2.5 : 0;
|
|
13
|
+
switch (s.action) {
|
|
14
|
+
case "goto": return 0.1;
|
|
15
|
+
case "settle": return Math.max(ms(s.ms, 1200) / 1000, say) + slack;
|
|
16
|
+
case "scroll": return Math.max(ms(s.ms, 1900) / 1000, say) + slack;
|
|
17
|
+
case "click":
|
|
18
|
+
case "hover": return 1.0 + Math.max(ms(s.after, 2000) / 1000, say) + slack;
|
|
19
|
+
case "type": return 1.0 + String(s.text ?? "").length * 0.05 + Math.max(ms(s.after, 1500) / 1000, say) + slack;
|
|
20
|
+
case "expect": return 0.3;
|
|
21
|
+
default: return 1 + say;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
export const estimateTake = (steps) => {
|
|
25
|
+
let first = true, total = CLOSING_S;
|
|
26
|
+
for (const s of steps ?? []) { total += secondsOf(s, first); if (s.narration) first = false; }
|
|
27
|
+
return total;
|
|
28
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// EXPLORE (the built-in agent, founder 2026-10-15: "the agent we're building does
|
|
3
|
+
// a very short exploration if the brief isn't enough… the customer needs
|
|
4
|
+
// nothing but identity"). Without the app's code, the planner knows only the
|
|
5
|
+
// words on the starting page. This walks the live app for it: from the start
|
|
6
|
+
// address it follows the nav links, tabs and menus whose words match what the
|
|
7
|
+
// planner is looking for (the brief's nouns), two hops deep, and brings back
|
|
8
|
+
// every screen it reached — address, title, headings, tabs, controls — with a
|
|
9
|
+
// screenshot of the best matches. Headless, on the camera's profile (signed
|
|
10
|
+
// in), no video, no window. Reversible clicks only: links, tabs, menu
|
|
11
|
+
// openers; never a button that submits, creates, deletes or pays.
|
|
12
|
+
//
|
|
13
|
+
// node scripts/explore.mjs <url> --for "integrations,settings,github" [--depth 2] [--out <dir>]
|
|
14
|
+
// prints one "· phrase" per screen (the app shows them live), then RESULT {json}
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
const url = argv.find((a) => /^https?:\/\//.test(a));
|
|
20
|
+
const forArg = argv[argv.indexOf("--for") + 1] || "";
|
|
21
|
+
const depth = Math.min(3, Math.max(1, Number(argv[argv.indexOf("--depth") + 1]) || 2));
|
|
22
|
+
const outDir = argv.includes("--out") ? argv[argv.indexOf("--out") + 1] : process.cwd();
|
|
23
|
+
if (!url || !forArg) { console.error('Usage: node scripts/explore.mjs <url> --for "word,word" [--depth 2] [--out <dir>]'); process.exit(2); }
|
|
24
|
+
const STOP = new Set(["the", "a", "an", "and", "or", "of", "to", "in", "on", "at", "for", "with", "page", "section", "tab", "go", "open", "show", "explain", "demo", "record", "then", "available", "there", "it", "its", "this", "that", "your", "our", "my", "how", "what", "all", "about", "from", "into"]);
|
|
25
|
+
const stem = (w) => w.toLowerCase().replace(/[^a-z0-9]/g, "").replace(/(ings|ing|ies|es|s|ed)$/, "");
|
|
26
|
+
const wants = [...new Set(forArg.split(/[,\s]+/).map(stem).filter((w) => w.length > 2 && !STOP.has(w)))];
|
|
27
|
+
const say = (t) => console.log(`· ${t}`);
|
|
28
|
+
const DANGER = /\b(delete|remove|pay|purchase|buy|subscribe|upgrade|publish|send|submit|save|create|add|invite|export|sign out|log out|logout|cancel|disconnect|revoke|reset|confirm)\b/i;
|
|
29
|
+
|
|
30
|
+
const { chromium } = await import("playwright");
|
|
31
|
+
const profileDir = process.env.RECORDER_PROFILE || path.resolve(process.cwd(), ".recorder", "profile");
|
|
32
|
+
const launchOpts = { headless: true, viewport: { width: 1440, height: 900 }, args: ["--force-color-profile=srgb", "--disable-blink-features=AutomationControlled"], ignoreDefaultArgs: ["--enable-automation"] };
|
|
33
|
+
let ctx;
|
|
34
|
+
try { ctx = await chromium.launchPersistentContext(profileDir, { ...launchOpts, channel: "chrome" }); }
|
|
35
|
+
catch { ctx = await chromium.launchPersistentContext(profileDir, launchOpts); }
|
|
36
|
+
const page = ctx.pages()[0] ?? (await ctx.newPage());
|
|
37
|
+
const origin = new URL(url).origin;
|
|
38
|
+
|
|
39
|
+
// What is on this screen, and which of its controls could lead somewhere.
|
|
40
|
+
const harvest = () => page.evaluate(() => {
|
|
41
|
+
const vis = (el) => { const r = el.getBoundingClientRect(); const cs = getComputedStyle(el); return r.width > 0 && r.height > 0 && cs.visibility !== "hidden" && cs.display !== "none"; };
|
|
42
|
+
const clean = (t) => (t || "").replace(/\s+/g, " ").trim();
|
|
43
|
+
const label = (el) => clean(el.innerText || el.getAttribute("aria-label") || el.getAttribute("title") || "");
|
|
44
|
+
const uniq = (xs) => { const seen = new Set(); return xs.filter((x) => { const k = JSON.stringify(x); if (!x || seen.has(k)) return false; seen.add(k); return true; }); };
|
|
45
|
+
const headings = uniq([...document.querySelectorAll("h1, h2, h3")].filter(vis).map((el) => clean(el.innerText)).filter((t) => t && t.length < 90));
|
|
46
|
+
const links = uniq([...document.querySelectorAll("a[href]")].filter(vis).map((el) => ({ kind: "link", text: label(el).slice(0, 60), href: el.href, tip: clean(el.closest("[title],[aria-label]")?.getAttribute("title") || el.closest("[aria-label]")?.getAttribute("aria-label") || "").slice(0, 60) })).filter((l) => (l.text || l.tip) && l.href.startsWith(location.origin) && !/^(mailto|tel):/.test(l.href)));
|
|
47
|
+
const tabs = uniq([...document.querySelectorAll("[role='tab'], [role='tablist'] button, [data-state] button[data-value], nav button, header button, aside button, button[aria-haspopup], button[aria-expanded]")].filter(vis).map((el) => ({ kind: el.getAttribute("role") === "tab" ? "tab" : "menu", text: label(el).slice(0, 60), id: el.id || el.getAttribute("data-value") || "" })).filter((t) => t.text));
|
|
48
|
+
const buttons = uniq([...document.querySelectorAll("button, [role='button']")].filter(vis).map((el) => label(el).slice(0, 60)).filter(Boolean)).slice(0, 40);
|
|
49
|
+
const fields = uniq([...document.querySelectorAll("input, textarea, select")].filter(vis).map((el) => clean(el.getAttribute("aria-label") || el.getAttribute("placeholder") || el.closest("label")?.innerText || "")).filter(Boolean)).slice(0, 20);
|
|
50
|
+
const dialog = clean(document.querySelector("[role='dialog'] h1, [role='dialog'] h2, [role='dialog'] h3")?.innerText || "");
|
|
51
|
+
return { title: document.title, url: location.href, headings, links, tabs, buttons, fields, dialog };
|
|
52
|
+
});
|
|
53
|
+
const score = (text) => { const s = stem(text); let n = 0; for (const w of wants) if (s.includes(w) || w.includes(s) && s.length > 3) n++; return n; };
|
|
54
|
+
|
|
55
|
+
const screens = [];
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
let shots = 0;
|
|
58
|
+
const started = Date.now();
|
|
59
|
+
const budgetMs = 75000;
|
|
60
|
+
const settle = async () => { await page.waitForLoadState("load").catch(() => {}); await page.waitForTimeout(1800); };
|
|
61
|
+
|
|
62
|
+
const record = async (how) => {
|
|
63
|
+
const h = await harvest();
|
|
64
|
+
const key = h.url.replace(/[#?].*$/, "") + (h.dialog ? `#${h.dialog}` : "") + (h.url.includes("?") ? `?${new URL(h.url).search.slice(1)}` : "");
|
|
65
|
+
if (seen.has(key)) return null;
|
|
66
|
+
seen.add(key);
|
|
67
|
+
const sc = score([h.title, ...h.headings, h.dialog].join(" ")) * 2 + h.tabs.reduce((n, t) => n + score(t.text), 0) + h.buttons.reduce((n, b) => n + score(b), 0);
|
|
68
|
+
const entry = { how, url: h.url, title: h.title, headings: h.headings.slice(0, 12), tabs: h.tabs.map((t) => t.text).slice(0, 20), buttons: h.buttons, fields: h.fields, dialog: h.dialog || undefined, nav: h.links.filter((l) => l.tip || l.text).slice(0, 40).map((l) => `${l.text || l.tip} → ${l.href.replace(origin, "")}`), score: sc, shot: null };
|
|
69
|
+
if (shots < 6) { const f = path.join(outDir, `explore-${++shots}.jpg`); await page.screenshot({ path: f, type: "jpeg", quality: 50 }).catch(() => {}); entry.shot = f; }
|
|
70
|
+
screens.push(entry);
|
|
71
|
+
say(`${how}: "${(h.dialog || h.headings[0] || h.title || h.url).slice(0, 70)}"${h.tabs.length ? ` · tabs: ${h.tabs.map((t) => t.text).slice(0, 6).join(", ")}` : ""}`);
|
|
72
|
+
return { h, entry };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const candidates = (h) => {
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const l of h.links) { const s = score(`${l.text} ${l.tip} ${l.href.replace(origin, "")}`); if (s) out.push({ s, kind: "link", text: l.text || l.tip, href: l.href }); }
|
|
78
|
+
for (const t of h.tabs) { const s = score(t.text); if (s && !DANGER.test(t.text)) out.push({ s, kind: t.kind, text: t.text }); }
|
|
79
|
+
return out.sort((a, b) => b.s - a.s).slice(0, 4);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const visit = async (c, fromUrl) => {
|
|
83
|
+
if (c.kind === "link") {
|
|
84
|
+
await page.goto(c.href, { waitUntil: "load", timeout: 30000 }).catch(() => null);
|
|
85
|
+
await settle();
|
|
86
|
+
return record(`${c.text}`);
|
|
87
|
+
}
|
|
88
|
+
// A tab or a menu opener on the current page: click it by its text, stay on the page.
|
|
89
|
+
if (page.url() !== fromUrl) { await page.goto(fromUrl, { waitUntil: "load", timeout: 30000 }).catch(() => null); await settle(); }
|
|
90
|
+
const loc = page.locator(c.kind === "tab" ? `[role='tab']:has-text("${c.text.replace(/"/g, "")}")` : `button:has-text("${c.text.replace(/"/g, "")}")`).first();
|
|
91
|
+
const ok = await loc.click({ timeout: 5000 }).then(() => true).catch(() => false);
|
|
92
|
+
if (!ok) return null;
|
|
93
|
+
await page.waitForTimeout(1500);
|
|
94
|
+
const r = await record(`${c.text}`);
|
|
95
|
+
// A menu that opened: its links are candidates too, then close it.
|
|
96
|
+
if (c.kind === "menu" && r) { r.menu = true; }
|
|
97
|
+
return r;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
say(`Looking around ${new URL(url).host} for ${wants.join(", ")}…`);
|
|
102
|
+
await page.goto(url, { waitUntil: "load", timeout: 45000 });
|
|
103
|
+
await settle();
|
|
104
|
+
let frontier = [];
|
|
105
|
+
const root = await record("Start");
|
|
106
|
+
if (root) frontier.push({ ...root, d: 0 });
|
|
107
|
+
while (frontier.length && Date.now() - started < budgetMs && screens.length < 10) {
|
|
108
|
+
const cur = frontier.shift();
|
|
109
|
+
if (cur.d >= depth) continue;
|
|
110
|
+
for (const c of candidates(cur.h)) {
|
|
111
|
+
if (Date.now() - started > budgetMs || screens.length >= 10) break;
|
|
112
|
+
const r = await visit(c, cur.entry.url);
|
|
113
|
+
if (r) frontier.push({ ...r, d: cur.d + 1 });
|
|
114
|
+
if (r?.menu) await page.keyboard.press("Escape").catch(() => {});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
say(screens.length > 1 ? `Found ${screens.length - 1} screen${screens.length > 2 ? "s" : ""} beyond the start.` : "Nothing beyond the start page matched.");
|
|
118
|
+
screens.sort((a, b) => b.score - a.score);
|
|
119
|
+
console.log("RESULT " + JSON.stringify({ wants, screens }));
|
|
120
|
+
} catch (e) {
|
|
121
|
+
console.error(String(e.message).split("\n")[0].slice(0, 160));
|
|
122
|
+
console.log("RESULT " + JSON.stringify({ wants, screens, error: String(e.message).slice(0, 120) }));
|
|
123
|
+
}
|
|
124
|
+
await ctx.close();
|
|
@@ -347,6 +347,10 @@ const wire = {
|
|
|
347
347
|
frame: { width: FW, height: FH },
|
|
348
348
|
// BROWSER HEADER (2026-09-16): true by default; DemoBites adds the dark macOS bar on top at ingest. A workspace rule "No browser header" sets it false.
|
|
349
349
|
...(typeof man.browserHeader === "boolean" ? { browserHeader: man.browserHeader } : {}),
|
|
350
|
+
// APPROVED STORY (founder 2026-10-15, bite 1228: "the storyteller is blabbing"): the lines came from the
|
|
351
|
+
// director and the person said Film it to THESE lines. DemoBites keeps them verbatim and never writes
|
|
352
|
+
// a filler into a silence or a longer line into a longer beat.
|
|
353
|
+
narration_approved: true,
|
|
350
354
|
duration,
|
|
351
355
|
steps,
|
|
352
356
|
camera,
|
package/skill/scripts/plan.mjs
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// reads is logged on the take (<takeDir>/code-shared.log) so the person can
|
|
19
19
|
// see exactly what left the machine.
|
|
20
20
|
import fs from "node:fs";
|
|
21
|
+
import { estimateTake } from "./clock.mjs";
|
|
21
22
|
import path from "node:path";
|
|
22
23
|
import { spawnSync } from "node:child_process";
|
|
23
24
|
import { fileURLToPath } from "node:url";
|
|
@@ -108,6 +109,26 @@ const screenshot = (url) => {
|
|
|
108
109
|
{ type: "image", source: { type: "base64", media_type: "image/jpeg", data: fs.readFileSync(file).toString("base64") } },
|
|
109
110
|
];
|
|
110
111
|
};
|
|
112
|
+
let explored = 0;
|
|
113
|
+
const explore = (from, lookingFor, depth) => {
|
|
114
|
+
if (!/^https?:\/\//.test(String(from))) return [{ type: "text", text: "from (a url) required" }];
|
|
115
|
+
const words = Array.isArray(lookingFor) ? lookingFor.join(",") : String(lookingFor || "");
|
|
116
|
+
if (!words.trim()) return [{ type: "text", text: "looking_for required" }];
|
|
117
|
+
if (++explored > 3) return [{ type: "text", text: "No more exploring for this storyboard; write it from what you saw." }];
|
|
118
|
+
const exDir = path.join(dir, `explore-${explored}`);
|
|
119
|
+
fs.mkdirSync(exDir, { recursive: true });
|
|
120
|
+
const child = spawnSync("node", [script("explore.mjs"), from, "--for", words, "--depth", String(Math.min(3, Math.max(1, Number(depth) || 2))), "--out", exDir], { cwd: process.cwd(), encoding: "utf8", env: process.env, maxBuffer: 64 * 1024 * 1024 });
|
|
121
|
+
const lines = (child.stdout || "").split("\n");
|
|
122
|
+
for (const l of lines) if (l.startsWith("· ")) say(l.slice(2));
|
|
123
|
+
const last = lines.filter((l) => l.startsWith("RESULT ")).pop();
|
|
124
|
+
let res = null; try { res = JSON.parse(last.slice(7)); } catch { /* none */ }
|
|
125
|
+
if (!res) return [{ type: "text", text: `Exploring failed: ${(child.stderr || "").trim().split("\n").pop() || "no result"}` }];
|
|
126
|
+
const content = [];
|
|
127
|
+
const text = res.screens.map((sc, i) => `## Screen ${i + 1}: ${sc.title || sc.url}\n${sc.url}\nreached by: ${sc.how}${sc.dialog ? `\ndialog: ${sc.dialog}` : ""}\nheadings: ${sc.headings.join(" · ") || "(none)"}\ntabs: ${sc.tabs.join(" · ") || "(none)"}\nbuttons: ${sc.buttons.join(" · ") || "(none)"}${sc.fields.length ? `\nfields: ${sc.fields.join(" · ")}` : ""}\nnav: ${sc.nav.slice(0, 25).join(" · ") || "(none)"}`).join("\n\n");
|
|
128
|
+
content.push({ type: "text", text: `${text || "(no screens)"}${res.error ? `\n\n(stopped early: ${res.error})` : ""}\n\nScreenshots follow for the best matches, in that order.` });
|
|
129
|
+
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") } });
|
|
130
|
+
return content;
|
|
131
|
+
};
|
|
111
132
|
const probe = (sb) => {
|
|
112
133
|
if (!sb || !Array.isArray(sb.steps)) return "storyboard with steps[] required";
|
|
113
134
|
const file = path.join(dir, "probe.json");
|
|
@@ -130,6 +151,10 @@ const validate = (sb) => {
|
|
|
130
151
|
if (s.frame !== undefined && s.frame !== "close" && s.frame !== "wide") return `${at}: frame must be close or wide`;
|
|
131
152
|
}
|
|
132
153
|
if (sb.steps.length > 14) return "more than 14 steps; 6 to 10 is the law";
|
|
154
|
+
// THE CAMERA'S CLOCK (founder 2026-10-15): the take may not run past 45 s. The estimate is the
|
|
155
|
+
// camera's own (clock.mjs); the model gets the number and the cure.
|
|
156
|
+
const est = estimateTake(sb.steps);
|
|
157
|
+
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.`;
|
|
133
158
|
return null;
|
|
134
159
|
};
|
|
135
160
|
|
|
@@ -158,7 +183,7 @@ const prune = (messages) => {
|
|
|
158
183
|
if (b.type !== "tool_result" || !Array.isArray(b.content)) continue;
|
|
159
184
|
for (let k = 0; k < b.content.length; k++) {
|
|
160
185
|
if (b.content[k].type !== "image") continue;
|
|
161
|
-
if (++seen >
|
|
186
|
+
if (++seen > 4) b.content[k] = { type: "text", text: "(a screenshot shown earlier)" };
|
|
162
187
|
}
|
|
163
188
|
}
|
|
164
189
|
}
|
|
@@ -172,7 +197,7 @@ const firstMessage = () => {
|
|
|
172
197
|
`## The app's words on the starting screen\n${vocab}`,
|
|
173
198
|
`## Workspace rules (version ${rules.version || 0})\n${rules.lines?.length ? rules.lines.map((l, i) => `${i + 1}. ${l}`).join("\n") : "(none)"}`,
|
|
174
199
|
`## The project's files${codeOn ? " (this machine sits in the app's repository; read_file is on)" : " (no code here)"}\n${listFiles()}`,
|
|
175
|
-
`Write the storyboard, probe it, fix it, probe again, then submit_storyboard.`,
|
|
200
|
+
codeOn ? `Write the storyboard, probe it, fix it, probe again, then submit_storyboard.` : `There is no code: explore first (explore from the starting address, looking_for = the brief's nouns), then write the storyboard, probe it, fix it, probe again, then submit_storyboard.`,
|
|
176
201
|
];
|
|
177
202
|
return parts.join("\n\n");
|
|
178
203
|
};
|
|
@@ -206,6 +231,7 @@ const plan = async () => {
|
|
|
206
231
|
if (u.name === "list_files") out = listFiles();
|
|
207
232
|
else if (u.name === "read_file") out = readFile(input.path);
|
|
208
233
|
else if (u.name === "harvest_words") { console.log(` harvesting ${(input.urls || []).join(", ")}`); out = harvest(input.urls); }
|
|
234
|
+
else if (u.name === "explore") out = explore(input.from, input.looking_for, input.depth);
|
|
209
235
|
else if (u.name === "screenshot") { console.log(` looking at ${input.url}`); out = screenshot(input.url); }
|
|
210
236
|
else if (u.name === "probe") { console.log(" dry run…"); out = probe(input.storyboard); }
|
|
211
237
|
else if (u.name === "submit_storyboard") {
|
|
@@ -23,11 +23,13 @@ if (!mode || !file || !["print", "words", "await"].includes(mode)) {
|
|
|
23
23
|
}
|
|
24
24
|
const sb = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
25
25
|
const steps = Array.isArray(sb.steps) ? sb.steps : [];
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
};
|
|
26
|
+
// THE CAMERA'S CLOCK (founder 2026-10-15, bite 1228 said 0:36 and ran 0:51): a beat is held until its
|
|
27
|
+
// line would have finished (words / 2.6 s + 0.4 s, record.mjs estimateSpeech) plus half a second of
|
|
28
|
+
// breath; a click or hover adds about a second of cursor flight and dwell; the first narrated settle
|
|
29
|
+
// carries 2.5 s of startup slack; the take closes on a 2.2 s beat. Same numbers as record.mjs.
|
|
30
|
+
import { secondsOf as cameraSeconds, CLOSING_S } from "./clock.mjs";
|
|
31
|
+
let firstNarrated = true;
|
|
32
|
+
const secondsOf = (s) => { const r = cameraSeconds(s, firstNarrated); if (s.narration) firstNarrated = false; return r; };
|
|
31
33
|
const mmss = (sec) => `${Math.floor(sec / 60)}:${String(Math.round(sec % 60)).padStart(2, "0")}`;
|
|
32
34
|
// A row per step that says or shows something; silent plumbing (a bare goto,
|
|
33
35
|
// an expect) rides inside the previous row's time.
|
|
@@ -39,7 +41,7 @@ for (const s of steps) {
|
|
|
39
41
|
if (text && !(s.action === "expect" && !s.narration)) rows.push({ at: clock, text: text.replace(/\s+/g, " "), pointed: s.pointed === true, step: s });
|
|
40
42
|
clock += dur;
|
|
41
43
|
}
|
|
42
|
-
const total = clock;
|
|
44
|
+
const total = clock + CLOSING_S;
|
|
43
45
|
const title = (sb.title ?? sb.app ?? "Demo").toString().trim();
|
|
44
46
|
|
|
45
47
|
if (mode === "words") {
|