Repository files navigation

pr-visual

A Claude Code plugin that captures visual PR documentation: AI-generated Playwright scenarios from the PR description or git diff, annotated screenshots (desktop 2x + mobile 3x, light + dark), and walkthrough videos with burned-in captions.

Each run is isolated in a git worktree with its own port and namespaced resources (Docker containers, networks, volumes), so multiple runs can execute in parallel without collisions.

Prerequisites

  • Node.js 20+
  • ffmpegbrew install ffmpeg (optional — needed for video captions and voice-over transcoding)
  • GitHub CLIbrew install gh
  • Chromium (installed automatically via Playwright on postinstall)

Installation

From Claude Code marketplace (recommended)

/plugin marketplace add gerokeller/pr-visual

Then install the plugin:

/plugin install pr-visual

To share with your team, add this to your project's .claude/settings.json:

{
"extraKnownMarketplaces": {
"pr-visual": {
"source": {
"source": "github",
"repo": "gerokeller/pr-visual"
}
}
},
"enabledPlugins": {
"pr-visual@pr-visual": true
}
}

Via npm

npm install -D pr-visual

Claude Code automatically discovers the plugin via .claude-plugin/plugin.json inside node_modules/pr-visual/. This gives you:

  • /pr-visual slash command
  • PostToolUse hook that reminds you to run it after gh pr create

Quick start

1. Scaffold the config

/pr-visual init

Or via CLI:

npx pr-visual init

This detects your project setup and generates a tailored .pr-visual.config.ts:

pr-visual init: Detecting project setup...
Framework: Next.js
Package manager: pnpm
Docker: yes (postgres, redis)
ORM: prisma
Health endpoint: /api/health
Default port: 3000
Created: .pr-visual.config.ts

2. Review and commit the config

The generated config is ready to use but worth reviewing. Commit it so every team member gets the same behavior.

3. Run it

/pr-visual

Or manually:

npx pr-visual

Configuration

Plugin settings

The plugin accepts the following user configuration (set during plugin install or in settings):

SettingDescription
anthropic_api_keyAPI key for AI-generated scenarios (stored in system keychain). Falls back to ANTHROPIC_API_KEY env var, then to static route capture.

Project config

.pr-visual.config.ts is the contract between your project and the recorder. It declares everything needed to bring up the application from a cold worktree:

importtype{ProjectConfig}from"pr-visual/scripts/pr-visual/types.js";exportdefault{port: 3000,devServer: {command: "npm run dev",env: {PORT: "{{port}}"},},// Setup steps — Docker resources are auto-scoped via COMPOSE_PROJECT_NAMEsetup: [{name: "Start database",command: "docker compose up -d postgres redis"},{name: "Run migrations",command: "npx prisma migrate deploy"},{name: "Seed data",command: "npx prisma db seed"},],readiness: {path: "/api/health",status: 200,timeout: 60_000,},// Teardown — only this run's containers are removedteardown: [{name: "Stop database",command: "docker compose down -v"},],isolate: true,installCommand: "npm ci",}satisfiesProjectConfig;

Template variables

All command strings and env values support these placeholders:

VariableDescription
{{port}}Auto-allocated TCP port for this run
{{runId}}Unique run identifier — safe as Docker project name, DB suffix, directory name
{{rootDir}}Absolute path to the working directory (worktree or project root)

Automatic resource isolation

Every lifecycle step and the dev server receive these environment variables automatically — no manual setup needed:

VariableValuePurpose
COMPOSE_PROJECT_NAME{{runId}}Scopes all Docker Compose containers, networks, and volumes to this run
PORTAllocated portStandard port variable
PR_VISUAL_RUN_ID{{runId}}Available for custom scripts
PR_VISUAL_PORTAllocated portAvailable for custom scripts
PR_VISUAL_ROOT_DIR{{rootDir}}Available for custom scripts

This means docker compose up -d postgres in two parallel runs creates two independent Postgres containers, and each run's docker compose down -v only removes its own.

Config reference

FieldTypeDefaultDescription
portnumber3000Preferred port (auto-incremented if busy)
baseUrlstringhttp://localhost:{{port}}URL template
devServer.commandstringnpm run devDev server command
devServer.envRecordExtra env vars (template substitution)
setupLifecycleStep[]Pre-server steps (Docker, migrations, seeds)
readiness.pathstring/Readiness probe endpoint
readiness.statusnumber200Expected HTTP status
readiness.timeoutnumber45000Max wait time in ms
readiness.intervalnumber1000Probe interval in ms
teardownLifecycleStep[]Post-capture cleanup steps
isolatebooleantrueUse git worktree for isolation
worktreeDirstring../.pr-visual-worktreesWhere to create worktrees
installCommandstringnpm ciInstall command for worktrees
outputDirstring.pr-visualOutput directory (relative to root)
routesArray<string | { path, label }>["/"]Routes for static fallback capture
quality"720p" | "1080p" | "2k" | "4k"Desktop quality preset (see Quality presets)
pacing.wordsPerSecondnumber3.2Reading speed used by adaptive pacing
overlays.cursorbooleanfalseInject a visible custom cursor during capture (see Interaction overlays)
overlays.clicksbooleanfalseEmit a ripple + center dot at each click's coordinates
overlays.highlightsbooleanfalseEnable the highlight scenario step action (pulsing glow + dimmed backdrop)
video.compositing"none" | "remotion""none"Run the recorded clip through a Remotion composition (see Video production)
video.brandColorstring"#3b82f6"Brand accent color for intro/outro/caption-pill chrome
video.categorystringOptional category label rendered as a glassmorphism badge
video.sprintLabelstringOptional sprint / release label rendered subtly in the intro
video.orgNamestringOptional org name rendered in the outro footer
video.highlightsstring[]Optional bullets rendered as a "Key Highlights" card in the outro
video.mobile.enabledbooleanfalseRun a dedicated mobile composite pass after the main matrix and composite both streams (see Mobile composite layouts). Implies compositing: "remotion".
video.mobile.viewport{ width, height }{ 390, 844 }Mobile pass viewport
video.mobile.deviceScaleFactornumber3Mobile pass DPR
video.mobile.layout"side-by-side" | "pip" | "sequential""side-by-side"Composition layout
auth.storageStateDirstring".pr-visual/auth"Directory holding Playwright storage state files (see Authenticated demos)
auth.profilesRecord<string, string>Named profiles → relative storage state file paths
auth.tokenGeneratorLifecycleStepOptional command run after setup and before devServer to refresh storage state
pomsRecord<string, string>Page Object Model registry. Keys are referenced from pom scenario steps (see Page Object Models). Values are module paths relative to the project root.
voiceover.enabledbooleanfalseSynthesize per-step audio and mix it into the composited MP4 (see Voice-over). Implies compositing: "remotion".
voiceover.provider"piper" | "google" | "openai" | "say"Explicit provider. Defaults to the first available in detection order.
voiceover.voicestring(per-provider)Provider-specific voice name (e.g. en-US-Neural2-F, alloy, Samantha).
voiceover.cacheDirstring".pr-visual/tts"Audio cache directory (relative to project root). Content-hash keyed; re-runs with unchanged captions skip synthesis.

Minimal config examples

Next.js (zero-setup):

exportdefault{devServer: {command: "npm run dev"}};

Vite + Docker Postgres:

exportdefault{port: 5173,devServer: {command: "npx vite --port {{port}}"},setup: [{name: "DB",command: "docker compose up -d db",timeout: 30_000},{name: "Migrate",command: "npx prisma migrate deploy"},],teardown: [{name: "DB down",command: "docker compose down -v"},],readiness: {path: "/api/health"},};

i18n site (content at /en):

exportdefault{devServer: {command: "npx next dev --port {{port}}"},readiness: {path: "/en"},routes: [{path: "/en",label: "Homepage"},{path: "/en/about",label: "About"},],};

Monorepo (custom cwd):

exportdefault{devServer: {command: "turbo dev --filter=web",cwd: "apps/web"},setup: [{name: "Build packages",command: "turbo build --filter=web^..."},],};

Quality presets

By default the desktop capture runs at 1440×900 @2x. You can bump this to a named preset to get higher-resolution video and screenshots. The preset sets the logical viewport (CSS pixels); final output dimensions are viewport × deviceScaleFactor (DSF stays at 2 by default).

PresetViewportOutput (DSF=2)
720p1280×7202560×1440
1080p1920×10803840×2160
2k2560×14405120×2880
4k3840×21607680×4320

Mobile capture is not affected by quality presets.

Project-wide default in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},quality: "1080p",}satisfiesProjectConfig;

Per-scenario override (AI-generated or hand-authored scenarios):

{name: "Checkout flow",description: "...",quality: "2k",// preset wins over viewportsteps: [/* ... */],}

Explicit viewport override (when a preset doesn't fit):

{name: "Tablet layout",description: "...",viewport: {width: 1024,height: 768,deviceScaleFactor: 2},steps: [/* ... */],}

One-off env override — useful in CI or for spot checks:

PR_VISUAL_QUALITY=4k npx pr-visual

Precedence (highest wins):

  1. PR_VISUAL_QUALITY env var
  2. scenario.quality
  3. scenario.viewport
  4. projectConfig.quality
  5. Built-in default (1440×900 @2x)

An unknown preset value (env, scenario, or project) fails hard with a clear error.

Adaptive pacing

Each step holds on-screen long enough for viewers to read the caption and absorb the change, scaled by an explicit pacing hint. The hold is computed from the caption's reading time, the action type (first-navigation gets extra breathing room; type scales with value length), a transition cushion when the action changes, and the pacing mode.

Modes (multiplier / floor / cap in ms):

ModeMultiplierFloorCap
quick0.6×9004000
normal(default)1.0×17008000
slow1.5×220010000
dramatic2.0×320012000

dramatic also inserts an 800ms pre-action settle before the step fires, to build anticipation.

Per-step:

{action: "click",selector: "#checkout",caption: "Confirm the order",pacing: "dramatic",// the final beat — let it land}

Project-level reading speed in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},pacing: {wordsPerSecond: 2.8},// slower — for non-native audiences}satisfiesProjectConfig;

Captions of six words or fewer are read proportionally faster (+0.6 w/s) so short beats don't linger.

Narrative beats

Scenarios can tag each step with a beatsetup, action, payoff, or close — to mark where the step sits in the story arc. The annotation layer picks these up:

  • Video: a brief 700ms title-card chip fades in whenever the beat changes between two consecutive steps (so three distinct beats produce two chips).
  • Screenshots: the sidebar shows the beat label under the viewport badge.

Beats also enforce a minimum hold in the pacing formula (setup 1200ms, action 1800ms, payoff 2800ms, close 2200ms), so a payoff step earns scene-length breathing room even under quick pacing.

Emphasis

Each step can also carry emphasis: "strong" to render as a larger title-card caption (1.5× the base caption font, bolder weight). Use it on the key moments you want viewers to remember — usually a payoff beat.

{action: "screenshot",caption: "The deal is closed",beat: "payoff",emphasis: "strong",pacing: "dramatic",}

Persona

Scenarios can carry an audience label via persona: "Agency PM" (any free-form string). This is stored on the scenario for later use by the Remotion intro composer and the Story Director. It does not render directly in the current annotation layer.

{name: "New client onboarding",description: "...",persona: "Agency PM",steps: [/* ... */],}

Invalid beat, emphasis, or pacing values fail the run with a clear error before capture starts.

Interaction overlays

By default, pr-visual captures clean recordings with no cursor or click indicators. If you want your videos to look human-driven, opt into one or more overlays in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},overlays: {cursor: true,// visible custom cursor tracking the mouseclicks: true,// ripple + center dot at each clickhighlights: true,// enables the `highlight` scenario step},}satisfiesProjectConfig;

Each flag is independent; all default to false so existing users see no change.

highlight step

When overlays.highlights: true, scenarios can use a new step action that pulses a glow ring around a selector while dimming the rest of the viewport:

{action: "highlight",selector: "#primary-cta",duration: 1500,// ms; defaults to 1500 when omittedcaption: "The primary call to action",beat: "payoff",}

The highlight runs for duration ms; the scenario's pacing hold starts after cleanup.

Capture-time DOM injection

Overlays are injected into the page during capture (unlike the post-capture sidebar and ASS caption layers), so they appear in the recorded video at the right moment. The trade-off: an active cursor or highlight will be visible in screenshots taken right after a navigate. If you want clean screenshots alongside an overlay-rich video, leave overlays.cursor off.

Mobile viewports automatically use a touch-style cursor and tap-ring animations.

Video production

By default the captioned MP4 is the final video artifact. Opt in to a polished Remotion composition (animated intro, crossfades, glassmorphism caption pill, outro with step summary) per scenario or project-wide:

exportdefault{devServer: {command: "npm run dev"},video: {compositing: "remotion",brandColor: "#3b82f6",category: "Checkout",sprintLabel: "Sprint 12",orgName: "Acme Co",highlights: ["Faster checkout","Cleaner cart"],},}satisfiesProjectConfig;

Optional peer dependencies

The Remotion stack is intentionally not a baseline dependency — npm i pr-visual stays small for users who only need captioned recordings. Install the peer deps when you want compositing:

npm i -D remotion @remotion/bundler @remotion/renderer react react-dom

If video.compositing: "remotion" is set but the peer deps aren't installed, pr-visual prints a clear warning and falls back to the captioned MP4. The run still succeeds.

What gets composited

  • Compositing runs on the desktop + light variant only. Mobile composite layouts arrive in #6; the other three variants stay raw.
  • Output is written next to the captioned MP4 as <scenario>-composited.mp4 (H.264, CRF 16).
  • When a composited video exists, the PR comment uses it for the desktop+light slot; other variants keep the captioned MP4.

Adaptive intro/outro length

Intro and outro durations scale with the title + description word count and the number of annotated steps (reading speed 3 w/s), clamped to sensible bounds (intro 3-8s, outro 4-12s).

Mobile composite layouts

Set video.mobile.enabled: true to run a dedicated mobile pass after the main matrix and composite both streams into one MP4. Setting mobile.enabled also implies compositing: "remotion" so a single flag covers the common case.

exportdefault{devServer: {command: "npm run dev"},video: {mobile: {enabled: true,layout: "side-by-side"},},}satisfiesProjectConfig;

Layouts

  • side-by-side (default): desktop 80% + phone 20% in a stylized device frame. The canvas widens by 25% to fit both columns at near-native size.
  • pip: phone bottom-right over fullscreen desktop. Canvas dimensions unchanged.
  • sequential: desktop for the first half of the recording, phone for the second. Canvas dimensions unchanged.

Per-step mobile overrides

Scenarios can tweak the mobile pass without forking the script:

{action: "navigate",url: "/",mobilePath: "/m",caption: "Open"}{action: "click",selector: "#desktop-cta",mobileSelector: "#mobile-cta",caption: "Tap CTA"}{action: "highlight",selector: "#desktop-only",mobileSkip: true,caption: "Hover hint"}
  • mobilePath: rewrites the navigate URL on mobile.
  • mobileSelector: swaps the selector on mobile.
  • mobileSkip: omits the step from the mobile pass entirely.

Wall-clock cost

The mobile pass is sequential (separate browser context, fresh navigation), so runs with mobile compositing take ~1.8x the wall-clock of desktop-only runs. The pipeline prints a heads-up when mobile compositing fires.

If the mobile pass throws (selector missing, navigation fails), the compositing step aborts and the captioned MP4 stays as the final artifact — the run otherwise succeeds.

Authenticated demos

pr-visual is framework-agnostic about auth: you supply Playwright storage state JSON files, name them as profiles in .pr-visual.config.ts, and scenarios opt in via scenario.profile. The captured matrix variants and the mobile composite pass all load the same storage state.

exportdefault{devServer: {command: "npm run dev"},auth: {storageStateDir: ".pr-visual/auth",// defaultprofiles: {admin: "admin.json",viewer: "viewer.json",},// Optional — runs after `setup` and before `devServer`. Use it to// refresh storage state per run; pr-visual just calls the command.tokenGenerator: {name: "Refresh storage state",command: "node scripts/refresh-auth.mjs",},},}satisfiesProjectConfig;
// In your scenario:{name: "Admin dashboard tour",description: "...",profile: "admin",steps: [/* ... */],}

npx pr-visual init adds .pr-visual/auth/ to .gitignore automatically — storage state files contain session tokens.

Generating storage state

How you produce the JSON files is up to you. Two common patterns:

Pattern 1: Playwright login script

Run a one-off Playwright script that drives the login UI and saves the context state:

// scripts/refresh-auth.mjsimport{chromium}from"playwright";constbrowser=awaitchromium.launch();constctx=awaitbrowser.newContext();constpage=awaitctx.newPage();awaitpage.goto("http://localhost:3000/login");awaitpage.getByLabel("Email").fill("admin@example.com");awaitpage.getByLabel("Password").fill(process.env.ADMIN_PASSWORD);awaitpage.getByRole("button",{name: "Sign in"}).click();awaitpage.waitForURL("**/dashboard");awaitctx.storageState({path: ".pr-visual/auth/admin.json"});awaitbrowser.close();

Pattern 2: Supabase admin API (no browser needed)

// scripts/refresh-auth.mjsimportfsfrom"node:fs";import{createClient}from"@supabase/supabase-js";constsupabase=createClient(process.env.SUPABASE_URL,process.env.SUPABASE_SERVICE_ROLE_KEY,);const{ data, error }=awaitsupabase.auth.admin.generateLink({type: "magiclink",email: "admin@example.com",});if(error)throwerror;// Build a Playwright storage-state JSON with the supabase localStorage entry,// keyed `sb-<project-ref>-auth-token`. Shape per Supabase JS docs.constsession={access_token: data.properties.action_link,/* ... */};constprojectRef=newURL(process.env.SUPABASE_URL).hostname.split(".")[0];fs.writeFileSync(".pr-visual/auth/admin.json",JSON.stringify({cookies: [],origins: [{origin: "http://localhost:3000",localStorage: [{name: `sb-${projectRef}-auth-token`,value: JSON.stringify({currentSession: session}),}],}],}),);

Anything that writes a Playwright storage-state JSON works. The tokenGenerator step is templated with {{runId}}, {{port}}, {{rootDir}} like other lifecycle steps.

Validation

After the generator runs, pr-visual verifies every configured profile points at a readable JSON file. A missing or malformed file fails the run before capture starts, so a silently-broken generator surfaces immediately.

The PR_VISUAL_AUTH_DIR env var overrides storageStateDir — useful when storage state is generated outside the repo.

Page Object Models

Non-trivial real-world demos often need multi-step orchestration (dismiss a modal, wait for data, assert an intermediate state). Rather than duplicating that logic in every scenario, point pr-visual at your existing E2E Page Object Model modules:

// .pr-visual.config.tsexportdefault{devServer: {command: "npm run dev"},poms: {dashboard: "./e2e/pages/dashboard.ts",checkout: "./e2e/pages/checkout.ts",},}satisfiesProjectConfig;
// ./e2e/pages/dashboard.ts — pr-visual expects plain functions.// Each function receives the Playwright `Page` as the first argument// plus any user arguments defined on the scenario step.importtype{Page}from"playwright";exportasyncfunctionlogin(page: Page,email: string): Promise<void>{awaitpage.getByLabel("Email").fill(email);awaitpage.getByLabel("Password").fill(process.env.DEMO_PASSWORD!);awaitpage.getByRole("button",{name: "Sign in"}).click();awaitpage.waitForURL("**/dashboard");}exportasyncfunctionopenInbox(page: Page): Promise<void>{awaitpage.getByRole("link",{name: "Inbox"}).click();awaitpage.waitForSelector("[data-testid=inbox-list]");}

Then use them in scenarios:

{name: "Inbox tour",description: "...",steps: [{action: "navigate",url: "/",caption: "Open the app"},{action: "pom",page: "dashboard",method: "login",args: ["demo@example.com"],caption: "Sign in",},{action: "pom",page: "dashboard",method: "openInbox",caption: "Open the inbox",},{action: "screenshot",caption: "Inbox view"},],}

Contract

  • Each registered module exports named functions shaped as (page: Page, ...args: unknown[]) => void | Promise<void>.
  • Classes are not supported directly (predictable stateless lifecycle). Wrap them with a thin factory if you need class-based POMs.
  • args on the scenario step is an array forwarded positionally after page. Omitted args means the function is called with just (page).

Validation

pr-visual loads POM modules eagerly at scenario-validation time, so unknown page names, unknown method names, and import failures surface as pre-capture errors instead of runtime crashes deep in the capture loop.

Overlay interaction

  • Custom cursor tracking (when overlays.cursor: true) works inside POM methods automatically — the mousemove listener follows any Playwright-driven movement.
  • Click ripples and highlight spotlights do not fire inside POM methods. Those overlays are injected at the call site in pr-visual's step executor, not globally. If you want a ripple on a POM-internal click, add an explicit click step for that interaction instead.

Voice-over

Step captions become narration in the composited MP4. Each caption is synthesized to an MP3 and mixed into the Remotion composition, anchored to the start of that step on the video timeline. Setting voiceover.enabled: true also implies compositing: "remotion".

exportdefault{devServer: {command: "npm run dev"},voiceover: {enabled: true,// Leave `provider` / `voice` out to auto-detect the first available.},}satisfiesProjectConfig;

Provider chain

Detection order (first available wins — the MP4 uses one provider throughout):

  1. Piper — local neural TTS, offline, no account. Requires piper on PATH and a voice model. Point PIPER_MODEL at an .onnx file, or drop one into ~/.cache/piper/voices/.
  2. Google Cloud TTS — OAuth via gcloud. Requires gcloud auth application-default login. Default voice en-US-Neural2-F.
  3. OpenAI TTSOPENAI_API_KEY env var. Default voice alloy.
  4. macOS say — always available on macOS. Default voice Samantha.

Override via voiceover.provider; that provider is then used regardless of detection order. Per-clip synthesis failures log a warning and skip that step — the rest of the MP4 still narrates.

If no provider is available, the run fails with an explicit error listing the install options.

Caching

Clips are cached at .pr-visual/tts/step-NN-<hash>.mp3, keyed by sha256(provider + caption text). Re-running a scenario with unchanged captions is essentially free. Switching provider invalidates the cache for that step (the hash changes).

npx pr-visual init adds .pr-visual/tts/ to .gitignore automatically.

ffmpeg

Piper and say emit WAV/AIFF and use ffmpeg / ffprobe to transcode to MP3 and measure duration. pr-visual already expects ffmpeg for subtitle burning, so there's no new prerequisite.

Story Director

When ANTHROPIC_API_KEY (or CLAUDE_PLUGIN_OPTION_ANTHROPIC_API_KEY) is set, AI scenario generation runs through the Story Director instead of emitting flat step lists. The director picks one of four personas (End User, Admin, New User, Stakeholder) based on the PR content and drafts a three-act narrative arc:

Persona: End User
Setup: A user opens the dashboard expecting today's metrics.
Inciting: They notice a new tile they have never seen before.
Payoff: Clicking the tile reveals a clearer breakdown of the data.
Closing: Users now answer the question without leaving the dashboard.

Each generated scenario carries the matching persona and every step arrives pre-populated with the right beat (setup / action / payoff / close) and emphasis. The annotation layers from Narrative beats and Adaptive pacing then take over.

When no API key is set, the run falls back to the static-routes scenarios (unchanged from before).

Brief cache

The director caches each brief by sha256(prDescription + diff) to .pr-visual/story/<hash>.json. Re-running on an unchanged PR is free. init adds .pr-visual/story/ to .gitignore.

story subcommand

Inspect the brief without recording, or scaffold it to disk:

# Print the human-readable arc for the current branch's PR.
npx pr-visual story
# Same, against an explicit PR number.
npx pr-visual story --pr 42
# Machine-readable JSON to stdout.
npx pr-visual story --pr 42 --json
# Write the full {narrative, scenarios} brief to disk for editing.# Output: .pr-visual/story-scaffold.json
npx pr-visual story --scaffold

The scaffold path is convenient for tweaking the arc by hand before re-running pr-visual — load the JSON yourself and pass it as a hand-authored scenario set.

CLI commands

npx pr-visual [command]
CommandDescription
run (default)Execute the full capture pipeline
initDetect project setup and generate .pr-visual.config.ts
cleanupRemove orphaned worktrees, Docker projects, and stale directories
storyPrint or scaffold the Story Director's brief without recording. Flags: --pr <n>, --scaffold, --json.

Cleanup

If a run is interrupted (Ctrl+C, crash, killed terminal), resources may be left behind. The cleanup command finds and removes them:

npx pr-visual cleanup

This removes:

  • Orphaned git worktrees (pr-visual-* branches and directories)
  • Orphaned Docker Compose projects (containers, networks, volumes named pr-visual-*)
  • Stale worktree parent directories

The recorder also registers signal handlers for SIGINT and SIGTERM, so a normal Ctrl+C during a run will attempt to tear down services and remove the worktree before exiting.

Environment variables

VariableDefaultDescription
ANTHROPIC_API_KEYEnables AI-generated scenarios (falls back to static)
PR_BODYOverride PR body text for scenario generation
PR_VISUAL_CONFIGExplicit path to config file
PR_VISUAL_NO_ISOLATESet to 1 to skip worktree isolation
PR_VISUAL_QUALITYDesktop quality preset override: 720p, 1080p, 2k, 4k. Takes precedence over scenario and project config.
PR_VISUAL_AUTH_DIROverride auth.storageStateDir. Useful when storage state is generated outside the repo.

How it works

Pipeline

┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐
│ Worktree │───▶│ Setup │───▶│ Dev Server │───▶│ Readiness │
│ + install │ │ steps │ │ start │ │ probe │
└─────────────┘ └──────────┘ └───────────┘ └──────┬───────┘
│
┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌─────▼────────┐
│ PR attach │◀───│ Annotate │◀───│ Capture │◀───│ Scenarios │
│ + cleanup │ │ + video │ │ all vars │ │ (AI / diff) │
└─────────────┘ └──────────┘ └───────────┘ └──────────────┘

Isolation model

Each run creates a git worktree at the current commit:

  • Directory: ../.pr-visual-worktrees/pr-visual-<timestamp>-<hex> (outside repo)
  • Branch: pr-visual/pr-visual-<timestamp>-<hex> (temporary)
  • Port: auto-allocated from preferred port upward (scans 100 ports)
  • Docker: COMPOSE_PROJECT_NAME=pr-visual-<timestamp>-<hex> namespaces all resources
  • Dependencies: full install from lockfile in the worktree

Multiple parallel runs get different worktrees, ports, and Docker project names — complete isolation.

Lifecycle

  1. Setup steps — sequential shell commands with per-step timeouts
  2. Dev server — spawned as a detached process group
  3. Readiness probe — polls endpoint until expected status or timeout
  4. Teardown — runs cleanup commands; errors are logged but don't abort

Cleanup guarantees

  • Signal handlers (SIGINT/SIGTERM) run teardown and worktree removal on interrupt
  • Explicit cleanup in finally block for normal completion or exceptions
  • npx pr-visual cleanup as a manual recovery for hard crashes

Troubleshooting

Skills not appearing after install

Run /reload-plugins to refresh the plugin list.

ffmpeg captioning fails

The video captioning feature requires ffmpeg with either libass or subtitles filter support. If neither is available, the plugin gracefully skips captioning and returns the raw video.

To get full captioning support:

brew install ffmpeg

Screenshots show wrong page (i18n sites)

If your site redirects / to a locale path (e.g. /en), configure the routes field in your .pr-visual.config.ts:

exportdefault{readiness: {path: "/en"},routes: [{path: "/en",label: "Homepage"}],};

next: command not found in worktree

Use npx in your dev server command to resolve binaries from node_modules:

exportdefault{devServer: {command: "npx next dev --port {{port}}"},};

Project structure

.claude-plugin/
plugin.json # Plugin manifest
marketplace.json # Plugin marketplace definition
skills/pr-visual/
SKILL.md # Slash command definition
hooks/
hooks.json # PostToolUse hook for gh pr create
bin/
pr-visual # CLI entrypoint
scripts/pr-visual/
index.ts # CLI routing (run | init | cleanup)
types.ts # Shared types (ViewportConfig, ProjectConfig, RunContext, etc.)
config.ts # Config discovery, loading, template substitution
worktree.ts # Git worktree creation, port allocation, cleanup
lifecycle.ts # Setup/teardown steps, dev server, readiness, signal handlers
init.ts # Project detection and config scaffolding
cleanup.ts # Orphaned resource discovery and removal
scenario-generator.ts # Claude API integration for scenario generation
capture.ts # Playwright capture across viewports and color schemes
pr-attach.ts # GitHub PR body patching and comment posting
annotate/
screenshots.ts # sharp + SVG sidebar compositing → WebP
video.ts # ffmpeg ASS caption burning → H.264 MP4

Development

npm ci # install deps + Playwright chromium
npm run typecheck # tsc --noEmit
npm run lint # Biome (lint + format check, fails on warnings)
npm run lint:fix # Biome auto-fix (safe rules) + write
npm run format # Biome format only — write
npm test# vitest run (unit + integration + e2e)

CI runs typecheck, lint, and the full test suite on every PR and on push to master (Node 20). All warnings are treated as errors.

Releases

Releases are created by .github/workflows/release.yml, which runs after the CI workflow finishes successfully on master. A red CI run blocks the release.

A release is cut only when package.jsonversion is bumped above the latest v* git tag. Merging a PR that does not change version does not produce a release — this lets you land refactors, docs, and chore work between shipments.

To cut a release, open a PR that:

  • bumps version in package.json (patch / minor / major as appropriate);
  • bumps version in .claude-plugin/plugin.json to the same value;
  • bumps both version fields in .claude-plugin/marketplace.json to the same value;
  • adds a CHANGELOG.md entry describing the release.

When the PR lands on master and CI passes, the workflow tags the commit v<version> and publishes a GitHub Release with auto-generated notes (merged PRs and commits since the previous tag). The workflow itself never writes to the repository.

If CI passed but the release did not fire (e.g., a transient failure), use the workflow's workflow_dispatch trigger from the Actions tab to re-run it.

License

MIT — see LICENSE for details.

About

Claude Code plugin for visual PR documentation — annotated screenshots and walkthrough videos

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

pr-visual

A Claude Code plugin that captures visual PR documentation: AI-generated Playwright scenarios from the PR description or git diff, annotated screenshots (desktop 2x + mobile 3x, light + dark), and walkthrough videos with burned-in captions.

Each run is isolated in a git worktree with its own port and namespaced resources (Docker containers, networks, volumes), so multiple runs can execute in parallel without collisions.

Prerequisites

  • Node.js 20+
  • ffmpegbrew install ffmpeg (optional — needed for video captions and voice-over transcoding)
  • GitHub CLIbrew install gh
  • Chromium (installed automatically via Playwright on postinstall)

Installation

From Claude Code marketplace (recommended)

/plugin marketplace add gerokeller/pr-visual

Then install the plugin:

/plugin install pr-visual

To share with your team, add this to your project's .claude/settings.json:

{
"extraKnownMarketplaces": {
"pr-visual": {
"source": {
"source": "github",
"repo": "gerokeller/pr-visual"
}
}
},
"enabledPlugins": {
"pr-visual@pr-visual": true
}
}

Via npm

npm install -D pr-visual

Claude Code automatically discovers the plugin via .claude-plugin/plugin.json inside node_modules/pr-visual/. This gives you:

  • /pr-visual slash command
  • PostToolUse hook that reminds you to run it after gh pr create

Quick start

1. Scaffold the config

/pr-visual init

Or via CLI:

npx pr-visual init

This detects your project setup and generates a tailored .pr-visual.config.ts:

pr-visual init: Detecting project setup...
Framework: Next.js
Package manager: pnpm
Docker: yes (postgres, redis)
ORM: prisma
Health endpoint: /api/health
Default port: 3000
Created: .pr-visual.config.ts

2. Review and commit the config

The generated config is ready to use but worth reviewing. Commit it so every team member gets the same behavior.

3. Run it

/pr-visual

Or manually:

npx pr-visual

Configuration

Plugin settings

The plugin accepts the following user configuration (set during plugin install or in settings):

SettingDescription
anthropic_api_keyAPI key for AI-generated scenarios (stored in system keychain). Falls back to ANTHROPIC_API_KEY env var, then to static route capture.

Project config

.pr-visual.config.ts is the contract between your project and the recorder. It declares everything needed to bring up the application from a cold worktree:

importtype{ProjectConfig}from"pr-visual/scripts/pr-visual/types.js";exportdefault{port: 3000,devServer: {command: "npm run dev",env: {PORT: "{{port}}"},},// Setup steps — Docker resources are auto-scoped via COMPOSE_PROJECT_NAMEsetup: [{name: "Start database",command: "docker compose up -d postgres redis"},{name: "Run migrations",command: "npx prisma migrate deploy"},{name: "Seed data",command: "npx prisma db seed"},],readiness: {path: "/api/health",status: 200,timeout: 60_000,},// Teardown — only this run's containers are removedteardown: [{name: "Stop database",command: "docker compose down -v"},],isolate: true,installCommand: "npm ci",}satisfiesProjectConfig;

Template variables

All command strings and env values support these placeholders:

VariableDescription
{{port}}Auto-allocated TCP port for this run
{{runId}}Unique run identifier — safe as Docker project name, DB suffix, directory name
{{rootDir}}Absolute path to the working directory (worktree or project root)

Automatic resource isolation

Every lifecycle step and the dev server receive these environment variables automatically — no manual setup needed:

VariableValuePurpose
COMPOSE_PROJECT_NAME{{runId}}Scopes all Docker Compose containers, networks, and volumes to this run
PORTAllocated portStandard port variable
PR_VISUAL_RUN_ID{{runId}}Available for custom scripts
PR_VISUAL_PORTAllocated portAvailable for custom scripts
PR_VISUAL_ROOT_DIR{{rootDir}}Available for custom scripts

This means docker compose up -d postgres in two parallel runs creates two independent Postgres containers, and each run's docker compose down -v only removes its own.

Config reference

FieldTypeDefaultDescription
portnumber3000Preferred port (auto-incremented if busy)
baseUrlstringhttp://localhost:{{port}}URL template
devServer.commandstringnpm run devDev server command
devServer.envRecordExtra env vars (template substitution)
setupLifecycleStep[]Pre-server steps (Docker, migrations, seeds)
readiness.pathstring/Readiness probe endpoint
readiness.statusnumber200Expected HTTP status
readiness.timeoutnumber45000Max wait time in ms
readiness.intervalnumber1000Probe interval in ms
teardownLifecycleStep[]Post-capture cleanup steps
isolatebooleantrueUse git worktree for isolation
worktreeDirstring../.pr-visual-worktreesWhere to create worktrees
installCommandstringnpm ciInstall command for worktrees
outputDirstring.pr-visualOutput directory (relative to root)
routesArray<string | { path, label }>["/"]Routes for static fallback capture
quality"720p" | "1080p" | "2k" | "4k"Desktop quality preset (see Quality presets)
pacing.wordsPerSecondnumber3.2Reading speed used by adaptive pacing
overlays.cursorbooleanfalseInject a visible custom cursor during capture (see Interaction overlays)
overlays.clicksbooleanfalseEmit a ripple + center dot at each click's coordinates
overlays.highlightsbooleanfalseEnable the highlight scenario step action (pulsing glow + dimmed backdrop)
video.compositing"none" | "remotion""none"Run the recorded clip through a Remotion composition (see Video production)
video.brandColorstring"#3b82f6"Brand accent color for intro/outro/caption-pill chrome
video.categorystringOptional category label rendered as a glassmorphism badge
video.sprintLabelstringOptional sprint / release label rendered subtly in the intro
video.orgNamestringOptional org name rendered in the outro footer
video.highlightsstring[]Optional bullets rendered as a "Key Highlights" card in the outro
video.mobile.enabledbooleanfalseRun a dedicated mobile composite pass after the main matrix and composite both streams (see Mobile composite layouts). Implies compositing: "remotion".
video.mobile.viewport{ width, height }{ 390, 844 }Mobile pass viewport
video.mobile.deviceScaleFactornumber3Mobile pass DPR
video.mobile.layout"side-by-side" | "pip" | "sequential""side-by-side"Composition layout
auth.storageStateDirstring".pr-visual/auth"Directory holding Playwright storage state files (see Authenticated demos)
auth.profilesRecord<string, string>Named profiles → relative storage state file paths
auth.tokenGeneratorLifecycleStepOptional command run after setup and before devServer to refresh storage state
pomsRecord<string, string>Page Object Model registry. Keys are referenced from pom scenario steps (see Page Object Models). Values are module paths relative to the project root.
voiceover.enabledbooleanfalseSynthesize per-step audio and mix it into the composited MP4 (see Voice-over). Implies compositing: "remotion".
voiceover.provider"piper" | "google" | "openai" | "say"Explicit provider. Defaults to the first available in detection order.
voiceover.voicestring(per-provider)Provider-specific voice name (e.g. en-US-Neural2-F, alloy, Samantha).
voiceover.cacheDirstring".pr-visual/tts"Audio cache directory (relative to project root). Content-hash keyed; re-runs with unchanged captions skip synthesis.

Minimal config examples

Next.js (zero-setup):

exportdefault{devServer: {command: "npm run dev"}};

Vite + Docker Postgres:

exportdefault{port: 5173,devServer: {command: "npx vite --port {{port}}"},setup: [{name: "DB",command: "docker compose up -d db",timeout: 30_000},{name: "Migrate",command: "npx prisma migrate deploy"},],teardown: [{name: "DB down",command: "docker compose down -v"},],readiness: {path: "/api/health"},};

i18n site (content at /en):

exportdefault{devServer: {command: "npx next dev --port {{port}}"},readiness: {path: "/en"},routes: [{path: "/en",label: "Homepage"},{path: "/en/about",label: "About"},],};

Monorepo (custom cwd):

exportdefault{devServer: {command: "turbo dev --filter=web",cwd: "apps/web"},setup: [{name: "Build packages",command: "turbo build --filter=web^..."},],};

Quality presets

By default the desktop capture runs at 1440×900 @2x. You can bump this to a named preset to get higher-resolution video and screenshots. The preset sets the logical viewport (CSS pixels); final output dimensions are viewport × deviceScaleFactor (DSF stays at 2 by default).

PresetViewportOutput (DSF=2)
720p1280×7202560×1440
1080p1920×10803840×2160
2k2560×14405120×2880
4k3840×21607680×4320

Mobile capture is not affected by quality presets.

Project-wide default in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},quality: "1080p",}satisfiesProjectConfig;

Per-scenario override (AI-generated or hand-authored scenarios):

{name: "Checkout flow",description: "...",quality: "2k",// preset wins over viewportsteps: [/* ... */],}

Explicit viewport override (when a preset doesn't fit):

{name: "Tablet layout",description: "...",viewport: {width: 1024,height: 768,deviceScaleFactor: 2},steps: [/* ... */],}

One-off env override — useful in CI or for spot checks:

PR_VISUAL_QUALITY=4k npx pr-visual

Precedence (highest wins):

  1. PR_VISUAL_QUALITY env var
  2. scenario.quality
  3. scenario.viewport
  4. projectConfig.quality
  5. Built-in default (1440×900 @2x)

An unknown preset value (env, scenario, or project) fails hard with a clear error.

Adaptive pacing

Each step holds on-screen long enough for viewers to read the caption and absorb the change, scaled by an explicit pacing hint. The hold is computed from the caption's reading time, the action type (first-navigation gets extra breathing room; type scales with value length), a transition cushion when the action changes, and the pacing mode.

Modes (multiplier / floor / cap in ms):

ModeMultiplierFloorCap
quick0.6×9004000
normal(default)1.0×17008000
slow1.5×220010000
dramatic2.0×320012000

dramatic also inserts an 800ms pre-action settle before the step fires, to build anticipation.

Per-step:

{action: "click",selector: "#checkout",caption: "Confirm the order",pacing: "dramatic",// the final beat — let it land}

Project-level reading speed in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},pacing: {wordsPerSecond: 2.8},// slower — for non-native audiences}satisfiesProjectConfig;

Captions of six words or fewer are read proportionally faster (+0.6 w/s) so short beats don't linger.

Narrative beats

Scenarios can tag each step with a beatsetup, action, payoff, or close — to mark where the step sits in the story arc. The annotation layer picks these up:

  • Video: a brief 700ms title-card chip fades in whenever the beat changes between two consecutive steps (so three distinct beats produce two chips).
  • Screenshots: the sidebar shows the beat label under the viewport badge.

Beats also enforce a minimum hold in the pacing formula (setup 1200ms, action 1800ms, payoff 2800ms, close 2200ms), so a payoff step earns scene-length breathing room even under quick pacing.

Emphasis

Each step can also carry emphasis: "strong" to render as a larger title-card caption (1.5× the base caption font, bolder weight). Use it on the key moments you want viewers to remember — usually a payoff beat.

{action: "screenshot",caption: "The deal is closed",beat: "payoff",emphasis: "strong",pacing: "dramatic",}

Persona

Scenarios can carry an audience label via persona: "Agency PM" (any free-form string). This is stored on the scenario for later use by the Remotion intro composer and the Story Director. It does not render directly in the current annotation layer.

{name: "New client onboarding",description: "...",persona: "Agency PM",steps: [/* ... */],}

Invalid beat, emphasis, or pacing values fail the run with a clear error before capture starts.

Interaction overlays

By default, pr-visual captures clean recordings with no cursor or click indicators. If you want your videos to look human-driven, opt into one or more overlays in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},overlays: {cursor: true,// visible custom cursor tracking the mouseclicks: true,// ripple + center dot at each clickhighlights: true,// enables the `highlight` scenario step},}satisfiesProjectConfig;

Each flag is independent; all default to false so existing users see no change.

highlight step

When overlays.highlights: true, scenarios can use a new step action that pulses a glow ring around a selector while dimming the rest of the viewport:

{action: "highlight",selector: "#primary-cta",duration: 1500,// ms; defaults to 1500 when omittedcaption: "The primary call to action",beat: "payoff",}

The highlight runs for duration ms; the scenario's pacing hold starts after cleanup.

Capture-time DOM injection

Overlays are injected into the page during capture (unlike the post-capture sidebar and ASS caption layers), so they appear in the recorded video at the right moment. The trade-off: an active cursor or highlight will be visible in screenshots taken right after a navigate. If you want clean screenshots alongside an overlay-rich video, leave overlays.cursor off.

Mobile viewports automatically use a touch-style cursor and tap-ring animations.

Video production

By default the captioned MP4 is the final video artifact. Opt in to a polished Remotion composition (animated intro, crossfades, glassmorphism caption pill, outro with step summary) per scenario or project-wide:

exportdefault{devServer: {command: "npm run dev"},video: {compositing: "remotion",brandColor: "#3b82f6",category: "Checkout",sprintLabel: "Sprint 12",orgName: "Acme Co",highlights: ["Faster checkout","Cleaner cart"],},}satisfiesProjectConfig;

Optional peer dependencies

The Remotion stack is intentionally not a baseline dependency — npm i pr-visual stays small for users who only need captioned recordings. Install the peer deps when you want compositing:

npm i -D remotion @remotion/bundler @remotion/renderer react react-dom

If video.compositing: "remotion" is set but the peer deps aren't installed, pr-visual prints a clear warning and falls back to the captioned MP4. The run still succeeds.

What gets composited

  • Compositing runs on the desktop + light variant only. Mobile composite layouts arrive in #6; the other three variants stay raw.
  • Output is written next to the captioned MP4 as <scenario>-composited.mp4 (H.264, CRF 16).
  • When a composited video exists, the PR comment uses it for the desktop+light slot; other variants keep the captioned MP4.

Adaptive intro/outro length

Intro and outro durations scale with the title + description word count and the number of annotated steps (reading speed 3 w/s), clamped to sensible bounds (intro 3-8s, outro 4-12s).

Mobile composite layouts

Set video.mobile.enabled: true to run a dedicated mobile pass after the main matrix and composite both streams into one MP4. Setting mobile.enabled also implies compositing: "remotion" so a single flag covers the common case.

exportdefault{devServer: {command: "npm run dev"},video: {mobile: {enabled: true,layout: "side-by-side"},},}satisfiesProjectConfig;

Layouts

  • side-by-side (default): desktop 80% + phone 20% in a stylized device frame. The canvas widens by 25% to fit both columns at near-native size.
  • pip: phone bottom-right over fullscreen desktop. Canvas dimensions unchanged.
  • sequential: desktop for the first half of the recording, phone for the second. Canvas dimensions unchanged.

Per-step mobile overrides

Scenarios can tweak the mobile pass without forking the script:

{action: "navigate",url: "/",mobilePath: "/m",caption: "Open"}{action: "click",selector: "#desktop-cta",mobileSelector: "#mobile-cta",caption: "Tap CTA"}{action: "highlight",selector: "#desktop-only",mobileSkip: true,caption: "Hover hint"}
  • mobilePath: rewrites the navigate URL on mobile.
  • mobileSelector: swaps the selector on mobile.
  • mobileSkip: omits the step from the mobile pass entirely.

Wall-clock cost

The mobile pass is sequential (separate browser context, fresh navigation), so runs with mobile compositing take ~1.8x the wall-clock of desktop-only runs. The pipeline prints a heads-up when mobile compositing fires.

If the mobile pass throws (selector missing, navigation fails), the compositing step aborts and the captioned MP4 stays as the final artifact — the run otherwise succeeds.

Authenticated demos

pr-visual is framework-agnostic about auth: you supply Playwright storage state JSON files, name them as profiles in .pr-visual.config.ts, and scenarios opt in via scenario.profile. The captured matrix variants and the mobile composite pass all load the same storage state.

exportdefault{devServer: {command: "npm run dev"},auth: {storageStateDir: ".pr-visual/auth",// defaultprofiles: {admin: "admin.json",viewer: "viewer.json",},// Optional — runs after `setup` and before `devServer`. Use it to// refresh storage state per run; pr-visual just calls the command.tokenGenerator: {name: "Refresh storage state",command: "node scripts/refresh-auth.mjs",},},}satisfiesProjectConfig;
// In your scenario:{name: "Admin dashboard tour",description: "...",profile: "admin",steps: [/* ... */],}

npx pr-visual init adds .pr-visual/auth/ to .gitignore automatically — storage state files contain session tokens.

Generating storage state

How you produce the JSON files is up to you. Two common patterns:

Pattern 1: Playwright login script

Run a one-off Playwright script that drives the login UI and saves the context state:

// scripts/refresh-auth.mjsimport{chromium}from"playwright";constbrowser=awaitchromium.launch();constctx=awaitbrowser.newContext();constpage=awaitctx.newPage();awaitpage.goto("http://localhost:3000/login");awaitpage.getByLabel("Email").fill("admin@example.com");awaitpage.getByLabel("Password").fill(process.env.ADMIN_PASSWORD);awaitpage.getByRole("button",{name: "Sign in"}).click();awaitpage.waitForURL("**/dashboard");awaitctx.storageState({path: ".pr-visual/auth/admin.json"});awaitbrowser.close();

Pattern 2: Supabase admin API (no browser needed)

// scripts/refresh-auth.mjsimportfsfrom"node:fs";import{createClient}from"@supabase/supabase-js";constsupabase=createClient(process.env.SUPABASE_URL,process.env.SUPABASE_SERVICE_ROLE_KEY,);const{ data, error }=awaitsupabase.auth.admin.generateLink({type: "magiclink",email: "admin@example.com",});if(error)throwerror;// Build a Playwright storage-state JSON with the supabase localStorage entry,// keyed `sb-<project-ref>-auth-token`. Shape per Supabase JS docs.constsession={access_token: data.properties.action_link,/* ... */};constprojectRef=newURL(process.env.SUPABASE_URL).hostname.split(".")[0];fs.writeFileSync(".pr-visual/auth/admin.json",JSON.stringify({cookies: [],origins: [{origin: "http://localhost:3000",localStorage: [{name: `sb-${projectRef}-auth-token`,value: JSON.stringify({currentSession: session}),}],}],}),);

Anything that writes a Playwright storage-state JSON works. The tokenGenerator step is templated with {{runId}}, {{port}}, {{rootDir}} like other lifecycle steps.

Validation

After the generator runs, pr-visual verifies every configured profile points at a readable JSON file. A missing or malformed file fails the run before capture starts, so a silently-broken generator surfaces immediately.

The PR_VISUAL_AUTH_DIR env var overrides storageStateDir — useful when storage state is generated outside the repo.

Page Object Models

Non-trivial real-world demos often need multi-step orchestration (dismiss a modal, wait for data, assert an intermediate state). Rather than duplicating that logic in every scenario, point pr-visual at your existing E2E Page Object Model modules:

// .pr-visual.config.tsexportdefault{devServer: {command: "npm run dev"},poms: {dashboard: "./e2e/pages/dashboard.ts",checkout: "./e2e/pages/checkout.ts",},}satisfiesProjectConfig;
// ./e2e/pages/dashboard.ts — pr-visual expects plain functions.// Each function receives the Playwright `Page` as the first argument// plus any user arguments defined on the scenario step.importtype{Page}from"playwright";exportasyncfunctionlogin(page: Page,email: string): Promise<void>{awaitpage.getByLabel("Email").fill(email);awaitpage.getByLabel("Password").fill(process.env.DEMO_PASSWORD!);awaitpage.getByRole("button",{name: "Sign in"}).click();awaitpage.waitForURL("**/dashboard");}exportasyncfunctionopenInbox(page: Page): Promise<void>{awaitpage.getByRole("link",{name: "Inbox"}).click();awaitpage.waitForSelector("[data-testid=inbox-list]");}

Then use them in scenarios:

{name: "Inbox tour",description: "...",steps: [{action: "navigate",url: "/",caption: "Open the app"},{action: "pom",page: "dashboard",method: "login",args: ["demo@example.com"],caption: "Sign in",},{action: "pom",page: "dashboard",method: "openInbox",caption: "Open the inbox",},{action: "screenshot",caption: "Inbox view"},],}

Contract

  • Each registered module exports named functions shaped as (page: Page, ...args: unknown[]) => void | Promise<void>.
  • Classes are not supported directly (predictable stateless lifecycle). Wrap them with a thin factory if you need class-based POMs.
  • args on the scenario step is an array forwarded positionally after page. Omitted args means the function is called with just (page).

Validation

pr-visual loads POM modules eagerly at scenario-validation time, so unknown page names, unknown method names, and import failures surface as pre-capture errors instead of runtime crashes deep in the capture loop.

Overlay interaction

  • Custom cursor tracking (when overlays.cursor: true) works inside POM methods automatically — the mousemove listener follows any Playwright-driven movement.
  • Click ripples and highlight spotlights do not fire inside POM methods. Those overlays are injected at the call site in pr-visual's step executor, not globally. If you want a ripple on a POM-internal click, add an explicit click step for that interaction instead.

Voice-over

Step captions become narration in the composited MP4. Each caption is synthesized to an MP3 and mixed into the Remotion composition, anchored to the start of that step on the video timeline. Setting voiceover.enabled: true also implies compositing: "remotion".

exportdefault{devServer: {command: "npm run dev"},voiceover: {enabled: true,// Leave `provider` / `voice` out to auto-detect the first available.},}satisfiesProjectConfig;

Provider chain

Detection order (first available wins — the MP4 uses one provider throughout):

  1. Piper — local neural TTS, offline, no account. Requires piper on PATH and a voice model. Point PIPER_MODEL at an .onnx file, or drop one into ~/.cache/piper/voices/.
  2. Google Cloud TTS — OAuth via gcloud. Requires gcloud auth application-default login. Default voice en-US-Neural2-F.
  3. OpenAI TTSOPENAI_API_KEY env var. Default voice alloy.
  4. macOS say — always available on macOS. Default voice Samantha.

Override via voiceover.provider; that provider is then used regardless of detection order. Per-clip synthesis failures log a warning and skip that step — the rest of the MP4 still narrates.

If no provider is available, the run fails with an explicit error listing the install options.

Caching

Clips are cached at .pr-visual/tts/step-NN-<hash>.mp3, keyed by sha256(provider + caption text). Re-running a scenario with unchanged captions is essentially free. Switching provider invalidates the cache for that step (the hash changes).

npx pr-visual init adds .pr-visual/tts/ to .gitignore automatically.

ffmpeg

Piper and say emit WAV/AIFF and use ffmpeg / ffprobe to transcode to MP3 and measure duration. pr-visual already expects ffmpeg for subtitle burning, so there's no new prerequisite.

Story Director

When ANTHROPIC_API_KEY (or CLAUDE_PLUGIN_OPTION_ANTHROPIC_API_KEY) is set, AI scenario generation runs through the Story Director instead of emitting flat step lists. The director picks one of four personas (End User, Admin, New User, Stakeholder) based on the PR content and drafts a three-act narrative arc:

Persona: End User
Setup: A user opens the dashboard expecting today's metrics.
Inciting: They notice a new tile they have never seen before.
Payoff: Clicking the tile reveals a clearer breakdown of the data.
Closing: Users now answer the question without leaving the dashboard.

Each generated scenario carries the matching persona and every step arrives pre-populated with the right beat (setup / action / payoff / close) and emphasis. The annotation layers from Narrative beats and Adaptive pacing then take over.

When no API key is set, the run falls back to the static-routes scenarios (unchanged from before).

Brief cache

The director caches each brief by sha256(prDescription + diff) to .pr-visual/story/<hash>.json. Re-running on an unchanged PR is free. init adds .pr-visual/story/ to .gitignore.

story subcommand

Inspect the brief without recording, or scaffold it to disk:

# Print the human-readable arc for the current branch's PR.
npx pr-visual story
# Same, against an explicit PR number.
npx pr-visual story --pr 42
# Machine-readable JSON to stdout.
npx pr-visual story --pr 42 --json
# Write the full {narrative, scenarios} brief to disk for editing.# Output: .pr-visual/story-scaffold.json
npx pr-visual story --scaffold

The scaffold path is convenient for tweaking the arc by hand before re-running pr-visual — load the JSON yourself and pass it as a hand-authored scenario set.

CLI commands

npx pr-visual [command]
CommandDescription
run (default)Execute the full capture pipeline
initDetect project setup and generate .pr-visual.config.ts
cleanupRemove orphaned worktrees, Docker projects, and stale directories
storyPrint or scaffold the Story Director's brief without recording. Flags: --pr <n>, --scaffold, --json.

Cleanup

If a run is interrupted (Ctrl+C, crash, killed terminal), resources may be left behind. The cleanup command finds and removes them:

npx pr-visual cleanup

This removes:

  • Orphaned git worktrees (pr-visual-* branches and directories)
  • Orphaned Docker Compose projects (containers, networks, volumes named pr-visual-*)
  • Stale worktree parent directories

The recorder also registers signal handlers for SIGINT and SIGTERM, so a normal Ctrl+C during a run will attempt to tear down services and remove the worktree before exiting.

Environment variables

VariableDefaultDescription
ANTHROPIC_API_KEYEnables AI-generated scenarios (falls back to static)
PR_BODYOverride PR body text for scenario generation
PR_VISUAL_CONFIGExplicit path to config file
PR_VISUAL_NO_ISOLATESet to 1 to skip worktree isolation
PR_VISUAL_QUALITYDesktop quality preset override: 720p, 1080p, 2k, 4k. Takes precedence over scenario and project config.
PR_VISUAL_AUTH_DIROverride auth.storageStateDir. Useful when storage state is generated outside the repo.

How it works

Pipeline

┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐
│ Worktree │───▶│ Setup │───▶│ Dev Server │───▶│ Readiness │
│ + install │ │ steps │ │ start │ │ probe │
└─────────────┘ └──────────┘ └───────────┘ └──────┬───────┘
│
┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌─────▼────────┐
│ PR attach │◀───│ Annotate │◀───│ Capture │◀───│ Scenarios │
│ + cleanup │ │ + video │ │ all vars │ │ (AI / diff) │
└─────────────┘ └──────────┘ └───────────┘ └──────────────┘

Isolation model

Each run creates a git worktree at the current commit:

  • Directory: ../.pr-visual-worktrees/pr-visual-<timestamp>-<hex> (outside repo)
  • Branch: pr-visual/pr-visual-<timestamp>-<hex> (temporary)
  • Port: auto-allocated from preferred port upward (scans 100 ports)
  • Docker: COMPOSE_PROJECT_NAME=pr-visual-<timestamp>-<hex> namespaces all resources
  • Dependencies: full install from lockfile in the worktree

Multiple parallel runs get different worktrees, ports, and Docker project names — complete isolation.

Lifecycle

  1. Setup steps — sequential shell commands with per-step timeouts
  2. Dev server — spawned as a detached process group
  3. Readiness probe — polls endpoint until expected status or timeout
  4. Teardown — runs cleanup commands; errors are logged but don't abort

Cleanup guarantees

  • Signal handlers (SIGINT/SIGTERM) run teardown and worktree removal on interrupt
  • Explicit cleanup in finally block for normal completion or exceptions
  • npx pr-visual cleanup as a manual recovery for hard crashes

Troubleshooting

Skills not appearing after install

Run /reload-plugins to refresh the plugin list.

ffmpeg captioning fails

The video captioning feature requires ffmpeg with either libass or subtitles filter support. If neither is available, the plugin gracefully skips captioning and returns the raw video.

To get full captioning support:

brew install ffmpeg

Screenshots show wrong page (i18n sites)

If your site redirects / to a locale path (e.g. /en), configure the routes field in your .pr-visual.config.ts:

exportdefault{readiness: {path: "/en"},routes: [{path: "/en",label: "Homepage"}],};

next: command not found in worktree

Use npx in your dev server command to resolve binaries from node_modules:

exportdefault{devServer: {command: "npx next dev --port {{port}}"},};

Project structure

.claude-plugin/
plugin.json # Plugin manifest
marketplace.json # Plugin marketplace definition
skills/pr-visual/
SKILL.md # Slash command definition
hooks/
hooks.json # PostToolUse hook for gh pr create
bin/
pr-visual # CLI entrypoint
scripts/pr-visual/
index.ts # CLI routing (run | init | cleanup)
types.ts # Shared types (ViewportConfig, ProjectConfig, RunContext, etc.)
config.ts # Config discovery, loading, template substitution
worktree.ts # Git worktree creation, port allocation, cleanup
lifecycle.ts # Setup/teardown steps, dev server, readiness, signal handlers
init.ts # Project detection and config scaffolding
cleanup.ts # Orphaned resource discovery and removal
scenario-generator.ts # Claude API integration for scenario generation
capture.ts # Playwright capture across viewports and color schemes
pr-attach.ts # GitHub PR body patching and comment posting
annotate/
screenshots.ts # sharp + SVG sidebar compositing → WebP
video.ts # ffmpeg ASS caption burning → H.264 MP4

Development

npm ci # install deps + Playwright chromium
npm run typecheck # tsc --noEmit
npm run lint # Biome (lint + format check, fails on warnings)
npm run lint:fix # Biome auto-fix (safe rules) + write
npm run format # Biome format only — write
npm test# vitest run (unit + integration + e2e)

CI runs typecheck, lint, and the full test suite on every PR and on push to master (Node 20). All warnings are treated as errors.

Releases

Releases are created by .github/workflows/release.yml, which runs after the CI workflow finishes successfully on master. A red CI run blocks the release.

A release is cut only when package.jsonversion is bumped above the latest v* git tag. Merging a PR that does not change version does not produce a release — this lets you land refactors, docs, and chore work between shipments.

To cut a release, open a PR that:

  • bumps version in package.json (patch / minor / major as appropriate);
  • bumps version in .claude-plugin/plugin.json to the same value;
  • bumps both version fields in .claude-plugin/marketplace.json to the same value;
  • adds a CHANGELOG.md entry describing the release.

When the PR lands on master and CI passes, the workflow tags the commit v<version> and publishes a GitHub Release with auto-generated notes (merged PRs and commits since the previous tag). The workflow itself never writes to the repository.

If CI passed but the release did not fire (e.g., a transient failure), use the workflow's workflow_dispatch trigger from the Actions tab to re-run it.

License

MIT — see LICENSE for details.

About

Claude Code plugin for visual PR documentation — annotated screenshots and walkthrough videos

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

pr-visual

A Claude Code plugin that captures visual PR documentation: AI-generated Playwright scenarios from the PR description or git diff, annotated screenshots (desktop 2x + mobile 3x, light + dark), and walkthrough videos with burned-in captions.

Each run is isolated in a git worktree with its own port and namespaced resources (Docker containers, networks, volumes), so multiple runs can execute in parallel without collisions.

Prerequisites

  • Node.js 20+
  • ffmpegbrew install ffmpeg (optional — needed for video captions and voice-over transcoding)
  • GitHub CLIbrew install gh
  • Chromium (installed automatically via Playwright on postinstall)

Installation

From Claude Code marketplace (recommended)

/plugin marketplace add gerokeller/pr-visual

Then install the plugin:

/plugin install pr-visual

To share with your team, add this to your project's .claude/settings.json:

{
"extraKnownMarketplaces": {
"pr-visual": {
"source": {
"source": "github",
"repo": "gerokeller/pr-visual"
}
}
},
"enabledPlugins": {
"pr-visual@pr-visual": true
}
}

Via npm

npm install -D pr-visual

Claude Code automatically discovers the plugin via .claude-plugin/plugin.json inside node_modules/pr-visual/. This gives you:

  • /pr-visual slash command
  • PostToolUse hook that reminds you to run it after gh pr create

Quick start

1. Scaffold the config

/pr-visual init

Or via CLI:

npx pr-visual init

This detects your project setup and generates a tailored .pr-visual.config.ts:

pr-visual init: Detecting project setup...
Framework: Next.js
Package manager: pnpm
Docker: yes (postgres, redis)
ORM: prisma
Health endpoint: /api/health
Default port: 3000
Created: .pr-visual.config.ts

2. Review and commit the config

The generated config is ready to use but worth reviewing. Commit it so every team member gets the same behavior.

3. Run it

/pr-visual

Or manually:

npx pr-visual

Configuration

Plugin settings

The plugin accepts the following user configuration (set during plugin install or in settings):

SettingDescription
anthropic_api_keyAPI key for AI-generated scenarios (stored in system keychain). Falls back to ANTHROPIC_API_KEY env var, then to static route capture.

Project config

.pr-visual.config.ts is the contract between your project and the recorder. It declares everything needed to bring up the application from a cold worktree:

importtype{ProjectConfig}from"pr-visual/scripts/pr-visual/types.js";exportdefault{port: 3000,devServer: {command: "npm run dev",env: {PORT: "{{port}}"},},// Setup steps — Docker resources are auto-scoped via COMPOSE_PROJECT_NAMEsetup: [{name: "Start database",command: "docker compose up -d postgres redis"},{name: "Run migrations",command: "npx prisma migrate deploy"},{name: "Seed data",command: "npx prisma db seed"},],readiness: {path: "/api/health",status: 200,timeout: 60_000,},// Teardown — only this run's containers are removedteardown: [{name: "Stop database",command: "docker compose down -v"},],isolate: true,installCommand: "npm ci",}satisfiesProjectConfig;

Template variables

All command strings and env values support these placeholders:

VariableDescription
{{port}}Auto-allocated TCP port for this run
{{runId}}Unique run identifier — safe as Docker project name, DB suffix, directory name
{{rootDir}}Absolute path to the working directory (worktree or project root)

Automatic resource isolation

Every lifecycle step and the dev server receive these environment variables automatically — no manual setup needed:

VariableValuePurpose
COMPOSE_PROJECT_NAME{{runId}}Scopes all Docker Compose containers, networks, and volumes to this run
PORTAllocated portStandard port variable
PR_VISUAL_RUN_ID{{runId}}Available for custom scripts
PR_VISUAL_PORTAllocated portAvailable for custom scripts
PR_VISUAL_ROOT_DIR{{rootDir}}Available for custom scripts

This means docker compose up -d postgres in two parallel runs creates two independent Postgres containers, and each run's docker compose down -v only removes its own.

Config reference

FieldTypeDefaultDescription
portnumber3000Preferred port (auto-incremented if busy)
baseUrlstringhttp://localhost:{{port}}URL template
devServer.commandstringnpm run devDev server command
devServer.envRecordExtra env vars (template substitution)
setupLifecycleStep[]Pre-server steps (Docker, migrations, seeds)
readiness.pathstring/Readiness probe endpoint
readiness.statusnumber200Expected HTTP status
readiness.timeoutnumber45000Max wait time in ms
readiness.intervalnumber1000Probe interval in ms
teardownLifecycleStep[]Post-capture cleanup steps
isolatebooleantrueUse git worktree for isolation
worktreeDirstring../.pr-visual-worktreesWhere to create worktrees
installCommandstringnpm ciInstall command for worktrees
outputDirstring.pr-visualOutput directory (relative to root)
routesArray<string | { path, label }>["/"]Routes for static fallback capture
quality"720p" | "1080p" | "2k" | "4k"Desktop quality preset (see Quality presets)
pacing.wordsPerSecondnumber3.2Reading speed used by adaptive pacing
overlays.cursorbooleanfalseInject a visible custom cursor during capture (see Interaction overlays)
overlays.clicksbooleanfalseEmit a ripple + center dot at each click's coordinates
overlays.highlightsbooleanfalseEnable the highlight scenario step action (pulsing glow + dimmed backdrop)
video.compositing"none" | "remotion""none"Run the recorded clip through a Remotion composition (see Video production)
video.brandColorstring"#3b82f6"Brand accent color for intro/outro/caption-pill chrome
video.categorystringOptional category label rendered as a glassmorphism badge
video.sprintLabelstringOptional sprint / release label rendered subtly in the intro
video.orgNamestringOptional org name rendered in the outro footer
video.highlightsstring[]Optional bullets rendered as a "Key Highlights" card in the outro
video.mobile.enabledbooleanfalseRun a dedicated mobile composite pass after the main matrix and composite both streams (see Mobile composite layouts). Implies compositing: "remotion".
video.mobile.viewport{ width, height }{ 390, 844 }Mobile pass viewport
video.mobile.deviceScaleFactornumber3Mobile pass DPR
video.mobile.layout"side-by-side" | "pip" | "sequential""side-by-side"Composition layout
auth.storageStateDirstring".pr-visual/auth"Directory holding Playwright storage state files (see Authenticated demos)
auth.profilesRecord<string, string>Named profiles → relative storage state file paths
auth.tokenGeneratorLifecycleStepOptional command run after setup and before devServer to refresh storage state
pomsRecord<string, string>Page Object Model registry. Keys are referenced from pom scenario steps (see Page Object Models). Values are module paths relative to the project root.
voiceover.enabledbooleanfalseSynthesize per-step audio and mix it into the composited MP4 (see Voice-over). Implies compositing: "remotion".
voiceover.provider"piper" | "google" | "openai" | "say"Explicit provider. Defaults to the first available in detection order.
voiceover.voicestring(per-provider)Provider-specific voice name (e.g. en-US-Neural2-F, alloy, Samantha).
voiceover.cacheDirstring".pr-visual/tts"Audio cache directory (relative to project root). Content-hash keyed; re-runs with unchanged captions skip synthesis.

Minimal config examples

Next.js (zero-setup):

exportdefault{devServer: {command: "npm run dev"}};

Vite + Docker Postgres:

exportdefault{port: 5173,devServer: {command: "npx vite --port {{port}}"},setup: [{name: "DB",command: "docker compose up -d db",timeout: 30_000},{name: "Migrate",command: "npx prisma migrate deploy"},],teardown: [{name: "DB down",command: "docker compose down -v"},],readiness: {path: "/api/health"},};

i18n site (content at /en):

exportdefault{devServer: {command: "npx next dev --port {{port}}"},readiness: {path: "/en"},routes: [{path: "/en",label: "Homepage"},{path: "/en/about",label: "About"},],};

Monorepo (custom cwd):

exportdefault{devServer: {command: "turbo dev --filter=web",cwd: "apps/web"},setup: [{name: "Build packages",command: "turbo build --filter=web^..."},],};

Quality presets

By default the desktop capture runs at 1440×900 @2x. You can bump this to a named preset to get higher-resolution video and screenshots. The preset sets the logical viewport (CSS pixels); final output dimensions are viewport × deviceScaleFactor (DSF stays at 2 by default).

PresetViewportOutput (DSF=2)
720p1280×7202560×1440
1080p1920×10803840×2160
2k2560×14405120×2880
4k3840×21607680×4320

Mobile capture is not affected by quality presets.

Project-wide default in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},quality: "1080p",}satisfiesProjectConfig;

Per-scenario override (AI-generated or hand-authored scenarios):

{name: "Checkout flow",description: "...",quality: "2k",// preset wins over viewportsteps: [/* ... */],}

Explicit viewport override (when a preset doesn't fit):

{name: "Tablet layout",description: "...",viewport: {width: 1024,height: 768,deviceScaleFactor: 2},steps: [/* ... */],}

One-off env override — useful in CI or for spot checks:

PR_VISUAL_QUALITY=4k npx pr-visual

Precedence (highest wins):

  1. PR_VISUAL_QUALITY env var
  2. scenario.quality
  3. scenario.viewport
  4. projectConfig.quality
  5. Built-in default (1440×900 @2x)

An unknown preset value (env, scenario, or project) fails hard with a clear error.

Adaptive pacing

Each step holds on-screen long enough for viewers to read the caption and absorb the change, scaled by an explicit pacing hint. The hold is computed from the caption's reading time, the action type (first-navigation gets extra breathing room; type scales with value length), a transition cushion when the action changes, and the pacing mode.

Modes (multiplier / floor / cap in ms):

ModeMultiplierFloorCap
quick0.6×9004000
normal(default)1.0×17008000
slow1.5×220010000
dramatic2.0×320012000

dramatic also inserts an 800ms pre-action settle before the step fires, to build anticipation.

Per-step:

{action: "click",selector: "#checkout",caption: "Confirm the order",pacing: "dramatic",// the final beat — let it land}

Project-level reading speed in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},pacing: {wordsPerSecond: 2.8},// slower — for non-native audiences}satisfiesProjectConfig;

Captions of six words or fewer are read proportionally faster (+0.6 w/s) so short beats don't linger.

Narrative beats

Scenarios can tag each step with a beatsetup, action, payoff, or close — to mark where the step sits in the story arc. The annotation layer picks these up:

  • Video: a brief 700ms title-card chip fades in whenever the beat changes between two consecutive steps (so three distinct beats produce two chips).
  • Screenshots: the sidebar shows the beat label under the viewport badge.

Beats also enforce a minimum hold in the pacing formula (setup 1200ms, action 1800ms, payoff 2800ms, close 2200ms), so a payoff step earns scene-length breathing room even under quick pacing.

Emphasis

Each step can also carry emphasis: "strong" to render as a larger title-card caption (1.5× the base caption font, bolder weight). Use it on the key moments you want viewers to remember — usually a payoff beat.

{action: "screenshot",caption: "The deal is closed",beat: "payoff",emphasis: "strong",pacing: "dramatic",}

Persona

Scenarios can carry an audience label via persona: "Agency PM" (any free-form string). This is stored on the scenario for later use by the Remotion intro composer and the Story Director. It does not render directly in the current annotation layer.

{name: "New client onboarding",description: "...",persona: "Agency PM",steps: [/* ... */],}

Invalid beat, emphasis, or pacing values fail the run with a clear error before capture starts.

Interaction overlays

By default, pr-visual captures clean recordings with no cursor or click indicators. If you want your videos to look human-driven, opt into one or more overlays in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},overlays: {cursor: true,// visible custom cursor tracking the mouseclicks: true,// ripple + center dot at each clickhighlights: true,// enables the `highlight` scenario step},}satisfiesProjectConfig;

Each flag is independent; all default to false so existing users see no change.

highlight step

When overlays.highlights: true, scenarios can use a new step action that pulses a glow ring around a selector while dimming the rest of the viewport:

{action: "highlight",selector: "#primary-cta",duration: 1500,// ms; defaults to 1500 when omittedcaption: "The primary call to action",beat: "payoff",}

The highlight runs for duration ms; the scenario's pacing hold starts after cleanup.

Capture-time DOM injection

Overlays are injected into the page during capture (unlike the post-capture sidebar and ASS caption layers), so they appear in the recorded video at the right moment. The trade-off: an active cursor or highlight will be visible in screenshots taken right after a navigate. If you want clean screenshots alongside an overlay-rich video, leave overlays.cursor off.

Mobile viewports automatically use a touch-style cursor and tap-ring animations.

Video production

By default the captioned MP4 is the final video artifact. Opt in to a polished Remotion composition (animated intro, crossfades, glassmorphism caption pill, outro with step summary) per scenario or project-wide:

exportdefault{devServer: {command: "npm run dev"},video: {compositing: "remotion",brandColor: "#3b82f6",category: "Checkout",sprintLabel: "Sprint 12",orgName: "Acme Co",highlights: ["Faster checkout","Cleaner cart"],},}satisfiesProjectConfig;

Optional peer dependencies

The Remotion stack is intentionally not a baseline dependency — npm i pr-visual stays small for users who only need captioned recordings. Install the peer deps when you want compositing:

npm i -D remotion @remotion/bundler @remotion/renderer react react-dom

If video.compositing: "remotion" is set but the peer deps aren't installed, pr-visual prints a clear warning and falls back to the captioned MP4. The run still succeeds.

What gets composited

  • Compositing runs on the desktop + light variant only. Mobile composite layouts arrive in #6; the other three variants stay raw.
  • Output is written next to the captioned MP4 as <scenario>-composited.mp4 (H.264, CRF 16).
  • When a composited video exists, the PR comment uses it for the desktop+light slot; other variants keep the captioned MP4.

Adaptive intro/outro length

Intro and outro durations scale with the title + description word count and the number of annotated steps (reading speed 3 w/s), clamped to sensible bounds (intro 3-8s, outro 4-12s).

Mobile composite layouts

Set video.mobile.enabled: true to run a dedicated mobile pass after the main matrix and composite both streams into one MP4. Setting mobile.enabled also implies compositing: "remotion" so a single flag covers the common case.

exportdefault{devServer: {command: "npm run dev"},video: {mobile: {enabled: true,layout: "side-by-side"},},}satisfiesProjectConfig;

Layouts

  • side-by-side (default): desktop 80% + phone 20% in a stylized device frame. The canvas widens by 25% to fit both columns at near-native size.
  • pip: phone bottom-right over fullscreen desktop. Canvas dimensions unchanged.
  • sequential: desktop for the first half of the recording, phone for the second. Canvas dimensions unchanged.

Per-step mobile overrides

Scenarios can tweak the mobile pass without forking the script:

{action: "navigate",url: "/",mobilePath: "/m",caption: "Open"}{action: "click",selector: "#desktop-cta",mobileSelector: "#mobile-cta",caption: "Tap CTA"}{action: "highlight",selector: "#desktop-only",mobileSkip: true,caption: "Hover hint"}
  • mobilePath: rewrites the navigate URL on mobile.
  • mobileSelector: swaps the selector on mobile.
  • mobileSkip: omits the step from the mobile pass entirely.

Wall-clock cost

The mobile pass is sequential (separate browser context, fresh navigation), so runs with mobile compositing take ~1.8x the wall-clock of desktop-only runs. The pipeline prints a heads-up when mobile compositing fires.

If the mobile pass throws (selector missing, navigation fails), the compositing step aborts and the captioned MP4 stays as the final artifact — the run otherwise succeeds.

Authenticated demos

pr-visual is framework-agnostic about auth: you supply Playwright storage state JSON files, name them as profiles in .pr-visual.config.ts, and scenarios opt in via scenario.profile. The captured matrix variants and the mobile composite pass all load the same storage state.

exportdefault{devServer: {command: "npm run dev"},auth: {storageStateDir: ".pr-visual/auth",// defaultprofiles: {admin: "admin.json",viewer: "viewer.json",},// Optional — runs after `setup` and before `devServer`. Use it to// refresh storage state per run; pr-visual just calls the command.tokenGenerator: {name: "Refresh storage state",command: "node scripts/refresh-auth.mjs",},},}satisfiesProjectConfig;
// In your scenario:{name: "Admin dashboard tour",description: "...",profile: "admin",steps: [/* ... */],}

npx pr-visual init adds .pr-visual/auth/ to .gitignore automatically — storage state files contain session tokens.

Generating storage state

How you produce the JSON files is up to you. Two common patterns:

Pattern 1: Playwright login script

Run a one-off Playwright script that drives the login UI and saves the context state:

// scripts/refresh-auth.mjsimport{chromium}from"playwright";constbrowser=awaitchromium.launch();constctx=awaitbrowser.newContext();constpage=awaitctx.newPage();awaitpage.goto("http://localhost:3000/login");awaitpage.getByLabel("Email").fill("admin@example.com");awaitpage.getByLabel("Password").fill(process.env.ADMIN_PASSWORD);awaitpage.getByRole("button",{name: "Sign in"}).click();awaitpage.waitForURL("**/dashboard");awaitctx.storageState({path: ".pr-visual/auth/admin.json"});awaitbrowser.close();

Pattern 2: Supabase admin API (no browser needed)

// scripts/refresh-auth.mjsimportfsfrom"node:fs";import{createClient}from"@supabase/supabase-js";constsupabase=createClient(process.env.SUPABASE_URL,process.env.SUPABASE_SERVICE_ROLE_KEY,);const{ data, error }=awaitsupabase.auth.admin.generateLink({type: "magiclink",email: "admin@example.com",});if(error)throwerror;// Build a Playwright storage-state JSON with the supabase localStorage entry,// keyed `sb-<project-ref>-auth-token`. Shape per Supabase JS docs.constsession={access_token: data.properties.action_link,/* ... */};constprojectRef=newURL(process.env.SUPABASE_URL).hostname.split(".")[0];fs.writeFileSync(".pr-visual/auth/admin.json",JSON.stringify({cookies: [],origins: [{origin: "http://localhost:3000",localStorage: [{name: `sb-${projectRef}-auth-token`,value: JSON.stringify({currentSession: session}),}],}],}),);

Anything that writes a Playwright storage-state JSON works. The tokenGenerator step is templated with {{runId}}, {{port}}, {{rootDir}} like other lifecycle steps.

Validation

After the generator runs, pr-visual verifies every configured profile points at a readable JSON file. A missing or malformed file fails the run before capture starts, so a silently-broken generator surfaces immediately.

The PR_VISUAL_AUTH_DIR env var overrides storageStateDir — useful when storage state is generated outside the repo.

Page Object Models

Non-trivial real-world demos often need multi-step orchestration (dismiss a modal, wait for data, assert an intermediate state). Rather than duplicating that logic in every scenario, point pr-visual at your existing E2E Page Object Model modules:

// .pr-visual.config.tsexportdefault{devServer: {command: "npm run dev"},poms: {dashboard: "./e2e/pages/dashboard.ts",checkout: "./e2e/pages/checkout.ts",},}satisfiesProjectConfig;
// ./e2e/pages/dashboard.ts — pr-visual expects plain functions.// Each function receives the Playwright `Page` as the first argument// plus any user arguments defined on the scenario step.importtype{Page}from"playwright";exportasyncfunctionlogin(page: Page,email: string): Promise<void>{awaitpage.getByLabel("Email").fill(email);awaitpage.getByLabel("Password").fill(process.env.DEMO_PASSWORD!);awaitpage.getByRole("button",{name: "Sign in"}).click();awaitpage.waitForURL("**/dashboard");}exportasyncfunctionopenInbox(page: Page): Promise<void>{awaitpage.getByRole("link",{name: "Inbox"}).click();awaitpage.waitForSelector("[data-testid=inbox-list]");}

Then use them in scenarios:

{name: "Inbox tour",description: "...",steps: [{action: "navigate",url: "/",caption: "Open the app"},{action: "pom",page: "dashboard",method: "login",args: ["demo@example.com"],caption: "Sign in",},{action: "pom",page: "dashboard",method: "openInbox",caption: "Open the inbox",},{action: "screenshot",caption: "Inbox view"},],}

Contract

  • Each registered module exports named functions shaped as (page: Page, ...args: unknown[]) => void | Promise<void>.
  • Classes are not supported directly (predictable stateless lifecycle). Wrap them with a thin factory if you need class-based POMs.
  • args on the scenario step is an array forwarded positionally after page. Omitted args means the function is called with just (page).

Validation

pr-visual loads POM modules eagerly at scenario-validation time, so unknown page names, unknown method names, and import failures surface as pre-capture errors instead of runtime crashes deep in the capture loop.

Overlay interaction

  • Custom cursor tracking (when overlays.cursor: true) works inside POM methods automatically — the mousemove listener follows any Playwright-driven movement.
  • Click ripples and highlight spotlights do not fire inside POM methods. Those overlays are injected at the call site in pr-visual's step executor, not globally. If you want a ripple on a POM-internal click, add an explicit click step for that interaction instead.

Voice-over

Step captions become narration in the composited MP4. Each caption is synthesized to an MP3 and mixed into the Remotion composition, anchored to the start of that step on the video timeline. Setting voiceover.enabled: true also implies compositing: "remotion".

exportdefault{devServer: {command: "npm run dev"},voiceover: {enabled: true,// Leave `provider` / `voice` out to auto-detect the first available.},}satisfiesProjectConfig;

Provider chain

Detection order (first available wins — the MP4 uses one provider throughout):

  1. Piper — local neural TTS, offline, no account. Requires piper on PATH and a voice model. Point PIPER_MODEL at an .onnx file, or drop one into ~/.cache/piper/voices/.
  2. Google Cloud TTS — OAuth via gcloud. Requires gcloud auth application-default login. Default voice en-US-Neural2-F.
  3. OpenAI TTSOPENAI_API_KEY env var. Default voice alloy.
  4. macOS say — always available on macOS. Default voice Samantha.

Override via voiceover.provider; that provider is then used regardless of detection order. Per-clip synthesis failures log a warning and skip that step — the rest of the MP4 still narrates.

If no provider is available, the run fails with an explicit error listing the install options.

Caching

Clips are cached at .pr-visual/tts/step-NN-<hash>.mp3, keyed by sha256(provider + caption text). Re-running a scenario with unchanged captions is essentially free. Switching provider invalidates the cache for that step (the hash changes).

npx pr-visual init adds .pr-visual/tts/ to .gitignore automatically.

ffmpeg

Piper and say emit WAV/AIFF and use ffmpeg / ffprobe to transcode to MP3 and measure duration. pr-visual already expects ffmpeg for subtitle burning, so there's no new prerequisite.

Story Director

When ANTHROPIC_API_KEY (or CLAUDE_PLUGIN_OPTION_ANTHROPIC_API_KEY) is set, AI scenario generation runs through the Story Director instead of emitting flat step lists. The director picks one of four personas (End User, Admin, New User, Stakeholder) based on the PR content and drafts a three-act narrative arc:

Persona: End User
Setup: A user opens the dashboard expecting today's metrics.
Inciting: They notice a new tile they have never seen before.
Payoff: Clicking the tile reveals a clearer breakdown of the data.
Closing: Users now answer the question without leaving the dashboard.

Each generated scenario carries the matching persona and every step arrives pre-populated with the right beat (setup / action / payoff / close) and emphasis. The annotation layers from Narrative beats and Adaptive pacing then take over.

When no API key is set, the run falls back to the static-routes scenarios (unchanged from before).

Brief cache

The director caches each brief by sha256(prDescription + diff) to .pr-visual/story/<hash>.json. Re-running on an unchanged PR is free. init adds .pr-visual/story/ to .gitignore.

story subcommand

Inspect the brief without recording, or scaffold it to disk:

# Print the human-readable arc for the current branch's PR.
npx pr-visual story
# Same, against an explicit PR number.
npx pr-visual story --pr 42
# Machine-readable JSON to stdout.
npx pr-visual story --pr 42 --json
# Write the full {narrative, scenarios} brief to disk for editing.# Output: .pr-visual/story-scaffold.json
npx pr-visual story --scaffold

The scaffold path is convenient for tweaking the arc by hand before re-running pr-visual — load the JSON yourself and pass it as a hand-authored scenario set.

CLI commands

npx pr-visual [command]
CommandDescription
run (default)Execute the full capture pipeline
initDetect project setup and generate .pr-visual.config.ts
cleanupRemove orphaned worktrees, Docker projects, and stale directories
storyPrint or scaffold the Story Director's brief without recording. Flags: --pr <n>, --scaffold, --json.

Cleanup

If a run is interrupted (Ctrl+C, crash, killed terminal), resources may be left behind. The cleanup command finds and removes them:

npx pr-visual cleanup

This removes:

  • Orphaned git worktrees (pr-visual-* branches and directories)
  • Orphaned Docker Compose projects (containers, networks, volumes named pr-visual-*)
  • Stale worktree parent directories

The recorder also registers signal handlers for SIGINT and SIGTERM, so a normal Ctrl+C during a run will attempt to tear down services and remove the worktree before exiting.

Environment variables

VariableDefaultDescription
ANTHROPIC_API_KEYEnables AI-generated scenarios (falls back to static)
PR_BODYOverride PR body text for scenario generation
PR_VISUAL_CONFIGExplicit path to config file
PR_VISUAL_NO_ISOLATESet to 1 to skip worktree isolation
PR_VISUAL_QUALITYDesktop quality preset override: 720p, 1080p, 2k, 4k. Takes precedence over scenario and project config.
PR_VISUAL_AUTH_DIROverride auth.storageStateDir. Useful when storage state is generated outside the repo.

How it works

Pipeline

┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐
│ Worktree │───▶│ Setup │───▶│ Dev Server │───▶│ Readiness │
│ + install │ │ steps │ │ start │ │ probe │
└─────────────┘ └──────────┘ └───────────┘ └──────┬───────┘
│
┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌─────▼────────┐
│ PR attach │◀───│ Annotate │◀───│ Capture │◀───│ Scenarios │
│ + cleanup │ │ + video │ │ all vars │ │ (AI / diff) │
└─────────────┘ └──────────┘ └───────────┘ └──────────────┘

Isolation model

Each run creates a git worktree at the current commit:

  • Directory: ../.pr-visual-worktrees/pr-visual-<timestamp>-<hex> (outside repo)
  • Branch: pr-visual/pr-visual-<timestamp>-<hex> (temporary)
  • Port: auto-allocated from preferred port upward (scans 100 ports)
  • Docker: COMPOSE_PROJECT_NAME=pr-visual-<timestamp>-<hex> namespaces all resources
  • Dependencies: full install from lockfile in the worktree

Multiple parallel runs get different worktrees, ports, and Docker project names — complete isolation.

Lifecycle

  1. Setup steps — sequential shell commands with per-step timeouts
  2. Dev server — spawned as a detached process group
  3. Readiness probe — polls endpoint until expected status or timeout
  4. Teardown — runs cleanup commands; errors are logged but don't abort

Cleanup guarantees

  • Signal handlers (SIGINT/SIGTERM) run teardown and worktree removal on interrupt
  • Explicit cleanup in finally block for normal completion or exceptions
  • npx pr-visual cleanup as a manual recovery for hard crashes

Troubleshooting

Skills not appearing after install

Run /reload-plugins to refresh the plugin list.

ffmpeg captioning fails

The video captioning feature requires ffmpeg with either libass or subtitles filter support. If neither is available, the plugin gracefully skips captioning and returns the raw video.

To get full captioning support:

brew install ffmpeg

Screenshots show wrong page (i18n sites)

If your site redirects / to a locale path (e.g. /en), configure the routes field in your .pr-visual.config.ts:

exportdefault{readiness: {path: "/en"},routes: [{path: "/en",label: "Homepage"}],};

next: command not found in worktree

Use npx in your dev server command to resolve binaries from node_modules:

exportdefault{devServer: {command: "npx next dev --port {{port}}"},};

Project structure

.claude-plugin/
plugin.json # Plugin manifest
marketplace.json # Plugin marketplace definition
skills/pr-visual/
SKILL.md # Slash command definition
hooks/
hooks.json # PostToolUse hook for gh pr create
bin/
pr-visual # CLI entrypoint
scripts/pr-visual/
index.ts # CLI routing (run | init | cleanup)
types.ts # Shared types (ViewportConfig, ProjectConfig, RunContext, etc.)
config.ts # Config discovery, loading, template substitution
worktree.ts # Git worktree creation, port allocation, cleanup
lifecycle.ts # Setup/teardown steps, dev server, readiness, signal handlers
init.ts # Project detection and config scaffolding
cleanup.ts # Orphaned resource discovery and removal
scenario-generator.ts # Claude API integration for scenario generation
capture.ts # Playwright capture across viewports and color schemes
pr-attach.ts # GitHub PR body patching and comment posting
annotate/
screenshots.ts # sharp + SVG sidebar compositing → WebP
video.ts # ffmpeg ASS caption burning → H.264 MP4

Development

npm ci # install deps + Playwright chromium
npm run typecheck # tsc --noEmit
npm run lint # Biome (lint + format check, fails on warnings)
npm run lint:fix # Biome auto-fix (safe rules) + write
npm run format # Biome format only — write
npm test# vitest run (unit + integration + e2e)

CI runs typecheck, lint, and the full test suite on every PR and on push to master (Node 20). All warnings are treated as errors.

Releases

Releases are created by .github/workflows/release.yml, which runs after the CI workflow finishes successfully on master. A red CI run blocks the release.

A release is cut only when package.jsonversion is bumped above the latest v* git tag. Merging a PR that does not change version does not produce a release — this lets you land refactors, docs, and chore work between shipments.

To cut a release, open a PR that:

  • bumps version in package.json (patch / minor / major as appropriate);
  • bumps version in .claude-plugin/plugin.json to the same value;
  • bumps both version fields in .claude-plugin/marketplace.json to the same value;
  • adds a CHANGELOG.md entry describing the release.

When the PR lands on master and CI passes, the workflow tags the commit v<version> and publishes a GitHub Release with auto-generated notes (merged PRs and commits since the previous tag). The workflow itself never writes to the repository.

If CI passed but the release did not fire (e.g., a transient failure), use the workflow's workflow_dispatch trigger from the Actions tab to re-run it.

License

MIT — see LICENSE for details.

About

Claude Code plugin for visual PR documentation — annotated screenshots and walkthrough videos

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

pr-visual

A Claude Code plugin that captures visual PR documentation: AI-generated Playwright scenarios from the PR description or git diff, annotated screenshots (desktop 2x + mobile 3x, light + dark), and walkthrough videos with burned-in captions.

Each run is isolated in a git worktree with its own port and namespaced resources (Docker containers, networks, volumes), so multiple runs can execute in parallel without collisions.

Prerequisites

  • Node.js 20+
  • ffmpegbrew install ffmpeg (optional — needed for video captions and voice-over transcoding)
  • GitHub CLIbrew install gh
  • Chromium (installed automatically via Playwright on postinstall)

Installation

From Claude Code marketplace (recommended)

/plugin marketplace add gerokeller/pr-visual

Then install the plugin:

/plugin install pr-visual

To share with your team, add this to your project's .claude/settings.json:

{
"extraKnownMarketplaces": {
"pr-visual": {
"source": {
"source": "github",
"repo": "gerokeller/pr-visual"
}
}
},
"enabledPlugins": {
"pr-visual@pr-visual": true
}
}

Via npm

npm install -D pr-visual

Claude Code automatically discovers the plugin via .claude-plugin/plugin.json inside node_modules/pr-visual/. This gives you:

  • /pr-visual slash command
  • PostToolUse hook that reminds you to run it after gh pr create

Quick start

1. Scaffold the config

/pr-visual init

Or via CLI:

npx pr-visual init

This detects your project setup and generates a tailored .pr-visual.config.ts:

pr-visual init: Detecting project setup...
Framework: Next.js
Package manager: pnpm
Docker: yes (postgres, redis)
ORM: prisma
Health endpoint: /api/health
Default port: 3000
Created: .pr-visual.config.ts

2. Review and commit the config

The generated config is ready to use but worth reviewing. Commit it so every team member gets the same behavior.

3. Run it

/pr-visual

Or manually:

npx pr-visual

Configuration

Plugin settings

The plugin accepts the following user configuration (set during plugin install or in settings):

SettingDescription
anthropic_api_keyAPI key for AI-generated scenarios (stored in system keychain). Falls back to ANTHROPIC_API_KEY env var, then to static route capture.

Project config

.pr-visual.config.ts is the contract between your project and the recorder. It declares everything needed to bring up the application from a cold worktree:

importtype{ProjectConfig}from"pr-visual/scripts/pr-visual/types.js";exportdefault{port: 3000,devServer: {command: "npm run dev",env: {PORT: "{{port}}"},},// Setup steps — Docker resources are auto-scoped via COMPOSE_PROJECT_NAMEsetup: [{name: "Start database",command: "docker compose up -d postgres redis"},{name: "Run migrations",command: "npx prisma migrate deploy"},{name: "Seed data",command: "npx prisma db seed"},],readiness: {path: "/api/health",status: 200,timeout: 60_000,},// Teardown — only this run's containers are removedteardown: [{name: "Stop database",command: "docker compose down -v"},],isolate: true,installCommand: "npm ci",}satisfiesProjectConfig;

Template variables

All command strings and env values support these placeholders:

VariableDescription
{{port}}Auto-allocated TCP port for this run
{{runId}}Unique run identifier — safe as Docker project name, DB suffix, directory name
{{rootDir}}Absolute path to the working directory (worktree or project root)

Automatic resource isolation

Every lifecycle step and the dev server receive these environment variables automatically — no manual setup needed:

VariableValuePurpose
COMPOSE_PROJECT_NAME{{runId}}Scopes all Docker Compose containers, networks, and volumes to this run
PORTAllocated portStandard port variable
PR_VISUAL_RUN_ID{{runId}}Available for custom scripts
PR_VISUAL_PORTAllocated portAvailable for custom scripts
PR_VISUAL_ROOT_DIR{{rootDir}}Available for custom scripts

This means docker compose up -d postgres in two parallel runs creates two independent Postgres containers, and each run's docker compose down -v only removes its own.

Config reference

FieldTypeDefaultDescription
portnumber3000Preferred port (auto-incremented if busy)
baseUrlstringhttp://localhost:{{port}}URL template
devServer.commandstringnpm run devDev server command
devServer.envRecordExtra env vars (template substitution)
setupLifecycleStep[]Pre-server steps (Docker, migrations, seeds)
readiness.pathstring/Readiness probe endpoint
readiness.statusnumber200Expected HTTP status
readiness.timeoutnumber45000Max wait time in ms
readiness.intervalnumber1000Probe interval in ms
teardownLifecycleStep[]Post-capture cleanup steps
isolatebooleantrueUse git worktree for isolation
worktreeDirstring../.pr-visual-worktreesWhere to create worktrees
installCommandstringnpm ciInstall command for worktrees
outputDirstring.pr-visualOutput directory (relative to root)
routesArray<string | { path, label }>["/"]Routes for static fallback capture
quality"720p" | "1080p" | "2k" | "4k"Desktop quality preset (see Quality presets)
pacing.wordsPerSecondnumber3.2Reading speed used by adaptive pacing
overlays.cursorbooleanfalseInject a visible custom cursor during capture (see Interaction overlays)
overlays.clicksbooleanfalseEmit a ripple + center dot at each click's coordinates
overlays.highlightsbooleanfalseEnable the highlight scenario step action (pulsing glow + dimmed backdrop)
video.compositing"none" | "remotion""none"Run the recorded clip through a Remotion composition (see Video production)
video.brandColorstring"#3b82f6"Brand accent color for intro/outro/caption-pill chrome
video.categorystringOptional category label rendered as a glassmorphism badge
video.sprintLabelstringOptional sprint / release label rendered subtly in the intro
video.orgNamestringOptional org name rendered in the outro footer
video.highlightsstring[]Optional bullets rendered as a "Key Highlights" card in the outro
video.mobile.enabledbooleanfalseRun a dedicated mobile composite pass after the main matrix and composite both streams (see Mobile composite layouts). Implies compositing: "remotion".
video.mobile.viewport{ width, height }{ 390, 844 }Mobile pass viewport
video.mobile.deviceScaleFactornumber3Mobile pass DPR
video.mobile.layout"side-by-side" | "pip" | "sequential""side-by-side"Composition layout
auth.storageStateDirstring".pr-visual/auth"Directory holding Playwright storage state files (see Authenticated demos)
auth.profilesRecord<string, string>Named profiles → relative storage state file paths
auth.tokenGeneratorLifecycleStepOptional command run after setup and before devServer to refresh storage state
pomsRecord<string, string>Page Object Model registry. Keys are referenced from pom scenario steps (see Page Object Models). Values are module paths relative to the project root.
voiceover.enabledbooleanfalseSynthesize per-step audio and mix it into the composited MP4 (see Voice-over). Implies compositing: "remotion".
voiceover.provider"piper" | "google" | "openai" | "say"Explicit provider. Defaults to the first available in detection order.
voiceover.voicestring(per-provider)Provider-specific voice name (e.g. en-US-Neural2-F, alloy, Samantha).
voiceover.cacheDirstring".pr-visual/tts"Audio cache directory (relative to project root). Content-hash keyed; re-runs with unchanged captions skip synthesis.

Minimal config examples

Next.js (zero-setup):

exportdefault{devServer: {command: "npm run dev"}};

Vite + Docker Postgres:

exportdefault{port: 5173,devServer: {command: "npx vite --port {{port}}"},setup: [{name: "DB",command: "docker compose up -d db",timeout: 30_000},{name: "Migrate",command: "npx prisma migrate deploy"},],teardown: [{name: "DB down",command: "docker compose down -v"},],readiness: {path: "/api/health"},};

i18n site (content at /en):

exportdefault{devServer: {command: "npx next dev --port {{port}}"},readiness: {path: "/en"},routes: [{path: "/en",label: "Homepage"},{path: "/en/about",label: "About"},],};

Monorepo (custom cwd):

exportdefault{devServer: {command: "turbo dev --filter=web",cwd: "apps/web"},setup: [{name: "Build packages",command: "turbo build --filter=web^..."},],};

Quality presets

By default the desktop capture runs at 1440×900 @2x. You can bump this to a named preset to get higher-resolution video and screenshots. The preset sets the logical viewport (CSS pixels); final output dimensions are viewport × deviceScaleFactor (DSF stays at 2 by default).

PresetViewportOutput (DSF=2)
720p1280×7202560×1440
1080p1920×10803840×2160
2k2560×14405120×2880
4k3840×21607680×4320

Mobile capture is not affected by quality presets.

Project-wide default in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},quality: "1080p",}satisfiesProjectConfig;

Per-scenario override (AI-generated or hand-authored scenarios):

{name: "Checkout flow",description: "...",quality: "2k",// preset wins over viewportsteps: [/* ... */],}

Explicit viewport override (when a preset doesn't fit):

{name: "Tablet layout",description: "...",viewport: {width: 1024,height: 768,deviceScaleFactor: 2},steps: [/* ... */],}

One-off env override — useful in CI or for spot checks:

PR_VISUAL_QUALITY=4k npx pr-visual

Precedence (highest wins):

  1. PR_VISUAL_QUALITY env var
  2. scenario.quality
  3. scenario.viewport
  4. projectConfig.quality
  5. Built-in default (1440×900 @2x)

An unknown preset value (env, scenario, or project) fails hard with a clear error.

Adaptive pacing

Each step holds on-screen long enough for viewers to read the caption and absorb the change, scaled by an explicit pacing hint. The hold is computed from the caption's reading time, the action type (first-navigation gets extra breathing room; type scales with value length), a transition cushion when the action changes, and the pacing mode.

Modes (multiplier / floor / cap in ms):

ModeMultiplierFloorCap
quick0.6×9004000
normal(default)1.0×17008000
slow1.5×220010000
dramatic2.0×320012000

dramatic also inserts an 800ms pre-action settle before the step fires, to build anticipation.

Per-step:

{action: "click",selector: "#checkout",caption: "Confirm the order",pacing: "dramatic",// the final beat — let it land}

Project-level reading speed in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},pacing: {wordsPerSecond: 2.8},// slower — for non-native audiences}satisfiesProjectConfig;

Captions of six words or fewer are read proportionally faster (+0.6 w/s) so short beats don't linger.

Narrative beats

Scenarios can tag each step with a beatsetup, action, payoff, or close — to mark where the step sits in the story arc. The annotation layer picks these up:

  • Video: a brief 700ms title-card chip fades in whenever the beat changes between two consecutive steps (so three distinct beats produce two chips).
  • Screenshots: the sidebar shows the beat label under the viewport badge.

Beats also enforce a minimum hold in the pacing formula (setup 1200ms, action 1800ms, payoff 2800ms, close 2200ms), so a payoff step earns scene-length breathing room even under quick pacing.

Emphasis

Each step can also carry emphasis: "strong" to render as a larger title-card caption (1.5× the base caption font, bolder weight). Use it on the key moments you want viewers to remember — usually a payoff beat.

{action: "screenshot",caption: "The deal is closed",beat: "payoff",emphasis: "strong",pacing: "dramatic",}

Persona

Scenarios can carry an audience label via persona: "Agency PM" (any free-form string). This is stored on the scenario for later use by the Remotion intro composer and the Story Director. It does not render directly in the current annotation layer.

{name: "New client onboarding",description: "...",persona: "Agency PM",steps: [/* ... */],}

Invalid beat, emphasis, or pacing values fail the run with a clear error before capture starts.

Interaction overlays

By default, pr-visual captures clean recordings with no cursor or click indicators. If you want your videos to look human-driven, opt into one or more overlays in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},overlays: {cursor: true,// visible custom cursor tracking the mouseclicks: true,// ripple + center dot at each clickhighlights: true,// enables the `highlight` scenario step},}satisfiesProjectConfig;

Each flag is independent; all default to false so existing users see no change.

highlight step

When overlays.highlights: true, scenarios can use a new step action that pulses a glow ring around a selector while dimming the rest of the viewport:

{action: "highlight",selector: "#primary-cta",duration: 1500,// ms; defaults to 1500 when omittedcaption: "The primary call to action",beat: "payoff",}

The highlight runs for duration ms; the scenario's pacing hold starts after cleanup.

Capture-time DOM injection

Overlays are injected into the page during capture (unlike the post-capture sidebar and ASS caption layers), so they appear in the recorded video at the right moment. The trade-off: an active cursor or highlight will be visible in screenshots taken right after a navigate. If you want clean screenshots alongside an overlay-rich video, leave overlays.cursor off.

Mobile viewports automatically use a touch-style cursor and tap-ring animations.

Video production

By default the captioned MP4 is the final video artifact. Opt in to a polished Remotion composition (animated intro, crossfades, glassmorphism caption pill, outro with step summary) per scenario or project-wide:

exportdefault{devServer: {command: "npm run dev"},video: {compositing: "remotion",brandColor: "#3b82f6",category: "Checkout",sprintLabel: "Sprint 12",orgName: "Acme Co",highlights: ["Faster checkout","Cleaner cart"],},}satisfiesProjectConfig;

Optional peer dependencies

The Remotion stack is intentionally not a baseline dependency — npm i pr-visual stays small for users who only need captioned recordings. Install the peer deps when you want compositing:

npm i -D remotion @remotion/bundler @remotion/renderer react react-dom

If video.compositing: "remotion" is set but the peer deps aren't installed, pr-visual prints a clear warning and falls back to the captioned MP4. The run still succeeds.

What gets composited

  • Compositing runs on the desktop + light variant only. Mobile composite layouts arrive in #6; the other three variants stay raw.
  • Output is written next to the captioned MP4 as <scenario>-composited.mp4 (H.264, CRF 16).
  • When a composited video exists, the PR comment uses it for the desktop+light slot; other variants keep the captioned MP4.

Adaptive intro/outro length

Intro and outro durations scale with the title + description word count and the number of annotated steps (reading speed 3 w/s), clamped to sensible bounds (intro 3-8s, outro 4-12s).

Mobile composite layouts

Set video.mobile.enabled: true to run a dedicated mobile pass after the main matrix and composite both streams into one MP4. Setting mobile.enabled also implies compositing: "remotion" so a single flag covers the common case.

exportdefault{devServer: {command: "npm run dev"},video: {mobile: {enabled: true,layout: "side-by-side"},},}satisfiesProjectConfig;

Layouts

  • side-by-side (default): desktop 80% + phone 20% in a stylized device frame. The canvas widens by 25% to fit both columns at near-native size.
  • pip: phone bottom-right over fullscreen desktop. Canvas dimensions unchanged.
  • sequential: desktop for the first half of the recording, phone for the second. Canvas dimensions unchanged.

Per-step mobile overrides

Scenarios can tweak the mobile pass without forking the script:

{action: "navigate",url: "/",mobilePath: "/m",caption: "Open"}{action: "click",selector: "#desktop-cta",mobileSelector: "#mobile-cta",caption: "Tap CTA"}{action: "highlight",selector: "#desktop-only",mobileSkip: true,caption: "Hover hint"}
  • mobilePath: rewrites the navigate URL on mobile.
  • mobileSelector: swaps the selector on mobile.
  • mobileSkip: omits the step from the mobile pass entirely.

Wall-clock cost

The mobile pass is sequential (separate browser context, fresh navigation), so runs with mobile compositing take ~1.8x the wall-clock of desktop-only runs. The pipeline prints a heads-up when mobile compositing fires.

If the mobile pass throws (selector missing, navigation fails), the compositing step aborts and the captioned MP4 stays as the final artifact — the run otherwise succeeds.

Authenticated demos

pr-visual is framework-agnostic about auth: you supply Playwright storage state JSON files, name them as profiles in .pr-visual.config.ts, and scenarios opt in via scenario.profile. The captured matrix variants and the mobile composite pass all load the same storage state.

exportdefault{devServer: {command: "npm run dev"},auth: {storageStateDir: ".pr-visual/auth",// defaultprofiles: {admin: "admin.json",viewer: "viewer.json",},// Optional — runs after `setup` and before `devServer`. Use it to// refresh storage state per run; pr-visual just calls the command.tokenGenerator: {name: "Refresh storage state",command: "node scripts/refresh-auth.mjs",},},}satisfiesProjectConfig;
// In your scenario:{name: "Admin dashboard tour",description: "...",profile: "admin",steps: [/* ... */],}

npx pr-visual init adds .pr-visual/auth/ to .gitignore automatically — storage state files contain session tokens.

Generating storage state

How you produce the JSON files is up to you. Two common patterns:

Pattern 1: Playwright login script

Run a one-off Playwright script that drives the login UI and saves the context state:

// scripts/refresh-auth.mjsimport{chromium}from"playwright";constbrowser=awaitchromium.launch();constctx=awaitbrowser.newContext();constpage=awaitctx.newPage();awaitpage.goto("http://localhost:3000/login");awaitpage.getByLabel("Email").fill("admin@example.com");awaitpage.getByLabel("Password").fill(process.env.ADMIN_PASSWORD);awaitpage.getByRole("button",{name: "Sign in"}).click();awaitpage.waitForURL("**/dashboard");awaitctx.storageState({path: ".pr-visual/auth/admin.json"});awaitbrowser.close();

Pattern 2: Supabase admin API (no browser needed)

// scripts/refresh-auth.mjsimportfsfrom"node:fs";import{createClient}from"@supabase/supabase-js";constsupabase=createClient(process.env.SUPABASE_URL,process.env.SUPABASE_SERVICE_ROLE_KEY,);const{ data, error }=awaitsupabase.auth.admin.generateLink({type: "magiclink",email: "admin@example.com",});if(error)throwerror;// Build a Playwright storage-state JSON with the supabase localStorage entry,// keyed `sb-<project-ref>-auth-token`. Shape per Supabase JS docs.constsession={access_token: data.properties.action_link,/* ... */};constprojectRef=newURL(process.env.SUPABASE_URL).hostname.split(".")[0];fs.writeFileSync(".pr-visual/auth/admin.json",JSON.stringify({cookies: [],origins: [{origin: "http://localhost:3000",localStorage: [{name: `sb-${projectRef}-auth-token`,value: JSON.stringify({currentSession: session}),}],}],}),);

Anything that writes a Playwright storage-state JSON works. The tokenGenerator step is templated with {{runId}}, {{port}}, {{rootDir}} like other lifecycle steps.

Validation

After the generator runs, pr-visual verifies every configured profile points at a readable JSON file. A missing or malformed file fails the run before capture starts, so a silently-broken generator surfaces immediately.

The PR_VISUAL_AUTH_DIR env var overrides storageStateDir — useful when storage state is generated outside the repo.

Page Object Models

Non-trivial real-world demos often need multi-step orchestration (dismiss a modal, wait for data, assert an intermediate state). Rather than duplicating that logic in every scenario, point pr-visual at your existing E2E Page Object Model modules:

// .pr-visual.config.tsexportdefault{devServer: {command: "npm run dev"},poms: {dashboard: "./e2e/pages/dashboard.ts",checkout: "./e2e/pages/checkout.ts",},}satisfiesProjectConfig;
// ./e2e/pages/dashboard.ts — pr-visual expects plain functions.// Each function receives the Playwright `Page` as the first argument// plus any user arguments defined on the scenario step.importtype{Page}from"playwright";exportasyncfunctionlogin(page: Page,email: string): Promise<void>{awaitpage.getByLabel("Email").fill(email);awaitpage.getByLabel("Password").fill(process.env.DEMO_PASSWORD!);awaitpage.getByRole("button",{name: "Sign in"}).click();awaitpage.waitForURL("**/dashboard");}exportasyncfunctionopenInbox(page: Page): Promise<void>{awaitpage.getByRole("link",{name: "Inbox"}).click();awaitpage.waitForSelector("[data-testid=inbox-list]");}

Then use them in scenarios:

{name: "Inbox tour",description: "...",steps: [{action: "navigate",url: "/",caption: "Open the app"},{action: "pom",page: "dashboard",method: "login",args: ["demo@example.com"],caption: "Sign in",},{action: "pom",page: "dashboard",method: "openInbox",caption: "Open the inbox",},{action: "screenshot",caption: "Inbox view"},],}

Contract

  • Each registered module exports named functions shaped as (page: Page, ...args: unknown[]) => void | Promise<void>.
  • Classes are not supported directly (predictable stateless lifecycle). Wrap them with a thin factory if you need class-based POMs.
  • args on the scenario step is an array forwarded positionally after page. Omitted args means the function is called with just (page).

Validation

pr-visual loads POM modules eagerly at scenario-validation time, so unknown page names, unknown method names, and import failures surface as pre-capture errors instead of runtime crashes deep in the capture loop.

Overlay interaction

  • Custom cursor tracking (when overlays.cursor: true) works inside POM methods automatically — the mousemove listener follows any Playwright-driven movement.
  • Click ripples and highlight spotlights do not fire inside POM methods. Those overlays are injected at the call site in pr-visual's step executor, not globally. If you want a ripple on a POM-internal click, add an explicit click step for that interaction instead.

Voice-over

Step captions become narration in the composited MP4. Each caption is synthesized to an MP3 and mixed into the Remotion composition, anchored to the start of that step on the video timeline. Setting voiceover.enabled: true also implies compositing: "remotion".

exportdefault{devServer: {command: "npm run dev"},voiceover: {enabled: true,// Leave `provider` / `voice` out to auto-detect the first available.},}satisfiesProjectConfig;

Provider chain

Detection order (first available wins — the MP4 uses one provider throughout):

  1. Piper — local neural TTS, offline, no account. Requires piper on PATH and a voice model. Point PIPER_MODEL at an .onnx file, or drop one into ~/.cache/piper/voices/.
  2. Google Cloud TTS — OAuth via gcloud. Requires gcloud auth application-default login. Default voice en-US-Neural2-F.
  3. OpenAI TTSOPENAI_API_KEY env var. Default voice alloy.
  4. macOS say — always available on macOS. Default voice Samantha.

Override via voiceover.provider; that provider is then used regardless of detection order. Per-clip synthesis failures log a warning and skip that step — the rest of the MP4 still narrates.

If no provider is available, the run fails with an explicit error listing the install options.

Caching

Clips are cached at .pr-visual/tts/step-NN-<hash>.mp3, keyed by sha256(provider + caption text). Re-running a scenario with unchanged captions is essentially free. Switching provider invalidates the cache for that step (the hash changes).

npx pr-visual init adds .pr-visual/tts/ to .gitignore automatically.

ffmpeg

Piper and say emit WAV/AIFF and use ffmpeg / ffprobe to transcode to MP3 and measure duration. pr-visual already expects ffmpeg for subtitle burning, so there's no new prerequisite.

Story Director

When ANTHROPIC_API_KEY (or CLAUDE_PLUGIN_OPTION_ANTHROPIC_API_KEY) is set, AI scenario generation runs through the Story Director instead of emitting flat step lists. The director picks one of four personas (End User, Admin, New User, Stakeholder) based on the PR content and drafts a three-act narrative arc:

Persona: End User
Setup: A user opens the dashboard expecting today's metrics.
Inciting: They notice a new tile they have never seen before.
Payoff: Clicking the tile reveals a clearer breakdown of the data.
Closing: Users now answer the question without leaving the dashboard.

Each generated scenario carries the matching persona and every step arrives pre-populated with the right beat (setup / action / payoff / close) and emphasis. The annotation layers from Narrative beats and Adaptive pacing then take over.

When no API key is set, the run falls back to the static-routes scenarios (unchanged from before).

Brief cache

The director caches each brief by sha256(prDescription + diff) to .pr-visual/story/<hash>.json. Re-running on an unchanged PR is free. init adds .pr-visual/story/ to .gitignore.

story subcommand

Inspect the brief without recording, or scaffold it to disk:

# Print the human-readable arc for the current branch's PR.
npx pr-visual story
# Same, against an explicit PR number.
npx pr-visual story --pr 42
# Machine-readable JSON to stdout.
npx pr-visual story --pr 42 --json
# Write the full {narrative, scenarios} brief to disk for editing.# Output: .pr-visual/story-scaffold.json
npx pr-visual story --scaffold

The scaffold path is convenient for tweaking the arc by hand before re-running pr-visual — load the JSON yourself and pass it as a hand-authored scenario set.

CLI commands

npx pr-visual [command]
CommandDescription
run (default)Execute the full capture pipeline
initDetect project setup and generate .pr-visual.config.ts
cleanupRemove orphaned worktrees, Docker projects, and stale directories
storyPrint or scaffold the Story Director's brief without recording. Flags: --pr <n>, --scaffold, --json.

Cleanup

If a run is interrupted (Ctrl+C, crash, killed terminal), resources may be left behind. The cleanup command finds and removes them:

npx pr-visual cleanup

This removes:

  • Orphaned git worktrees (pr-visual-* branches and directories)
  • Orphaned Docker Compose projects (containers, networks, volumes named pr-visual-*)
  • Stale worktree parent directories

The recorder also registers signal handlers for SIGINT and SIGTERM, so a normal Ctrl+C during a run will attempt to tear down services and remove the worktree before exiting.

Environment variables

VariableDefaultDescription
ANTHROPIC_API_KEYEnables AI-generated scenarios (falls back to static)
PR_BODYOverride PR body text for scenario generation
PR_VISUAL_CONFIGExplicit path to config file
PR_VISUAL_NO_ISOLATESet to 1 to skip worktree isolation
PR_VISUAL_QUALITYDesktop quality preset override: 720p, 1080p, 2k, 4k. Takes precedence over scenario and project config.
PR_VISUAL_AUTH_DIROverride auth.storageStateDir. Useful when storage state is generated outside the repo.

How it works

Pipeline

┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐
│ Worktree │───▶│ Setup │───▶│ Dev Server │───▶│ Readiness │
│ + install │ │ steps │ │ start │ │ probe │
└─────────────┘ └──────────┘ └───────────┘ └──────┬───────┘
│
┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌─────▼────────┐
│ PR attach │◀───│ Annotate │◀───│ Capture │◀───│ Scenarios │
│ + cleanup │ │ + video │ │ all vars │ │ (AI / diff) │
└─────────────┘ └──────────┘ └───────────┘ └──────────────┘

Isolation model

Each run creates a git worktree at the current commit:

  • Directory: ../.pr-visual-worktrees/pr-visual-<timestamp>-<hex> (outside repo)
  • Branch: pr-visual/pr-visual-<timestamp>-<hex> (temporary)
  • Port: auto-allocated from preferred port upward (scans 100 ports)
  • Docker: COMPOSE_PROJECT_NAME=pr-visual-<timestamp>-<hex> namespaces all resources
  • Dependencies: full install from lockfile in the worktree

Multiple parallel runs get different worktrees, ports, and Docker project names — complete isolation.

Lifecycle

  1. Setup steps — sequential shell commands with per-step timeouts
  2. Dev server — spawned as a detached process group
  3. Readiness probe — polls endpoint until expected status or timeout
  4. Teardown — runs cleanup commands; errors are logged but don't abort

Cleanup guarantees

  • Signal handlers (SIGINT/SIGTERM) run teardown and worktree removal on interrupt
  • Explicit cleanup in finally block for normal completion or exceptions
  • npx pr-visual cleanup as a manual recovery for hard crashes

Troubleshooting

Skills not appearing after install

Run /reload-plugins to refresh the plugin list.

ffmpeg captioning fails

The video captioning feature requires ffmpeg with either libass or subtitles filter support. If neither is available, the plugin gracefully skips captioning and returns the raw video.

To get full captioning support:

brew install ffmpeg

Screenshots show wrong page (i18n sites)

If your site redirects / to a locale path (e.g. /en), configure the routes field in your .pr-visual.config.ts:

exportdefault{readiness: {path: "/en"},routes: [{path: "/en",label: "Homepage"}],};

next: command not found in worktree

Use npx in your dev server command to resolve binaries from node_modules:

exportdefault{devServer: {command: "npx next dev --port {{port}}"},};

Project structure

.claude-plugin/
plugin.json # Plugin manifest
marketplace.json # Plugin marketplace definition
skills/pr-visual/
SKILL.md # Slash command definition
hooks/
hooks.json # PostToolUse hook for gh pr create
bin/
pr-visual # CLI entrypoint
scripts/pr-visual/
index.ts # CLI routing (run | init | cleanup)
types.ts # Shared types (ViewportConfig, ProjectConfig, RunContext, etc.)
config.ts # Config discovery, loading, template substitution
worktree.ts # Git worktree creation, port allocation, cleanup
lifecycle.ts # Setup/teardown steps, dev server, readiness, signal handlers
init.ts # Project detection and config scaffolding
cleanup.ts # Orphaned resource discovery and removal
scenario-generator.ts # Claude API integration for scenario generation
capture.ts # Playwright capture across viewports and color schemes
pr-attach.ts # GitHub PR body patching and comment posting
annotate/
screenshots.ts # sharp + SVG sidebar compositing → WebP
video.ts # ffmpeg ASS caption burning → H.264 MP4

Development

npm ci # install deps + Playwright chromium
npm run typecheck # tsc --noEmit
npm run lint # Biome (lint + format check, fails on warnings)
npm run lint:fix # Biome auto-fix (safe rules) + write
npm run format # Biome format only — write
npm test# vitest run (unit + integration + e2e)

CI runs typecheck, lint, and the full test suite on every PR and on push to master (Node 20). All warnings are treated as errors.

Releases

Releases are created by .github/workflows/release.yml, which runs after the CI workflow finishes successfully on master. A red CI run blocks the release.

A release is cut only when package.jsonversion is bumped above the latest v* git tag. Merging a PR that does not change version does not produce a release — this lets you land refactors, docs, and chore work between shipments.

To cut a release, open a PR that:

  • bumps version in package.json (patch / minor / major as appropriate);
  • bumps version in .claude-plugin/plugin.json to the same value;
  • bumps both version fields in .claude-plugin/marketplace.json to the same value;
  • adds a CHANGELOG.md entry describing the release.

When the PR lands on master and CI passes, the workflow tags the commit v<version> and publishes a GitHub Release with auto-generated notes (merged PRs and commits since the previous tag). The workflow itself never writes to the repository.

If CI passed but the release did not fire (e.g., a transient failure), use the workflow's workflow_dispatch trigger from the Actions tab to re-run it.

License

MIT — see LICENSE for details.

About

Claude Code plugin for visual PR documentation — annotated screenshots and walkthrough videos

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

pr-visual

A Claude Code plugin that captures visual PR documentation: AI-generated Playwright scenarios from the PR description or git diff, annotated screenshots (desktop 2x + mobile 3x, light + dark), and walkthrough videos with burned-in captions.

Each run is isolated in a git worktree with its own port and namespaced resources (Docker containers, networks, volumes), so multiple runs can execute in parallel without collisions.

Prerequisites

  • Node.js 20+
  • ffmpegbrew install ffmpeg (optional — needed for video captions and voice-over transcoding)
  • GitHub CLIbrew install gh
  • Chromium (installed automatically via Playwright on postinstall)

Installation

From Claude Code marketplace (recommended)

/plugin marketplace add gerokeller/pr-visual

Then install the plugin:

/plugin install pr-visual

To share with your team, add this to your project's .claude/settings.json:

{
"extraKnownMarketplaces": {
"pr-visual": {
"source": {
"source": "github",
"repo": "gerokeller/pr-visual"
}
}
},
"enabledPlugins": {
"pr-visual@pr-visual": true
}
}

Via npm

npm install -D pr-visual

Claude Code automatically discovers the plugin via .claude-plugin/plugin.json inside node_modules/pr-visual/. This gives you:

  • /pr-visual slash command
  • PostToolUse hook that reminds you to run it after gh pr create

Quick start

1. Scaffold the config

/pr-visual init

Or via CLI:

npx pr-visual init

This detects your project setup and generates a tailored .pr-visual.config.ts:

pr-visual init: Detecting project setup...
Framework: Next.js
Package manager: pnpm
Docker: yes (postgres, redis)
ORM: prisma
Health endpoint: /api/health
Default port: 3000
Created: .pr-visual.config.ts

2. Review and commit the config

The generated config is ready to use but worth reviewing. Commit it so every team member gets the same behavior.

3. Run it

/pr-visual

Or manually:

npx pr-visual

Configuration

Plugin settings

The plugin accepts the following user configuration (set during plugin install or in settings):

SettingDescription
anthropic_api_keyAPI key for AI-generated scenarios (stored in system keychain). Falls back to ANTHROPIC_API_KEY env var, then to static route capture.

Project config

.pr-visual.config.ts is the contract between your project and the recorder. It declares everything needed to bring up the application from a cold worktree:

importtype{ProjectConfig}from"pr-visual/scripts/pr-visual/types.js";exportdefault{port: 3000,devServer: {command: "npm run dev",env: {PORT: "{{port}}"},},// Setup steps — Docker resources are auto-scoped via COMPOSE_PROJECT_NAMEsetup: [{name: "Start database",command: "docker compose up -d postgres redis"},{name: "Run migrations",command: "npx prisma migrate deploy"},{name: "Seed data",command: "npx prisma db seed"},],readiness: {path: "/api/health",status: 200,timeout: 60_000,},// Teardown — only this run's containers are removedteardown: [{name: "Stop database",command: "docker compose down -v"},],isolate: true,installCommand: "npm ci",}satisfiesProjectConfig;

Template variables

All command strings and env values support these placeholders:

VariableDescription
{{port}}Auto-allocated TCP port for this run
{{runId}}Unique run identifier — safe as Docker project name, DB suffix, directory name
{{rootDir}}Absolute path to the working directory (worktree or project root)

Automatic resource isolation

Every lifecycle step and the dev server receive these environment variables automatically — no manual setup needed:

VariableValuePurpose
COMPOSE_PROJECT_NAME{{runId}}Scopes all Docker Compose containers, networks, and volumes to this run
PORTAllocated portStandard port variable
PR_VISUAL_RUN_ID{{runId}}Available for custom scripts
PR_VISUAL_PORTAllocated portAvailable for custom scripts
PR_VISUAL_ROOT_DIR{{rootDir}}Available for custom scripts

This means docker compose up -d postgres in two parallel runs creates two independent Postgres containers, and each run's docker compose down -v only removes its own.

Config reference

FieldTypeDefaultDescription
portnumber3000Preferred port (auto-incremented if busy)
baseUrlstringhttp://localhost:{{port}}URL template
devServer.commandstringnpm run devDev server command
devServer.envRecordExtra env vars (template substitution)
setupLifecycleStep[]Pre-server steps (Docker, migrations, seeds)
readiness.pathstring/Readiness probe endpoint
readiness.statusnumber200Expected HTTP status
readiness.timeoutnumber45000Max wait time in ms
readiness.intervalnumber1000Probe interval in ms
teardownLifecycleStep[]Post-capture cleanup steps
isolatebooleantrueUse git worktree for isolation
worktreeDirstring../.pr-visual-worktreesWhere to create worktrees
installCommandstringnpm ciInstall command for worktrees
outputDirstring.pr-visualOutput directory (relative to root)
routesArray<string | { path, label }>["/"]Routes for static fallback capture
quality"720p" | "1080p" | "2k" | "4k"Desktop quality preset (see Quality presets)
pacing.wordsPerSecondnumber3.2Reading speed used by adaptive pacing
overlays.cursorbooleanfalseInject a visible custom cursor during capture (see Interaction overlays)
overlays.clicksbooleanfalseEmit a ripple + center dot at each click's coordinates
overlays.highlightsbooleanfalseEnable the highlight scenario step action (pulsing glow + dimmed backdrop)
video.compositing"none" | "remotion""none"Run the recorded clip through a Remotion composition (see Video production)
video.brandColorstring"#3b82f6"Brand accent color for intro/outro/caption-pill chrome
video.categorystringOptional category label rendered as a glassmorphism badge
video.sprintLabelstringOptional sprint / release label rendered subtly in the intro
video.orgNamestringOptional org name rendered in the outro footer
video.highlightsstring[]Optional bullets rendered as a "Key Highlights" card in the outro
video.mobile.enabledbooleanfalseRun a dedicated mobile composite pass after the main matrix and composite both streams (see Mobile composite layouts). Implies compositing: "remotion".
video.mobile.viewport{ width, height }{ 390, 844 }Mobile pass viewport
video.mobile.deviceScaleFactornumber3Mobile pass DPR
video.mobile.layout"side-by-side" | "pip" | "sequential""side-by-side"Composition layout
auth.storageStateDirstring".pr-visual/auth"Directory holding Playwright storage state files (see Authenticated demos)
auth.profilesRecord<string, string>Named profiles → relative storage state file paths
auth.tokenGeneratorLifecycleStepOptional command run after setup and before devServer to refresh storage state
pomsRecord<string, string>Page Object Model registry. Keys are referenced from pom scenario steps (see Page Object Models). Values are module paths relative to the project root.
voiceover.enabledbooleanfalseSynthesize per-step audio and mix it into the composited MP4 (see Voice-over). Implies compositing: "remotion".
voiceover.provider"piper" | "google" | "openai" | "say"Explicit provider. Defaults to the first available in detection order.
voiceover.voicestring(per-provider)Provider-specific voice name (e.g. en-US-Neural2-F, alloy, Samantha).
voiceover.cacheDirstring".pr-visual/tts"Audio cache directory (relative to project root). Content-hash keyed; re-runs with unchanged captions skip synthesis.

Minimal config examples

Next.js (zero-setup):

exportdefault{devServer: {command: "npm run dev"}};

Vite + Docker Postgres:

exportdefault{port: 5173,devServer: {command: "npx vite --port {{port}}"},setup: [{name: "DB",command: "docker compose up -d db",timeout: 30_000},{name: "Migrate",command: "npx prisma migrate deploy"},],teardown: [{name: "DB down",command: "docker compose down -v"},],readiness: {path: "/api/health"},};

i18n site (content at /en):

exportdefault{devServer: {command: "npx next dev --port {{port}}"},readiness: {path: "/en"},routes: [{path: "/en",label: "Homepage"},{path: "/en/about",label: "About"},],};

Monorepo (custom cwd):

exportdefault{devServer: {command: "turbo dev --filter=web",cwd: "apps/web"},setup: [{name: "Build packages",command: "turbo build --filter=web^..."},],};

Quality presets

By default the desktop capture runs at 1440×900 @2x. You can bump this to a named preset to get higher-resolution video and screenshots. The preset sets the logical viewport (CSS pixels); final output dimensions are viewport × deviceScaleFactor (DSF stays at 2 by default).

PresetViewportOutput (DSF=2)
720p1280×7202560×1440
1080p1920×10803840×2160
2k2560×14405120×2880
4k3840×21607680×4320

Mobile capture is not affected by quality presets.

Project-wide default in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},quality: "1080p",}satisfiesProjectConfig;

Per-scenario override (AI-generated or hand-authored scenarios):

{name: "Checkout flow",description: "...",quality: "2k",// preset wins over viewportsteps: [/* ... */],}

Explicit viewport override (when a preset doesn't fit):

{name: "Tablet layout",description: "...",viewport: {width: 1024,height: 768,deviceScaleFactor: 2},steps: [/* ... */],}

One-off env override — useful in CI or for spot checks:

PR_VISUAL_QUALITY=4k npx pr-visual

Precedence (highest wins):

  1. PR_VISUAL_QUALITY env var
  2. scenario.quality
  3. scenario.viewport
  4. projectConfig.quality
  5. Built-in default (1440×900 @2x)

An unknown preset value (env, scenario, or project) fails hard with a clear error.

Adaptive pacing

Each step holds on-screen long enough for viewers to read the caption and absorb the change, scaled by an explicit pacing hint. The hold is computed from the caption's reading time, the action type (first-navigation gets extra breathing room; type scales with value length), a transition cushion when the action changes, and the pacing mode.

Modes (multiplier / floor / cap in ms):

ModeMultiplierFloorCap
quick0.6×9004000
normal(default)1.0×17008000
slow1.5×220010000
dramatic2.0×320012000

dramatic also inserts an 800ms pre-action settle before the step fires, to build anticipation.

Per-step:

{action: "click",selector: "#checkout",caption: "Confirm the order",pacing: "dramatic",// the final beat — let it land}

Project-level reading speed in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},pacing: {wordsPerSecond: 2.8},// slower — for non-native audiences}satisfiesProjectConfig;

Captions of six words or fewer are read proportionally faster (+0.6 w/s) so short beats don't linger.

Narrative beats

Scenarios can tag each step with a beatsetup, action, payoff, or close — to mark where the step sits in the story arc. The annotation layer picks these up:

  • Video: a brief 700ms title-card chip fades in whenever the beat changes between two consecutive steps (so three distinct beats produce two chips).
  • Screenshots: the sidebar shows the beat label under the viewport badge.

Beats also enforce a minimum hold in the pacing formula (setup 1200ms, action 1800ms, payoff 2800ms, close 2200ms), so a payoff step earns scene-length breathing room even under quick pacing.

Emphasis

Each step can also carry emphasis: "strong" to render as a larger title-card caption (1.5× the base caption font, bolder weight). Use it on the key moments you want viewers to remember — usually a payoff beat.

{action: "screenshot",caption: "The deal is closed",beat: "payoff",emphasis: "strong",pacing: "dramatic",}

Persona

Scenarios can carry an audience label via persona: "Agency PM" (any free-form string). This is stored on the scenario for later use by the Remotion intro composer and the Story Director. It does not render directly in the current annotation layer.

{name: "New client onboarding",description: "...",persona: "Agency PM",steps: [/* ... */],}

Invalid beat, emphasis, or pacing values fail the run with a clear error before capture starts.

Interaction overlays

By default, pr-visual captures clean recordings with no cursor or click indicators. If you want your videos to look human-driven, opt into one or more overlays in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},overlays: {cursor: true,// visible custom cursor tracking the mouseclicks: true,// ripple + center dot at each clickhighlights: true,// enables the `highlight` scenario step},}satisfiesProjectConfig;

Each flag is independent; all default to false so existing users see no change.

highlight step

When overlays.highlights: true, scenarios can use a new step action that pulses a glow ring around a selector while dimming the rest of the viewport:

{action: "highlight",selector: "#primary-cta",duration: 1500,// ms; defaults to 1500 when omittedcaption: "The primary call to action",beat: "payoff",}

The highlight runs for duration ms; the scenario's pacing hold starts after cleanup.

Capture-time DOM injection

Overlays are injected into the page during capture (unlike the post-capture sidebar and ASS caption layers), so they appear in the recorded video at the right moment. The trade-off: an active cursor or highlight will be visible in screenshots taken right after a navigate. If you want clean screenshots alongside an overlay-rich video, leave overlays.cursor off.

Mobile viewports automatically use a touch-style cursor and tap-ring animations.

Video production

By default the captioned MP4 is the final video artifact. Opt in to a polished Remotion composition (animated intro, crossfades, glassmorphism caption pill, outro with step summary) per scenario or project-wide:

exportdefault{devServer: {command: "npm run dev"},video: {compositing: "remotion",brandColor: "#3b82f6",category: "Checkout",sprintLabel: "Sprint 12",orgName: "Acme Co",highlights: ["Faster checkout","Cleaner cart"],},}satisfiesProjectConfig;

Optional peer dependencies

The Remotion stack is intentionally not a baseline dependency — npm i pr-visual stays small for users who only need captioned recordings. Install the peer deps when you want compositing:

npm i -D remotion @remotion/bundler @remotion/renderer react react-dom

If video.compositing: "remotion" is set but the peer deps aren't installed, pr-visual prints a clear warning and falls back to the captioned MP4. The run still succeeds.

What gets composited

  • Compositing runs on the desktop + light variant only. Mobile composite layouts arrive in #6; the other three variants stay raw.
  • Output is written next to the captioned MP4 as <scenario>-composited.mp4 (H.264, CRF 16).
  • When a composited video exists, the PR comment uses it for the desktop+light slot; other variants keep the captioned MP4.

Adaptive intro/outro length

Intro and outro durations scale with the title + description word count and the number of annotated steps (reading speed 3 w/s), clamped to sensible bounds (intro 3-8s, outro 4-12s).

Mobile composite layouts

Set video.mobile.enabled: true to run a dedicated mobile pass after the main matrix and composite both streams into one MP4. Setting mobile.enabled also implies compositing: "remotion" so a single flag covers the common case.

exportdefault{devServer: {command: "npm run dev"},video: {mobile: {enabled: true,layout: "side-by-side"},},}satisfiesProjectConfig;

Layouts

  • side-by-side (default): desktop 80% + phone 20% in a stylized device frame. The canvas widens by 25% to fit both columns at near-native size.
  • pip: phone bottom-right over fullscreen desktop. Canvas dimensions unchanged.
  • sequential: desktop for the first half of the recording, phone for the second. Canvas dimensions unchanged.

Per-step mobile overrides

Scenarios can tweak the mobile pass without forking the script:

{action: "navigate",url: "/",mobilePath: "/m",caption: "Open"}{action: "click",selector: "#desktop-cta",mobileSelector: "#mobile-cta",caption: "Tap CTA"}{action: "highlight",selector: "#desktop-only",mobileSkip: true,caption: "Hover hint"}
  • mobilePath: rewrites the navigate URL on mobile.
  • mobileSelector: swaps the selector on mobile.
  • mobileSkip: omits the step from the mobile pass entirely.

Wall-clock cost

The mobile pass is sequential (separate browser context, fresh navigation), so runs with mobile compositing take ~1.8x the wall-clock of desktop-only runs. The pipeline prints a heads-up when mobile compositing fires.

If the mobile pass throws (selector missing, navigation fails), the compositing step aborts and the captioned MP4 stays as the final artifact — the run otherwise succeeds.

Authenticated demos

pr-visual is framework-agnostic about auth: you supply Playwright storage state JSON files, name them as profiles in .pr-visual.config.ts, and scenarios opt in via scenario.profile. The captured matrix variants and the mobile composite pass all load the same storage state.

exportdefault{devServer: {command: "npm run dev"},auth: {storageStateDir: ".pr-visual/auth",// defaultprofiles: {admin: "admin.json",viewer: "viewer.json",},// Optional — runs after `setup` and before `devServer`. Use it to// refresh storage state per run; pr-visual just calls the command.tokenGenerator: {name: "Refresh storage state",command: "node scripts/refresh-auth.mjs",},},}satisfiesProjectConfig;
// In your scenario:{name: "Admin dashboard tour",description: "...",profile: "admin",steps: [/* ... */],}

npx pr-visual init adds .pr-visual/auth/ to .gitignore automatically — storage state files contain session tokens.

Generating storage state

How you produce the JSON files is up to you. Two common patterns:

Pattern 1: Playwright login script

Run a one-off Playwright script that drives the login UI and saves the context state:

// scripts/refresh-auth.mjsimport{chromium}from"playwright";constbrowser=awaitchromium.launch();constctx=awaitbrowser.newContext();constpage=awaitctx.newPage();awaitpage.goto("http://localhost:3000/login");awaitpage.getByLabel("Email").fill("admin@example.com");awaitpage.getByLabel("Password").fill(process.env.ADMIN_PASSWORD);awaitpage.getByRole("button",{name: "Sign in"}).click();awaitpage.waitForURL("**/dashboard");awaitctx.storageState({path: ".pr-visual/auth/admin.json"});awaitbrowser.close();

Pattern 2: Supabase admin API (no browser needed)

// scripts/refresh-auth.mjsimportfsfrom"node:fs";import{createClient}from"@supabase/supabase-js";constsupabase=createClient(process.env.SUPABASE_URL,process.env.SUPABASE_SERVICE_ROLE_KEY,);const{ data, error }=awaitsupabase.auth.admin.generateLink({type: "magiclink",email: "admin@example.com",});if(error)throwerror;// Build a Playwright storage-state JSON with the supabase localStorage entry,// keyed `sb-<project-ref>-auth-token`. Shape per Supabase JS docs.constsession={access_token: data.properties.action_link,/* ... */};constprojectRef=newURL(process.env.SUPABASE_URL).hostname.split(".")[0];fs.writeFileSync(".pr-visual/auth/admin.json",JSON.stringify({cookies: [],origins: [{origin: "http://localhost:3000",localStorage: [{name: `sb-${projectRef}-auth-token`,value: JSON.stringify({currentSession: session}),}],}],}),);

Anything that writes a Playwright storage-state JSON works. The tokenGenerator step is templated with {{runId}}, {{port}}, {{rootDir}} like other lifecycle steps.

Validation

After the generator runs, pr-visual verifies every configured profile points at a readable JSON file. A missing or malformed file fails the run before capture starts, so a silently-broken generator surfaces immediately.

The PR_VISUAL_AUTH_DIR env var overrides storageStateDir — useful when storage state is generated outside the repo.

Page Object Models

Non-trivial real-world demos often need multi-step orchestration (dismiss a modal, wait for data, assert an intermediate state). Rather than duplicating that logic in every scenario, point pr-visual at your existing E2E Page Object Model modules:

// .pr-visual.config.tsexportdefault{devServer: {command: "npm run dev"},poms: {dashboard: "./e2e/pages/dashboard.ts",checkout: "./e2e/pages/checkout.ts",},}satisfiesProjectConfig;
// ./e2e/pages/dashboard.ts — pr-visual expects plain functions.// Each function receives the Playwright `Page` as the first argument// plus any user arguments defined on the scenario step.importtype{Page}from"playwright";exportasyncfunctionlogin(page: Page,email: string): Promise<void>{awaitpage.getByLabel("Email").fill(email);awaitpage.getByLabel("Password").fill(process.env.DEMO_PASSWORD!);awaitpage.getByRole("button",{name: "Sign in"}).click();awaitpage.waitForURL("**/dashboard");}exportasyncfunctionopenInbox(page: Page): Promise<void>{awaitpage.getByRole("link",{name: "Inbox"}).click();awaitpage.waitForSelector("[data-testid=inbox-list]");}

Then use them in scenarios:

{name: "Inbox tour",description: "...",steps: [{action: "navigate",url: "/",caption: "Open the app"},{action: "pom",page: "dashboard",method: "login",args: ["demo@example.com"],caption: "Sign in",},{action: "pom",page: "dashboard",method: "openInbox",caption: "Open the inbox",},{action: "screenshot",caption: "Inbox view"},],}

Contract

  • Each registered module exports named functions shaped as (page: Page, ...args: unknown[]) => void | Promise<void>.
  • Classes are not supported directly (predictable stateless lifecycle). Wrap them with a thin factory if you need class-based POMs.
  • args on the scenario step is an array forwarded positionally after page. Omitted args means the function is called with just (page).

Validation

pr-visual loads POM modules eagerly at scenario-validation time, so unknown page names, unknown method names, and import failures surface as pre-capture errors instead of runtime crashes deep in the capture loop.

Overlay interaction

  • Custom cursor tracking (when overlays.cursor: true) works inside POM methods automatically — the mousemove listener follows any Playwright-driven movement.
  • Click ripples and highlight spotlights do not fire inside POM methods. Those overlays are injected at the call site in pr-visual's step executor, not globally. If you want a ripple on a POM-internal click, add an explicit click step for that interaction instead.

Voice-over

Step captions become narration in the composited MP4. Each caption is synthesized to an MP3 and mixed into the Remotion composition, anchored to the start of that step on the video timeline. Setting voiceover.enabled: true also implies compositing: "remotion".

exportdefault{devServer: {command: "npm run dev"},voiceover: {enabled: true,// Leave `provider` / `voice` out to auto-detect the first available.},}satisfiesProjectConfig;

Provider chain

Detection order (first available wins — the MP4 uses one provider throughout):

  1. Piper — local neural TTS, offline, no account. Requires piper on PATH and a voice model. Point PIPER_MODEL at an .onnx file, or drop one into ~/.cache/piper/voices/.
  2. Google Cloud TTS — OAuth via gcloud. Requires gcloud auth application-default login. Default voice en-US-Neural2-F.
  3. OpenAI TTSOPENAI_API_KEY env var. Default voice alloy.
  4. macOS say — always available on macOS. Default voice Samantha.

Override via voiceover.provider; that provider is then used regardless of detection order. Per-clip synthesis failures log a warning and skip that step — the rest of the MP4 still narrates.

If no provider is available, the run fails with an explicit error listing the install options.

Caching

Clips are cached at .pr-visual/tts/step-NN-<hash>.mp3, keyed by sha256(provider + caption text). Re-running a scenario with unchanged captions is essentially free. Switching provider invalidates the cache for that step (the hash changes).

npx pr-visual init adds .pr-visual/tts/ to .gitignore automatically.

ffmpeg

Piper and say emit WAV/AIFF and use ffmpeg / ffprobe to transcode to MP3 and measure duration. pr-visual already expects ffmpeg for subtitle burning, so there's no new prerequisite.

Story Director

When ANTHROPIC_API_KEY (or CLAUDE_PLUGIN_OPTION_ANTHROPIC_API_KEY) is set, AI scenario generation runs through the Story Director instead of emitting flat step lists. The director picks one of four personas (End User, Admin, New User, Stakeholder) based on the PR content and drafts a three-act narrative arc:

Persona: End User
Setup: A user opens the dashboard expecting today's metrics.
Inciting: They notice a new tile they have never seen before.
Payoff: Clicking the tile reveals a clearer breakdown of the data.
Closing: Users now answer the question without leaving the dashboard.

Each generated scenario carries the matching persona and every step arrives pre-populated with the right beat (setup / action / payoff / close) and emphasis. The annotation layers from Narrative beats and Adaptive pacing then take over.

When no API key is set, the run falls back to the static-routes scenarios (unchanged from before).

Brief cache

The director caches each brief by sha256(prDescription + diff) to .pr-visual/story/<hash>.json. Re-running on an unchanged PR is free. init adds .pr-visual/story/ to .gitignore.

story subcommand

Inspect the brief without recording, or scaffold it to disk:

# Print the human-readable arc for the current branch's PR.
npx pr-visual story
# Same, against an explicit PR number.
npx pr-visual story --pr 42
# Machine-readable JSON to stdout.
npx pr-visual story --pr 42 --json
# Write the full {narrative, scenarios} brief to disk for editing.# Output: .pr-visual/story-scaffold.json
npx pr-visual story --scaffold

The scaffold path is convenient for tweaking the arc by hand before re-running pr-visual — load the JSON yourself and pass it as a hand-authored scenario set.

CLI commands

npx pr-visual [command]
CommandDescription
run (default)Execute the full capture pipeline
initDetect project setup and generate .pr-visual.config.ts
cleanupRemove orphaned worktrees, Docker projects, and stale directories
storyPrint or scaffold the Story Director's brief without recording. Flags: --pr <n>, --scaffold, --json.

Cleanup

If a run is interrupted (Ctrl+C, crash, killed terminal), resources may be left behind. The cleanup command finds and removes them:

npx pr-visual cleanup

This removes:

  • Orphaned git worktrees (pr-visual-* branches and directories)
  • Orphaned Docker Compose projects (containers, networks, volumes named pr-visual-*)
  • Stale worktree parent directories

The recorder also registers signal handlers for SIGINT and SIGTERM, so a normal Ctrl+C during a run will attempt to tear down services and remove the worktree before exiting.

Environment variables

VariableDefaultDescription
ANTHROPIC_API_KEYEnables AI-generated scenarios (falls back to static)
PR_BODYOverride PR body text for scenario generation
PR_VISUAL_CONFIGExplicit path to config file
PR_VISUAL_NO_ISOLATESet to 1 to skip worktree isolation
PR_VISUAL_QUALITYDesktop quality preset override: 720p, 1080p, 2k, 4k. Takes precedence over scenario and project config.
PR_VISUAL_AUTH_DIROverride auth.storageStateDir. Useful when storage state is generated outside the repo.

How it works

Pipeline

┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐
│ Worktree │───▶│ Setup │───▶│ Dev Server │───▶│ Readiness │
│ + install │ │ steps │ │ start │ │ probe │
└─────────────┘ └──────────┘ └───────────┘ └──────┬───────┘
│
┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌─────▼────────┐
│ PR attach │◀───│ Annotate │◀───│ Capture │◀───│ Scenarios │
│ + cleanup │ │ + video │ │ all vars │ │ (AI / diff) │
└─────────────┘ └──────────┘ └───────────┘ └──────────────┘

Isolation model

Each run creates a git worktree at the current commit:

  • Directory: ../.pr-visual-worktrees/pr-visual-<timestamp>-<hex> (outside repo)
  • Branch: pr-visual/pr-visual-<timestamp>-<hex> (temporary)
  • Port: auto-allocated from preferred port upward (scans 100 ports)
  • Docker: COMPOSE_PROJECT_NAME=pr-visual-<timestamp>-<hex> namespaces all resources
  • Dependencies: full install from lockfile in the worktree

Multiple parallel runs get different worktrees, ports, and Docker project names — complete isolation.

Lifecycle

  1. Setup steps — sequential shell commands with per-step timeouts
  2. Dev server — spawned as a detached process group
  3. Readiness probe — polls endpoint until expected status or timeout
  4. Teardown — runs cleanup commands; errors are logged but don't abort

Cleanup guarantees

  • Signal handlers (SIGINT/SIGTERM) run teardown and worktree removal on interrupt
  • Explicit cleanup in finally block for normal completion or exceptions
  • npx pr-visual cleanup as a manual recovery for hard crashes

Troubleshooting

Skills not appearing after install

Run /reload-plugins to refresh the plugin list.

ffmpeg captioning fails

The video captioning feature requires ffmpeg with either libass or subtitles filter support. If neither is available, the plugin gracefully skips captioning and returns the raw video.

To get full captioning support:

brew install ffmpeg

Screenshots show wrong page (i18n sites)

If your site redirects / to a locale path (e.g. /en), configure the routes field in your .pr-visual.config.ts:

exportdefault{readiness: {path: "/en"},routes: [{path: "/en",label: "Homepage"}],};

next: command not found in worktree

Use npx in your dev server command to resolve binaries from node_modules:

exportdefault{devServer: {command: "npx next dev --port {{port}}"},};

Project structure

.claude-plugin/
plugin.json # Plugin manifest
marketplace.json # Plugin marketplace definition
skills/pr-visual/
SKILL.md # Slash command definition
hooks/
hooks.json # PostToolUse hook for gh pr create
bin/
pr-visual # CLI entrypoint
scripts/pr-visual/
index.ts # CLI routing (run | init | cleanup)
types.ts # Shared types (ViewportConfig, ProjectConfig, RunContext, etc.)
config.ts # Config discovery, loading, template substitution
worktree.ts # Git worktree creation, port allocation, cleanup
lifecycle.ts # Setup/teardown steps, dev server, readiness, signal handlers
init.ts # Project detection and config scaffolding
cleanup.ts # Orphaned resource discovery and removal
scenario-generator.ts # Claude API integration for scenario generation
capture.ts # Playwright capture across viewports and color schemes
pr-attach.ts # GitHub PR body patching and comment posting
annotate/
screenshots.ts # sharp + SVG sidebar compositing → WebP
video.ts # ffmpeg ASS caption burning → H.264 MP4

Development

npm ci # install deps + Playwright chromium
npm run typecheck # tsc --noEmit
npm run lint # Biome (lint + format check, fails on warnings)
npm run lint:fix # Biome auto-fix (safe rules) + write
npm run format # Biome format only — write
npm test# vitest run (unit + integration + e2e)

CI runs typecheck, lint, and the full test suite on every PR and on push to master (Node 20). All warnings are treated as errors.

Releases

Releases are created by .github/workflows/release.yml, which runs after the CI workflow finishes successfully on master. A red CI run blocks the release.

A release is cut only when package.jsonversion is bumped above the latest v* git tag. Merging a PR that does not change version does not produce a release — this lets you land refactors, docs, and chore work between shipments.

To cut a release, open a PR that:

  • bumps version in package.json (patch / minor / major as appropriate);
  • bumps version in .claude-plugin/plugin.json to the same value;
  • bumps both version fields in .claude-plugin/marketplace.json to the same value;
  • adds a CHANGELOG.md entry describing the release.

When the PR lands on master and CI passes, the workflow tags the commit v<version> and publishes a GitHub Release with auto-generated notes (merged PRs and commits since the previous tag). The workflow itself never writes to the repository.

If CI passed but the release did not fire (e.g., a transient failure), use the workflow's workflow_dispatch trigger from the Actions tab to re-run it.

License

MIT — see LICENSE for details.

About

Claude Code plugin for visual PR documentation — annotated screenshots and walkthrough videos

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

pr-visual

A Claude Code plugin that captures visual PR documentation: AI-generated Playwright scenarios from the PR description or git diff, annotated screenshots (desktop 2x + mobile 3x, light + dark), and walkthrough videos with burned-in captions.

Each run is isolated in a git worktree with its own port and namespaced resources (Docker containers, networks, volumes), so multiple runs can execute in parallel without collisions.

Prerequisites

  • Node.js 20+
  • ffmpegbrew install ffmpeg (optional — needed for video captions and voice-over transcoding)
  • GitHub CLIbrew install gh
  • Chromium (installed automatically via Playwright on postinstall)

Installation

From Claude Code marketplace (recommended)

/plugin marketplace add gerokeller/pr-visual

Then install the plugin:

/plugin install pr-visual

To share with your team, add this to your project's .claude/settings.json:

{
"extraKnownMarketplaces": {
"pr-visual": {
"source": {
"source": "github",
"repo": "gerokeller/pr-visual"
}
}
},
"enabledPlugins": {
"pr-visual@pr-visual": true
}
}

Via npm

npm install -D pr-visual

Claude Code automatically discovers the plugin via .claude-plugin/plugin.json inside node_modules/pr-visual/. This gives you:

  • /pr-visual slash command
  • PostToolUse hook that reminds you to run it after gh pr create

Quick start

1. Scaffold the config

/pr-visual init

Or via CLI:

npx pr-visual init

This detects your project setup and generates a tailored .pr-visual.config.ts:

pr-visual init: Detecting project setup...
Framework: Next.js
Package manager: pnpm
Docker: yes (postgres, redis)
ORM: prisma
Health endpoint: /api/health
Default port: 3000
Created: .pr-visual.config.ts

2. Review and commit the config

The generated config is ready to use but worth reviewing. Commit it so every team member gets the same behavior.

3. Run it

/pr-visual

Or manually:

npx pr-visual

Configuration

Plugin settings

The plugin accepts the following user configuration (set during plugin install or in settings):

SettingDescription
anthropic_api_keyAPI key for AI-generated scenarios (stored in system keychain). Falls back to ANTHROPIC_API_KEY env var, then to static route capture.

Project config

.pr-visual.config.ts is the contract between your project and the recorder. It declares everything needed to bring up the application from a cold worktree:

importtype{ProjectConfig}from"pr-visual/scripts/pr-visual/types.js";exportdefault{port: 3000,devServer: {command: "npm run dev",env: {PORT: "{{port}}"},},// Setup steps — Docker resources are auto-scoped via COMPOSE_PROJECT_NAMEsetup: [{name: "Start database",command: "docker compose up -d postgres redis"},{name: "Run migrations",command: "npx prisma migrate deploy"},{name: "Seed data",command: "npx prisma db seed"},],readiness: {path: "/api/health",status: 200,timeout: 60_000,},// Teardown — only this run's containers are removedteardown: [{name: "Stop database",command: "docker compose down -v"},],isolate: true,installCommand: "npm ci",}satisfiesProjectConfig;

Template variables

All command strings and env values support these placeholders:

VariableDescription
{{port}}Auto-allocated TCP port for this run
{{runId}}Unique run identifier — safe as Docker project name, DB suffix, directory name
{{rootDir}}Absolute path to the working directory (worktree or project root)

Automatic resource isolation

Every lifecycle step and the dev server receive these environment variables automatically — no manual setup needed:

VariableValuePurpose
COMPOSE_PROJECT_NAME{{runId}}Scopes all Docker Compose containers, networks, and volumes to this run
PORTAllocated portStandard port variable
PR_VISUAL_RUN_ID{{runId}}Available for custom scripts
PR_VISUAL_PORTAllocated portAvailable for custom scripts
PR_VISUAL_ROOT_DIR{{rootDir}}Available for custom scripts

This means docker compose up -d postgres in two parallel runs creates two independent Postgres containers, and each run's docker compose down -v only removes its own.

Config reference

FieldTypeDefaultDescription
portnumber3000Preferred port (auto-incremented if busy)
baseUrlstringhttp://localhost:{{port}}URL template
devServer.commandstringnpm run devDev server command
devServer.envRecordExtra env vars (template substitution)
setupLifecycleStep[]Pre-server steps (Docker, migrations, seeds)
readiness.pathstring/Readiness probe endpoint
readiness.statusnumber200Expected HTTP status
readiness.timeoutnumber45000Max wait time in ms
readiness.intervalnumber1000Probe interval in ms
teardownLifecycleStep[]Post-capture cleanup steps
isolatebooleantrueUse git worktree for isolation
worktreeDirstring../.pr-visual-worktreesWhere to create worktrees
installCommandstringnpm ciInstall command for worktrees
outputDirstring.pr-visualOutput directory (relative to root)
routesArray<string | { path, label }>["/"]Routes for static fallback capture
quality"720p" | "1080p" | "2k" | "4k"Desktop quality preset (see Quality presets)
pacing.wordsPerSecondnumber3.2Reading speed used by adaptive pacing
overlays.cursorbooleanfalseInject a visible custom cursor during capture (see Interaction overlays)
overlays.clicksbooleanfalseEmit a ripple + center dot at each click's coordinates
overlays.highlightsbooleanfalseEnable the highlight scenario step action (pulsing glow + dimmed backdrop)
video.compositing"none" | "remotion""none"Run the recorded clip through a Remotion composition (see Video production)
video.brandColorstring"#3b82f6"Brand accent color for intro/outro/caption-pill chrome
video.categorystringOptional category label rendered as a glassmorphism badge
video.sprintLabelstringOptional sprint / release label rendered subtly in the intro
video.orgNamestringOptional org name rendered in the outro footer
video.highlightsstring[]Optional bullets rendered as a "Key Highlights" card in the outro
video.mobile.enabledbooleanfalseRun a dedicated mobile composite pass after the main matrix and composite both streams (see Mobile composite layouts). Implies compositing: "remotion".
video.mobile.viewport{ width, height }{ 390, 844 }Mobile pass viewport
video.mobile.deviceScaleFactornumber3Mobile pass DPR
video.mobile.layout"side-by-side" | "pip" | "sequential""side-by-side"Composition layout
auth.storageStateDirstring".pr-visual/auth"Directory holding Playwright storage state files (see Authenticated demos)
auth.profilesRecord<string, string>Named profiles → relative storage state file paths
auth.tokenGeneratorLifecycleStepOptional command run after setup and before devServer to refresh storage state
pomsRecord<string, string>Page Object Model registry. Keys are referenced from pom scenario steps (see Page Object Models). Values are module paths relative to the project root.
voiceover.enabledbooleanfalseSynthesize per-step audio and mix it into the composited MP4 (see Voice-over). Implies compositing: "remotion".
voiceover.provider"piper" | "google" | "openai" | "say"Explicit provider. Defaults to the first available in detection order.
voiceover.voicestring(per-provider)Provider-specific voice name (e.g. en-US-Neural2-F, alloy, Samantha).
voiceover.cacheDirstring".pr-visual/tts"Audio cache directory (relative to project root). Content-hash keyed; re-runs with unchanged captions skip synthesis.

Minimal config examples

Next.js (zero-setup):

exportdefault{devServer: {command: "npm run dev"}};

Vite + Docker Postgres:

exportdefault{port: 5173,devServer: {command: "npx vite --port {{port}}"},setup: [{name: "DB",command: "docker compose up -d db",timeout: 30_000},{name: "Migrate",command: "npx prisma migrate deploy"},],teardown: [{name: "DB down",command: "docker compose down -v"},],readiness: {path: "/api/health"},};

i18n site (content at /en):

exportdefault{devServer: {command: "npx next dev --port {{port}}"},readiness: {path: "/en"},routes: [{path: "/en",label: "Homepage"},{path: "/en/about",label: "About"},],};

Monorepo (custom cwd):

exportdefault{devServer: {command: "turbo dev --filter=web",cwd: "apps/web"},setup: [{name: "Build packages",command: "turbo build --filter=web^..."},],};

Quality presets

By default the desktop capture runs at 1440×900 @2x. You can bump this to a named preset to get higher-resolution video and screenshots. The preset sets the logical viewport (CSS pixels); final output dimensions are viewport × deviceScaleFactor (DSF stays at 2 by default).

PresetViewportOutput (DSF=2)
720p1280×7202560×1440
1080p1920×10803840×2160
2k2560×14405120×2880
4k3840×21607680×4320

Mobile capture is not affected by quality presets.

Project-wide default in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},quality: "1080p",}satisfiesProjectConfig;

Per-scenario override (AI-generated or hand-authored scenarios):

{name: "Checkout flow",description: "...",quality: "2k",// preset wins over viewportsteps: [/* ... */],}

Explicit viewport override (when a preset doesn't fit):

{name: "Tablet layout",description: "...",viewport: {width: 1024,height: 768,deviceScaleFactor: 2},steps: [/* ... */],}

One-off env override — useful in CI or for spot checks:

PR_VISUAL_QUALITY=4k npx pr-visual

Precedence (highest wins):

  1. PR_VISUAL_QUALITY env var
  2. scenario.quality
  3. scenario.viewport
  4. projectConfig.quality
  5. Built-in default (1440×900 @2x)

An unknown preset value (env, scenario, or project) fails hard with a clear error.

Adaptive pacing

Each step holds on-screen long enough for viewers to read the caption and absorb the change, scaled by an explicit pacing hint. The hold is computed from the caption's reading time, the action type (first-navigation gets extra breathing room; type scales with value length), a transition cushion when the action changes, and the pacing mode.

Modes (multiplier / floor / cap in ms):

ModeMultiplierFloorCap
quick0.6×9004000
normal(default)1.0×17008000
slow1.5×220010000
dramatic2.0×320012000

dramatic also inserts an 800ms pre-action settle before the step fires, to build anticipation.

Per-step:

{action: "click",selector: "#checkout",caption: "Confirm the order",pacing: "dramatic",// the final beat — let it land}

Project-level reading speed in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},pacing: {wordsPerSecond: 2.8},// slower — for non-native audiences}satisfiesProjectConfig;

Captions of six words or fewer are read proportionally faster (+0.6 w/s) so short beats don't linger.

Narrative beats

Scenarios can tag each step with a beatsetup, action, payoff, or close — to mark where the step sits in the story arc. The annotation layer picks these up:

  • Video: a brief 700ms title-card chip fades in whenever the beat changes between two consecutive steps (so three distinct beats produce two chips).
  • Screenshots: the sidebar shows the beat label under the viewport badge.

Beats also enforce a minimum hold in the pacing formula (setup 1200ms, action 1800ms, payoff 2800ms, close 2200ms), so a payoff step earns scene-length breathing room even under quick pacing.

Emphasis

Each step can also carry emphasis: "strong" to render as a larger title-card caption (1.5× the base caption font, bolder weight). Use it on the key moments you want viewers to remember — usually a payoff beat.

{action: "screenshot",caption: "The deal is closed",beat: "payoff",emphasis: "strong",pacing: "dramatic",}

Persona

Scenarios can carry an audience label via persona: "Agency PM" (any free-form string). This is stored on the scenario for later use by the Remotion intro composer and the Story Director. It does not render directly in the current annotation layer.

{name: "New client onboarding",description: "...",persona: "Agency PM",steps: [/* ... */],}

Invalid beat, emphasis, or pacing values fail the run with a clear error before capture starts.

Interaction overlays

By default, pr-visual captures clean recordings with no cursor or click indicators. If you want your videos to look human-driven, opt into one or more overlays in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},overlays: {cursor: true,// visible custom cursor tracking the mouseclicks: true,// ripple + center dot at each clickhighlights: true,// enables the `highlight` scenario step},}satisfiesProjectConfig;

Each flag is independent; all default to false so existing users see no change.

highlight step

When overlays.highlights: true, scenarios can use a new step action that pulses a glow ring around a selector while dimming the rest of the viewport:

{action: "highlight",selector: "#primary-cta",duration: 1500,// ms; defaults to 1500 when omittedcaption: "The primary call to action",beat: "payoff",}

The highlight runs for duration ms; the scenario's pacing hold starts after cleanup.

Capture-time DOM injection

Overlays are injected into the page during capture (unlike the post-capture sidebar and ASS caption layers), so they appear in the recorded video at the right moment. The trade-off: an active cursor or highlight will be visible in screenshots taken right after a navigate. If you want clean screenshots alongside an overlay-rich video, leave overlays.cursor off.

Mobile viewports automatically use a touch-style cursor and tap-ring animations.

Video production

By default the captioned MP4 is the final video artifact. Opt in to a polished Remotion composition (animated intro, crossfades, glassmorphism caption pill, outro with step summary) per scenario or project-wide:

exportdefault{devServer: {command: "npm run dev"},video: {compositing: "remotion",brandColor: "#3b82f6",category: "Checkout",sprintLabel: "Sprint 12",orgName: "Acme Co",highlights: ["Faster checkout","Cleaner cart"],},}satisfiesProjectConfig;

Optional peer dependencies

The Remotion stack is intentionally not a baseline dependency — npm i pr-visual stays small for users who only need captioned recordings. Install the peer deps when you want compositing:

npm i -D remotion @remotion/bundler @remotion/renderer react react-dom

If video.compositing: "remotion" is set but the peer deps aren't installed, pr-visual prints a clear warning and falls back to the captioned MP4. The run still succeeds.

What gets composited

  • Compositing runs on the desktop + light variant only. Mobile composite layouts arrive in #6; the other three variants stay raw.
  • Output is written next to the captioned MP4 as <scenario>-composited.mp4 (H.264, CRF 16).
  • When a composited video exists, the PR comment uses it for the desktop+light slot; other variants keep the captioned MP4.

Adaptive intro/outro length

Intro and outro durations scale with the title + description word count and the number of annotated steps (reading speed 3 w/s), clamped to sensible bounds (intro 3-8s, outro 4-12s).

Mobile composite layouts

Set video.mobile.enabled: true to run a dedicated mobile pass after the main matrix and composite both streams into one MP4. Setting mobile.enabled also implies compositing: "remotion" so a single flag covers the common case.

exportdefault{devServer: {command: "npm run dev"},video: {mobile: {enabled: true,layout: "side-by-side"},},}satisfiesProjectConfig;

Layouts

  • side-by-side (default): desktop 80% + phone 20% in a stylized device frame. The canvas widens by 25% to fit both columns at near-native size.
  • pip: phone bottom-right over fullscreen desktop. Canvas dimensions unchanged.
  • sequential: desktop for the first half of the recording, phone for the second. Canvas dimensions unchanged.

Per-step mobile overrides

Scenarios can tweak the mobile pass without forking the script:

{action: "navigate",url: "/",mobilePath: "/m",caption: "Open"}{action: "click",selector: "#desktop-cta",mobileSelector: "#mobile-cta",caption: "Tap CTA"}{action: "highlight",selector: "#desktop-only",mobileSkip: true,caption: "Hover hint"}
  • mobilePath: rewrites the navigate URL on mobile.
  • mobileSelector: swaps the selector on mobile.
  • mobileSkip: omits the step from the mobile pass entirely.

Wall-clock cost

The mobile pass is sequential (separate browser context, fresh navigation), so runs with mobile compositing take ~1.8x the wall-clock of desktop-only runs. The pipeline prints a heads-up when mobile compositing fires.

If the mobile pass throws (selector missing, navigation fails), the compositing step aborts and the captioned MP4 stays as the final artifact — the run otherwise succeeds.

Authenticated demos

pr-visual is framework-agnostic about auth: you supply Playwright storage state JSON files, name them as profiles in .pr-visual.config.ts, and scenarios opt in via scenario.profile. The captured matrix variants and the mobile composite pass all load the same storage state.

exportdefault{devServer: {command: "npm run dev"},auth: {storageStateDir: ".pr-visual/auth",// defaultprofiles: {admin: "admin.json",viewer: "viewer.json",},// Optional — runs after `setup` and before `devServer`. Use it to// refresh storage state per run; pr-visual just calls the command.tokenGenerator: {name: "Refresh storage state",command: "node scripts/refresh-auth.mjs",},},}satisfiesProjectConfig;
// In your scenario:{name: "Admin dashboard tour",description: "...",profile: "admin",steps: [/* ... */],}

npx pr-visual init adds .pr-visual/auth/ to .gitignore automatically — storage state files contain session tokens.

Generating storage state

How you produce the JSON files is up to you. Two common patterns:

Pattern 1: Playwright login script

Run a one-off Playwright script that drives the login UI and saves the context state:

// scripts/refresh-auth.mjsimport{chromium}from"playwright";constbrowser=awaitchromium.launch();constctx=awaitbrowser.newContext();constpage=awaitctx.newPage();awaitpage.goto("http://localhost:3000/login");awaitpage.getByLabel("Email").fill("admin@example.com");awaitpage.getByLabel("Password").fill(process.env.ADMIN_PASSWORD);awaitpage.getByRole("button",{name: "Sign in"}).click();awaitpage.waitForURL("**/dashboard");awaitctx.storageState({path: ".pr-visual/auth/admin.json"});awaitbrowser.close();

Pattern 2: Supabase admin API (no browser needed)

// scripts/refresh-auth.mjsimportfsfrom"node:fs";import{createClient}from"@supabase/supabase-js";constsupabase=createClient(process.env.SUPABASE_URL,process.env.SUPABASE_SERVICE_ROLE_KEY,);const{ data, error }=awaitsupabase.auth.admin.generateLink({type: "magiclink",email: "admin@example.com",});if(error)throwerror;// Build a Playwright storage-state JSON with the supabase localStorage entry,// keyed `sb-<project-ref>-auth-token`. Shape per Supabase JS docs.constsession={access_token: data.properties.action_link,/* ... */};constprojectRef=newURL(process.env.SUPABASE_URL).hostname.split(".")[0];fs.writeFileSync(".pr-visual/auth/admin.json",JSON.stringify({cookies: [],origins: [{origin: "http://localhost:3000",localStorage: [{name: `sb-${projectRef}-auth-token`,value: JSON.stringify({currentSession: session}),}],}],}),);

Anything that writes a Playwright storage-state JSON works. The tokenGenerator step is templated with {{runId}}, {{port}}, {{rootDir}} like other lifecycle steps.

Validation

After the generator runs, pr-visual verifies every configured profile points at a readable JSON file. A missing or malformed file fails the run before capture starts, so a silently-broken generator surfaces immediately.

The PR_VISUAL_AUTH_DIR env var overrides storageStateDir — useful when storage state is generated outside the repo.

Page Object Models

Non-trivial real-world demos often need multi-step orchestration (dismiss a modal, wait for data, assert an intermediate state). Rather than duplicating that logic in every scenario, point pr-visual at your existing E2E Page Object Model modules:

// .pr-visual.config.tsexportdefault{devServer: {command: "npm run dev"},poms: {dashboard: "./e2e/pages/dashboard.ts",checkout: "./e2e/pages/checkout.ts",},}satisfiesProjectConfig;
// ./e2e/pages/dashboard.ts — pr-visual expects plain functions.// Each function receives the Playwright `Page` as the first argument// plus any user arguments defined on the scenario step.importtype{Page}from"playwright";exportasyncfunctionlogin(page: Page,email: string): Promise<void>{awaitpage.getByLabel("Email").fill(email);awaitpage.getByLabel("Password").fill(process.env.DEMO_PASSWORD!);awaitpage.getByRole("button",{name: "Sign in"}).click();awaitpage.waitForURL("**/dashboard");}exportasyncfunctionopenInbox(page: Page): Promise<void>{awaitpage.getByRole("link",{name: "Inbox"}).click();awaitpage.waitForSelector("[data-testid=inbox-list]");}

Then use them in scenarios:

{name: "Inbox tour",description: "...",steps: [{action: "navigate",url: "/",caption: "Open the app"},{action: "pom",page: "dashboard",method: "login",args: ["demo@example.com"],caption: "Sign in",},{action: "pom",page: "dashboard",method: "openInbox",caption: "Open the inbox",},{action: "screenshot",caption: "Inbox view"},],}

Contract

  • Each registered module exports named functions shaped as (page: Page, ...args: unknown[]) => void | Promise<void>.
  • Classes are not supported directly (predictable stateless lifecycle). Wrap them with a thin factory if you need class-based POMs.
  • args on the scenario step is an array forwarded positionally after page. Omitted args means the function is called with just (page).

Validation

pr-visual loads POM modules eagerly at scenario-validation time, so unknown page names, unknown method names, and import failures surface as pre-capture errors instead of runtime crashes deep in the capture loop.

Overlay interaction

  • Custom cursor tracking (when overlays.cursor: true) works inside POM methods automatically — the mousemove listener follows any Playwright-driven movement.
  • Click ripples and highlight spotlights do not fire inside POM methods. Those overlays are injected at the call site in pr-visual's step executor, not globally. If you want a ripple on a POM-internal click, add an explicit click step for that interaction instead.

Voice-over

Step captions become narration in the composited MP4. Each caption is synthesized to an MP3 and mixed into the Remotion composition, anchored to the start of that step on the video timeline. Setting voiceover.enabled: true also implies compositing: "remotion".

exportdefault{devServer: {command: "npm run dev"},voiceover: {enabled: true,// Leave `provider` / `voice` out to auto-detect the first available.},}satisfiesProjectConfig;

Provider chain

Detection order (first available wins — the MP4 uses one provider throughout):

  1. Piper — local neural TTS, offline, no account. Requires piper on PATH and a voice model. Point PIPER_MODEL at an .onnx file, or drop one into ~/.cache/piper/voices/.
  2. Google Cloud TTS — OAuth via gcloud. Requires gcloud auth application-default login. Default voice en-US-Neural2-F.
  3. OpenAI TTSOPENAI_API_KEY env var. Default voice alloy.
  4. macOS say — always available on macOS. Default voice Samantha.

Override via voiceover.provider; that provider is then used regardless of detection order. Per-clip synthesis failures log a warning and skip that step — the rest of the MP4 still narrates.

If no provider is available, the run fails with an explicit error listing the install options.

Caching

Clips are cached at .pr-visual/tts/step-NN-<hash>.mp3, keyed by sha256(provider + caption text). Re-running a scenario with unchanged captions is essentially free. Switching provider invalidates the cache for that step (the hash changes).

npx pr-visual init adds .pr-visual/tts/ to .gitignore automatically.

ffmpeg

Piper and say emit WAV/AIFF and use ffmpeg / ffprobe to transcode to MP3 and measure duration. pr-visual already expects ffmpeg for subtitle burning, so there's no new prerequisite.

Story Director

When ANTHROPIC_API_KEY (or CLAUDE_PLUGIN_OPTION_ANTHROPIC_API_KEY) is set, AI scenario generation runs through the Story Director instead of emitting flat step lists. The director picks one of four personas (End User, Admin, New User, Stakeholder) based on the PR content and drafts a three-act narrative arc:

Persona: End User
Setup: A user opens the dashboard expecting today's metrics.
Inciting: They notice a new tile they have never seen before.
Payoff: Clicking the tile reveals a clearer breakdown of the data.
Closing: Users now answer the question without leaving the dashboard.

Each generated scenario carries the matching persona and every step arrives pre-populated with the right beat (setup / action / payoff / close) and emphasis. The annotation layers from Narrative beats and Adaptive pacing then take over.

When no API key is set, the run falls back to the static-routes scenarios (unchanged from before).

Brief cache

The director caches each brief by sha256(prDescription + diff) to .pr-visual/story/<hash>.json. Re-running on an unchanged PR is free. init adds .pr-visual/story/ to .gitignore.

story subcommand

Inspect the brief without recording, or scaffold it to disk:

# Print the human-readable arc for the current branch's PR.
npx pr-visual story
# Same, against an explicit PR number.
npx pr-visual story --pr 42
# Machine-readable JSON to stdout.
npx pr-visual story --pr 42 --json
# Write the full {narrative, scenarios} brief to disk for editing.# Output: .pr-visual/story-scaffold.json
npx pr-visual story --scaffold

The scaffold path is convenient for tweaking the arc by hand before re-running pr-visual — load the JSON yourself and pass it as a hand-authored scenario set.

CLI commands

npx pr-visual [command]
CommandDescription
run (default)Execute the full capture pipeline
initDetect project setup and generate .pr-visual.config.ts
cleanupRemove orphaned worktrees, Docker projects, and stale directories
storyPrint or scaffold the Story Director's brief without recording. Flags: --pr <n>, --scaffold, --json.

Cleanup

If a run is interrupted (Ctrl+C, crash, killed terminal), resources may be left behind. The cleanup command finds and removes them:

npx pr-visual cleanup

This removes:

  • Orphaned git worktrees (pr-visual-* branches and directories)
  • Orphaned Docker Compose projects (containers, networks, volumes named pr-visual-*)
  • Stale worktree parent directories

The recorder also registers signal handlers for SIGINT and SIGTERM, so a normal Ctrl+C during a run will attempt to tear down services and remove the worktree before exiting.

Environment variables

VariableDefaultDescription
ANTHROPIC_API_KEYEnables AI-generated scenarios (falls back to static)
PR_BODYOverride PR body text for scenario generation
PR_VISUAL_CONFIGExplicit path to config file
PR_VISUAL_NO_ISOLATESet to 1 to skip worktree isolation
PR_VISUAL_QUALITYDesktop quality preset override: 720p, 1080p, 2k, 4k. Takes precedence over scenario and project config.
PR_VISUAL_AUTH_DIROverride auth.storageStateDir. Useful when storage state is generated outside the repo.

How it works

Pipeline

┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐
│ Worktree │───▶│ Setup │───▶│ Dev Server │───▶│ Readiness │
│ + install │ │ steps │ │ start │ │ probe │
└─────────────┘ └──────────┘ └───────────┘ └──────┬───────┘
│
┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌─────▼────────┐
│ PR attach │◀───│ Annotate │◀───│ Capture │◀───│ Scenarios │
│ + cleanup │ │ + video │ │ all vars │ │ (AI / diff) │
└─────────────┘ └──────────┘ └───────────┘ └──────────────┘

Isolation model

Each run creates a git worktree at the current commit:

  • Directory: ../.pr-visual-worktrees/pr-visual-<timestamp>-<hex> (outside repo)
  • Branch: pr-visual/pr-visual-<timestamp>-<hex> (temporary)
  • Port: auto-allocated from preferred port upward (scans 100 ports)
  • Docker: COMPOSE_PROJECT_NAME=pr-visual-<timestamp>-<hex> namespaces all resources
  • Dependencies: full install from lockfile in the worktree

Multiple parallel runs get different worktrees, ports, and Docker project names — complete isolation.

Lifecycle

  1. Setup steps — sequential shell commands with per-step timeouts
  2. Dev server — spawned as a detached process group
  3. Readiness probe — polls endpoint until expected status or timeout
  4. Teardown — runs cleanup commands; errors are logged but don't abort

Cleanup guarantees

  • Signal handlers (SIGINT/SIGTERM) run teardown and worktree removal on interrupt
  • Explicit cleanup in finally block for normal completion or exceptions
  • npx pr-visual cleanup as a manual recovery for hard crashes

Troubleshooting

Skills not appearing after install

Run /reload-plugins to refresh the plugin list.

ffmpeg captioning fails

The video captioning feature requires ffmpeg with either libass or subtitles filter support. If neither is available, the plugin gracefully skips captioning and returns the raw video.

To get full captioning support:

brew install ffmpeg

Screenshots show wrong page (i18n sites)

If your site redirects / to a locale path (e.g. /en), configure the routes field in your .pr-visual.config.ts:

exportdefault{readiness: {path: "/en"},routes: [{path: "/en",label: "Homepage"}],};

next: command not found in worktree

Use npx in your dev server command to resolve binaries from node_modules:

exportdefault{devServer: {command: "npx next dev --port {{port}}"},};

Project structure

.claude-plugin/
plugin.json # Plugin manifest
marketplace.json # Plugin marketplace definition
skills/pr-visual/
SKILL.md # Slash command definition
hooks/
hooks.json # PostToolUse hook for gh pr create
bin/
pr-visual # CLI entrypoint
scripts/pr-visual/
index.ts # CLI routing (run | init | cleanup)
types.ts # Shared types (ViewportConfig, ProjectConfig, RunContext, etc.)
config.ts # Config discovery, loading, template substitution
worktree.ts # Git worktree creation, port allocation, cleanup
lifecycle.ts # Setup/teardown steps, dev server, readiness, signal handlers
init.ts # Project detection and config scaffolding
cleanup.ts # Orphaned resource discovery and removal
scenario-generator.ts # Claude API integration for scenario generation
capture.ts # Playwright capture across viewports and color schemes
pr-attach.ts # GitHub PR body patching and comment posting
annotate/
screenshots.ts # sharp + SVG sidebar compositing → WebP
video.ts # ffmpeg ASS caption burning → H.264 MP4

Development

npm ci # install deps + Playwright chromium
npm run typecheck # tsc --noEmit
npm run lint # Biome (lint + format check, fails on warnings)
npm run lint:fix # Biome auto-fix (safe rules) + write
npm run format # Biome format only — write
npm test# vitest run (unit + integration + e2e)

CI runs typecheck, lint, and the full test suite on every PR and on push to master (Node 20). All warnings are treated as errors.

Releases

Releases are created by .github/workflows/release.yml, which runs after the CI workflow finishes successfully on master. A red CI run blocks the release.

A release is cut only when package.jsonversion is bumped above the latest v* git tag. Merging a PR that does not change version does not produce a release — this lets you land refactors, docs, and chore work between shipments.

To cut a release, open a PR that:

  • bumps version in package.json (patch / minor / major as appropriate);
  • bumps version in .claude-plugin/plugin.json to the same value;
  • bumps both version fields in .claude-plugin/marketplace.json to the same value;
  • adds a CHANGELOG.md entry describing the release.

When the PR lands on master and CI passes, the workflow tags the commit v<version> and publishes a GitHub Release with auto-generated notes (merged PRs and commits since the previous tag). The workflow itself never writes to the repository.

If CI passed but the release did not fire (e.g., a transient failure), use the workflow's workflow_dispatch trigger from the Actions tab to re-run it.

License

MIT — see LICENSE for details.

About

Claude Code plugin for visual PR documentation — annotated screenshots and walkthrough videos

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

pr-visual

A Claude Code plugin that captures visual PR documentation: AI-generated Playwright scenarios from the PR description or git diff, annotated screenshots (desktop 2x + mobile 3x, light + dark), and walkthrough videos with burned-in captions.

Each run is isolated in a git worktree with its own port and namespaced resources (Docker containers, networks, volumes), so multiple runs can execute in parallel without collisions.

Prerequisites

  • Node.js 20+
  • ffmpegbrew install ffmpeg (optional — needed for video captions and voice-over transcoding)
  • GitHub CLIbrew install gh
  • Chromium (installed automatically via Playwright on postinstall)

Installation

From Claude Code marketplace (recommended)

/plugin marketplace add gerokeller/pr-visual

Then install the plugin:

/plugin install pr-visual

To share with your team, add this to your project's .claude/settings.json:

{
"extraKnownMarketplaces": {
"pr-visual": {
"source": {
"source": "github",
"repo": "gerokeller/pr-visual"
}
}
},
"enabledPlugins": {
"pr-visual@pr-visual": true
}
}

Via npm

npm install -D pr-visual

Claude Code automatically discovers the plugin via .claude-plugin/plugin.json inside node_modules/pr-visual/. This gives you:

  • /pr-visual slash command
  • PostToolUse hook that reminds you to run it after gh pr create

Quick start

1. Scaffold the config

/pr-visual init

Or via CLI:

npx pr-visual init

This detects your project setup and generates a tailored .pr-visual.config.ts:

pr-visual init: Detecting project setup...
Framework: Next.js
Package manager: pnpm
Docker: yes (postgres, redis)
ORM: prisma
Health endpoint: /api/health
Default port: 3000
Created: .pr-visual.config.ts

2. Review and commit the config

The generated config is ready to use but worth reviewing. Commit it so every team member gets the same behavior.

3. Run it

/pr-visual

Or manually:

npx pr-visual

Configuration

Plugin settings

The plugin accepts the following user configuration (set during plugin install or in settings):

SettingDescription
anthropic_api_keyAPI key for AI-generated scenarios (stored in system keychain). Falls back to ANTHROPIC_API_KEY env var, then to static route capture.

Project config

.pr-visual.config.ts is the contract between your project and the recorder. It declares everything needed to bring up the application from a cold worktree:

importtype{ProjectConfig}from"pr-visual/scripts/pr-visual/types.js";exportdefault{port: 3000,devServer: {command: "npm run dev",env: {PORT: "{{port}}"},},// Setup steps — Docker resources are auto-scoped via COMPOSE_PROJECT_NAMEsetup: [{name: "Start database",command: "docker compose up -d postgres redis"},{name: "Run migrations",command: "npx prisma migrate deploy"},{name: "Seed data",command: "npx prisma db seed"},],readiness: {path: "/api/health",status: 200,timeout: 60_000,},// Teardown — only this run's containers are removedteardown: [{name: "Stop database",command: "docker compose down -v"},],isolate: true,installCommand: "npm ci",}satisfiesProjectConfig;

Template variables

All command strings and env values support these placeholders:

VariableDescription
{{port}}Auto-allocated TCP port for this run
{{runId}}Unique run identifier — safe as Docker project name, DB suffix, directory name
{{rootDir}}Absolute path to the working directory (worktree or project root)

Automatic resource isolation

Every lifecycle step and the dev server receive these environment variables automatically — no manual setup needed:

VariableValuePurpose
COMPOSE_PROJECT_NAME{{runId}}Scopes all Docker Compose containers, networks, and volumes to this run
PORTAllocated portStandard port variable
PR_VISUAL_RUN_ID{{runId}}Available for custom scripts
PR_VISUAL_PORTAllocated portAvailable for custom scripts
PR_VISUAL_ROOT_DIR{{rootDir}}Available for custom scripts

This means docker compose up -d postgres in two parallel runs creates two independent Postgres containers, and each run's docker compose down -v only removes its own.

Config reference

FieldTypeDefaultDescription
portnumber3000Preferred port (auto-incremented if busy)
baseUrlstringhttp://localhost:{{port}}URL template
devServer.commandstringnpm run devDev server command
devServer.envRecordExtra env vars (template substitution)
setupLifecycleStep[]Pre-server steps (Docker, migrations, seeds)
readiness.pathstring/Readiness probe endpoint
readiness.statusnumber200Expected HTTP status
readiness.timeoutnumber45000Max wait time in ms
readiness.intervalnumber1000Probe interval in ms
teardownLifecycleStep[]Post-capture cleanup steps
isolatebooleantrueUse git worktree for isolation
worktreeDirstring../.pr-visual-worktreesWhere to create worktrees
installCommandstringnpm ciInstall command for worktrees
outputDirstring.pr-visualOutput directory (relative to root)
routesArray<string | { path, label }>["/"]Routes for static fallback capture
quality"720p" | "1080p" | "2k" | "4k"Desktop quality preset (see Quality presets)
pacing.wordsPerSecondnumber3.2Reading speed used by adaptive pacing
overlays.cursorbooleanfalseInject a visible custom cursor during capture (see Interaction overlays)
overlays.clicksbooleanfalseEmit a ripple + center dot at each click's coordinates
overlays.highlightsbooleanfalseEnable the highlight scenario step action (pulsing glow + dimmed backdrop)
video.compositing"none" | "remotion""none"Run the recorded clip through a Remotion composition (see Video production)
video.brandColorstring"#3b82f6"Brand accent color for intro/outro/caption-pill chrome
video.categorystringOptional category label rendered as a glassmorphism badge
video.sprintLabelstringOptional sprint / release label rendered subtly in the intro
video.orgNamestringOptional org name rendered in the outro footer
video.highlightsstring[]Optional bullets rendered as a "Key Highlights" card in the outro
video.mobile.enabledbooleanfalseRun a dedicated mobile composite pass after the main matrix and composite both streams (see Mobile composite layouts). Implies compositing: "remotion".
video.mobile.viewport{ width, height }{ 390, 844 }Mobile pass viewport
video.mobile.deviceScaleFactornumber3Mobile pass DPR
video.mobile.layout"side-by-side" | "pip" | "sequential""side-by-side"Composition layout
auth.storageStateDirstring".pr-visual/auth"Directory holding Playwright storage state files (see Authenticated demos)
auth.profilesRecord<string, string>Named profiles → relative storage state file paths
auth.tokenGeneratorLifecycleStepOptional command run after setup and before devServer to refresh storage state
pomsRecord<string, string>Page Object Model registry. Keys are referenced from pom scenario steps (see Page Object Models). Values are module paths relative to the project root.
voiceover.enabledbooleanfalseSynthesize per-step audio and mix it into the composited MP4 (see Voice-over). Implies compositing: "remotion".
voiceover.provider"piper" | "google" | "openai" | "say"Explicit provider. Defaults to the first available in detection order.
voiceover.voicestring(per-provider)Provider-specific voice name (e.g. en-US-Neural2-F, alloy, Samantha).
voiceover.cacheDirstring".pr-visual/tts"Audio cache directory (relative to project root). Content-hash keyed; re-runs with unchanged captions skip synthesis.

Minimal config examples

Next.js (zero-setup):

exportdefault{devServer: {command: "npm run dev"}};

Vite + Docker Postgres:

exportdefault{port: 5173,devServer: {command: "npx vite --port {{port}}"},setup: [{name: "DB",command: "docker compose up -d db",timeout: 30_000},{name: "Migrate",command: "npx prisma migrate deploy"},],teardown: [{name: "DB down",command: "docker compose down -v"},],readiness: {path: "/api/health"},};

i18n site (content at /en):

exportdefault{devServer: {command: "npx next dev --port {{port}}"},readiness: {path: "/en"},routes: [{path: "/en",label: "Homepage"},{path: "/en/about",label: "About"},],};

Monorepo (custom cwd):

exportdefault{devServer: {command: "turbo dev --filter=web",cwd: "apps/web"},setup: [{name: "Build packages",command: "turbo build --filter=web^..."},],};

Quality presets

By default the desktop capture runs at 1440×900 @2x. You can bump this to a named preset to get higher-resolution video and screenshots. The preset sets the logical viewport (CSS pixels); final output dimensions are viewport × deviceScaleFactor (DSF stays at 2 by default).

PresetViewportOutput (DSF=2)
720p1280×7202560×1440
1080p1920×10803840×2160
2k2560×14405120×2880
4k3840×21607680×4320

Mobile capture is not affected by quality presets.

Project-wide default in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},quality: "1080p",}satisfiesProjectConfig;

Per-scenario override (AI-generated or hand-authored scenarios):

{name: "Checkout flow",description: "...",quality: "2k",// preset wins over viewportsteps: [/* ... */],}

Explicit viewport override (when a preset doesn't fit):

{name: "Tablet layout",description: "...",viewport: {width: 1024,height: 768,deviceScaleFactor: 2},steps: [/* ... */],}

One-off env override — useful in CI or for spot checks:

PR_VISUAL_QUALITY=4k npx pr-visual

Precedence (highest wins):

  1. PR_VISUAL_QUALITY env var
  2. scenario.quality
  3. scenario.viewport
  4. projectConfig.quality
  5. Built-in default (1440×900 @2x)

An unknown preset value (env, scenario, or project) fails hard with a clear error.

Adaptive pacing

Each step holds on-screen long enough for viewers to read the caption and absorb the change, scaled by an explicit pacing hint. The hold is computed from the caption's reading time, the action type (first-navigation gets extra breathing room; type scales with value length), a transition cushion when the action changes, and the pacing mode.

Modes (multiplier / floor / cap in ms):

ModeMultiplierFloorCap
quick0.6×9004000
normal(default)1.0×17008000
slow1.5×220010000
dramatic2.0×320012000

dramatic also inserts an 800ms pre-action settle before the step fires, to build anticipation.

Per-step:

{action: "click",selector: "#checkout",caption: "Confirm the order",pacing: "dramatic",// the final beat — let it land}

Project-level reading speed in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},pacing: {wordsPerSecond: 2.8},// slower — for non-native audiences}satisfiesProjectConfig;

Captions of six words or fewer are read proportionally faster (+0.6 w/s) so short beats don't linger.

Narrative beats

Scenarios can tag each step with a beatsetup, action, payoff, or close — to mark where the step sits in the story arc. The annotation layer picks these up:

  • Video: a brief 700ms title-card chip fades in whenever the beat changes between two consecutive steps (so three distinct beats produce two chips).
  • Screenshots: the sidebar shows the beat label under the viewport badge.

Beats also enforce a minimum hold in the pacing formula (setup 1200ms, action 1800ms, payoff 2800ms, close 2200ms), so a payoff step earns scene-length breathing room even under quick pacing.

Emphasis

Each step can also carry emphasis: "strong" to render as a larger title-card caption (1.5× the base caption font, bolder weight). Use it on the key moments you want viewers to remember — usually a payoff beat.

{action: "screenshot",caption: "The deal is closed",beat: "payoff",emphasis: "strong",pacing: "dramatic",}

Persona

Scenarios can carry an audience label via persona: "Agency PM" (any free-form string). This is stored on the scenario for later use by the Remotion intro composer and the Story Director. It does not render directly in the current annotation layer.

{name: "New client onboarding",description: "...",persona: "Agency PM",steps: [/* ... */],}

Invalid beat, emphasis, or pacing values fail the run with a clear error before capture starts.

Interaction overlays

By default, pr-visual captures clean recordings with no cursor or click indicators. If you want your videos to look human-driven, opt into one or more overlays in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},overlays: {cursor: true,// visible custom cursor tracking the mouseclicks: true,// ripple + center dot at each clickhighlights: true,// enables the `highlight` scenario step},}satisfiesProjectConfig;

Each flag is independent; all default to false so existing users see no change.

highlight step

When overlays.highlights: true, scenarios can use a new step action that pulses a glow ring around a selector while dimming the rest of the viewport:

{action: "highlight",selector: "#primary-cta",duration: 1500,// ms; defaults to 1500 when omittedcaption: "The primary call to action",beat: "payoff",}

The highlight runs for duration ms; the scenario's pacing hold starts after cleanup.

Capture-time DOM injection

Overlays are injected into the page during capture (unlike the post-capture sidebar and ASS caption layers), so they appear in the recorded video at the right moment. The trade-off: an active cursor or highlight will be visible in screenshots taken right after a navigate. If you want clean screenshots alongside an overlay-rich video, leave overlays.cursor off.

Mobile viewports automatically use a touch-style cursor and tap-ring animations.

Video production

By default the captioned MP4 is the final video artifact. Opt in to a polished Remotion composition (animated intro, crossfades, glassmorphism caption pill, outro with step summary) per scenario or project-wide:

exportdefault{devServer: {command: "npm run dev"},video: {compositing: "remotion",brandColor: "#3b82f6",category: "Checkout",sprintLabel: "Sprint 12",orgName: "Acme Co",highlights: ["Faster checkout","Cleaner cart"],},}satisfiesProjectConfig;

Optional peer dependencies

The Remotion stack is intentionally not a baseline dependency — npm i pr-visual stays small for users who only need captioned recordings. Install the peer deps when you want compositing:

npm i -D remotion @remotion/bundler @remotion/renderer react react-dom

If video.compositing: "remotion" is set but the peer deps aren't installed, pr-visual prints a clear warning and falls back to the captioned MP4. The run still succeeds.

What gets composited

  • Compositing runs on the desktop + light variant only. Mobile composite layouts arrive in #6; the other three variants stay raw.
  • Output is written next to the captioned MP4 as <scenario>-composited.mp4 (H.264, CRF 16).
  • When a composited video exists, the PR comment uses it for the desktop+light slot; other variants keep the captioned MP4.

Adaptive intro/outro length

Intro and outro durations scale with the title + description word count and the number of annotated steps (reading speed 3 w/s), clamped to sensible bounds (intro 3-8s, outro 4-12s).

Mobile composite layouts

Set video.mobile.enabled: true to run a dedicated mobile pass after the main matrix and composite both streams into one MP4. Setting mobile.enabled also implies compositing: "remotion" so a single flag covers the common case.

exportdefault{devServer: {command: "npm run dev"},video: {mobile: {enabled: true,layout: "side-by-side"},},}satisfiesProjectConfig;

Layouts

  • side-by-side (default): desktop 80% + phone 20% in a stylized device frame. The canvas widens by 25% to fit both columns at near-native size.
  • pip: phone bottom-right over fullscreen desktop. Canvas dimensions unchanged.
  • sequential: desktop for the first half of the recording, phone for the second. Canvas dimensions unchanged.

Per-step mobile overrides

Scenarios can tweak the mobile pass without forking the script:

{action: "navigate",url: "/",mobilePath: "/m",caption: "Open"}{action: "click",selector: "#desktop-cta",mobileSelector: "#mobile-cta",caption: "Tap CTA"}{action: "highlight",selector: "#desktop-only",mobileSkip: true,caption: "Hover hint"}
  • mobilePath: rewrites the navigate URL on mobile.
  • mobileSelector: swaps the selector on mobile.
  • mobileSkip: omits the step from the mobile pass entirely.

Wall-clock cost

The mobile pass is sequential (separate browser context, fresh navigation), so runs with mobile compositing take ~1.8x the wall-clock of desktop-only runs. The pipeline prints a heads-up when mobile compositing fires.

If the mobile pass throws (selector missing, navigation fails), the compositing step aborts and the captioned MP4 stays as the final artifact — the run otherwise succeeds.

Authenticated demos

pr-visual is framework-agnostic about auth: you supply Playwright storage state JSON files, name them as profiles in .pr-visual.config.ts, and scenarios opt in via scenario.profile. The captured matrix variants and the mobile composite pass all load the same storage state.

exportdefault{devServer: {command: "npm run dev"},auth: {storageStateDir: ".pr-visual/auth",// defaultprofiles: {admin: "admin.json",viewer: "viewer.json",},// Optional — runs after `setup` and before `devServer`. Use it to// refresh storage state per run; pr-visual just calls the command.tokenGenerator: {name: "Refresh storage state",command: "node scripts/refresh-auth.mjs",},},}satisfiesProjectConfig;
// In your scenario:{name: "Admin dashboard tour",description: "...",profile: "admin",steps: [/* ... */],}

npx pr-visual init adds .pr-visual/auth/ to .gitignore automatically — storage state files contain session tokens.

Generating storage state

How you produce the JSON files is up to you. Two common patterns:

Pattern 1: Playwright login script

Run a one-off Playwright script that drives the login UI and saves the context state:

// scripts/refresh-auth.mjsimport{chromium}from"playwright";constbrowser=awaitchromium.launch();constctx=awaitbrowser.newContext();constpage=awaitctx.newPage();awaitpage.goto("http://localhost:3000/login");awaitpage.getByLabel("Email").fill("admin@example.com");awaitpage.getByLabel("Password").fill(process.env.ADMIN_PASSWORD);awaitpage.getByRole("button",{name: "Sign in"}).click();awaitpage.waitForURL("**/dashboard");awaitctx.storageState({path: ".pr-visual/auth/admin.json"});awaitbrowser.close();

Pattern 2: Supabase admin API (no browser needed)

// scripts/refresh-auth.mjsimportfsfrom"node:fs";import{createClient}from"@supabase/supabase-js";constsupabase=createClient(process.env.SUPABASE_URL,process.env.SUPABASE_SERVICE_ROLE_KEY,);const{ data, error }=awaitsupabase.auth.admin.generateLink({type: "magiclink",email: "admin@example.com",});if(error)throwerror;// Build a Playwright storage-state JSON with the supabase localStorage entry,// keyed `sb-<project-ref>-auth-token`. Shape per Supabase JS docs.constsession={access_token: data.properties.action_link,/* ... */};constprojectRef=newURL(process.env.SUPABASE_URL).hostname.split(".")[0];fs.writeFileSync(".pr-visual/auth/admin.json",JSON.stringify({cookies: [],origins: [{origin: "http://localhost:3000",localStorage: [{name: `sb-${projectRef}-auth-token`,value: JSON.stringify({currentSession: session}),}],}],}),);

Anything that writes a Playwright storage-state JSON works. The tokenGenerator step is templated with {{runId}}, {{port}}, {{rootDir}} like other lifecycle steps.

Validation

After the generator runs, pr-visual verifies every configured profile points at a readable JSON file. A missing or malformed file fails the run before capture starts, so a silently-broken generator surfaces immediately.

The PR_VISUAL_AUTH_DIR env var overrides storageStateDir — useful when storage state is generated outside the repo.

Page Object Models

Non-trivial real-world demos often need multi-step orchestration (dismiss a modal, wait for data, assert an intermediate state). Rather than duplicating that logic in every scenario, point pr-visual at your existing E2E Page Object Model modules:

// .pr-visual.config.tsexportdefault{devServer: {command: "npm run dev"},poms: {dashboard: "./e2e/pages/dashboard.ts",checkout: "./e2e/pages/checkout.ts",},}satisfiesProjectConfig;
// ./e2e/pages/dashboard.ts — pr-visual expects plain functions.// Each function receives the Playwright `Page` as the first argument// plus any user arguments defined on the scenario step.importtype{Page}from"playwright";exportasyncfunctionlogin(page: Page,email: string): Promise<void>{awaitpage.getByLabel("Email").fill(email);awaitpage.getByLabel("Password").fill(process.env.DEMO_PASSWORD!);awaitpage.getByRole("button",{name: "Sign in"}).click();awaitpage.waitForURL("**/dashboard");}exportasyncfunctionopenInbox(page: Page): Promise<void>{awaitpage.getByRole("link",{name: "Inbox"}).click();awaitpage.waitForSelector("[data-testid=inbox-list]");}

Then use them in scenarios:

{name: "Inbox tour",description: "...",steps: [{action: "navigate",url: "/",caption: "Open the app"},{action: "pom",page: "dashboard",method: "login",args: ["demo@example.com"],caption: "Sign in",},{action: "pom",page: "dashboard",method: "openInbox",caption: "Open the inbox",},{action: "screenshot",caption: "Inbox view"},],}

Contract

  • Each registered module exports named functions shaped as (page: Page, ...args: unknown[]) => void | Promise<void>.
  • Classes are not supported directly (predictable stateless lifecycle). Wrap them with a thin factory if you need class-based POMs.
  • args on the scenario step is an array forwarded positionally after page. Omitted args means the function is called with just (page).

Validation

pr-visual loads POM modules eagerly at scenario-validation time, so unknown page names, unknown method names, and import failures surface as pre-capture errors instead of runtime crashes deep in the capture loop.

Overlay interaction

  • Custom cursor tracking (when overlays.cursor: true) works inside POM methods automatically — the mousemove listener follows any Playwright-driven movement.
  • Click ripples and highlight spotlights do not fire inside POM methods. Those overlays are injected at the call site in pr-visual's step executor, not globally. If you want a ripple on a POM-internal click, add an explicit click step for that interaction instead.

Voice-over

Step captions become narration in the composited MP4. Each caption is synthesized to an MP3 and mixed into the Remotion composition, anchored to the start of that step on the video timeline. Setting voiceover.enabled: true also implies compositing: "remotion".

exportdefault{devServer: {command: "npm run dev"},voiceover: {enabled: true,// Leave `provider` / `voice` out to auto-detect the first available.},}satisfiesProjectConfig;

Provider chain

Detection order (first available wins — the MP4 uses one provider throughout):

  1. Piper — local neural TTS, offline, no account. Requires piper on PATH and a voice model. Point PIPER_MODEL at an .onnx file, or drop one into ~/.cache/piper/voices/.
  2. Google Cloud TTS — OAuth via gcloud. Requires gcloud auth application-default login. Default voice en-US-Neural2-F.
  3. OpenAI TTSOPENAI_API_KEY env var. Default voice alloy.
  4. macOS say — always available on macOS. Default voice Samantha.

Override via voiceover.provider; that provider is then used regardless of detection order. Per-clip synthesis failures log a warning and skip that step — the rest of the MP4 still narrates.

If no provider is available, the run fails with an explicit error listing the install options.

Caching

Clips are cached at .pr-visual/tts/step-NN-<hash>.mp3, keyed by sha256(provider + caption text). Re-running a scenario with unchanged captions is essentially free. Switching provider invalidates the cache for that step (the hash changes).

npx pr-visual init adds .pr-visual/tts/ to .gitignore automatically.

ffmpeg

Piper and say emit WAV/AIFF and use ffmpeg / ffprobe to transcode to MP3 and measure duration. pr-visual already expects ffmpeg for subtitle burning, so there's no new prerequisite.

Story Director

When ANTHROPIC_API_KEY (or CLAUDE_PLUGIN_OPTION_ANTHROPIC_API_KEY) is set, AI scenario generation runs through the Story Director instead of emitting flat step lists. The director picks one of four personas (End User, Admin, New User, Stakeholder) based on the PR content and drafts a three-act narrative arc:

Persona: End User
Setup: A user opens the dashboard expecting today's metrics.
Inciting: They notice a new tile they have never seen before.
Payoff: Clicking the tile reveals a clearer breakdown of the data.
Closing: Users now answer the question without leaving the dashboard.

Each generated scenario carries the matching persona and every step arrives pre-populated with the right beat (setup / action / payoff / close) and emphasis. The annotation layers from Narrative beats and Adaptive pacing then take over.

When no API key is set, the run falls back to the static-routes scenarios (unchanged from before).

Brief cache

The director caches each brief by sha256(prDescription + diff) to .pr-visual/story/<hash>.json. Re-running on an unchanged PR is free. init adds .pr-visual/story/ to .gitignore.

story subcommand

Inspect the brief without recording, or scaffold it to disk:

# Print the human-readable arc for the current branch's PR.
npx pr-visual story
# Same, against an explicit PR number.
npx pr-visual story --pr 42
# Machine-readable JSON to stdout.
npx pr-visual story --pr 42 --json
# Write the full {narrative, scenarios} brief to disk for editing.# Output: .pr-visual/story-scaffold.json
npx pr-visual story --scaffold

The scaffold path is convenient for tweaking the arc by hand before re-running pr-visual — load the JSON yourself and pass it as a hand-authored scenario set.

CLI commands

npx pr-visual [command]
CommandDescription
run (default)Execute the full capture pipeline
initDetect project setup and generate .pr-visual.config.ts
cleanupRemove orphaned worktrees, Docker projects, and stale directories
storyPrint or scaffold the Story Director's brief without recording. Flags: --pr <n>, --scaffold, --json.

Cleanup

If a run is interrupted (Ctrl+C, crash, killed terminal), resources may be left behind. The cleanup command finds and removes them:

npx pr-visual cleanup

This removes:

  • Orphaned git worktrees (pr-visual-* branches and directories)
  • Orphaned Docker Compose projects (containers, networks, volumes named pr-visual-*)
  • Stale worktree parent directories

The recorder also registers signal handlers for SIGINT and SIGTERM, so a normal Ctrl+C during a run will attempt to tear down services and remove the worktree before exiting.

Environment variables

VariableDefaultDescription
ANTHROPIC_API_KEYEnables AI-generated scenarios (falls back to static)
PR_BODYOverride PR body text for scenario generation
PR_VISUAL_CONFIGExplicit path to config file
PR_VISUAL_NO_ISOLATESet to 1 to skip worktree isolation
PR_VISUAL_QUALITYDesktop quality preset override: 720p, 1080p, 2k, 4k. Takes precedence over scenario and project config.
PR_VISUAL_AUTH_DIROverride auth.storageStateDir. Useful when storage state is generated outside the repo.

How it works

Pipeline

┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐
│ Worktree │───▶│ Setup │───▶│ Dev Server │───▶│ Readiness │
│ + install │ │ steps │ │ start │ │ probe │
└─────────────┘ └──────────┘ └───────────┘ └──────┬───────┘
│
┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌─────▼────────┐
│ PR attach │◀───│ Annotate │◀───│ Capture │◀───│ Scenarios │
│ + cleanup │ │ + video │ │ all vars │ │ (AI / diff) │
└─────────────┘ └──────────┘ └───────────┘ └──────────────┘

Isolation model

Each run creates a git worktree at the current commit:

  • Directory: ../.pr-visual-worktrees/pr-visual-<timestamp>-<hex> (outside repo)
  • Branch: pr-visual/pr-visual-<timestamp>-<hex> (temporary)
  • Port: auto-allocated from preferred port upward (scans 100 ports)
  • Docker: COMPOSE_PROJECT_NAME=pr-visual-<timestamp>-<hex> namespaces all resources
  • Dependencies: full install from lockfile in the worktree

Multiple parallel runs get different worktrees, ports, and Docker project names — complete isolation.

Lifecycle

  1. Setup steps — sequential shell commands with per-step timeouts
  2. Dev server — spawned as a detached process group
  3. Readiness probe — polls endpoint until expected status or timeout
  4. Teardown — runs cleanup commands; errors are logged but don't abort

Cleanup guarantees

  • Signal handlers (SIGINT/SIGTERM) run teardown and worktree removal on interrupt
  • Explicit cleanup in finally block for normal completion or exceptions
  • npx pr-visual cleanup as a manual recovery for hard crashes

Troubleshooting

Skills not appearing after install

Run /reload-plugins to refresh the plugin list.

ffmpeg captioning fails

The video captioning feature requires ffmpeg with either libass or subtitles filter support. If neither is available, the plugin gracefully skips captioning and returns the raw video.

To get full captioning support:

brew install ffmpeg

Screenshots show wrong page (i18n sites)

If your site redirects / to a locale path (e.g. /en), configure the routes field in your .pr-visual.config.ts:

exportdefault{readiness: {path: "/en"},routes: [{path: "/en",label: "Homepage"}],};

next: command not found in worktree

Use npx in your dev server command to resolve binaries from node_modules:

exportdefault{devServer: {command: "npx next dev --port {{port}}"},};

Project structure

.claude-plugin/
plugin.json # Plugin manifest
marketplace.json # Plugin marketplace definition
skills/pr-visual/
SKILL.md # Slash command definition
hooks/
hooks.json # PostToolUse hook for gh pr create
bin/
pr-visual # CLI entrypoint
scripts/pr-visual/
index.ts # CLI routing (run | init | cleanup)
types.ts # Shared types (ViewportConfig, ProjectConfig, RunContext, etc.)
config.ts # Config discovery, loading, template substitution
worktree.ts # Git worktree creation, port allocation, cleanup
lifecycle.ts # Setup/teardown steps, dev server, readiness, signal handlers
init.ts # Project detection and config scaffolding
cleanup.ts # Orphaned resource discovery and removal
scenario-generator.ts # Claude API integration for scenario generation
capture.ts # Playwright capture across viewports and color schemes
pr-attach.ts # GitHub PR body patching and comment posting
annotate/
screenshots.ts # sharp + SVG sidebar compositing → WebP
video.ts # ffmpeg ASS caption burning → H.264 MP4

Development

npm ci # install deps + Playwright chromium
npm run typecheck # tsc --noEmit
npm run lint # Biome (lint + format check, fails on warnings)
npm run lint:fix # Biome auto-fix (safe rules) + write
npm run format # Biome format only — write
npm test# vitest run (unit + integration + e2e)

CI runs typecheck, lint, and the full test suite on every PR and on push to master (Node 20). All warnings are treated as errors.

Releases

Releases are created by .github/workflows/release.yml, which runs after the CI workflow finishes successfully on master. A red CI run blocks the release.

A release is cut only when package.jsonversion is bumped above the latest v* git tag. Merging a PR that does not change version does not produce a release — this lets you land refactors, docs, and chore work between shipments.

To cut a release, open a PR that:

  • bumps version in package.json (patch / minor / major as appropriate);
  • bumps version in .claude-plugin/plugin.json to the same value;
  • bumps both version fields in .claude-plugin/marketplace.json to the same value;
  • adds a CHANGELOG.md entry describing the release.

When the PR lands on master and CI passes, the workflow tags the commit v<version> and publishes a GitHub Release with auto-generated notes (merged PRs and commits since the previous tag). The workflow itself never writes to the repository.

If CI passed but the release did not fire (e.g., a transient failure), use the workflow's workflow_dispatch trigger from the Actions tab to re-run it.

License

MIT — see LICENSE for details.

About

Claude Code plugin for visual PR documentation — annotated screenshots and walkthrough videos

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

pr-visual

A Claude Code plugin that captures visual PR documentation: AI-generated Playwright scenarios from the PR description or git diff, annotated screenshots (desktop 2x + mobile 3x, light + dark), and walkthrough videos with burned-in captions.

Each run is isolated in a git worktree with its own port and namespaced resources (Docker containers, networks, volumes), so multiple runs can execute in parallel without collisions.

Prerequisites

  • Node.js 20+
  • ffmpegbrew install ffmpeg (optional — needed for video captions and voice-over transcoding)
  • GitHub CLIbrew install gh
  • Chromium (installed automatically via Playwright on postinstall)

Installation

From Claude Code marketplace (recommended)

/plugin marketplace add gerokeller/pr-visual

Then install the plugin:

/plugin install pr-visual

To share with your team, add this to your project's .claude/settings.json:

{
"extraKnownMarketplaces": {
"pr-visual": {
"source": {
"source": "github",
"repo": "gerokeller/pr-visual"
}
}
},
"enabledPlugins": {
"pr-visual@pr-visual": true
}
}

Via npm

npm install -D pr-visual

Claude Code automatically discovers the plugin via .claude-plugin/plugin.json inside node_modules/pr-visual/. This gives you:

  • /pr-visual slash command
  • PostToolUse hook that reminds you to run it after gh pr create

Quick start

1. Scaffold the config

/pr-visual init

Or via CLI:

npx pr-visual init

This detects your project setup and generates a tailored .pr-visual.config.ts:

pr-visual init: Detecting project setup...
Framework: Next.js
Package manager: pnpm
Docker: yes (postgres, redis)
ORM: prisma
Health endpoint: /api/health
Default port: 3000
Created: .pr-visual.config.ts

2. Review and commit the config

The generated config is ready to use but worth reviewing. Commit it so every team member gets the same behavior.

3. Run it

/pr-visual

Or manually:

npx pr-visual

Configuration

Plugin settings

The plugin accepts the following user configuration (set during plugin install or in settings):

SettingDescription
anthropic_api_keyAPI key for AI-generated scenarios (stored in system keychain). Falls back to ANTHROPIC_API_KEY env var, then to static route capture.

Project config

.pr-visual.config.ts is the contract between your project and the recorder. It declares everything needed to bring up the application from a cold worktree:

importtype{ProjectConfig}from"pr-visual/scripts/pr-visual/types.js";exportdefault{port: 3000,devServer: {command: "npm run dev",env: {PORT: "{{port}}"},},// Setup steps — Docker resources are auto-scoped via COMPOSE_PROJECT_NAMEsetup: [{name: "Start database",command: "docker compose up -d postgres redis"},{name: "Run migrations",command: "npx prisma migrate deploy"},{name: "Seed data",command: "npx prisma db seed"},],readiness: {path: "/api/health",status: 200,timeout: 60_000,},// Teardown — only this run's containers are removedteardown: [{name: "Stop database",command: "docker compose down -v"},],isolate: true,installCommand: "npm ci",}satisfiesProjectConfig;

Template variables

All command strings and env values support these placeholders:

VariableDescription
{{port}}Auto-allocated TCP port for this run
{{runId}}Unique run identifier — safe as Docker project name, DB suffix, directory name
{{rootDir}}Absolute path to the working directory (worktree or project root)

Automatic resource isolation

Every lifecycle step and the dev server receive these environment variables automatically — no manual setup needed:

VariableValuePurpose
COMPOSE_PROJECT_NAME{{runId}}Scopes all Docker Compose containers, networks, and volumes to this run
PORTAllocated portStandard port variable
PR_VISUAL_RUN_ID{{runId}}Available for custom scripts
PR_VISUAL_PORTAllocated portAvailable for custom scripts
PR_VISUAL_ROOT_DIR{{rootDir}}Available for custom scripts

This means docker compose up -d postgres in two parallel runs creates two independent Postgres containers, and each run's docker compose down -v only removes its own.

Config reference

FieldTypeDefaultDescription
portnumber3000Preferred port (auto-incremented if busy)
baseUrlstringhttp://localhost:{{port}}URL template
devServer.commandstringnpm run devDev server command
devServer.envRecordExtra env vars (template substitution)
setupLifecycleStep[]Pre-server steps (Docker, migrations, seeds)
readiness.pathstring/Readiness probe endpoint
readiness.statusnumber200Expected HTTP status
readiness.timeoutnumber45000Max wait time in ms
readiness.intervalnumber1000Probe interval in ms
teardownLifecycleStep[]Post-capture cleanup steps
isolatebooleantrueUse git worktree for isolation
worktreeDirstring../.pr-visual-worktreesWhere to create worktrees
installCommandstringnpm ciInstall command for worktrees
outputDirstring.pr-visualOutput directory (relative to root)
routesArray<string | { path, label }>["/"]Routes for static fallback capture
quality"720p" | "1080p" | "2k" | "4k"Desktop quality preset (see Quality presets)
pacing.wordsPerSecondnumber3.2Reading speed used by adaptive pacing
overlays.cursorbooleanfalseInject a visible custom cursor during capture (see Interaction overlays)
overlays.clicksbooleanfalseEmit a ripple + center dot at each click's coordinates
overlays.highlightsbooleanfalseEnable the highlight scenario step action (pulsing glow + dimmed backdrop)
video.compositing"none" | "remotion""none"Run the recorded clip through a Remotion composition (see Video production)
video.brandColorstring"#3b82f6"Brand accent color for intro/outro/caption-pill chrome
video.categorystringOptional category label rendered as a glassmorphism badge
video.sprintLabelstringOptional sprint / release label rendered subtly in the intro
video.orgNamestringOptional org name rendered in the outro footer
video.highlightsstring[]Optional bullets rendered as a "Key Highlights" card in the outro
video.mobile.enabledbooleanfalseRun a dedicated mobile composite pass after the main matrix and composite both streams (see Mobile composite layouts). Implies compositing: "remotion".
video.mobile.viewport{ width, height }{ 390, 844 }Mobile pass viewport
video.mobile.deviceScaleFactornumber3Mobile pass DPR
video.mobile.layout"side-by-side" | "pip" | "sequential""side-by-side"Composition layout
auth.storageStateDirstring".pr-visual/auth"Directory holding Playwright storage state files (see Authenticated demos)
auth.profilesRecord<string, string>Named profiles → relative storage state file paths
auth.tokenGeneratorLifecycleStepOptional command run after setup and before devServer to refresh storage state
pomsRecord<string, string>Page Object Model registry. Keys are referenced from pom scenario steps (see Page Object Models). Values are module paths relative to the project root.
voiceover.enabledbooleanfalseSynthesize per-step audio and mix it into the composited MP4 (see Voice-over). Implies compositing: "remotion".
voiceover.provider"piper" | "google" | "openai" | "say"Explicit provider. Defaults to the first available in detection order.
voiceover.voicestring(per-provider)Provider-specific voice name (e.g. en-US-Neural2-F, alloy, Samantha).
voiceover.cacheDirstring".pr-visual/tts"Audio cache directory (relative to project root). Content-hash keyed; re-runs with unchanged captions skip synthesis.

Minimal config examples

Next.js (zero-setup):

exportdefault{devServer: {command: "npm run dev"}};

Vite + Docker Postgres:

exportdefault{port: 5173,devServer: {command: "npx vite --port {{port}}"},setup: [{name: "DB",command: "docker compose up -d db",timeout: 30_000},{name: "Migrate",command: "npx prisma migrate deploy"},],teardown: [{name: "DB down",command: "docker compose down -v"},],readiness: {path: "/api/health"},};

i18n site (content at /en):

exportdefault{devServer: {command: "npx next dev --port {{port}}"},readiness: {path: "/en"},routes: [{path: "/en",label: "Homepage"},{path: "/en/about",label: "About"},],};

Monorepo (custom cwd):

exportdefault{devServer: {command: "turbo dev --filter=web",cwd: "apps/web"},setup: [{name: "Build packages",command: "turbo build --filter=web^..."},],};

Quality presets

By default the desktop capture runs at 1440×900 @2x. You can bump this to a named preset to get higher-resolution video and screenshots. The preset sets the logical viewport (CSS pixels); final output dimensions are viewport × deviceScaleFactor (DSF stays at 2 by default).

PresetViewportOutput (DSF=2)
720p1280×7202560×1440
1080p1920×10803840×2160
2k2560×14405120×2880
4k3840×21607680×4320

Mobile capture is not affected by quality presets.

Project-wide default in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},quality: "1080p",}satisfiesProjectConfig;

Per-scenario override (AI-generated or hand-authored scenarios):

{name: "Checkout flow",description: "...",quality: "2k",// preset wins over viewportsteps: [/* ... */],}

Explicit viewport override (when a preset doesn't fit):

{name: "Tablet layout",description: "...",viewport: {width: 1024,height: 768,deviceScaleFactor: 2},steps: [/* ... */],}

One-off env override — useful in CI or for spot checks:

PR_VISUAL_QUALITY=4k npx pr-visual

Precedence (highest wins):

  1. PR_VISUAL_QUALITY env var
  2. scenario.quality
  3. scenario.viewport
  4. projectConfig.quality
  5. Built-in default (1440×900 @2x)

An unknown preset value (env, scenario, or project) fails hard with a clear error.

Adaptive pacing

Each step holds on-screen long enough for viewers to read the caption and absorb the change, scaled by an explicit pacing hint. The hold is computed from the caption's reading time, the action type (first-navigation gets extra breathing room; type scales with value length), a transition cushion when the action changes, and the pacing mode.

Modes (multiplier / floor / cap in ms):

ModeMultiplierFloorCap
quick0.6×9004000
normal(default)1.0×17008000
slow1.5×220010000
dramatic2.0×320012000

dramatic also inserts an 800ms pre-action settle before the step fires, to build anticipation.

Per-step:

{action: "click",selector: "#checkout",caption: "Confirm the order",pacing: "dramatic",// the final beat — let it land}

Project-level reading speed in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},pacing: {wordsPerSecond: 2.8},// slower — for non-native audiences}satisfiesProjectConfig;

Captions of six words or fewer are read proportionally faster (+0.6 w/s) so short beats don't linger.

Narrative beats

Scenarios can tag each step with a beatsetup, action, payoff, or close — to mark where the step sits in the story arc. The annotation layer picks these up:

  • Video: a brief 700ms title-card chip fades in whenever the beat changes between two consecutive steps (so three distinct beats produce two chips).
  • Screenshots: the sidebar shows the beat label under the viewport badge.

Beats also enforce a minimum hold in the pacing formula (setup 1200ms, action 1800ms, payoff 2800ms, close 2200ms), so a payoff step earns scene-length breathing room even under quick pacing.

Emphasis

Each step can also carry emphasis: "strong" to render as a larger title-card caption (1.5× the base caption font, bolder weight). Use it on the key moments you want viewers to remember — usually a payoff beat.

{action: "screenshot",caption: "The deal is closed",beat: "payoff",emphasis: "strong",pacing: "dramatic",}

Persona

Scenarios can carry an audience label via persona: "Agency PM" (any free-form string). This is stored on the scenario for later use by the Remotion intro composer and the Story Director. It does not render directly in the current annotation layer.

{name: "New client onboarding",description: "...",persona: "Agency PM",steps: [/* ... */],}

Invalid beat, emphasis, or pacing values fail the run with a clear error before capture starts.

Interaction overlays

By default, pr-visual captures clean recordings with no cursor or click indicators. If you want your videos to look human-driven, opt into one or more overlays in .pr-visual.config.ts:

exportdefault{devServer: {command: "npm run dev"},overlays: {cursor: true,// visible custom cursor tracking the mouseclicks: true,// ripple + center dot at each clickhighlights: true,// enables the `highlight` scenario step},}satisfiesProjectConfig;

Each flag is independent; all default to false so existing users see no change.

highlight step

When overlays.highlights: true, scenarios can use a new step action that pulses a glow ring around a selector while dimming the rest of the viewport:

{action: "highlight",selector: "#primary-cta",duration: 1500,// ms; defaults to 1500 when omittedcaption: "The primary call to action",beat: "payoff",}

The highlight runs for duration ms; the scenario's pacing hold starts after cleanup.

Capture-time DOM injection

Overlays are injected into the page during capture (unlike the post-capture sidebar and ASS caption layers), so they appear in the recorded video at the right moment. The trade-off: an active cursor or highlight will be visible in screenshots taken right after a navigate. If you want clean screenshots alongside an overlay-rich video, leave overlays.cursor off.

Mobile viewports automatically use a touch-style cursor and tap-ring animations.

Video production

By default the captioned MP4 is the final video artifact. Opt in to a polished Remotion composition (animated intro, crossfades, glassmorphism caption pill, outro with step summary) per scenario or project-wide:

exportdefault{devServer: {command: "npm run dev"},video: {compositing: "remotion",brandColor: "#3b82f6",category: "Checkout",sprintLabel: "Sprint 12",orgName: "Acme Co",highlights: ["Faster checkout","Cleaner cart"],},}satisfiesProjectConfig;

Optional peer dependencies

The Remotion stack is intentionally not a baseline dependency — npm i pr-visual stays small for users who only need captioned recordings. Install the peer deps when you want compositing:

npm i -D remotion @remotion/bundler @remotion/renderer react react-dom

If video.compositing: "remotion" is set but the peer deps aren't installed, pr-visual prints a clear warning and falls back to the captioned MP4. The run still succeeds.

What gets composited

  • Compositing runs on the desktop + light variant only. Mobile composite layouts arrive in #6; the other three variants stay raw.
  • Output is written next to the captioned MP4 as <scenario>-composited.mp4 (H.264, CRF 16).
  • When a composited video exists, the PR comment uses it for the desktop+light slot; other variants keep the captioned MP4.

Adaptive intro/outro length

Intro and outro durations scale with the title + description word count and the number of annotated steps (reading speed 3 w/s), clamped to sensible bounds (intro 3-8s, outro 4-12s).

Mobile composite layouts

Set video.mobile.enabled: true to run a dedicated mobile pass after the main matrix and composite both streams into one MP4. Setting mobile.enabled also implies compositing: "remotion" so a single flag covers the common case.

exportdefault{devServer: {command: "npm run dev"},video: {mobile: {enabled: true,layout: "side-by-side"},},}satisfiesProjectConfig;

Layouts

  • side-by-side (default): desktop 80% + phone 20% in a stylized device frame. The canvas widens by 25% to fit both columns at near-native size.
  • pip: phone bottom-right over fullscreen desktop. Canvas dimensions unchanged.
  • sequential: desktop for the first half of the recording, phone for the second. Canvas dimensions unchanged.

Per-step mobile overrides

Scenarios can tweak the mobile pass without forking the script:

{action: "navigate",url: "/",mobilePath: "/m",caption: "Open"}{action: "click",selector: "#desktop-cta",mobileSelector: "#mobile-cta",caption: "Tap CTA"}{action: "highlight",selector: "#desktop-only",mobileSkip: true,caption: "Hover hint"}
  • mobilePath: rewrites the navigate URL on mobile.
  • mobileSelector: swaps the selector on mobile.
  • mobileSkip: omits the step from the mobile pass entirely.

Wall-clock cost

The mobile pass is sequential (separate browser context, fresh navigation), so runs with mobile compositing take ~1.8x the wall-clock of desktop-only runs. The pipeline prints a heads-up when mobile compositing fires.

If the mobile pass throws (selector missing, navigation fails), the compositing step aborts and the captioned MP4 stays as the final artifact — the run otherwise succeeds.

Authenticated demos

pr-visual is framework-agnostic about auth: you supply Playwright storage state JSON files, name them as profiles in .pr-visual.config.ts, and scenarios opt in via scenario.profile. The captured matrix variants and the mobile composite pass all load the same storage state.

exportdefault{devServer: {command: "npm run dev"},auth: {storageStateDir: ".pr-visual/auth",// defaultprofiles: {admin: "admin.json",viewer: "viewer.json",},// Optional — runs after `setup` and before `devServer`. Use it to// refresh storage state per run; pr-visual just calls the command.tokenGenerator: {name: "Refresh storage state",command: "node scripts/refresh-auth.mjs",},},}satisfiesProjectConfig;
// In your scenario:{name: "Admin dashboard tour",description: "...",profile: "admin",steps: [/* ... */],}

npx pr-visual init adds .pr-visual/auth/ to .gitignore automatically — storage state files contain session tokens.

Generating storage state

How you produce the JSON files is up to you. Two common patterns:

Pattern 1: Playwright login script

Run a one-off Playwright script that drives the login UI and saves the context state:

// scripts/refresh-auth.mjsimport{chromium}from"playwright";constbrowser=awaitchromium.launch();constctx=awaitbrowser.newContext();constpage=awaitctx.newPage();awaitpage.goto("http://localhost:3000/login");awaitpage.getByLabel("Email").fill("admin@example.com");awaitpage.getByLabel("Password").fill(process.env.ADMIN_PASSWORD);awaitpage.getByRole("button",{name: "Sign in"}).click();awaitpage.waitForURL("**/dashboard");awaitctx.storageState({path: ".pr-visual/auth/admin.json"});awaitbrowser.close();

Pattern 2: Supabase admin API (no browser needed)

// scripts/refresh-auth.mjsimportfsfrom"node:fs";import{createClient}from"@supabase/supabase-js";constsupabase=createClient(process.env.SUPABASE_URL,process.env.SUPABASE_SERVICE_ROLE_KEY,);const{ data, error }=awaitsupabase.auth.admin.generateLink({type: "magiclink",email: "admin@example.com",});if(error)throwerror;// Build a Playwright storage-state JSON with the supabase localStorage entry,// keyed `sb-<project-ref>-auth-token`. Shape per Supabase JS docs.constsession={access_token: data.properties.action_link,/* ... */};constprojectRef=newURL(process.env.SUPABASE_URL).hostname.split(".")[0];fs.writeFileSync(".pr-visual/auth/admin.json",JSON.stringify({cookies: [],origins: [{origin: "http://localhost:3000",localStorage: [{name: `sb-${projectRef}-auth-token`,value: JSON.stringify({currentSession: session}),}],}],}),);

Anything that writes a Playwright storage-state JSON works. The tokenGenerator step is templated with {{runId}}, {{port}}, {{rootDir}} like other lifecycle steps.

Validation

After the generator runs, pr-visual verifies every configured profile points at a readable JSON file. A missing or malformed file fails the run before capture starts, so a silently-broken generator surfaces immediately.

The PR_VISUAL_AUTH_DIR env var overrides storageStateDir — useful when storage state is generated outside the repo.

Page Object Models

Non-trivial real-world demos often need multi-step orchestration (dismiss a modal, wait for data, assert an intermediate state). Rather than duplicating that logic in every scenario, point pr-visual at your existing E2E Page Object Model modules:

// .pr-visual.config.tsexportdefault{devServer: {command: "npm run dev"},poms: {dashboard: "./e2e/pages/dashboard.ts",checkout: "./e2e/pages/checkout.ts",},}satisfiesProjectConfig;
// ./e2e/pages/dashboard.ts — pr-visual expects plain functions.// Each function receives the Playwright `Page` as the first argument// plus any user arguments defined on the scenario step.importtype{Page}from"playwright";exportasyncfunctionlogin(page: Page,email: string): Promise<void>{awaitpage.getByLabel("Email").fill(email);awaitpage.getByLabel("Password").fill(process.env.DEMO_PASSWORD!);awaitpage.getByRole("button",{name: "Sign in"}).click();awaitpage.waitForURL("**/dashboard");}exportasyncfunctionopenInbox(page: Page): Promise<void>{awaitpage.getByRole("link",{name: "Inbox"}).click();awaitpage.waitForSelector("[data-testid=inbox-list]");}

Then use them in scenarios:

{name: "Inbox tour",description: "...",steps: [{action: "navigate",url: "/",caption: "Open the app"},{action: "pom",page: "dashboard",method: "login",args: ["demo@example.com"],caption: "Sign in",},{action: "pom",page: "dashboard",method: "openInbox",caption: "Open the inbox",},{action: "screenshot",caption: "Inbox view"},],}

Contract

  • Each registered module exports named functions shaped as (page: Page, ...args: unknown[]) => void | Promise<void>.
  • Classes are not supported directly (predictable stateless lifecycle). Wrap them with a thin factory if you need class-based POMs.
  • args on the scenario step is an array forwarded positionally after page. Omitted args means the function is called with just (page).

Validation

pr-visual loads POM modules eagerly at scenario-validation time, so unknown page names, unknown method names, and import failures surface as pre-capture errors instead of runtime crashes deep in the capture loop.

Overlay interaction

  • Custom cursor tracking (when overlays.cursor: true) works inside POM methods automatically — the mousemove listener follows any Playwright-driven movement.
  • Click ripples and highlight spotlights do not fire inside POM methods. Those overlays are injected at the call site in pr-visual's step executor, not globally. If you want a ripple on a POM-internal click, add an explicit click step for that interaction instead.

Voice-over

Step captions become narration in the composited MP4. Each caption is synthesized to an MP3 and mixed into the Remotion composition, anchored to the start of that step on the video timeline. Setting voiceover.enabled: true also implies compositing: "remotion".

exportdefault{devServer: {command: "npm run dev"},voiceover: {enabled: true,// Leave `provider` / `voice` out to auto-detect the first available.},}satisfiesProjectConfig;

Provider chain

Detection order (first available wins — the MP4 uses one provider throughout):

  1. Piper — local neural TTS, offline, no account. Requires piper on PATH and a voice model. Point PIPER_MODEL at an .onnx file, or drop one into ~/.cache/piper/voices/.
  2. Google Cloud TTS — OAuth via gcloud. Requires gcloud auth application-default login. Default voice en-US-Neural2-F.
  3. OpenAI TTSOPENAI_API_KEY env var. Default voice alloy.
  4. macOS say — always available on macOS. Default voice Samantha.

Override via voiceover.provider; that provider is then used regardless of detection order. Per-clip synthesis failures log a warning and skip that step — the rest of the MP4 still narrates.

If no provider is available, the run fails with an explicit error listing the install options.

Caching

Clips are cached at .pr-visual/tts/step-NN-<hash>.mp3, keyed by sha256(provider + caption text). Re-running a scenario with unchanged captions is essentially free. Switching provider invalidates the cache for that step (the hash changes).

npx pr-visual init adds .pr-visual/tts/ to .gitignore automatically.

ffmpeg

Piper and say emit WAV/AIFF and use ffmpeg / ffprobe to transcode to MP3 and measure duration. pr-visual already expects ffmpeg for subtitle burning, so there's no new prerequisite.

Story Director

When ANTHROPIC_API_KEY (or CLAUDE_PLUGIN_OPTION_ANTHROPIC_API_KEY) is set, AI scenario generation runs through the Story Director instead of emitting flat step lists. The director picks one of four personas (End User, Admin, New User, Stakeholder) based on the PR content and drafts a three-act narrative arc:

Persona: End User
Setup: A user opens the dashboard expecting today's metrics.
Inciting: They notice a new tile they have never seen before.
Payoff: Clicking the tile reveals a clearer breakdown of the data.
Closing: Users now answer the question without leaving the dashboard.

Each generated scenario carries the matching persona and every step arrives pre-populated with the right beat (setup / action / payoff / close) and emphasis. The annotation layers from Narrative beats and Adaptive pacing then take over.

When no API key is set, the run falls back to the static-routes scenarios (unchanged from before).

Brief cache

The director caches each brief by sha256(prDescription + diff) to .pr-visual/story/<hash>.json. Re-running on an unchanged PR is free. init adds .pr-visual/story/ to .gitignore.

story subcommand

Inspect the brief without recording, or scaffold it to disk:

# Print the human-readable arc for the current branch's PR.
npx pr-visual story
# Same, against an explicit PR number.
npx pr-visual story --pr 42
# Machine-readable JSON to stdout.
npx pr-visual story --pr 42 --json
# Write the full {narrative, scenarios} brief to disk for editing.# Output: .pr-visual/story-scaffold.json
npx pr-visual story --scaffold

The scaffold path is convenient for tweaking the arc by hand before re-running pr-visual — load the JSON yourself and pass it as a hand-authored scenario set.

CLI commands

npx pr-visual [command]
CommandDescription
run (default)Execute the full capture pipeline
initDetect project setup and generate .pr-visual.config.ts
cleanupRemove orphaned worktrees, Docker projects, and stale directories
storyPrint or scaffold the Story Director's brief without recording. Flags: --pr <n>, --scaffold, --json.

Cleanup

If a run is interrupted (Ctrl+C, crash, killed terminal), resources may be left behind. The cleanup command finds and removes them:

npx pr-visual cleanup

This removes:

  • Orphaned git worktrees (pr-visual-* branches and directories)
  • Orphaned Docker Compose projects (containers, networks, volumes named pr-visual-*)
  • Stale worktree parent directories

The recorder also registers signal handlers for SIGINT and SIGTERM, so a normal Ctrl+C during a run will attempt to tear down services and remove the worktree before exiting.

Environment variables

VariableDefaultDescription
ANTHROPIC_API_KEYEnables AI-generated scenarios (falls back to static)
PR_BODYOverride PR body text for scenario generation
PR_VISUAL_CONFIGExplicit path to config file
PR_VISUAL_NO_ISOLATESet to 1 to skip worktree isolation
PR_VISUAL_QUALITYDesktop quality preset override: 720p, 1080p, 2k, 4k. Takes precedence over scenario and project config.
PR_VISUAL_AUTH_DIROverride auth.storageStateDir. Useful when storage state is generated outside the repo.

How it works

Pipeline

┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌──────────────┐
│ Worktree │───▶│ Setup │───▶│ Dev Server │───▶│ Readiness │
│ + install │ │ steps │ │ start │ │ probe │
└─────────────┘ └──────────┘ └───────────┘ └──────┬───────┘
│
┌─────────────┐ ┌──────────┐ ┌───────────┐ ┌─────▼────────┐
│ PR attach │◀───│ Annotate │◀───│ Capture │◀───│ Scenarios │
│ + cleanup │ │ + video │ │ all vars │ │ (AI / diff) │
└─────────────┘ └──────────┘ └───────────┘ └──────────────┘

Isolation model

Each run creates a git worktree at the current commit:

  • Directory: ../.pr-visual-worktrees/pr-visual-<timestamp>-<hex> (outside repo)
  • Branch: pr-visual/pr-visual-<timestamp>-<hex> (temporary)
  • Port: auto-allocated from preferred port upward (scans 100 ports)
  • Docker: COMPOSE_PROJECT_NAME=pr-visual-<timestamp>-<hex> namespaces all resources
  • Dependencies: full install from lockfile in the worktree

Multiple parallel runs get different worktrees, ports, and Docker project names — complete isolation.

Lifecycle

  1. Setup steps — sequential shell commands with per-step timeouts
  2. Dev server — spawned as a detached process group
  3. Readiness probe — polls endpoint until expected status or timeout
  4. Teardown — runs cleanup commands; errors are logged but don't abort

Cleanup guarantees

  • Signal handlers (SIGINT/SIGTERM) run teardown and worktree removal on interrupt
  • Explicit cleanup in finally block for normal completion or exceptions
  • npx pr-visual cleanup as a manual recovery for hard crashes

Troubleshooting

Skills not appearing after install

Run /reload-plugins to refresh the plugin list.

ffmpeg captioning fails

The video captioning feature requires ffmpeg with either libass or subtitles filter support. If neither is available, the plugin gracefully skips captioning and returns the raw video.

To get full captioning support:

brew install ffmpeg

Screenshots show wrong page (i18n sites)

If your site redirects / to a locale path (e.g. /en), configure the routes field in your .pr-visual.config.ts:

exportdefault{readiness: {path: "/en"},routes: [{path: "/en",label: "Homepage"}],};

next: command not found in worktree

Use npx in your dev server command to resolve binaries from node_modules:

exportdefault{devServer: {command: "npx next dev --port {{port}}"},};

Project structure

.claude-plugin/
plugin.json # Plugin manifest
marketplace.json # Plugin marketplace definition
skills/pr-visual/
SKILL.md # Slash command definition
hooks/
hooks.json # PostToolUse hook for gh pr create
bin/
pr-visual # CLI entrypoint
scripts/pr-visual/
index.ts # CLI routing (run | init | cleanup)
types.ts # Shared types (ViewportConfig, ProjectConfig, RunContext, etc.)
config.ts # Config discovery, loading, template substitution
worktree.ts # Git worktree creation, port allocation, cleanup
lifecycle.ts # Setup/teardown steps, dev server, readiness, signal handlers
init.ts # Project detection and config scaffolding
cleanup.ts # Orphaned resource discovery and removal
scenario-generator.ts # Claude API integration for scenario generation
capture.ts # Playwright capture across viewports and color schemes
pr-attach.ts # GitHub PR body patching and comment posting
annotate/
screenshots.ts # sharp + SVG sidebar compositing → WebP
video.ts # ffmpeg ASS caption burning → H.264 MP4

Development

npm ci # install deps + Playwright chromium
npm run typecheck # tsc --noEmit
npm run lint # Biome (lint + format check, fails on warnings)
npm run lint:fix # Biome auto-fix (safe rules) + write
npm run format # Biome format only — write
npm test# vitest run (unit + integration + e2e)

CI runs typecheck, lint, and the full test suite on every PR and on push to master (Node 20). All warnings are treated as errors.

Releases

Releases are created by .github/workflows/release.yml, which runs after the CI workflow finishes successfully on master. A red CI run blocks the release.

A release is cut only when package.jsonversion is bumped above the latest v* git tag. Merging a PR that does not change version does not produce a release — this lets you land refactors, docs, and chore work between shipments.

To cut a release, open a PR that:

  • bumps version in package.json (patch / minor / major as appropriate);
  • bumps version in .claude-plugin/plugin.json to the same value;
  • bumps both version fields in .claude-plugin/marketplace.json to the same value;
  • adds a CHANGELOG.md entry describing the release.

When the PR lands on master and CI passes, the workflow tags the commit v<version> and publishes a GitHub Release with auto-generated notes (merged PRs and commits since the previous tag). The workflow itself never writes to the repository.

If CI passed but the release did not fire (e.g., a transient failure), use the workflow's workflow_dispatch trigger from the Actions tab to re-run it.

License

MIT — see LICENSE for details.

About

Claude Code plugin for visual PR documentation — annotated screenshots and walkthrough videos

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages