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 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:
- webreel CLI — installed globally via
npmor project-local vianpx. If missing, prompts to runnpm install -g @lgariv/webreel(a scoped fork of vercel-labs/webreel that adds cinematic autozoom; the binary is still invoked aswebreel). - Companion webreel Claude skill at
~/.claude/skills/webreel/. If missing, offers to fetch it fromlgariv-dn/webreel(the same fork — its companion skill documents theautoZoomfield). ghCLI — 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 forghbecause it's an OS-level package manager install.gh-imageextension (drogers0/gh-image) — required to upload demo videos to GitHub user-attachments programmatically. If missing, auto-installs silently viagh 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:
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 Linkor child stories, note the epic key and fetch children viamcp__claude_ai_Atlassian__searchJiraIssuesUsingJqlwithparent = 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.
- 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:
Scope — one video or several? If several: grouped by (a) PR concern, (b) E2E spec file, or (c) epic child story?
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.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?
Environment — confirm dev server is on the fix branch. If
git branch --show-currentdoesn't match the fix branch, flag it and ask whether to switch.Viewport + format — desktop preset (
1920×1080,1600×900,macbook-pro)? Output: MP4 / GIF / WebM? Duration target (short <10s / standard 15–30s / detailed 30–60s)?Captions / HUD — include keystroke overlays, custom cursor theme, or annotation callouts?
Autozoom — the
@lgariv/webreelfork 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
AskUserQuestionwith 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) andDisable 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 theAskUserQuestion'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 itsnamewith 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": trueat the video level inwebreel.config.json. For fine control, an object can override defaults (approachS,sessionGapS,minZoomRatio, etc.) — but default totrueunless the user explicitly asks to tune.- If the plan is ONE combined video — use a single-select
Delivery — ALWAYS ask as a multi-select
AskUserQuestion(setmultiSelect: true) with exactly these three options — the user may pick any combination:- Prepend to GitHub PR description — upload via
gh image, thengh pr editto 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 imageURL 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 MCPaddCommentToJiraIssue. - 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.
- Prepend to GitHub PR description — upload via
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:
curl -s -o /dev/null -w "%{http_code}" http://localhost:4200/→ expect200.- If reusing an instance ID, navigate to its URL via
mcp__chrome-devtools__navigate_pageand confirm key fix-related elements are present. Workflow definitions change; an instance that matched the repro yesterday may be stale today. - If the user's current checkout ≠ the fix branch, switch. Watch for untracked
.agents/skills/* symlink conflicts — they're regenerable, safe tormselectively before checkout. - If seeding fresh, use the DAP catalog API. See 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.
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 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:
"videos": {
"my-video": {
"url": "...",
"viewport": { "width": 1920, "height": 1080 },
"waitFor": ".app",
"autoZoom": true, // ← opt in
"steps": [ /* ... */ ]
}
}
trueuses 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
MutationObserverduring 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 at1.33× for ultra-wide UI, or/.claude/skills/webreel/SKILL.md{ "enabled": true, "sessionGapS": 2.5 }to force more rest-at-wide between unrelated actions. Consult the fork's companion skill at `(already fetched byensure-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 hereandPaste 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
sessionGapSand 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):
{ "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):
{
"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:
- Top-level
defaultDelay: 0— kills the implicit 400 ms padding webreel inserts after every step. Without this, a 400 ms gap opens up after themoveTothat can't be eliminated by any per-step setting. "delay": 0on themoveTostep — overrides any step-level delay that would otherwise run AFTER the moveTo completes and before the next step starts.- No
pausestep betweenmoveToandkey— 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,
clickand a subsequentwaitfor rendered content, the flow may race. Add explicitpausesteps 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:
{ "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.
Record pass — include a
{ action: "key", key: "F13", label: "..." }step at each narrative beat. F13 is the chosen benign key (modifier-only keys likeShiftare rejected with "pressKey requires a non-modifier key"). Thekeystep anchors a caption entry in the timeline at a precise timestamp. Keep the immediately-following action steps (click,moveTo, etc.) short with minimaldelays — the long visible window comes from the timeline pass, not frompauses between steps. Example beat:{ "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 }Timeline-extend pass — webreel writes a timeline JSON to
.webreel/timelines/<video-name>.timeline.jsonwith aframesarray, one entry per recorded frame. Each frame has an optionalhud: { labels: [...] }. Native recording populates48 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.bakon first run, always reads from the backup for idempotency, and writes the extended timeline back.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-
keyaction 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:
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-coreclamp (≥0.1.4-beta-20260418T145700Z) now shrinks oversized HUDs to fit via SVGviewBox, 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 — seereferences/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.
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").
- Bug-fix PRs → Before→After contrast (changelog voice). Use an arrow (
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)