From cd7bfa99f562f9ddffbfb218ce692035ac0ed138 Mon Sep 17 00:00:00 2001 From: sameerroboto Date: Thu, 30 Jul 2026 15:25:53 +0530 Subject: [PATCH 1/2] feat(skills): add figma-parity skill Measures a page's mobile (393px) and tablet (840px) rendering against its Figma frames and closes the gap, with desktop frozen as a signed-off baseline. Seven steps: resolve the ticket pair, read the spec out of Figma, measure the built page, report the drift table, fix below the frozen breakpoint, prove the numbers, then run a visual pass for what the checklist never thought to measure. Ships an engine workflow that fans out one agent per region x breakpoint, adversarially re-verifies every reported mismatch, and synthesises the survivors into shared-vs-page-specific calls. --- skills/figma-parity/SKILL.md | 111 ++++++++++ .../engine/figma-parity-verify.js | 209 ++++++++++++++++++ .../figma-parity/references/figma-values.md | 49 ++++ .../figma-parity/references/fix-patterns.md | 79 +++++++ skills/figma-parity/references/measuring.md | 93 ++++++++ 5 files changed, 541 insertions(+) create mode 100644 skills/figma-parity/SKILL.md create mode 100644 skills/figma-parity/engine/figma-parity-verify.js create mode 100644 skills/figma-parity/references/figma-values.md create mode 100644 skills/figma-parity/references/fix-patterns.md create mode 100644 skills/figma-parity/references/measuring.md diff --git a/skills/figma-parity/SKILL.md b/skills/figma-parity/SKILL.md new file mode 100644 index 0000000..bccfa37 --- /dev/null +++ b/skills/figma-parity/SKILL.md @@ -0,0 +1,111 @@ +--- +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: "/engine/figma-parity-verify.js", args: { + baseUrl: "http://localhost:3100", + route: "/terms", + frozenAt: 1440, + 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. diff --git a/skills/figma-parity/engine/figma-parity-verify.js b/skills/figma-parity/engine/figma-parity-verify.js new file mode 100644 index 0000000..dde18de --- /dev/null +++ b/skills/figma-parity/engine/figma-parity-verify.js @@ -0,0 +1,209 @@ +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' } }] +// } +const { baseUrl, route, regions, frozenAt } = args +const BREAKPOINTS = ['393', '840'] + +const PLAYWRIGHT = + '/Users/sameer/Code/work/gc-web/node_modules/.pnpm/playwright@1.61.1/node_modules/playwright/index.mjs' + +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("${PLAYWRIGHT}") + const b = await chromium.launch(); const p = await b.newPage() + await p.setViewportSize({ width: , height: 1200 }) + await p.goto("${baseUrl}${route}", { waitUntil: "domcontentloaded" }) + await p.locator("").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, +} diff --git a/skills/figma-parity/references/figma-values.md b/skills/figma-parity/references/figma-values.md new file mode 100644 index 0000000..5a0a059 --- /dev/null +++ b/skills/figma-parity/references/figma-values.md @@ -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//?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. diff --git a/skills/figma-parity/references/fix-patterns.md b/skills/figma-parity/references/fix-patterns.md new file mode 100644 index 0000000..a8c457e --- /dev/null +++ b/skills/figma-parity/references/fix-patterns.md @@ -0,0 +1,79 @@ +# Applying the fix + +## Keep desktop frozen + +Scope every override below `lg`, and add an `lg:` reset wherever today's desktop value differs from the value you introduce: + +``` +mb-14 md:mb-16 lg:mb-12 /* mobile 56, tablet 64, desktop keeps its 48 */ +gap-x-4 md:gap-x-6 lg:gap-x-8 +text-h-mobile md:text-5xl lg:text-7xl tracking-normal lg:tracking-tight +``` + +For CSS blocks, wrap the whole thing so it simply does not exist at desktop: + +```css +@media (width < 64rem) { … } +``` + +An `lg:` reset that restores a value which looks wrong beside its tablet counterpart — desktop with *less* space than tablet — is worth flagging to the user. It usually means the desktop frame was never measured. Preserve it as instructed and say so. + +## Beating the typography plugin + +`@tailwindcss/typography` emits low-specificity `:where()` rules inside `@layer utilities`. Unlayered CSS in `globals.css` beats all of it regardless of specificity, which is why the existing table styles sit unlayered. Put the parity rules there too. + +Its `prose-*` modifiers cannot express a flat scale — every size and margin is em-relative to the prose root, so `prose-headings:*` utilities fight the plugin rather than replace it. State the values outright instead. + +## Reset at any depth, set explicitly + +Direct-child selectors miss content wrapped in an authored `
`, and MDX bodies routinely contain them. Reset broadly at zero specificity, then set narrowly: + +```css +.doc :where(p, ul, ol, div, h2) { /* :where() = 0 specificity, so every rule below wins */ + margin-block: 0; + font-size: 1rem; + line-height: var(--leading-body); +} +.doc > * + *, +.doc :is(p, ul, ol) + :is(p, ul, ol) { margin-top: 1rem; } +.doc > h2 { margin-top: 2.5rem; margin-bottom: 0.75rem; } +``` + +Without `:where()` on the reset you end up counting specificity between overlapping `:is()` lists, and a more-specific reset silently wins over the rule meant to override it. + +## Margin collapse eats the smaller value + +Adjacent siblings collapse to `max(bottom, top)`. A 12px heading margin-bottom against a 16px paragraph margin-top renders 16px — the value you wrote never appears. Let one side own the gap: + +```css +.doc > h2 + :is(p, ul, ol, div) { margin-top: 0; } +``` + +Always confirm the fix with `gap()` from the measuring harness rather than re-reading the declaration. + +## Selecting structural position + +Some rhythms distinguish blocks by where they sit, not what they are — lead-in paragraphs before the first heading spaced differently from clauses under one. Both are plain `

` siblings: + +```css +.doc > *:has(~ h2:first-of-type) + * { margin-top: 2.5rem; } +``` + +"Still has the first heading ahead of it" = the lead-in. This beats restructuring the content, which would mean editing every MDX file in the collection. + +## Per-page values + +When sibling frames disagree, the value is page-specific. Put it behind a config flag rather than generalizing one page's number across a shared renderer: + +```ts +// LegalDocConfig +spaciousProse?: boolean; // documented with *why* the frames differ +``` + +Then `cn("shared-classes", flag && "variant-classes")`. Default to the value the majority of verified frames use, and leave the odd one out opted in. + +## Reaching for a token + +The canonical-classes rule bites hardest here, because Figma hands you raw pixels. Before writing one, check `globals.css` `@theme` — the value is often already a token: `--text-h-mobile` is exactly 40px/1, `--leading-body` exactly 1.4, `--leading-heading` exactly 1.2. `leading-heading` satisfies the rule where `leading-[1.2]` violates it, and says more. + +Comment *why* a value is what it is, especially a per-page flag or an `lg:` reset. Both look like redundancy to the next reader, who will otherwise "simplify" them back into a bug. diff --git a/skills/figma-parity/references/measuring.md b/skills/figma-parity/references/measuring.md new file mode 100644 index 0000000..715f356 --- /dev/null +++ b/skills/figma-parity/references/measuring.md @@ -0,0 +1,93 @@ +# Measuring the rendered page + +Computed styles from a headless browser. Screenshots confirm nothing numeric — a 2px leading error is invisible and is exactly what these tickets are about. + +## Serve a fresh build + +```bash +pnpm build +PORT=3100 pnpm start +``` + +Measure a production build, not `pnpm dev`. Pick a port you own — 3000 is often taken by another project, and `curl` will happily return 200 from *its* server. + +### The stale-build trap + +Rebuilding while a server is running replaces `.next`, and the running server keeps serving HTML that points at a CSS hash that no longer exists. The page loads, returns 200, and renders **completely unstyled** — every measurement comes back plausible-looking and uniformly wrong (all pages identical, `line-height: normal`, `textLeft: 8`). + +So after every rebuild, kill by port and confirm the stylesheet resolves: + +```bash +lsof -nP -iTCP:3100 -sTCP:LISTEN -t | xargs -r kill -9 +sleep 2 +PORT=3100 pnpm start & +sleep 8 +CSS=$(curl -s http://localhost:3100/ | grep -oE '/_next/static/css/[^"]+\.css' | head -1) +curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:3100$CSS" # must be 200, and $CSS must be non-empty +``` + +If a sweep returns identical numbers for pages you know differ, suspect this before believing the data. + +## Read computed styles + +Playwright is present but not linked at the repo root; import it by path: + +```js +const { chromium } = await import( + "/Users/sameer/Code/work/gc-web/node_modules/.pnpm/playwright@1.61.1/node_modules/playwright/index.mjs" +); + +const browser = await chromium.launch(); +const page = await browser.newPage(); +const out = {}; + +for (const w of [393, 840, 1440]) { + await page.setViewportSize({ width: w, height: 1200 }); + await page.goto("http://localhost:3100/", { waitUntil: "domcontentloaded" }); + out[w] = await page.evaluate(() => { + const n = (el, p) => Math.round(parseFloat(getComputedStyle(el)[p]) * 100) / 100; + // Rendered gap between two blocks, after margin collapse — not the declared margin. + const gap = (a, b) => + Math.round((b.getBoundingClientRect().top - a.getBoundingClientRect().bottom) * 100) / 100; + + const root = document.querySelector(""); + const kids = [...root.children]; + const h2 = root.querySelector("h2"); + const after = kids[kids.indexOf(h2) + 1]; + + return { + inset: Math.round(root.getBoundingClientRect().left + parseFloat(getComputedStyle(root).paddingLeft)), + colWidth: Math.round(root.getBoundingClientRect().width - parseFloat(getComputedStyle(root).paddingLeft) * 2), + bodySize: n(after, "fontSize"), + bodyLeading: n(after, "lineHeight"), + h2Size: n(h2, "fontSize"), + h2Weight: getComputedStyle(h2).fontWeight, + h2Tracking: getComputedStyle(h2).letterSpacing, + h2ToBody: gap(h2, after), + }; + }); +} + +await browser.close(); +console.log(JSON.stringify(out, null, 1)); +``` + +`waitUntil: "domcontentloaded"` — `networkidle` times out on pages with long-polling or embedded media. + +Use `gap()` rather than reading `marginTop`, so the number reflects margin collapse and any `lg:` override actually in force. + +Sweep sibling routes in the same run when a shared renderer is involved — that is how blast radius gets measured instead of assumed. + +## Interpreting a surprise + +When a measurement contradicts what the CSS says, walk the DOM before editing: + +```js +[...root.children].slice(0, 6).forEach((el) => { + const cs = getComputedStyle(el); + console.log(el.tagName, cs.fontSize, cs.lineHeight, cs.marginTop, cs.marginBottom, + JSON.stringify(el.textContent.slice(0, 30))); +}); +``` + +Descend one level into any wrapper element. Content nested inside an authored `

` is the usual explanation for a rule that appears to have no effect. From 9156191de77be24fa2da4ee51707d6a40be203a0 Mon Sep 17 00:00:00 2001 From: sameerroboto Date: Thu, 30 Jul 2026 15:29:25 +0530 Subject: [PATCH 2/2] fix(skills): take the playwright path as an arg, not a hardcoded one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine embedded one machine's absolute path to a gc-web playwright checkout, and measuring.md repeated it. Neither resolves anywhere else. The path still has to be absolute — playwright is usually a transitive dep and a bare specifier does not resolve from an agent's cwd — so the caller resolves it via require.resolve and passes it in as `playwrightPath`. The engine throws with that command in the message when it is missing, rather than failing later inside an agent's import. --- skills/figma-parity/SKILL.md | 3 +++ .../figma-parity/engine/figma-parity-verify.js | 16 ++++++++++++---- skills/figma-parity/references/measuring.md | 12 ++++++++---- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/skills/figma-parity/SKILL.md b/skills/figma-parity/SKILL.md index bccfa37..608d0be 100644 --- a/skills/figma-parity/SKILL.md +++ b/skills/figma-parity/SKILL.md @@ -77,6 +77,9 @@ Workflow({ scriptPath: "/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: "", regions: [ { name: "header", selector: "article > header", nodes: { "393": "1948:107772", "840": "1948:107547" } }, diff --git a/skills/figma-parity/engine/figma-parity-verify.js b/skills/figma-parity/engine/figma-parity-verify.js index dde18de..78da010 100644 --- a/skills/figma-parity/engine/figma-parity-verify.js +++ b/skills/figma-parity/engine/figma-parity-verify.js @@ -14,12 +14,20 @@ export const meta = { // 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 } = args +const { baseUrl, route, regions, frozenAt, playwrightPath } = args const BREAKPOINTS = ['393', '840'] -const PLAYWRIGHT = - '/Users/sameer/Code/work/gc-web/node_modules/.pnpm/playwright@1.61.1/node_modules/playwright/index.mjs' +// 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', @@ -64,7 +72,7 @@ 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("${PLAYWRIGHT}") + const { chromium } = await import("${playwrightPath}") const b = await chromium.launch(); const p = await b.newPage() await p.setViewportSize({ width: , height: 1200 }) await p.goto("${baseUrl}${route}", { waitUntil: "domcontentloaded" }) diff --git a/skills/figma-parity/references/measuring.md b/skills/figma-parity/references/measuring.md index 715f356..7c7beec 100644 --- a/skills/figma-parity/references/measuring.md +++ b/skills/figma-parity/references/measuring.md @@ -30,12 +30,16 @@ If a sweep returns identical numbers for pages you know differ, suspect this bef ## Read computed styles -Playwright is present but not linked at the repo root; import it by path: +Playwright is usually a transitive dep and not linked at the project root, so a bare +`playwright` specifier does not resolve. Resolve the absolute path once, in the project +under test, and import that: + +```bash +node -e "console.log(require.resolve('playwright'))" +``` ```js -const { chromium } = await import( - "/Users/sameer/Code/work/gc-web/node_modules/.pnpm/playwright@1.61.1/node_modules/playwright/index.mjs" -); +const { chromium } = await import(""); const browser = await chromium.launch(); const page = await browser.newPage();