# Pr Demo Recorder

> Records scripted webreel demos of a PR's changes using the current branch's PR description, linked Jira ticket, reproduction artifacts, and newly-added Playwright E2E tests as the source of truth. Use when the user asks to "create a demo for this PR", "record a webreel for AR-XXXXX", "demo this fix/feature", "generate a demo video", "make a video of the E2E flow", "demo this epic", or "record a visual for this change". Handles single-concern PRs, large multi-concern PRs, and epic-level demos with one or many videos. Always plans scope, flow, data source, and format with the user via AskUserQuestion before recording — never records unprompted.

- Skill: `lgariv-dn/pr-demo-recorder` (Agent Skill, multi-file: 13 files)
- Install (CLI): `npx skillmds@latest add lgariv-dn/pr-demo-recorder`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lgariv-dn/pr-demo-recorder/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: lgariv-dn (https://skillmd.com/u/lgariv-dn)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/lgariv-dn/pr-demo-recorder

---


# PR Demo Recorder

Records scripted webreel demos from PR context. Pulls research from the PR description, Jira ticket, reproduction artifacts, and newly-added Playwright E2E specs. Plans the flow interactively with the user, then generates `webreel.config.json`(s) and records MP4/GIF/WebM.

## Prerequisites

Before anything else, run the environment check:

```bash
bash dap-workspace/.claude/skills/pr-demo-recorder/scripts/ensure-webreel.sh
```

The script verifies four things in order and exits non-zero at the first failure:

1. **webreel CLI** — installed globally via `npm` or project-local via `npx`. If missing, prompts to run `npm install -g @lgariv/webreel` (a scoped fork of vercel-labs/webreel that adds cinematic autozoom; the binary is still invoked as `webreel`).
2. **Companion webreel Claude skill** at `~/.claude/skills/webreel/`. If missing, offers to fetch it from `lgariv-dn/webreel` (the same fork — its companion skill documents the `autoZoom` field).
3. **`gh` CLI** — required for PR metadata and asset upload. If missing, the script **prints install instructions and exits** (user must install + authenticate before retrying). There is no auto-install for `gh` because it's an OS-level package manager install.
4. **`gh-image` extension** (`drogers0/gh-image`) — required to upload demo videos to GitHub user-attachments programmatically. If missing, **auto-installs silently** via `gh extension install drogers0/gh-image`. If install fails, the script exits non-zero.

Do not proceed past a non-zero exit. The script is the sole source of truth for "is this skill's runtime ready?" — every later phase assumes all four dependencies are present.

## Workflow

Run every phase in order. Do not skip to recording.

### Phase 1 — Research (no user prompts yet)

Gather context in parallel:

```bash
git branch --show-current
gh pr view --json number,title,body,headRefName,baseRefName
gh pr diff --name-only
```

Then, from those outputs:

- **Extract the Jira ticket** — e.g. branch `lgariv/ar-58199/fix-...` → `AR-58199`, or pluck from PR title.
- **Fetch the Jira issue** via `mcp__claude_ai_Atlassian__getJiraIssue` — read description, acceptance criteria, recent comments, linked issues.
- **Detect epic scope** — if the ticket has an `Epic Link` or child stories, note the epic key and fetch children via `mcp__claude_ai_Atlassian__searchJiraIssuesUsingJql` with `parent = EPIC-KEY`.
- **Filter the diff** for `*.e2e.spec.ts`, `*.seed.ts`, and seed YAML files — these encode the exact verified flow.
- **Read the new E2E specs** — each `await x.click()` / `await expect(...)` is a future webreel step.
- **Read referenced Page Object Models** — POMs hold the authoritative selectors. See [references/pom-to-webreel.md](references/pom-to-webreel.md).
- **Find repro artifacts** — `/tmp/<ticket>_*.yaml`, instance IDs mentioned in PR body or Jira comments, screenshots.

Produce a short internal summary: what the PR changes, what the E2Es exercise, what demo-worthy moments exist. Keep it for yourself; don't dump it on the user.

### Phase 2 — Plan with the user (AskUserQuestion)

Never assume. Ask about every non-obvious decision. Batch 1–3 related questions per `AskUserQuestion` call, and iterate — when an answer opens a new decision, ask it next. Asking is cheaper than re-recording.

**Minimum decisions to elicit:**

1. **Scope** — one video or several? If several: grouped by (a) PR concern, (b) E2E spec file, or (c) epic child story?
2. **Flow per video** — what must the cursor tour show? Offer concrete options derived from the research: "bug-fix before/after", "feature walkthrough", "critical E2E path", "full user journey". Include a short preview of each in the question's `description`.
3. **Data source** — reuse the repro instance (paste the ID you found), seed a fresh instance via API, or let the user execute interactively in the browser first?
4. **Environment** — confirm dev server is on the fix branch. If `git branch --show-current` doesn't match the fix branch, flag it and ask whether to switch.
5. **Viewport + format** — desktop preset (`1920×1080`, `1600×900`, `macbook-pro`)? Output: MP4 / GIF / WebM? Duration target (short <10s / standard 15–30s / detailed 30–60s)?
6. **Captions / HUD** — include keystroke overlays, custom cursor theme, or annotation callouts?
7. **Autozoom** — the `@lgariv/webreel` fork ships an opt-in cinematic zoom that eases the camera into each interaction target, holds through the action, and releases to the full viewport. It dramatically improves readability of small UI (form fields, icon buttons, dropdown options) and gives the video a produced, Cursor-walkthrough-style feel. Ask the user:
   - **If the plan is ONE combined video** — use a **single-select `AskUserQuestion`** with exactly two options: `Enable autozoom` (recommended when the demo hits any form input, dropdown, small icon button, or modal; the fork's default tuning works well — no config needed) and `Disable autozoom` (recommended when the demo is mostly large UI areas, full-page content, or long scrolls where a zoomed frame would crop important context). Pick the first option by default in the `AskUserQuestion`'s list if any action target is <40% of the viewport; pick the second default if the flow is dominated by full-page views.
   - **If the plan is MULTIPLE videos** — use a **multi-select `AskUserQuestion`** (`multiSelect: true`) listing every planned video by its `name` with a short one-line description, and let the user pick which subset should have autozoom enabled. Videos NOT selected stay on the default (no autozoom). Phrase the question as: *"Which of these videos should use the cinematic autozoom? (uncheck any that are mostly full-page / large-UI flows.)"*

   When autozoom is enabled for a video, set `"autoZoom": true` at the video level in `webreel.config.json`. For fine control, an object can override defaults (`approachS`, `sessionGapS`, `minZoomRatio`, etc.) — but default to `true` unless the user explicitly asks to tune.
8. **Delivery** — ALWAYS ask as a **multi-select `AskUserQuestion`** (set `multiSelect: true`) with exactly these three options — the user may pick any combination:
   - **Prepend to GitHub PR description** — upload via `gh image`, then `gh pr edit` to prepend the embed while preserving every byte of existing body content.
   - **Post as a comment on the linked Jira ticket** — upload once (reuse the `gh image` URL if already uploaded; otherwise upload separately for Jira) and post a comment on the Jira issue from branch/title (e.g. `AR-58199`) via the Atlassian MCP `addCommentToJiraIssue`.
   - **Save to the user's Downloads folder** — copy the MP4 (and thumbnail PNG if present) to `~/Downloads/` and report the absolute paths. No network upload.

   Do NOT offer Slack, disk-only-in-repo, gist, or other channels — keep this question stable and minimal. The user can always type custom text into the "Other" field if they need something else.

For **epic-level demos**: plan one video per child story, plus an optional "epic summary" video for the end-to-end user journey. Ask which subset of children to cover before you generate configs.

### Phase 3 — Verify environment

Before writing any config:

1. `curl -s -o /dev/null -w "%{http_code}" http://localhost:4200/` → expect `200`.
2. If reusing an instance ID, navigate to its URL via `mcp__chrome-devtools__navigate_page` and confirm key fix-related elements are present. Workflow definitions change; an instance that matched the repro yesterday may be stale today.
3. If the user's current checkout ≠ the fix branch, switch. Watch for untracked `.agents/skills/`* symlink conflicts — they're regenerable, safe to `rm` selectively before checkout.
4. If seeding fresh, use the DAP catalog API. See [references/research-sources.md](references/research-sources.md) for upload → approve → execute → poll recipes.

### Phase 4 — Lock captions BEFORE building the config

Captions anchor the demo's narrative. Every beat that follows — which element to hover, where the camera should zoom, what "evidence" to show — exists to support the caption's claim. If the user rejects a caption in favor of a different angle on the fix, the flow restructures with it: different hover targets, different zoom regions, possibly different beats. Doing caption review *after* the config is written means every caption change ripples into config edits you'd have to throw away.

Lock captions first. Build the config to serve them.

**Draft captions from the Phase 1 research + Phase 2 flow plan.** At this point you know the PR's fix(es), which E2E specs encode the verified flow, and how the user wants the video scoped. That's enough to draft 1–2 narrative captions per video without having touched a selector or written a config.

**Present variants per caption via `AskUserQuestion`.** The drafts you wrote are your recommendations; the user is the one shipping the demo. Offer 2–4 meaningfully-different takes — don't produce synonym-swapped near-duplicates. The templates in the caption-writing section above give you three voices:

- **Before/after arrow** — quotes literal UI strings: `"Before: 'No input data' → now: full workflow input."`
- **Natural prose with connectives** — uses `used to / now / no longer / previously`: `"Drill-back no longer resets the sidebar."`
- **Keynote declarative** — present-tense benefit-forward: `"Status icons persist through drill-back."`

**Example** for a bug-fix caption about a state-preservation fix:

```
Q: "Pick a caption for the post-drill-back moment, or write your own:"
  1. "Icons and expansion survive drill-back." (Recommended — natural prose, 39 ch, 6 w)
  2. "Before: state reset → now: fully preserved." (arrow template, 45 ch, 7 w)
  3. "Drill-back no longer resets the sidebar." (natural prose, 40 ch, 7 w)
```

Always include "Other" implicitly (auto-added). One "Recommended" per question (your best pick first, per global user instructions).

**Iterate each caption separately** — don't batch all captions into one question. Each caption gets dedicated attention.

Keep option labels to the caption text itself (≤ ~45 chars fits the question UI). Use the `description` field to tag voice style: `"Arrow template — quotes literal UI"`, `"Natural prose"`, `"Keynote declarative"`.

**If the user picks "Other" and their write-in implies a DIFFERENT fix or angle**, that's a flow-level change, not a caption-level change. For example, if your draft caption was "Branches nest under the split" and the user writes "Search now filters branch children", that's a completely different fix being showcased. Stop caption review, loop back to Phase 2's flow question, and re-plan the video. This is the catch the early caption review is designed to enable — cheap to fix here, expensive to fix after the config is built.

**After the final caption for a video is locked, move to Phase 5** and build the config with those captions baked in. The hover targets, zoom moments, and beat ordering all serve the approved captions.

### Phase 5 — Generate config(s)

Write `webreel.config.json` (one file can hold multiple named videos via the `videos` map; split into separate files only when format or base URL differs substantially).

**Selector priority** — pick the first strategy that matches uniquely:


| Priority | Example                                               | When                                      |
| -------- | ----------------------------------------------------- | ----------------------------------------- |
| 1        | `text: "Save", within: "#modal"`                      | Visible text that's unique within a scope |
| 2        | `selector: "button[aria-label=\"Navigate to root\"]"` | Icon buttons; i18n-robust                 |
| 3        | `selector: "[data-testid=\"...\"]"`                   | Explicit test hooks                       |
| 4        | `selector: "[data-part=\"branch-trigger\"]"`          | Ark UI / Radix primitives                 |
| 5        | `selector: "[class*=\"itemAction\"]"`                 | CSS Modules — match the pre-hash name     |
| 6        | `selector: "#details"`                                | Developer-assigned DOM IDs                |


**Never** use hashed CSS-Module class names literally (`.hz88NG_itemAction`). **Never** use Playwright-specific combinators (`:has-text(...)`). See [references/selector-strategies.md](references/selector-strategies.md).

**Map E2E steps directly** from the spec: `await x.click()` → `click`, `await expect(y).toBeVisible()` → `wait`, `await page.goto(url)` → the video's `url` + `waitFor`. See [references/pom-to-webreel.md](references/pom-to-webreel.md) for the full translation table.

**Pacing defaults**: `defaultDelay: 400`, 600–900ms `pause` between actions, 1000–1200ms at demo-critical moments (drill-back, status reveal, before/after state changes). `fps: 60`, `quality: 85`.

**Autozoom (`@lgariv/webreel` fork)**: for every video the user opted into autozoom (see Phase 2 item 7), add `"autoZoom": true` as a sibling of `url` / `viewport` / `steps` inside the video's object in `webreel.config.json`. Example:

```jsonc
"videos": {
  "my-video": {
    "url": "...",
    "viewport": { "width": 1920, "height": 1080 },
    "waitFor": ".app",
    "autoZoom": true,          // ← opt in
    "steps": [ /* ... */ ]
  }
}
```

- `true` uses the fork's tuned defaults (approach 0.5 s, release 0.5 s, minZoomRatio 0.6, sessionGapS 4.0) — these match Cursor's documentation walkthrough feel on form-style UI.
- The fork also runs a `MutationObserver` during click/drag steps so dropdowns, modals, and tooltips that open in response to a click get framed with their trigger in one shot (no lateral pan to re-center on the menu option).
- Use an object only when the user explicitly asks to tune: e.g. `{ "enabled": true, "minZoomRatio": 0.75 }` to cap peak zoom at ~1.33× for ultra-wide UI, or `{ "enabled": true, "sessionGapS": 2.5 }` to force more rest-at-wide between unrelated actions. Consult the fork's companion skill at `~/.claude/skills/webreel/SKILL.md` (already fetched by `ensure-webreel.sh`) for the full knob table.

**`sessionGapS` tuning for navigation-heavy flows.** The default `sessionGapS: 4.0` is calibrated for "click a trigger, see the response" patterns (button + modal, tab + panel) where all interactions cluster within 4 s. Drill-in / drill-back flows routinely exceed this: the drill click fires, the new view loads for ~2–3 s, then the breadcrumb click fires — total gap often ~4–5 s, just over the default threshold. Autozoom then splits the navigation into TWO sessions, producing a visible **zoom-in → zoom-out → zoom-in → zoom-out** double-pulse around what the viewer perceives as a single "go in, come back" action. This looks jittery and draws attention to navigation chrome instead of evidence.

**Diagnosis:** after recording with default autoZoom, inspect the `webreel record` stdout — it logs each autozoom event's timestamp (`t=5.27s`, `t=9.33s`, …). If any two consecutive events targeting navigation controls (drill button, breadcrumb, tab trigger, modal close) are **between 4.0 and ~6.5 s apart**, you'll see the double-pulse. Confirm by sampling frames at the gap midpoint — if the camera is wide when it logically should still be holding, you've hit this.

**Fix:** bump `sessionGapS` just enough to absorb the gap, e.g. `{ "enabled": true, "sessionGapS": 6.0 }`. This keeps the camera zoomed through the loading + drill-back, producing a single clean pulse around the whole navigation. Don't go much higher than ~7.0 — unrelated clusters later in the video (e.g. a second caption's hovers) need their own session to feel distinct, and at 8.0+ you risk fusing them with the navigation session into one sprawling hold.

**Alternative:** tighten the flow itself. Reducing the post-drill-in `pause` from ~1200 ms → ~600 ms, or dropping the explicit `hover` before a drill click (if the previous `moveTo` already put the cursor on the row and CSS `:hover` fires from the mouse position), can bring the gap under 4.0 s without any config knob. Faster navigation means fewer pauses means naturally merged sessions. Prefer this over `sessionGapS` tuning when the flow's pacing was already too slow anyway.

**Cursor-style act standard — one smooth guided task, not a checklist.** The Cursor `/multitask` reference clip (`x.com/cursor_ai/status/2047764651363180839`, 2026-04-24) is the benchmark for polish: ~25 s, 60 fps, one continuous product action, sparse/no captions, no long setup, and camera movement that always has an obvious target. Use that as the bar for PR demos unless the user asks for a raw QA proof.

For every generated config, ask: "would this feel like one recorded act if captions were removed?" If the answer is no, restructure before recording.

- **Start on action in < 1 s.** The opening frame may establish context, but the cursor should begin moving to the first meaningful target almost immediately. Do not spend 2–4 s on a static canvas before the first proof.
- **One user intent per video.** For context-menu work, the intent might be "copy a node, paste it at the pane cursor, then paste a clean copy." Do not make the video a tour of every menu item unless the PR itself is about menu taxonomy.
- **Keep type/state evidence while smoothing.** If the value of the demo depends on item type, disabled/enabled state, or the distinction between `Paste here` and `Paste here without config`, those UI states must remain visible. Polish means fewer redundant beats, not removing the evidence that makes the feature reviewable.
- **Prefer 2–3 reveal captions max for a simple PR.** More captions turns the clip into a narrated test run. Use captions only when the pixels alone do not explain the change. If every action needs a caption, the flow is too unclear.
- **No caption should cover the evidence.** The HUD is large. If a lower-center caption overlaps the node, menu row, dialog field, or value being proven, shorten/drop the caption or change the framing so the evidence stays readable.
- **Avoid menu ping-pong.** Opening and closing the same menu repeatedly reads as mechanical. For menus/dropdowns, open once to show state, perform the decisive action, and only reopen when the before/after state is the actual story.
- **No long blank-canvas crops.** Autozoom on a pane coordinate can produce a close-up of white space. If the viewer sees mostly blank canvas for >500 ms, either target the nearby node/menu instead, start from a tighter fit-view, or remove that beat.
- **Cursor motion should be readable.** Add a 150–250 ms pre-click settle only when it clarifies the target, then a 400–700 ms reaction dwell after UI changes. Static cursor holds >1 s are allowed only while a caption is being read and the frame visibly proves that caption.
- **Do not release/re-acquire camera inside one logical action.** Copy → open pane menu → paste is one cluster. Tune `sessionGapS` and remove extra pauses so the camera does not pulse wide between related steps.
- **Cut the runtime before adding more proof.** A simple PR demo should aim for 15–25 s. If it exceeds ~25 s, cut redundant captions, repeated hovers, housekeeping clicks, and menu reopenings before accepting a long clip.

**Caption-after-zoom ordering — reveal captions must fire AFTER the camera settles, not before.** When autozoom is on, the camera needs ~500 ms to approach from wide → target crop before it "arrives" at the zoomed-in view. If the reveal `key` step fires while the camera is still wide (or mid-approach), the viewer reads the caption against an uninformative wide frame, then the camera zooms in right as the caption is fading. The viewer's eye is drawn away from the caption mid-read, and the punchline lands over a now-irrelevant wide shot. The correct pattern is "camera arrives first, caption appears on top of the zoomed evidence."

Mechanism: autozoom generates a zoom event for each `moveTo` / `hover` / `click` step. The camera approach is scheduled to **settle 0.15 s before the event's timestamp**, so the camera is already at the target crop by the time the cursor physically arrives. If the `key` step comes AFTER the `moveTo`, the caption naturally fires on the settled view. If the `key` step comes BEFORE the `moveTo`, the caption fires while the camera is still wide (or mid-approach).

**The rule — reorder reveal beats to put `moveTo` BEFORE the `key`:**

❌ **Wrong** (caption fires wide, camera zooms in while caption is mid-read):
```jsonc
{ "action": "key",    "key": "F13", "label": "Branches nested under the split." },
{ "action": "moveTo", "selector": "#sidebar [data-value='branch-1']" },
{ "action": "pause",  "ms": 2700 }
```

✅ **Right** (camera zooms in first, caption appears as zoom completes — no dead time):
```jsonc
{
  "defaultDelay": 0,                                                    // strip all inter-step padding
  "videos": {
    "my-video": {
      "autoZoom": { "enabled": true, "sessionGapS": 6.0 },
      "steps": [
        /* ... */
        { "action": "moveTo", "selector": "#sidebar [data-value='branch-1']", "delay": 0 },
        { "action": "key",    "key": "F13", "label": "Branches nested under the split." },
        { "action": "pause",  "ms": 2400 }                              // caption dwell budget
      ]
    }
  }
}
```

**Three pieces, all required:**

1. **Top-level `defaultDelay: 0`** — kills the implicit 400 ms padding webreel inserts after every step. Without this, a 400 ms gap opens up after the `moveTo` that can't be eliminated by any per-step setting.
2. **`"delay": 0` on the `moveTo` step** — overrides any step-level delay that would otherwise run AFTER the moveTo completes and before the next step starts.
3. **No `pause` step between `moveTo` and `key`** — any pause here directly adds dead-time to "camera settled but caption hasn't fired yet."

With all three, the HUD fires essentially the instant the cursor arrives at the target. Autozoom's approach settles 0.15 s **before** cursor arrival, so the caption appears ~150 ms after the zoom visually completes — close enough that the viewer perceives it as one motion: "camera arrives AND caption appears," no dead beat in between. Adding even a 300 ms pause here opens a visible gap; an 800 ms pause produces a full second of dead zoomed-but-silent frame before the HUD appears, which the viewer reads as "why are we waiting?"

**Caveats of `defaultDelay: 0`:**
- Every inter-step beat becomes tight. If you rely on implicit padding between, say, `click` and a subsequent `wait` for rendered content, the flow may race. Add **explicit `pause` steps** wherever the UI genuinely needs time to react (post-drill-in, post-route-change, modal-open animations). Think of pauses as a budget you now allocate manually instead of getting implicitly.
- Total video runtime drops noticeably — my AR-58199 demo fell from ~25 s to ~15 s after switching to `defaultDelay: 0`. This is a feature, not a bug: dead time was being padded into the recording even when nothing was happening.

For caption 2 of a two-caption demo, apply the same pattern: `moveTo (delay: 0) → key → pause dwell`. In practice this pairs cleanly with the "one hover to name the finding" rule — that single hover *is* the `moveTo` that triggers the zoom, and the `key` follows immediately after.

**Action captions (as opposed to reveal captions) don't need this ordering** — a 2-word imperative like "Click Start" is short enough that it's readable during the approach-phase without the reader noticing. Only reveal captions (5–7 words naming a fix) are long enough that the ordering matters.

**Keep the cursor moving during the caption dwell — don't freeze it for the full 3 s.** A reveal caption's full 3000 ms window is too long to sit on a static cursor. Viewers read the 5–7 words in ~1500 ms, then their eyes scan back to the scene. If the cursor is frozen on the initial target, the frame feels "paused" — the viewer has nothing to track while the caption is still up, and when the cursor finally moves *after* the caption fades, it feels like the demo "waited" for them.

The correct pattern walks the cursor through the evidence *during* the caption window, so the caption narrates what the viewer is actively watching:

```jsonc
{ "action": "moveTo", "selector": "<first-evidence-target>", "delay": 0 },   // hover 1 → triggers zoom
{ "action": "key",    "key": "F13", "label": "..." },                          // caption fires on zoomed view
{ "action": "pause",  "ms": 1000 },                                            // let caption register (~1 s)
{ "action": "moveTo", "selector": "<adjacent-evidence-target>" },              // hover 2 while caption still visible
{ "action": "pause",  "ms": 1400 }                                             // rest of caption dwell
```

**This is NOT a violation of the hover-count rule** — the second hover illustrates the SAME claim by walking through it (e.g., hovering the `Branch 2` wrapper, then hovering the `child` workflow nested inside it, both illustrating "nested under the split"). It's visual continuity for a single evidence point, not two separate findings.

Self-test: during the caption dwell, is the cursor *doing* anything the viewer can track? If it's static for >1 s while the caption is up, add a hover. Watch the clip and notice: when does the cursor start moving relative to when the caption appears? If the cursor only moves *after* the caption disappears, the viewer reads about something they can't see being pointed to.

The caption-2 pattern naturally satisfies this when the caption bundles two facts (e.g., "icons and expansion survive") because you already have two hovers showing each fact. Single-claim captions (caption-1) need the extra hover added explicitly.

**Captions only render on `key` action steps — and last only 800 ms unless you extend them.** Despite what the webreel docs suggest, in webreel 0.1.4 the HUD caption is drawn only when `pressKey` fires, and `pressKey` calls `showHud` → sleep 800 ms → `hideHud` — hardcoded. `label` on `click`, `moveTo`, `pause` etc. is **silently ignored at composite time**. A `delay` on the `key` step doesn't extend HUD visibility either — it only delays the next step. So the native output of a `key F13 + label "foo"` step gives you a caption visible for ~0.8 s, which is unreadable.

**Use the two-pass workflow: record, then extend-and-composite.**

1. **Record pass** — include a `{ action: "key", key: "F13", label: "..." }` step at each narrative beat. F13 is the chosen benign key (modifier-only keys like `Shift` are rejected with "pressKey requires a non-modifier key"). The `key` step anchors a caption entry in the timeline at a precise timestamp. Keep the immediately-following action steps (`click`, `moveTo`, etc.) short with minimal `delay`s — the long visible window comes from the timeline pass, not from `pause`s between steps. Example beat:
   ```jsonc
   { "action": "key",   "key": "F13", "label": "Click Start \u2192 see the workflow input" },
   { "action": "click", "selector": ".react-flow__node[data-id=\"start-state\"]", "delay": 800 },
   { "action": "wait",  "text": "Input", "within": "#details", "timeout": 10000 },
   { "action": "pause", "ms": 600 }
   ```

2. **Timeline-extend pass** — webreel writes a timeline JSON to `.webreel/timelines/<video-name>.timeline.json` with a `frames` array, one entry per recorded frame. Each frame has an optional `hud: { labels: [...] }`. Native recording populates ~48 consecutive frames per caption (~800 ms at 60 fps). Walk the timeline, detect each run of contiguous HUD frames, and **copy the label across subsequent frames up to the target duration, or until the next HUD run starts, whichever comes first**. Default target: **3000 ms (180 frames at 60 fps)** — verified readable for 6–10 word captions without bloating runtime. Bump to 3500–4500 ms only for verbose labels (>12 words). This stretches the caption's visibility without re-encoding or re-recording. Implementation: a Python script that backs up the timeline to `.json.bak` on first run, always reads from the backup for idempotency, and writes the extended timeline back.

3. **Composite pass** — `npx webreel composite <video-name>`. Re-runs only the overlay compositor using the modified timeline + the raw frames (already stored under `.webreel/raw/`). Takes ~5–10 s per video instead of 30–60 s for a full re-record. This is the step that actually produces the user-visible MP4 with the extended captions burned in.

**Why this is the right pattern:**
- Captions appear during the cursor movement and click — exactly what the user originally asked for — because the timeline extension overlaps the caption with the post-`key` action steps.
- 3000 ms is the sweet spot: long enough to read comfortably (covers 6–10-word captions), short enough to keep the video moving.
- No ffmpeg post-processing, no repeated recording, no reliance on docs claims that don't hold in 0.1.4.

**Prove the value, don't just click.** After a beat's click reveals content, follow with **short `moveTo` hovers (700–1000 ms each) over the specific values that demonstrate the fix**. The click tells "what I did"; the hovers show "what this produced." Keep hover dwell short — once the cursor lands and the viewer registers the value for ~1 s, move on. Long hover dwell adds no signal.

**Hover COUNT is as important as hover duration — one hover per distinct evidence point, not one per visible element.** Walking the cursor through sibling items in a tight cluster ("Branch 1 → Branch 2 → child" in a 200-px-tall sidebar) is a time tax when each item carries the same evidence the caption just announced. The viewer absorbs the tree shape at a glance once the caption frames it; pointing at every row replays information they've already read.

Budget per reveal caption:
- **One hover** to name the finding the caption describes (e.g., hover the nested branch that proves "nested under the split").
- **Optionally a second hover** if — and only if — it names a DIFFERENT fact the caption bundles together. Example: "Icons and expansion survive drill-back." genuinely combines two fixes, so hovering the status icon (proof of icons) AND the nested grandchild (proof of expansion) is legitimate.
- **Three or more hovers on siblings in <200 px of viewport space is a red flag.** Collapse to the one hover that tells the viewer something they can't read from the caption plus the still frame. If you can't name a distinct fact each extra hover reveals, cut it.

**Self-test before adding each extra hover:** "If I removed this hover, would the viewer lose evidence, or just lose a repeat of the caption?" If the latter, cut it. Extra hovers inflate runtime and train the viewer to tune out — the demo feels slow and the punchline lands softer.

### Caption writing — phrase every label on purpose

Captions are the spine of the demo. Vague labels like "Task input we passed in" waste screen time. Every caption must satisfy these rules:

1. **Length: ≤7 words AND ≤45 characters, one line.** Both limits are load-bearing and both must hold:
   - Words drive read time — >7 words is unreadable in the 3000 ms window.
   - Characters drive HUD pixel width. At `DEFAULT_HUD_THEME.fontSize=56`, each caption char is ~34 px. A 50-char caption is ~1680 px wide — already larger than a 1600×900 frame. The `@lgariv/webreel-core` clamp (≥ `0.1.4-beta-20260418T145700Z`) now shrinks oversized HUDs to fit via SVG `viewBox`, but the shrunk text reads worse than a caption written short to begin with. On older webreel without the clamp, oversized HUDs crash the compositor and hang ffmpeg — see `references/troubleshooting.md  Image to composite`.

   **5–6 words / ~30–40 chars is the sweet spot; 7 words / 45 chars is the hard ceiling.** If your draft exceeds either, cut qualifiers before shortening vocabulary. Example: "Branches nest under the split, in execution order." (50 ch, 8 w) → "Branches nest under the split." (30 ch, 5 w) — the hovers below already demonstrate the order nuance; the caption doesn't need to carry it.
2. **Pick a style based on PR type:**
   - **Bug-fix PRs → Before→After contrast** (changelog voice). Use an arrow (`→`) to make the delta explicit. The viewer is typically a reviewer who needs to see the fix.
   - **New-feature PRs → Keynote reveal** (Apple-keynote voice). Declarative, present-tense, benefit-forward. The viewer wants the capability, not the bug story.
   - **Infra / refactor / perf PRs → Keynote reveal** with a metric instead of a benefit when available ("10× faster", "5 fewer renders", "No more network roundtrip").
3. **Action captions are OPTIONAL — drop them when the cursor action is self-evident.** Regardless of PR type, when an action caption is used it's a 2–4 word imperative ("Click Start", "Open the Completed tab", "Drag Branch 2"). But captions must *earn* their screen time — if the cursor visibly lands on the obvious target and the UI immediately responds, the caption just echoes the pixels and trains the viewer to skim-read. **Default to no caption on such beats.** Spend captions on (a) before/after state reveals, (b) behaviors the viewer would miss without annotation ("Embedded tree expanded by default"), (c) values-on-hover that name what's being shown. The STYLE distinction lives in the **reveal captions** — the ones that announce what changed.

   **Self-test before adding an action caption:** "If I remove this caption, does the viewer lose information or just lose a redundant echo?" If the latter, drop it. Captions like "Drill into child", "Back via breadcrumb", "Open the panel" — where the cursor motion + click fully communicate the action — are the common failure mode. When unsure, consult the user with concrete options (including one that drops the caption entirely) rather than shipping a narrating caption.

#### Bug-fix example (AR-55120 pattern) — use this for bug-fix PRs

```
Action:  "Click Start"                                              (2w)
Reveal:  "Before: 'No input data' → now: full workflow input."      (8w)
Action:  "Click Finish"                                             (2w)
Reveal:  "Before: 'No output data' → now: workflow output."         (7w)
```

The arrow template `Before: <literal UI string> → now: <new UI state>.` is ONE tool — it shines when you can quote literal UI text on both sides. It is NOT the default voice for every bug-fix caption. When there's no literal string to quote on the "before" side, the template collapses into telegram-speak ("Before: status icons disappeared → now: stay present.") that reads like machine translation. In those cases, **write a natural-prose sentence instead** using connectives like *used to / previously / no longer / now / instead of*:

```
Natural prose (preferred when no literal UI string to quote):
  "Branches were flat; now nested under the split."            (8w)
  "Drill-back used to drop status icons; now they persist."    (9w)
  "The sidebar no longer resets on return."                    (7w)

Arrow template (preferred when quoting a literal UI placeholder):
  "Before: 'No input data' → now: full workflow input."        (8w)
  "Before: 'No output data' → now: workflow output."           (7w)
```

Read every caption aloud. If it sounds stilted — comma-arrow-fragment, verb tenses that don't match, missing connective words — rewrite as a single natural sentence. The caption should read like a reviewer describing the fix in one breath, not like a template filled in by a script.

##### Why this specific shape beats the variants that keep failing review

Every alternative phrasing that a reasonable-looking draft reaches for — and that a reviewer will bounce — fails for the same underlying reason: **it describes the bug from the engineer's perspective, not the reviewer's.** The reviewer lived with the bug as visible UI. The engineer fixed the bug as code. A caption that reads like the commit message is invisible; a caption that reads like the bug report hits.

Rejected patterns, why they fail, and the quote-the-UI replacement:

| Rejected caption | What went wrong | Replacement |
|------------------|-----------------|-------------|
| `"Task input we passed in"` | First-person + no before-state + no evidence. Just narration. | `"Before: 'No input data' → now: full workflow input."` |
| `"Was empty → now shows workflow input."` | "Empty" is an abstract qualifier — empty what? The input section? The page? A value? Forces the viewer to interpret. | `"Before: 'No input data' → now: full workflow input."` |
| `"Hardcoded empty → real values."` | "Hardcoded" is a code concept the viewer can't see. "Real" is a meaningless contrast word (vs fake?). | Quote the placeholder string the hardcoded-empty rendered as. |
| `"Cleared selection before → now opens output."` | "Selection" is an engineering abstraction. Users don't think in selections; they think "I clicked and nothing useful happened." | `"Before: 'No output data' → now: workflow output."` |
| `"Blanked the panel"` / `"Panel went blank"` | Awkward verbs. "Blank" isn't a common verb. Descriptive of the *effect* rather than the *thing the viewer saw*. | Quote the placeholder or the actual empty-state text. |
| `"Click did nothing"` | Accurate but too abstract. Gives the viewer nothing to anchor on visually. | Quote what was on the screen during the "nothing" state. |
| `"Always empty"` / `"Full workflow input"` | Abstract qualifiers without an anchor. "Always empty" of what? "Full" of what? | Name the specific UI string that proved it was empty. |
| `"Real values. Every field."` | "Real" is a weak contrast. "Every field" hand-waves — which fields? | The hover beats already show the fields. Don't narrate what the cursor is about to demonstrate. |
| `"Before: status icons disappeared → now: stay present."` | Forced arrow template applied where no literal UI string is quoted. Grammatically inconsistent fragments ("disappeared" past-tense verb vs "stay" bare infinitive) welded by an arrow. Reads caveman-like. | Drop the template; write natural prose: `"Status icons used to vanish on drill-back; now they persist."` |

**The rule that makes the right phrasing fall out automatically:** *Before a reveal caption is finalized, grep the codebase for the user-facing placeholder string that rendered in the buggy state.* If you can find it (via `t('...noInput')`, `noData`, `emptyState`, `placeholder`, or a hardcoded string in the component), quote it. If you can't find one (the bug was behavioral, not a placeholder), describe what the viewer saw at the viewport level — "Page wouldn't load past row 20", "Save button stayed grey" — with quotes around anything literal.

##### The quotes are the evidence mark

Quotation marks in a caption signal "this is what was on screen, verbatim." They do two things at once:
- Compress the before-state to a recognizable trigger — any reviewer who lived with the bug recognizes the placeholder instantly.
- Create the contrast visually — the quoted string on the left of the arrow looks like a UI artifact; the unquoted phrase on the right looks like a description of the new reality. The typography itself carries the before/after distinction before the words do.

Treat the quotes as load-bearing punctuation, not decoration. Drop them and the caption stops landing.

##### When no placeholder exists

If the before-state had no visible text (e.g., a crash, a missing element, a silent failure), the fallback is a short concrete UI-level observation in quotes:

- `"Before: click did nothing → now: opens the details."` — if the bug was a dead click with no visible feedback
- `"Before: panel never updated → now: reflects the new state."` — if the bug was stale UI

Still prefer quoting anything literal you CAN quote (a tooltip, an aria-label, a confirmation dialog title) before reaching for abstract descriptions.

#

…(truncated)
