Screen Recorder
Produces a short .webm clip of a specific page section so an agent —
or a reviewer reading a PR — can see motion that a static screenshot
hides.
Not a test suite, not a permanent fixture: every recording is a
throwaway artifact written to .agent/recordings/ for one-shot visual
review.
This SKILL.md is a thin index. Detailed rules live in
rules/*.md and load on demand. Literal Playwright and
ffmpeg boilerplate the skill emits lives in
templates/*.md. Do not preload everything — load only
what the current phase asks for.
When to use
Reach for this skill when any of the following is true:
- A diff changes animation, transition, or interactive motion code
(
@keyframes, transition, View Transitions, Motion layout,
@starting-style, scroll-driven timelines).
- A still screenshot would not falsify the bug or feature claim — the
proof requires at least two frames.
- A reviewer asks "what does this look like?" on a PR whose answer is
a moving image, not a description.
- The
animations skill has produced an animation and wants visual
evidence it hits 60 fps and respects prefers-reduced-motion.
- The
ux skill flagged a flow (drag, focus ring, hover reveal,
expanding nav, modal entrance) where the asserted UX claim is
about timing, not layout.
Do not reach for this skill when:
- A static screenshot is sufficient — capture a still directly (the
pr-reviewer agent's visual pass reads stills locally; it never
attaches them to the PR).
- The task is to author a durable Playwright test — use the
e2e-testing skill.
- The input is an existing
trace.zip — use
playwright-trace-analyzer.
- The input is an existing screen recording (e.g., a user bug report) —
use
video-analyser.
Phase 0 — Preflight (mandatory gate)
Before recording anything, verify the environment. Halt and ask the
user before installing tools.
Run these checks (read-only):
# 1. Playwright installed?
command -v npx >/dev/null && npx --no-install playwright --version 2>/dev/null
# 2. Chromium driver available?
npx --no-install playwright install --dry-run chromium 2>/dev/null
# 3. ffmpeg available (cropping + transcoding)?
command -v ffmpeg
Decision table:
| State |
Action |
Playwright + Chromium + ffmpeg all present |
Proceed to Phase 1. |
| Playwright missing |
Halt. Print install plan, ask permission. See rules/preflight.md. |
| Playwright present, Chromium driver missing |
Halt. Print npx playwright install chromium, ask first. |
ffmpeg missing |
Halt. Print install plan (brew install ffmpeg), ask first. Cropping disabled until installed. |
Target URL is localhost:* and not reachable |
Halt. Ask the user to start the dev server before recording. |
Print exact commands; do not run silently.
Full preflight rules: rules/preflight.md.
Phase 1 — Inputs
Collect, then confirm back to the user before running:
| Input |
Required |
Default |
Notes |
url |
Yes |
— |
Full URL or localhost:<port>/<path>. Auth handled in Phase 2. |
selector |
Yes |
— |
CSS, data-testid="...", or role=button[name="..."]. The crop target. |
interaction |
No |
idle (record state only) |
Named recipe or inline script. See rules/interactions.md. |
duration |
No |
5000 ms |
Total recording length. Cap 15 s. Default 5 s gives the video-analyser enough I-frames (≈ 10 at the 0.5 s GOP) for its 8-frame default. |
viewport |
No |
{ width: 1280, height: 800 } |
The recording canvas — kept large for layout fidelity (responsive breakpoints, container queries). Downscaling happens at the crop stage. |
output-name |
No |
<selector-slug>-<ts> |
File slug. .webm is always produced; .mp4 / .gif opt-in. |
out-format |
No |
webm |
One of webm, mp4, gif. Non-webm requires ffmpeg transcode. |
max-width |
No |
768 px |
Output downscale ceiling. 768 px is the video-analyser Pareto knee — UI text stays legible, image tokens stay cheap (~786 tokens/frame on Sonnet). Crops smaller than this are not upscaled. Set 0 to disable downscale. |
keyint |
No |
15 frames |
Forced GOP length. At 30 fps this places one I-frame every 0.5 s, so the video-analyser's select='eq(pict_type,I)' sampling always lands enough frames for short clips. |
reduced-motion |
No |
false |
If true, emulate prefers-reduced-motion: reduce. |
Echo the resolved inputs back as a one-screen summary before Phase 2.
Caller-specific overrides. When caller: pr-reviewer and out-format is unspecified, default to mp4 (GitHub previews .mp4 inline). When caller: animations and the immediate next step is a Skill("video-analyser") invocation, keep max-width: 768 and keyint: 15 — they are already analyser-optimal. Pass max-width: 0 only when a human reviewer has reported text-still-unreadable at 768 px (rare).
Phase 2 — Generate the recording script
Write a single-purpose Node.js script to
.agent/recordings/<slug>/record.mjs using the template at
templates/record.mjs.template.
Substitution rules and the interaction recipe catalog are in
rules/recording-script.md and
rules/interactions.md.
Hard rules:
- Use
chromium.launch({ headless: true }) — never headed, never webkit,
never firefox (recording fidelity differs between engines).
- Pass
recordVideo: { dir, size: viewport } on the context, not the
browser. Playwright emits one .webm per page.
- Always
await context.close() before reading the video path — the file
is finalised only on context close.
- Cap the script at one page, one context, one recording per run. Multiple
recordings means multiple runs.
Phase 3 — Run the script
node .agent/recordings/<slug>/record.mjs
Capture stdout / stderr. On non-zero exit, do not crop — surface the
error and stop.
Resolve the produced .webm path from the script's stdout (the template
prints VIDEO=<absolute-path> as its last line).
Phase 4 — Crop and downscale for the analyser
Use ffmpeg to crop the full-viewport .webm to the bounding box of
the target element, then downscale to a max-width-px-wide output with
a fixed 0.5 s GOP. The script in Phase 2 captures the bbox via
locator.boundingBox() and writes it to a sibling bbox.json.
The defaults (max-width: 768, keyint: 15) are tuned for the
video-analyser skill's Pareto knee — see
the Analyser-optimised sizing
section for the numbers. Net effect: 768 px wide keeps UI text legible
at ~786 image tokens/frame on Sonnet, and the short GOP guarantees the
analyser's keyframe-first sampling (Step 5a) lands on signal-dense scene
transitions rather than falling back to uniform-time sampling.
Crop command, downscale filter, and format-conversion rules: rules/cropping.md.
If ffmpeg is unavailable, skip cropping with a one-line note and
deliver the uncropped .webm.
Phase 5 — Deliver
Print a delivery summary:
Recording: <absolute path>
Format: <webm | mp4 | gif>
Size: <KB>
Duration: <ms>
Viewport: <w>x<h>
Cropped: <yes (WxH at X,Y) | no — ffmpeg missing>
URL: <recorded URL>
Selector: <selector>
Interaction: <recipe or inline summary>
If the caller is another skill (Phase 6 below), return the path only —
no narration.
Phase 6 — Integration callers
This skill is called by four other consumers via Skill("screen-recorder").
Full handshake spec: rules/integrations.md.
| Caller |
When |
Required inputs |
animations |
After Phase 7 ("Measure") to attach a clip to the delivery, or when the user asks "show me". |
url, selector, animation name (becomes output-name). |
ux |
When a finding is severity Critical / High and concerns timing, motion, focus order, or interaction feedback. |
url, selector, the finding ID. |
pr-reviewer |
In PR Mode when the diff matches animations / ux heuristics and the PR author has not attached a recording. |
url, selector, PR number (slugs the local artifact — pr-reviewer never uploads it). |
storybook |
When a scaffolded story includes motion or transitions that a still screenshot cannot prove (multi-frame interactions). |
url (story permalink), selector, story name (becomes output-name). |
Callers pass inputs in their Skill() call body; this skill never asks
the calling skill questions — it falls back to defaults and proceeds, or
fails fast with one explanatory line.
Required Reading by Phase
Load on demand — do not preload.
Core Principles
- Throwaway, not test.
Recordings live in
.agent/recordings/ and are never checked into git.
Add .agent/recordings/ to .gitignore if it is not already covered.
- One concern per clip.
One URL, one selector, one interaction.
If two animations need verifying, run twice.
- Headless Chromium only.
Headed mode introduces window-chrome that ruins the crop; other
engines re-encode
transform and filter differently.
- Crop with
ffmpeg, not with viewport gymnastics.
Resizing the viewport to the element bbox changes layout (responsive
breakpoints, container queries) and lies about what the user sees.
Record the real viewport, crop after.
- Respect
prefers-reduced-motion explicitly.
If the user passes reduced-motion: true, the recording must show
the reduced variant. The animations skill calls this skill twice
(default + reduced) to validate both paths.
- No live URLs without consent.
Recording staging or production captures real user data into a video
file. Refuse unless the user has explicitly named the staging /
production host.
Anti-patterns (one-liners — full lists in linked rules)
- Recording with
headed: true and submitting the result as evidence
(rules/recording-script.md).
- Sizing the viewport to the element to "auto-crop" — distorts layout
(
rules/cropping.md).
- Skipping
await context.close() and reading a half-written .webm
(rules/recording-script.md).
- Multiple
page.goto() calls in one recording — the second navigation
appends to the same video and confuses the viewer.
- Recording at
localhost:3000 without checking the dev server is up
(rules/preflight.md).
- Hard-coding selectors that drift (
div > div:nth-child(3)) instead
of data-testid or role
(rules/interactions.md).
- Transcoding to
.gif for anything over 4 s — the file balloons past
10 MB (rules/cropping.md).
- Committing
.agent/recordings/ into git.
Definition of Done
1---2name: screen-recorder3description: Records short videos of specific page sections using Playwright's `recordVideo` API, plays scripted interactions (hover, click, focus, scroll, keypress), crops the output to a target element via `ffmpeg`, and saves a `.webm` (or `.mp4` / `.gif`) artifact to `.agent/recordings/`. Use when a still screenshot cannot prove the change — verifying a View Transition, a Motion `layout` morph, a hover stagger, a scroll-driven timeline, an `@starting-style` entry, or any multi-frame interaction. Called by the `animations` skill to validate a generated animation, by the `ux` skill to capture an interaction the `pr-reviewer` cannot read from code, and by the `pr-reviewer` agent to produce a local clip it can inspect on motion-heavy diffs. Triggers on "record this interaction", "capture this animation", "video of this section", "validate the transition visually", "screen recording", "/screen-recorder".4license: MIT5---67# Screen Recorder89Produces a short `.webm` clip of a specific page section so an agent —10or a reviewer reading a PR — can see motion that a static screenshot11hides.12Not a test suite, not a permanent fixture: every recording is a13throwaway artifact written to `.agent/recordings/` for one-shot visual14review.1516> **This `SKILL.md` is a thin index.** Detailed rules live in17> [`rules/*.md`](./rules) and load on demand. Literal Playwright and18> `ffmpeg` boilerplate the skill emits lives in19> [`templates/*.md`](./templates). Do not preload everything — load only20> what the current phase asks for.2122---2324## When to use2526Reach for this skill when **any** of the following is true:2728- A diff changes animation, transition, or interactive motion code29 (`@keyframes`, `transition`, `View Transitions`, Motion `layout`,30 `@starting-style`, scroll-driven timelines).31- A still screenshot would not falsify the bug or feature claim — the32 proof requires at least two frames.33- A reviewer asks "what does this look like?" on a PR whose answer is34 a moving image, not a description.35- The `animations` skill has produced an animation and wants visual36 evidence it hits 60 fps and respects `prefers-reduced-motion`.37- The `ux` skill flagged a flow (drag, focus ring, hover reveal,38 expanding nav, modal entrance) where the asserted UX claim is39 about timing, not layout.4041Do **not** reach for this skill when:4243- A static screenshot is sufficient — capture a still directly (the44 `pr-reviewer` agent's visual pass reads stills locally; it never45 attaches them to the PR).46- The task is to author a durable Playwright test — use the47 `e2e-testing` skill.48- The input is an existing `trace.zip` — use49 `playwright-trace-analyzer`.50- The input is an existing screen recording (e.g., a user bug report) —51 use `video-analyser`.5253---5455## Phase 0 — Preflight (mandatory gate)5657Before recording anything, verify the environment. **Halt and ask the58user before installing tools.**5960Run these checks (read-only):6162```bash63# 1. Playwright installed?64command -v npx >/dev/null && npx --no-install playwright --version 2>/dev/null6566# 2. Chromium driver available?67npx --no-install playwright install --dry-run chromium 2>/dev/null6869# 3. ffmpeg available (cropping + transcoding)?70command -v ffmpeg71```7273Decision table:7475| State | Action |76| ---------------------------------------------- | ---------------------------------------------------------------------- |77| Playwright + Chromium + `ffmpeg` all present | Proceed to Phase 1. |78| Playwright missing | **Halt.** Print install plan, ask permission. See [`rules/preflight.md`](./rules/preflight.md). |79| Playwright present, Chromium driver missing | **Halt.** Print `npx playwright install chromium`, ask first. |80| `ffmpeg` missing | **Halt.** Print install plan (`brew install ffmpeg`), ask first. Cropping disabled until installed. |81| Target URL is `localhost:*` and not reachable | **Halt.** Ask the user to start the dev server before recording. |8283Print exact commands; do not run silently.84Full preflight rules: [`rules/preflight.md`](./rules/preflight.md).8586---8788## Phase 1 — Inputs8990Collect, then confirm back to the user before running:9192| Input | Required | Default | Notes |93| -------------- | -------- | ------------------------------------ | --------------------------------------------------------------------- |94| `url` | Yes | — | Full URL or `localhost:<port>/<path>`. Auth handled in Phase 2. |95| `selector` | Yes | — | CSS, `data-testid="..."`, or `role=button[name="..."]`. The crop target. |96| `interaction` | No | `idle` (record state only) | Named recipe or inline script. See [`rules/interactions.md`](./rules/interactions.md). |97| `duration` | No | `5000` ms | Total recording length. Cap 15 s. Default 5 s gives the `video-analyser` enough I-frames (≈ 10 at the 0.5 s GOP) for its 8-frame default. |98| `viewport` | No | `{ width: 1280, height: 800 }` | The recording canvas — kept large for layout fidelity (responsive breakpoints, container queries). Downscaling happens at the crop stage. |99| `output-name` | No | `<selector-slug>-<ts>` | File slug. `.webm` is always produced; `.mp4` / `.gif` opt-in. |100| `out-format` | No | `webm` | One of `webm`, `mp4`, `gif`. Non-`webm` requires `ffmpeg` transcode. |101| `max-width` | No | `768` px | Output downscale ceiling. 768 px is the [`video-analyser`](../../analysis/video-analyser/SKILL.md) Pareto knee — UI text stays legible, image tokens stay cheap (~786 tokens/frame on Sonnet). Crops smaller than this are not upscaled. Set `0` to disable downscale. |102| `keyint` | No | `15` frames | Forced GOP length. At 30 fps this places one I-frame every 0.5 s, so the `video-analyser`'s `select='eq(pict_type,I)'` sampling always lands enough frames for short clips. |103| `reduced-motion` | No | `false` | If `true`, emulate `prefers-reduced-motion: reduce`. |104105Echo the resolved inputs back as a one-screen summary before Phase 2.106107**Caller-specific overrides.** When `caller: pr-reviewer` and `out-format` is unspecified, default to `mp4` (GitHub previews `.mp4` inline). When `caller: animations` and the immediate next step is a `Skill("video-analyser")` invocation, keep `max-width: 768` and `keyint: 15` — they are already analyser-optimal. Pass `max-width: 0` only when a human reviewer has reported text-still-unreadable at 768 px (rare).108109---110111## Phase 2 — Generate the recording script112113Write a single-purpose Node.js script to114`.agent/recordings/<slug>/record.mjs` using the template at115[`templates/record.mjs.template`](./templates/record.mjs.template).116Substitution rules and the interaction recipe catalog are in117[`rules/recording-script.md`](./rules/recording-script.md) and118[`rules/interactions.md`](./rules/interactions.md).119120Hard rules:121122- Use `chromium.launch({ headless: true })` — never headed, never `webkit`,123 never `firefox` (recording fidelity differs between engines).124- Pass `recordVideo: { dir, size: viewport }` on the **context**, not the125 browser. Playwright emits one `.webm` per page.126- Always `await context.close()` before reading the video path — the file127 is finalised only on context close.128- Cap the script at one page, one context, one recording per run. Multiple129 recordings means multiple runs.130131---132133## Phase 3 — Run the script134135```bash136node .agent/recordings/<slug>/record.mjs137```138139Capture stdout / stderr. On non-zero exit, do not crop — surface the140error and stop.141142Resolve the produced `.webm` path from the script's stdout (the template143prints `VIDEO=<absolute-path>` as its last line).144145---146147## Phase 4 — Crop and downscale for the analyser148149Use `ffmpeg` to crop the full-viewport `.webm` to the bounding box of150the target element, then downscale to a `max-width`-px-wide output with151a fixed 0.5 s GOP. The script in Phase 2 captures the bbox via152`locator.boundingBox()` and writes it to a sibling `bbox.json`.153154The defaults (`max-width: 768`, `keyint: 15`) are tuned for the155[`video-analyser`](../../analysis/video-analyser/SKILL.md) skill's Pareto knee — see156the [Analyser-optimised sizing](./rules/cropping.md#analyser-optimised-sizing)157section for the numbers. Net effect: 768 px wide keeps UI text legible158at ~786 image tokens/frame on Sonnet, and the short GOP guarantees the159analyser's keyframe-first sampling (Step 5a) lands on signal-dense scene160transitions rather than falling back to uniform-time sampling.161162Crop command, downscale filter, and format-conversion rules: [`rules/cropping.md`](./rules/cropping.md).163164If `ffmpeg` is unavailable, skip cropping with a one-line note and165deliver the uncropped `.webm`.166167---168169## Phase 5 — Deliver170171Print a delivery summary:172173```text174Recording: <absolute path>175Format: <webm | mp4 | gif>176Size: <KB>177Duration: <ms>178Viewport: <w>x<h>179Cropped: <yes (WxH at X,Y) | no — ffmpeg missing>180URL: <recorded URL>181Selector: <selector>182Interaction: <recipe or inline summary>183```184185If the caller is another skill (Phase 6 below), return the path only —186no narration.187188---189190## Phase 6 — Integration callers191192This skill is called by four other consumers via `Skill("screen-recorder")`.193Full handshake spec: [`rules/integrations.md`](./rules/integrations.md).194195| Caller | When | Required inputs |196| ------------- | --------------------------------------------------------------------------------- | ---------------------------------------------- |197| `animations` | After Phase 7 ("Measure") to attach a clip to the delivery, or when the user asks "show me". | `url`, `selector`, animation name (becomes `output-name`). |198| `ux` | When a finding is severity Critical / High and concerns timing, motion, focus order, or interaction feedback. | `url`, `selector`, the finding ID. |199| `pr-reviewer` | In PR Mode when the diff matches `animations` / `ux` heuristics and the PR author has not attached a recording. | `url`, `selector`, PR number (slugs the local artifact — `pr-reviewer` never uploads it). |200| `storybook` | When a scaffolded story includes motion or transitions that a still screenshot cannot prove (multi-frame interactions). | `url` (story permalink), `selector`, story name (becomes `output-name`). |201202Callers pass inputs in their `Skill()` call body; this skill never asks203the calling skill questions — it falls back to defaults and proceeds, or204fails fast with one explanatory line.205206---207208## Required Reading by Phase209210Load on demand — do not preload.211212| Phase | Files |213| ----- | ------------------------------------------------------------------ |214| 0 | [`rules/preflight.md`](./rules/preflight.md) |215| 1 | [`rules/interactions.md`](./rules/interactions.md) |216| 2 | [`rules/recording-script.md`](./rules/recording-script.md), [`templates/record.mjs.template`](./templates/record.mjs.template) |217| 2/3 | [`rules/interactions.md`](./rules/interactions.md) |218| 4 | [`rules/cropping.md`](./rules/cropping.md) |219| 6 | [`rules/integrations.md`](./rules/integrations.md) |220221---222223## Core Principles2242251. **Throwaway, not test.**226 Recordings live in `.agent/recordings/` and are never checked into git.227 Add `.agent/recordings/` to `.gitignore` if it is not already covered.2282. **One concern per clip.**229 One URL, one selector, one interaction.230 If two animations need verifying, run twice.2313. **Headless Chromium only.**232 Headed mode introduces window-chrome that ruins the crop; other233 engines re-encode `transform` and `filter` differently.2344. **Crop with `ffmpeg`, not with viewport gymnastics.**235 Resizing the viewport to the element bbox changes layout (responsive236 breakpoints, container queries) and lies about what the user sees.237 Record the real viewport, crop after.2385. **Respect `prefers-reduced-motion` explicitly.**239 If the user passes `reduced-motion: true`, the recording must show240 the reduced variant. The `animations` skill calls this skill twice241 (default + reduced) to validate both paths.2426. **No live URLs without consent.**243 Recording staging or production captures real user data into a video244 file. Refuse unless the user has explicitly named the staging /245 production host.246247---248249## Anti-patterns (one-liners — full lists in linked rules)250251- Recording with `headed: true` and submitting the result as evidence252 ([`rules/recording-script.md`](./rules/recording-script.md)).253- Sizing the viewport to the element to "auto-crop" — distorts layout254 ([`rules/cropping.md`](./rules/cropping.md)).255- Skipping `await context.close()` and reading a half-written `.webm`256 ([`rules/recording-script.md`](./rules/recording-script.md)).257- Multiple `page.goto()` calls in one recording — the second navigation258 appends to the same video and confuses the viewer.259- Recording at `localhost:3000` without checking the dev server is up260 ([`rules/preflight.md`](./rules/preflight.md)).261- Hard-coding selectors that drift (`div > div:nth-child(3)`) instead262 of `data-testid` or role263 ([`rules/interactions.md`](./rules/interactions.md)).264- Transcoding to `.gif` for anything over 4 s — the file balloons past265 10 MB ([`rules/cropping.md`](./rules/cropping.md)).266- Committing `.agent/recordings/` into git.267268---269270## Definition of Done271272- [ ] Preflight passed — Playwright, Chromium, and (if cropping273 requested) `ffmpeg` are present.274- [ ] Inputs echoed back and confirmed.275- [ ] `record.mjs` written from the template, no manual edits to the276 Playwright API surface.277- [ ] Script exited 0 and printed `VIDEO=<path>`.278- [ ] `.webm` exists at the printed path and is `> 0 bytes`.279- [ ] If cropping requested, `bbox.json` is non-empty and the cropped280 output exists.281- [ ] Delivery summary printed (or path-only return if called by another282 skill).283- [ ] `.agent/recordings/` is in `.gitignore` (or covered by an existing284 pattern).