--- name: michel-ui-demo-recorder description: Record polished UI demo videos and screenshots of a running web app using Playwright MCP — for client deliverables, release notes, feature walkthroughs, or bug repros. Produces an HD WebM video with chapter markers, a mandatory animated cursor overlay, and a mandatory subtitle bar that narrates each step (positioned deliberately so it never masks the UI being demonstrated), plus full-page screenshots at each step. Use this whenever the user asks to "record a demo", "create a screencast", "make a UI walkthrough video", "document this feature with video", "show the client how X works", "capture screenshots of the app", or anything similar — even when the user only says "make a video" or "take screenshots" in the context of a running frontend. Also use it when the user wants to demonstrate a workflow, generate marketing-quality footage of an app, or produce repeatable visual documentation. --- # UI Demo Recorder Produce client-ready UI documentation (HD video + screenshots) from a running web app using the Playwright MCP video tools. ## When to reach for this skill - "Record a demo / walkthrough / screencast / video of the app" - "Take screenshots of the feature working" - "Show the client what X looks like" - "Document this flow visually" - "Make a GIF/video of clicking through Y" The deliverable is always one or more of: - WebM video (1440×900 HD by default), with optional chapter cards - Full-page PNG screenshots at key moments - An animated cursor overlay so viewers can follow what's being clicked (**always present**) - A subtitle bar narrating each step (**always present**) **Non-negotiable:** every video this skill produces MUST carry both the cursor overlay and the subtitle bar. They are injected via `browser_evaluate` (pure DOM), independent of `.mcp.json` — a run that omits them is a defective deliverable, not a stylistic choice. If you cannot inject them, stop and report why rather than shipping a bare recording. ## Why a skill exists for this The Playwright MCP video tools work, but they have several non-obvious gotchas that waste a lot of time if you discover them mid-recording: 1. The video tools live behind an opt-in flag (`--caps=devtools`) and are silently absent if the MCP server wasn't launched with it. 2. There are usually **two different Playwright MCP tool prefixes** in the deferred-tools list (a stock one and a plugin one). They use **different browser contexts**, and only one of them can record video. Mixing them mid-session causes "Browser is already in use" errors. 3. The recorder doesn't render the OS cursor — videos look like the app is operating itself unless you inject a fake cursor. 4. Any `location.reload()` wipes the injected cursor — it must be reinjected. 5. The output file lands at the _project root_, not in whatever `--output-dir` says. This skill codifies the working recipe so the model doesn't relearn it every time. ## Pre-flight check (do this first, every time) ### 1. Confirm the Playwright MCP has `--caps=devtools` Open `.mcp.json` (project root or `~/.claude/.mcp.json`). The Playwright server entry **must** include `--caps=devtools` in its args. If it doesn't, video tools won't be exposed. Working config: ```json { "mcpServers": { "playwright": { "command": "npx", "args": [ "@playwright/mcp@latest", "--caps=devtools", "--output-dir=./videos" ] } } } ``` If you have to add the flag, tell the user — they have to reload MCP (`/mcp` reconnect or restart Claude Code) before `browser_start_video` becomes callable. ### 2. Confirm the right tool prefix Use `ToolSearch` for `mcp__playwright__browser_start_video`. The video tools belong to the `mcp__playwright__browser_*` family. Use the **same prefix** for every browser interaction in the recording — `browser_navigate`, `browser_click`, `browser_type`, `browser_snapshot`, `browser_wait_for`, `browser_evaluate`, `browser_close`. **Do not mix in `mcp__plugin_playwright_playwright__browser_*`** — that's a different MCP server with a different browser context; mixing causes "Browser is already in use". ### 3. Get the app running — from a clean volume See the **`michel-run-local-dev-stack`** skill for the full stack lifecycle. Packmind runs as a Docker Compose stack (PostgreSQL, Redis, NestJS API on container port **:3000**, React/Vite frontend on container port **:4200**, MCP server). Those are **container-internal** ports — from the host the API is reached only through the frontend's Vite proxy, and the frontend's **host** port is edition-dependent (`4200` OSS / `4201` proprietary). For recordings, start from **wiped volumes** so stale Postgres schema/rows from a prior run don't leak into the footage, then confirm it's serving before recording: ```bash export PACKMIND_EDITION="$(bash scripts/michel/resolve-edition.sh)" # oss | proprietary, from the git remote docker compose down -v && docker compose up -d --build PM_WEB="$(docker compose port frontend 4200 | sed 's#.*:##')" # host port: 4200 oss / 4201 proprietary # wait for the API via the Vite proxy (NOT :3000 — that container port is not host-exposed) until curl -sf "localhost:$PM_WEB/api/v0" >/dev/null; do sleep 1; done # then wait for the frontend you'll actually record until curl -sf "localhost:$PM_WEB/" >/dev/null; do sleep 1; done ``` The frontend you record lives at **`http://localhost:$PM_WEB`** (4200 OSS / 4201 proprietary) — navigate there, not at the API port. On a freshly wiped database there is no account, so the first screen is the **sign-up / login** page. Create an account (or seed via the API) before recording the feature, otherwise the demo is just the auth screen. If a populated workspace matters, seed it via the UI or the API after the stack is up — do not record over stale state. ## Recording recipe The recipe in plain English: 1. Make sure no browser tab is currently open on the Playwright side, and **always call `browser_stop_video` first (ignore any error it returns)** to clear a recorder a prior session may have left open. Delete any stub WebMs it drops at the project root. 2. Start the video. 3. Navigate to the app. 4. **Inject BOTH overlays — cursor and subtitle bar — in one `browser_evaluate`. This step is mandatory, never skip it.** 5. Add a chapter card. 6. Drive the UI (click, type slowly, wait for visible feedback). Call `window.__setSubtitle(...)` before every step. 7. Whenever the page reloads, **reinject BOTH overlays** (cursor + subtitle bar). A reload wipes them. 8. Add chapter cards between major sections. 9. Stop the video. 10. Move the file from project root into `videos/`. 11. Stop the app stack (`docker compose down` — see the `michel-run-local-dev-stack` skill). ### Starting the video (with the common pitfall) **Always call `browser_stop_video` before `start_video`, even on the first recording** — wrap it so its error is ignored. An earlier browser session in this run frequently leaves a screencast open, and `start_video` then fails with `Error: Screencast is already started`. Stopping first is idempotent: if nothing was recording it's a harmless no-op; if something was, it clears it. ``` mcp__playwright__browser_stop_video() # ignore any error; deletes/returns stub WebMs at project root mcp__playwright__browser_start_video( filename=".webm", size={"width": 1440, "height": 900} ) ``` If you still get `Error: Screencast is already started` after that, the recorder is wedged to a stale browser context that a second `stop_video` won't clear on its own. Escalate — do **not** just retry stop+start in a loop: 1. `mcp__playwright__browser_stop_video()` again (ignore error). 2. `mcp__playwright__browser_close()` — this tears down the wedged context that owns the orphaned screencast. 3. Delete the stub WebMs it drops at the project root. 4. Retry `start_video`. The fresh context starts clean. This is the common failure mode on the fly worker, where an earlier browser session in the same run left both a screencast _and_ its browser context alive — stopping the screencast alone is not idempotent there, because the next `start_video` reattaches to the same wedged context. Closing the context is what actually resets it. If you get `Browser is already in use for ... use --isolated`, a non-recording Playwright session has the persistent profile locked. Call `mcp__playwright__browser_close` first, then start_video. ### Injecting the cursor overlay The Playwright video recorder is a CDP screencast of the viewport — the real OS cursor is never in the frame. To make the video readable, inject a DOM cursor that listens to `mousemove`/`mousedown`/`mouseup` (Playwright's pointer events do dispatch these in capture phase). Run the script in `scripts/inject-cursor.js` (relative to **this skill's directory**, not the project repo you're working in — the agent cwd is usually a checked-out repo that has no such file) via `browser_evaluate` immediately after every navigate or reload. **Inject the cursor and the subtitle bar (next section) together in a single `browser_evaluate`** so neither is ever forgotten — call both injector bodies in one function: ``` mcp__playwright__browser_evaluate(function=`() => { ()(); ()({position: 'bottom'}); }`) ``` The cursor is: - A blue radial-gradient disk with a soft glow - Animates with a 220ms CSS transition so jumps look smooth - Turns red and shrinks on `mousedown` - Emits an expanding ring "ripple" on click Reinject after every `location.reload()` — there is no `initScript` equivalent exposed in MCP, so it has to be a manual step. ### Chapter cards `mcp__playwright__browser_video_chapter` renders a full-screen card with a blurred backdrop over the page for a configurable duration. **Scale to flow complexity — do not use 3-6 cards by default:** - ≤5 action steps → **0-1 cards max** (one opener only if the flow needs context) - 6-12 steps → 2-3 cards (intro + sections) - 12+ steps → up to 5 cards Every card adds dead time. For short demos like "create a standard" or "open a recipe", skip chapter cards entirely or use a single 1000ms opener. **Duration is raw recording time — multiply by your speedup factor to get on-screen time.** A `duration: 2000` card at 4× speedup shows for only 500 ms after ffmpeg — too fast to read. Formula: `duration = desired_screen_ms × speedup`. For a 2 s on-screen hold at 4×, set `duration: 8000`. At 2× use `4000`, at 3× use `6000`. ### Subtitle bar (continuous narration) — mandatory Chapter cards interrupt the action. For inline narration that doesn't hide the UI — short captions that explain each step while the viewer watches the click happen — inject a subtitle bar via `scripts/inject-subtitles.js`. **This is required on every recording, not optional.** Bundle it with the cursor injection so both go in with one `browser_evaluate` (see the combined snippet above), and reinject both after every reload. The script exposes `window.__setSubtitle(text)`. Call it before each step: ``` mcp__playwright__browser_evaluate(function="() => window.__setSubtitle('Type the task name')") ``` Pass an empty string or `null` to hide the bar. **Pick the position deliberately.** The default is bottom-center. A subtitle that sits over the very area the viewer needs to watch defeats the purpose — they'll either miss the action or miss the caption. - If the action happens in the **header/top bar** (a form, search input, primary CTA at the top), use the **bottom** position (default). - If the action happens in a **footer, sticky action bar, or fixed bottom CTA**, switch to the **top** position by passing `{position: 'top'}` to the injector, or call `window.__moveSubtitle('top')` mid-recording. - If a single screen has critical content at both top and bottom, reposition between steps with `window.__moveSubtitle` so the bar always sits on the inert side of the UI. The bar fades + slides on text change (280ms) so swaps look intentional rather than glitchy. Reinject after `location.reload()` along with the cursor. ### Scenario script (written narration for the PR) — mandatory A video link alone is useless to a reviewer who can't or won't play it — typically the Product Manager the demo is for. Every recording MUST ship a short written **scenario** so the demo flow is understandable from text alone. You already have the raw material: the captions you fed to `__setSubtitle` ARE the per-step narration. After recording, distill that sequence into a concise, bullet-point scenario: - Audience: a non-technical reviewer who may never open the video. They should grasp the whole flow from the bullets. - Synthetic and essential only — `login → key action(s) → observable result`. Drop superficial UI steps (scrolling, opening menus, incidental clicks). Keep what proves the feature works. - One line per bullet. No screenshots-of-text, no implementation detail. Write it to a **sidecar file with the SAME basename as the final webm**, in the artifacts dir Michel passes you: ```bash # video: feature-demo.webm → sidecar: feature-demo.scenario.md cat > "/feature-demo.scenario.md" <<'EOF' - Log in as a Product Manager and open the Standards page - Create a new standard "React naming conventions" with two rules - The standard appears in the list; open its detail view - Edit a rule inline and confirm the change persists after reload EOF ``` Michel's publish step finds this sidecar by basename and inlines it directly under the video link in the PR body as **What the video shows:** — you do NOT edit the PR body yourself. If the sidecar is missing or empty, the PR shows `_No scenario available — an error occurred during generation._` under the link instead — a visible defect, so always write it. For uses outside Michel (no automatic PR assembly), hand the same bullets to whoever embeds the video so they go right under the link. ### Lead-in dead air The recorder buffers for a second or two after `start_video` before useful frames appear. Combined with MCP tool round-trip latency, the first ~10–25 seconds of the WebM can show an empty page or `about:blank`. Mitigations: 1. Set the first subtitle and a chapter card **before** the first `wait_for` — gives the viewer something to read during the lead-in. 2. Keep the gap between `start_video`, `navigate`, and the inject-overlays `browser_evaluate` as tight as possible — no intermediate snapshots. 3. **Always trim dead air in post** — add `-ss 5` before the speedup step (see post-processing section). This cuts the first 5 seconds from the raw file before any speedup is applied, removing blank openers at essentially zero quality cost. ### Typing and clicking — make it watchable - `browser_type(..., slowly=true)` types one character at a time. Use it for any text the viewer should read. - Before each `browser_click`, take a fresh `browser_snapshot` to read current UI state — but click by text/CSS (next section), not by `ref=`, so a re-render or navigation can't invalidate your target. - After actions that change the DOM, call `browser_wait_for(text=)` instead of arbitrary sleeps — recordings made of `wait_for` look like a real user; recordings made of fixed sleeps look robotic. - **`wait_for(text=...)` must use the label the UI actually renders, not the one you assume.** A guessed string that never appears burns the full 30s timeout. Read the real text from a fresh `browser_snapshot` first, then wait on it. If a step is flaky to wait on by text, drive it via `browser_evaluate` instead of waiting. ### Do not use snapshot `ref=` tokens as click selectors `browser_snapshot` output contains `ref=eXX` tokens. They look like selectors but are snapshot-internal handles, **not** selector-engine names — passing one to `browser_click` fails with `Error: Unknown engine ref`. Target elements by text or CSS instead: `:text('Add task')`, `input[name="assignee"]`, etc. — or drive the interaction with `browser_evaluate`. ### Clearing a text input (there is no `browser_triple_click`) The Playwright MCP exposes **no** `browser_triple_click`, and `browser_type` appends — it does not replace existing text. The Packmind frontend is React (Chakra UI / `@packmind/ui`), so its inputs are controlled. To clear a field (e.g. before retyping into an edit drawer), set the value via `browser_evaluate` and dispatch an `input` event so React's controlled state updates: ``` mcp__playwright__browser_evaluate(function=`() => { const el = document.querySelector(''); // e.g. input[name="name"] const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setter.call(el, ''); // native setter so React sees the change el.dispatchEvent(new Event('input', { bubbles: true })); }`) ``` Using the native value setter (not `el.value = ''`) is required for React-controlled inputs — a plain assignment is silently overwritten on the next render. After clearing, `browser_type(slowly=true)` the new text so the viewer reads it. ### Chakra checkboxes/radios — click the control part, not the native input Chakra UI v3 (`@packmind/ui`, on zag-js) renders a checkbox/radio as a composite: a hidden native `` plus a visible control element carrying `data-part="control"`. The native `` is **not interactable** — clicking it (or the row's checkbox role) is a silent no-op, and you burn snapshot/retry cycles wondering why selection never toggles. Click the visible control part instead, via `browser_evaluate`: ``` mcp__playwright__browser_evaluate(function=`() => { // the visible control toggles selection; the native does not document.querySelector('table tbody tr:has(a:text("")) [data-part="control"]').click(); // select-all lives in the header row: // document.querySelector('table thead [data-part="control"]').click(); }`) ``` This mirrors the e2e Page Objects, which click `[data-part="control"]` (see `apps/e2e-tests/src/infra/pages/StandardsPage.ts`). Select/combobox options follow the same idea — pick `[data-part="item"]` filtered by text. ### Waiting on a Chakra dialog/drawer — target the overlay or the toast, never an inner button After opening or submitting a dialog, **do not** `wait_for` an inner button's disabled/hidden state — a condition that never settles burns the full 30s timeout. zag-js carries the open state as `data-state` **on the same node** as `role="dialog"`, so: - Wait for OPEN with one **combined** selector (a descendant search finds nothing): `[role="dialog"][data-state="open"]`. - Wait for the action to FINISH on the **success-toast text** the app actually renders (e.g. `moved to the selected space`), or on the dialog node detaching — not on the button you clicked. Same patterns the e2e POMs use: `AbstractPackmindAppPage.ts` for the drawer-open wait, `StandardsPage.ts` / `SkillsPage.ts` for toast waits. ### When a click doesn't propagate (drag-drop and friends) The Playwright `browser_drag` tool dispatches mouse events but does **not** reliably trigger libraries that use pointer-event sensors (e.g. `@dnd-kit`'s `PointerSensor`). If you see the drag "land" on the wrong drop zone or no state change happens, don't fight it. Switch to driving state via the app's API: ``` mcp__playwright__browser_evaluate(function=`async () => { // fetch + PATCH the relevant endpoint // then location.reload() }`) ``` Then reinject the cursor. The video still shows the UI visually changing — viewers don't see the difference. ### Screenshots **Create the target dir before the first capture.** `browser_take_screenshot` throws `ENOENT` if the directory in `filePath` doesn't exist — it will not create it. Run `mkdir -p screenshots` once up front: ```bash mkdir -p screenshots ``` Use `browser_take_screenshot(filePath=..., fullPage=true)` for full-page PNGs at moments worth capturing as stills. Number them (`01-initial.png`, `02-typing.png`, ...) so they sort correctly. Save them under `screenshots/` inside the project. **Wait for real content before the first screenshot.** The Packmind frontend is an SPA — on first paint it shows only a loading spinner, so a screenshot taken right after `browser_navigate` captures the spinner, not the page. Always `browser_wait_for(text=)` (e.g. the sign-in heading) before capturing, exactly as for the video steps above. If the project doesn't have ffmpeg/ImageMagick installed and you also want a GIF or MP4 from a sequence of stills (for an environment that can't play WebM), use a Python venv with `Pillow` (GIF) or `imageio-ffmpeg` (MP4) — both ship a usable binary so brew install isn't needed. ## Stopping and packaging ``` mcp__playwright__browser_stop_video() mcp__playwright__browser_close() ``` The WebM lands at the **project root**, not in `videos/`, regardless of what `--output-dir` says. Move it: ```bash mv ./.webm ./videos/ ``` Stop the app stack you started in the background (`docker compose down`) — don't leave it running. See the `michel-run-local-dev-stack` skill for the teardown rationale. ## Post-processing: trim dead air and speed up Playwright recordings run at real-time wall-clock speed, which includes MCP round-trip latency. The raw WebM always feels sluggish. **Always apply both steps after moving the file:** ### Step 1: check raw duration and choose speed multiplier ```bash ffprobe -v quiet -show_entries format=duration -of csv=p=0 ./videos/.webm ``` Pick multiplier based on raw duration: - < 60s → **2×** (`setpts=0.5*PTS`) - 60–120s → **3×** (`setpts=0.333*PTS`) - > 120s → **4×** (`setpts=0.25*PTS`) ### Step 2: trim lead-in and apply speedup in one pass ```bash # Replace SETPTS with the value chosen above (0.5, 0.333, or 0.25) ffmpeg -i ./videos/.webm \ -vf "setpts=SETPTS*PTS,fps=30" \ -an -c:v libvpx-vp9 -b:v 2M \ -ss 5 \ ./videos/-fast.webm && mv ./videos/-fast.webm ./videos/.webm ``` `-ss 5` (placed **after** `-i`, output-side) trims the first 5 seconds (blank lead-in) accurately. `-an` drops the silent audio track. **The `fps=30` and explicit `-c:v libvpx-vp9 -b:v 2M` are mandatory — do not drop them.** Playwright's recorder is a CDP screencast: a **variable-frame-rate** stream that emits a frame only when the page changes, leaving large gaps between PTS timestamps. Applying `setpts` alone and re-encoding with libvpx defaults keeps those sparse frames, producing a file that _reports_ the full duration in its container metadata but holds almost no real frames — it plays for a few seconds then freezes or won't play at all. (Symptom seen in the wild: a 169s source produced a 256 KB "fast" file that still claimed 169s duration but only showed ~4s of content.) The `fps=30` filter resamples the VFR stream to a **constant 30 fps**, regenerating real frames across the whole timeline; the explicit codec + bitrate guarantee a clean VP9 re-encode instead of a degenerate passthrough. **Verify the output before shipping it.** A correct fast file's duration ≈ source duration × SETPTS, and its size is a meaningful fraction of the source. Check: ```bash ffprobe -v error -show_entries format=duration,size -of default=noprint_wrappers=1 ./videos/.webm ``` If the duration didn't shrink by the multiplier, or the file collapsed to a tiny fraction of the source (e.g. 6 MB source → 256 KB output), the re-encode is corrupt — do **not** hand it over. Re-run with `fps=30` present. If the recording has a real audio track (rare for app demos), remove `-an` and add `-af "atempo=2.0"` (only valid for 2×; higher speedups need chained atempo filters). **Why the subtitle `minHold` is 2500ms:** at 2× speed that becomes ≈1.25 s on screen — long enough to read a short caption comfortably. At 3× it becomes ≈0.83 s — still readable for short captions. Do not lower `minHold` below 2000ms or subtitles will flash too fast after the speed-up. If you skip the ffmpeg step, the default hold will make subtitles feel unnecessarily long — only skip if ffmpeg is not available. ## What "good" looks like - Resolution 1440×900 or larger - Smooth cursor that's clearly visible against the app's UI — **present the whole time** (no bare frames) - A subtitle caption visible for **every** step, repositioned so it never masks the active UI - 3–6 chapter cards (intro, 2–4 sections, outro) - ~15–60 seconds total for focused flows (single feature/action); 60–90 s for multi-section walkthroughs — anything longer should be split or sped up more aggressively - File size 4–15 MB for typical demos; if it's bigger, the run was too long - No console errors visible in the video (close DevTools if it was open) ## What to tell the user at the end Hand over: - The path of the final WebM - The path of the `.scenario.md` sidecar (the written scenario that lands under the video link in the PR) - A one-line summary of the chapters - Anything that was faked (e.g. "drag-drop was driven via the API because dnd-kit doesn't respond to MCP drag synth") - A reminder that WebM may need a modern browser or VLC to play; offer to also produce an MP4 if the client uses a tool that doesn't accept WebM ## Reference material - `scripts/inject-cursor.js` — paste-ready cursor overlay - `scripts/inject-subtitles.js` — paste-ready subtitle bar with `__setSubtitle` / `__moveSubtitle` helpers - `scripts/check-mcp-config.sh` — one-liner to confirm `--caps=devtools` is set - `references/playwright-mcp-tools.md` — table of the relevant tools and their gotchas