reelson 0.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.
Files changed (105) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +15 -0
  4. package/README.md +180 -0
  5. package/bin/reelson.js +649 -0
  6. package/bin/reelson.ts +676 -0
  7. package/bin/run.js +5 -0
  8. package/demo.config.example.json +51 -0
  9. package/docs/demo.webp +0 -0
  10. package/docs/prompting.md +72 -0
  11. package/docs/style-guide.md +117 -0
  12. package/examples/demo.config.json +18 -0
  13. package/examples/todo-add-item/scenario.ts +45 -0
  14. package/examples/todo-add-item/video.json +35 -0
  15. package/music/README.md +7 -0
  16. package/package.json +68 -0
  17. package/skills/reelson-compose/SKILL.md +226 -0
  18. package/skills/reelson-compose/schemas/video.schema.json +172 -0
  19. package/skills/reelson-compose/scripts/build.js +424 -0
  20. package/skills/reelson-compose/scripts/build.ts +532 -0
  21. package/skills/reelson-compose/scripts/captions.js +24 -0
  22. package/skills/reelson-compose/scripts/captions.ts +40 -0
  23. package/skills/reelson-compose/scripts/check.js +80 -0
  24. package/skills/reelson-compose/scripts/check.ts +93 -0
  25. package/skills/reelson-compose/scripts/composition.js +163 -0
  26. package/skills/reelson-compose/scripts/composition.ts +241 -0
  27. package/skills/reelson-compose/scripts/hyperframes.js +138 -0
  28. package/skills/reelson-compose/scripts/hyperframes.ts +157 -0
  29. package/skills/reelson-compose/scripts/portrait.js +150 -0
  30. package/skills/reelson-compose/scripts/portrait.ts +218 -0
  31. package/skills/reelson-compose/scripts/project.js +238 -0
  32. package/skills/reelson-compose/scripts/project.ts +324 -0
  33. package/skills/reelson-compose/scripts/studio.js +330 -0
  34. package/skills/reelson-compose/scripts/studio.ts +394 -0
  35. package/skills/reelson-compose/scripts/timeline.js +271 -0
  36. package/skills/reelson-compose/scripts/timeline.ts +514 -0
  37. package/skills/reelson-compose/scripts/tts.js +303 -0
  38. package/skills/reelson-compose/scripts/tts.ts +371 -0
  39. package/skills/reelson-compose/scripts/verify.js +190 -0
  40. package/skills/reelson-compose/scripts/verify.ts +209 -0
  41. package/skills/reelson-compose/scripts/voice.js +178 -0
  42. package/skills/reelson-compose/scripts/voice.ts +224 -0
  43. package/skills/reelson-compose/scripts/zooms.js +259 -0
  44. package/skills/reelson-compose/scripts/zooms.ts +333 -0
  45. package/skills/reelson-compose/sections/README.md +5 -0
  46. package/skills/reelson-compose/sections/intro/minimal/section.css +27 -0
  47. package/skills/reelson-compose/sections/intro/minimal/section.html +12 -0
  48. package/skills/reelson-compose/sections/intro/minimal/section.js +16 -0
  49. package/skills/reelson-compose/sections/intro/minimal/section.json +9 -0
  50. package/skills/reelson-compose/sections/intro/poster/section.css +27 -0
  51. package/skills/reelson-compose/sections/intro/poster/section.html +14 -0
  52. package/skills/reelson-compose/sections/intro/poster/section.js +28 -0
  53. package/skills/reelson-compose/sections/intro/poster/section.json +9 -0
  54. package/skills/reelson-compose/sections/intro/split/section.css +30 -0
  55. package/skills/reelson-compose/sections/intro/split/section.html +12 -0
  56. package/skills/reelson-compose/sections/intro/split/section.js +19 -0
  57. package/skills/reelson-compose/sections/intro/split/section.json +9 -0
  58. package/skills/reelson-compose/sections/outro/compact/section.css +23 -0
  59. package/skills/reelson-compose/sections/outro/compact/section.html +10 -0
  60. package/skills/reelson-compose/sections/outro/compact/section.js +11 -0
  61. package/skills/reelson-compose/sections/outro/compact/section.json +8 -0
  62. package/skills/reelson-compose/sections/outro/endcard/section.css +25 -0
  63. package/skills/reelson-compose/sections/outro/endcard/section.html +13 -0
  64. package/skills/reelson-compose/sections/outro/endcard/section.js +13 -0
  65. package/skills/reelson-compose/sections/outro/endcard/section.json +8 -0
  66. package/skills/reelson-compose/sections/outro/wordmark/section.css +11 -0
  67. package/skills/reelson-compose/sections/outro/wordmark/section.html +5 -0
  68. package/skills/reelson-compose/sections/outro/wordmark/section.js +12 -0
  69. package/skills/reelson-compose/sections/outro/wordmark/section.json +8 -0
  70. package/skills/reelson-compose/sections/recap/compact/section.css +26 -0
  71. package/skills/reelson-compose/sections/recap/compact/section.html +8 -0
  72. package/skills/reelson-compose/sections/recap/compact/section.js +23 -0
  73. package/skills/reelson-compose/sections/recap/compact/section.json +11 -0
  74. package/skills/reelson-compose/sections/recap/steps/section.css +36 -0
  75. package/skills/reelson-compose/sections/recap/steps/section.html +8 -0
  76. package/skills/reelson-compose/sections/recap/steps/section.js +27 -0
  77. package/skills/reelson-compose/sections/recap/steps/section.json +11 -0
  78. package/skills/reelson-compose/studio/index.html +81 -0
  79. package/skills/reelson-compose/studio/studio.css +142 -0
  80. package/skills/reelson-compose/studio/studio.js +1031 -0
  81. package/skills/reelson-compose/templates/README.md +193 -0
  82. package/skills/reelson-compose/templates/classic/assets/vendor/Inter-OFL.txt +93 -0
  83. package/skills/reelson-compose/templates/classic/assets/vendor/gsap.min.js +11 -0
  84. package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-ext-wght-normal.woff2 +0 -0
  85. package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-wght-normal.woff2 +0 -0
  86. package/skills/reelson-compose/templates/classic/stage.html +447 -0
  87. package/skills/reelson-compose/templates/classic/template.json +5 -0
  88. package/skills/reelson-record/SKILL.md +220 -0
  89. package/skills/reelson-record/schemas/demo.config.schema.json +269 -0
  90. package/skills/reelson-record/scripts/capture.js +142 -0
  91. package/skills/reelson-record/scripts/capture.ts +190 -0
  92. package/skills/reelson-record/scripts/config.js +148 -0
  93. package/skills/reelson-record/scripts/config.ts +264 -0
  94. package/skills/reelson-record/scripts/cursor-overlay.js +111 -0
  95. package/skills/reelson-record/scripts/cursor-overlay.ts +122 -0
  96. package/skills/reelson-record/scripts/doctor.js +128 -0
  97. package/skills/reelson-record/scripts/doctor.ts +147 -0
  98. package/skills/reelson-record/scripts/languages.js +29 -0
  99. package/skills/reelson-record/scripts/languages.ts +41 -0
  100. package/skills/reelson-record/scripts/record.js +365 -0
  101. package/skills/reelson-record/scripts/record.ts +451 -0
  102. package/skills/reelson-record/scripts/scenario.js +356 -0
  103. package/skills/reelson-record/scripts/scenario.ts +570 -0
  104. package/skills/reelson-record/scripts/validate.js +143 -0
  105. package/skills/reelson-record/scripts/validate.ts +179 -0
@@ -0,0 +1,570 @@
1
+ /**
2
+ * The API a demo scenario is written against. A scenario file lives next to
3
+ * its output (<videosDir>/<slug>/scenario.ts) and default-exports a Scenario.
4
+ *
5
+ * `Demo` wraps a Playwright Page with human-paced actions: the cursor glides
6
+ * to the target before every click, typing has a per-key delay, and
7
+ * `marker()` stamps the current video time so the composition can time its
8
+ * callouts. Anything not covered here is available on `demo.page`.
9
+ */
10
+ import type { Locator, Page } from '@playwright/test'
11
+
12
+ export interface Scenario {
13
+ /** Slug used in logs and markers.json. */
14
+ name: string
15
+ /** Origin the scenario runs against, e.g. https://app.test (a local app with seeded data). */
16
+ baseURL: string
17
+ /** Defaults to demo.config.json `record.viewport` (1440x900). Keep 16:10 or 16:9. */
18
+ viewport?: { width: number; height: number }
19
+ /**
20
+ * Capture pixel ratio (default 2): the page is laid out at `viewport` CSS
21
+ * pixels but filmed at 2x, so UI text stays sharp in the 1080p frame and
22
+ * under zooms (and a 4K render has real detail). Defaults to the config's.
23
+ */
24
+ deviceScaleFactor?: number
25
+ /** Silence before the first action / after the last one (ms). */
26
+ leadInMs?: number
27
+ leadOutMs?: number
28
+ run: (demo: Demo) => Promise<void>
29
+ }
30
+
31
+ export interface Marker {
32
+ label: string
33
+ /** Seconds from the start of the recording. */
34
+ at: number
35
+ }
36
+
37
+ /**
38
+ * Where and when the cursor clicked (or clicked into a field to type), in
39
+ * viewport pixels. reelson-compose's check-zooms.ts uses these to keep zooms still
40
+ * and on target while the viewer watches a click.
41
+ */
42
+ export interface Click {
43
+ at: number
44
+ x: number
45
+ y: number
46
+ kind: 'click' | 'type'
47
+ /** When the cursor started gliding towards this target (zooms ride along with it). */
48
+ move: number
49
+ /** The typing ends here (kind 'type' only). */
50
+ until?: number
51
+ }
52
+
53
+ /**
54
+ * What the demo was working on: the element it moved to (`box`) and the compact block
55
+ * around it (`area`: e.g. a form field with its label), viewport CSS px, from `at` (the
56
+ * glide towards it). A portrait video frames `area` so the element is never cut.
57
+ */
58
+ export interface Focus {
59
+ at: number
60
+ box: { x: number; y: number; width: number; height: number }
61
+ area: { x: number; y: number; width: number; height: number }
62
+ }
63
+
64
+ /** A range of the raw capture removed from recording.mp4 (see Demo.cut). */
65
+ export interface Cut {
66
+ from: number
67
+ to: number
68
+ }
69
+
70
+ /**
71
+ * A hand-off between two actors (manager → employee). The stretch in between
72
+ * (logout, second login) is cut from recording.mp4; the reelson-compose scaffold
73
+ * splits the footage at `at` and shows a transition card there.
74
+ */
75
+ export interface Transition {
76
+ /** Seconds in the raw capture (record.ts rewrites it to cut-video time). */
77
+ at: number
78
+ /** Big line on the card, e.g. "The employee receives the document". */
79
+ title: string
80
+ /** Optional smaller line under it. */
81
+ subtitle?: string
82
+ /** Role chips on the card: from → to. */
83
+ from?: string
84
+ to?: string
85
+ }
86
+
87
+ /** A believable customer for forms. Email is unique per call (numeric suffix). */
88
+ export interface Persona {
89
+ firstName: string
90
+ lastName: string
91
+ fullName: string
92
+ email: string
93
+ phone: string
94
+ company: string
95
+ }
96
+
97
+ type PersonaSeed = { firstName: string; lastName: string; company: string }
98
+
99
+ /**
100
+ * Name pools per UI language (demo.config.json `language`). Add a language by
101
+ * adding a key; unknown languages fall back to English.
102
+ */
103
+ const PERSONAS: Record<string, { phonePrefix: string; people: PersonaSeed[] }> = {
104
+ en: {
105
+ phonePrefix: '+1 555 ',
106
+ people: [
107
+ { firstName: 'Emma', lastName: 'Carter', company: 'Carter Consulting' },
108
+ { firstName: 'Liam', lastName: 'Bennett', company: 'Bennett Design Co.' },
109
+ { firstName: 'Olivia', lastName: 'Hayes', company: 'Hayes Logistics' },
110
+ { firstName: 'Noah', lastName: 'Foster', company: 'Foster Construction' },
111
+ { firstName: 'Ava', lastName: 'Mitchell', company: 'Mitchell Health' },
112
+ { firstName: 'Lucas', lastName: 'Reed', company: 'Reed Tech' },
113
+ { firstName: 'Mia', lastName: 'Collins', company: 'Collins Events' },
114
+ { firstName: 'Ethan', lastName: 'Brooks', company: 'Brooks Farms' },
115
+ ],
116
+ },
117
+ ro: {
118
+ phonePrefix: '07',
119
+ people: [
120
+ { firstName: 'Andrei', lastName: 'Popescu', company: 'Popescu Consulting SRL' },
121
+ { firstName: 'Ioana', lastName: 'Ionescu', company: 'Ionescu Design SRL' },
122
+ { firstName: 'Mihai', lastName: 'Dumitrescu', company: 'Dumitrescu Construct SRL' },
123
+ { firstName: 'Elena', lastName: 'Stan', company: 'Stan Logistic SRL' },
124
+ { firstName: 'Alexandru', lastName: 'Radu', company: 'Radu Tech SRL' },
125
+ { firstName: 'Maria', lastName: 'Constantin', company: 'Constantin Medical SRL' },
126
+ { firstName: 'Cristian', lastName: 'Moldovan', company: 'Moldovan Agro SRL' },
127
+ { firstName: 'Ana', lastName: 'Georgescu', company: 'Georgescu Events SRL' },
128
+ ],
129
+ },
130
+ }
131
+
132
+ export function persona(
133
+ seed: number = Date.now(),
134
+ domain = 'example.com',
135
+ language = 'en',
136
+ ): Persona {
137
+ const pool = PERSONAS[language.split('-')[0]] ?? PERSONAS.en
138
+ const base = pool.people[seed % pool.people.length]
139
+ const strip = (v: string): string =>
140
+ v
141
+ .normalize('NFD')
142
+ .replace(/[\u0300-\u036f]/g, '')
143
+ .toLowerCase()
144
+ const suffix = (seed % 89) + 10
145
+ const digits = String(seed).padStart(8, '0').slice(-8)
146
+
147
+ return {
148
+ ...base,
149
+ fullName: `${base.firstName} ${base.lastName}`,
150
+ email: `${strip(base.firstName)}.${strip(base.lastName)}${suffix}@${domain}`,
151
+ phone:
152
+ pool.phonePrefix === '07'
153
+ ? `07${digits}`
154
+ : `${pool.phonePrefix}${digits.slice(0, 3)} ${digits.slice(3, 7)}`,
155
+ company: base.company,
156
+ }
157
+ }
158
+
159
+ export interface Demo {
160
+ page: Page
161
+ markers: Marker[]
162
+ clicks: Click[]
163
+ /** Every element moveTo/click/type went to, with its surroundings (see Focus). */
164
+ focus: Focus[]
165
+ /**
166
+ * Realistic customer data for forms (see Persona), in the project's UI
167
+ * language. Domain defaults to demo.config.json `record.personaDomain`.
168
+ */
169
+ persona: (seed?: number, domain?: string) => Persona
170
+ cuts: Cut[]
171
+ /**
172
+ * Everything that happens inside `fn` is removed from recording.mp4 (a
173
+ * hard cut), except the first `keepMs` so the viewer glimpses the waiting
174
+ * state. Use it around slow waits: 3-D Secure, long spinners, emails.
175
+ */
176
+ cut: <T>(fn: () => Promise<T>, opts?: { keepMs?: number }) => Promise<T>
177
+ transitions: Transition[]
178
+ /**
179
+ * Switch actors off camera: `fn` (log out, log in as someone else, open the
180
+ * first page) is cut from the video entirely, and the composition shows a
181
+ * transition card at that point. End `fn` on a settled page.
182
+ */
183
+ transition: <T>(
184
+ fn: () => Promise<T>,
185
+ card: Omit<Transition, 'at'>,
186
+ ) => Promise<T>
187
+ elapsedSeconds: () => number
188
+ /** Date.now() at the start of the recording clock (elapsedSeconds() = 0). */
189
+ startedAt: number
190
+ /** Record the current time under a label (use for "callout here", "zoom here"). */
191
+ marker: (label: string) => void
192
+ pause: (ms: number) => Promise<void>
193
+ /**
194
+ * Navigate and wait for the page to settle. The cursor is parked at a
195
+ * resting point near the middle first (see rest), so a page that opens the
196
+ * video never starts with the cursor in a corner.
197
+ */
198
+ goto: (path: string) => Promise<void>
199
+ /** Glide the cursor to a random resting point in the middle third of the viewport. */
200
+ rest: () => Promise<void>
201
+ /**
202
+ * Glide the cursor to the element on a curved, eased path. An element out of sight is
203
+ * scrolled into view first, smoothly (its container glides), so the viewer can follow.
204
+ */
205
+ moveTo: (target: Locator) => Promise<void>
206
+ /** moveTo + click + short settle. */
207
+ click: (target: Locator, opts?: { settleMs?: number }) => Promise<void>
208
+ /** moveTo + click + human-paced typing (uneven delays, beats after spaces). */
209
+ type: (target: Locator, text: string) => Promise<void>
210
+ /** Slow wheel scroll so the viewer can follow. */
211
+ scroll: (deltaY: number, opts?: { stepPx?: number }) => Promise<void>
212
+ /**
213
+ * Runs `action` (e.g. a click on a target="_blank" link), waits for the tab or pop-up it
214
+ * opens, and continues there: `demo.page` and every demo action now use it, and the video
215
+ * films it. When it closes, the demo (and the video) return to the page that opened it.
216
+ */
217
+ popup: (action: () => Promise<unknown>) => Promise<Page>
218
+ /** Continue on another page of the context (see popup); the video follows. */
219
+ switchTo: (next: Page) => Promise<void>
220
+ /**
221
+ * True when recording the phone version (`reelson record --mobile`): branch where the
222
+ * mobile UI differs, e.g. open the menu behind the hamburger button first.
223
+ */
224
+ mobile: boolean
225
+ /**
226
+ * True when recording the square version (`reelson record --square`, a square browser):
227
+ * branch where the narrower window changes the UI.
228
+ */
229
+ square: boolean
230
+ }
231
+
232
+ /** What the recorder tells the demo, and hears from it. */
233
+ export interface DemoHooks {
234
+ /** The page the demo acts on (and the video shows) changed. */
235
+ onSwitch?: (page: Page) => void
236
+ /** Recording on a phone (`reelson record --mobile`). */
237
+ mobile?: boolean
238
+ /** Recording in a square browser (`reelson record --square`). */
239
+ square?: boolean
240
+ }
241
+
242
+ export function createDemo(
243
+ first: Page,
244
+ seed: number = 1,
245
+ personaDefaults: { domain?: string; language?: string } = {},
246
+ hooks: DemoHooks = {},
247
+ ): Demo {
248
+ // The page every action uses; demo.popup / demo.switchTo move it, a close moves it back.
249
+ let page = first
250
+ const openers: Page[] = []
251
+ const startedAt = Date.now()
252
+ const markers: Marker[] = []
253
+ const clicks: Click[] = []
254
+ const focus: Focus[] = []
255
+ const cuts: Cut[] = []
256
+ const transitions: Transition[] = []
257
+ const elapsedSeconds = (): number => (Date.now() - startedAt) / 1000
258
+ const stamp = (): number => Number(elapsedSeconds().toFixed(2))
259
+
260
+ // Deterministic per scenario, so a re-record moves and types the same way.
261
+ const rng = mulberry32(seed)
262
+ const viewport = page.viewportSize() ?? { width: 1440, height: 900 }
263
+ // A believable place for a hand to rest: somewhere in the middle third, never a corner.
264
+ const restingPoint = (): { x: number; y: number } => ({
265
+ x: Math.round(viewport.width * (0.38 + rng() * 0.24)),
266
+ y: Math.round(viewport.height * (0.38 + rng() * 0.24)),
267
+ })
268
+ let pos = restingPoint()
269
+
270
+ /**
271
+ * Human-like glide: a gently curved path (quadratic bezier with a random
272
+ * perpendicular bow), ease-in-out speed, a little tremor that dies out as
273
+ * the cursor settles, and a landing point slightly off the exact centre.
274
+ */
275
+ const glide = async (to: { x: number; y: number }): Promise<void> => {
276
+ const from = pos
277
+ const dx = to.x - from.x
278
+ const dy = to.y - from.y
279
+ const dist = Math.hypot(dx, dy)
280
+ if (dist < 2) {
281
+ return
282
+ }
283
+ const bow = (0.1 + rng() * 0.18) * (rng() < 0.5 ? -1 : 1)
284
+ const cx = from.x + dx / 2 - dy * bow
285
+ const cy = from.y + dy / 2 + dx * bow
286
+ const steps = Math.round(Math.min(42, Math.max(14, dist / 16)))
287
+ const durationMs = Math.min(720, Math.max(260, 200 + dist * 0.55))
288
+ for (let i = 1; i <= steps; i++) {
289
+ const u = i / steps
290
+ const e = u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2
291
+ const tremor = (1 - e) * 1.2
292
+ const x =
293
+ (1 - e) * (1 - e) * from.x +
294
+ 2 * (1 - e) * e * cx +
295
+ e * e * to.x +
296
+ (rng() - 0.5) * tremor
297
+ const y =
298
+ (1 - e) * (1 - e) * from.y +
299
+ 2 * (1 - e) * e * cy +
300
+ e * e * to.y +
301
+ (rng() - 0.5) * tremor
302
+ await page.mouse.move(x, y)
303
+ await page.waitForTimeout(durationMs / steps)
304
+ }
305
+ pos = to
306
+ }
307
+
308
+ const rest: Demo['rest'] = async () => {
309
+ await glide(restingPoint())
310
+ await page.mouse.move(pos.x, pos.y)
311
+ }
312
+
313
+ const moveTo: Demo['moveTo'] = async (target) => {
314
+ if (await revealSmoothly(target.first())) {
315
+ // Let the viewer see where the scroll landed before the cursor sets off.
316
+ await page.waitForTimeout(250)
317
+ }
318
+ await target.first().scrollIntoViewIfNeeded()
319
+ const box = await target.first().boundingBox()
320
+ if (!box) {
321
+ throw new Error(`moveTo: target has no bounding box: ${target}`)
322
+ }
323
+ focus.push({ at: stamp(), box: rounded(box), area: rounded(await areaAround(target.first(), box)) })
324
+ // Land near, not exactly on, the centre — people don't hit the middle.
325
+ const jx = (rng() - 0.5) * Math.min(24, box.width * 0.3)
326
+ const jy = (rng() - 0.5) * Math.min(10, box.height * 0.3)
327
+ await glide({
328
+ x: box.x + box.width / 2 + jx,
329
+ y: box.y + box.height / 2 + jy,
330
+ })
331
+ await page.waitForTimeout(120 + rng() * 120)
332
+ }
333
+
334
+ /**
335
+ * An element out of sight is scrolled into view the way a person would (its scrolling
336
+ * container glides, eased, over 0.5–1.4 s by distance), not in one jump — so the viewer
337
+ * can follow a menu or a list moving. Returns whether it scrolled.
338
+ */
339
+ const revealSmoothly = async (el: Locator): Promise<boolean> =>
340
+ el.evaluate(async (node) => {
341
+ const r = node.getBoundingClientRect()
342
+ const x = Math.min(Math.max(r.left + r.width / 2, 0), innerWidth - 1)
343
+ const y = Math.min(Math.max(r.top + r.height / 2, 0), innerHeight - 1)
344
+ const hit = document.elementFromPoint(x, y)
345
+ const inView = r.top >= 0 && r.bottom <= innerHeight && !!hit && (node.contains(hit) || hit.contains(node))
346
+ if (inView) {
347
+ return false
348
+ }
349
+ // The nearest ancestor that scrolls vertically, else the page.
350
+ let box: Element | null = node.parentElement
351
+ while (box && !(/(auto|scroll)/.test(getComputedStyle(box).overflowY) && box.scrollHeight > box.clientHeight)) {
352
+ box = box.parentElement
353
+ }
354
+ const scroller = (box ?? document.scrollingElement ?? document.documentElement) as HTMLElement
355
+ const isPage = !box
356
+ const view = isPage ? { top: 0, height: innerHeight } : { top: scroller.getBoundingClientRect().top, height: scroller.clientHeight }
357
+ const start = scroller.scrollTop
358
+ const max = scroller.scrollHeight - scroller.clientHeight
359
+ const target = Math.min(Math.max(start + (r.top + r.height / 2) - (view.top + view.height / 2), 0), max)
360
+ const distance = target - start
361
+ if (Math.abs(distance) < 2) {
362
+ return false
363
+ }
364
+ const duration = Math.min(1400, Math.max(500, 400 + Math.abs(distance) * 0.8))
365
+ const began = performance.now()
366
+ await new Promise<void>((done) => {
367
+ const step = (now: number) => {
368
+ const t = Math.min(1, (now - began) / duration)
369
+ const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2
370
+ scroller.scrollTop = start + distance * eased
371
+ if (t < 1) requestAnimationFrame(step)
372
+ else done()
373
+ }
374
+ requestAnimationFrame(step)
375
+ })
376
+ return true
377
+ })
378
+
379
+ /**
380
+ * Clicks where the cursor landed, not at the element's exact centre (Playwright's
381
+ * default would snap the cursor there). If the element moved since the glide, the
382
+ * point is kept inside it.
383
+ */
384
+ const clickWhereLanded = async (target: Locator): Promise<void> => {
385
+ const el = target.first()
386
+ const box = await el.boundingBox()
387
+ if (!box) {
388
+ throw new Error(`click: target has no bounding box: ${target}`)
389
+ }
390
+ const inside = (v: number, size: number): number => Math.min(Math.max(v, Math.min(2, size / 2)), size - Math.min(2, size / 2))
391
+ const position = { x: inside(pos.x - box.x, box.width), y: inside(pos.y - box.y, box.height) }
392
+ pos = { x: box.x + position.x, y: box.y + position.y }
393
+ await el.click({ position })
394
+ }
395
+
396
+ /**
397
+ * Human-like typing: uneven per-key delay, a beat after spaces and
398
+ * punctuation, the odd longer hesitation — but never slow overall.
399
+ */
400
+ const typeNaturally = async (text: string): Promise<void> => {
401
+ for (const ch of text) {
402
+ await page.keyboard.type(ch)
403
+ let delay = 38 + rng() * 60
404
+ if (ch === ' ') {
405
+ delay += 50 + rng() * 80
406
+ } else if ('.,@-_'.includes(ch)) {
407
+ delay += 60 + rng() * 60
408
+ }
409
+ if (rng() < 0.05) {
410
+ delay += 120 + rng() * 160
411
+ }
412
+ await page.waitForTimeout(delay)
413
+ }
414
+ }
415
+
416
+ const switchTo: Demo['switchTo'] = async (next) => {
417
+ if (next === page) {
418
+ return
419
+ }
420
+ openers.push(page)
421
+ page = next
422
+ await next.waitForLoadState('domcontentloaded').catch(() => {})
423
+ await next.bringToFront().catch(() => {})
424
+ // The new page's mouse starts where the cursor is, so the video shows no jump.
425
+ await next.mouse.move(pos.x, pos.y)
426
+ hooks.onSwitch?.(next)
427
+ next.once('close', () => {
428
+ if (page === next) {
429
+ page = openers.pop() ?? first
430
+ hooks.onSwitch?.(page)
431
+ }
432
+ })
433
+ }
434
+
435
+ return {
436
+ get page() {
437
+ return page
438
+ },
439
+ markers,
440
+ clicks,
441
+ focus,
442
+ persona: (personaSeed, domain) =>
443
+ persona(
444
+ personaSeed,
445
+ domain ?? personaDefaults.domain,
446
+ personaDefaults.language,
447
+ ),
448
+ cuts,
449
+ cut: async (fn, opts = {}) => {
450
+ const from = elapsedSeconds() + (opts.keepMs ?? 600) / 1000
451
+ const result = await fn()
452
+ const to = elapsedSeconds()
453
+ if (to > from) {
454
+ cuts.push({
455
+ from: Number(from.toFixed(2)),
456
+ to: Number(to.toFixed(2)),
457
+ })
458
+ }
459
+
460
+ return result
461
+ },
462
+ transitions,
463
+ transition: async (fn, card) => {
464
+ const from = elapsedSeconds()
465
+ const result = await fn()
466
+ // Re-assert the cursor on the new page so the first kept frame has it.
467
+ await page.mouse.move(pos.x, pos.y)
468
+ await page.waitForTimeout(300)
469
+ const to = elapsedSeconds()
470
+ cuts.push({
471
+ from: Number(from.toFixed(2)),
472
+ to: Number(to.toFixed(2)),
473
+ })
474
+ transitions.push({ ...card, at: Number(from.toFixed(2)) })
475
+
476
+ return result
477
+ },
478
+ elapsedSeconds,
479
+ startedAt,
480
+ marker: (label) => {
481
+ markers.push({ label, at: Number(elapsedSeconds().toFixed(2)) })
482
+ },
483
+ pause: (ms) => page.waitForTimeout(ms),
484
+ goto: async (path) => {
485
+ // Park the cursor mid-screen BEFORE leaving: the overlay carries the position
486
+ // into the next page, and the trimmed video usually starts right after this.
487
+ await rest()
488
+ await page.goto(path, { waitUntil: 'networkidle' })
489
+ // Re-assert on the new document (a new origin has no saved overlay position).
490
+ await page.mouse.move(pos.x, pos.y)
491
+ await page.waitForTimeout(600)
492
+ },
493
+ rest,
494
+ moveTo,
495
+ click: async (target, opts = {}) => {
496
+ const move = stamp()
497
+ await moveTo(target)
498
+ const at = stamp()
499
+ await clickWhereLanded(target)
500
+ clicks.push({ move, at, x: Math.round(pos.x), y: Math.round(pos.y), kind: 'click' })
501
+ await page.waitForTimeout(opts.settleMs ?? 700)
502
+ },
503
+ type: async (target, text) => {
504
+ const move = stamp()
505
+ await moveTo(target)
506
+ const at = stamp()
507
+ await clickWhereLanded(target)
508
+ const click: Click = { move, at, x: Math.round(pos.x), y: Math.round(pos.y), kind: 'type' }
509
+ clicks.push(click)
510
+ await typeNaturally(text)
511
+ click.until = stamp()
512
+ await page.waitForTimeout(300 + rng() * 200)
513
+ },
514
+ switchTo,
515
+ mobile: hooks.mobile ?? false,
516
+ square: hooks.square ?? false,
517
+ popup: async (action) => {
518
+ const [opened] = await Promise.all([page.context().waitForEvent('page'), action()])
519
+ await switchTo(opened)
520
+ return opened
521
+ },
522
+ scroll: async (deltaY, opts = {}) => {
523
+ const step = opts.stepPx ?? 80
524
+ const steps = Math.max(1, Math.round(Math.abs(deltaY) / step))
525
+ const sign = Math.sign(deltaY)
526
+ for (let i = 0; i < steps; i++) {
527
+ await page.mouse.wheel(0, sign * step)
528
+ await page.waitForTimeout(40)
529
+ }
530
+ await page.waitForTimeout(300)
531
+ },
532
+ }
533
+ }
534
+
535
+ /** Small seeded PRNG so recordings are reproducible. */
536
+ function mulberry32(seed: number): () => number {
537
+ let a = seed >>> 0
538
+ return () => {
539
+ a = (a + 0x6d2b79f5) >>> 0
540
+ let t = a
541
+ t = Math.imul(t ^ (t >>> 15), t | 1)
542
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
543
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
544
+ }
545
+ }
546
+
547
+ type Box = { x: number; y: number; width: number; height: number }
548
+
549
+ const rounded = (b: Box): Box => ({ x: Math.round(b.x), y: Math.round(b.y), width: Math.round(b.width), height: Math.round(b.height) })
550
+
551
+ /**
552
+ * The compact block around an element: the largest ancestor that is still about one row
553
+ * (at most 3x the element's height, or 180 px) and narrower than the viewport — e.g. a form
554
+ * field with its label and hint, a table row, a toolbar. Falls back to the element itself.
555
+ */
556
+ async function areaAround(element: Locator, box: Box): Promise<Box> {
557
+ return element
558
+ .evaluate((node, own) => {
559
+ const maxHeight = Math.max(own.height * 3, 180)
560
+ const maxWidth = window.innerWidth * 0.9
561
+ let best = own
562
+ for (let el = node.parentElement; el && el !== document.body; el = el.parentElement) {
563
+ const r = el.getBoundingClientRect()
564
+ if (r.height > maxHeight || r.width > maxWidth) break
565
+ if (r.width >= best.width && r.height >= best.height) best = { x: r.x, y: r.y, width: r.width, height: r.height }
566
+ }
567
+ return best
568
+ }, box)
569
+ .catch(() => box)
570
+ }