Command Palette

Search for a command to run...

UnylyUnyly
Browse all

Specgate

FreeNot checked

specgate is an MCP server that verifies UI design fidelity by comparing a live DOM against Figma specs, reporting specific CSS property deltas so agents know ex

GitHubEmbed

About

specgate is an MCP server that verifies UI design fidelity by comparing a live DOM against Figma specs, reporting specific CSS property deltas so agents know exactly what to fix. It also freezes measurement results into lock files for fast offline regression detection.

README

specgate

Design-fidelity verification that tells an agent what to fix — not that something looks off.

Measure the DOM against the Figma spec property by property, report the delta in CSS terms, then freeze the result so regressions are caught offline in ~2 seconds.

License: MIT TypeScript Tests Gates

English · Tiếng Việt


Contents


The problem

The intuitive design-fidelity loop — agent writes code → screenshot → compare images → agent fixes — asks a language model to do the thing it is worst at: spatial pixel comparison. A score like 97.3% match does not say which element is wrong, which property is wrong, or by how much. The agent cannot act on it, so it guesses, patches blindly, and breaks something else.

The failure is measurable, not theoretical. We built a testbed screen that matches its Figma frame exactly, then injected 17 known defects and ran the most mature tool in this space as a baseline. Here is one of them:

Defect d01 — a 12×12 checkbox changes from #1660CF to #2F80ED. Fully visible to a human. Baseline verdict: DFS 100 · pixelDiffRatio 0.53% · 0 style diffs · PASS

The defect is real but invisible to the tool: 144 px² of change inside a 378,000 px² frame lands below the 1% pixel threshold, and the style comparison never reaches an element that deep. Five of ten injected defects went undetected the same way, and all three mapping defects passed silently.

Global pixel scores lie about small elements. That is the gap specgate is built to close.

The approach

Measurement is the source of truth; pixels are a supplement.

Layer Role Output
Measurement gate The only source of truth Figma node tree ↔ getComputedStyle() + getBoundingClientRect() → numeric delta + CSS property name
Pixel pass Catches only what measurement cannot see Per-node crop diff → "the numbers match but it still looks wrong" (wrong icon, wrong asset, missing gradient)

Two rules follow from this, and they shape everything else:

  1. No global pixel score is ever produced. If you emit one, the agent optimises for it, and it will lead the agent astray.
  2. Every finding names a CSS property, a want, a got, and a signed delta with units — because that is the minimum an agent needs to write a one-line fix.
flowchart LR
    subgraph inputs[" "]
        FIG["Figma REST API<br/><small>files · nodes · images</small>"]
        APP["Running app<br/><small>Playwright + Chromium</small>"]
    end

    FIG --> FN["@ui-perfect/figma<br/><small>version cache · 429 backoff<br/>normalize</small>"]
    APP --> PW["@ui-perfect/probe-web<br/><small>freeze kit · WebProbe<br/>per-node crop</small>"]

    FN -->|"NormalizedNode<br/>(spec)"| CORE
    PW -->|"NormalizedNode<br/>(measured)"| CORE

    CORE["<b>@ui-perfect/core</b><br/><small>compare engine · tolerance<br/>offset consensus · ΔE2000<br/>lock schema · report</small>"]

    CORE --> REPORT["Report &lt;600 tokens<br/><small>for the agent</small>"]
    CORE --> LOCK["Lock file<br/><small>frozen measurements</small>"]

    CLI["@ui-perfect/cli<br/><small>verify · accept · guard<br/>audit · impact · mcp</small>"] -.orchestrates.-> FN
    CLI -.-> PW
    CLI -.-> CORE

    style CORE stroke-width:3px

@ui-perfect/core imports neither Figma nor Playwright. It receives two NormalizedNode trees and returns findings — which is why the entire comparison engine is unit-testable with no network and no browser.

Results

Same testbed, same 17 injected defects, same viewport. The baseline column is a mature existing tool measured during Phase 0 to decide whether building this was justified.

Test set What it measures Baseline specgate
detect (10 real defects) Recall — does it catch the bug? 5 / 10 9 / 10 ¹
benign (4 no-op refactors) Noise — false positives on visually identical changes 4 / 4 clean 4 / 4 clean
mapping (3 broken data-fig) Does it notice its own mapping is broken? 0 / 3 (silent) 3 / 3 reported

¹ 9 of the 10 d* defects are caught by measurement. d09 (gradient → flat colour) is deliberately not caught here — measurement does not compare gradients by design — and is verified separately by the pixel gate at 19.8% diff. The Phase 2 gate reports these buckets independently rather than merging them into one score.

Every detection in the baseline column came from the global pixel ratio; none came from measurement. specgate's detections all name the node and the property.

The same defect, both tools:

- baseline:  DFS 100 · pixelDiffRatio 0.53% · 0 style diffs · PASS
+ specgate:  [checkout.summary.checkbox] · background-color: want #1660CF, got #2F80ED (ΔE 12.3)

Gates

Three hard gates, each run against the live system rather than a mock:

Gate Criterion Result
1 — Build justified? Does an existing tool already close the gap? Recall 5/10, mapping silent → build
2 — Does the loop converge? Agent fixes from the report alone, cap 5 rounds 2 rounds on both d04 and d05
3 — Is guard trustworthy? 20 consecutive runs, unchanged code 20 / 20, zero false positives

Gate 3 is the important one. A flaky guard is worse than no guard, because it teaches the team to ignore warnings. Its precondition — 20 measurement runs that are byte-identical after quantisation — is verified separately by the jitter test.

Quick start

Prerequisites: Node ≥ 20.19 · pnpm ≥ 9.15 · Docker · a Figma personal access token (create one).

# 1. Install
pnpm install

# 2. Configure — token stays in .env, which is gitignored
cp .env.example .env
$EDITOR .env          # FIGMA_TOKEN=figd_...
                      # PLAYWRIGHT_SERVER_URL=ws://localhost:6003/specgate

# 3. Start the pinned browser (determinism — see Design decisions)
cd docker && docker compose up -d && cd ..

# 4. Start the testbed app
pnpm dev              # http://localhost:5173/checkout

# 5. Verify it against the Figma frame
npx tsx packages/cli/src/cli.ts verify checkout mobile

Expected output on a clean testbed:

# specgate: checkout/mobile vs figma teZCNcleh8Oa16hWuqwA5M:1:4960
PASS · 0 high, 0 med, 1 low · coverage 38%

(1 LOW — within rendering tolerance or dynamic data, ignore)

Now break something on purpose and watch the report change:

node apps/testbed/mutations/run.mjs apply d04-fontweight
npx tsx packages/cli/src/cli.ts verify checkout mobile --skip-pixel
node apps/testbed/mutations/run.mjs revert

How it works

The verify loop

The agent never sees an image. It sees a token-budgeted report where the order of lines is itself information — an agent fixes roughly what it reads first, so root causes are printed above symptoms.

sequenceDiagram
    participant A as Claude Code
    participant M as verify_ui (MCP)
    participant F as Figma (cached)
    participant B as Chromium (Docker)

    A->>A: write / edit UI code
    A->>M: verify_ui(screen, viewport)
    M->>F: nodes (1 request, pinned per session)
    M->>B: measure DOM + screenshot
    M->>M: compare · offset consensus · ΔE2000
    M-->>A: report < 600 tokens<br/>HIGH → MEDIUM, CSS properties
    A->>A: fix the HIGH items
    A->>M: verify_ui(...)
    M-->>A: PASS (or next round)
    Note over A,M: cap 5 rounds — then<br/>"N issues left, needs a human"

A real report, produced by injecting d04 (Grand Total loses its bold) — 136 tokens:

# specgate: checkout/mobile vs figma teZCNcleh8Oa16hWuqwA5M:1:4960
FAIL · 2 high, 1 med, 1 low · coverage 38%

## HIGH (2)
- [checkout.summary.grandTotalLabel] Grand Total · font-weight: want 700, got 400 (-300)
- [checkout.summary.grandTotalValue] $3,439.00 · font-weight: want 700, got 400 (-300)

## MEDIUM (1)
- [checkout.summary.grandTotalValue] $3,439.00 · x (left edge): want 284px, got 286px (+2px)

(1 LOW — within rendering tolerance or dynamic data, ignore)

Fix HIGH first, then run verify_ui again. Don't chase LOW.
Round 1/5.

Root cause before symptoms

A single wrong margin on a container shifts its whole subtree. Naively that is a dozen position findings, none of which is the bug. specgate runs an offset consensus per subtree, subtracts the shift from every child, and charges it to the container instead — one finding at the cause:

FAIL · 1 high, 0 med, 0 low · coverage 83%

⚠ offset dx=0 dy=40 at k.summary — subtracted before position checks
  (4 child nodes affected). Fix this container first.

## HIGH (1)
- [k.summary] k.summary · subtree offset: want (0, 0), got (0, 40) (+40px)
  ← all 4 children shifted by dy=40px — fix this container, not each child

Four position findings collapse into one that names the container. The shift is still a defect, so it fails the gate — suppressing the children is about locating the bug, not forgiving it. A global offset (the whole frame shifted) is treated differently: it is reported as the line only, because a uniform frame-wide shift is usually a measurement-origin artefact rather than a UI bug.

Consensus requires agreement, deliberately: at least 4 samples, a median of at least 1px, and ≥60% of nodes within ±1px of it. Without that guard, a median of [0, 0, −8, −8] would "detect" a −4px offset and turn one bug into two wrong ones. The x and y axes are resolved independently, so a subtree that is correct horizontally but shifted vertically is still caught.

The finding pipeline

flowchart TD
    START["spec node ↔ measured node<br/><small>paired by logical key</small>"]

    START --> ROT{"rotated<br/>in Figma?"}
    ROT -->|yes| SKIP["skip geometry<br/><small>bbox of a rotated node lies</small>"]
    ROT -->|no| OFF["subtract subtree offset"]

    OFF --> CLS["classify against tolerance profile"]
    CLS --> T{"|Δ| vs rule"}
    T -->|"≤ tol"| DROP["dropped — not reported"]
    T -->|"tol .. high"| MED["MEDIUM"]
    T -->|"≥ high"| HIGH["HIGH"]

    START --> FILL{"fill type"}
    FILL -->|solid| DE["ΔE2000 after alpha compositing"]
    FILL -->|"gradient / image"| PX["pixel pass<br/><small>per-node crop, text masked</small>"]
    FILL -->|"design has fill,<br/>impl transparent"| MISSING["HIGH — missing style,<br/>not a colour delta"]

    DE --> CLS
    HIGH --> GATE
    MED --> GATE
    PX --> GATE

    GATE["gate: HIGH + MEDIUM only<br/><small>LOW is informational</small>"] --> REP["report<br/><small>severity → key → property</small>"]

    style DROP stroke-dasharray: 5 5
    style GATE stroke-width:3px

Deliberately not compared: shadow geometry (Figma blur radius and CSS box-shadow blur are not the same quantity — presence only), gradients and image fills (pushed to the pixel pass), text content differences (usually dynamic data → LOW), and line-height: normal against a pinned design value (font-dependent → MEDIUM with a note instead of a fabricated number). Hidden nodes are dropped from the tree entirely — comparing against visible: false layers is the number one source of false "missing element" reports.

Four more exclusions were added after measuring them against the real testbed, each because it produced noise rather than signal:

  • text-align / text-transform. Categorical, and redundant. A browser reports start where Figma reports left; a <button> reports center from the UA stylesheet. When alignment genuinely differs the element's box moves, and the geometry check already catches that.
  • Node type (TEXT vs FRAME). The DOM-side heuristic (no element children and non-empty text) disagrees with Figma often enough — a label wrapping a checkbox, for instance — that the mismatch says more about the heuristic than about the UI.
  • 1px solid transparent. A standard trick for reserving layout space, and the default in many button resets. Invisible, therefore not a border.
  • Auto and percentage line-height. Figma still returns a computed lineHeightPx when the designer chose "Auto", so pinning that number would invent a requirement nobody specified. Only lineHeightUnit: "PIXELS" is treated as a pinned value.

Percentage border-radius is resolved against the element's own box (50% on a 48×48 button is 24px, not 50px), and Figma group opacity is composited down the tree so a child inside a 50%-opacity group is compared as 0.5, not 1.

Mapping: logical keys, not node ids

Markup references a logical key; the config maps that key to a Figma node id per viewport. Figma node ids change when a designer recreates a frame, and the same element has a different id in every breakpoint — markup should not die for either reason.

flowchart LR
    subgraph code["Implementation"]
        H["&lt;h2 data-fig=<br/>&quot;checkout.title&quot;&gt;"]
        B["&lt;button data-fig=<br/>&quot;checkout.cta&quot;&gt;"]
    end

    subgraph cfg["screens.config.json"]
        K1["checkout.title"]
        K2["checkout.cta"]
        K3["checkout.sidebar"]
    end

    subgraph fig["Figma frames"]
        M["mobile 1:4960"]
        D["desktop 1:2082"]
    end

    H --- K1
    B --- K2
    K1 -->|"2:15"| M
    K1 -->|"1:2"| D
    K2 -->|"2:20"| M
    K2 -->|"1:3"| D
    K3 -.->|"1:9"| D
    K3 -.-x M

    style K3 stroke-dasharray: 5 5

checkout.sidebar exists on desktop and is intentionally absent on mobile — expressible with logical keys, impossible with raw node ids. Two metrics keep the mapping honest: coverage (share of significant Figma nodes that are mapped — where "significant" deliberately includes long-thin shapes like a 339×1 divider, which code generators forget and users notice) and staleMappings (keys that fail to resolve on either side, in both directions).

Lock file lifecycle

Once a screen is correct, freezing its measurements turns an expensive question ("does this match the design?") into a cheap one ("did anything move?").

stateDiagram-v2
    [*] --> Verifying

    Verifying: <b>verify</b><br/>Figma + browser + agent loop
    Accepted: <b>accept</b><br/>measurements frozen to lock
    Guarding: <b>guard</b><br/>no Figma · no AI · ~2s
    Rotted: baseline is stale

    Verifying --> Accepted: 0 HIGH + --reason
    Accepted --> Guarding: every code change
    Guarding --> Guarding: exit 0 — no drift
    Guarding --> Verifying: exit 1 — drift found
    Guarding --> Accepted: exit 3 — env changed,<br/>re-accept in the new env
    Accepted --> Rotted: designer edits Figma
    Rotted --> Verifying: <b>audit</b> exit 1<br/>reports what changed
    Accepted --> Accepted: <b>audit</b> exit 0 — fresh

Two decisions make this work:

The lock stores measured values, not the spec. If it stored the spec, every guard run would re-report deviations you already consciously accepted — permanent noise. Storing what was measured at acceptance time bakes in tolerated deviations, so guard stays silent until something actually changes.

audit exists because the lock stores measured values. That is the failure mode of the previous decision: if Figma moves ahead and nobody re-verifies, guard passes forever while the design has changed. audit closes it cheaply — it compares the file version first (one small request, ~zero cost when nothing changed), and only when that differs does it fetch nodes and compare a specHash of the mapped subtree. A designer editing an unrelated page changes lastModified but not the hash.

Drift tolerance is roughly 20× tighter than design tolerance (0.05px vs 1px), and that asymmetry is intentional. Comparing Figma to a browser means comparing two different rendering engines; comparing a browser to the same browser on the same frozen page has no such noise. Jitter at 0.05px is a determinism bug to fix, never a tolerance to loosen.

Commands

specgate verify <screen> [viewport]    # compare against Figma → report for human or agent
specgate accept <screen> [viewport]    # freeze measurements into .specgate/lock/  (--reason required)
specgate guard  <screen> [viewport]    # compare against lock — no Figma, no AI
specgate audit  [screen]               # baseline rot check via specHash
specgate impact [--base <rev>]         # which screens need guarding, from git diff
specgate mcp                           # MCP server exposing one tool: verify_ui
Command Exit codes Notes
verify 0 pass · 1 findings · 2 config/env --skip-pixel for a faster measurement-only run
accept 0 written · 2 refused Refuses while any HIGH finding is unreviewed; checks the Figma version first unless --offline
guard 0 clean · 1 drift · 2 no lock/config · 3 env mismatch Exit 3 means "not compared" — a changed browser or tolerance profile is not drift
audit 0 fresh · 1 rot · 2 no lock Lists what the designer changed, property by property
impact 0 always when it runs Fails open: if the dependency graph errors, guard everything
mcp Runs until the client disconnects

During development, run through tsx: npx tsx packages/cli/src/cli.ts verify checkout mobile. Full reference with real output for every command: docs/CLI.md.

Using with Claude Code

.mcp.json is committed at the project root, so opening this repo in Claude Code exposes the verify_ui tool automatically — no claude mcp add needed:

{
  "mcpServers": {
    "specgate": {
      "type": "stdio",
      "command": "npx",
      "args": ["tsx", "packages/cli/src/cli.ts", "mcp"]
    }
  }
}

The token is not in that file. The CLI loads .env from the working directory at startup, and any variable already present in the environment wins.

The tool description carries its own stopping condition, because a loop without one burns tokens chasing sub-pixel antialiasing:

Call after implementing or editing UI, fix the HIGH items, then call again. Stop after 5 calls or when only LOW items remain — LOW items are rendering tolerance, not bugs.

Infrastructure failures are reported as infrastructure failures. A Figma rate limit comes back flagged ⚠ specgate infrastructure error (NOT a UI bug — do not change code because of this), so the agent does not "fix" a network error by editing CSS.

Configuration

.specgate/screens.config.json — one entry per screen, one map per viewport:

{
  "$schema": 1,
  "figma": { "fileKey": "teZCNcleh8Oa16hWuqwA5M" },
  "screens": {
    "checkout": {
      "sources": ["apps/testbed/src/**"],        // for impact analysis
      "app": {
        "url": "http://localhost:5173/checkout",
        "command": "pnpm --filter testbed dev",   // how guard boots the app
        "fixtureMode": "static"
      },
      "viewports": {
        "mobile": {
          "width": 375, "height": 1008, "dpr": 2, "isMobile": true,
          "figmaNode": "1:4960",                  // API form "1:4960", not URL form "1-4960"
          "map": {
            "checkout.summary.payButton": "I70:7095;70:6703;41:2400",
            "checkout.summary.divider":   "I70:7095;1:5105"
            // … 29 keys total
          }
        }
      }
    }
  },
  "impact": {
    // Changes to these bypass dependency analysis entirely and guard every screen —
    // a dependency graph cannot see global CSS, tokens, or config
    "always": ["**/tokens/**", "apps/testbed/src/styles/**",
               "pnpm-lock.yaml", ".specgate/screens.config.json"],
    // Where to build the dependency graph. Must be WIDER than any screen's `sources`:
    // a screen imports shared code living outside its own glob, and scanning narrowly
    // means those imports are never seen. Omit to auto-detect packages/ apps/ src/ lib/
    "scan": []
  }
}

Tolerance profiles

tol — below this, the difference is dropped entirely, never reported. Between tol and high → MEDIUM. At or above high → HIGH. The gate counts HIGH and MEDIUM only.

Property Design tolhigh Drift tolhigh
x y w h 1 → 8 px 0.05 → 0.5 px
fontSize 0.5 → 2 px 0.05 → 0.5 px
lineHeight 1 → 4 px 0.05 → 0.5 px
letterSpacing 0.15 → 0.8 px 0.02 → 0.2 px
fontWeight 0 → 0.5 — any difference is HIGH same
borderWidth 0.5 → 1.5 px 0.05 → 0.5 px
radius 0.5 → 4 px 0.05 → 0.5 px
gap padding 1 → 8 px 0.05 → 0.5 px
opacity 0.02 → 0.1 0.01 → 0.05
color ΔE 1.2 → 4 ΔE 0.5 → 2

Colour distance is CIEDE2000, never RGB. UI palettes are full of near-neutral darks — #0F172A against #111827 is a large RGB distance and a barely perceptible one to the eye. RGB or CIE76 would send an agent chasing colour bugs that do not exist.

A delta that is not a finite number — NaN from a node that failed to measure, or an infinite difference — is classified HIGH, never dropped. "Within tolerance" has to mean measured and fine; a node nobody could measure must not pass the gate by omission.

The profile version is written into the lock file. When the table changes meaning, guard refuses to compare (exit 3) rather than silently reinterpreting an old baseline.

Project layout

packages/
  core/          # ~1,170 lines · no Figma, no Playwright imports
    types.ts       NormalizedNode — the shape both sides normalise to
    tolerance.ts   design + drift profiles, classify()
    offset.ts      per-subtree consensus
    color.ts       ΔE2000 via culori, alpha compositing
    mapping.ts     significance rule, coverage, stale mappings
    compare.ts     the comparison engine
    report.ts      token-budgeted formatter
    lock.ts        schema + deterministic serialiser
  figma/         # REST client: version cache, session pinning, 429 backoff,
                 # stale-if-error · node → NormalizedNode
  probe-web/     # WebProbe over Playwright · freeze kit · per-node pixel pass
  cli/           # specgate binary + MCP server, one shared code path
apps/testbed/    # checkout screen matching a real Figma frame
  mutations/       17 injected defects + 4 gate scripts — the permanent regression suite
.specgate/
  screens.config.json
  lock/checkout.mobile.json    # committed — a baseline you can review in a PR
docker/          # pinned Playwright browser server
docs/            # ARCHITECTURE · CLI · GOTCHAS · phase0-report
PLAN.md, PLAN-02-lockfile-mobile.md   # the original design specification

Testing and gates

125 unit tests across 15 files — core 64, probe-web 22, cli 21, figma 18 — plus 8 integration tests in a 16th file that need Docker and Vite running.

pnpm test                                              # unit tests
pnpm --filter @ui-perfect/probe-web test               # includes integration (needs Docker + Vite)

Four gate scripts run the whole system against the testbed. They need the Docker browser and the Vite server running, and must be run from the repository root:

npx tsx apps/testbed/mutations/mapping-gate.mjs    # Phase 1 — mapping survives refactors, reports breakage
npx tsx apps/testbed/mutations/specgate-gate.mjs   # Phase 2 — detect 9/9, d09 defer→pixel, mapping 1/1, benign 4/4
npx tsx apps/testbed/mutations/pixel-gate.mjs      # Phase 3 — gradient defect caught by pixels
npx tsx apps/testbed/mutations/jitter-test.mjs 20  # Phase 5 — 20/20 byte-identical

The mutation harness itself is the interesting part. Three sets, three different questions:

  • detect (10) — real defects. Measures recall.
  • benign (4) — refactors with no visual change: wrapping a div, swapping a for button, 14px0.875rem. Must produce zero findings. This is the set that measures noise, and noise is what kills these tools: past ~40% false positives an agent stops trusting the report.
  • mapping (3) — deliberately broken data-fig attributes. Must be reported, never silently passed.

Freezing the tolerance table before writing the mutations was deliberate — otherwise the person tuning thresholds is the person choosing the tests, and the numbers mean nothing.

Design decisions

Six decisions carry most of the weight. docs/ARCHITECTURE.md covers the reasoning in full.

Decision Why
Measurement first, no global score A percentage is not actionable. A CSS property with a signed delta is.
Logical keys instead of Figma node ids Node ids change when frames are recreated and differ per breakpoint; markup should not. Also makes "intentionally absent on mobile" expressible.
Lock stores measured, not spec Storing the spec re-reports every consciously accepted deviation, forever.
Drift tolerance ≈ 20× tighter than design tolerance Browser-vs-browser has none of the two-renderer noise that browser-vs-Figma has.
Text excluded from pixel diff, permanently Figma and browsers never rasterise glyphs identically. This is not a tolerance problem, so it is not solved with a tolerance. Text is masked out of every crop, including its parents'.
Determinism before the first lock file If the baseline is machine-specific, guard fails for everyone but its author. The browser is pinned to an exact version and runs in Docker; the client version must match exactly.

Status and roadmap

Phase Scope Outcome
−1 Bootstrap Monorepo, pinned Docker browser, Figma sample, testbed
0 Validate 17-mutation harness against a baseline tool ✅ Gate 1 — build justified
1 Mapping Logical keys, coverage, stale detection ✅ 6/6
2 Engine Per-node compare, tolerance, offset consensus, ΔE2000 ✅ detect 9/9 (d09 defer→pixel) + mapping 1/1, benign 4/4
3 Pixel Per-node crops, text masking ✅ gradient defect at 19.8%
4 Report + MCP 136-token report, verify_ui, 5-round cap ✅ Gate 2 — converged in 2 rounds
5 Determinism Jitter test ✅ 20/20 byte-identical
6 Lock + guard accept, guard, drift profile, env fingerprint ✅ Gate 3 — 20 runs, 0 false positives
7 Audit + impact specHash rot check, dependency analysis with escape hatch
8 Mobile Mobile freeze kit, safe-area injection

Next: desktop viewport mapping · design-token checking via Figma boundVariables (catching "right number, hardcoded instead of using the token") · interaction states (hover, focus, dark mode) · an occasional real-device pass.

Limitations

Stated plainly, because a verification tool that oversells itself is worse than none:

  • Gradients and image fills are not measured. By design — they cannot be reduced to one colour. The pixel pass catches them, which means they need the screenshot path, not just measurement.
  • Emulation is not a real device. Playwright's mobile emulation answers "is my responsive layout right?", not "is it right on an actual iPhone?". Safe-area insets read as 0 and are injected artificially; mobile Safari handles some flex cases differently. Ship-critical mobile work still needs a real-device pass.
  • Figma Starter plans are rate-limited to roughly 10 requests per minute on the endpoints this tool needs. The client caches per file version, pins that version for a whole verify session, and backs off on 429 — but a cold cache on a busy token will wait.
  • Code Connect is not usable here. It requires an Organization or Enterprise plan, which is why mapping is attribute-based.
  • The data-fig attributes reach production markup unless stripped at build time. A babel/swc plugin handles this; it is documented, not yet wired up.

Credits

The architecture — measurement-first comparison, logical-key mapping, per-subtree offset consensus, measured-value lock files, the split between verify, guard, and audit — is specified in PLAN.md and PLAN-02-lockfile-mobile.md, written before any code existed. Everything in packages/ is implemented from that specification.

Built on: pixelmatch · culori · pngjs · Playwright · @modelcontextprotocol/sdk · commander · zod · @figma/rest-api-spec · dependency-cruiser.

uimatch is the most mature tool in this space and served as the measured baseline in Phase 0: running it over the same 17 mutations is what quantified the remaining gap and justified building this.

License

MIT

from github.com/nv-minh/UI-perfect

Installing Specgate

This server has no published package — it is built from source. Open the repository and follow its README.

▸ github.com/nv-minh/UI-perfect

FAQ

Is Specgate MCP free?

Yes, Specgate MCP is free — one-click install via Unyly at no cost.

Does Specgate need an API key?

No, Specgate runs without API keys or environment variables.

Is Specgate hosted or self-hosted?

Self-hosted: the server runs locally on your machine via the install command above.

How do I install Specgate in Claude Desktop, Claude Code or Cursor?

Open Specgate on unyly.org, pick your client tab (Claude Desktop, Claude Code, Cursor) and press Install — the config is generated automatically, no JSON editing.

Related MCPs

Compare Specgate with

Not sure what to pick?

Find your stack in 60 seconds

Author?

Embed badge for your README

Browse similar

All design MCPs