Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions skills/figma-parity/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
---
name: figma-parity
description: Measure a page's mobile and tablet rendering against its Figma frames and close the gap. Use when a Linear ticket asks whether a page matches Figma at 393/840, or when the user reports typography or spacing drifting from the design at a breakpoint.
user-invocable: true
---

# Figma parity

Bring a page to **parity** with its Figma frames at mobile (393px) and tablet (840px).

Parity is decided by **measured** numbers on both sides: pixel values read out of Figma, pixel values read out of the rendered DOM. Two frames that look identical at a glance routinely differ by 2px of leading on every line, so the DOM is the instrument here — `getComputedStyle` and `getBoundingClientRect`, at each target width.

Desktop is **frozen**: it was signed off against its own frame, so it must render byte-identical before and after. Every change is scoped below `lg` (1024px), with an explicit `lg:` reset wherever today's desktop value differs from the value you are introducing.

## Step 1 — Set up

Takes one or both ticket IDs (`/figma-parity GC-265`, or `GC-222 GC-265`). With none, ask for one and stop.

Mobile and tablet are usually **two tickets** — one is the other's sub-issue, each with its own Figma frame. Given one, find the sibling: check the ticket's `parentId`, then list its children. Handle both on one branch — they share a renderer, so splitting them means fixing the same file twice. Confirm which ticket the PR should name.

Then run the `prep` skill for its Steps 1–3 only: fetch, branch, move to In Progress. Steps 2–6 here replace prep's generic exploration and plan.

Desktop is frozen by default. If the user says this page's desktop was never checked against Figma, treat all three widths as in scope and say so in the Step 4 report.

Done when: both ticket IDs, both Figma node IDs, and the live route are in hand.

## Step 2 — Read the spec out of Figma

Follow `references/figma-values.md` to pull each frame and decode it. Frame geometry alone yields most of the checklist; named styles fill the rest.

Done when: every row of the parity checklist below has a Figma number **for both breakpoints**. A row you could not source is reported as unknown, not guessed.

## Step 3 — Measure the built page

Follow `references/measuring.md` to build, serve, and read computed styles at 393, 840, and 1440.

Measure the frozen breakpoint now — that reading is the baseline Step 6 proves you did not disturb.

Done when: every checklist row has a rendered number at all three widths, **and** the page's stylesheet was confirmed to load. A stale server serves an unstyled page that returns 200 and yields plausible, uniformly wrong numbers — the reason that check is part of the criterion rather than a footnote.

## Step 4 — Report the drift

Give the user a table before touching code: metric, Figma value, rendered value, delta — for both breakpoints.

Alongside it, state:

- **Blast radius.** Which other routes render through the files you would change. One renderer commonly serves many pages, so a fix aimed at one page silently restyles its siblings.
- **Contradictions.** Where sibling frames disagree with each other. Frames drift apart as a design evolves; when they do, a value is page-specific and belongs behind a per-page flag rather than generalized. Surface the disagreement — resolving it is the designer's call.
- **Regressions you would introduce.** Any page currently correct that your change would move.

Done when: the user has the table and has chosen how to handle any contradiction.

## Step 5 — Fix

Apply the deltas, scoped below the frozen breakpoint. `references/fix-patterns.md` carries the traps that make a correct-looking edit render wrong.

Done when: every row of the table is applied, or deferred with a stated reason. A partial pass reads as finished once the obvious rows are green, which is how the 2px rows survive.

## Step 6 — Prove the numbers

Rebuild, re-serve, re-measure all three widths.

Done when: every checklist row matches Figma at 393 and 840; the frozen breakpoint is identical to the Step 3 baseline, value for value; and `pnpm typecheck && pnpm build && pnpm lint` pass.

## Step 7 — Close the loop

The checklist only proves the rows you thought to measure. Everything you did not think of — a heading that wraps to two lines where Figma has one, a button 6px too wide, a divider that vanished — survives a green table. Step 7 looks at the page.

Run this skill's engine, `engine/figma-parity-verify.js` — resolve it against this skill's base directory and pass the absolute path as `scriptPath`. It ships with the skill rather than in `.claude/workflows/`, so it is not invocable on its own.

It fans out one Sonnet agent per region × breakpoint, each screenshotting both the Figma node and the rendered region, then re-verifies every reported mismatch adversarially — a vision model comparing two images invents differences, and a false positive costs a wrong edit. A final Opus pass reads the surviving set as a whole and reports what no single-finding agent can see: frames that contradict each other, values that are page-specific rather than shared, and any currently-correct route the obvious fix would move.

Leave the server from Step 6 running and pass it in:

```
Workflow({ scriptPath: "<skill dir>/engine/figma-parity-verify.js", args: {
baseUrl: "http://localhost:3100",
route: "/terms",
frozenAt: 1440,
// resolve once in the project under test:
// node -e "console.log(require.resolve('playwright'))"
playwrightPath: "<absolute path to playwright/index.mjs>",
regions: [
{ name: "header", selector: "article > header",
nodes: { "393": "1948:107772", "840": "1948:107547" } },
{ name: "body", selector: ".legal-prose",
nodes: { "393": "1948:107794", "840": "1948:107569" } },
{ name: "cta", selector: "#legal-cta",
nodes: { "393": "1948:107905", "840": "1948:107680" } },
],
}})
```

Regions come from Step 2 — you already walked the frame tree, so you have the node IDs and can name the matching selectors. Three to five regions covers a page; a whole-page screenshot of a long document is too tall to compare and finds nothing.

Then loop: apply the confirmed mismatches — reading `synthesis` first, since it decides whether a fix is shared or belongs behind a per-page flag — rebuild, and re-run. Stop when it returns `clean: true` twice running, or when the only findings left are ones you have deliberately declined and reported.

If it returns `unstyledRegions`, the server went stale mid-run — rebuild, restart, re-run, and discard that round's findings entirely.

Done when: the workflow comes back clean, and the user has the Step 4 table with an after column plus anything the visual pass caught that the checklist missed.

## The parity checklist

Every row, at both breakpoints. Adapt the roles to the page; the categories are fixed.

**Box** — content inset from the viewport edge, column width, section top and bottom padding.

**Type**, for each distinct text role (page title, section heading, body, meta, link, button label) — font-size, line-height, font-weight, letter-spacing.

**Rhythm** — the gap between every adjacent block pair: title to body, heading to its first paragraph, paragraph to paragraph, section to section, last block to any trailing component.

**Components** — for each button, badge, or pill: font-size, line-height, padding, border-radius, and rendered height. Height is the tell that catches a wrong line-height when font-size and padding both look right.

**Colour** — only where a value looks off. Convert the token and compare hex; tokens frequently already match and the row costs nothing to confirm.
217 changes: 217 additions & 0 deletions skills/figma-parity/engine/figma-parity-verify.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
export const meta = {
name: 'figma-parity-verify',
description: 'Compare a built page against its Figma frames at mobile and tablet, region by region, and return the surviving mismatches',
whenToUse: 'After applying a parity fix, to catch what the numeric checklist did not cover. Invoked by the figma-parity skill.',
phases: [
{ title: 'Inspect', detail: 'one agent per region × breakpoint — screenshot both sides, measure, diff', model: 'sonnet' },
{ title: 'Confirm', detail: 'adversarial re-check of each reported mismatch', model: 'sonnet' },
{ title: 'Synthesize', detail: 'read the confirmed set as a whole — patterns, blast radius, regressions', model: 'opus' },
],
}

// args: {
// baseUrl: 'http://localhost:3100' — a server the caller already started
// route: '/terms'
// frozenAt: 1440 — width that must not have moved (0 to skip)
// regions: [{ name, selector, nodes: { '393': '1948:107772', '840': '1948:107547' } }]
// playwrightPath: '/abs/path/to/playwright/index.mjs'
// }
const { baseUrl, route, regions, frozenAt, playwrightPath } = args
const BREAKPOINTS = ['393', '840']

// Absolute, because playwright is usually a transitive dep and not linked at the
// project root — a bare 'playwright' specifier does not resolve from an agent's cwd.
// The caller resolves it; hardcoding one machine's path here breaks every other.
if (!playwrightPath) {
throw new Error(
"playwrightPath is required — resolve it in the target project with:\n" +
" node -e \"console.log(require.resolve('playwright'))\"",
)
}

const FINDINGS = {
type: 'object',
properties: {
region: { type: 'string' },
breakpoint: { type: 'string' },
stylesheetLoaded: {
type: 'boolean',
description: 'False if the page rendered unstyled — findings are then meaningless',
},
diffs: {
type: 'array',
items: {
type: 'object',
properties: {
element: { type: 'string', description: 'What it is, plus a CSS selector that reaches it' },
property: { type: 'string', description: 'font-size, line-height, gap, padding, weight, colour, alignment, …' },
figma: { type: 'string' },
rendered: { type: 'string' },
evidence: { type: 'string', enum: ['measured', 'visual'] },
note: { type: 'string' },
},
required: ['element', 'property', 'figma', 'rendered', 'evidence'],
},
},
},
required: ['region', 'breakpoint', 'stylesheetLoaded', 'diffs'],
}

const VERDICT = {
type: 'object',
properties: {
real: { type: 'boolean' },
reason: { type: 'string' },
fix: { type: 'string', description: 'Concrete change, or empty if not real' },
},
required: ['real', 'reason'],
}

const HOWTO = `
Figma tools are deferred — load them first:
ToolSearch({ query: "select:mcp__figma-dev-mode__get_screenshot,mcp__figma-dev-mode__get_metadata,mcp__figma-dev-mode__get_design_context", max_results: 5 })

Screenshot the rendered region with playwright (imported by absolute path, it is not linked at the repo root):
const { chromium } = await import("${playwrightPath}")
const b = await chromium.launch(); const p = await b.newPage()
await p.setViewportSize({ width: <bp>, height: 1200 })
await p.goto("${baseUrl}${route}", { waitUntil: "domcontentloaded" })
await p.locator("<selector>").screenshot({ path: "/tmp/region.png" })
Then Read that file to see it.

Guard against a stale server before trusting anything: the page must reference a
/_next/static/css/*.css that returns 200. An unstyled page still returns 200 and
yields plausible, uniformly wrong readings — report stylesheetLoaded:false and stop.
`

phase('Inspect')

const pairs = []
for (const region of regions) {
for (const bp of BREAKPOINTS) {
if (region.nodes && region.nodes[bp]) pairs.push({ region, bp })
}
}

log(`${pairs.length} region×breakpoint pairs across ${regions.length} regions`)

const results = await pipeline(
pairs,
({ region, bp }) =>
agent(
`Compare one region of a built page against its Figma frame. Report only genuine mismatches.

Region: "${region.name}" selector: ${region.selector}
Breakpoint: ${bp}px Figma node: ${region.nodes[bp]}
Page: ${baseUrl}${route}

1. get_screenshot on Figma node ${region.nodes[bp]}.
2. Screenshot the same region of the rendered page at ${bp}px wide.
3. Put them side by side and look: type size and weight, leading, letter-spacing,
the gap between every adjacent block, padding, alignment, colour, corner radius,
the rendered height of any button or pill.
4. For anything that looks off, confirm it numerically before reporting it — read
getComputedStyle / getBoundingClientRect for that element, and get the Figma
number from get_design_context or the node geometry. Report the two numbers.
5. Content differences (wording, length, line counts) are out of scope and are not
mismatches. Only styling counts.

${HOWTO}

Return every mismatch with both numbers. An empty diffs array is the right answer
when the region matches — do not manufacture findings.`,
{ label: `inspect:${region.name}@${bp}`, phase: 'Inspect', schema: FINDINGS, model: 'sonnet' },
),
// Stage callbacks receive (previousResult, originalItem) — `region`/`bp` live on
// the item, not in the first stage's closure.
(found, { region, bp }) => {
if (!found || !found.stylesheetLoaded || !found.diffs.length) return found
// Verify each mismatch independently — a vision model comparing two images
// invents differences, and a false positive costs a wrong edit.
return parallel(
found.diffs.map((d) => () =>
agent(
`Try to REFUTE this reported Figma/code mismatch on ${baseUrl}${route} at ${found.breakpoint}px.

element: ${d.element}
property: ${d.property}
figma: ${d.figma}
rendered: ${d.rendered}

Re-measure the rendered value yourself, and re-read the Figma value from node
${region.nodes[bp]}.
Set real:false if the numbers actually agree, if the difference is sub-pixel or a
rounding artefact, if it is a content difference rather than a styling one, or if it
sits at a breakpoint outside ${found.breakpoint}px. Default to real:false when unsure.
When real:true, give the concrete fix.

${HOWTO}`,
// Sonnet, not Opus: the rule set is fixed and the failure mode is skipping
// the re-measurement rather than reasoning wrong about it — effort buys more
// here than tier does.
{ label: `confirm:${d.property}`, phase: 'Confirm', schema: VERDICT, model: 'sonnet', effort: 'high' },
).then((v) => ({ ...d, region: found.region, breakpoint: found.breakpoint, verdict: v })),
),
)
},
)

const flat = results.flat().filter(Boolean)
const unstyled = flat.filter((r) => r.stylesheetLoaded === false)
const confirmed = flat.filter((r) => r.verdict && r.verdict.real)

if (unstyled.length) {
log(`${unstyled.length} region(s) rendered unstyled — rebuild and restart the server, then re-run`)
}
log(`${confirmed.length} confirmed mismatch(es)`)

const frozen = !frozenAt
? null
: await agent(
`Confirm the frozen breakpoint did not move. Load ${baseUrl}${route} at ${frozenAt}px and
read computed styles for the page title, body text, headings, and any button: font-size,
line-height, letter-spacing, font-weight, and the gaps between adjacent blocks.

Report whether anything looks like it was disturbed by a change intended for narrower
widths — e.g. a value that now matches the mobile or tablet spec instead of its own.

${HOWTO}`,
{ label: `frozen@${frozenAt}`, phase: 'Confirm', model: 'sonnet', effort: 'medium' },
)

// Per-finding agents each see one diff, so none of them can notice that the set forms
// a pattern — that two frames disagree, that a value is page-specific, that the obvious
// fix would regress a sibling route. That judgment is the whole point of this stage, and
// it is the one place in the workflow where reasoning depth pays for itself.
const synthesis = !confirmed.length
? null
: await agent(
`${confirmed.length} Figma/code mismatches on ${route} survived adversarial verification:

${JSON.stringify(confirmed.map((c) => ({ region: c.region, bp: c.breakpoint, element: c.element, property: c.property, figma: c.figma, rendered: c.rendered, fix: c.verdict.fix })), null, 1)}

Read them as a set, not one at a time, and answer:

1. Do any of these contradict each other across breakpoints or regions — the same
property wanting different values? That usually means the frames themselves drifted
apart, and the value is page-specific rather than global. Say which.
2. What is the blast radius? Find the file each fix would touch and check what else
renders through it. Name any route that is currently correct and would be moved by
the obvious fix.
3. Which fixes are one shared change, and which must be scoped per page?
4. Anything here that is not worth fixing — a sub-pixel artefact, or a Figma value that
looks like a mistake in the frame rather than a spec?

Ground every claim by reading the actual source. Report what you verified, and flag
anything you could not.`,
{ label: 'synthesize', phase: 'Synthesize', model: 'opus' },
)

return {
route,
confirmed,
synthesis,
unstyledRegions: unstyled.map((r) => `${r.region}@${r.breakpoint}`),
frozenBreakpointReport: frozen,
clean: confirmed.length === 0 && unstyled.length === 0,
}
49 changes: 49 additions & 0 deletions skills/figma-parity/references/figma-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Reading values out of Figma

The Figma desktop app must be running with the file open. Tools are `mcp__figma-dev-mode__*`; load them with `ToolSearch` if deferred.

## Get the node IDs

Each ticket's description and attachments carry its frame URL:

```
https://www.figma.com/design/<fileKey>/<name>?node-id=1948-107545&m=dev
```

`node-id=1948-107545` → nodeId `1948:107545`. Mobile and tablet are separate frames on separate tickets.

## Pull geometry first

`get_metadata` on the frame returns the whole tree as `x`/`y`/`width`/`height`. This is the cheap, complete source — one call per frame covers nearly the entire checklist. Large frames persist to a file; slice them with python rather than re-requesting.

Decode it:

| Want | From |
| --- | --- |
| font-size of a text node | `height ÷ lines ÷ line-height` — a 26px-tall single-line node at 1.2 leading is 22px type |
| line count | compare `height` against the single-line height of its neighbours |
| gap between two blocks | `next.y − (prev.y + prev.height)` |
| content inset | child `x` within a full-width container |
| column width | child `width` |
| section padding | container `height` minus the span its children occupy |

Sibling y-gaps are exact and need no second call. A frame whose children sit at `x=80` in an 840-wide container has an 80px inset, whichever gutter the header uses.

## Confirm named styles

`get_design_context` on two or three representative nodes — the page title, one section heading, one body paragraph — returns the reference markup plus a line like:

```
Tablet/Headlines/H1: Font(family: "Aeonik", style: Medium, size: 48, weight: 500, lineHeight: 1, letterSpacing: 0)
```

That is the authority for family, weight, and letter-spacing, which geometry cannot give you. It also reveals inline weight changes — clause numbers set in Medium inside a Regular paragraph, for instance.

Do not call it on the whole frame; the response is truncated to metadata and you lose the style block.

## Watch for

- **Copy-pasted frames.** A frame whose body text belongs to a different page was duplicated and not updated. Its values are unreliable — say so rather than building to them.
- **Mislabelled frames.** Tickets sometimes note this themselves ("the desktop frame is mislabelled …").
- **Hidden nodes.** `hidden="true"` in the metadata — excluded from layout, so skip them.
- **A design system with tokens already in the codebase.** Check `globals.css` `@theme` before writing a value: `--text-h-mobile: 2.5rem` and `--leading-body: 1.4` may already be exactly the numbers the frame specifies. Using the token beats a raw value and satisfies the canonical-classes rule.
Loading
Loading