norma-scope
v0.14.13
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 — a local, self-contained report, and a sticky comment on your pull request. 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.
- 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, via the
normascope-mcpserver.
Design sources
Normascope compares against reference images, wherever they come from:
- Figma — frames exported straight from your file
- Image folders — any directory of reference PNGs, no design tool required
- URLs — capture a second environment as the reference, e.g. production vs staging
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 asks what you compare against — a Figma file, a folder of PNGs, another URL, or nothing (baseline) — and which screens to track.
For a practical guide to every command, scenario, prerequisite, generated file, and troubleshooting path, see normascope.com/guide. For the command-by-command reference — every command, every flag, and what each one reads, writes, sends and spends — see normascope.com/commands.
Two ways to use Normascope
Normascope has two distinct pathways. You can supply screenshots by hand, or let capture take them from your running app. They share the same 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 — Automatic 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
→ capture takes screenshots
→ compare diffs them against the reference
→ .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 be captured automatically. 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 capturecapture 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 with no route are simply skipped by capture and expected to be supplied manually.
compare only scores a captured screenshot it can vouch for. Each capture run records what it captured in .bridge/screenshots/manifest.json. A frame is skipped, with the reason, when:
- its last capture failed, or that capture run never finished (the browser could not start, or the process crashed or was stopped);
- the page answered with the wrong HTTP status — by default anything other than 2xx, such as a 500 error page or a redirect that ends on a 404 (see
expectStatusbelow); - the page ended up somewhere other than the route it was asked for — by default a different origin, path or hash route, such as
/dashboardsending a signed-out browser to/login(seeexpectRoutebelow); - it was captured with a different URL, viewport, selector, wait, expected status or expected route than the config now gives it — including a different base URL from
NORMA_BASE_URLor a caller's override; - it was captured by a version before 0.14.7, which did not record the page's status or where the page ended up. This happens once: capture again;
- it was captured, but it no longer has both a
routeand a base URL (app.baseUrl, orNORMA_BASE_URL); - the screenshot changed after it was captured;
- the manifest is damaged, or is a link to a file that does not exist;
- the screenshot is a link to a file outside the project.
With a url source, the reference page is held to the same status and destination rules, against its own origin, and waits for the frame's waitForSelector and waitMs as the build does. A skip says which side failed: the build or the reference.
check also skips screenshots from any capture run but its own, such as one another capture wrote in the same checkout at the same time. Usually the fix is to run capture again; the skip reason says when it is not. Screenshots for frames with no route are compared as before, unless the manifest records an earlier capture of that frame. To use a hand-made screenshot for a frame the manifest records, delete the manifest first.
The CLI never writes its output under .bridge/ through a link. A link at an output file's name is replaced with the real file, and an output folder that links outside the project is refused.
To enable it:
- Start your app first. Normascope does not start dev servers — it assumes your app is already running.
- 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 capture, thennpx norma-scope compare— or both at once:
npx norma-scope checkCapture fields
| Field | Meaning |
|---|---|
| app.baseUrl | Where your app is running. Required for capture. 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 capture. |
| 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. Up to 60000 (one minute) per frame. |
| expectStatus | Optional: the HTTP status the page must have, for a page that is an error on purpose, such as a designed 404. One number, 200–299 or 400–599. Without it, the page must answer 2xx. Any other status skips the frame, so a crashed build's error page is never compared as your screen. The status checked is the one of the page in the picture, after redirects. |
| expectRoute | Optional: the route the page must end on, for a page that redirects on purpose, such as / → /en. Without it, the page must end on the origin, path and hash route it was asked for: query strings and plain #anchors are ignored, and so is a trailing slash. Joined to each side's own base URL, like route. A query parameter you write here is checked; any others are not. A destination that does not match skips the frame, and the reason names where the page went, without its query string. |
| referenceExpectStatus | Optional, url source only: the status the reference page must have, when it differs from the build's — for example a 404 page your build serves as a 404 and the reference host serves as a 200. Defaults to expectStatus. Not allowed on images or figma sources or on baseline frames, which have no reference page to load. |
Absent means the default; present and unusable is an error. A field you leave
out gets the default described above. A field you set to something Normascope
cannot honour — "capture": "fullpage" for "fullPage", a viewport with a
string width, a negative waitMs, a misspelled key like cloud.uplaod — fails
the config and says so, naming the frame and the field. It used to drop them
quietly, so the file said one thing and the run did another. A key Normascope
does not recognise at all is a warning rather than an error, so a config written
for a newer version still works on an older one; doctor and cloud validate
list them.
You still tell Normascope explicitly which route/selector maps to which Figma frame — it does not infer DOM ↔ Figma matching on its own. capture 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. It is tuned 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
capturecaptures 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. capture 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/, .bridge/context/ (the page text Explain reads) and .bridge/.cache/. That is all the local output Normascope generates. Useful when switching to a different Figma file or reclaiming disk space. It does not erase everything: .bridge/config.json, .bridge/baseline/ and .bridge/design/ are never touched (they are inputs you commit, and approved baselines are captures too, so delete them yourself if you mean to), and it cannot reach Git history or copies already published to Normascope Cloud.
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 zoomed view 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, free and deterministic. The comparison never involves an LLM: the score you get is pixels, every time, and it does not change because a model had a different day. Reports are generated locally and never block commits or CI.
explain --local is the one command that calls a model, and it is opt-in, runs under your own ANTHROPIC_API_KEY, and only ever produces hypotheses beside the score — it never changes the score. Nothing is sent to Normascope Cloud unless you run cloud publish.
Want hosted reports, trends, and team workflow on top of this? That's Normascope Cloud.
Command reference
All fourteen, grouped the way the CLI groups them. A bare group (cloud, frames, ci, baseline, design) prints its choices and does nothing.
Verbs — the everyday loop:
| Command | What it does |
|---|---|
| npx norma-scope init | One-time setup: choose the reference (Figma, PNGs, a URL or baseline), pick screens, install pre-commit hook |
| npx norma-scope doctor | Validate config, token, frame IDs, app URL, selectors, browser |
| npx norma-scope capture | 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 | capture + compare in one command |
| npx norma-scope clean | Wipe screenshots, diffs, reports, page context and cache |
Explain — why a frame drifted, as hypotheses to verify:
| Command | What it does |
|---|---|
| npx norma-scope explain --local | Analyse flagged frames with your own ANTHROPIC_API_KEY. Nothing is uploaded to Normascope Cloud; the evidence still reaches Anthropic under your key. Add a filename to analyse one frame |
Groups:
| Command | What it does |
|---|---|
| npx norma-scope cloud publish | Send the last run to Normascope Cloud. Explicit, never automatic. Add --explain to spend organization credits on a hosted analysis |
| npx norma-scope cloud validate | Preflight: will Cloud accept this project's identity and frames? Changes nothing |
| npx norma-scope cloud status | Is this project approved to upload, and where does an admin approve it? Sends no run data |
| npx norma-scope frames list | The frames this project tracks, with their stable ids |
| npx norma-scope frames adopt | Give an existing frame a stable id so it keeps one history across renames |
| npx norma-scope ci comment | Write the sticky pull-request comment from the last run |
| npx norma-scope baseline approve | Approve the current capture as the baseline future runs diff against |
| npx norma-scope design snapshot | Pin the current design exports so a designer's edit doesn't change a past comparison |
Full flags, and what each command reads, writes, sends and spends: normascope.com/commands.
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 — the LICENSE and NOTICE files ship in this package.
"Normascope" is a trademark of Harsha Attray, trading as Yutic; the license
grants no trademark rights beyond the limited uses in its Section 6. What you
may and may not do with the name is set out in the
trademark policy.
Normascope is operated by Harsha Attray, trading as Yutic.
Anonymous CLI analytics
Telemetry is currently release-gated off. NORMA_TELEMETRY=1 explicitly opts in for preview testing. norma telemetry status names all eight fields and the collection state. norma telemetry disable removes the local installation ID and permanently stops product events and service counts; NORMA_TELEMETRY=0 or DO_NOT_TRACK=1 suppress sending without changing saved preferences.
Normascope product analytics: random installation_id, cli_version (numeric release), platform (macos/windows/linux/other), day (UTC date), event_type (activation/usage/report_generated/publish_succeeded), report_generated (boolean), frame_bucket (none/1-10/11-50/51-200/201+), report_size_bucket (none/under-1MiB/1-10MiB/over-10MiB). No customer content, names, email, repository/org names, URLs, paths, commits, screenshots, source, DOM, Figma data, prompts, model output or API keys. No retained IP in analytics. Events expire after 90 UTC dates; no Cloud identity join. publish_succeeded means this CLI reported a publish it believed succeeded — it is not an account, a subscription or a payment, and no telemetry can grant Cloud access. Disable and remove the local ID: norma telemetry disable. After disabling, this CLI still sends an empty request carrying no identifier, version or platform, so we can count service volume; it cannot distinguish installations. To send nothing at all, set NORMA_TELEMETRY=0 or DO_NOT_TRACK=1. Policy: https://normascope.com/legal/privacy
The package-name aliases are npx norma-scope telemetry status and npx norma-scope telemetry disable.
