norma-scope
v0.10.1
Published
Compare your implementation screenshots against Figma designs with a zero-friction, local pre-commit report.
Maintainers
Readme
Normascope
Verify that what you shipped matches what you intended.
The intent can be a design file, yesterday's approved build, or the mock your AI agent was handed. Normascope captures your running UI, compares honestly, and puts the result where your team works — as a local, self-contained report today, and in your PRs next. No servers, no LLM, no blocking.
What people use it for
- Visual regression — catch unintended UI changes in every PR. Approve a known-good build as your baseline and every later run diffs against it. (Baseline mode ships in the current release cycle.)
- Design fidelity — ship what was designed. Compare your implementation against the design source of truth, with a diff tuned so anti-aliasing noise doesn't drown out real drift.
- Agent verification — give your coding agent eyes. Point Normascope at the mock your agent was handed and let it verify its own work. (MCP server ships in the current release cycle.)
Design sources
Normascope compares against reference images, wherever they come from:
- Figma — frames exported straight from your file (available today; the flows documented below)
- Image folders — any directory of reference PNGs, no design tool required (current release cycle)
- URLs — capture a second environment as the reference, e.g. production vs staging (current release cycle)
Free and paid
The CLI — everything in this README — is Normascope: free, open, local-first, forever. Normascope Cloud is the upcoming paid tier for teams: hosted reports, trends over time, and PR workflow features. Nothing in the free CLI is ever gated.
Install
npx norma-scope initRun this once per project. It walks you through connecting your design source and picking which frames to track.
For a practical guide to every command, scenario, prerequisite, generated file, and troubleshooting path, see USER-GUIDE.md. For the shorter command-by-command reference, see COMMANDS.md.
Two ways to use Normascope
Normascope has two distinct pathways. You can use the original manual screenshot flow, or the newer Auto flow that captures your running app for you. They share the same Figma config, screenshot folder, diff engine, and report.
Path 1 — Manual screenshots
Use this when you want full control over what gets captured, your app is not easy to run locally, or the UI state is easier to prepare by hand.
1. npx norma-scope init
2. Build your feature
3. Take a screenshot at the exact dimensions init printed
4. Drop it into .bridge/screenshots/
5. git commit
→ compare runs from the pre-commit hook
→ .bridge/reports/report.html is generated
6. Open the report and share if neededPath 2 — Auto screenshots
Use this when your app is running locally or on a preview URL and you want Normascope to capture screenshots automatically.
1. npx norma-scope init
→ optionally enter app.baseUrl during setup
2. Add or refine per-frame route/selector fields in .bridge/config.json
3. Start your app
4. npx norma-scope check
→ auto captures screenshots
→ compare diffs them against Figma
→ .bridge/reports/report.html is generated
5. Open the report and share if neededFrames without a route stay on the manual path. Frames with route/selector can use Auto. Both pathways can live in the same project at the same time.
The pre-commit hook installed by init never blocks a commit — it only generates the report. If your Figma token is missing or invalid, the hook skips the comparison and prints a warning, but your commit still goes through.
Screenshot naming convention
Screenshot filenames are the lowercased, hyphenated version of the matching Figma frame name. init tells you the exact filenames it expects:
Figma frame name Screenshot filename
──────────────── ───────────────────
"Hero Section" → hero-section.png
"Primary Button" → primary-button.png
"Nav Bar" → nav-bar.pngPath 1 in detail: Manual screenshots
Normascope compares images pixel-for-pixel from the top-left corner — it doesn't understand scrolling or page sections. So your screenshot needs to be a full-page capture at the exact dimensions init showed you (e.g. 1440×10661px), not just what's visible in your browser viewport. A partial screenshot (like just the footer) will get compared against the top of the design and produce a meaningless, huge diff %.
Chrome / Edge (Mac and Windows — same steps, since this is a browser feature, not an OS one):
- Open the page and resize your browser window to match the frame's width (e.g. 1440px wide)
- Open DevTools (
Cmd+Option+Ion Mac,Ctrl+Shift+Ion Windows) - Open the Command Menu (
Cmd+Shift+Pon Mac,Ctrl+Shift+Pon Windows) - Type "screenshot" and choose Capture full size screenshot
- Chrome downloads a PNG of the entire page, full height, no scrolling needed
Firefox (Mac and Windows):
- Right-click anywhere on the page and choose Take Screenshot (or press
Shift+F2, typescreenshot --fullpage, then Enter) - Choose Save full page
Browser extensions (any browser, if you prefer a permanent toolbar button):
- GoFullPage (Chrome) or FireShot (Firefox) both export a single full-page PNG in one click
Once you have the PNG, drop it into .bridge/screenshots/ with the exact filename init printed for that frame, then git commit as normal.
Path 2 in detail: Automatic screenshots
Instead of screenshotting by hand for every frame, Normascope can capture your running app for you:
npx norma-scope autoauto opens your app in a headless Chromium (via Playwright), captures each configured frame, and saves the PNGs into .bridge/screenshots/ — the same place manual screenshots go. The existing compare pipeline then works on them without any special handling.
Manual screenshots still work exactly as before. Automatic capture is opt-in, per frame: frames without Auto config are simply skipped by auto and expected to be supplied manually.
To enable it:
- Start your app first. Normascope does not start dev servers — it assumes your app is already running (this is a V2.0 limitation).
- Add
app.baseUrland per-frame capture fields to.bridge/config.json:
{
"figmaFileKey": "abc123XYZ",
"threshold": 5,
"app": {
"baseUrl": "http://localhost:3000"
},
"frames": [
{
"label": "Hero Section",
"screenshot": "hero-section.png",
"figmaFrameId": "12:34",
"width": 1440,
"height": 900,
"viewport": { "width": 1440, "height": 900 },
"route": "/",
"selector": "[data-norma='hero-section']"
},
{
"label": "Pricing Page",
"screenshot": "pricing-page.png",
"figmaFrameId": "56:78",
"route": "/pricing",
"capture": "fullPage",
"viewport": { "width": 1440, "height": 1200 }
},
{
"label": "Manual Only Modal",
"screenshot": "manual-only-modal.png",
"figmaFrameId": "90:12"
}
]
}- Run
npx norma-scope auto, thennpx norma-scope compare— or both at once:
npx norma-scope checkCapture fields
| Field | Meaning |
|---|---|
| app.baseUrl | Where your app is running. Required for auto. Usually http://localhost:PORT, but a deployed/public URL also works for verification. |
| route | Which page to open (baseUrl + route). No route = manual frame, skipped by auto. |
| selector | CSS selector of the DOM element to capture. A dedicated attribute like data-norma="hero-section" makes this stable: "selector": "[data-norma='hero-section']". |
| capture | "selector", "viewport", or "fullPage". Defaults to "selector" when a selector is set, otherwise "viewport". |
| width / height | The Figma frame's dimensions — init fills these in automatically. |
| viewport | Browser size for the capture. init defaults it to the Figma frame dimensions, so your capture and the design export are the same size — comparing images of different sizes is the most common source of meaningless, huge diff percentages. Selection order: viewport → width/height → 1440×900 with a warning. |
| waitForSelector | Optional: wait for this selector before capturing (app readiness). |
| waitMs | Optional: extra fixed delay in ms — use sparingly, for animations. |
You still tell Normascope explicitly which route/selector maps to which Figma frame — it does not infer DOM ↔ Figma matching on its own. auto never blocks: unreachable app, missing selector, or missing app.baseUrl all just print a warning and exit 0.
During init, one optional question ("Is your app running locally or deployed for preview?") writes app.baseUrl and gives every selected frame a starter route: "/" — so the common setup path needs no hand-edited JSON. You only edit the config to fine-tune routes and selectors.
Checking your setup
npx norma-scope doctordoctor validates everything before you capture or compare: config parses, Figma token is valid, the file and every configured frame ID exist, app.baseUrl is reachable, every route loads, every selector resolves, and a usable browser is available. It reports pass/warn/fail per check and exits non-zero only on hard failures — it diagnoses, it doesn't fix anything itself.
How comparison works (and what the numbers mean)
Normascope's diff is deterministic pixel comparison (pixelmatch) — it is not semantically intelligent. V2.1 tunes it so noise doesn't drown out signal:
- Anti-aliasing-aware: anti-aliased pixel differences (subpixel text rendering, browser vs Figma rasterization) are detected and excluded from the mismatch percentage. The raw percentage (including AA) is available as a tooltip on the report's diff badge.
- Significant regions: mismatched pixels are clustered into regions, and each component row shows a count (e.g. "3 significant regions"). Tiny isolated specks are ignored; a real layout shift shows up as one or more concrete regions.
- Same-size captures: because
autocaptures at the Figma frame's own dimensions, the percentage reflects visual differences rather than canvas-size mismatch.
Browser requirement
Normascope uses playwright-core and does not download a browser on install. auto and doctor look for a browser in this order: installed Chrome → installed Edge → NORMA_BROWSER_PATH env var → Playwright's bundled Chromium. Most machines already have Chrome, so usually nothing is needed; if no browser is found:
npx playwright install chromiumRunning manually
The pre-commit hook runs this for you automatically, but you can also run it by hand any time:
npx norma-scope compareAdd --fresh to bypass the local Figma cache and force a re-fetch — use this when your designer has pushed changes to a tracked frame:
npx norma-scope compare --freshNormascope caches Figma frame exports locally and by default never re-fetches them automatically (cacheMode: "aggressive"). This protects free-tier Figma tokens from quota exhaustion — the pre-commit hook fires on every commit, and a short cache window would burn through the API allowance fast. --fresh is the intentional trigger for picking up design changes.
To capture screenshots automatically and compare in one go (your app must already be running):
npx norma-scope checkCleaning up
npx norma-scope cleanEmpties .bridge/screenshots/, .bridge/diff/, .bridge/reports/, and .bridge/.cache/. Useful when switching to a different Figma file or reclaiming disk space — .bridge/config.json is never touched.
Note: clean wipes the Figma export cache, so the next compare will re-fetch from Figma. If you only want updated design exports without clearing everything, use compare --fresh instead.
Config
init writes .bridge/config.json, which is committed to your repo:
{
"figmaFileKey": "abc123XYZ",
"threshold": 5,
"cacheMode": "aggressive",
"frames": [
{
"label": "Hero Section",
"screenshot": "hero-section.png",
"figmaFrameId": "12:34"
}
]
}threshold is the mismatch percentage above which a component is flagged in the report — it's a visual flag, not a blocker.
cacheMode controls when Normascope re-fetches Figma frame exports:
| Value | TTL | Use when |
|---|---|---|
| aggressive (default) | No expiry | Free-tier tokens, stable design files. Zero API calls after first fetch. |
| mid | 24 hours | Designers ship daily updates. |
| normal | 4 hours | Fast-moving teams with frequent frame changes. |
compare --fresh always bypasses the cache regardless of mode.
Your Figma personal access token is stored in .env.local, which is never committed.
Report size
The report embeds downscaled JPEG thumbnails and stays self-contained; a typical run targets under 5MB (not a hard guarantee — very many or very tall frames can exceed it, and the terminal will say so). When everything fits, the lightbox gets full-resolution images automatically; otherwise it uses the thumbnails and the full-res PNGs remain in .bridge/screenshots/ and .bridge/diff/. To force full resolution into the report:
npx norma-scope compare --fullWhat's not committed
.bridge/screenshots/
.bridge/reports/
.bridge/diff/
.bridge/.cache/
.env.localOnly .bridge/config.json is committed — everything else is local to your machine.
Normascope is screenshot diffing — manual or automatic capture — local and free, deterministic, with no LLM involved. Reports are generated locally and never block commits or CI.
Want hosted reports, trends, and team workflow on top of this? That's Normascope Cloud, coming soon.
Command reference
| Command | What it does |
|---|---|
| npx norma-scope init | One-time setup: connect Figma file, pick frames, install pre-commit hook |
| npx norma-scope doctor | Validate config, token, frame IDs, app URL, selectors, browser |
| npx norma-scope auto | Capture screenshots from your running app |
| npx norma-scope compare | Fetch Figma exports (from cache), diff, generate report |
| npx norma-scope compare --fresh | Same, but re-fetch Figma exports (designer pushed changes) |
| npx norma-scope compare --full | Same, but embed full-resolution images in the report |
| npx norma-scope check | auto + compare in one command |
| npx norma-scope clean | Wipe screenshots, diffs, reports, and Figma export cache |
| npx norma-scope upload | Send the last run to Normascope Cloud. Paid, explicit, never automatic — see COMMANDS.md |
Day-to-day workflows
Manual screenshots (always available):
1. npx norma-scope init once per project
2. Build your feature
3. Take a full-page screenshot at the exact dimensions init printed
4. Drop it into .bridge/screenshots/hero-section.png
5. git commit
→ pre-commit hook fires
→ .bridge/reports/report.html generates
→ commit lands regardless of the diff result
6. Open report.html — share with your designer if neededAutomatic screenshots (optional, requires a running app):
1. npx norma-scope init once per project
2. Add app.baseUrl + route/selector to .bridge/config.json
3. Build your feature and start your app
4. npx norma-scope check
→ captures screenshots from the running app
→ diffs against Figma, generates report
5. Open report.html — share with your designer if neededFrames without a route stay manual. Both workflows mix freely in one config.
Picking up a designer's changes:
npx norma-scope compare --fresh # re-fetches Figma exports, re-diffs, new reportLicense
Apache-2.0 — see LICENSE and NOTICE. "Normascope" is a trademark; the license grants no rights to the name or logo.
Normascope is a product of the Norma family, Built and maintained by Harsha Attray.
