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/AGENTS.md +63 -0
- package/README.md +136 -0
- package/RECIPE.md +68 -0
- package/audio.js +154 -0
- package/cli.js +96 -0
- package/mcp.js +116 -0
- package/overlay.js +157 -0
- package/package.json +38 -0
- package/pipeline.js +91 -0
- package/public/index.html +117 -0
- package/recorder.js +171 -0
- package/render.js +177 -0
- package/review.js +66 -0
- package/server.js +74 -0
- package/skills/voila/SKILL.md +59 -0
- package/tour.js +315 -0
package/render.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Renderer: turns captured frames + cursor/zoom timeline into a smooth MP4.
|
|
2
|
+
// For each output frame we compute the eased zoom level and a camera center
|
|
3
|
+
// following the cursor, crop the 2x-DPR source frame, and pipe JPEGs to ffmpeg.
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { spawn } = require('child_process');
|
|
8
|
+
const sharp = require('sharp');
|
|
9
|
+
const ffmpegPath = require('ffmpeg-static');
|
|
10
|
+
|
|
11
|
+
const cubicInOut = p => (p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2);
|
|
12
|
+
|
|
13
|
+
// Piecewise value from timeline events [{t, from, to, dur}] at time t.
|
|
14
|
+
function scalarAt(events, t, initial) {
|
|
15
|
+
let v = initial;
|
|
16
|
+
for (const e of events) {
|
|
17
|
+
if (t < e.t) break;
|
|
18
|
+
v = e.from + (e.to - e.from) * cubicInOut(Math.min(1, (t - e.t) / e.dur));
|
|
19
|
+
}
|
|
20
|
+
return v;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function pointAt(events, t, initial) {
|
|
24
|
+
let v = { ...initial };
|
|
25
|
+
for (const e of events) {
|
|
26
|
+
if (t < e.t) break;
|
|
27
|
+
const p = cubicInOut(Math.min(1, (t - e.t) / e.dur));
|
|
28
|
+
v = { x: e.from.x + (e.to.x - e.from.x) * p, y: e.from.y + (e.to.y - e.from.y) * p };
|
|
29
|
+
}
|
|
30
|
+
return v;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CAPTION_MAX_MS = 7000;
|
|
34
|
+
|
|
35
|
+
function captionAt(segments, t) {
|
|
36
|
+
let active = null;
|
|
37
|
+
for (const s of segments || []) {
|
|
38
|
+
if (s.t > t) break;
|
|
39
|
+
active = s;
|
|
40
|
+
}
|
|
41
|
+
if (!active || !active.caption) return null;
|
|
42
|
+
// Caption lifetime follows the narration clip when its duration is known.
|
|
43
|
+
const windowMs = active.dur ? active.dur + 500 : CAPTION_MAX_MS;
|
|
44
|
+
return t - active.t < windowMs ? active.caption : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const escapeXml = s => s.replace(/[<>&'"]/g, c =>
|
|
48
|
+
({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[c]));
|
|
49
|
+
|
|
50
|
+
function wrapText(text, maxChars = 46) {
|
|
51
|
+
const words = text.split(/\s+/);
|
|
52
|
+
const lines = [''];
|
|
53
|
+
for (const w of words) {
|
|
54
|
+
const cur = lines[lines.length - 1];
|
|
55
|
+
if (cur && (cur + ' ' + w).length > maxChars) lines.push(w);
|
|
56
|
+
else lines[lines.length - 1] = cur ? cur + ' ' + w : w;
|
|
57
|
+
}
|
|
58
|
+
return lines.slice(0, 2);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function captionOverlay(text, outW) {
|
|
62
|
+
const fontSize = Math.max(19, Math.min(34, Math.round(outW * 0.0177)));
|
|
63
|
+
const maxChars = Math.min(46, Math.floor((outW * 0.85) / (fontSize * 0.56)));
|
|
64
|
+
const lines = wrapText(text, maxChars);
|
|
65
|
+
const lineH = Math.round(fontSize * 1.35), padX = Math.round(fontSize * 0.88), padY = Math.round(fontSize * 0.59);
|
|
66
|
+
const boxW = Math.min(outW - 80, Math.max(...lines.map(l => l.length)) * fontSize * 0.56 + padX * 2);
|
|
67
|
+
const boxH = lines.length * lineH + padY * 2 - 8;
|
|
68
|
+
const svg = `<svg width="${Math.round(boxW)}" height="${boxH}" xmlns="http://www.w3.org/2000/svg">
|
|
69
|
+
<rect x="0" y="0" width="${Math.round(boxW)}" height="${boxH}" rx="14" fill="rgba(10,10,14,0.74)"/>
|
|
70
|
+
${lines.map((l, i) =>
|
|
71
|
+
`<text x="50%" y="${padY + (i + 0.78) * lineH - Math.round(fontSize * 0.29)}" text-anchor="middle" fill="#ffffff"
|
|
72
|
+
font-family="Helvetica, Arial, sans-serif" font-size="${fontSize}" font-weight="600">${escapeXml(l)}</text>`
|
|
73
|
+
).join('')}
|
|
74
|
+
</svg>`;
|
|
75
|
+
return {
|
|
76
|
+
input: await sharp(Buffer.from(svg)).png().toBuffer(),
|
|
77
|
+
width: Math.round(boxW),
|
|
78
|
+
height: boxH,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function render(meta, outFile, { fps = 30, outW = null, outH = null, onStatus = () => {} } = {}) {
|
|
83
|
+
const { frames, moves, zooms, tStart, tEnd, viewport, framesDir } = meta;
|
|
84
|
+
if (!frames.length) throw new Error('no frames captured');
|
|
85
|
+
|
|
86
|
+
// Output follows the recorded viewport's aspect (portrait for mobile).
|
|
87
|
+
// 1.5x the CSS viewport, rounded to even for yuv420p (desktop → 1920x1200).
|
|
88
|
+
const even = n => Math.round(n / 2) * 2;
|
|
89
|
+
if (!outW) outW = even(viewport.width * 1.5);
|
|
90
|
+
if (!outH) outH = even(viewport.height * 1.5);
|
|
91
|
+
|
|
92
|
+
// Actual encoded frame size (screencast can letterbox/scale).
|
|
93
|
+
const first = await sharp(path.join(framesDir, frames[0].file)).metadata();
|
|
94
|
+
const W = first.width, H = first.height;
|
|
95
|
+
const sx = W / viewport.width, sy = H / viewport.height;
|
|
96
|
+
|
|
97
|
+
const ffmpeg = spawn(ffmpegPath, [
|
|
98
|
+
'-y', '-f', 'image2pipe', '-framerate', String(fps), '-i', 'pipe:0',
|
|
99
|
+
'-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '18', '-preset', 'veryfast',
|
|
100
|
+
'-movflags', '+faststart', outFile,
|
|
101
|
+
], { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
102
|
+
|
|
103
|
+
let ffErr = '';
|
|
104
|
+
ffmpeg.stderr.on('data', d => { ffErr += d; if (ffErr.length > 20000) ffErr = ffErr.slice(-10000); });
|
|
105
|
+
const done = new Promise((res, rej) => {
|
|
106
|
+
ffmpeg.on('close', code => (code === 0 ? res() : rej(new Error(`ffmpeg exited ${code}\n${ffErr.slice(-2000)}`))));
|
|
107
|
+
ffmpeg.on('error', rej);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const writeFrame = buf => new Promise((res, rej) => {
|
|
111
|
+
ffmpeg.stdin.write(buf, err => (err ? rej(err) : res()));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const total = Math.max(1, Math.floor(((tEnd - tStart) / 1000) * fps));
|
|
115
|
+
let frameIdx = 0;
|
|
116
|
+
let lastKey = null, lastBuf = null;
|
|
117
|
+
let srcCache = { file: null, img: null };
|
|
118
|
+
const overlayCache = new Map();
|
|
119
|
+
|
|
120
|
+
for (let i = 0; i < total; i++) {
|
|
121
|
+
const t = tStart + (i * 1000) / fps;
|
|
122
|
+
|
|
123
|
+
while (frameIdx + 1 < frames.length && frames[frameIdx + 1].t <= t) frameIdx++;
|
|
124
|
+
const srcFile = frames[frameIdx].file;
|
|
125
|
+
|
|
126
|
+
const z = Math.max(1, Math.min(3, scalarAt(zooms, t, 1)));
|
|
127
|
+
const cur = pointAt(moves, t, { x: viewport.width / 2, y: viewport.height / 2 });
|
|
128
|
+
const caption = captionAt(meta.segments, t);
|
|
129
|
+
|
|
130
|
+
const cropW = W / z, cropH = H / z;
|
|
131
|
+
let cx = cur.x * sx, cy = cur.y * sy;
|
|
132
|
+
cx = Math.max(cropW / 2, Math.min(W - cropW / 2, cx));
|
|
133
|
+
cy = Math.max(cropH / 2, Math.min(H - cropH / 2, cy));
|
|
134
|
+
|
|
135
|
+
const left = Math.max(0, Math.min(W - Math.round(cropW), Math.round(cx - cropW / 2)));
|
|
136
|
+
const top = Math.max(0, Math.min(H - Math.round(cropH), Math.round(cy - cropH / 2)));
|
|
137
|
+
|
|
138
|
+
const key = `${srcFile}|${left}|${top}|${Math.round(cropW)}|${caption || ''}`;
|
|
139
|
+
if (key === lastKey && lastBuf) {
|
|
140
|
+
await writeFrame(lastBuf);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (srcCache.file !== srcFile) {
|
|
145
|
+
srcCache = { file: srcFile, img: await fs.promises.readFile(path.join(framesDir, srcFile)) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let img = sharp(srcCache.img)
|
|
149
|
+
.extract({ left, top, width: Math.round(cropW), height: Math.round(cropH) })
|
|
150
|
+
.resize(outW, outH, { fit: 'fill' });
|
|
151
|
+
|
|
152
|
+
if (caption) {
|
|
153
|
+
if (!overlayCache.has(caption)) overlayCache.set(caption, await captionOverlay(caption, outW));
|
|
154
|
+
const ov = overlayCache.get(caption);
|
|
155
|
+
img = sharp(await img.toBuffer()).composite([{
|
|
156
|
+
input: ov.input,
|
|
157
|
+
left: Math.round((outW - ov.width) / 2),
|
|
158
|
+
top: outH - ov.height - 46,
|
|
159
|
+
}]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const buf = await img.jpeg({ quality: 93 }).toBuffer();
|
|
163
|
+
|
|
164
|
+
lastKey = key;
|
|
165
|
+
lastBuf = buf;
|
|
166
|
+
await writeFrame(buf);
|
|
167
|
+
|
|
168
|
+
if (i % (fps * 2) === 0) onStatus(`rendering ${Math.round((i / total) * 100)}%`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
ffmpeg.stdin.end();
|
|
172
|
+
await done;
|
|
173
|
+
onStatus('render complete');
|
|
174
|
+
return outFile;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = { render };
|
package/review.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Review: turn a finished demo back into something an agent can look at.
|
|
2
|
+
// Extracts evenly spaced frames, the embedded recipe, and segment timings —
|
|
3
|
+
// the agent inspects the frames, patches the steps YAML, and re-records.
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { execFile } = require('child_process');
|
|
8
|
+
const ffmpegPath = require('ffmpeg-static');
|
|
9
|
+
|
|
10
|
+
// ffmpeg -i with no output exits non-zero by design; capture both streams and
|
|
11
|
+
// let callers pattern-match (duration lives on stderr, ffmetadata on stdout).
|
|
12
|
+
const ff = args => new Promise(res => {
|
|
13
|
+
execFile(ffmpegPath, args, { maxBuffer: 1e7 }, (_err, stdout, stderr) =>
|
|
14
|
+
res({ stdout: String(stdout || ''), stderr: String(stderr || '') }));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
async function videoInfo(videoPath) {
|
|
18
|
+
const info = await ff(['-i', videoPath]);
|
|
19
|
+
const m = /Duration:\s*(\d+):(\d+):(\d+)\.(\d+)/.exec(info.stderr);
|
|
20
|
+
const durMs = m ? (+m[1] * 3600 + +m[2] * 60 + +m[3]) * 1000 + +m[4] * 10 : 0;
|
|
21
|
+
const meta = await ff(['-i', videoPath, '-f', 'ffmetadata', '-']);
|
|
22
|
+
const r = /voila-recipe:(\{.*)/.exec(meta.stdout);
|
|
23
|
+
let recipe = null;
|
|
24
|
+
if (r) { try { recipe = JSON.parse(r[1].split('\n')[0].replace(/\\(.)/g, '$1')); } catch { /* unparseable */ } }
|
|
25
|
+
return { durMs, recipe };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function reviewDemo(videoPath, { count = 12, outDir = null } = {}) {
|
|
29
|
+
if (!fs.existsSync(videoPath)) throw new Error(`video not found: ${videoPath}`);
|
|
30
|
+
const { durMs, recipe } = await videoInfo(videoPath);
|
|
31
|
+
if (!durMs) throw new Error('could not read video duration');
|
|
32
|
+
|
|
33
|
+
outDir = outDir || path.join(path.dirname(videoPath), 'review');
|
|
34
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
35
|
+
|
|
36
|
+
const frames = [];
|
|
37
|
+
for (let i = 0; i < count; i++) {
|
|
38
|
+
const atSec = +(((i + 0.5) * durMs) / count / 1000).toFixed(1);
|
|
39
|
+
const file = path.join(outDir, `frame-${String(atSec).padStart(5, '0')}s.png`);
|
|
40
|
+
await new Promise((res, rej) => {
|
|
41
|
+
execFile(ffmpegPath, ['-y', '-ss', String(atSec), '-i', videoPath, '-vframes', '1', file],
|
|
42
|
+
err => (err ? rej(err) : res()));
|
|
43
|
+
});
|
|
44
|
+
frames.push({ atSec, file });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Segment timings from the sibling meta.json when available (richer than the recipe).
|
|
48
|
+
let segments = recipe ? recipe.segments || null : null;
|
|
49
|
+
const metaPath = path.join(path.dirname(videoPath), 'meta.json');
|
|
50
|
+
let warnings = [];
|
|
51
|
+
if (fs.existsSync(metaPath)) {
|
|
52
|
+
try {
|
|
53
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
|
54
|
+
warnings = meta.warnings || [];
|
|
55
|
+
segments = (meta.segments || []).map(s => ({
|
|
56
|
+
at: +((s.t - meta.tStart) / 1000).toFixed(2),
|
|
57
|
+
caption: s.caption, narration: s.narration,
|
|
58
|
+
durSec: s.dur ? +(s.dur / 1000).toFixed(2) : null,
|
|
59
|
+
}));
|
|
60
|
+
} catch { /* keep recipe segments */ }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { video: videoPath, durationSec: +(durMs / 1000).toFixed(1), frames, segments, recipe, warnings };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { reviewDemo };
|
package/server.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Voila server: one-click UI + API.
|
|
2
|
+
// POST /api/open {url} -> opens the recording browser so the user can sign in
|
|
3
|
+
// POST /api/record {url, mode} -> runs tour + render, job tracked in /api/status
|
|
4
|
+
// GET /api/status -> {state, detail, video?}
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const express = require('express');
|
|
9
|
+
const yaml = require('js-yaml');
|
|
10
|
+
const { VoilaSession } = require('./recorder');
|
|
11
|
+
const { produceDemo } = require('./pipeline');
|
|
12
|
+
|
|
13
|
+
const PORT = process.env.PORT || 4477;
|
|
14
|
+
const HEADLESS = process.env.VOILA_HEADLESS === '1';
|
|
15
|
+
const RECORDINGS = path.join(__dirname, 'recordings');
|
|
16
|
+
|
|
17
|
+
const app = express();
|
|
18
|
+
app.use(express.json());
|
|
19
|
+
app.use(express.static(path.join(__dirname, 'public')));
|
|
20
|
+
app.use('/videos', express.static(RECORDINGS));
|
|
21
|
+
|
|
22
|
+
let session = new VoilaSession({ headless: HEADLESS });
|
|
23
|
+
let job = { state: 'idle', detail: '', video: null };
|
|
24
|
+
|
|
25
|
+
async function sessionFor(device = 'desktop') {
|
|
26
|
+
if (session.device !== device) {
|
|
27
|
+
await session.close();
|
|
28
|
+
session = new VoilaSession({ headless: HEADLESS, device });
|
|
29
|
+
}
|
|
30
|
+
return session;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
app.post('/api/open', async (req, res) => {
|
|
34
|
+
try {
|
|
35
|
+
const { url, device } = req.body;
|
|
36
|
+
if (!url) return res.status(400).json({ error: 'url required' });
|
|
37
|
+
await (await sessionFor(device || 'desktop')).open(url);
|
|
38
|
+
res.json({ ok: true, message: 'Browser open — sign in there if the site needs it, then hit Record.' });
|
|
39
|
+
} catch (e) {
|
|
40
|
+
res.status(500).json({ error: String(e.message || e) });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
app.post('/api/record', async (req, res) => {
|
|
45
|
+
if (job.state === 'working') return res.status(409).json({ error: 'a recording is already in progress' });
|
|
46
|
+
const { url, mode = 'auto', stepsYaml = null } = req.body;
|
|
47
|
+
if (!url) return res.status(400).json({ error: 'url required' });
|
|
48
|
+
|
|
49
|
+
const id = `demo-${Date.now()}`;
|
|
50
|
+
const workDir = path.join(RECORDINGS, id);
|
|
51
|
+
job = { state: 'working', detail: 'starting', video: null };
|
|
52
|
+
res.json({ ok: true, id });
|
|
53
|
+
|
|
54
|
+
(async () => {
|
|
55
|
+
try {
|
|
56
|
+
const steps = stepsYaml ? yaml.load(stepsYaml) : null;
|
|
57
|
+
await produceDemo(await sessionFor(req.body.device || 'desktop'), {
|
|
58
|
+
url, mode, steps, workDir,
|
|
59
|
+
narrate: req.body.narrate !== false,
|
|
60
|
+
voice: req.body.voice || null,
|
|
61
|
+
onStatus: d => { job.detail = d; },
|
|
62
|
+
});
|
|
63
|
+
job = { state: 'done', detail: 'ready', video: `/videos/${id}/demo.mp4` };
|
|
64
|
+
} catch (e) {
|
|
65
|
+
job = { state: 'error', detail: String(e.message || e), video: null };
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
app.get('/api/status', (_req, res) => res.json(job));
|
|
71
|
+
|
|
72
|
+
app.listen(PORT, () => {
|
|
73
|
+
console.log(`voila running at http://localhost:${PORT} (headless=${HEADLESS})`);
|
|
74
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: voila
|
|
3
|
+
description: Record a narrated, auto-zoomed product demo video of any website — no screen-recording permission, fully on-device. Use when the user asks for a product demo video, a site walkthrough recording, a narrated tour of a web app, or to recreate/fork a demo from a voila MP4/recipe. Plans with a page outline, scripts steps YAML (captions + narration + title slides), records, then self-reviews frames and iterates.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# voila — agent-recorded product demos
|
|
7
|
+
|
|
8
|
+
voila renders a site in its own Chromium and records the viewport (CDP
|
|
9
|
+
screencast) — the user never grants screen-recording permission and nothing
|
|
10
|
+
leaves the machine. ffmpeg is bundled, the Kokoro TTS model and Chromium
|
|
11
|
+
download themselves on first use. The user installs nothing (Node >= 20).
|
|
12
|
+
|
|
13
|
+
Prefer the MCP tools if registered (`voila_outline`, `voila_record`,
|
|
14
|
+
`voila_review`); otherwise use the CLI via npx:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx -y voila-recorder outline <url>
|
|
18
|
+
npx -y voila-recorder record <url> --steps steps.yaml [--device mobile]
|
|
19
|
+
npx -y voila-recorder review demo.mp4
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Register the MCP server once with: `claude mcp add voila -- npx -y voila-recorder mcp`
|
|
23
|
+
Reference: https://voila.anzalabidi.dev/llms.txt · https://github.com/anzal1/voila
|
|
24
|
+
|
|
25
|
+
## Workflow — always this loop
|
|
26
|
+
|
|
27
|
+
1. **Outline first** → real nav text, headings, CTAs. Build selectors from it;
|
|
28
|
+
never guess.
|
|
29
|
+
2. **Write steps YAML**. Every meaningful beat gets a `caption` (burned-in
|
|
30
|
+
lower-third) and `narration` (spoken; the recording auto-paces to each
|
|
31
|
+
clip's length — do NOT pad waits for narration). Open and close with a
|
|
32
|
+
`slide` (animated title card: `title`, `subtitle`, `accent` hex).
|
|
33
|
+
Actions: goto, click, hover, type, scroll (y), scroll_to (selector),
|
|
34
|
+
slide, zoom (level), wait. Mark risky steps `optional: true`.
|
|
35
|
+
3. **Record** (`voila_record` / `record --steps`).
|
|
36
|
+
4. **Review your own output** (`voila_review`) → frames + timeline. Read the
|
|
37
|
+
frames. Check: cursor near what narration discusses; captions not covering
|
|
38
|
+
key UI; zooms centered on content, not whitespace; every page actually
|
|
39
|
+
loaded; no dead segments. Patch the YAML, re-record. One review pass
|
|
40
|
+
minimum before delivering.
|
|
41
|
+
5. Deliver the MP4 (the recipe travels inside it — extract from any voila MP4
|
|
42
|
+
with `ffmpeg -i demo.mp4 -f ffmetadata -`).
|
|
43
|
+
|
|
44
|
+
## Hard-won rules
|
|
45
|
+
|
|
46
|
+
- Selectors: prefer `a[href='/path']`, roles, and ids over text= (hydration
|
|
47
|
+
makes text selectors flaky). Append `>> visible=true` when duplicates exist
|
|
48
|
+
(desktop + mobile nav).
|
|
49
|
+
- On selector failure the error includes the live page outline — use it to
|
|
50
|
+
patch, don't retry blindly.
|
|
51
|
+
- Devices: desktop (1280×800), mobile (390×844, iPhone emulation, zoom
|
|
52
|
+
auto-disabled — mobile layouts crop badly), tablet (834×1112). Portrait
|
|
53
|
+
output on mobile.
|
|
54
|
+
- Zoom levels 1.3–1.6 on desktop; always return to 1 before ending.
|
|
55
|
+
- Login-protected apps: ask the user to run the record once with `--headful`,
|
|
56
|
+
or open the web UI (`npx -y voila-recorder serve`, port 4477) and sign in —
|
|
57
|
+
the session persists in the local profile. NEVER type credentials yourself.
|
|
58
|
+
- Narration style: short sentences, product language, no "as you can see".
|
|
59
|
+
8–15 words per beat reads best at Kokoro's pace.
|