/screenshot-pr
Take responsive screenshots that demonstrate what the PR changed — activated feature shots only, never baseline page views. Screenshots are cropped to the feature element (dialog, popover, drawer, etc.) so reviewers see the change without scrolling through a full-page render. Uploads to a public GCS bucket and embeds into the PR's ## Screenshots section. Re-running always replaces the existing Screenshots section with fresh images.
The user may pass explicit routes/selectors as arguments. If none are given, infer from changed files and confirm before proceeding.
Step 0 — Commit any uncommitted changes first
Before screenshotting, ensure all local code changes are committed and pushed. Screenshots taken from a dirty working tree will not match the code in the PR.
cd /path/to/frontend # absolute path
npm run cleanup
If cleanup modifies files, stage and commit:
git -C /path/to/repo add -u
git -C /path/to/repo status --porcelain
If any tracked files are modified (lines starting with M or M ):
git -C /path/to/repo add frontend/src frontend/CLAUDE.md .claude/skills
git -C /path/to/repo commit -m "$(cat <<'COMMITMSG'
<short description of changes being screenshotted>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
COMMITMSG
)"
FORK_REMOTE=$(git -C /path/to/repo remote -v | grep -i "github.com[/:]${GH_USER}/" | head -1 | awk '{print $1}')
git -C /path/to/repo push $FORK_REMOTE HEAD
Only continue to Step 1 once the working tree is clean and all changes are pushed.
Step 1 — Get PR context
gh pr view --json number,title,body,headRefName
If no open PR is found: print an error and stop.
Extract: number, headRefName, body.
Step 2 — Derive slugs
- Feature slug: strip
feat/, fix/, feature/, chore/ prefix from branch name; replace / and non-alphanumeric chars with -; lowercase; collapse consecutive -.
- Example:
feat/recent-geo-locations → recent-geo-locations
- Output dir:
/tmp/het-screenshots/pr-{number}/{feature-slug}
- GCS upload prefix:
pr-{number}/{feature-slug}/
- Public URL base:
https://storage.googleapis.com/het-pr-screenshots/pr-{number}/{feature-slug}/
Step 3 — Determine what to capture
The goal is screenshots that show the activated feature, cropped tightly to the changed UI. A page in its default/empty state is never useful.
If the user passed explicit routes or selectors: use those. Skip inference.
If no routes given: read the diff and work out the minimum interaction needed to see the change.
git diff --name-only $(git merge-base HEAD origin/main)
git diff $(git merge-base HEAD origin/main) -- frontend/src/
For each changed file, ask:
- What URL loads the page containing this component?
- What interaction (click, URL param, seeded localStorage) activates it?
- What CSS selector uniquely identifies the feature element to crop to?
Scan Playwright tests for ready-made URLs:
grep -rn "goto(" frontend/playwright-tests/ --include="*.ts" | head -20
Scan for useParamState calls to find URL params that open modals/drawers:
grep -rn "useParamState" frontend/src/ --include="*.tsx" --include="*.ts"
Always confirm the plan with the user — list each capture, the activation steps, and the crop selector — before taking any screenshots.
Step 4 — Ensure dev server is running with the right env
A server started as a bare npx vite still answers 200, so a status check alone is not enough. It skips the env-cmd -f .env.localhost wrapper, leaving VITE_BASE_API_URL unset, and every screenshot comes back with an empty page. Require both a healthy response and evidence the process was launched through the npm script:
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000
pgrep -f "env-cmd -f .env.localhost" > /dev/null && echo "env ok" || echo "env missing"
Restart unless the status is 200 and the env check printed env ok:
lsof -ti :3000 | xargs kill -9 2>/dev/null; sleep 1
cd /path/to/frontend
npm run dev > /tmp/het-dev-server.log 2>&1 &
DEV_PID=$!
TIMEOUT=60
until curl -s http://localhost:3000 > /dev/null 2>&1; do
sleep 1; TIMEOUT=$((TIMEOUT - 1))
[ $TIMEOUT -le 0 ] && echo "Dev server failed to start" && kill $DEV_PID 2>/dev/null && exit 1
done
echo "Server ready"
Track $DEV_PID for cleanup in Step 9.
Step 5 — Feature screenshots (element-cropped)
Write a temporary Playwright script at frontend/scripts/_screenshots.ts. For each capture:
- Navigate to the URL with
{ waitUntil: 'networkidle' }
- Disable animations via an injected style tag
- Perform the activation (click button, seed localStorage, set URL param, etc.)
- Wait for the feature element to appear
- Crop to the feature element using
locator.screenshot() — this captures only the element's bounding box, not the full page
Cropping approach — always use boundingBox + clip with padding:
Show the feature element with enough surrounding context that the reviewer can see its boundary, shadow, and relationship to its parent/neighbors. Never crop flush to the element edge.
const PADDING = 80 // pixels of surrounding context on each side
const el = page.locator(CROP_SELECTOR).first()
await el.waitFor({ timeout: 10000 })
const box = await el.boundingBox()
if (box) {
const clipX = Math.max(0, box.x - PADDING)
const clipY = Math.max(0, box.y - PADDING)
const clipRight = Math.min(vp.width, box.x + box.width + PADDING)
const clipBottom = Math.min(vp.height, box.y + box.height + PADDING)
await page.screenshot({
path: join(OUT, filename),
clip: { x: clipX, y: clipY, width: clipRight - clipX, height: clipBottom - clipY },
})
}
Adjust PADDING per feature — 80px is the default. Use 120px+ for modals where you want the dimmed backdrop edge visible.
Common crop selectors:
- MUI Popover/Menu paper:
'.MuiPopover-paper'
- MUI Dialog:
'[role="dialog"]'
- MUI Drawer:
'.MuiDrawer-paper'
- Custom panels/cards: use the outermost container class or
data-testid
Viewport heights: 844 mobile, 1024 tablet, 900 desktop.
Key considerations:
- Use
{ waitUntil: 'networkidle' } — the dev server fetches live data from GCP.
- Disable animations before activating the feature:
page.addStyleTag({ content: '*, *::before, *::after { animation: none !important; transition: none !important; }' })
- Create a fresh browser context per viewport so state doesn't bleed.
- If a feature only renders below a breakpoint, add a
maxWidth guard and log a skip message.
- If the feature element never appears, log a warning and continue — do not crash.
Example script structure:
import { chromium } from '@playwright/test'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
const BASE = 'http://localhost:3000'
const OUT = '/tmp/het-screenshots/pr-{number}/{feature-slug}'
const CROP_SELECTOR = '.MuiPopover-paper' // adjust per feature
const VIEWPORTS = [
{ width: 375, height: 844, label: 'mobile' },
{ width: 768, height: 1024, label: 'tablet' },
{ width: 1280, height: 900, label: 'desktop' },
]
mkdirSync(OUT, { recursive: true })
const browser = await chromium.launch()
for (const vp of VIEWPORTS) {
const ctx = await browser.newContext({ viewport: { width: vp.width, height: vp.height } })
const page = await ctx.newPage()
// Seed any required state before navigation
await page.goto(BASE, { waitUntil: 'domcontentloaded' })
await page.evaluate(() => {
localStorage.setItem('my-key', JSON.stringify({ /* seed data */ }))
})
await page.goto(`${BASE}/exploredata?mls=1.hiv-3.06&mlp=disparity`, { waitUntil: 'networkidle' })
await page.addStyleTag({ content: '*, *::before, *::after { animation: none !important; transition: none !important; }' })
// Activate the feature
await page.getByRole('button', { name: /location name/i }).first().click()
await page.waitForTimeout(400)
// Crop to feature element with surrounding context
const PADDING = 80
const el = page.locator(CROP_SELECTOR).first()
try {
await el.waitFor({ timeout: 10000 })
const box = await el.boundingBox()
if (!box) throw new Error('no bounding box')
const clipX = Math.max(0, box.x - PADDING)
const clipY = Math.max(0, box.y - PADDING)
const clipRight = Math.min(vp.width, box.x + box.width + PADDING)
const clipBottom = Math.min(vp.height, box.y + box.height + PADDING)
const filename = `feature-name-${vp.label}.png`
await page.screenshot({
path: join(OUT, filename),
clip: { x: clipX, y: clipY, width: clipRight - clipX, height: clipBottom - clipY },
})
console.info(` [${vp.label}] ${filename}`)
} catch {
console.warn(` [warn] feature element not found @ ${vp.width}px`)
}
await ctx.close()
}
await browser.close()
console.info('Done.')
Run it:
npx tsx /absolute/path/to/frontend/scripts/_screenshots.ts
After all screenshots are captured, delete the temp script:
rm /absolute/path/to/frontend/scripts/_screenshots.ts
Step 6 — Upload to GCS
Do not use gsutil or gcloud storage. Both read the gcloud user credential store, which this org expires on a RAPT (ReAuth Proof Token) timer. Refreshing a RAPT requires an interactive terminal, and the Bash tool is not one, so those commands fail with ReauthUnattendedError and no amount of re-running fixes it. Making the user run gcloud auth login only buys a few hours before the same wall reappears.
Application Default Credentials are a separate credential of type authorized_user. Its refresh token exchanges for an access token through a plain POST to Google's OAuth endpoint with no reauth challenge, so it keeps working unattended for weeks. Talk to the GCS JSON API directly with it.
Write a throwaway uploader to /tmp/het_upload.py. It reads the ADC file at runtime from the standard path below. Never copy a token, client secret, or refresh token into this skill or into any committed file.
import json, os, sys, urllib.parse, urllib.request
from pathlib import Path
ADC = os.path.expanduser('~/.config/gcloud/application_default_credentials.json')
BUCKET = 'het-pr-screenshots'
PREFIX = 'pr-{number}/{feature-slug}/'
SRC = '/tmp/het-screenshots/pr-{number}/{feature-slug}'
BOUNDARY = 'het-boundary-7f3a'
def token():
c = json.load(open(ADC))
data = urllib.parse.urlencode({
'client_id': c['client_id'],
'client_secret': c['client_secret'],
'refresh_token': c['refresh_token'],
'grant_type': 'refresh_token',
}).encode()
url = 'https://oauth2.googleapis.com/token'
return json.load(urllib.request.urlopen(url, data))['access_token']
def api(tok, url, method='GET', body=None, headers=None):
h = {'Authorization': 'Bearer ' + tok}
h.update(headers or {})
req = urllib.request.Request(url, data=body, headers=h, method=method)
return urllib.request.urlopen(req)
tok = token()
base = f'https://storage.googleapis.com/storage/v1/b/{BUCKET}/o'
# Clear stale objects from earlier runs so the prefix matches the new run exactly
listing = json.load(api(tok, f'{base}?prefix={urllib.parse.quote(PREFIX)}'))
for obj in listing.get('items', []):
api(tok, f'{base}/{urllib.parse.quote(obj["name"], safe="")}', method='DELETE')
for path in sorted(Path(SRC).glob('*.png')):
name = PREFIX + path.name
meta = json.dumps({'name': name, 'cacheControl': 'no-cache, no-store'}).encode()
body = (
f'--{BOUNDARY}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n'.encode()
+ meta
+ f'\r\n--{BOUNDARY}\r\nContent-Type: image/png\r\n\r\n'.encode()
+ path.read_bytes()
+ f'\r\n--{BOUNDARY}--\r\n'.encode()
)
api(
tok,
f'https://storage.googleapis.com/upload/storage/v1/b/{BUCKET}/o?uploadType=multipart',
method='POST',
body=body,
headers={'Content-Type': f'multipart/related; boundary={BOUNDARY}'},
)
print('uploaded', name)
python3 /tmp/het_upload.py && rm /tmp/het_upload.py
Confirm the count matches the number of PNGs captured. If any file is missing, re-run the script — it is idempotent.
If the token refresh itself fails (invalid_grant, or the ADC file does not exist), ADC has genuinely expired or was never created. That is the one case where the user must act: ask them to run gcloud auth application-default login, then retry. This should be rare; ADC outlives the user credential by a wide margin.
Step 7 — Build the Screenshots markdown
Group screenshots by feature/section. Append ?v={unix-timestamp} to every URL (same TS=$(date +%s) across the whole run) to bust GitHub's image proxy cache.
### {Feature Name}
**Mobile (375px)**

**Tablet (768px)**

**Desktop (1280px)**

Separate feature groups with ---. Omit the trailing --- after the last block.
Step 8 — Update the PR body (always replaces)
- Fetch the current body if not already held.
- Strip everything from
## Screenshots to end-of-string (it's always last).
- Append the new Screenshots section.
- Write to
/tmp/het-screenshots/pr-{number}/body.md and apply:
gh pr edit {number} --body-file /tmp/het-screenshots/pr-{number}/body.md
Step 9 — Cleanup and confirm
[ -n "$DEV_PID" ] && kill $DEV_PID 2>/dev/null
rm -rf /tmp/het-screenshots/pr-{number}
Print:
"✓ {count} screenshots embedded in PR #{number}: {PR URL}"
Notes
- GCS bucket:
het-pr-screenshots — public read, 90-day auto-delete lifecycle.
- Upload auth: ADC only, via the JSON API (Step 6).
gsutil / gcloud storage hit an interactive-only reauth challenge and cannot work from a tool call.
- Breakpoints: 375px mobile, 768px tablet, 1280px desktop.
- Crop first: always use
locator.screenshot() or clip — never fullPage: true.
- No baseline shots: only capture the activated/changed UI state.
- Re-running: always replaces the
## Screenshots section — never appends a second copy.
- If Playwright browsers aren't installed: run
npx playwright install chromium from frontend/.