| name | validate-ui |
| description | Validate a Breadbox admin UI change in a real browser and produce a screenshot as PR evidence. Prefers Chrome DevTools MCP when available; falls back to headless Chromium via Playwright in cloud sessions where the MCP is not loaded. Saves a JPEG under 1MB, uploads to the self-hosted bb-artifacts.exe.xyz CDN (auth via `gh auth token`), ready to embed in a PR. Triggers: "validate the UI change", "screenshot this page", "capture the transactions page", "attach a screenshot to the PR", "show me how it looks", or any task needing a visual of the running app before a PR can be marked done.
|
Validate UI
Fastest path — try this first: make dev-shot ARGS="/transactions --mobile".
It rebuilds, (re)starts this worktree's tracked background server (resolving a
port for you — no fishing), and screenshots the route(s) via puppeteer + system
Chrome with a fresh profile, printing the JPEG paths. It never collides with a
locked Chrome DevTools MCP profile and is auto-cleaned when the session ends.
Flags: --desktop|--mobile|--tablet|--wide (repeatable), --wait <css>,
--full, --no-rebuild. Then upload via the github-image-hosting skill and
embed per the rules below. The two backends below are for interactive
multi-step flows (filling forms, JS evaluation across turns) — for a straight
capture, make dev-shot is fewer moving parts. See scripts/README.md.
Validate a Breadbox admin UI change in the running app, then attach a screenshot to the PR as evidence. Two backends, picked in this order:
- Chrome DevTools MCP (
mcp__plugin_chrome-devtools-mcp_chrome-devtools__*) — preferred when available. Drives a real Chrome you can also see; supports interactive flows (forms, snapshots, JS evaluation).
- Headless Chromium via Playwright — fallback for cloud sessions where the MCP isn't loaded. Pre-installed under
/opt/node22/lib/node_modules/playwright with bundled Chromium at /opt/pw-browsers/chromium-1194/chrome-linux/chrome. No MCP, no GUI — just a Node script that navigates and captures.
Both replace the old screencapture / AppleScript / sips pipeline. Never fall back to OS screen recording.
Pick a backend up front
if [ "${CLAUDE_CODE_REMOTE:-}" = "true" ]; then
echo "cloud session — use the Playwright fallback (Step 4b below)"
else
echo "local session — use Chrome DevTools MCP (Steps 2–7 below)"
fi
If you're not sure, check the available tools in your context. If mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_pages is not in your tool list, the MCP isn't available and you must use the Playwright fallback.
Prerequisites
- Breadbox running locally. Main repo:
http://localhost:8080. Worktrees get a port in 8081–8099 from .claude/hooks/session-start.sh, which exports both $PORT (consumed by the Makefile) and $SERVER_PORT (consumed by the binary) — either works. If neither is set, curl each port to find the live one.
- Backend (one of):
- Chrome DevTools MCP tools available under
mcp__plugin_chrome-devtools-mcp_chrome-devtools__* (preferred), or
- Node 18+ on
PATH and the global Playwright install at /opt/node22/lib/node_modules/playwright (cloud-session default).
Steps
1. Verify the app is up
curl -s -o /dev/null -w "%{http_code}" http://localhost:<PORT>/health/live
If not 200, start it: make dev.
2. Pick or open a Chrome page
list_pages — see what's already open.
select_page(pageId) — reuse a Breadbox tab if one exists.
new_page(url) — otherwise open one pointed at the target path.
Common paths (admin UI is served at the root, not under /admin/): /, /transactions, /connections, /reviews, /rules, /categories, /reports, /agents, /settings.
3. Handle login if redirected
If /login shows up, fill the form via evaluate_script and submit, then navigate_page back to the target:
() => {
document.querySelector('input[type="text"]').value = 'admin@example.com';
document.querySelector('input[type="password"]').value = 'password';
document.querySelector('form').submit();
}
Use wait_for({ text: [...] }) with a string you expect on the target page (e.g., "Dashboard", "Transactions") so the capture doesn't race the render.
4. Resize the viewport — pick the form factor
Always resize before capturing so the image renders at a predictable breakpoint. Use one of:
- Desktop (default):
resize_page(width: 1280, height: 800)
- Desktop wide:
resize_page(width: 1440, height: 900) — use when the change involves wide-screen layouts (multi-column dashboards, tables).
- Mobile:
resize_page(width: 390, height: 844) — iPhone 14-class viewport. Use for any responsive / mobile-first change, or when validating that a desktop change hasn't regressed mobile.
- Tablet:
resize_page(width: 768, height: 1024) — use when the change touches the md Tailwind breakpoint.
For before/after pairs, resize once and reuse the same dimensions for both captures.
5. Capture
take_screenshot(
filePath: "/tmp/app-<PAGE>.jpg",
format: "jpeg",
quality: 85,
fullPage: false,
)
fullPage: false (default) captures only the visible viewport — this is what you want in almost every case, since very tall full-page images are hard to review in a PR.
fullPage: true is for rare cases where the change is below the fold and scrolling wouldn't convey it (e.g., a new footer, a long settings form that's entirely below the fold). Prefer scrolling to the relevant section and capturing the viewport instead.
- Use a descriptive
<PAGE> slug (e.g., dashboard, transactions-before, transactions-after, dashboard-mobile).
- JPEG at quality 85 keeps most captures well under 1MB. Drop to 70 only if you hit the limit.
5b. Capture — headless Chromium fallback (cloud sessions)
When the Chrome DevTools MCP isn't available (typical for cloud sessions where CLAUDE_CODE_REMOTE=true), fall back to headless Chromium driven by the globally-installed Playwright. The bundled binary lives at /opt/pw-browsers/chromium-1194/chrome-linux/chrome; the Node module at /opt/node22/lib/node_modules/playwright. Both ship pre-installed on the cloud image — no npm install.
Drop this into /tmp/shot.js, then run it:
const { chromium } = require('/opt/node22/lib/node_modules/playwright');
const url = process.env.URL || (() => { console.error('URL required'); process.exit(1); })();
const out = process.env.OUT || '/tmp/app.jpg';
const width = parseInt(process.env.W || '1280', 10);
const height = parseInt(process.env.H || '800', 10);
const fullPage = process.env.FULL === 'true';
const waitFor = process.env.WAIT_FOR || '';
const user = process.env.BB_USER || '';
const pass = process.env.BB_PASS || '';
(async () => {
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({ viewport: { width, height } });
const page = await ctx.newPage();
const goto = async (target) => page.goto(target, { waitUntil: 'networkidle', timeout: 15_000 });
await goto(url);
if (page.url().includes('/login') && user && pass) {
await page.fill('input[name="username"]', user);
await page.fill('input[name="password"]', pass);
await Promise.all([
page.waitForLoadState('networkidle'),
page.click('form button[type="submit"]'),
]);
await goto(url);
}
if (waitFor) await page.waitForSelector(waitFor, { timeout: 10_000 });
await page.screenshot({ path: out, type: 'jpeg', quality: 85, fullPage });
await browser.close();
console.log('wrote', out);
})().catch((e) => { console.error(e); process.exit(1); });
URL=http://localhost:${SERVER_PORT:-8081}/transactions \
OUT=/tmp/app-transactions.jpg \
W=1280 H=800 \
WAIT_FOR='[data-page="transactions"]' \
node /tmp/shot.js
Conventions are identical to the MCP path: JPEG at 85% quality, viewport-only by default (FULL=false), descriptive <PAGE> slug in the filename. For before/after diffs, run the script twice with the same W/H but different OUT.
Constraints / gotchas:
- This backend can capture but cannot drive the page interactively across multiple turns — the browser exits when the script returns. For complex multi-step flows (open a modal, fill a form, navigate, then capture), inline the steps inside the
async block before the screenshot call. Prefer the Chrome DevTools MCP when you need to script a long sequence.
- No
chromium-browser / chromium symlink on PATH — always reference the Playwright module path so you get the version-pinned bundled binary.
- If the cloud image upgrades Playwright and the path under
/opt/pw-browsers/ changes, fall back to require('playwright') after cd /opt/node22/lib/node_modules — the package is installed globally either way.
6. Verify size (rare safeguard)
FILE_SIZE=$(stat -f%z /tmp/app-<PAGE>.jpg)
echo "$FILE_SIZE bytes"
If over 1MB: retake at quality: 70 or drop fullPage.
7. Upload
Preferred: self-hosted bb-artifacts.exe.xyz — auth with a configured
IMGHOST_UPLOAD_TOKEN (cloud/remote sessions export it) or, if unset, your GitHub
identity via gh auth token (local). Public read, ~180-day auto-expiry, no release
clutter. See the github-image-hosting skill for full details.
AUTH="${IMGHOST_UPLOAD_TOKEN:-$(gh auth token 2>/dev/null)}"
URL=$(curl -sf -H "Authorization: Bearer $AUTH" \
-F file=@/tmp/app-<PAGE>.jpg https://bb-artifacts.exe.xyz/upload | jq -r .url)
echo "$URL"
Fallback: img402.dev (only if bb-artifacts is unreachable). Tokenless and
ephemeral, 1MB cap — fine for these JPEGs. Requires img402.dev in the sandbox
allowlist — it is.
URL=$(curl -s -X POST https://img402.dev/api/free -F image=@/tmp/app-<PAGE>.jpg | jq -r .url)
echo "$URL"
8. Embed in the PR
Use inline HTML (not ) so you control the displayed width. GitHub Markdown accepts <img> tags — the full-res image stays one click away, and the rendered size stays readable.
Single screenshot:
<img src="https://bb-artifacts.exe.xyz/f/<id>.jpg" width="800" alt="<page> — after">
Before/after — side-by-side table (preferred for visual diffs):
<table>
<tr>
<th>Before</th>
<th>After</th>
</tr>
<tr>
<td><img src="https://bb-artifacts.exe.xyz/f/<id-before>.jpg" width="400" alt="before"></td>
<td><img src="https://bb-artifacts.exe.xyz/f/<id-after>.jpg" width="400" alt="after"></td>
</tr>
</table>
Mobile screenshot (narrow — embed smaller):
<img src="https://bb-artifacts.exe.xyz/f/<id>.jpg" width="320" alt="<page> — mobile">
Tall capture you still want to include (fullPage: true) — collapse it:
<details><summary><page> — full page</summary>
<img src="https://bb-artifacts.exe.xyz/f/<id>.jpg" width="800" alt="<page> — full page">
</details>
Post via gh:
gh pr comment <PR_NUMBER> --body-file evidence.md
gh pr comment <PR_NUMBER> --body "<img src=\"$URL\" width=\"800\" alt=\"<page>\">"
Tips
- Responsive changes always get at least two captures: desktop (1280 or 1440) + mobile (390). Tablet (768) only when the change crosses the
md breakpoint.
- For before/after diffs, name the files
<PAGE>-before and <PAGE>-after and embed them in the table above.
- After template / CSS / Alpine changes, restart
make dev before capturing so Tailwind / partials rebuild.
- bb-artifacts URLs are immutable per upload (random id) and auto-expire after ~180 days — long enough for PR review, short enough that stale screenshots clean themselves up.
- For quick visual checks (not PR evidence), skip the upload step — the local JPEG is enough.
- What does NOT work on GitHub Markdown:
{width=600} (ignored), style="..." attributes (stripped). Stick to <img width="...">.