voila-recorder 0.4.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/tour.js ADDED
@@ -0,0 +1,315 @@
1
+ // Tour engine: the choreography that runs inside the recorded browser.
2
+ // autoTour = zero-config guided pass over any page (hover, scroll, zoom cues).
3
+ // runSteps = deterministic YAML script for custom flows (clicks, typing, navigation).
4
+
5
+ const { OVERLAY_SOURCE } = require('./overlay');
6
+
7
+ async function ensureOverlay(page, tl) {
8
+ await page.evaluate(OVERLAY_SOURCE);
9
+ await page.evaluate(([x, y]) => window.__voila.jump(x, y), [tl.pos.x, tl.pos.y]);
10
+ }
11
+
12
+ async function moveCursor(page, tl, x, y, dur = 700) {
13
+ await ensureOverlay(page, tl);
14
+ tl.recordMove({ x, y }, dur);
15
+ await page.evaluate(([x, y, d]) => window.__voila.moveTo(x, y, d), [x, y, dur]);
16
+ }
17
+
18
+ async function zoomTo(page, tl, level, dur = 800, { sleep, maxZoom = 3 }) {
19
+ tl.recordZoom(Math.min(level, maxZoom), dur);
20
+ await sleep(dur);
21
+ }
22
+
23
+ async function smoothScroll(page, tl, y, dur = 1100) {
24
+ await ensureOverlay(page, tl);
25
+ await page.evaluate(([y, d]) => window.__voila.scrollToY(y, d), [y, dur]);
26
+ }
27
+
28
+ async function clickWithRipple(page, tl, x, y) {
29
+ await page.evaluate(() => { window.__voila.press(); window.__voila.ripple(); });
30
+ await page.mouse.click(x, y);
31
+ }
32
+
33
+ // Move the cursor to the most salient element in view — keeps it alive after
34
+ // navigations and scrolls instead of parking in a corner.
35
+ async function driftToContent(page, tl, opts) {
36
+ const { sleep } = opts;
37
+ const focal = await page.evaluate(pickFocalInView).catch(() => null);
38
+ if (focal) {
39
+ await moveCursor(page, tl, focal.x + 14, focal.y + 10, 900);
40
+ await sleep(150);
41
+ }
42
+ }
43
+
44
+ // --- auto tour ---------------------------------------------------------------
45
+
46
+ const pickHero = () => {
47
+ const el = document.querySelector('h1') || document.querySelector('main h2, header h2, h2');
48
+ if (!el) return null;
49
+ const r = el.getBoundingClientRect();
50
+ if (r.width < 20 || r.top > innerHeight) return null;
51
+ return { x: Math.min(r.x + r.width / 2, innerWidth - 40), y: Math.max(30, r.y + r.height / 2) };
52
+ };
53
+
54
+ const pickNavLinks = () => {
55
+ const els = [...document.querySelectorAll('header a, nav a')].filter(e => {
56
+ const r = e.getBoundingClientRect();
57
+ return r.width > 12 && r.height > 8 && r.y >= 0 && r.y < 140;
58
+ });
59
+ return els.slice(0, 3).map(e => {
60
+ const r = e.getBoundingClientRect();
61
+ return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
62
+ });
63
+ };
64
+
65
+ const pickFocalInView = () => {
66
+ const cands = [...document.querySelectorAll('h2, h3, button, [role="button"], img, video, pre, table')];
67
+ let best = null, bestScore = 0, bestEl = null;
68
+ for (const el of cands) {
69
+ const r = el.getBoundingClientRect();
70
+ if (r.bottom < innerHeight * 0.15 || r.top > innerHeight * 0.85) continue;
71
+ if (r.width < 40 || r.height < 16) continue;
72
+ const area = Math.min(r.width * r.height, innerWidth * innerHeight * 0.4);
73
+ const score = area + (/^H[23]$/.test(el.tagName) ? 30000 : 0);
74
+ if (score > bestScore) { bestScore = score; best = r; bestEl = el; }
75
+ }
76
+ if (!best) return null;
77
+ const heading = /^H[23]$/.test(bestEl.tagName)
78
+ ? bestEl
79
+ : bestEl.closest('section, article, div')?.querySelector('h2, h3');
80
+ const text = heading ? (heading.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 70) : '';
81
+ return {
82
+ x: Math.max(30, Math.min(best.x + best.width / 2, innerWidth - 40)),
83
+ y: Math.max(30, Math.min(best.y + best.height / 2, innerHeight - 40)),
84
+ text,
85
+ };
86
+ };
87
+
88
+ const pickPageInfo = () => {
89
+ const h1 = (document.querySelector('h1')?.textContent || document.title || '')
90
+ .trim().replace(/\s+/g, ' ').slice(0, 80);
91
+ const desc = (document.querySelector('meta[name="description"]')?.content || '')
92
+ .trim().split(/(?<=[.!?])\s/)[0]?.slice(0, 160) || '';
93
+ return { h1, desc };
94
+ };
95
+
96
+ // Extracts a structural outline of the page — used by agent planners (MCP/CLI)
97
+ // to write a steps script without guessing selectors.
98
+ const pickOutline = () => {
99
+ const clean = s => (s || '').trim().replace(/\s+/g, ' ').slice(0, 90);
100
+ return {
101
+ title: document.title,
102
+ h1: clean(document.querySelector('h1')?.textContent),
103
+ description: document.querySelector('meta[name="description"]')?.content || '',
104
+ nav: [...document.querySelectorAll('header a, nav a')].slice(0, 12)
105
+ .map(a => ({ text: clean(a.textContent), href: a.getAttribute('href') }))
106
+ .filter(l => l.text),
107
+ headings: [...document.querySelectorAll('h1, h2, h3')].slice(0, 30)
108
+ .map(h => ({ tag: h.tagName.toLowerCase(), text: clean(h.textContent) }))
109
+ .filter(h => h.text),
110
+ buttons: [...document.querySelectorAll('button, a[class*="btn"], [role="button"]')].slice(0, 15)
111
+ .map(b => clean(b.textContent)).filter(Boolean),
112
+ };
113
+ };
114
+
115
+ const pickCta = () => {
116
+ const el = [...document.querySelectorAll('a, button')].find(e => {
117
+ const r = e.getBoundingClientRect();
118
+ return /get started|sign ?up|try|start|demo|download|docs/i.test(e.textContent || '')
119
+ && r.y >= 0 && r.y < innerHeight && r.width > 30;
120
+ });
121
+ if (!el) return null;
122
+ const r = el.getBoundingClientRect();
123
+ return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
124
+ };
125
+
126
+ async function autoTour(page, tl, opts) {
127
+ const { sleep } = opts;
128
+ await ensureOverlay(page, tl);
129
+ await sleep(800);
130
+
131
+ const info = await page.evaluate(pickPageInfo);
132
+ const hero = await page.evaluate(pickHero);
133
+ if (hero) {
134
+ tl.recordSegment(info.h1, info.desc ? `${info.h1}. ${info.desc}` : `This is ${info.h1}.`);
135
+ await moveCursor(page, tl, hero.x, hero.y, 950);
136
+ await zoomTo(page, tl, 1.5, 900, opts);
137
+ await sleep(900);
138
+ await zoomTo(page, tl, 1.12, 750, opts);
139
+ }
140
+
141
+ const links = await page.evaluate(pickNavLinks);
142
+ if (links.length) tl.recordSegment('A quick look around', "Here's a quick look at what's inside.");
143
+ for (const l of links) {
144
+ await moveCursor(page, tl, l.x, l.y, 650);
145
+ await sleep(450);
146
+ }
147
+
148
+ const m = await page.evaluate(() => ({ h: document.documentElement.scrollHeight, vh: innerHeight }));
149
+ const maxStops = 4;
150
+ let y = 0;
151
+ for (let s = 0; s < maxStops && y < m.h - m.vh - 10; s++) {
152
+ y = Math.min(y + m.vh * 0.9, m.h - m.vh);
153
+ await smoothScroll(page, tl, y, 1100);
154
+ await sleep(1250);
155
+ const focal = await page.evaluate(pickFocalInView);
156
+ if (focal) {
157
+ if (focal.text) tl.recordSegment(focal.text, `${focal.text}.`);
158
+ await moveCursor(page, tl, focal.x, focal.y, 700);
159
+ await zoomTo(page, tl, 1.45, 700, opts);
160
+ await sleep(900);
161
+ await zoomTo(page, tl, 1.1, 600, opts);
162
+ }
163
+ }
164
+
165
+ await smoothScroll(page, tl, 0, 950);
166
+ await sleep(1050);
167
+
168
+ const cta = await page.evaluate(pickCta);
169
+ if (cta) {
170
+ tl.recordSegment('Get started', "And when you're ready, getting started takes one click.");
171
+ await moveCursor(page, tl, cta.x, cta.y, 800);
172
+ await page.evaluate(() => window.__voila.ripple());
173
+ await zoomTo(page, tl, 1.55, 700, opts);
174
+ await sleep(1100);
175
+ }
176
+ await zoomTo(page, tl, 1, 800, opts);
177
+ await sleep(700);
178
+ }
179
+
180
+ // --- steps mode --------------------------------------------------------------
181
+ // YAML: a list of {action, selector?, text?, url?, ms?, level?}
182
+ // actions: goto, click, hover, type, scroll_to, wait, zoom
183
+
184
+ async function targetBox(page, selector) {
185
+ let box = await page.locator(selector).first().boundingBox().catch(() => null);
186
+ if (!box) box = await page.locator(`${selector} >> visible=true`).first().boundingBox().catch(() => null);
187
+ if (!box) throw new Error(`selector not found or not visible: ${selector}`);
188
+ return { box, c: { x: box.x + box.width / 2, y: box.y + box.height / 2 } };
189
+ }
190
+
191
+ async function runSteps(page, tl, steps, opts) {
192
+ const { sleep } = opts;
193
+ await ensureOverlay(page, tl);
194
+ await sleep(600);
195
+
196
+ // Narration-paced segments: a segment stays on screen at least as long as
197
+ // its narration clip (durations pre-measured by the pipeline as _narrDurMs).
198
+ let segStart = 0, segMinMs = 0;
199
+ const finishSegment = async () => {
200
+ const remaining = segStart + segMinMs - Date.now();
201
+ if (remaining > 0) await sleep(remaining);
202
+ };
203
+
204
+ for (let si = 0; si < steps.length; si++) {
205
+ const step = steps[si];
206
+ if (step.caption || step.narration) {
207
+ await finishSegment();
208
+ tl.recordSegment(step.caption, step.narration, step._narrDurMs || null);
209
+ segStart = Date.now();
210
+ segMinMs = (step._narrDurMs || 0) + 600;
211
+ }
212
+ try {
213
+ switch (step.action) {
214
+ case 'goto':
215
+ await page.goto(step.url, { waitUntil: 'domcontentloaded' });
216
+ await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
217
+ await ensureOverlay(page, tl);
218
+ await sleep(400);
219
+ await driftToContent(page, tl, opts);
220
+ break;
221
+ case 'hover': {
222
+ const { c } = await targetBox(page, step.selector);
223
+ await moveCursor(page, tl, c.x, c.y, step.ms || 700);
224
+ await page.mouse.move(c.x, c.y);
225
+ break;
226
+ }
227
+ case 'click': {
228
+ const { c, box } = await targetBox(page, step.selector);
229
+ await moveCursor(page, tl, c.x, c.y, step.ms || 700);
230
+ await page.evaluate(b => window.__voila.highlight(b.x, b.y, b.width, b.height), box);
231
+ await sleep(300);
232
+ await clickWithRipple(page, tl, c.x, c.y);
233
+ await sleep(450);
234
+ await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {});
235
+ await ensureOverlay(page, tl);
236
+ await driftToContent(page, tl, opts);
237
+ break;
238
+ }
239
+ case 'type': {
240
+ const { c, box } = await targetBox(page, step.selector);
241
+ await moveCursor(page, tl, c.x, c.y, 600);
242
+ await page.evaluate(b => window.__voila.highlight(b.x, b.y, b.width, b.height), box);
243
+ await clickWithRipple(page, tl, c.x, c.y);
244
+ await page.keyboard.type(step.text || '', { delay: step.delay || 55 });
245
+ break;
246
+ }
247
+ case 'scroll': {
248
+ await smoothScroll(page, tl, step.y || 0, step.ms || 1100);
249
+ await driftToContent(page, tl, opts);
250
+ break;
251
+ }
252
+ case 'scroll_to': {
253
+ const box = await page.locator(step.selector).first().boundingBox();
254
+ if (box) {
255
+ const y = Math.max(0, box.y + (await page.evaluate(() => window.scrollY)) - 150);
256
+ await smoothScroll(page, tl, y, step.ms || 1100);
257
+ await driftToContent(page, tl, opts);
258
+ }
259
+ break;
260
+ }
261
+ case 'slide': {
262
+ await ensureOverlay(page, tl);
263
+ await page.evaluate(o => window.__voila.slide(o), {
264
+ title: step.title || '',
265
+ subtitle: step.subtitle || '',
266
+ accent: step.accent || null,
267
+ ms: step.ms || Math.max(3200, (step._narrDurMs || 0) + 800),
268
+ });
269
+ break;
270
+ }
271
+ case 'zoom':
272
+ await zoomTo(page, tl, step.level || 1.5, step.ms || 800, opts);
273
+ break;
274
+ case 'wait': {
275
+ const ms = step.ms || 1000;
276
+ if (ms > 2600) {
277
+ // keep the cursor breathing during long holds
278
+ await sleep(ms * 0.45);
279
+ await moveCursor(page, tl, tl.pos.x + 26, tl.pos.y - 16, 800);
280
+ const rest = ms * 0.55 - 800;
281
+ await sleep(rest > 0 ? rest : 200);
282
+ } else {
283
+ await sleep(ms);
284
+ }
285
+ break;
286
+ }
287
+ default:
288
+ throw new Error(`unknown action: ${step.action}`);
289
+ }
290
+ } catch (e) {
291
+ if (step.optional) {
292
+ tl.warnings.push(`step ${si + 1} (${step.action}) skipped: ${e.message}`);
293
+ } else {
294
+ // Enrich the error so an agent can self-repair: which step, where the
295
+ // page actually is, and what the page really contains.
296
+ let hint = '';
297
+ if (/selector/i.test(e.message)) {
298
+ const outline = await page.evaluate(pickOutline).catch(() => null);
299
+ if (outline) hint = ` | page outline: ${JSON.stringify(outline).slice(0, 900)}`;
300
+ }
301
+ throw new Error(`step ${si + 1}/${steps.length} (${step.action}${step.selector ? ` ${step.selector}` : ''}) failed on ${page.url()}: ${e.message}${hint}`);
302
+ }
303
+ }
304
+ await sleep(step.pause ?? 500);
305
+ }
306
+ await finishSegment();
307
+ await zoomTo(page, tl, 1, 800, opts);
308
+ await sleep(600);
309
+ }
310
+
311
+ async function extractOutline(page) {
312
+ return page.evaluate(pickOutline);
313
+ }
314
+
315
+ module.exports = { autoTour, runSteps, extractOutline };