Design image → pixel-accurate build
The core idea
A reference screenshot is data, not inspiration. Eyeballing produces an endless "a bit bigger… now too big" loop with no finish line. Measuring converts the job into a question that terminates: is the delta under 5px?
So the loop is always:
measure the reference → build to those numbers → measure your own build → diff → fix
Two habits make this work:
- Numbers before pixels. Derive a spec first. Don't start writing CSS from vibes.
- Verify your own output the same way. Read your build's DOM with
getBoundingClientRect()and print aREF | MINE | DELTAtable. Your opinion of whether it matches is not evidence; the table is.
scripts/refmeasure.py does the reference-side measuring and scripts/measure_build.js
does the build-side. Use them rather than rewriting this analysis each time.
Phase 1 — Establish the coordinate system
Nothing else is meaningful until you know what scale the reference is at.
Use the original image. If someone sends an AI-upscaled or "enhanced" copy, measure the original instead. Upscales reframe the content and invent detail — trusting one can put every vertical position out by tens of pixels while looking sharper and more convincing.
Find the mockup's real edges. Promo images often paste a desktop screenshot beside a narrow mobile column, or add padding. Measure the desktop region only.
Derive the container and the capture viewport. Take the nav row and find the leftmost
and rightmost non-background pixel. Equal left/right margins means a centred container, and
container ÷ page_width gives the ratio. A ratio near 0.77 with a common container
(1140/1170/1230/1320) tells you the viewport it was captured at — e.g. a 1230 container at
76.9% implies a 1600px viewport.
Then every measurement scales by target_viewport ÷ mockup_page_width.
python scripts/refmeasure.py scale ref.jpg --page-width 380 --target 1600
python scripts/refmeasure.py extents ref.jpg --y0 22 --y1 43 # nav row → container
Build at that viewport. Matching a 1600px design while screenshotting at 1440 produces deltas everywhere that aren't real.
Phase 2 — Extract the spec
Run these against the reference and write the results into a spec table before touching CSS.
| What | How | Command |
|---|---|---|
| Section bands (heights, boundaries) | Scan one column, classify each pixel, print transitions | bands |
| Element left/right/width | For a row range, find first/last non-background pixel | extents |
| Text line positions & line-height | Count ink pixels per row; groups are lines | textrows |
| Colours | Median of a flat sample region | colors |
| Corner radius / square corners | ASCII pixel map of each corner | corners |
python scripts/refmeasure.py bands ref.jpg --x 60
python scripts/refmeasure.py textrows ref.jpg --x0 190 --x1 340 --y0 290 --y1 460
python scripts/refmeasure.py colors ref.jpg --box 200,60,330,75
python scripts/refmeasure.py corners ref.jpg --box 115,364,181,443
Font size from text width, not cap height
Cap-height estimates are unreliable at low resolution and vary between fonts. Text width is unambiguous. Measure the reference string's width, render the same string in your build at any size, then scale:
correct_size = your_size × ref_width ÷ your_width
This self-corrects for the font not being an exact match, because it targets the thing a viewer actually perceives — how much horizontal space the line occupies.
If a heading needs to look heavier but the width already matches, raise the weight and drop the size a little so the width holds (e.g. 48px/700 → 46px/800). Going heavier alone overshoots the width.
Colours: median, never mode or mean
JPEG artifacts skew the mode; antialiasing at edges skews the mean. Take the median of a flat region away from text and boundaries. If you need to know a text colour, sample the darkest pixels in the glyph area — small text never reaches its true colour because of antialiasing, so treat the reading as an upper bound on lightness rather than the value.
Corner radii: print the pixels
A filled corner is square; a staircase is rounded. The pixel map settles arguments instantly and is worth pasting into your reply when a corner is disputed:
TOP-LEFT BOTTOM-LEFT
.########### ..##########
.########### ...######### ← staircase = rounded
.########### ....########
↑ filled = SQUARE
Radius ≈ how far down the edge travels before reaching full width.
Phase 3 — Build one section at a time
Build a section, verify it, then move to the next. Building everything and comparing at the end buries small errors and makes them expensive to unpick. It also gives the person you're working with natural checkpoints to redirect you.
Keep the measured numbers in comments next to the values they justify. Six months later
/* ref: line 1 = 598px wide @1600 */ explains a 61px font-size that otherwise looks
arbitrary.
Phase 4 — Verify by measuring your build
Screenshot pixel-reading is noisy; the DOM is exact. Load your page, run
scripts/measure_build.js, and print the comparison table.
ITEM REF MINE DELTA
topbar h 58.9 58 -0.9
hero h 783 784 +1.0
h1 line1 w 597.9 595.8 -2.1
card top 808.4 806 -2.4
Reading the deltas:
- Within ~5px on real dimensions → done, move on.
- A large delta on a top edge with a tiny delta on the bottom edge is usually not a bug: a CSS box top sits above the glyph top by half-leading plus the ascent gap. Compare bottoms and widths.
- x-positions off by a constant ~7–8px across everything → a scrollbar. Screenshot with
--hide-scrollbarsor account for it; don't "fix" the CSS.
Phase 5 — Show a side-by-side
Numbers miss things nobody thought to measure. Scale the reference crop to your build's width, stack them with labels, and look:
python scripts/compare_sheet.py ref.jpg build.png out.png --ref-crop 0,7,380,260
This is how a corner motif, an off-centre row, or a wrong crop gets caught.
Rendering screenshots
Use real headless Chrome. Editor preview panes often don't composite, so
requestAnimationFrame never fires and anything scroll- or animation-driven looks broken
when it is fine.
chrome --headless=new --disable-gpu --hide-scrollbars \
--window-size=1600,1100 --screenshot=out.png --virtual-time-budget=15000 URL
--virtual-time-budget matters — without it you screenshot before fonts and images land.
Traps that cost real time
Headless Chrome enforces a minimum window width (~500px on Windows.) Ask for 414 and it
lays out at ~500, then crops to 414 — so a perfectly good responsive page looks badly
broken. Verify narrow layouts by measuring the DOM (scrollWidth vs clientWidth), not by
screenshot.
Photo content defeats background detection. A white wall or a bright window inside a photo reads as page background, so an automatic edge-finder reports a square corner that isn't there. When a measurement looks strange, zoom in and look before acting on it.
A composite promo image may not be internally consistent. If the nav is centred but a row below is offset, that may be a stitching artifact — or a real design choice. Say which you think it is and why, and let the person decide rather than silently "correcting" it.
Verify critique before applying it. Feedback about geometry — yours or someone else's — is a hypothesis. Check it against the pixels first. Applying a confident but wrong correction moves you away from the reference while feeling like progress. When your measurement contradicts the instruction, say so, show the evidence, and ask; if the person reaffirms, do it their way and note the discrepancy.
Content, not just layout
A reference demo ships with placeholder copy and stock photos. Matching the layout is the job; shipping the placeholder text usually isn't. Swap in real content, and be careful with anything that reads as a factual claim — invented statistics ("1900 students", "40 years of service") on a real organisation's site are a liability. Use only figures you can source, or leave them clearly marked for the owner to fill in, and say which you did.
Also check supplied photography before it goes live: camera watermarks burned into a corner, duplicates of the same shot under different filenames, and multi-megabyte originals that need resizing are all common and all worth flagging.
Files
scripts/refmeasure.py— reference-side measuring (scale,bands,extents,textrows,colors,corners). Run with--helpfor options.scripts/measure_build.js— paste into the browser to measure your build and print theREF | MINE | DELTAtable.scripts/compare_sheet.py— stacked reference-vs-build image.references/worked-example.md— a full walkthrough with real numbers, useful when you want to see how the phases connect on an actual page.