| name | polaris-visual-sandbox |
| description | Use when converting a UI reference (screenshot/design) into Polaris React, matching a design pixel-for-pixel, or verifying built UI against a target. Triggers on "make it match", "exact match", "build this UI from this screenshot/design", or when a previous attempt was "close but not exact". Renders in an isolated sandbox at localhost:6090 that loads YOUR project's Polaris, measures the reference, and asserts the rendered DOM against those numbers via Playwright. |
| argument-hint | [path to UI reference image] [target file: the real component/page to apply the result to] |
| allowed-tools | Read, Write, Edit, Glob, Grep, Bash, mcp__playwright__browser_navigate, mcp__playwright__browser_resize, mcp__playwright__browser_take_screenshot, mcp__playwright__browser_console_messages, mcp__playwright__browser_evaluate, mcp__playwright__browser_snapshot, mcp__playwright__browser_click, mcp__playwright__browser_hover, mcp__playwright__browser_type, mcp__playwright__browser_press_key, mcp__playwright__browser_select_option, mcp__playwright__browser_fill_form, mcp__playwright__browser_wait_for, mcp__context7__resolve-library-id, mcp__context7__query-docs |
Polaris UI Sandbox — Exact Visual Match
Produce a pixel-exact Polaris React build of a reference and prove it by
measuring the reference and asserting the rendered DOM against those numbers —
not by eyeballing a screenshot. "Looks close" is failure. The target is exact:
padding, margins, radius, shadows, alignment, color, font size and weight,
optical alignment, and every interactive state.
Pairs with polaris-design (how to author idiomatic Polaris). This skill adds the
strict measure → build → assert → iterate loop and the direct-to-target delivery.
The sandbox runs outside your project (from a cache dir) but renders your
project's exact @shopify/polaris + React — start-sandbox.sh discovers your
project's node_modules and aliases Vite to it, and tokens.py reads the same
Polaris stylesheet. So the numbers match what your app actually ships. Nothing is
scaffolded into your repo.
Conventions used below
$S = ${CLAUDE_PLUGIN_ROOT}/skills/polaris-visual-sandbox (this skill's dir).
Scripts live at $S/scripts/.
- Run all commands from your project root (the project whose Polaris you want
to match).
start-sandbox.sh and tokens.py both discover Polaris from there.
The Iron Rule
You may NOT say "match", "done", "faithful", or "looks right" until DOM
assertions pass against numbers you measured from the reference. A screenshot
that looks correct is not evidence — the chevron-centering, wrong-color, and
dropped-token bugs all pass the eye test and fail the DOM. Violating the letter
of this (skipping one assertion "because it obviously matches") is violating the
spirit. If a number is unverified, you are not done.
Never guess a number — fetch it
Every pixel, color, and token comes from a tool, never from memory:
| You need | Use |
|---|
| A Polaris token's real value (px/hex) | $S/scripts/tokens.py — reads the Polaris build YOUR project loads |
| Which token a measured value maps to | tokens.py nearest-space <px> / nearest-color <hex> |
| A measurement off the reference image | $S/scripts/measure.py (dims, color, bbox, edges, grid, compose, diff) |
| The rendered value of your build | mcp__playwright__browser_evaluate → getBoundingClientRect + getComputedStyle |
| A component's props / valid values | context7 MCP (resolve-library-id → @shopify/polaris → query-docs) |
| Quick token/recipe/color map | $S/polaris-reference.md (then confirm exact values with tokens.py) |
Guessing a number is the #1 cause of "close but not exact". An off-grid spacing
token (e.g. 250, 350) is silently dropped to 0 — tokens.py nearest-space
catches it before you ship it.
Scope
- The sandbox is visual + interactive. Use hard-coded text, but wire real
local UI state (
useState) so the output is clickable — Popovers open,
tabs switch, inputs focus, accordions expand.
- Do NOT wire data, Redux, React Query, data hooks, or
t() in the sandbox.
Local component state for interaction is encouraged.
- Avoid
@shopify/app-bridge-react components (NavMenu, TitleBar, App Bridge
Modal) — use Polaris equivalents for the visual.
- The deliverable is the change applied directly to the real component/page
(Step 8), not JSX pasted into chat.
Step 0 — Prerequisites
- Playwright MCP must be connected (
mcp__playwright__browser_*). If not, ask
the user to enable it, then retry. (DOM assertion and interaction checks require it.)
node + npm on PATH (the sandbox's build tooling installs on first run).
python3 with PIL (python3 -c "import PIL"). The skill's scripts need it.
- Your project must have
@shopify/polaris + react installed. The sandbox
renders those exact versions.
Step 1 — Boot the sandbox (idempotent)
From your project root:
"$S/scripts/start-sandbox.sh"
(If $S isn't expanded in your shell, use the literal
"${CLAUDE_PLUGIN_ROOT}/skills/polaris-visual-sandbox/scripts/start-sandbox.sh".)
It prints one JSON line, e.g.:
{"type":"sandbox-started","url":"http://localhost:6090","port":6090,
"slot_path":"/Users/you/.cache/polaris-visual-sandbox/Slot.tsx",
"node_modules":"/path/to/project/web/frontend/node_modules",
"polaris_styles":".../@shopify/polaris/build/esm/styles.css","polaris_version":"13.9.5"}
Save slot_path — that's the file you edit in Step 3. Confirm polaris_version
matches your app. If the script can't find Polaris, pass
--node-modules <path/to/node_modules>. If your shell reaps background processes,
run it with the Bash tool's run_in_background: true (it self-backgrounds anyway).
Step 2 — Set the goal: write a measured spec
This is where exactness is won. Do NOT start building from a glance.
Read the reference image.
- Scale check (step zero):
python3 "$S/scripts/measure.py" edges <ref> <x0> <y0> <x1> <y1> across a known 1px hairline. If it's ~2px wide / controls look ~2× the reference tables, the image is 2× — divide every measurement by 2. Record imagePxPerCssPx.
- Enumerate every element and every state (use the states & edges checklist in
$S/polaris-reference.md).
- For each, capture hard numbers and map them to tokens — write this as an explicit spec checklist you will converge to:
- boxes/insets/gaps via
measure.py bbox / edges;
- font size via measurement →
tokens.py nearest-... / text → the variant (remember bodyMd=13px, bodyLg=14px);
- colors via
measure.py color → tokens.py nearest-color → the token;
- radius, border width, weight, shadow.
- Confirm every spacing token exists (
tokens.py nearest-space must say EXACT).
Run tokens.py from your project root so it reads your app's Polaris.
Step 3 — Build the interactive slot
Write Polaris JSX into the slot_path from Step 1 (the cache-dir Slot.tsx):
default export, hard-coded content, real useState for interactions.
Initialize the state you're matching to its target (e.g. useState(true) for an
open Popover) so HMR always renders that state while it stays clickable. Use only
the tokens you confirmed in Step 2. Confirm exact props via context7 /
polaris-design. Reuse the recipes in $S/polaris-reference.md (Select-style
trigger, inset hover band) instead of rediscovering them.
Step 4 — Render & screenshot
browser_navigate → http://localhost:6090 (first iteration only; HMR repaints after).
browser_resize → reference width ÷ scale.
- If a state needs a click to appear, drive it with
browser_click/browser_hover (use browser_snapshot to find refs).
browser_take_screenshot. Blank/broken? browser_console_messages → fix → retry.
Step 5 — Assert the DOM (the gate)
For each spec element, browser_evaluate getBoundingClientRect() +
getComputedStyle() (snippet in $S/polaris-reference.md). Assert against Step-2:
- geometry within ±2px (widths, heights, insets, gaps);
- font-size exact, font-weight exact (
550=medium, 450=regular);
- background/text/border color exact rgb;
- border-radius, padding exact;
- trailing-element alignment by geometry — assert
icon.right ≈ container.right − paddingRight; do not trust justify-content: space-between (it centers a lone child while reading "correct").
Any mismatch → edit the slot, re-screenshot, re-assert. Loop. List the failing
numbers explicitly each round.
Step 6 — Verify interactions
Exercise every interaction with Playwright and assert each resulting state, so the
UI actually responds (not just static pixels):
browser_click the trigger → Popover/menu opens; click outside → closes.
browser_hover a row → hover band appears with the exact spec color/inset.
browser_type / browser_press_key / browser_select_option for inputs, focus rings, selects.
Screenshot + assert each interactive state against the reference's matching state.
Step 7 — Stop criterion
Done only when: every Step-5 assertion passes AND a scale-normalized
overlay shows no structural drift (measure.py compose <ref> <shot> out.png and
measure.py diff — the diff's changed-region should be noise, not structure)
AND every Step-6 interaction behaves. Then, and only then, report "match" —
citing the asserted numbers.
Step 8 — Apply to the real target (deliver output, not code)
Apply the matched UI directly to the target file (from the argument, or ask
once if unknown). Do not paste the final JSX into chat as the deliverable.
Edit/Write the real component/page file in YOUR project with the matched structure.
- Swap hard-coded text for real data/props, wrap user-facing strings in
t() if your app uses i18n, wire hooks — follow polaris-design for placement and the surrounding file's conventions.
- Run your project's typecheck + lint (e.g.
pnpm run pre-commit, or your equivalent).
- Tell the user it's applied and where; they review and confirm.
Step 9 — Reset the slot
"$S/scripts/stop-sandbox.sh" --reset
Or keep the server running for the next task and just reset the slot file by
copying the placeholder back (Slot.placeholder.tsx, next to slot_path).
Red flags — STOP, you're about to ship "close, not exact"
- "looks close / faithful / matches closely / I think it matches" with no DOM numbers
- eyeballing a color, font size, or spacing instead of
measure.py + tokens.py
- using a token you didn't confirm exists (esp. spacing —
250/350 vanish)
- declaring done with any interactive state unexercised
- pasting the final code into chat instead of applying it to the target
Rationalization table
| Excuse | Reality |
|---|
| "It's visually a faithful match" | Visual gestalt is exactly what shipped the chevron/color/size bugs. Assert the DOM. |
| "justify-content is space-between, so it's pinned" | It centered a lone child at x=860 in a 699–1009 box. Measure icon.right. |
| "This looks like ~14px" | bodyMd is 13px, bodyLg is 14px. getComputedStyle decides, not your eye. |
| "Gray hover, I'll use bg-surface-selected" | That's neutral too, but the blue one is bg-surface-emphasis. Sample, then nearest-color. |
| "paddingBlock 250 is about right" | 250 isn't a token → 0 padding → collapsed rows. tokens.py nearest-space 10. |
| "The reference is small, I'll estimate" | Estimating at thumbnail scale sends you down wrong paths. Run the scale check. |
| "I'll show the user the code to paste" | The deliverable is the applied change. Edit the real file. |
See also
$S/polaris-reference.md — token tables (verified), color map, recipes, DOM-assert snippet, states checklist, worked example.
$S/scripts/tokens.py / $S/scripts/measure.py — run with -h for usage.
$S/scripts/start-sandbox.sh / stop-sandbox.sh — boot/stop the isolated sandbox.
- context7 MCP — authoritative component prop/API docs.
polaris-design skill — idiomatic Polaris authoring patterns.