@artcom/figma-visual-parity
v0.2.0
Published
Measure whether a rendered implementation matches its design reference, and diagnose why it does not.
Readme
@artcom/figma-visual-parity
Measure whether a rendered implementation matches its design reference — and when it does not, say why.
Built for the design-to-code loop: verify is a programmatic verdict you can gate on,
explain runs an ordered diagnosis so the first thing you read is the thing worth
fixing. A bare "3 % of pixels differ" tells you nothing about what to change.
npm i -D @artcom/figma-visual-parity
npx playwright install chromium # or point FIGMA_VISUAL_PARITY_CHROMIUM at a binaryCommands
figma-visual-parity doctor # preflight: browser, dev server, reference sizes, hide list
figma-visual-parity capture [scenario...] # export reference PNGs from Figma (needs FIGMA_TOKEN)
figma-visual-parity verify [scenario...] # verdict per scenario; --json; exit 1 on mismatch
figma-visual-parity explain <scenario> # ranked diagnosisBoth verify and explain accept --save-captures, which writes two PNGs per
scenario into config.outDir (default figma-visual-parity-output/): the raw capture
(<scenario>.capture.png) and a "difference"-blend of capture against reference
(<scenario>.diff.png) — matching pixels go black, mismatches light up by how far
apart the channels are. Off by default since these are debugging artifacts, not
something you want written on every gate run.
Config
figma-visual-parity.config.js in the project root:
export default {
baseUrl: "http://localhost:5173",
viewport: { width: 1080, height: 1920 },
referenceDir: "public/design-overlays",
figma: { fileKey: "…" },
hide: ["[class*='_switcher_']", "body>div:not(#root)"],
overlay: { storageKey: "my-app-overlay" },
mqtt: { brokerUrl: "mqtt://localhost:1883", baseTopic: "app/main" },
scenarios: [
{
name: "ready",
node: "4814:189355",
prepare: [{ click: 'button:text-is("ready")' }],
},
{ name: "detail", node: "4814:188845", path: "/?step=detail" },
{
name: "item-added",
node: "4814:190002",
prepare: [
{ mqtt: { topic: "api/doAddItem", payload: { id: "vase-01" } } },
{ waitFor: "[data-testid='item-vase-01']" },
{ key: "Escape" },
],
},
],
}prepare steps are declarative (click, waitFor, waitMs, key, type, mqtt)
rather than callbacks on purpose: Playwright cannot serialise a closure into the page,
and a stray free variable fails inside the browser where it is easy to miss. The
library owns page setup so you never write that code.
keypresses a single Playwright key or combo ("Enter","ArrowDown","Control+A") — useful for use cases driven by hardware buttons or shortcuts rather than clicks.typesends literal text as individual keystrokes.mqttpublishes{ topic, payload, retain?, qos? }throughconfig.mqtt, for apps whose state is driven over MQTT rather than the DOM.topicis resolved againstmqtt.baseTopicunless it starts with/. The broker connects once per run and is shared across scenarios.
referenceDir defaults to the folder
@artcom/react-pixel-overlay already
scans, so the same exports serve the human overlay and this tool.
Dev-mode UI
A capture against a dev server must never include the dev server's own chrome, or
every downstream number is measuring a difference that isn't a design defect.
vite-error-overlay is hidden unconditionally, and its presence is still reported as
an environment finding (with the error text) even though it is hidden, since a visible
overlay means the app actually errored. Project-specific dev-only UI — a scenario
switcher, a debug panel — still needs its own selector in hide; figma-visual-parity doctor
reports which selectors are active so you can tell defaults from what you added.
What explain checks, in order
- environment — dev-only UI still visible, a Vite error overlay, page errors. Nothing else is trustworthy until this is clean.
- font-metrics — an offset that grows with font size. That signature means the app is not loading the files the design composes text from (typically a variable font against static per-weight faces), not that a margin is wrong.
- uniform-offset — one global shift. Demoted to a note when the font probe already explains it.
- hotspots — blocks where a large share of pixels differ by a moderate amount or
more, attributed to the DOM element under them, so you get
div.circle.activerather thanblock (60,180). This catches both a sharp, high-contrast mismatch (wrong text) and a large but lower-contrast one (a whole icon that's missing or extra) — coverage of the block is what's ranked, not the single darkest pixel in it. - baseline-rounding — the residual floor, labelled as expected rather than as a defect.
Verdict
verify fails on any of: mean channel delta over tolerance, share of strongly differing
pixels over tolerance, a non-zero global offset, or a single block over
tolerance.worstBlockPct. That last one matters — a small control rendered in the wrong
state barely moves a frame-wide mean, so a mean-only gate misses it.
Defaults are calibrated against a matching implementation, where per-scenario means land around 0.4–1.1 of 255.
Notes
- Offset scans are integer-only. Sub-pixel transforms do not move text: browsers snap glyph baselines to whole pixels.
- References must match the viewport exactly. A mismatch is a hard error, because a wrongly scaled reference produces plausible-looking numbers that mean nothing.
- The comparison runs inside the page using canvas, so there is no image library in the Node dependency tree.
