diff --git a/.agents/skills/design-diff/SKILL.md b/.agents/skills/design-diff/SKILL.md new file mode 100644 index 0000000..107b35f --- /dev/null +++ b/.agents/skills/design-diff/SKILL.md @@ -0,0 +1,152 @@ +--- +name: design-diff +description: 'Measure how closely a live web page matches a design, pixel for pixel, and iterate until it matches. Use this whenever the user builds or checks a UI against a design, a PNG or a Figma frame, closes a design handoff, chases visual drift, verifies a page looks like a mockup, or gates a build on visual match, even when they never say design-diff by name. Drives the design-diff CLI and library. Read matchPercent and diffBounds, aim the next edit at the box, repeat.' +argument-hint: ' and a design source, --png or Figma --file and --frame' +--- + +# Design Diff, a visual feedback loop + +Overlay a design on a screenshot of a live page at the same pixel size and get back two numbers you act on. + +- **`matchPercent`**, how close the page is to the design. Read it to know when to stop. +- **`diffBounds`**, the box in CSS pixels around what is still wrong. Aim the next edit here. + +Run it, read the score, fix what the box points at, run again, and stop when the score clears your bar. The rest of this skill makes those two numbers trustworthy. + +## When to use + +- Building a page or component from a design export or a Figma frame. +- Closing a design to code handoff, or hunting down visual drift. +- Verifying a built page matches a mockup before opening a PR. +- Gating CI on a minimum visual match. + +## Prerequisites + +- [Bun](https://bun.sh). Check with `bun --version`. The CLI runs via `bunx design-diff`. The first run downloads Chromium once, then every run is instant. +- A running page URL like `http://localhost:3000`, or a local screenshot PNG. +- A design source. A local PNG, or a Figma `fileKey` and `frameId` with `DESIGN_DIFF_FIGMA_TOKEN` set. Read-only access is enough. + +## The loop + +1. **Get a baseline.** Run the tool against the page and the design. Prefer `--json` so you can parse the result directly. + ```sh + bunx design-diff http://localhost:3000 --png design.png --scale 1 --json + ``` +2. **Read the two values** from the JSON. `matchPercent` is the headline score. `diffBounds` is `{ x, y, width, height }` in CSS pixels, or `null` when there is no diff. +3. **Diagnose from the shape**, not just the number. See the table below. +4. **Make one targeted edit** aimed at `diffBounds`, then run step 1 again. +5. **Stop** when `matchPercent` clears your bar. Real pages settle 1 to 2 points below 100 from anti-aliasing and font rendering, so pick a threshold instead of chasing a perfect score. + +### Reading the diff shape + +The shape is the diagnosis. Read it before you open any report. + +| Signal | Diagnosis | +| --- | --- | +| Tight box, low `coveragePercent` | One component is off. Wrong color, size, position, or spacing. | +| Box spanning the page, high `coveragePercent` | A global problem. A font that never loaded, a viewport or size mismatch, or a layout shift. | +| `readiness.fontsReady` is `false` | Captured before web fonts loaded. The score is not trustworthy yet. Wait for readiness, see below, and run again. | +| `diffBounds` is `null` | No differences at this threshold. | + +## Key flags + +``` +--png Design export (PNG). Viewport is derived from it. +--file --frame Pull the export straight from Figma instead of --png. +--actual Compare a local screenshot instead of visiting a url. No browser. +--scale Match the export. 1 for 1x, 2 for retina. Default 1. +--threshold <0..1> Per-pixel color sensitivity. Default 0.1. Lower is stricter. +--ignore Mask a fixed rectangle in CSS px. Repeatable. +--ignore-selector Mask every element matching a CSS selector. Repeatable. +--wait-for Hold capture until a selector appears. 15s timeout. Repeatable. +--auth Playwright storageState JSON for pages behind login. +--fail-under Exit 1 when matchPercent is below this. CI gate. +--json Print only the metrics object to stdout. +--annotate Also write annotated.png with the diff box drawn on the page. +--no-overlay Skip the HTML report and heatmap. Faster, metrics only. +--out Output dir. Default .design-diff. +--open Open the HTML report when done. +``` + +Full walkthrough in [How it works](../../../docs/HOW_IT_WORKS.md). See every flag with `bunx design-diff --help`. + +## Common scenarios + +**Tight agent loop, you already have a screenshot.** Skip the browser and diff two PNGs. Fast, offline, sandbox friendly. Bounds come back in image pixels and `--scale` is ignored. +```sh +bunx design-diff --actual screenshot.png --png design.png --json +``` + +**Pull the design from Figma.** The frame id is the one in the frame's URL, like `10-2`. +```sh +export DESIGN_DIFF_FIGMA_TOKEN=figd_your_token +bunx design-diff http://localhost:3000 --file abc123 --frame 10-2 --json +``` + +**Mask what is meant to change**, like avatars, timestamps, and live counters, so it never counts as a diff. Prefer `--ignore-selector` when the region moves or resizes. +```sh +bunx design-diff http://localhost:3000 --png design.png \ + --ignore-selector "[data-dynamic], time, .avatar" +``` + +**Wait for readiness** when the page fetches content late. It fails loudly if the selector never appears within 15s. +```sh +bunx design-diff http://localhost:3000 --png design.png --wait-for ".hero-loaded" +``` + +**Pages behind login.** Save a session once, then reuse it. No credentials touch the tool. +```sh +bunx playwright codegen --save-storage=auth.json https://your-app/login +# log in in the window, then close it +bunx design-diff https://your-app/dashboard --png design.png --auth auth.json +``` + +**CI gate.** Turn the visual match into pass or fail. +```sh +bunx design-diff http://localhost:3000 --png design.png --json --fail-under 98 +``` + +**Retina export.** Pass `--scale 2` so a 2x PNG is measured honestly. + +## Programmatic API, for looping in code + +The CLI is a thin wrapper over `designDiff`, which returns the same data written to `metrics.json`, including artifact paths. + +```ts +import { designDiff } from "design-diff"; + +const result = await designDiff({ + url: "http://localhost:3000", // or actual: "screenshot.png" for image-vs-image + design: "design.png", // or { fileKey, frameId } + scale: 1, + threshold: 0.1, + ignore: [{ selector: "[data-dynamic]" }, { x: 24, y: 24, width: 48, height: 48 }], + waitFor: ".hero-loaded", +}); + +if (result.matchPercent < 98) { + // aim the next edit at result.diffBounds, then run again +} +``` + +Looping over many pages? Reuse one browser instead of launching Chromium each time. +```ts +import { designDiff, launchBrowser } from "design-diff"; + +const browser = await launchBrowser(); +try { + for (const url of urls) { + await designDiff({ url, design: "design.png", browser }); + } +} finally { + await browser.close(); +} +``` + +## Gotchas + +- **`--fail-under` and `--threshold` are different.** `--fail-under` is a percentage gate on the whole-page `matchPercent`. `--threshold` is per-pixel color sensitivity passed to pixelmatch, where lower is stricter. Do not conflate them. +- **Same size or hard error.** The design and the page must share dimensions. A large mismatch fails by design, so match `--scale` to the export. +- **A high score is necessary, not sufficient.** A shifted component can hide inside a high match. Keep the diff box as the final check, plus the overlay or annotated image for a human. +- **`--actual` mode has no page.** So `--wait-for`, `--ignore-selector`, and `readiness` do not apply, and bounds are in image pixels. +- **Trust the score only when the page was caught ready.** When `readiness.fontsReady` is false, use `--wait-for` and run again before you act on the number.