playwright-director 0.2.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 (122) hide show
  1. package/README.md +860 -0
  2. package/dist/Tutorial.d.ts +108 -0
  3. package/dist/Tutorial.d.ts.map +1 -0
  4. package/dist/Tutorial.js +767 -0
  5. package/dist/Tutorial.js.map +1 -0
  6. package/dist/bin/export-transcript.d.ts +3 -0
  7. package/dist/bin/export-transcript.d.ts.map +1 -0
  8. package/dist/bin/export-transcript.js +111 -0
  9. package/dist/bin/export-transcript.js.map +1 -0
  10. package/dist/cursor.d.ts +24 -0
  11. package/dist/cursor.d.ts.map +1 -0
  12. package/dist/cursor.js +96 -0
  13. package/dist/cursor.js.map +1 -0
  14. package/dist/index.d.ts +16 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +12 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/init.d.ts +3 -0
  19. package/dist/init.d.ts.map +1 -0
  20. package/dist/init.js +183 -0
  21. package/dist/init.js.map +1 -0
  22. package/dist/merge.d.ts +26 -0
  23. package/dist/merge.d.ts.map +1 -0
  24. package/dist/merge.js +65 -0
  25. package/dist/merge.js.map +1 -0
  26. package/dist/music.d.ts +29 -0
  27. package/dist/music.d.ts.map +1 -0
  28. package/dist/music.js +107 -0
  29. package/dist/music.js.map +1 -0
  30. package/dist/overlay-html.d.ts +44 -0
  31. package/dist/overlay-html.d.ts.map +1 -0
  32. package/dist/overlay-html.js +150 -0
  33. package/dist/overlay-html.js.map +1 -0
  34. package/dist/overlay.d.ts +39 -0
  35. package/dist/overlay.d.ts.map +1 -0
  36. package/dist/overlay.js +134 -0
  37. package/dist/overlay.js.map +1 -0
  38. package/dist/postinstall.d.ts +3 -0
  39. package/dist/postinstall.d.ts.map +1 -0
  40. package/dist/postinstall.js +38 -0
  41. package/dist/postinstall.js.map +1 -0
  42. package/dist/reporter.d.ts +22 -0
  43. package/dist/reporter.d.ts.map +1 -0
  44. package/dist/reporter.js +166 -0
  45. package/dist/reporter.js.map +1 -0
  46. package/dist/site/build-site.d.ts +2 -0
  47. package/dist/site/build-site.d.ts.map +1 -0
  48. package/dist/site/build-site.js +82 -0
  49. package/dist/site/build-site.js.map +1 -0
  50. package/dist/site/embed.d.ts +48 -0
  51. package/dist/site/embed.d.ts.map +1 -0
  52. package/dist/site/embed.js +45 -0
  53. package/dist/site/embed.js.map +1 -0
  54. package/dist/site/generate-manifest.d.ts +15 -0
  55. package/dist/site/generate-manifest.d.ts.map +1 -0
  56. package/dist/site/generate-manifest.js +106 -0
  57. package/dist/site/generate-manifest.js.map +1 -0
  58. package/dist/site/scaffold.d.ts +3 -0
  59. package/dist/site/scaffold.d.ts.map +1 -0
  60. package/dist/site/scaffold.js +70 -0
  61. package/dist/site/scaffold.js.map +1 -0
  62. package/dist/site/scan-tutorials.d.ts +12 -0
  63. package/dist/site/scan-tutorials.d.ts.map +1 -0
  64. package/dist/site/scan-tutorials.js +54 -0
  65. package/dist/site/scan-tutorials.js.map +1 -0
  66. package/dist/site/types.d.ts +75 -0
  67. package/dist/site/types.d.ts.map +1 -0
  68. package/dist/site/types.js +2 -0
  69. package/dist/site/types.js.map +1 -0
  70. package/dist/skill-stamp.d.ts +21 -0
  71. package/dist/skill-stamp.d.ts.map +1 -0
  72. package/dist/skill-stamp.js +39 -0
  73. package/dist/skill-stamp.js.map +1 -0
  74. package/dist/slugify.d.ts +19 -0
  75. package/dist/slugify.d.ts.map +1 -0
  76. package/dist/slugify.js +29 -0
  77. package/dist/slugify.js.map +1 -0
  78. package/dist/stage-presets.d.ts +51 -0
  79. package/dist/stage-presets.d.ts.map +1 -0
  80. package/dist/stage-presets.js +45 -0
  81. package/dist/stage-presets.js.map +1 -0
  82. package/dist/styles.css +637 -0
  83. package/dist/timeline.d.ts +87 -0
  84. package/dist/timeline.d.ts.map +1 -0
  85. package/dist/timeline.js +108 -0
  86. package/dist/timeline.js.map +1 -0
  87. package/dist/transcript.d.ts +87 -0
  88. package/dist/transcript.d.ts.map +1 -0
  89. package/dist/transcript.js +291 -0
  90. package/dist/transcript.js.map +1 -0
  91. package/dist/tts-provider.d.ts +69 -0
  92. package/dist/tts-provider.d.ts.map +1 -0
  93. package/dist/tts-provider.js +182 -0
  94. package/dist/tts-provider.js.map +1 -0
  95. package/dist/types.d.ts +109 -0
  96. package/dist/types.d.ts.map +1 -0
  97. package/dist/types.js +2 -0
  98. package/dist/types.js.map +1 -0
  99. package/dist/voice-prerender.d.ts +41 -0
  100. package/dist/voice-prerender.d.ts.map +1 -0
  101. package/dist/voice-prerender.js +122 -0
  102. package/dist/voice-prerender.js.map +1 -0
  103. package/dist/voice.d.ts +76 -0
  104. package/dist/voice.d.ts.map +1 -0
  105. package/dist/voice.js +244 -0
  106. package/dist/voice.js.map +1 -0
  107. package/package.json +87 -0
  108. package/skills/tutorialize/SKILL.md +54 -0
  109. package/skills/tutorialize/agent.md +58 -0
  110. package/skills/tutorialize/references/api.md +489 -0
  111. package/skills/tutorialize/references/storytelling.md +193 -0
  112. package/src/styles.css +637 -0
  113. package/templates/site/public/.gitkeep +0 -0
  114. package/templates/site/public/widget.js +273 -0
  115. package/templates/site/src/components/TutorialsHome.astro +141 -0
  116. package/templates/site/src/components/VideoCard.astro +192 -0
  117. package/templates/site/src/components/VideoModal.astro +170 -0
  118. package/templates/site/src/data/.gitkeep +0 -0
  119. package/templates/site/src/layouts/Base.astro +56 -0
  120. package/templates/site/src/pages/[slug].astro +745 -0
  121. package/templates/site/src/pages/index.astro +5 -0
  122. package/templates/site/src/styles/tutorials.css +303 -0
package/README.md ADDED
@@ -0,0 +1,860 @@
1
+ # playwright-director
2
+
3
+ **Turn your Playwright end-to-end tests into directed video guides — automatically.**
4
+
5
+ Write your tests once, get polished how-to videos with voice narration, animated cursor, step overlays, background music, and ffmpeg post-processing. Publish them as a static gallery site, and embed any tutorial back into your app as contextual help with a single `data-tutorial` attribute. No screen-recording software, no video editors, no docs that lag behind the product.
6
+
7
+ An AI agent can also write or adapt the test for a specific communication goal — a product demo, a feature explanation, an onboarding walkthrough — and the direction is yours to apply: what to emphasize, how fast to move, where to pause. Every demonstration stays reproducible, because it originates from an automated test.
8
+
9
+ 🌐 **[Project site](https://youniwemi.github.io/playwright-director/)** · 🎬 **[Live demo gallery](https://youniwemi.github.io/playwright-director/gallery/)** — every gallery video is recorded by this repo's own CI from its e2e tests on each push to `main`.
10
+
11
+ ---
12
+
13
+ ## Table of Contents
14
+
15
+ - [Why playwright-director?](#why-playwright-director)
16
+ - [The name](#the-name)
17
+ - [Installation](#installation)
18
+ - [Quick Start](#quick-start)
19
+ - [API Reference](#api-reference)
20
+ - [Multiple user profiles](#multiple-user-profiles)
21
+ - [Variants — record the same tutorial for mobile](#variants--record-the-same-tutorial-for-mobile)
22
+ - [TTS Configuration](#tts-configuration)
23
+ - [Environment Variables](#environment-variables)
24
+ - [Styling](#styling)
25
+ - [Output Files](#output-files)
26
+ - [Reviewing & Correcting Narration](#reviewing--correcting-narration)
27
+ - [Claude Code Integration](#claude-code-integration)
28
+ - [Tutorial Gallery Site](#tutorial-gallery-site)
29
+ - [In-app help widget](#in-app-help-widget)
30
+ - [Deploying to GitHub Pages from CI](#deploying-to-github-pages-from-ci)
31
+ - [How It Works](#how-it-works)
32
+ - [License](#license)
33
+
34
+ ---
35
+
36
+ ## Why playwright-director?
37
+
38
+ Most software teams maintain **tests** and **documentation** separately. Tests verify features work; docs explain how to use them. When a feature changes, the docs lag behind — or never get updated.
39
+
40
+ `playwright-director` bridges this gap: your Playwright tests **are** your tutorial source. Run them normally for CI; flip a flag and they produce broadcast-ready **directed video guides**.
41
+
42
+ ### Key Features
43
+
44
+ - **Dual-mode tests** — Same test file runs as a fast E2E test (`playwright test`) or a narrated video tutorial (`TUTORIAL_MODE=true`)
45
+ - **Voice narration** — Built-in TTS with macOS `say`, Microsoft Edge TTS, or any custom command via `TUTORIAL_TTS_CMD`
46
+ - **Animated cursor** — Smooth, eased mouse movements with click animations that follow your test actions
47
+ - **Step overlays** — On-screen banners showing current step, progress bar, and descriptions
48
+ - **Context screens** — Goal / clarification / attention cards between steps to explain what's happening
49
+ - **Background music** — Looping audio with fade-out on completion
50
+ - **Email previews** — Simulated email popups for verification flow demos
51
+ - **Multiple user profiles** — Two signed-in personas as browser-like tabs in one video, with an optional side-by-side moment
52
+ - **ffmpeg post-processing** — Automatic video + audio merge with timeline-accurate voice placement
53
+ - **Screenshot capture** — WebP screenshots at each step, poster image from step 1
54
+ - **Transcript generation** — Markdown transcripts auto-generated from timeline data
55
+ - **Multi-language** — Full RTL support (Arabic), per-language video filenames, i18n-ready
56
+ - **Playwright Reporter** — Auto-merges audio into video as each tutorial test completes
57
+ - **Gallery site** — `build-site` turns your recordings into a static video gallery with step-by-step guides, ready for any static host
58
+ - **In-app help widget** — the published gallery serves a `widget.js`: one `data-tutorial` attribute opens any tutorial as an overlay inside your own app
59
+ - **Zero runtime overhead** — All tutorial logic is no-op when `TUTORIAL_MODE` is not set
60
+
61
+ ## The name
62
+
63
+ This package went through more names than it went through versions. It started as `pw-tutorial-video` — literal, honest, and about as charming as a filename. It worked until the tool outgrew it: this package no longer just records videos, it stages them — deciding what to emphasize, how fast to move, where to pause, what deserves the viewer's attention.
64
+
65
+ Then came the search, and the search was humbling. `tutorialize` — the most natural verb in the space — was taken. `testory` (test + story) was brandable but told you nothing. `test2video` described the mechanics, not the craft. `playwright-tutorial` was accurate and instantly forgettable. Every candidate either over-explained or under-sold.
66
+
67
+ The way out was to flip the question: stop naming the output, name the role. Playwright is the actor — it performs every click, scroll, and keystroke on stage. This package is everything around the performance: the staging, the pacing, the narration, the final cut. That job already has a name — **director**. `playwright-director` was free, and it was the only candidate that described the job instead of the file format.
68
+
69
+ ## Installation
70
+
71
+ ```bash
72
+ npm install --save-dev playwright-director
73
+ ```
74
+
75
+ ### Peer Dependencies
76
+
77
+ | Package | Required | Notes |
78
+ |---|---|---|
79
+ | `@playwright/test` | Yes | >= 1.40.0 |
80
+ | `sharp` | Optional | For optimized WebP screenshots (falls back to raw PNG) |
81
+ | `ffmpeg` | Runtime | Required on PATH for audio/video merge |
82
+ | `ffprobe` | Runtime | Required on PATH for audio duration detection |
83
+
84
+ ## Quick Start
85
+
86
+ ### 1. Create a tutorial-enabled test
87
+
88
+ ```typescript
89
+ import { test, expect } from '@playwright/test';
90
+ import { Tutorial } from 'playwright-director';
91
+
92
+ test('Create your first invoice', { tag: ['@tutorial'] }, async ({ page }, testInfo) => {
93
+ const tutorial = new Tutorial(page, {
94
+ title: 'Create Your First Invoice',
95
+ lang: 'en',
96
+ audioBaseUrl: 'http://localhost:5173', // your dev server port
97
+ backgroundMusic: '', // or a URL to a .mp3 file
98
+ // Required for the reporter to match and merge the video:
99
+ testTitle: testInfo.title,
100
+ testFile: testInfo.file,
101
+ projectName: testInfo.project.name,
102
+ });
103
+
104
+ // Add a context screen (goal explanation)
105
+ tutorial.context('invoice.intro', {
106
+ text: 'Learn how to create and send your first invoice',
107
+ style: 'goal',
108
+ });
109
+
110
+ // Add steps — actions are queued, not executed yet
111
+ tutorial.step('invoice.client', async () => {
112
+ await tutorial.click(page.getByLabel('Client'));
113
+ await tutorial.selectOption(page.getByLabel('Client'), 'Acme Corp');
114
+ }, {
115
+ do: 'Select your client',
116
+ explain: 'Choose from your existing client list or create a new one',
117
+ });
118
+
119
+ tutorial.step('invoice.amount', async () => {
120
+ await tutorial.fill(page.getByLabel('Amount'), '1500');
121
+ }, {
122
+ do: 'Enter the invoice amount',
123
+ });
124
+
125
+ tutorial.step('invoice.send', async () => {
126
+ await tutorial.click(page.getByRole('button', { name: 'Send' }));
127
+ await expect(page.getByText('Invoice sent')).toBeVisible();
128
+ }, {
129
+ do: 'Send the invoice',
130
+ explain: 'Your client will receive the invoice by email',
131
+ });
132
+
133
+ // Execute all steps and finalize the video
134
+ await tutorial.complete('Invoice created successfully!');
135
+ });
136
+ ```
137
+
138
+ ### 2. Run as a normal test
139
+
140
+ ```bash
141
+ npx playwright test --grep "@tutorial"
142
+ ```
143
+
144
+ ### 3. Run in tutorial mode (generates video)
145
+
146
+ ```bash
147
+ TUTORIAL_MODE=true npx playwright test --grep "@tutorial"
148
+ ```
149
+
150
+ Videos are saved to `tutorials/videos/`, timelines to `tutorials/output/`.
151
+
152
+ > **Important**: Never override `--reporter` on the CLI for tutorial runs — it disables the merge step. Use the config file instead.
153
+
154
+ ### 4. Add generated paths to `.gitignore`
155
+
156
+ Tutorial runs create files in your project. Add these to `.gitignore`:
157
+
158
+ ```gitignore
159
+ # playwright-director generated files
160
+ tutorials/
161
+ static/audio/tutorial-voice/
162
+ ```
163
+
164
+ ### 5. Converting an existing test
165
+
166
+ The most common use case is converting an existing Playwright test:
167
+
168
+ | Before (plain test) | After (tutorial) |
169
+ |---|---|
170
+ | `await page.click(...)` | `await tutorial.click(...)` |
171
+ | `await page.fill(...)` | `await tutorial.fill(...)` or `tutorial.typeSlowly(...)` |
172
+ | `await page.selectOption(...)` | `await tutorial.selectOption(...)` |
173
+ | — | `tutorial.context(...)` between sections |
174
+ | — | `tutorial.step(title, action, { do, explain })` wrapping groups |
175
+ | — | `await tutorial.complete(message)` at the end |
176
+
177
+ Keep the `expect()` assertions — they still run in both modes, ensuring your tutorial stays in sync with the real UI.
178
+
179
+ ## API Reference
180
+
181
+ ### `Tutorial` class
182
+
183
+ ```typescript
184
+ import { Tutorial } from 'playwright-director';
185
+
186
+ const tutorial = new Tutorial(page, options);
187
+ ```
188
+
189
+ #### `TutorialOptions`
190
+
191
+ | Option | Type | Default | Description |
192
+ |---|---|---|---|
193
+ | `title` | `string` | *required* | Tutorial title shown in overlay |
194
+ | `translate` | `(key: string) => string` | `k => k` | Translation function — pass your i18n `t()` |
195
+ | `audioBaseUrl` | `string` | `'http://localhost:5173'` | Base URL for serving audio files |
196
+ | `lang` | `string` | `'en'` | Language for UI text and TTS (e.g. `'en'`, `'fr'`, `'ar'`) |
197
+ | `testName` | `string` | auto | Output filename slug |
198
+ | `testFile` | `string` | `''` | Test file path for metadata |
199
+ | `projectName` | `string` | `''` | Playwright project name |
200
+ | `enableVoice` | `boolean` | `true` | Enable TTS voice narration |
201
+ | `voiceName` | `string` | auto | TTS voice name |
202
+ | `voiceRate` | `number` | `1.0` | Speech rate multiplier |
203
+ | `backgroundMusic` | `string` | `''` | Music file URL |
204
+ | `musicVolume` | `number` | `0.15` | Background music volume (0-1) |
205
+ | `voiceVolume` | `number` | `2.5` | Voice volume multiplier |
206
+ | `stepDelay` | `number` | `500` | Delay before the action for non-voiced steps (ms). Voiced steps overlap narration and action instead: the action starts at the estimated end of the `do` sentence inside the clip (25% of the clip for single-phase steps), and the step lasts `max(narration, offset + action)` |
207
+ | `mouseSteps` | `number` | `25` | Cursor animation smoothness |
208
+ | `customStyles` | `string` | built-in | Custom CSS for overlays |
209
+ | `scenes` | `Record<string, SceneOptions>` | — | Named scenes for multi-profile tutorials — see [Multiple user profiles](#multiple-user-profiles) |
210
+ | `focus` | `string \| string[]` | first scene | Scene(s) active when the stage mounts |
211
+ | `sceneTransition` | `{ duration?: number }` | `{ duration: 600 }` | Pause on a scene switch (ms) |
212
+
213
+ #### Methods
214
+
215
+ | Method | Description |
216
+ |---|---|
217
+ | `context(key, options?)` | Add a context screen (goal/clarification/attention) |
218
+ | `step(key, action, options?)` | Add a tutorial step with an action callback |
219
+ | `complete(message?)` | Execute all queued steps and finalize |
220
+ | `click(locator)` | Click with cursor animation and highlight |
221
+ | `fill(locator, value)` | Fill input with highlight |
222
+ | `typeSlowly(locator, value, delay?)` | Type character by character (visual effect) |
223
+ | `selectOption(locator, value)` | Select dropdown option with highlight |
224
+ | `highlight(locator, duration?)` | Highlight an element |
225
+ | `moveMouseToElement(locator)` | Animate cursor to element |
226
+ | `showEmailPreview(options)` | Show simulated email popup |
227
+ | `switchPage(page)` | Switch recording to another tab/window |
228
+ | `clearFields()` | Clear form fields on next page load |
229
+ | `stage()` | Mount the multi-scene stage (tab bar + one iframe per scene) |
230
+ | `scene(name)` | Get a scene as a Playwright `FrameLocator` |
231
+ | `goto(name, url)` | Navigate a scene (relative to its `baseUrl`, or absolute) |
232
+ | `focus(name \| names[])` | Bring scene(s) on stage — one fills it, two share it |
233
+
234
+ ### `StepOptions`
235
+
236
+ | Option | Type | Description |
237
+ |---|---|---|
238
+ | `do` | `string` | Short action text shown in overlay |
239
+ | `explain` | `string` | Explanation played during/after action |
240
+ | `voiceText` | `string` | Custom TTS text (overrides do/explain) |
241
+ | `skipVoice` | `boolean` | Skip voice for this step |
242
+ | `description` | `string` | Description shown below step title |
243
+ | `delay` | `number` | Custom delay after this step (ms) |
244
+ | `scene` | `string \| string[]` | Scene(s) this step plays on — the stage switches before the action runs |
245
+
246
+ ### `ContextOptions`
247
+
248
+ | Option | Type | Description |
249
+ |---|---|---|
250
+ | `text` | `string` | Description shown below title |
251
+ | `style` | `'goal' \| 'clarification' \| 'attention'` | Visual style |
252
+ | `voiceText` | `string` | Custom TTS text |
253
+
254
+ ## Multiple user profiles
255
+
256
+ Some stories need two people: an accountant issues an invoice, a client pays it.
257
+ Declare each one as a **scene** and the stage becomes a browser-like tab bar,
258
+ with one `<iframe>` per scene.
259
+
260
+ ```typescript
261
+ const tutorial = new Tutorial(page, {
262
+ title: 'Invoice, end to end',
263
+ testTitle: 'invoice issued then paid',
264
+ audioBaseUrl: 'http://localhost:5173',
265
+ scenes: {
266
+ accountant: { label: 'Sara — Accountant', baseUrl: 'http://localhost:5173' },
267
+ client: { label: 'ACME — Client', baseUrl: 'http://localhost:5174' },
268
+ },
269
+ focus: 'accountant',
270
+ });
271
+
272
+ const accountant = tutorial.scene('accountant'); // a Playwright FrameLocator
273
+ const client = tutorial.scene('client');
274
+
275
+ await tutorial.stage();
276
+ await tutorial.goto('accountant', '/invoices/new');
277
+ await tutorial.goto('client', '/login');
278
+
279
+ tutorial.step('The accountant issues the invoice',
280
+ () => tutorial.click(accountant.getByRole('button', { name: 'Issue' })),
281
+ { scene: 'accountant' });
282
+
283
+ tutorial.step('The client pays it',
284
+ () => tutorial.click(client.getByRole('button', { name: 'Pay now' })),
285
+ { scene: 'client' }); // the stage switches tabs on its own
286
+
287
+ tutorial.step('Both sides, at once',
288
+ () => expect(accountant.getByText('Paid')).toBeVisible(),
289
+ { scene: ['accountant', 'client'] }); // side by side, just for this step
290
+
291
+ await tutorial.complete();
292
+ ```
293
+
294
+ ### How it behaves
295
+
296
+ - **Sessions are independent** because each scene is a separate **origin**. Two
297
+ iframes on the same origin share cookies and `localStorage`, so the second
298
+ login overwrites the first. For two users of the *same* app, serve it under a
299
+ second hostname (`app.localhost` / `app2.localhost`) to get a second origin.
300
+ - **Inactive scenes stay mounted**, hidden but never unloaded — a profile logged
301
+ in behind another tab is still logged in when you come back to it.
302
+ - **A hidden scene is not interactive.** Pass `scene` on every step that touches
303
+ one; the stage switches before the action runs. Acting on an off-stage scene
304
+ will simply time out.
305
+ - **`scene: [a, b]` puts two scenes side by side**, each taking half the stage.
306
+ Treat it as an exception for the moment cause and effect must share one frame:
307
+ at 1280px wide, each pane only gets ~640px. In an array, the first scene is
308
+ the one acting.
309
+ - **Tabs are always all visible**, so the viewer knows who else is in the story
310
+ and who is speaking now.
311
+ - Each timeline step records its `scene`, so transcripts say who was on screen.
312
+
313
+ ### Requirements
314
+
315
+ - Target pages must allow framing. Sites sending `X-Frame-Options: SAMEORIGIN`
316
+ or `DENY` (Google, Bing, many SaaS apps) **cannot** be used as scenes. For
317
+ your own app, relax `frame-ancestors` in tutorial mode only.
318
+ - `stage()` navigates the parent page to `audioBaseUrl` before injecting the
319
+ stage, so narration audio loads same-origin. Point `audioBaseUrl` at the app
320
+ serving `static/audio/tutorial-voice/`.
321
+
322
+ ## Variants — record the same tutorial for mobile
323
+
324
+ Set `TUTORIAL_VARIANT=mobile` (or pass `variant: 'mobile'`) to record a second,
325
+ phone-sized version of a tutorial without touching the spec:
326
+
327
+ ```typescript
328
+ import { Tutorial, mobileStage } from 'playwright-director';
329
+
330
+ // Widens the viewport (and video) to N phones side by side.
331
+ // Inert unless TUTORIAL_VARIANT=mobile — the same spec records both versions.
332
+ test.use(mobileStage(2)); // default device 'Pixel 7'; or mobileStage(2, 'iPhone 14'), or an explicit {width, height}
333
+
334
+ const tutorial = new Tutorial(page, { /* options unchanged */ });
335
+ ```
336
+
337
+ ```bash
338
+ TUTORIAL_MODE=true npx playwright test # → tutorials/videos/<name>.webm
339
+ TUTORIAL_MODE=true TUTORIAL_VARIANT=mobile npx playwright test # → tutorials/videos/<name>-mobile.webm
340
+ ```
341
+
342
+ What the `mobile` variant does automatically:
343
+
344
+ - **Suffixes every output** with `-mobile` (video, timeline, transcript,
345
+ screenshots, poster) so the desktop version is never overwritten.
346
+ - **Pins the split** on multi-scene tutorials: every phone stays visible at
347
+ equal width for the whole video, the tab bar never shows, per-scene labels
348
+ take over (the inactive one is dimmed), and `focus()` ratios are ignored —
349
+ on phone-width panes an asymmetric split has no room to work.
350
+ - **Compacts the overlay**: smaller card, smaller type, icon and step badge
351
+ hidden. It is all CSS variables scoped under `html[data-tutorial-variant='mobile']`
352
+ remapping to `--tutorial-*-mobile` values, so tuning it is a plain `:root`
353
+ override from your own styles (see [Styling](#styling)):
354
+
355
+ ```css
356
+ :root {
357
+ --tutorial-overlay-width-mobile: 220px;
358
+ --tutorial-icon-display-mobile: inline-flex; /* bring the icon back */
359
+ }
360
+ ```
361
+
362
+ Any other variant name (`TUTORIAL_VARIANT=tablet`) only suffixes the outputs
363
+ and stamps `data-tutorial-variant` — no preset. The reporter matches each run
364
+ to the timeline of the same variant, so both runs can share
365
+ `TUTORIAL_OUTPUT_DIR`, and the gallery site gains a Desktop/Mobile filter when
366
+ variants are present.
367
+
368
+ Note: `mobileStage()` must be passed to `test.use()` at the top of the spec —
369
+ the video size is frozen when the browser context is created.
370
+
371
+ ### Sharp mobile video (oversampling)
372
+
373
+ Playwright records video at *window* pixels and never upscales, so a
374
+ phone-sized viewport would yield a blurry ~400px-wide video. In tutorial mode,
375
+ `mobileStage()` therefore records **oversampled 2× by default**: it launches
376
+ Chromium with `--force-device-scale-factor=2`, aligns `deviceScaleFactor`, and
377
+ doubles the video size — the page layout (CSS viewport) is unchanged, a Pixel 7
378
+ video comes out at 824×1678.
379
+
380
+ ```typescript
381
+ test.use(mobileStage(2, 'Pixel 7', { scale: 3 })); // even sharper
382
+ test.use(mobileStage(2, 'Pixel 7', { scale: 1 })); // old behavior, native CSS pixels
383
+ ```
384
+
385
+ Caveat: `test.use()` replaces the config's `launchOptions` wholesale. If your
386
+ playwright.config passes Chromium args for tutorial runs (e.g.
387
+ `--autoplay-policy=no-user-gesture-required`), repeat them via `launchArgs`:
388
+
389
+ ```typescript
390
+ test.use(mobileStage(2, 'Pixel 7', {
391
+ launchArgs: ['--autoplay-policy=no-user-gesture-required'],
392
+ }));
393
+ ```
394
+
395
+ ### Playwright Reporter
396
+
397
+ Auto-merge audio into video after each tutorial test:
398
+
399
+ ```typescript
400
+ // playwright.config.ts
401
+ export default defineConfig({
402
+ reporter: [
403
+ ['playwright-director/reporter', {
404
+ mappingFile: 'path/to/tutorial-mapping.txt', // optional
405
+ tutorialsJson: 'path/to/tutorials.json', // optional
406
+ }],
407
+ ],
408
+ });
409
+ ```
410
+
411
+ ### Utilities
412
+
413
+ ```typescript
414
+ import { slugify } from 'playwright-director/slugify';
415
+ import { createTTSProvider } from 'playwright-director';
416
+ import { buildMergeCommand } from 'playwright-director';
417
+ ```
418
+
419
+ ## TTS Configuration
420
+
421
+ ### macOS (default)
422
+
423
+ Uses the built-in `say` command. Voices per language:
424
+ - French: Thomas
425
+ - English: Samantha
426
+ - Arabic: Maged
427
+
428
+ ### Edge TTS
429
+
430
+ Free Microsoft neural voices. Install: `pip install edge-tts`
431
+
432
+ ### Custom TTS
433
+
434
+ Set `TUTORIAL_TTS_CMD` with placeholders:
435
+
436
+ ```bash
437
+ # Custom voice engine
438
+ TUTORIAL_TTS_CMD='my-tts --voice premium -l {lang} {text} -o {output}'
439
+ ```
440
+
441
+ ### Pre-rendering the voice cache
442
+
443
+ TTS synthesis is the slow part of a tutorial run, and clips are cached by
444
+ `md5(lang:text)` — the hash covers the **text only**, so a voice or
445
+ `TUTORIAL_TTS_CMD` change never invalidates old clips. `regen-voices` generates
446
+ the clips outside of any test run:
447
+
448
+ ```bash
449
+ npx playwright-director regen-voices # synthesize the missing clips
450
+ npx playwright-director regen-voices --workers=4 # parallel TTS (default 2)
451
+ npx playwright-director regen-voices --force # after a voice change: redo everything
452
+ npx playwright-director regen-voices --lang=fr # one language only
453
+ rm -rf static/audio/tutorial-voice && npx playwright-director regen-voices # full rebuild
454
+ ```
455
+
456
+ Narration texts are collected from the artifacts that record them verbatim:
457
+ the transcripts (`tutorials/transcripts/*.md`) and the timelines
458
+ (`tutorials/output/*_timeline.json`). The project `.env` is loaded for the
459
+ `TUTORIAL_*` settings. Videos are not remixed — the next `TUTORIAL_MODE=true`
460
+ run picks the clips up from cache.
461
+
462
+ ## Environment Variables
463
+
464
+ | Variable | Default | Description |
465
+ |---|---|---|
466
+ | `TUTORIAL_MODE` | `false` | Enable tutorial video generation |
467
+ | `TUTORIAL_VARIANT` | none | Recording variant — suffixes outputs; `mobile` also compacts the overlay and pins the split |
468
+ | `TUTORIAL_VOICE` | `true` | Enable/disable voice narration |
469
+ | `TUTORIAL_VOICE_NAME` | auto | TTS voice name override |
470
+ | `TUTORIAL_TTS_CMD` | `say` (macOS) | Custom TTS command |
471
+ | `TUTORIAL_OUTPUT_DIR` | `tutorials/output` | Timeline output directory |
472
+ | `TUTORIAL_MUSIC` | none | Background music file URL |
473
+ | `TUTORIAL_MUSIC_VOLUME` | `0.15` | Background music volume |
474
+ | `TUTORIAL_VOICE_VOLUME` | `2.5` | Voice narration volume |
475
+ | `TUTORIAL_MAPPING_FILE` | none | Tutorial-to-video mapping file |
476
+ | `TUTORIAL_TUTORIALS_JSON` | none | Tutorials metadata JSON |
477
+
478
+ ## Styling
479
+
480
+ Import the default styles or provide your own:
481
+
482
+ ```typescript
483
+ // Use default styles (automatic)
484
+ const tutorial = new Tutorial(page, { title: 'My Tutorial' });
485
+
486
+ // Use custom styles
487
+ const tutorial = new Tutorial(page, {
488
+ title: 'My Tutorial',
489
+ customStyles: '.tutorial-overlay { background: navy; }'
490
+ });
491
+ ```
492
+
493
+ CSS variables for theming:
494
+
495
+ ```css
496
+ :root {
497
+ --tutorial-primary: #3b82f6;
498
+ --tutorial-bg-start: rgba(30, 41, 59, 0.95);
499
+ --tutorial-border: rgba(148, 163, 184, 0.3);
500
+ --tutorial-text: #f8fafc;
501
+ --tutorial-z-index: 10000;
502
+ --tutorial-animation-duration: 0.3s;
503
+
504
+ /* Multi-scene stage */
505
+ --tutorial-stage-bg: #1e293b;
506
+ --tutorial-scene-bg: #ffffff;
507
+ --tutorial-tab-bg: #334155;
508
+ --tutorial-tab-text: #94a3b8;
509
+ --tutorial-tab-bg-active: #f8fafc;
510
+ --tutorial-tab-text-active: #0f172a;
511
+ --tutorial-tab-dot: #64748b;
512
+ --tutorial-tab-dot-active: #22c55e;
513
+ --tutorial-tab-padding: 10px 20px;
514
+ --tutorial-tab-radius: 10px 10px 0 0;
515
+ --tutorial-tab-size: 15px;
516
+ --tutorial-tab-transition: 250ms;
517
+ --tutorial-stage-gap: 2px;
518
+ --tutorial-tabbar-padding: 8px 8px 0;
519
+ }
520
+ ```
521
+
522
+ Scene panes deliberately expose no size variables: they share the stage evenly
523
+ via flexbox, and animating that sizing makes Playwright treat the frame as never
524
+ stable, which times out every click inside it.
525
+
526
+ ## Output Files
527
+
528
+ After running in tutorial mode:
529
+
530
+ ```
531
+ tutorials/
532
+ ├── output/
533
+ │ └── {test-name}_timeline.json # Step timing + ffmpeg command
534
+ ├── transcripts/
535
+ │ └── {test-name}.md # Auto-generated transcript
536
+ └── videos/
537
+ ├── {test-name}.webm # Merged video with audio
538
+ ├── {test-name}-poster.webp # Poster image (step 1)
539
+ └── {test-name}-step-{n}.webp # Step screenshots
540
+ ```
541
+
542
+ ## Reviewing & Correcting Narration
543
+
544
+ Every tutorial run auto-generates a markdown transcript in
545
+ `tutorials/transcripts/{test-name}.md`. To rework the narration, edit the
546
+ transcript and write the corrections back into your test source:
547
+
548
+ ```bash
549
+ # 1. (Optional) regenerate transcripts from the timeline JSON files
550
+ npx tutorial-transcript
551
+
552
+ # 2. Edit tutorials/transcripts/{test-name}.md — fix the narration texts
553
+
554
+ # 3. Apply: rewrites the corrected texts in your test file
555
+ npx tutorial-transcript apply
556
+
557
+ # 4. Re-run: only the changed TTS clips are regenerated (content-hash caching)
558
+ TUTORIAL_MODE=true npx playwright test
559
+ ```
560
+
561
+ `apply` pairs each transcript entry with its timeline step (by the `**key:**`
562
+ lines, in order — a key used twice corrects each occurrence in turn; the
563
+ `**[Complete]**` entry is the completion message), then locates the original
564
+ text in the test file (`testFile` from the timeline) as a quoted string
565
+ literal and replaces it. A narration composed of two fields
566
+ (`do` + `explain`, or title + `description`/`text`) is handled by splitting
567
+ old and new text at the first sentence boundary and replacing both halves.
568
+
569
+ What `apply` **won't** touch: texts that come from an i18n catalog (the
570
+ literal isn't in the test file) and step keys rendered verbatim as titles —
571
+ those are reported with their key so you can fix the translation instead.
572
+ The timeline JSON is updated on success, so re-running `apply` is a no-op.
573
+
574
+ `npx tutorial-transcript apply [file.md ...]` limits the run to specific
575
+ transcripts; without arguments it processes every `.md` in the transcript
576
+ directory (`TUTORIAL_TRANSCRIPT_DIR`, default `tutorials/transcripts`).
577
+
578
+ ## Claude Code Integration
579
+
580
+ This package ships **two assets for Claude Code** that teach AI agents how to convert your Playwright tests into professional tutorials:
581
+
582
+ ### What you get
583
+
584
+ | Asset | Installed to | Purpose |
585
+ |---|---|---|
586
+ | `/tutorialize` skill | `.claude/skills/tutorialize/` | Slash command that loads tutorial design methodology — persona analysis, storytelling arc, choreography rules, and the full `playwright-director` API. Invoke with `/tutorialize` in Claude Code. |
587
+ | `tutorial-crafter` agent | `.claude/agents/tutorial-crafter.md` | Specialized agent (Sonnet) that reads your test, designs the tutorial arc, and writes the tutorial code. Dispatched automatically or manually. |
588
+
589
+ ### Skill reference files
590
+
591
+ The skill bundles two reference documents that Claude reads before tutorializing:
592
+
593
+ | File | Content |
594
+ |---|---|
595
+ | `SKILL.md` | 4-phase process: understand the viewer → design the arc → implement with `playwright-director` → verify |
596
+ | `references/storytelling.md` | Who is watching (role, expertise, emotional state), narration voice rules, pacing decisions, when to use context screens vs steps, multi-profile scene heuristics |
597
+ | `references/api.md` | Complete `Tutorial` class API with timing model, critical rules (e.g., navigate before any tutorial call, never override `--reporter`), and a pre-commit checklist |
598
+
599
+ ### Setup
600
+
601
+ ```bash
602
+ npx playwright-director init
603
+ ```
604
+
605
+ This interactively copies the skill and agent into your `.claude/` directory. Example session:
606
+
607
+ ```
608
+ playwright-director 0.2.0 — Claude Code Setup
609
+
610
+ Install /tutorialize skill into .claude/skills/? [Y/n] y
611
+ + Skill copied to .claude/skills/tutorialize/
612
+ Install tutorial-crafter agent into .claude/agents/? [Y/n] y
613
+ + Agent copied to .claude/agents/tutorial-crafter.md
614
+
615
+ Done! You can now use /tutorialize in Claude Code.
616
+ ```
617
+
618
+ ### Usage in Claude Code
619
+
620
+ ```
621
+ # Ask Claude to convert a test into a tutorial
622
+ > Tutorialize tests/free/01_company.init.ts
623
+
624
+ # Or invoke the skill directly
625
+ > /tutorialize tests/premium/10_expense-scan.test.ts
626
+ ```
627
+
628
+ Claude will:
629
+ 1. Read the test and identify the viewer persona
630
+ 2. Design a storytelling arc (goal → steps → completion)
631
+ 3. Write the tutorial code with `tutorial.context()`, `tutorial.step()`, voice narration text, and `tutorial.complete()`
632
+ 4. Verify the test still passes in both normal and tutorial mode
633
+
634
+ ### Keeping them up to date
635
+
636
+ The skill and agent are **copies**, so upgrading the package does not auto-update them.
637
+ `init` stamps the version it installed in `.claude/.playwright-director.json`, and:
638
+
639
+ - after an upgrade, a post-install message names what went stale and tells you to
640
+ re-run `init` — it only speaks when there is something to say, and never writes
641
+ to `.claude/` on its own;
642
+ - re-running `init` shows the transition (`0.1.0 → 0.2.0`) and skips anything
643
+ already current;
644
+ - `npx playwright-director init --yes` answers yes to everything, for scripted
645
+ updates.
646
+
647
+ If you customize the copied skill, keep your additions in a separate file next to
648
+ it — `init` overwrites, it does not merge.
649
+
650
+ ## Tutorial Gallery Site
651
+
652
+ Generate a static video gallery website from your tutorials — one command, zero config.
653
+
654
+ ### Quick start
655
+
656
+ ```bash
657
+ npx playwright-director build-site
658
+ ```
659
+
660
+ On the first run, a `tutorial-site.config.js` file is created with sensible defaults. Edit it to customize branding, then re-run `build-site` — the config is reused automatically.
661
+
662
+ The command scans your `tutorials/` directory for videos, screenshots, and timeline metadata, then builds a static site ready to deploy (e.g., to Cloudflare Pages, Netlify, or any static host).
663
+
664
+ ### What gets generated
665
+
666
+ ```
667
+ tutorial-site-dist/ # Static site output (configurable)
668
+ ├── index.html # Gallery home — videos grouped by category
669
+ ├── {video-slug}/index.html # Dedicated page per video: player + step-by-step guide
670
+ ├── widget.js # Embeddable in-app help widget (see below)
671
+ ├── embed/ # Widget data
672
+ │ ├── {video-slug}.json # One payload per tutorial: video, description, steps
673
+ │ └── index.json # All available slugs (discovery/debugging)
674
+ └── videos/ # Copied from tutorials/videos/
675
+ ├── *.webm # Video files
676
+ └── *-step-*.webp # Step screenshots (carousel on cards, guide on video pages)
677
+ ```
678
+
679
+ ### Video pages & the step guide
680
+
681
+ Each video page shows the player, the tutorial's intro narration as a
682
+ description, and a **step-by-step guide** built from the timeline: every step's
683
+ narration text paired with its screenshot. Context narrations recorded
684
+ mid-flow appear as callouts between steps, and steps recorded without voice
685
+ still show their text.
686
+
687
+ ![Video page with thumbnail strip and text guide](docs/images/video-page.png)
688
+
689
+ Two settings control the guide (`tutorials` block of the config):
690
+
691
+ | Setting | Values | Effect |
692
+ |---|---|---|
693
+ | `showStrip` | `true` (default) / `false` | Horizontal thumbnail strip under the video info. Clicking a thumbnail opens the screenshot in a lightbox with the step's number, title and text as a caption. |
694
+ | `stepsLayout` | `'text'` (default) | Text-only cards — clicking a step reveals its screenshot inline. |
695
+ | | `'cards'` | Numbered cards with a small screenshot; click opens the lightbox. |
696
+ | | `'full'` | Narration text + full-width screenshot under each step. |
697
+ | | `'none'` | No step guide (strip only, if enabled). |
698
+
699
+ | `stepsLayout: 'cards'` | Strip thumbnail clicked → lightbox with caption |
700
+ |---|---|
701
+ | ![Cards layout](docs/images/steps-cards.png) | ![Strip lightbox](docs/images/strip-lightbox.png) |
702
+
703
+ <details>
704
+ <summary><code>stepsLayout: 'full'</code> — full-width screenshot per step</summary>
705
+
706
+ ![Full layout](docs/images/steps-full.png)
707
+
708
+ </details>
709
+
710
+ ### In-app help widget
711
+
712
+ The published gallery doubles as a documentation backend for your app. Every
713
+ build ships a `widget.js` at the site root plus one JSON payload per tutorial
714
+ under `embed/`. Load the script from your deployed gallery and mark any
715
+ element with `data-tutorial="<video-slug>"`:
716
+
717
+ ```html
718
+ <script src="https://tutorials.myapp.com/widget.js" defer></script>
719
+
720
+ <button data-tutorial="create-account">📘 How does this work?</button>
721
+ ```
722
+
723
+ Clicking the element opens an overlay right inside your app — the tutorial
724
+ video, its intro narration, and the full step-by-step guide (click a step to
725
+ reveal its screenshot), with a link to the full tutorial page on the gallery:
726
+
727
+ ![In-app widget opened over a host application](docs/images/widget.png)
728
+
729
+ Notes:
730
+
731
+ - **Zero dependencies, framework-agnostic** — clicks are handled by event
732
+ delegation, so buttons added later (React/Vue re-renders) work without
733
+ re-initialization. Styles live in a shadow root and can't leak either way;
734
+ the widget inherits your app's font.
735
+ - **Slugs** are the video ids — the file names in the gallery's `videos/`
736
+ directory (a tutorial's `name`, plus `-<variant>` for variant recordings).
737
+ `GET <site>/embed/index.json` lists them all.
738
+ - **Programmatic API**: `window.PlaywrightDirector.open('create-account')` and
739
+ `window.PlaywrightDirector.close()` — e.g. to launch a tutorial from an onboarding
740
+ checklist instead of a button.
741
+ - **Theming**: the gallery's `primaryColor` is the default accent; override it
742
+ from the host page with `.playwright-director-widget { --playwright-director-accent: #16a34a; }`.
743
+ - **Cross-origin**: the widget fetches `embed/<slug>.json` from the gallery
744
+ host, so the gallery must send `Access-Control-Allow-Origin` for your app's
745
+ origin. GitHub Pages sends `*` out of the box; on Netlify or Cloudflare
746
+ Pages add a headers rule for `/embed/*` (e.g. a `_headers` file with
747
+ `Access-Control-Allow-Origin: *`). Video and screenshots are plain media
748
+ elements and need no CORS.
749
+ - **RTL** tutorials (`lang: 'ar'`, …) render the whole panel right-to-left;
750
+ individual texts use `dir="auto"` either way.
751
+
752
+ ### Configuration
753
+
754
+ ```js
755
+ // tutorial-site.config.js
756
+ export default {
757
+ // Branding
758
+ title: "My App Tutorials", // Site title (header + page titles)
759
+ logo: "./assets/logo.svg", // Path to logo image (optional)
760
+ primaryColor: "#6366f1", // Primary color (CSS)
761
+ font: "system-ui, sans-serif", // Font family (CSS)
762
+
763
+ // Paths
764
+ input: "tutorials/", // Where videos + timelines live
765
+ output: "tutorial-site-dist/", // Where the static site is built
766
+
767
+ // Site
768
+ baseUrl: "https://tutorials.myapp.com", // For SEO (optional)
769
+ lang: "fr", // Site language
770
+
771
+ // Content overrides (optional)
772
+ tutorials: {
773
+ categories: {
774
+ "getting-started": { icon: "⭐", label: "Getting Started" },
775
+ "advanced": { icon: "🚀", label: "Advanced" },
776
+ },
777
+ ui: {
778
+ heroTitle: "Learn My App",
779
+ heroSubtitle: "Step-by-step video tutorials",
780
+ },
781
+ stepsLayout: "text", // Step guide on video pages: 'text' | 'cards' | 'full' | 'none'
782
+ showStrip: true, // Thumbnail strip above the guide (click = lightbox + caption)
783
+ },
784
+ };
785
+ ```
786
+
787
+ ### How videos are discovered
788
+
789
+ The scanner looks for `.webm` files in `<input>/videos/`. For each video:
790
+
791
+ - If a matching `_timeline.json` exists in `<input>/output/`, its metadata is used (title from narration steps, duration, category from `@feature:*` tag)
792
+ - Otherwise, a human-readable title is derived from the filename and duration is left blank
793
+
794
+ Step screenshots (`{video}-step-{n}.{png,webp}`) are auto-detected and displayed as a carousel on each video card.
795
+
796
+ ### Previewing locally
797
+
798
+ The generated site uses relative paths, but browsers block `<video>` elements on `file://`. Use any static server:
799
+
800
+ ```bash
801
+ npx serve tutorial-site-dist
802
+ ```
803
+
804
+ ### CLI options
805
+
806
+ ```bash
807
+ playwright-director build-site # Uses ./tutorial-site.config.js
808
+ playwright-director build-site --config=path/to.js # Custom config path
809
+ ```
810
+
811
+ ### Deploying to GitHub Pages from CI
812
+
813
+ The gallery is a plain static site, so it deploys anywhere — this repository deploys its own on every push to `main` ([`.github/workflows/deploy-site.yml`](.github/workflows/deploy-site.yml)): a marketing landing page at the site root and the generated gallery under `/gallery/`.
814
+
815
+ ![Landing page deployed to GitHub Pages](docs/images/landing-page.png)
816
+
817
+ The workflow, in short:
818
+
819
+ ```yaml
820
+ on:
821
+ push:
822
+ branches: [main]
823
+
824
+ steps:
825
+ - uses: actions/checkout@v4
826
+ - uses: actions/setup-node@v4
827
+ with: { node-version: 22, cache: npm }
828
+ - run: npm ci
829
+ - run: sudo apt-get update && sudo apt-get install -y ffmpeg
830
+ - run: pipx install edge-tts # real narration on Linux
831
+ - run: npx playwright install --with-deps chromium
832
+ - run: npm run test:e2e:video # TUTORIAL_MODE=true playwright test
833
+ - run: npx playwright-director build-site -y
834
+ - run: | # landing page + gallery
835
+ mkdir -p _site/gallery
836
+ cp -r landing/. _site/
837
+ cp -r tutorial-site-dist/. _site/gallery/
838
+ - uses: peaceiris/actions-gh-pages@v4
839
+ with:
840
+ github_token: ${{ secrets.GITHUB_TOKEN }}
841
+ publish_dir: ./_site
842
+ ```
843
+
844
+ Things to know when adapting it:
845
+
846
+ - **TTS on Linux** — there is no macOS `say` on CI runners. With [`edge-tts`](https://pypi.org/project/edge-tts/) on the PATH the voice system falls back to it automatically, so the published videos have real narration. Alternatively set `TUTORIAL_TTS_CMD` to any command you prefer.
847
+ - **Sub-path hosting** — GitHub Pages serves project sites under `/<repo>/`. Set `baseUrl` in `tutorial-site.config.js` to the full public URL **including the path** (this repo uses `https://youniwemi.github.io/playwright-director/gallery/`); the site build derives the Astro `site` + `base` from it so all links and assets resolve.
848
+ - **Pages source** — the workflow publishes to the `gh-pages` branch; configure Pages (Settings → Pages) to serve from that branch.
849
+ - Skip the landing-page assembly step and publish `tutorial-site-dist/` directly if you only want the gallery at the site root.
850
+
851
+ ## How It Works
852
+
853
+ 1. **Test registration** — `tutorial.step()` / `tutorial.context()` queue actions and start preloading TTS audio in background
854
+ 2. **Execution** — `tutorial.complete()` waits for all TTS preloads, then executes steps sequentially with overlays, cursor animation, and voice playback
855
+ 3. **Timeline** — Each step records its timestamp and audio file reference
856
+ 4. **Post-processing** — The Playwright reporter reads the timeline JSON, waits for Playwright to finalize the video file, then runs ffmpeg to merge the silent screen recording with the voice clips and background music
857
+
858
+ ## License
859
+
860
+ MIT