Skip to content

Instantly share code, notes, and snippets.

@shaal
Created August 11, 2026 00:38
Show Gist options
  • Select an option

  • Save shaal/f92b236cf6fe3b99f051fb3c13e81c5f to your computer and use it in GitHub Desktop.

Select an option

Save shaal/f92b236cf6fe3b99f051fb3c13e81c5f to your computer and use it in GitHub Desktop.
before-after-compare — Claude Code skill: generate a self-contained before/after visual comparison HTML page from agent-browser screenshots
#!/usr/bin/env node
/*
* Build a self-contained before/after comparison HTML page from a manifest.
*
* node build-comparison.mjs <manifest.json> <out.html>
*
* Images are inlined as base64 data URIs, so the output is ONE portable file
* you can move, attach, or open from anywhere — no sibling PNGs required.
*
* Manifest schema (JSON):
* {
* "title": "HCP R2 — before & after", // page H1 (optional)
* "subtitle": "Before = staging · After = local", // sub-line (optional, HTML ok)
* "sections": [
* {
* "id": "SERB-343", // badge (optional)
* "title": "Clinical Data — font mismatch",// section title
* "desc": "Switched to <b>Gotham Medium</b>.", // one-liner, HTML allowed
* "before": "shots/B1.png", // path (rel to manifest), data: or http(s) URL
* "after": "shots/A1.png",
* "beforeLabel": "staging — Arial Bold", // small caption (optional)
* "afterLabel": "Gotham Medium" // small caption (optional)
* }
* ]
* }
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { extname, resolve, dirname } from 'node:path';
const [, , manifestPath, outPath] = process.argv;
if (!manifestPath || !outPath) {
console.error('Usage: node build-comparison.mjs <manifest.json> <out.html>');
process.exit(1);
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const baseDir = dirname(resolve(manifestPath));
const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml' };
function dataUri(p) {
if (!p) return '';
if (/^(data:|https?:)/.test(p)) return p; // already a URI — leave it
const abs = resolve(baseDir, p);
const buf = readFileSync(abs);
const mime = MIME[extname(abs).toLowerCase()] || 'application/octet-stream';
return `data:${mime};base64,${buf.toString('base64')}`;
}
const esc = (s = '') => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const sections = (manifest.sections || []).map((s) => `
<section class="cmp">
<div class="head">
<div>${s.id ? `<span class="id">${esc(s.id)}</span>` : ''}<span class="title">${esc(s.title || '')}</span></div>
${s.desc ? `<p class="desc">${s.desc}</p>` : '' /* desc is intentionally NOT escaped — author may use <b>, <code>, etc. */}
</div>
<div class="pair">
<div class="col before"><h3>Before${s.beforeLabel ? ` <span class="tag">${esc(s.beforeLabel)}</span>` : ''}</h3><img loading="lazy" src="${dataUri(s.before)}" alt="before"></div>
<div class="col after"><h3>After${s.afterLabel ? ` <span class="tag">${esc(s.afterLabel)}</span>` : ''}</h3><img loading="lazy" src="${dataUri(s.after)}" alt="after"></div>
</div>
</section>`).join('\n');
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${esc(manifest.title || 'Before / after comparison')}</title>
<style>
:root { --navy:#12365E; --ink:#1c2733; --line:#e2e6ea; --bg:#f6f8fa; }
* { box-sizing:border-box; }
body { margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; color:var(--ink); background:var(--bg); }
header { background:var(--navy); color:#fff; padding:28px 32px; }
header h1 { margin:0 0 4px; font-size:22px; }
header p { margin:0; opacity:.85; font-size:14px; }
main { max-width:1500px; margin:0 auto; padding:24px 24px 64px; }
.cmp { background:#fff; border:1px solid var(--line); border-radius:10px; margin:22px 0; overflow:hidden; box-shadow:0 1px 2px rgba(0,0,0,.04); }
.cmp > .head { padding:16px 20px; border-bottom:1px solid var(--line); }
.cmp .id { display:inline-block; font-weight:700; color:#fff; background:var(--navy); border-radius:5px; padding:2px 9px; font-size:13px; letter-spacing:.02em; margin-right:10px; }
.cmp .title { font-weight:600; font-size:15px; }
.cmp .desc { margin:8px 0 0; font-size:14px; line-height:1.5; color:#42505c; }
.cmp .desc b { color:var(--navy); }
.cmp code { background:#eef1f4; border-radius:4px; padding:1px 5px; font-size:.92em; }
.pair { display:grid; grid-template-columns:1fr 1fr; gap:0; }
.col { padding:14px 16px 18px; }
.col + .col { border-left:1px solid var(--line); }
.col h3 { margin:0 0 10px; font-size:12px; text-transform:uppercase; letter-spacing:.06em; }
.col.before h3 { color:#b22; }
.col.after h3 { color:#1a8a3c; }
.col h3 .tag { font-weight:400; text-transform:none; letter-spacing:0; color:#8794a0; }
img { width:100%; height:auto; display:block; border:1px solid var(--line); border-radius:6px; background:#fff; }
@media (max-width:860px){ .pair{grid-template-columns:1fr} .col+.col{border-left:0;border-top:1px solid var(--line)} }
</style>
</head>
<body>
<header>
<h1>${esc(manifest.title || 'Before / after comparison')}</h1>
${manifest.subtitle ? `<p>${manifest.subtitle}</p>` : ''}
</header>
<main>
${sections}
</main>
</body>
</html>
`;
writeFileSync(outPath, html);
console.log(`Wrote ${outPath}${(manifest.sections || []).length} section(s), ${(html.length / 1024).toFixed(0)} KB`);
name before-after-compare
description Generate a self-contained before/after visual comparison page for UI changes. Captures "before" (deployed/staging or a baseline build) and "after" (the local build) screenshots of each changed region with agent-browser, then assembles a side-by-side HTML report with a one-line explanation per change. Use when the user asks for a before/after comparison page, a visual change report, a "show me before and after" page, screenshots of changes side by side, or a QA/review page that ties UI fixes (e.g. Jira tickets) to before/after evidence.
allowed-tools Bash, Read, Write, Edit, Skill

before-after-compare

Build a single, portable HTML page that puts before and after screenshots side by side — one row per change, each with a one-line explanation. Great for PR evidence, client/QA review, and closing visual tickets.

The output is one self-contained .html file (images inlined as base64), so it can be moved, attached, or opened from anywhere.

When to use

  • "Make me a before/after page for these changes."
  • "Show the fix before and after, with screenshots from staging."
  • "I want a visual review page for these tickets."

If the user instead wants a pixel diff against a Figma design, use visual-parity. If they want to know which CSS property is wrong vs Figma, use figma-style-match. This skill is for before vs after of a real change (deployed/baseline vs local).

The workflow

  1. Identify the changes and a one-line explanation for each (often one per ticket).
  2. Pick the two sources:
    • After = the local build the user is iterating on (e.g. npm run build + a local preview server, or the dev server). Confirm it's serving the new code.
    • Before = whatever shows the old state: the deployed/staging URL (the user's changes aren't live there yet), or — if there's no deployment — git stash / a previous commit built into a second dir. Staging is usually behind HTTP Basic Auth (see below).
  3. Capture matched screenshots of each region, BEFORE and AFTER, using the identical framing recipe (below). Save to a working dir (e.g. screenshots/<id>/). Prefer a folder your repo already gitignores so the shots don't get committed.
  4. Verify each shot by Reading it — confirm the before actually shows the bug and the after shows it fixed. Re-frame if a sticky header covered the target. Then md5sum the pairs: identical hashes mean you captured the same page twice, which looks convincing and proves nothing. See Don't ship a fake comparison below.
  5. Write a manifest JSON and run the generator to emit the HTML.
  6. Preview the page (open it in agent-browser, screenshot --full, Read it) and deliver it to the user (SendUserFile the .html, plus the preview PNG so it's viewable inline).

Capturing with agent-browser

Load the browser skill first: Skill(agent-browser), then agent-browser skills get core. Key commands: open, set viewport, eval --stdin, screenshot. Hard-won recipe:

  • Match the viewport for before & after. Default to 1440px wide for desktop designs: agent-browser set viewport 1440 1000 2 (the trailing 2 = retina, crisper shots). Use the SAME width/zoom/scroll for both sources or the comparison is unfair.
  • Set the viewport BEFORE scrolling — changing the viewport resets scroll position.
  • Sticky/fixed headers overlay your target. Before scrolling to a region, hide them:
    // agent-browser eval --stdin
    [...document.querySelectorAll('body *')].forEach(el=>{
      const p=getComputedStyle(el).position;
      if(p==='fixed'||p==='sticky') el.style.setProperty('display','none','important');
    });
    document.body.style.paddingTop='0';
    then scroll the element to the top: el.scrollIntoView() or window.scrollTo(0, el.getBoundingClientRect().top + window.scrollY - 8).
  • Frame a region by cropping, not by shrinking the viewport. Capture at a realistic viewport (430×760 for a phone, 1440×1000 for desktop), then crop afterwards with PIL using getBoundingClientRect(). A short viewport makes sticky/overlay UI — cookie banners, chat widgets, pharma ISI drawers — expand to fill the screen and bury the target. Worse, that overlay usually renders identically on both sources, so before & after come out byte-identical and the bug silently never appears.
  • Zoom in for fine detail (small type, a registered mark, a footnote): set document.body.style.zoom='2', but avoid an over-narrow viewport — it re-wraps text and ruins the crop. A 1100–1200px wide viewport at zoom 2 is a safe detail crop.
  • Use ABSOLUTE paths for screenshot — agent-browser may resolve relative paths from a different cwd: agent-browser screenshot /abs/path/B1.png.
  • Basic-auth staging (HTTP Basic Auth at the edge): pass an Authorization header, scoped to the origin, on open:
    AUTH=$(printf 'USER:PASS' | base64)
    agent-browser --headers "{\"Authorization\":\"Basic $AUTH\"}" open "https://staging.example.com/page.html"

Don't ship a fake comparison

The failure mode of this skill is a page where before and after are the same screenshot. It looks completely convincing and proves nothing. Two silent causes, both seen in the wild:

  • open failed and left the previous page loaded. A transient auth/network blip makes open a no-op, so "before" captures whatever was already there — often the local, fixed build. The command still prints a success line.
  • An overlay buried the target on both sources (see the cropping bullet above).

Guard with two cheap checks — do not skip them just because the images look right:

# 1. Assert page identity + old-vs-new code AT CAPTURE TIME, not just that `open` returned.
agent-browser eval '(() => {
  const el = document.querySelector(SEL);
  return location.hostname + " | " + getComputedStyle(el).maxWidth;  // pick a prop the fix changes
})()'
# BEFORE must report the staging host AND the OLD value; AFTER the local host AND the NEW value.

# 2. Checksum the pairs — identical hashes ⇒ you captured the same page twice.
md5sum before-*.png after-*.png

If a pair matches, stop and re-capture. Reading the images is necessary but not sufficient: two shots of the same page look fine individually.

Optional: prove the change objectively (not just visually)

A screenshot shows a difference; a computed-style/geometry probe proves it's the difference. Run these with agent-browser eval --stdin and quote the numbers in the manifest desc:

  • Font fallback — does an element actually render in the intended webfont, or fall back? Measure the element's text width in its own stack vs a pure-fallback span of the same size/weight; equal widths ⇒ it's using the fallback:
    const el=document.querySelector(SEL), cs=getComputedStyle(el);
    const w=(ff)=>{const s=document.createElement('span');s.style.cssText=`position:absolute;visibility:hidden;white-space:nowrap;font:${cs.fontWeight} ${cs.fontSize} ${ff}`;s.textContent=el.textContent.trim();document.body.appendChild(s);const x=s.getBoundingClientRect().width;s.remove();return x;};
    Math.abs(w(cs.fontFamily)-w('Arial'))<0.5  // true ⇒ rendering as Arial fallback
  • Line breaks — does a token (e.g. "5-FU") wrap across lines? Range it and count rects: const r=document.createRange();r.setStart(node,i);r.setEnd(node,i+len);r.getClientRects().length>1.
  • Spacing/padding — read getBoundingClientRect() gaps between elements and compare to the Figma spec.

Generating the page

Write a manifest next to your screenshots, then run the bundled generator. Image paths in the manifest are resolved relative to the manifest file; data:/http(s) are passed through. desc accepts inline HTML (<b>, <code>).

{
  "title": "HCP R2 — before & after",
  "subtitle": "Before = current staging · After = local prod build. Captured at 1440px.",
  "sections": [
    {
      "id": "SERB-343",
      "title": "Clinical Data — subheading font mismatch",
      "desc": "Heading fell back to heavy <b>Arial Bold</b> (no <code>@font-face</code> for Gotham-Bold); switched to <b>Gotham Medium 18px</b> per Figma.",
      "before": "B1-clinical-BEFORE.png",
      "after":  "A1-clinical-AFTER.png",
      "beforeLabel": "staging — Arial Bold",
      "afterLabel":  "Gotham Medium"
    }
  ]
}
node ~/.claude/skills/before-after-compare/build-comparison.mjs <manifest.json> <out.html>

(~/.claude/skills/... is the user-level install path; adjust if the skill lives elsewhere.)

The generator inlines every image as base64, so <out.html> is a single portable file.

Deliver

  • Open it to sanity-check: agent-browser open "file://<abs out.html>", then agent-browser screenshot --full <preview.png> and Read it.
  • SendUserFile the .html (the page they asked for) and the full-page preview PNG (so it renders inline even where the HTML can't open).

Notes

  • One section per distinct change keeps the page scannable; if several fixes live in one screenshot, you can reuse the same before/after pair across sections with different desc lines.
  • Keep desc to one line. The screenshots carry the weight; the line says what changed and why.
  • Put screenshots in a gitignored dir so a review artifact never lands in a commit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment