pr-doctor
CLI + thin GitHub Action that gates PRs by pixel-comparing a running app against Figma design baselines.
Unlike visual-regression tools (Lost Pixel, Percy, Chromatic) that compare implementation now vs implementation before and need humans to approve screenshot baselines, pr-doctor treats Figma as the source of truth. A designer edit updates the baseline; there is no “approve this PNG” ritual.
v1 scope: pull → capture → compare → report; optional AI fix loop; optional E2E session video. No SaaS, no review web UI, no cloud baseline store.
Requirements
- Node.js ≥ 20
- Chromium (via Playwright; install with
npx playwright install chromiumif needed) - Figma access token with
file_content:read(and ideallyfile_metadata:readfor cheap version polling)
Install
npm install -D pr-doctor
# or run without install:
npx pr-doctor@latest check
Quickstart
- Config —
prdoctor.config.jsonat the repo root (or.mjs/.jswith a default export):
{
"figma": {
"fileKey": "YOUR_FIGMA_FILE_KEY"
},
"capture": {
"serve": {
"command": "npm run preview",
"url": "http://127.0.0.1:4173",
"readyTimeoutMs": 60000
}
},
"regions": [
{
"name": "primary-cta",
"figmaNode": "12:34",
"path": "/",
"selector": "[data-testid=primary-cta]",
"viewport": { "width": 1280, "height": 720 }
}
],
"compare": {
"threshold": 0.1,
"maxDiffPixelRatio": 0.01,
"scale": 2,
"background": "#ffffff"
},
"report": {
"comment": true,
"media": "auto"
}
}
Secrets never go in config. figmaNode accepts a bare node id (12:34 / 12-34), fileKey:nodeId, or a full Figma URL.
- Env
| Variable | Used by | Notes |
|---|---|---|
FIGMA_ACCESS_TOKEN |
pull / check | PAT or plan access token; Dev or Full seat on the plan that owns the files (View/Collab seats hit ~6 image exports/month and are unusable for CI) |
GITHUB_TOKEN |
report / CI comment | default in Actions |
PRDOCTOR_API_KEY |
fix | OpenRouter-compatible key |
PRDOCTOR_MODEL |
fix | model id for the agent |
Figma rate limits (Tier 1 image export): on the order of 10–20/min depending on seat. pr-doctor caches baselines by file version (cheap meta call each run) and only re-exports when the version changes. On 429 it honors Retry-After once, then falls back to cache (warn) or exits 3.
- Run
export FIGMA_ACCESS_TOKEN=…
prdoctor check
Artifacts land in .prdoctor/run/<region>/{design,actual,diff}.png and .prdoctor/run/score.json.
Commands
| Command | Does | Exit codes |
|---|---|---|
prdoctor pull |
Fetch Figma baselines for all (or --region) regions; version cache |
0 · 2 config · 3 Figma/API |
prdoctor check |
pull (unless --no-pull) → serve/attach → capture → compare → artifacts (+ PR comment in CI) |
0 pass · 1 over tolerance · 2 config · 3 env/infra |
prdoctor fix |
check; on fail, agent edits within fix.boundary, recheck ≤ maxRounds |
0 fixed/pass · 1 still failing · 2/3 |
prdoctor e2e |
Run configured Playwright specs with video; stage under .prdoctor/run/e2e/ |
0 · 1 test fail · 3 env |
prdoctor report |
(Re)post PR comment from existing artifacts | 0 · 3 |
Flags of note:
check --no-pull— use cached.prdoctor/baseline/onlycheck --region <name>/pull --region <name>check --at <versionId>/pull --at <versionId>— pin Figma file versionfix --push— push fix commits (CI)report --dry-run— print markdown without posting
Global: --config <path>, --cwd <path>.
Exit codes (uniform)
| Code | Meaning |
|---|---|
| 0 | Pass |
| 1 | Quality gate failed (diff over tolerance, or e2e test fail) |
| 2 | User/config error (bad config, size-mismatch design vs capture, missing token when required as config-class) |
| 3 | Environment / infra (Figma API, browser, network, serve timeout) |
A Figma outage or 429 is exit 3, not a red visual gate. Size mismatch is exit 2 — no silent resize.
score.json shape: { regions: [{ name, diffPixelRatio, pass, dims, figmaFileVersion? }], pass } — real ratios only, no display inflation.
Media modes (PR comments)
Controlled by report.media (default auto):
| Mode | Behavior | When |
|---|---|---|
auto |
public repo → branch; private → artifact |
default |
branch |
push media to orphan branch prdoctor-media; inline via raw URLs |
public repos only for reliable inline images |
artifact |
scores table + link to Actions artifact (no true inline images) | private repos, zero setup |
s3 |
upload to S3/R2; embed signed URLs (default TTL 7d) | private repos wanting inline |
Private-repo honesty: GitHub’s image proxy fetches raw.githubusercontent.com unauthenticated, so branch-hosted images break in private repos. There is no supported bot API for PR-comment image attachments. Use artifact (default via auto) or s3 for private repos.
Fix loop
{
"fix": {
"enabled": true,
"maxRounds": 2,
"boundary": ["src/components/**"]
}
}
export PRDOCTOR_API_KEY=… PRDOCTOR_MODEL=…
prdoctor fix # local
prdoctor fix --push # CI: push commits with PR-Doctor-Fix: trailer
- Edits outside
boundaryare rejected. - Hard gate: a push that still fails tolerance exits 1 (not “soft green”).
- Re-entry: workflows should skip the fix stage when the head commit message contains
PR-Doctor-Fix:(the composite action does this).
E2E video
{
"e2e": {
"enabled": true,
"specs": "e2e/**/*.spec.ts"
}
}
Runs the repo’s Playwright specs with video on; stages webm (+ gif/mp4 when ffmpeg is available). E2E failure is exit 1. Teams without specs leave e2e unset / disabled.
GitHub Action
Thin wrapper: npx pr-doctor@<version> — pin version for reproducibility.
# .github/workflows/pr-doctor.yml
name: PR Doctor
on:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: pr-doctor-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
visual-gate:
runs-on: ubuntu-latest
permissions:
contents: write # fix --push / media branch
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: 1infra/1doctor/action@main # or path to this action/
with:
figma-token: ${{ secrets.FIGMA_ACCESS_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
# config: prdoctor.config.json
fix: "false"
e2e: "false"
version: latest
# api-key / model — only if fix: "true"
Action steps: re-entry guard → check → optional fix --push on exit 1 → optional e2e on success → best-effort report → always upload .prdoctor/run artifact.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| 403 / “token expired or invalid” | PATs expire ≤90 days. Regenerate FIGMA_ACCESS_TOKEN. On Org/Enterprise prefer a plan access token not tied to one employee. |
| 429 rate limit | Dev/Full seat required; View/Collab is unusable. Cache by file version reduces Tier-1 spend. Wait / Retry-After, or commit .prdoctor/baseline/ as offline fallback (warn path). |
| size-mismatch (exit 2) | Design PNG dims ≠ capture dims. Align Figma frame size, scale / deviceScaleFactor, and region selector/viewport. Silent crop/resize is refused so layout bugs stay visible. |
| Gate red, tiny AA/text diffs | Expected Figma-vs-Chromium differences; tune compare.threshold and maxDiffPixelRatio. Bit-identity is a non-goal. |
| Dynamic content noise | Use region mask selectors and waitFor; capture already sets reducedMotion: reduce. |
| Preview never ready (exit 3) | Raise capture.serve.readyTimeoutMs; check serve logs (timeout includes last output lines). |
| Private PR images broken | media: branch cannot inline in private repos. Use auto/artifact or s3. |
License
MIT