npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

styleproof

v6.2.2

Published

Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.

Readme

StyleProof

StyleProof's job is a deterministic way to see visual regressions on the frontend. Here's what a report looks like.

StyleProof opens the app states you care about in a real browser, compares the base and head by computed CSS, and posts the evidence to the pull request. Intentional changes get approved. Unexpected changes block.

npm version CI license

On a pull request the Action comment has to stop at a link. GitHub comments cannot carry the crops. The README can. This is the unmodified product report: Save at rest comes first, followed by Docs hover, focus, and active. Both sides of each interaction-state crop are in that state.

🗺️ StyleProof report

2 computed-style difference(s) · 3 state-delta difference(s) across 1 distinct change(s) in 1 changed surface base with an existing baseline. Surface base = one product UI state; capture keys with @width or live-state/popup variants are width or state captures of that base.

Element-level changes

button.btn · 1 element restyled

demo-button @ 900

padding 14px 28px18px 32px background-color #14b8a6#dc2626

before ◀ │ ▶ after

◀ before · after ▶ — demo-button @ 900

highlighted before ◀ │ ▶ after

🔍 magenta boxes mark each change — changed: button.btn

button.btn

Style:

| Property | Before | After | | ------------------ | ----------- | ----------- | | padding | 14px 28px | 18px 32px | | background-color | #14b8a6 | #dc2626 |

a.link · 1 element restyled :hover

demo-button @ 900

Both sides are :hover. Left is the old :hover. Right is the new :hover.

:hover color #a5f3fc#fca5a5

base :hover ◀ │ ▶ head :hover

◀ base :hover · head :hover ▶ — both sides are :hover

highlighted base :hover ◀ │ ▶ head :hover

🔍 magenta boxes mark each change — changed: a.link

a.link

Interactive-state changes:

| State | Property | Before → After | | -------- | -------- | --------------------- | | :hover | color | #a5f3fc#fca5a5 |

a.link · 1 element restyled :focus

demo-button @ 900

Both sides are :focus. Left is the old :focus. Right is the new :focus.

:focus outline-color #5eead4#fca5a5

base :focus ◀ │ ▶ head :focus

◀ base :focus · head :focus ▶ — both sides are :focus

highlighted base :focus ◀ │ ▶ head :focus

🔍 magenta boxes mark each change — changed: a.link

a.link

Interactive-state changes:

| State | Property | Before → After | | -------- | --------------- | --------------------- | | :focus | outline-color | #5eead4#fca5a5 |

a.link · 1 element restyled :active

demo-button @ 900

Both sides are :active. Left is the old :active. Right is the new :active.

:active color #2dd4bf#f87171

base :active ◀ │ ▶ head :active

◀ base :active · head :active ▶ — both sides are :active

highlighted base :active ◀ │ ▶ head :active

🔍 magenta boxes mark each change — changed: a.link

a.link

Interactive-state changes:

| State | Property | Before → After | | --------- | -------- | --------------------- | | :active | color | #2dd4bf#f87171 |

  • [ ] Approve all changes

Tick Approve all changes to turn the StyleProof check green — write access required, one tick signs it off. A new push that changes styles or surfaces re-opens it.

Quickstart · Read the catch contract

See the gate work

Comment states

A StyleProof pull-request comment is a trust state, not a score. Reviewer approval can clear only STYLE_REVIEW_REQUIRED. Each state appears once.

| State | What the comment means | Approval box | | ---------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------- | | NO_REVIEWABLE_STYLE_CHANGES | Captured computed styles match. Content/structure may still be advisory. | Hidden. Check is green. | | STYLE_REVIEW_REQUIRED | Reviewable style or new-surface evidence exists. | Shown. One tick signs off this commit. | | INVENTORY_REMOVAL_UNACKNOWLEDGED | A navigable affordance disappeared without a reasoned exclusion. | Hidden. Approval cannot clear it. | | DATA_RESIDUE_UNACKNOWLEDGED | A data-boundary request failed during capture, so a fallback branch was certified. | Hidden. Approval cannot clear it. | | CERTIFICATION_FAILED | Coverage, determinism, or report/diff consistency is incomplete. | Hidden. Approval cannot clear it. | | PARTIAL_BASELINE | The base capture missed registered surfaces. | Hidden. Repair the base branch. | | DEGRADED_BASELINE | The base capture failed. This is a head-only receipt. | Hidden. Not a comparison. | | REPORT_PUBLICATION_FAILED | The comment or report branch could not be published. | Hidden. Delivery failed. |

Certified clean

A GitHub-rendered StyleProof report with complete coverage, proven determinism, unchanged inventory, no data residue, and no reviewable computed-style changes

This report earns every green claim: the registered surface was captured, both base and head passed the self-check, the navigable set stayed intact, no failing data boundary was captured, and no reviewable computed-style or forced-state change was detected among semantically matched elements.

Review required: approve the visual changes

An actual StyleProof GitHub PR comment showing detected computed-style and state changes with the unchecked Approve all changes control

This is the normal feature-work state, captured from an actual production pull request with repository details cropped out. StyleProof has found reviewable visual changes and kept the check red. A reviewer ticks Approve all changes to sign off that commit. Any later push that changes the evidence reopens the gate.

A safety policy blocks

A real StyleProof GitHub comment blocking an unacknowledged navigation removal

An unacknowledged navigation removal cannot be waived as a visual change. Repair it or acknowledge it in policy. The approval box cannot clear it.

Enterprise fit

  • Auditable decisions. Every approval is tied to a commit and a report with the rendered evidence behind the decision.
  • Fail-closed safety rails. Coverage gaps, unproved determinism, removed navigation, failed data boundaries, and incompatible captures stay explicit.
  • No StyleProof-hosted service. The CLI, Action, maps, reports, and approval workflow run in your repository and GitHub environment.
  • Framework and styling agnostic. Tailwind, CSS Modules, Sass, styled-components, design systems, and inline styles resolve to the same browser-computed contract.
  • Adopt without a rewrite. Start with discovered routes or rendered links, then add high-value states such as open dialogs, tabs, loading views, and responsive breakpoints.

Contents

Why

Use StyleProof when a PR can change CSS, design tokens, component classes, layout, or hidden/open UI states and you want CI to say whether the browser's rendered styles actually changed. Unit and e2e tests prove behavior; StyleProof proves the visual contract for the states you declared.

It catches:

  • a button recoloured by a token, utility class, CSS module, inline style, or design-system change;
  • a layout shift at one breakpoint but not another;
  • a dropped :hover, :focus, or :active style;
  • a modal, menu, listbox, popover, sheet, or toast whose open state changed;
  • a supposedly no-op refactor, such as CSS-to-Tailwind, that changed rendered output;
  • a required route, component, or UI state that exists but has no capture.

The end-to-end catch contract lives in docs/what-it-catches.md.

How it works

  1. A surface is one UI state to certify: a route, tab, modal-open state, dropdown-open state, toast-visible state, loading state, etc.
  2. You list or auto-discover surfaces in a Playwright-style spec.
  3. StyleProof opens each surface at real breakpoint widths and records computed styles for every captured element.
  4. On a PR, it compares base vs head and reports exactly which rendered styles changed.
  5. The PR gets a StyleProof status: green when nothing changed, red until someone approves intentional changes, or failing when certification mode is configured.

StyleProof is not a screenshot diff. Screenshots appear in the report so humans can see the change, but the gate compares browser-computed CSS: resolved longhands, pseudo-elements, layout boxes, motion longhands, and forced :hover/:focus/:active deltas.

Maps travel via the SHA-keyed styleproof-maps branch (or a CI artifact for forks) — never as files committed to the PR branch. Committed maps show up as changed files in every review, and because every PR writes the same paths, each merge forces every other open PR to rebase. .styleproof/ and stylemaps/ are gitignored to keep that door shut.

Quickstart

0. Set up everything

npx styleproof setup

That one command detects npm, pnpm, Yarn, or Bun; installs StyleProof and Playwright; installs Chromium; scaffolds the capture spec, dedicated Playwright config, split GitHub workflows, and pre-push integration; then verifies every machine-owned file against the installed release. Preview the exact operations without writing with npx styleproof setup --dry-run. Existing installations can use styleproof setup --skip-install --skip-browser to refresh scaffolding without network work. In a monorepo, target the consumer application explicitly:

styleproof setup --project-dir apps/web

--project-dir changes where dependencies are installed and all setup commands run. --dir remains the capture-spec path inside that project, for example --project-dir apps/web --dir e2e/styleproof.custom.spec.ts.

Requires Node ≥ 18 (ESM). Forced states are Chromium-only.

1. Understand the one CLI

styleproof capture          # capture this commit from the generated spec
styleproof crawl <url>       # direct URL or rendered-nav crawl
styleproof compare [base]    # fail-closed base/head comparison
styleproof report [base]     # generate the review report on command
styleproof variants          # inspect surface/state variants
styleproof affected          # resolve surfaces affected by source changes
styleproof ci                # cache-aware CI orchestration
styleproof store import ...  # migrate a v1 bundle into immutable evidence
styleproof store verify ...  # verify a ref and every referenced byte
styleproof store restore ... # atomically restore a verified ref

Run styleproof --help for the whole journey or styleproof <command> --help for command-specific options. The existing styleproof-* binaries remain as backwards-compatible aliases.

Exact-source certification: the composite Action binds both compare and report to the trusted pull-request base and head SHAs and to a canonical SHA-256 receipt over every regular artifact byte in both capture directories. Each command checks that receipt before and after consuming the evidence, then the Action validates the closed receipt, requires exact diff/report equality, and rejects impossible no-capture claims when maps exist. An ancestor-reused baseline is still useful as diagnostic/cache evidence, but it cannot certify an exact base SHA. Dirty captures also cannot bind to a trusted commit. Recapture the exact clean base for the certifying Action.

The experimental v2 evidence store separates immutable bytes from mutable refs:

styleproof store import .styleproof/maps/current --json
styleproof store verify commits/<sha>/<compatibility-key> --json
styleproof store restore commits/<sha>/<compatibility-key> ./restored-maps

Import derives coverage and determinism from the bundle's own ledgers, excludes HAR and unrelated user files by default, and fails on malformed trust evidence. verify hashes the capture manifest and every referenced object. restore verifies first, writes into a temporary directory, then exposes the result with one atomic rename. Git-backed remote publication still uses the v1 adapter while the dual-write and remote CAS migration is completed; see docs/evidence-store-v2.md.

styleproof setup detects your app and wires surface discovery for you — there is nothing to hand-list for the first capture:

  • Next.js — it discovers your routes (app/ + pages/) at run time and derives both the captured surfaces and the coverage guard from them, so a route you add later is captured automatically, never a guard failure.
  • Any other app — it scaffolds a nav crawl: StyleProof loads /, reads the rendered <a href> links, and captures every same-origin surface they point to. The surface set is the visible nav, so it cannot drift from that nav.

The distinction matters. Next.js supplies an enumerable route registry, so the generated gate can certify completeness immediately. A generic nav crawl cannot prove that invisible, auth-gated, or no-longer-linked routes do not exist. Its first comparison therefore fails closed with completeness NOT asserted until you add the generated spec's expected registry (and reasoned exclude entries). Use --allow-unasserted only for an explicit diagnostic comparison; its JSON receipt says certifiesFully: false.

Either way the generated spec runs as-is. It also wires everything around it so the gate behaves the same locally and in CI:

  • a dedicated playwright.styleproof.config.ts that builds and serves a production build (never a flaky dev server), scopes discovery to the StyleProof spec, and captures surfaces in parallel (fullyParallel) without disturbing your app's existing Playwright config;
  • widths you never set — omit widths and StyleProof sweeps your app's real @media breakpoints automatically;
  • determinism you never set up — network settle, frozen clock, animation freeze, and framework-noise filtering are all on by default (see Deterministic by default);
  • .gitignore entries for .styleproof/, test-results/, and playwright-report/;
  • a cache-first CI workflow that restores reusable maps from the styleproof-maps branch, captures only a missing head when the base is compatible, and publishes every cold fallback so later runs stay browserless;
  • a pre-push hook (.husky/ if present, else .githooks/) that restores an already-published commit or captures and publishes it once — CI's hot path stays report-only, repeated pushes do no browser work, and maps never get committed to the PR branch;
  • the approval workflow (styleproof-approve.yml) that turns the StyleProof status green when a reviewer ticks Approve all changes — so the review gate is complete, not half-wired (it activates once the init PR merges, since GitHub runs issue_comment workflows only from your default branch).

2. Capture, then diff

npx styleproof-map    # capture this commit's computed styles
npx styleproof-diff   # compare against the base branch

styleproof-map captures the current commit into .styleproof/maps/current, writes a manifest, and uploads the bundle to the dedicated styleproof-maps branch when the working tree was clean and a git remote is available. Nothing under .styleproof/ belongs in the PR branch. HAR recordings are removed before upload by default so private API responses do not land in the map store. Keep them locally only for an explicit record/replay workflow with styleproof-map --keep-har (or STYLEPROOF_KEEP_HAR=1).

styleproof-diff restores the base and head maps from styleproof-maps automatically: in GitHub Actions it uses the PR base/head SHAs; locally it checks branch.<name>.gh-merge-base, then the current GitHub PR base via gh pr view (handy for stacked PRs), then origin/main, origin/master, main, and master. Pin the base with styleproof-diff main or styleproof-diff master.

That's the whole loop. The map is built outside CI by default: the pre-push hook styleproof-init installs runs styleproof-map on every push that can affect render (skip one with STYLEPROOF_SKIP_CAPTURE=1 git push). On the PR, CI first restores the base/head bundles and only generates the report — no build, no browser. If only the head bundle is missing, CI keeps the compatible restored base and captures/publishes only the head. If the base bundle is missing or incompatible, CI recaptures and publishes both sides in the same pinned environment before reporting. Cold fallback work therefore becomes reusable instead of recurring on the next PR. Correctness wins over a stale cache, but the hot path is report-only.

Same-environment note. Computed styles depend on the browser build and installed fonts, so maps are only comparable when captured in the same runtime environment. StyleProof records a compatibility key to select the right cached bundle and refuses to compare maps captured under different browser/platform settings; CI then recaptures both sides instead of producing a bogus report. Each capture also records the real browser build (browser().version()) in its manifest — the npm @playwright/test version is only a proxy, and the actual Chromium binary can change while it holds constant (a playwright install re-download, a different PLAYWRIGHT_BROWSERS_PATH, a CI image bump). When both sides carry it, a differing build refuses to compare (exit 2, both builds named) instead of walling the PR with false diffs. This guard needs a styleproof-manifest.json on both sides. Since v4 a two-directory styleproof-diff/styleproof-report where a side ships maps but no manifest refuses to compare: it exits 2 (usage/capture error) naming the bare side(s), because the environment can't be verified and captures from different browser builds or platforms would diff as false changes. Re-capture with current StyleProof — styleproof-map, or styleproof-capture for a one-shot design diff (both write a manifest); maps without one are unsupported. (A dir with no maps at all is "no baseline yet", not a bare bundle — that stays the first-adoption review path, exit 3.) Installed fonts are your responsibility: they are noisy across machines (user-installed families, OS updates, and no cheap cross-platform enumeration), so StyleProof does not fingerprint them — capture both sides on the same fonts, which is what CI's pinned image already gives you.

Want the local side-by-side report (not just a pass/fail diff)? Run npx styleproof-report after styleproof-map; it uses the same inferred base ref and the same cached-map defaults as styleproof-diff. Pin the base with styleproof-report main or keep the manual form with styleproof-report before after --out report.

3. Wire it by hand instead (optional)

styleproof-init scaffolds both the report workflow and the styleproof-approve.yml handler that flips the StyleProof status when a reviewer ticks the box. GitHub only runs issue_comment workflows from the default branch, so the checkbox goes live the moment you merge the init PR — no manual copy. If you wire it by hand instead, restore or capture two dirs first, then use the Action on those dirs:

# .github/workflows/styleproof.yml
- uses: actions/checkout@v4
  with:
    fetch-depth: 0
# One command: restore both exact-SHA maps, or capture-and-publish on a miss
# (cold base rebuild under the head's exact release, HAR replay for the head).
- id: maps
  run: npx styleproof-ci --base "${{ github.event.pull_request.base.sha }}" --head "${{ github.event.pull_request.head.sha }}" --base-dir __stylemaps__
- uses: BenSheridanEdwards/StyleProof@v6
  with:
    baseline-dir: __stylemaps__/base
    fresh-dir: __stylemaps__/head
    base-capture-failed: ${{ steps.maps.outputs.base-capture-failed }}
    require-approval: true # review-gate mode (omit / use fail-on-diff: true to certify)

A note on the base commit, because it surprises people: github.event.pull_request.base.sha is the base branch tip as of the PR's last sync (open/synchronize), not the base branch's current tip or the merge target. That is by design and it is the commit you want: it names the base your branch actually diverged from, so the restored base map matches the code your change is diffed against. A stale-but-consistent base beats a moving one — comparing against a base tip your branch has never seen would attribute other people's merged changes to your PR. Updating the branch (merge or rebase, which fires synchronize) refreshes it.

Only for this hand-wired path: copy example/styleproof-approve.yml to .github/workflows/ on your default branch (GitHub only runs issue_comment workflows from there, so the approval checkbox is inert until it's merged). styleproof-init writes this file for you, so you can skip this step if you used it.

Prefer to always capture in CI? For a repo with many outside contributors on different machines, StyleProof can capture both base and head in CI and diff them there. See Forks and Dependabot for that flow (it's also the fork-safe split). The default cache-first flow is faster for same-repo teams because the pre-push hook builds the head map before CI starts.

Want to skip work safely? Skip the whole StyleProof workflow only for changes that cannot affect rendered output, such as docs-only edits, using your CI provider's native path filters. Do not skip individual surfaces from a StyleProof run based on a changed-file guess: shared CSS, tokens, resets, themes, layout primitives, and runtime styling can repaint any surface, and a missed surface would certify green without being measured. If you want faster feedback, order the highest-signal surfaces first in your spec, but still let the full sweep finish before treating the gate as passed.

on:
  pull_request:
    paths-ignore:
      - '**/*.md'
      - 'docs/**'
      - '.github/ISSUE_TEMPLATE/**'

What the PR gets

On every PR, StyleProof posts a small summary comment that links to the committed full report. GitHub comments cannot carry the crops, so the link is a product limit, not a design choice. This README inlines that same report. The report groups each distinct visual change with:

  • before/after crops from the same page rectangle;
  • highlighted crops that box the changed element;
  • a plain-English summary such as columns: 2 -> 3 or background brand-cyan -> brand-amber;
  • the exact computed CSS properties that changed.

In review-gate mode, one Approve all changes checkbox turns the StyleProof status green for that commit. Clean runs still leave a receipt: No visual changes detected. New surfaces are shown as new baselines and require approval; coverage gaps are handled by expected. Element additions, removals, and retags inside an existing surface are content/structure changes: they stay out of style certification by default and appear only in the opt-in advisory content section.

What a report looks like

New pages, states, and surfaces appear before element-level changes. Existing surfaces render one distinct change per section, with aligned crops, truthful annotations, a one-line summary, and exact properties under a toggle.

Headline counts cover matched-element restyles ("N computed-style difference(s)") and interaction-state differences. One-sided DOM structure has no like-for-like style baseline, so it cannot certify a style change. Turn on --include-content when copy and element structure belong in the review; that section stays advisory and never changes the style verdict.

CSSOM resolves layout-dependent values to pixels. StyleProof also records the browser's CSS Typed OM computed value, so an auto margin or percentage width can move with surrounding content without being mistaken for a stylesheet change. If the computed value itself changes — including a deliberate width, margin, colour, font, grid, or state change — it remains a blocking finding.

Tiny changes also receive a magnified crop. Structural matching avoids painting an unchanged shifted subtree as changed, while ambiguous duplicate elements stay explicit rather than receiving invented provenance.

Open the full generated report. The fixture includes a new surface, two real restyles, an added element, and a structural insertion. With content comparison off, only the new surface and real restyles appear; npm run demo:check verifies that this default report matches current code.

The report shown at the top of this README renders this exact entry:

### `button.cta` · 1 element restyled
_home @ 900_

- **`button.cta`** — background blue (`#2563eb`) → red (`#dc2626`)

▾ Show the property change
   | Property         | Before  | After   |
   | background-color | #2563eb | #dc2626 |

Two modes: review or certify

Review-gate mode (require-approval: true) is for normal feature work: every visual change is reported with evidence, the StyleProof status stays red until a reviewer ticks Approve all changes, and approved changes become the new baseline on merge. A surface that exists only on the PR head is still reviewable: it holds the status red until approved, then becomes part of the baseline once merged.

Certify mode (fail-on-diff: true) proves a change touched nothing visual: any difference at all fails the job. It's the job StyleProof was born for — certifying a CSS-to-Tailwind migration rendered byte-for-byte identical. Reach for it on any change whose whole promise is "the output is unchanged": a utility-class migration, a design-system swap, a dependency or build-tooling bump. Zero diff is the contract; one drifting longhand is a regression to investigate, not a change to approve.

There's also a third, spec-less use — pointing the one-shot capture at a design mockup and diffing your build against it until the number hits zero. See Match a design pixel-for-pixel.

What a green certifies

A passing check is more than "no style changed". Four gates qualify it, and the report leads with their verdicts:

  • Coverage — the expected registry travels with the captured bundle as a ledger, so the gate states its completeness basis: ✓ coverage complete, ✗ coverage INCOMPLETE (blocks — a registered surface wasn't captured, even on an empty diff), or ✗ completeness NOT asserted (no registry / filtered capture — blocks certification unless --allow-unasserted diagnostic mode).
  • Determinism — the ledger records how each capture proved itself (self-checked / replayed); a green from an unproven or unknown capture blocks, because a clean diff of two nondeterministic (or pre-ledger) reads could just be luck. Pass --allow-unasserted only for explicit diagnostic compares (certifiesFully: false in JSON).
  • Inventory — with inventory: true (on in styleproof-init scaffolds), each capture harvests the surface's navigable affordances (links, tabs, menu items, keyed by stable identity, not label text). A removal that makes a feature unreachable gates — in the styleproof-diff CLI and in the Action, both modes — until acknowledged in styleproof.inventory.json ({"<key>": "<why>"}); a stale acknowledgement is flagged so the ledger can't rot. Details and the keying rules: docs/inventory-guard.md. Make it advisory in the Action with "gateInventoryRemovals": false in styleproof.config.json.
  • Failed data request: a data-boundary request that failed during capture means the fallback UI was captured, not the state its responses drive. Gating is the default (dataResidue: 'gate'): an unacknowledged failing endpoint blocks until declared in styleproof.data-residue.json, and a stale declaration also fails. Opt down with dataResidue: 'warn'. See Failed data request.

Those verdicts roll up into one more line the report always states: the confidence ledger (styleproof-confidence.json, bundled next to the maps). It assigns every surface one status — captured, excluded-with-reason, inaccessible (an auth wall or blocked continuation), unknown (declared but never captured), or unproven-determinism — and renders a completeness badge (✓ complete, ⚠ limited, ⚠ unasserted, ⚠ unknown) separate from the visual verdict: a visual PASS and a complete capture are two claims, never one green. Crawl captures persist the ledger themselves (auth walls travel with the bundle); spec captures derive it from the coverage ledger; bundles from before the ledger existed read ⚠ unknown and are never blocked retroactively. No coverage percentage is ever invented for surfaces that cannot be enumerated. The same summary lands machine-readably in report.json (confidence).

Coverage: what you own, what's discovered

The important boundary: StyleProof only certifies states it can reach. It diffs the surfaces your spec lists or discovers — so a page nobody added to either set is invisible to the gate. Its change has no base capture and no head capture, so it never appears in any diff, and the status goes green having never looked at it. This is the one thing the captures can't catch on their own: a capture that was never taken.

Auto-discovery keeps the boring inventory out of your hands where it can be inferred safely: Next.js routes, crawlable links, component files, semantic popups, one-step variants, breakpoints, and volatile/live candidates. You own the app-specific list of states that matter:

  • routes and views belong in surfaces;
  • open states belong in variants or popups;
  • loading/loaded/empty/error states belong in liveStates;
  • component catalogs can be wired through discoverComponentFiles;
  • required-but-not-yet-captured states belong in expected, where the coverage guard fails until they are captured or explicitly excluded with a reason.

That boundary is deliberate. StyleProof should not guess destructive flows, auth-only fixtures, or which product state your component needs. It should make missing coverage loud.

expected is what makes it loud. Declare your app's route/view universe in expected and StyleProof emits a coverage-guard test in your normal suite (it runs even without STYLEMAP_DIR — it's a static check, no browser). It fails when expected and your captured surfaces diverge — a route you listed in expected with no surface and no exclude entry fails as missing coverage, so a registry entry can't quietly ship uncaptured:

import { defineStyleMapCapture } from 'styleproof';
import { ROUTES } from '../app/routes'; // your registry — wherever routes live

defineStyleMapCapture({
  dir: process.env.STYLEMAP_DIR,
  surfaces: SURFACES,
  expected: ROUTES.map((r) => r.id), // every route StyleProof should cover
  exclude: { checkout: 'auth-gated — capture fixture pending' }, // visible, reviewed opt-outs (key → reason)
});

A route that's neither a captured surface nor an exclude entry fails the guard; an exclude key that isn't in expected (a renamed/removed route) fails too, so the opt-out ledger can't quietly rot. Captured surfaces beyond expected are fine — one route can have several states (landing, landing-nav-open). Omit expected and behaviour is unchanged.

What's guarded depends on how expected is fed —

  • Next.js: auto-covered. styleproof-init derives both surfaces and expected from the same discoverNextRoutes() call, so a new static route lands in both at once — captured and expected together, with nothing to keep in sync.
  • Link-crawled SPAs: pass expected to defineCrawlCapture and the crawl reconciles it against the rendered nav (the route universe for such an app), both directions — a new linked route with no expected entry fails, and an expected route the nav stopped linking fails. This runs inside the capture, so it fires when you capture (unlike the Next guard, which runs in your plain suite).
  • Other frameworks: point expected at your own route registry.
  • Modals, dropdowns, toasts: guarded only for the state keys you enumerate in expected (e.g. dashboard-dialog-open) — nothing discovers UI states for you.

The un-exercised-state gap: an honest green gate can still miss a real restyle

The sharpest form of the boundary, observed end-to-end on a real consumer: a PR restyled a view's conditional render branch (a fault overlay repainting indicators green→amber when a probe reports a fault) and shipped through a fully-wired gate with every layer passing honestly. The capture spec served no fixture that put the view into the fault state, so the changed branch never rendered on either side — an honest recapture produced byte-identical maps, the diff was trivially clean, and the green gate certified a restyle it never saw. No component lied. The gap is structural: maps prove only the states the spec exercises. A restyle confined to an un-exercised conditional state (fault / error / empty / permission branches behind data) is invisible to any amount of honest recapturing, and the coverage guard cannot substitute — it checks that declared keys have captures, not that your branches have keys.

So the rule is: every conditional render branch whose styling matters needs a surface that exercises it. The recommended wiring is two-part:

  1. a dedicated capture surface (a liveState or variant) driving the branch via a per-surface fixture overridepage.route in that surface's setup, per-surface rather than global, so other surfaces reading the same endpoint keep their own state (exactly the shape of the liveStates example);
  2. a browserless guard test tied to the branch's source, failing loudly if the surface, fixture, or state assertion is removed while the conditional branch still exists — so the coverage can't silently rot.

This pairs with the data-residue guard, which names the failing half of the same blind spot: a data request that errors during capture is flagged as residue. An endpoint that succeeds with healthy data — so the fault branch simply never renders — is this gap, and only a fixture-driven surface closes it.

Declaring surfaces

Discovery captures every route your app links to. It deliberately won't guess app-specific states — a modal's open state, an auth-gated view, a destructive flow, a loading/error render — because guessing one wrong is worse than flagging it missing. Those are the only things you list by hand, and you add them to the spec styleproof-init already generated. This section covers each kind.

Next.js routes: wired for you

Run styleproof-init in a Next.js project and the generated spec discovers your routes (App Router app/ + Pages Router pages/) at run time and derives both the surfaces and expected from that same discoverNextRoutes() call. Because they share one source, a static route you add later is captured and expected in the same step — auto-covered, never a guard failure, with nothing to keep in sync. The guard exists for the cases where the two genuinely diverge: a dynamic [param] route (it can't be navigated without a value, so it's placed in exclude with a reason rather than captured), a registry you hand-maintain instead of the live call, or a route you drop from surfaces while it's still expected:

import { defineStyleMapCapture, discoverNextRoutes } from 'styleproof';

const ROUTES = discoverNextRoutes(); // [{ key, path, dynamic }, …] from app/ + pages/
defineStyleMapCapture({
  surfaces: ROUTES.filter((r) => !r.dynamic).map((r) => ({
    key: r.key,
    go: (p) => p.goto(r.path),
    widths: [1280, 768, 390],
  })),
  expected: ROUTES.map((r) => r.key),
  exclude: Object.fromEntries(
    ROUTES.filter((r) => r.dynamic).map((r) => [
      r.key,
      `dynamic route ${r.path} — add a surface with a concrete param`,
    ]),
  ),
  dir: process.env.STYLEMAP_DIR,
});

discoverNextRoutes(cwd?) reads the filesystem only (route groups (group) and @slots stripped, [param]/[...catchall] flagged dynamic) — a heuristic, not a router; edit the generated spec for exotic routing. For any other framework, point expected at your own route registry as above.

Single-route SPAs: crawl the nav

Filesystem discovery can't see a surface that isn't a page — a tab SPA where every view is /?tab=overview on one app/page.tsx, or anything client-routed. There the surfaces exist only in the rendered nav, as its links. defineCrawlCapture discovers them at run time: it loads a root URL, reads its same-origin <a href>s, and captures each — so the surface set is the nav, with no list to hand-maintain (and so none to drift).

import { defineCrawlCapture } from 'styleproof';

defineCrawlCapture({
  from: '/', // crawl the app root for links
  match: /\?tab=/, // keep just the tab views (omit to take every same-origin link)
  widths: [1440, 1024, 768],
  dir: process.env.STYLEMAP_DIR,
});

Each discovered link becomes a surface keyed by its URL (/?tab=overviewoverview; pass key for a different scheme). The app only has to render its nav as real <a href> links — a button-only nav (<button onClick>) exposes nothing to crawl. Replay, self-check and clock-freeze behave exactly as for explicit surfaces; one Playwright test runs the whole sweep (the link set isn't known until the page renders).

Pass expected (a route registry) to turn the crawl into a coverage guard: the crawl reconciles the rendered link set against it, both directions — a rendered link with no expected entry fails as a new route with no owner, and an expected route the nav stopped linking fails as a nav regression. For a link-crawled SPA the rendered nav is the route universe, so this is the same list-vs-ledger discipline as the spec guard with the nav as the source of truth. Because the link set isn't known until the page renders, this reconciliation runs inside the capture test — so it fires when you capture (STYLEMAP_DIR set), not in every npm test, unlike the static Next guard. A link that renders conditionally (behind auth or a feature flag) would otherwise make the guard flaky either direction; list it in exclude (key → reason) to opt it out visibly — an exclude key in neither expected nor the rendered nav fails as stale, so the ledger can't rot. Omit expected and the crawl keeps its default: capture what the nav links to, assert no completeness.

defineCrawlCapture({
  from: '/',
  expected: ['index', 'pricing'], // the routes the nav must link to
  exclude: { admin: 'feature-flagged, renders only for staff' },
  dir: process.env.STYLEMAP_DIR,
});

Component inventory: fail when the catalog misses a component

StyleProof cannot render arbitrary component files by itself across frameworks; props, providers, loaders, portals, and app shell context are app-owned. What it can do reliably is inventory component files and make your catalog/story route prove it has a capture for each one:

import { componentCatalogSurfaces, defineStyleMapCapture, discoverComponentFiles } from 'styleproof';

const COMPONENTS = discoverComponentFiles({
  roots: ['src/components'],
  ignore: [/\/icons\//],
});

defineStyleMapCapture({
  surfaces: componentCatalogSurfaces(COMPONENTS, {
    url: (component) => `/styleproof/components/${component.key}`,
    widths: [390, 1024],
  }),
  expected: COMPONENTS.map((component) => component.key),
  exclude: {
    'component-payment-card': 'needs a billing provider fixture',
  },
  dir: process.env.STYLEMAP_DIR,
});

Use Storybook, Ladle, a framework route, or a tiny app-specific catalog for /styleproof/components/:key. The inventory feeds both surfaces and expected, so a new component file appears immediately and CI fails until it has a rendered surface or an explicit exclusion.

Dialogs, popovers and menus: capture the open state as a variant

StyleProof cannot guess which app-specific button opens a modal, but once you tell it the interaction, it compares matching states on base and head (home-dialog-open to home-dialog-open). Keep these under the route/view that owns them:

const SURFACES: Surface[] = [
  {
    key: 'home',
    go: (page) => page.goto('/'),
    variants: [
      {
        key: 'dialog-open',
        go: async (page) => {
          await page.getByRole('button', { name: /open settings/i }).click();
          await page.getByRole('dialog').waitFor();
        },
      },
      {
        key: 'popover-open',
        go: async (page) => {
          await page.getByRole('button', { name: /more/i }).click();
          await page.locator('[popover], [role="menu"]').first().waitFor();
        },
      },
    ],
  },
];

Non-live variants add captures; the owning surface still captures too. Use liveStates instead when the default live state is too fuzzy and only pinned states such as loading, loaded, empty, or error should be compared.

Popups, discovered automatically

When popups: true is enabled, StyleProof also tries visible safe triggers and captures opened dialogs, menus, listboxes, modal roots, popovers, tooltips, and toast/status roots. Each saved map includes overlays proof metadata for semantic roots that were actually present in the computed-style map, so tests can assert a capture reached role="dialog", aria-modal, role="menu", role="listbox", or hot-toast text.

Triggers are enumerated once per surface and every reopen re-binds to that same element by identity — its DOM path and its accessible label — never by position. Between popups the surface is reset (Escape + go()) and the reset is verified: if an overlay a previous popup left behind is still visible (Escape closes dialogs, not toasts or status regions), or an enumerated trigger disappeared or changed identity (e.g. a same-tag sibling shifted in earlier), that candidate is skipped loudly — a styleproof: warning names the popup and why — instead of capturing contaminated state or keying a popup under the wrong trigger. Dismiss the leaking overlay in the surface's go(), or capture it as an explicit variant.

Harvest one-step variants

Routes are not the whole UI: drawers, tabs, dialogs, empty form errors, selects, and other one-step states need their own captures. styleproof-variants opens a running app, tries semantic controls ([aria-expanded], tabs, summaries, selects, required forms, etc.), captures a baseline and post-action StyleMap, and keeps only actions that change computed styles. It also reports live-state candidates that need fixtures or opt-outs.

styleproof-variants --base-url http://localhost:3000 --route / --route settings=/settings

Use it as a manifest generator, not a replacement for review. To refresh that manifest as part of the map loop, pass the same crawl inputs to styleproof-map; it runs the crawler before Playwright captures the maps:

styleproof-map --crawl-base-url http://localhost:3000 --crawl-route / --crawl-route settings=/settings

The app must already be reachable at --crawl-base-url. If Playwright's webServer is the thing starting the app, keep route-link crawling inside the capture run with defineCrawlCapture.

{
  "routes": [
    {
      "key": "settings",
      "url": "/settings",
      "variants": [
        {
          "key": "plan-selected",
          "action": "select-option",
          "selector": "select[aria-label=\"Plan\"]",
          "value": "pro"
        }
      ],
      "liveStates": [{ "key": "status", "fixtureRequired": true }],
      "skipped": []
    }
  ]
}
defineStyleMapCapture({
  surfaces: [
    {
      key: 'settings',
      go: (page) => page.goto('/settings'),
      variants: [
        {
          key: 'plan-selected',
          go: (page) => page.locator('select[aria-label="Plan"]').selectOption('pro'),
        },
      ],
    },
  ],
});

Destructive labels are skipped, duplicate computed-style outcomes are deduped, and --strict exits non-zero when live-state fixtures or skipped candidates remain unresolved. The harvester only finds states reachable by interacting — it clicks, selects, and expands. A data-driven conditional branch (a fault overlay, an empty render) has no control to click, so it never appears in the manifest; those need liveStates fixtures, per the un-exercised-state gap.

State recipes: explicit interaction, transient, and network-error variants

For states that must be driven as independent, named captures (not multi-step choreography), StyleProof exports a typed state recipe contract and wires it through Surface.stateRecipes (and the same field on crawl capture options):

import {
  defineStyleMapCapture,
  parseStateRecipes,
  stateRecipeGo,
  applyStateRecipe,
  type SurfaceVariant,
} from 'styleproof';

const recipes = parseStateRecipes([
  { action: 'hover', selector: '#plan-card', label: 'Plan card' },
  { action: 'focus', selector: '#email', label: 'Email' },
  { action: 'press', selector: '#menu', key: 'ArrowDown', label: 'Open menu' },
  { action: 'click', selector: '#menu', label: 'Open menu' },
  {
    action: 'click',
    selector: '#notify',
    stateKey: 'toast-visible',
    observeSelector: '[aria-live]',
    observeMs: 250,
  },
  {
    action: 'route',
    stateKey: 'plans-network-error',
    urlPattern: '**/api/plans',
    status: 503,
  },
]);

// Preferred: declare on the surface — each recipe expands to
// `<surface>-<stateKey>` after parent `go`, with `variantKind: 'state-recipe'`
// and report-only provenance (stable key, action, optional safe interaction
// selector / press key / observation window / response status). Declared labels,
// route patterns, and observation selectors are runtime-only. Metadata is ignored
// by the certification diff.
defineStyleMapCapture({
  dir: process.env.STYLEMAP_DIR,
  surfaces: [
    {
      key: 'pricing',
      go: (page) => page.goto('/pricing'),
      stateRecipes: recipes,
    },
  ],
});

// Still supported: hand-wire a single recipe through SurfaceVariant.go
const variant: SurfaceVariant = {
  key: 'plan-card-hover',
  go: stateRecipeGo(recipes[0]),
};

// Or drive ad hoc when you need AppliedStateRecipe provenance:
// const applied = await applyStateRecipe(page, recipes[0]);

Rules for this slice:

  • Interaction actions are hover, focus, press, and click. Their fields are action, selector, optional key, label, stateKey, and paired observeSelector / observeMs. A route recipe instead requires explicit stateKey, value-free urlPattern, and integer status from 400–599; it rejects every interaction field. All shapes are closed-world.
  • Every interaction, including press, requires an explicit CSS-only, value-free selector (#id, .class, [aria-expanded], input[name], li:nth-child(2), nav > a, …). Quotes/backticks, attribute-equality, Playwright engine prefixes (text=, xpath=, css=, …), Playwright locator chaining (>>, button >> …; single CSS > is fine), and value-carrying functions (:text(), :has-text(), url()) are rejected so secrets never enter keys, provenance, or error messages. Public stateRecipeKey runs full validateStateRecipe then shared internal key derivation. Labels and stateKey are length-bounded and control-sanitized; labels/stateKey that cannot produce a non-empty safe slug fragment (emoji/CJK/punctuation-only) are rejected before browser I/O (no generic state collision key). Bare Escape / ambient keyboard is deferred rather than unsafe.
  • A collection is a set of independent variants from a known baseline, not a multi-step choreography. Interaction expansion runs parent go then one recipe. A route recipe installs its one-shot intercept, parks the inherited pointer outside the viewport, and only then runs parent go, so navigation cannot dispatch a sticky mouseenter from prior hover discovery. Duplicate derived keys are rejected; order is sorted by stable key. Declared invalid/unsafe recipes fail closed at expansion (before browser tests register); unsafe live targets still fail the capture with a privacy-safe StateRecipeError.
  • Stable keys come from declared stateKey / label / selector. Live accessible labels still feed the destructive-action guard (so a benign declared label cannot authorize a control whose live label is Delete / Remove / …).
  • press keys are a fixed disclosure/navigation allowlist (Enter, Escape, Space, Tab, arrows, Home, End). The driver focuses the target, then presses — never ambient page focus.
  • Transient observation requires an explicit state key and paired structural selector/window. StyleProof waits at most one second for appearance, proves continuous visibility for the bounded 50–5000 ms window, then checks again before, during, and after map extraction. Disappearance fails with state key and phase only; the observation selector and rendered copy are never persisted or echoed.
  • route recipes install one empty-body error response only. No request values, inline payloads, arbitrary headers, or success fixtures enter the recipe. Use a consumer-owned liveStates.setup fixture for loaded/empty payload states.
  • Expanded keys participate in assertUniqueExpandedKeys alongside variants and live states (collision messages name origins without selectors/secrets). Coverage translation treats recipe expansions like other metadata-bearing captures.

Safe discovery ledger

styleproof variants performs one bounded semantic scan per route and records hover/focus candidates separately in route.stateCoverage. It never clicks these candidates during discovery. CSS pseudo-state evidence comes from the same CDP forced-state layer used by certification; a real browser action is only a fallback for JS-driven effects when no pseudo-state delta exists.

styleproof variants \
  --base-url http://localhost:3000 \
  --route home=/ \
  --max-state-actions 40

Every entry has a stable hashed stateKey, a value-free structural selector, and one exact outcome: captured, deduplicated, skipped, timed-out, or requires-fixture. Unsafe labels are checked inside the browser and discarded; the new ledger never persists the label, role, rendered text, attribute value, or exception string.

Detected live regions produce a typed consumer-owned-setup recommendation with an observeSelector and 250 ms observation window. They remain requires-fixture. StyleProof does not fabricate the missing application state or guess which control should trigger it. In --strict mode, skipped, timed-out, and fixture-required outcomes fail the command.

Config-file recipe parsing and bare Escape without a target selector remain follow-up slices.

Before promoting a new state class, capture it in at least five fresh browser contexts and pass the public determinism oracle:

import { assessDeterminismOracle, hashDeterminismMap } from 'styleproof';

const runs = captureDirs.map((dir) => ({
  stateKeys: orderedKeys,
  mapHashes: Object.fromEntries(orderedKeys.map((key) => [key, hashDeterminismMap(loadMap(dir, key))])),
}));

const verdict = assessDeterminismOracle(runs);
if (verdict.status !== 'deterministic') throw new Error(JSON.stringify(verdict));

deterministic means exactly five valid runs were supplied and all five match. Every other result is flake, with a machine-readable reason: run-count, invalid-receipt, or mismatch. Receipts require unique non-empty ordered state keys, an exact matching hash-key set, and 64-character SHA-256 hexadecimal map hashes. CI prints and uploads test-results/determinism-oracle.json; use that artifact as the review receipt instead of inferring determinism from a green test count. Do not retry or weaken the assertion until a flake turns green; diagnose the unstable or malformed input.

Live UI states: capture each state, not an average

StyleProof automatically detects semantic live-state candidates (aria-live, role=status, role=alert, aria-busy=true) and keeps stable ones in the normal diff. If a stream, poll, or live region represents product states you want certified (loading, loaded, empty, error), list only those pinned states with liveStates. StyleProof writes separate captures such as dashboard-loading@1440 and dashboard-loaded@1440, so the base branch's loading state compares to the feature branch's loading state, and loaded compares to loaded.

This is also how you close the un-exercised-state gap: a conditional branch that only renders under specific data (a fault overlay, an empty list, a permission wall) needs its own pinned state here, with the fixture in that surface's setup — per-surface, not a global route override, which would leak the faulty payload into every other surface reading the same endpoint. Note both fixtures in the example below are scoped this way.

defineStyleMapCapture({
  dir: process.env.STYLEMAP_DIR,
  surfaces: [
    {
      key: 'dashboard',
      go: (page) => page.goto('/dashboard'),
      widths: [1440, 768],
      liveStates: [
        {
          key: 'loading',
          setup: (page) =>
            page.route('**/api/widgets', (route) => route.fulfill({ json: { status: 'loading', widgets: [] } })),
        },
        {
          key: 'loaded',
          setup: (page) =>
            page.route('**/api/widgets', (route) =>
              route.fulfill({ json: { status: 'loaded', widgets: [{ label: 'Revenue' }] } }),
            ),
        },
      ],
    },
  ],
});

Deterministic by default

A style diff only means something if both sides saw the same inputs; otherwise live-data drift (a backend blip, a 5m ago timestamp, a status chip that flips) reads as a style change on a PR that touched no CSS. StyleProof handles this for you — no fixtures required:

  • Record / replay. The base capture records each surface's data responses (anything matching **/api/**) to a HAR; the head capture replays them, so the head renders its code against the base's data — the app's own JS/CSS still load live. Backend down during a run? Both sides replay the same recording, so there's no phantom diff. Point the head capture at the base's recording with STYLEPROOF_REPLAY_FROM=<base dir> (set on the head capture); tune the data boundary with STYLEPROOF_REPLAY_URL / replayUrl if your API isn't under /api.
  • Frozen clock. Date.now() / new Date() are pinned to a fixed instant, so time-derived styling (stale > 1h → red) can't drift. Timers keep running, so settling still works. Both clocks are covered: the browser clock on every captured page, and the spec processstyleproof-map sets STYLEPROOF_FREEZE_SPEC_CLOCK=1 so that importing styleproof pins Node's Date before your spec's module body runs. A fixture stamped new Date().toISOString() at module level is therefore identical on the base and head captures, instead of leaking each run's wall clock into the rendered page as phantom text-width diffs (the in-run self-check can't see that class — both of its captures share one process and therefore one stamp). Align a custom instant with STYLEPROOF_CLOCK_TIME; opt out with STYLEPROOF_FREEZE_SPEC_CLOCK=0 or freezeClock: false.
  • Self-check — captures each surface twice and fails if they differ, so a replay gap or unseeded randomness surfaces as a clear "non-deterministic capture" error, never as a phantom change on an unrelated PR. On by default while recording (where live nondeterminism shows up); off on the replay run, which renders against the recorded HAR and is deterministic by construction. STYLEPROOF_SELFCHECK=1 forces it on for both; selfCheck: false opts out.
  • Framework noise is skipped by default. Non-visual and framework-injected elements never count as a change — <meta>/<title>/<script>/<style>/… (which Next.js streams into the body then hoists) and live regions like Next's next-route-announcer. A real stylesheet change still shows up in the affected elements' computed styles, not in the <style> tag. Add your own selectors with ignore — they extend this default, they don't replace it.
  • Layout-equivalent margin noise is normalised. If the browser reports horizontal auto-centering margins (margin-left/margin-right and logical equivalents) differently but the captured document-space rectangle is identical, StyleProof treats that as the same rendered layout, including in forced :hover/:focus/:active deltas. The suppression only fires when the sides drift together (no demonstrable px imbalance between a side and its opposite): a one-sided change like margin-left: 0 → 40px still reports even when something else compensates and the box doesn't move, and if the box moves or resizes, any margin change reports.

Replay covers data the page fetches. If your app server-renders differently per environment (SSR feature flags, locale), still capture both sides with the same server env so the rendered HTML matches.

Live pages just work when the intended state is deterministic. Before each capture, StyleProof settles the page, and the settle is network-aware: it holds while the page's data requests are in flight (excluding long-lived EventSource/WebSocket streams, which never finish) and until the computed-style map stops changing. So async content (a fetch backfilling a grid, an SSE stream) is captured loaded, not mid-load — and, crucially, it can't false-settle on the loading state before a slow backend's response arrives. That's the failure mode of a fixed wait: against a slow server (e.g. a dev server under CI load) a timer settles on the loading skeleton one run and the loaded deck the next — a phantom diff / self-check flake. Waiting on the actual request removes it.

Anything still moving on its own after that is detected as a volatile region and excluded from direct element comparison, so a stream or ticker never reads as a change just because its value changed. That is not the same as certifying every state of the live UI: an ignored or volatile subtree can still change html/body layout if its height changes. When those states matter, make them deterministic liveStates (loading, loaded, empty, error) and capture each on both branches. Self-check and reports automatically mention detected live-state candidates when volatile layout drift appears. defineStyleMapCapture arms the request tracker before each go() automatically; for a direct captureStyleMap call, arm one before you navigate with trackInflightRequests(page) and pass { pendingRequests }. Disable or tune with { stabilize: false } / { stabilize: { quietFor, timeout, waitForRequests } }.

At a glance — almost everything is automatic. The few knobs exist only for what StyleProof can't know about your app, and each says why:

| Handled for you — zero config | How | | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | In-flight data, fonts, late layout | network-aware settle holds until requests finish and the computed styles stop changing | | Animations, transitions, real hover/focus, caret | frozen / neutralised before the map is read; forced states are captured separately | | Clock-derived styling (stale > 1h → red) | Date.now() / new Date() frozen to a fixed instant | | Framework & non-visual noise (<script>, route announcers) | skipped by default | | Layout-equivalent horizontal auto margins | ignored only when the rectangle is unchanged and the sides drift together — a one-sided change still reports | | Semantic live-state candidates (aria-live, role=status) | auto-detected and kept in the diff when stable | | Live / volatile regions (tickers, third-party embeds) | auto-detected as still-moving and excluded from direct element comparison | | Non-deterministic capture (replay gap, unseeded randomness) | self-check flags it while recording, with a named error |

| You set this — only because it's app-specific | Why it exists | | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | STYLEPROOF_REPLAY_FROM (record / replay) | Base and head capture at different times ag