Implement a Figma design (a Figma frame link or an exported PNG) in code as a Storybook story (or a Vitest browser-mode test) and converge the render to it using UI Verify as the source of truth. Decl
You are building a page/component in code to match a design image (a Figma export or mockup screenshot). UI Verify sets that image as the story's baseline, renders your story in its own browser, diffs it against the design, and hands you back the delta. You converge until the delta is gone, and stop there. You do NOT accept - accepting would rebase the baseline off the design (see "Do not accept" below).
This large 6,334-word instruction set is purpose-built for testing & qa work in AI coding agents. Paste directly into your CLAUDE.md or AI agent settings.
You are building a page/component in code to match a design image (a Figma export or mockup screenshot).
UI Verify sets that image as the story's baseline, renders your story in its own browser, diffs it against
the design, and hands you back the delta. You converge until the delta is gone, and stop there. You do
NOT accept - accepting would rebase the baseline off the design (see "Do not accept" below).
The flow: get the design PNG -> declare it as the story's baseline -> build the whole page once -> then
sweep top-to-bottom, locking one section at a time -> stop at the minimum. Every round renders only the
story you are building (uiverify check --target), never the whole Storybook.
Read "The section sweep" below before your second upload. It is the whole method. Four previous agents
failed this task, all in the same way: they treated convergence as tuning a number instead of locking
sections, and all four stalled between 2.5% and 5% and declared a floor that was not a floor.
These qualifications apply to the sweep and the historical examples below:
buildId that uiverify check prints
(Registered build <id>). A check build is addressable only by its id: commitSha and prNumber
resolve the branch's CI build, not your round.
metrics.changedPct is a fraction. Display 100 changedPct followed by %, alongside changed0.01581616 means 1.581616%. The denominator includes
ignored areas and empty space; this is not a visual-similarity score or a ranking between designs.
255 - gray applies to dark foreground on a whiteIf assets, reliable measurements, or the required native MCP remain unavailable, report incomplete with
the blocker and residuals. Do not spend uploads on an unavailable input or describe an incomplete run as
converged. Otherwise continue the sweep within the upload budget below.
You do not get to stop because the number "feels done". When the required inputs and tools are available:
sizeChanged is"1.09%, looks like AA, done" is not a stop condition - it is the exact sentence every failed run wrote while
fixable geometry (a default button border, a nav row 1px off in tracking, a gradient word) was still lit on
the page. If fixable errors remain and inputs/tools are available, keep going within the budget.
The evidence-pair gate. Before each upload after round 1, inspect and measure the current section
in the design baseline and the preceding UI Verify candidate. Use the matching render_diff_image views,
or download the original PNGs through get_diff and record their build/diff IDs, hashes, and crop window.
A URL request alone is not an image read. Reuse a baseline only after confirming its identity is unchanged.
The thresholded overlay alone is insufficient; original PNG pairs contain the same usable evidence
regardless of which native MCP retrieval method supplied them.
Log every round, and build the report ONLY from that log. Each round record: the section you were
locking, the diffResultId and pxPagination window you read, and the measured baseline-vs-candidate
numbers you acted on (headline: design 646 wide / render 631, -15 -> font-size 54->57). Your final report
must cite these per-round measurements. Never write a summary claim like "I read baseline and candidate at
each window" - a prior run wrote precisely that while its tool log shows one honest read in thirty rounds.
If a measurement is not in your round log, it did not happen and it does not go in the report.
The flatline trip. If the number moves less than ~0.05% across three consecutive uploads whilesizeChanged still toggles true, you are NOT near a floor - you are re-lighting the same glyph edges with
tracking nudges while an unmeasured box or section height sits untouched. Stop editing text and do a full
baseline/candidate sweep from the top to find the box you never measured.
This is not advice, it is the loop you run. It is how a person would do it with the design and the render
side by side: "Does the very top look right? No - fix, fix, fix, upload, look again. Yes? Good, I never
touch it again. Next block down. Does it look right? ..." all the way to the bottom.
baseline and candidate at exactly that window.LOCKED next to the block in your round log and move the cursor down one block. If no, go to 3.
NOT LOCKED with its table (the rows still non-zero) in the log and move the cursor down anyway. Do not
spend a 16th on it. You return to it only after the last block, and only with budget left under 50.
Rules of the cursor:
Did the two most recent runs do this? No. One swept top-down for rounds 1-12 with a loose lock (1-4px
counted as locked), then abandoned the order. The other never had a cursor at all: from round 2 it
alternated nav <-> footer <-> cards every round, declared "body locked" with "cards -3px, footer -3px,
negligible", and finished with its nav links 3px low and a footer text row 19px too wide.
You need the design as a 1x PNG at the page's real canvas size (a 1200px-wide frame exports as 1200px
wide, not 2400 or 1024). Get it in this order:
download_assets { fileKey, nodeId, defaultFormat: "png", defaultScale: 1 } and download the export
URL into the repo (e.g. .storybook/public/design/page.png). Take fileKey and nodeId from the URL
(node-id=12-34 -> 12:34); if the link has no node-id, ask for a link to the frame itself. If you
use get_screenshot instead, pass maxDimension at least the frame's longer edge (its default of 1024
silently downscales a page), and confirm the PNG's size equals the frame's original_width x
original_height.
If the Figma MCP is connected, get_design_context on the same node gives you the design's own values
(font family, sizes, weights, colours, spacing). Use them for the first build. They are a starting point:
UI Verify's diff against the PNG still decides what matches.
Once per project, before the first upload:
triage-visual-changes skill, "Connect the MCP"). If itsThen:
parameters: { uiVerify: { baselineImage: "<path-or-data-uri>" } }. The key is uiVerify
(camelCase). The value is either a bundle-relative path to a PNG that ships inside your built
Storybook (a staticDirs asset, e.g. "design/hero.png"), or a data:image/png;base64,... URI.
PNG only. No http(s)/file: URLs, no absolute paths, no .. - the image has to live inside the
artifact you upload.
@uiverify/vitest 1.3+, uploaded with uiverify 1.7+),import design from "./design/hero.png" then await takeSnapshot("", { baselineImage: design }) (pass
the import as-is, even where it resolves to an object). The SDK ships the image inside the archive. The snapshot is the whole page at the browser viewport width, so set the Vitest browser
viewport to the design's canvas width. Each round is
rm -rf uiverify-archive && vitest run <the design's test file>, then
uiverify check --static-dir ./uiverify-archive in place of the Storybook build + check below. Running
only that test file makes the archive the render set, so it needs no --target (clearing the archive
matters: the SDK never deletes old files, so a test you renamed or removed, or one an earlier full run
captured, would still be uploaded from the previous round).
data-uiverify-ignore attribute. UI Verify blanks that box on both the design and your render, so its
noise never counts. The whole bounding box is blanked, so keep critical foreground text out of it.
check that renders only the story you are building. Commit first souiverify check with --target set to the
design story's id. Each check is one upload in the budget below. This is a local loop; it needs no pull
request and no CI: git add -A && git commit -qm "attempt N: <block>: <change>"
npx storybook build -o storybook-static # or your repo's build script
UIVERIFY_API_KEY=uv_proj_... npx -y uiverify@latest check --static-dir ./storybook-static --target 'pages-profile--default'The story id is in storybook-static/index.json (a target that matches nothing fails the check and
lists the valid ids). For a design with several frames (desktop and mobile stories), pass --target
once per story, or one glob over just that component ('pages-profile--'). Never uiverify upload
the whole Storybook per round: it re-renders every other story in the project (every button variant,
every unrelated page) each time, so each round waits on renders that say nothing about the design. A
check still diffs against the declared design, posts no GitHub check, and never moves a CI baseline; the
full uiverify upload runs in CI on the pull request.
mcp__uiverify__ MCP tools. Never computepixelmatch/resemblejs/odiff/ImageMagick compare, no PIL subtraction).
storybook-static yourself, never open your page ingetBoundingClientRect, Range
metrics). The only view of your render that counts is the one UI Verify renders. A DOM box is not the
inked pixels: one run measured its own DOM, declared "every property matches within 1-2px", and accepted a
page whose buttons were 20px too wide and whose card row was 15px too tall.
baseline / candidate PNGs UI Verify returns (get_diff URLs). Widths, heights, row tops, colors.
That is measuring, not diffing, and it is the most effective thing you can do (see "Measure, do not
eyeball").
get_build returns the NUMBER, not the diff. You are FLYING BLIND until you view the pixels.After the first whole-page upload, stop thinking about the page and think about one section at a time,
from the top down.
Why top-down is not just tidy: in ordinary flow layout a height/gap fix in section
k moves sectionsThe loop:
render_diff_image which:"baseline" and which:"candidate"pxPagination offset/height, plus which:"diff" for the overlay. Never the overlay alone -
see "The overlay lies about low-contrast edges".
Budget: locking a 2000px page is ~7 sections, and ~2 uploads per section is the
floor of effort, not aThe strongest technique available to you: measure the design once to get the absolute y of every section
boundary (and the absolute x of every column), then measure the same anchors in your candidate and solve
for the property.
> design divider is at y=1810; my CTA bottom is at 1748 and my current margin is 29, so I compute 1777;
> to hit 1810 I need margin-top = 62.
That is a derivation, not a guess. Do this instead of nudging.
But it only works top-down and one section at a time. That arithmetic is a chain: if you also change
something
And match each section's OWN HEIGHT, not only where it lands. Absolute anchors alone can be satisfied
by cancelling errors: a card row 15px too tall plus a margin below it 8px too small, a preview band 11px too
short plus a gap 12px too big. Every later section lands at roughly the right y, the overlay looks calm, the
% stays low - and the structure is wrong. For every section check top, bottom AND height against the design.
sizeChanged: true means you are not done. If the candidate's height differs from the design's, some
section's height is wrong. Keep going - never treat it as done.
which:"diff" highlights per-pixel differences above a threshold (often ~0.06). A low-contrast element -
a near-white pill on white, a pale card fill, a light divider - can be displaced by tens of pixels and still
not trip the threshold, while the dark text inside it lights up green.
The overlay then tells you the exact opposite of the truth:
"the box is fine, only its text moved." In oneTherefore: never judge a section from the overlay alone. Always also read baseline and candidate at
the same window. A box that looks clean in the overlay can be the most wrong element on the page.
Eyeballing produces "the buttons look a bit off", which leads to nudging. Measuring produces "+3px tall,
+9px wide", which leads to a padding fix. Pull spans out of the images with a script:
font-size / letter-spacingpadding / line-height / font-sizeline-height error. These look identical in
an overlay and are told apart only by the numbers.Work out which
dimension is wrong before changing anything:font-size first, then letter-spacing. font-weight is a last guess, not a first - but it is NOT
"never": if the bundle ships that weight AND an ink-density measurement (see "Measure ink, not boxes")
shows the glyphs carry more/less ink than the baseline at the same width, weight IS the fix (a header
wordmark that was horizontally dead-on was still +9% ink - 700 vs the design's 600). Asking for a
weight the bundle does not ship changes nothing and proves nothing, so confirm the woff2 is loaded first.
padding or an internal gap.padding / explicit width / line-height.line-height.includeAA:false and threshold
0.063: pixels it classifies as anti-aliasing are already discarded before the count. So a lit edge has
survived the AA filter, and you may call it AA only after the element's row reads 0 (see "The element
table").If a fix does not work, change DIMENSION, not magnitude. Both failed runs got stuck turning the same
knob harder. One saw the two hero buttons differ, decided the
padding bug it never tested, because it never left the "gap" hypothesis.Bounding boxes round to whole pixels, so they go blind exactly when you get close. A header run stalled at
0.90% because every element's bbox was "within 1px" - it called that the AA floor. It was not. Whole-pixel
bboxes cannot see the two things that dominate the last mile, and a switch of
sum(x ink) / sum(ink) where
ink = 255 - gray only for dark foreground on white, with background-aware segmentation elsewhere.
Compare baseline and candidate; it can resolve shifts below 1px. Test a fractional
transform, e.g. translateX(-0.15px) on the group. Horizontal sub-pixel nudges work.
font-weight 700 vs the design's 600, +9% ink; weight 600 fixed
it. Centroid+ink also exposed a logo mark that was un-rotated by ~5deg (same total ink, shifted centroid +
wrong outline). None of this is visible to a whole-pixel bbox or to the eye.The rule: when every bbox reads "within 1px" but the window still lights up, your ruler is too coarse -
switch to centroid+ink (with correct foreground segmentation) before you conclude anything. The
gain comes from a sharper
measurement, not from turning a knob harder (which is the number-chasing trap).
Validate sub-pixel experiments. Compare the UI Verify candidate images or their pixel-content hashes;
an unchanged changed-pixel count does not mean the images are identical. Establish whether a fractional
CSS adjustment affects this element in this capture environment rather than assuming universal snapping.
Neither an unsuccessful adjustment nor matched centroid/ink proves cross-engine AA. Keep an unexplained
residual unresolved, and use the final visual sweep to catch shape, wrapping, border, and spacing errors.
Cap pxPagination.height at ~400. A page-tall slice averages everything into a grey wash where real
geometry is indistinguishable from anti-aliasing. One run paged at height: 1019, looked at the result, and
wrote "the page is now dominated by thin AA fringe" - at that zoom, a 13px footer displacement and a 35px
pill error both look like fringe. It then accepted.
For the block under the cursor, produce this table every time you read the pair. It is the only form of
"looks fine" you are allowed:
| element | design x0-x1 / y0-y1 | render x0-x1 / y0-y1 | dW | dH | dx | dy | colour D / R | centroid / ink | fix |
|---|---|---|---|---|---|---|---|---|---|
One row per text run, box, pill, icon, divider, image edge. Measure with PIL on the two PNGs UI Verify
returned (bbox by a threshold that suits the background - dark pages need >40, not <170; centroid and
ink for rows already at 0). The table goes in the round log verbatim, and the report is built from these
tables and nothing else.
What the table stops you from doing: writing "footer within 1px" next to a row that says +17. One run's
final explanation attributed a footer tagline to "cross-engine AA"; the row reads dW=+17 (font-size).
Another explained a row of small footer links as "small low-contrast text trips the threshold"; the row
reads dW=+19, dx=-10, colour 138 vs 61. Neither builder had a table, so the sentence won.
You may use the words "cross-engine AA", "baseline snapping", "rasterizer", "threshold noise" or "floor"
for an element only when its row is 0/0/0/0, its colour matches, its centroid is within 0.3px and its ink
within 3% - and you cite that row. Anything else is a fix you have not made yet. Two corollaries:
A section is done when, reading baseline and candidate at that window, no element appears as a
displaced second copy, and no box differs in measured width or height. "It looks like AA now" is not a
stop test - it is the sentence every failed run wrote just before quitting.
Bbox-clean is necessary but NOT sufficient. Whole-pixel bounding boxes are blind to sub-pixel position and
to ink density (weight/size). If every box reads "within 1px" but visual discrepancies remain, investigate
further with a valid centroid+ink pass ("Measure
ink, not boxes") on each element before you declare a floor. A header that stopped at 0.90% this way had a
+9% wordmark weight, a 5deg-un-rotated logo, and two sub-pixel offsets still on it.
The page is converged only when every section passes the stop test, sizeChanged is false, required
assets are present, ignored content has the agreed scope, and the final original-image sweep has no
unresolved visual defects. Record any supported rendering tolerance and its evidence. There is no fixed
AA percentage that proves this. Stop and report the final changed-% without accepting. Otherwise continue
within the budget, or report incomplete with the remaining discrepancies and any missing dependencies.
Never call accept_build (or review_diff with an accept) on this task. UI Verify diffs against the
design only while the design is the baseline. Accepting replaces the baseline with your render (accepting
a check build does it too, for every later check on the branch), which (a)
hides every remaining design delta forever and (b) makes every later upload diff against your render, not the
design. A prior run accepted at ~2.8%, then a fresh session re-uploaded against that accepted render, saw
~13% "drift", and spent 20 rounds polishing a phantom against its own snapshot instead of the design. Leave
the build changed at its floor - that is the finished state, and it keeps the project reusable without
recreating it.
Confirm you are still diffing the DESIGN, every round. get_diff must report hadBaseline: true, andrender_diff_image which:"baseline" must still show the design, not an earlier render of yours. If either
fails, STOP and tell the user - the branch was accepted or the baseline failed to resolve, and the number
is now measuring your render against itself.
Use the native mcp__uiverify__ tools - they return inline images you read directly. If they
are not available in your session, stop and tell the user. Do not fall back to curl against the MCP
endpoint: the MCP connects at session start, and the fix is connecting it and starting a fresh session.
(Downloading the PNG URLs that get_diff returns is fine - that is how you measure.)
get_build { buildId } - the build's changed stories, each with a diffResultId and changedPct.check printed (see "Evidence
integrity").
render_diff_image { diffResultId, which: "baseline" | "candidate", pxPagination: {offset, height} } -render_diff_image { diffResultId, which: "diff", pxPagination: { offset, height } } - the overlay forrender_diff_image { diffResultId, which: "before_after" } - baseline and candidate side by side,get_diff { buildId } - presigned URLs for the baseline/candidate/diff PNGs. Download these when youaccept_build / review_diff - do NOT use these on this task. Accepting rebases the baseline offdiff overlay for inspecting original baseline/candidate pixels.sizeChanged looks false while the structureuiverify upload. One run re-rendered 16 stories percheck --target the
design's story.
font-weight and quitting at a plateau.Implement Figma Design is a free testing & qa skill for AI coding agents. Implement a Figma design (a Figma frame link or an exported PNG) in code as a Storybook story (or a Vitest browser-mode test) and converge the render to it using UI Verify as the source of truth. Decl. It provides a specialized system prompt that configures your agent with testing & qa expertise.
Copy the system prompt below and paste it into your CLAUDE.md file or Claude Code's custom instructions to activate Implement Figma Design.
Implement Figma Design is compatible with Claude Code, Cursor, GitHub Copilot, Windsurf, OpenClaw, Cline, and any AI agent that supports custom system prompts or .cursorrules files.
Yes, Implement Figma Design is completely free and open source. The full source is available on GitHub at https://github.com/uiverify/uiverify/blob/main/packages/skills/skills/implement-figma-design/SKILL.md. You only need a subscription to the AI agent you use it with.
Weekly roundup of top Claude Code skills, MCP servers, and AI coding tips.