See every visual change a pull request makes, and confirm it is the change you meant.
StyleProof opens the UI states you care about in a real browser, records the computed CSS of every element on the
base branch and on the pull request, and posts what changed as a PR comment. The reviewer, a person or a coding agent,
reads the report and confirms that it shows the visual change the pull request set out to make. Approve the changes
you meant. In review-gate mode, the StyleProof check stays red until a reviewer approves them.
This is the comment StyleProof posts on a pull request. In this example the PR changed one button's padding and background colour, and one link's hover, focus, and active colours. What the report tells you walks through it.
How this example was made: a real run, not a mockup
scripts/live-readme-report.mjs captures example/demo/index.html in Chromium twice, injecting a
fixed CSS change between the captures, then renders the report and the PR comment it would post. The
block is that comment, with three edits for this page: crop links repointed at
docs/readme/live-report/crops/, element-level sections reordered so Save at rest comes before the
Docs hover, focus, and active states, and report.json left out of the committed bundle. Every
finding, value, and crop is what the run produced. Both sides of each interaction-state crop are in
that state.
1 change needs review
1 distinct change (2 computed-style difference(s) · 3 state-delta difference(s)) in 1 changed surface base with an existing baseline.
What changed
demo-button @ 900—button.btnbackground teal (#14b8a6) → red (#dc2626), padding adjusted ·a.linkinteraction states changed::hover,:focus,:active
demo-button @ 900
◀ before · after ▶
Btn button.btn
Style:
| Property | Before | After |
|---|---|---|
padding |
14px 28px |
18px 32px |
background-color |
#14b8a6 |
#dc2626 |
demo-button @ 900
◀ base :hover · head :hover ▶
Link a.link
Interactive-state changes:
| State | Property | Before → After |
|---|---|---|
:hover |
color |
#a5f3fc → #fca5a5 |
:hover |
↳ caret-color (currentColor) |
#a5f3fc → #fca5a5 |
:hover |
↳ outline-color (currentColor) |
#a5f3fc → #fca5a5 |
:hover |
↳ column-rule-color (currentColor) |
#a5f3fc → #fca5a5 |
:hover |
↳ row-rule-color (currentColor) |
#a5f3fc → #fca5a5 |
:hover |
↳ text-decoration-color (currentColor) |
#a5f3fc → #fca5a5 |
:hover |
↳ text-emphasis-color (currentColor) |
#a5f3fc → #fca5a5 |
:hover |
↳ -webkit-text-fill-color (currentColor) |
#a5f3fc → #fca5a5 |
:hover |
↳ -webkit-text-stroke-color (currentColor) |
#a5f3fc → #fca5a5 |
demo-button @ 900
◀ base :focus · head :focus ▶
Link a.link
Interactive-state changes:
| State | Property | Before → After |
|---|---|---|
:focus |
outline-color |
#5eead4 → #fca5a5 |
demo-button @ 900
◀ base :active · head :active ▶
Link a.link
Interactive-state changes:
| State | Property | Before → After |
|---|---|---|
:active |
color |
#2dd4bf → #f87171 |
:active |
↳ caret-color (currentColor) |
#2dd4bf → #f87171 |
:active |
↳ outline-color (currentColor) |
#2dd4bf → #f87171 |
:active |
↳ column-rule-color (currentColor) |
#2dd4bf → #f87171 |
:active |
↳ row-rule-color (currentColor) |
#2dd4bf → #f87171 |
:active |
↳ text-decoration-color (currentColor) |
#2dd4bf → #f87171 |
:active |
↳ text-emphasis-color (currentColor) |
#2dd4bf → #f87171 |
:active |
↳ -webkit-text-fill-color (currentColor) |
#2dd4bf → #f87171 |
:active |
↳ -webkit-text-stroke-color (currentColor) |
#2dd4bf → #f87171 |
Nothing else — no other element or inventory changes in this compare.
- Approve all changes
Tick Approve all changes to turn the StyleProof check green — write access required, and not the pull request author. One tick signs it off. A new push that changes styles or surfaces re-opens it. Quickstart · Read the catch contract
- The headline says how many distinct changes need review. Here that is one change, made of 2 computed-style differences and 3 interaction-state differences on one page.
- Each card is one changed element. It names the element (
button.btn), the page and viewport width it was captured at (demo-button @ 900), and the property changes in one line. - The before/after crop shows the element on the base branch and on the pull request. Cards for
:hover,:focus, and:activeshow both sides in that state. A highlight overlay marking the change is one click away. - The table lists every changed computed property with its old and new value. Properties that follow
currentColorare nested under the colour that drove them. - Warnings appear only when something is wrong, such as a page that was not captured or captures that may not show the same product state. A fully certified run keeps that evidence collapsed.
- Approve all changes turns the
StyleProofcheck green once a reviewer with write access, other than the PR author, ticks it. A later push that changes styles or surfaces reopens it.
The check compares the browser's computed CSS, not pixels. The crops are there so a person can see the change. The
pass or fail decision runs on the exact values in the table. Surface-key vocabulary (@width, live-state and popup
variants) is in docs/REFERENCE.md. Declare mediaStates to add dark-theme,
print, or forced-colors captures; see media environments.
Where the report lives. On a pull request, the PR comment is the same linked summary for public and private repositories. Reports publish as workflow artifacts by default. The comment links the artifact on the run, and nothing is written to repository git history. Images travel inside the self-contained HTML.
Report artifacts contain only report.html, report.md and report.json. Open HTML offline for embedded images. Set report-max-bytes (default 2000000) to cap their combined size; HTML gets what Markdown (400 KB cap) and JSON (450 KB findings cap) leave. For pre-existing receipts that exhaust the combined budget, the library, CLI and Action retain them and apply the cap to HTML when the remaining bytes cannot hold report text; HTML explicitly announces this combined-budget overrun. Crops may be downscaled to fit the 300 KB per-image limit. Full findings remain in the original diff input, which is not included in the report artifact; preserve that input separately. Omissions are named; capped JSON adds findingsBudget and positive per-surface findingsOmitted counts. Local reports support --report-max-bytes and --html-max-bytes. Branch storage keeps crop files.
Reports cap crops at 60 files and 900,000 bytes across all surfaces. Crops over 300 KB are downscaled, then omitted if they still exceed the per-file cap. Local reports support --max-crop-files and --max-crop-bytes (0 disables crops); omissions are named, and every change remains listed and counted.
Every report viewer needs an authenticated GitHub session, because artifact downloads are never anonymous.
report-storage: branch opts into the styleproof-reports orphan branch for an in-browser rendered report, with
anonymous access on public repositories only. PR reports use pr-<n>/; runs without a resolved PR use
run-<run_id>/. Branch sweeps prune closed PR reports and dated run reports oldest first, protecting open PRs
and folders whose age is unknown. If publication or receipt verification fails, StyleProof posts no delivery claim for that run.
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:activestyle, plus:checked,:disabled, and:placeholder-shownchanges on applicable form controls (with an independentmaxFormStateScanWorkcapture allowance); - 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 restyle on an element the PR also re-nested (a wrapper added or removed), paired back by geometry so the structural churn cannot hide it;
- 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.
- A surface is one UI state to certify: a route, tab, modal-open state, dropdown-open state, toast-visible state, loading state, etc.
- You list or auto-discover surfaces in a Playwright-style spec.
- StyleProof opens each surface at real breakpoint widths and records computed styles for every captured element.
- On a PR, it compares base vs head and reports exactly which rendered styles changed.
- The PR gets a
StyleProofstatus: green when nothing changed, red until someone approves intentional changes, or failing when certification mode is configured.
Selective remap
is opt-in. A changed file reached only through a computed import re-captures
every surface; recovered imports can widen a resolved dependency, but cannot
make an unplaced change selective. Changes reaching a conventional framework
wrapper (Next layout/_app, SPA App/main, Remix root, SvelteKit
+layout, Nuxt app.vue/layouts/, Gatsby gatsby-browser) before a declared
surface also re-capture everything. SPA page changes stay selective, and test, story,
script, or undeclared-route importers alone do not force a full re-capture.
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.
Forced-state reads share a browser round trip with their liveness check and style flush. Each reset still flushes before the next state or target; captures remain serial, with the same coverage and resource limits.
Nested overflow scrollers are expanded for full-page report screenshots. <body>
is a candidate only when it scrolls itself: the root's computed overflow-y must
not be visible, which would propagate body overflow to the viewport. Capture
keeps rect in the driven page's document coordinates for correspondence and layout
comparisons; optional screenshotRect locates the element in the expanded image.
Crops and pixel attribution prefer screenshotRect, falling back to rect.
Recapture nested-scroller baselines for aligned evidence.
When a surface scrolls the document, capture also records the viewport's scroll
origin, the driven document's scroll height, and viewport.screenshotHeight measured
with screenshot scrollers expanded. A non-hidden element addition or removal requires
review if either its non-zero driven rect intersects the original driven bounds,
or its non-zero screenshotRect ?? rect intersects the screenshot bounds. Both use
horizontal bounds scrollX to scrollX + width and vertical top min(0, scrollY).
The driven bottom remains max(scrollY + height, documentHeight ?? Infinity);
the screenshot bottom is screenshotHeight. The gate is never laxer than before:
more additions/removals can become reviewable after expansion.
When any scroller is expanded, elements inside unexpanded scrollers can also get
screenshotRect; the gate does not infer per-ancestor clipping. Missing or invalid
(non-finite or non-positive) screenshotHeight fails closed with an unbounded
bottom, even with documentHeight. Hidden and zero-opacity additions stay advisory;
zero-size additions stay advisory when neither box has non-zero size.
Horizontal-scrolled changes retain report crops.
Older maps lack the origin and assume zero; recapture affected scrolled baselines
after upgrading.
StyleProof requires Node 22.13 or later, locally and in CI.
Capture and CI browser installation run the project's @playwright/test/cli with Node when it resolves from the project directory. They fall back to Playwright on PATH only when the project CLI cannot be resolved. On Windows that fallback cannot run batch shims, so install @playwright/test in the project.
Capture refuses an already running Playwright webServer before writing maps.
Stop that server so this commit's build runs, or use styleproof capture --external-server
to capture it deliberately. Set BASE_URL for styleproof ci or the Action.
These opt-ins override reuseExistingServer: false for that run. New scaffolds disable reuse.
Existing Playwright config files stay consumer-owned; capture enforces the same refusal policy for them.
Validation errors name your selected Playwright config.
StyleProof 8 runs in CI. Upgrade the npm package and the report Action to major 8 together, then refresh and verify the generated workflows with the same server/spec options you used at install:
npm install --save-dev styleproof@^8
npx styleproof-init --upgrade
npx styleproof-init --check--upgrade moves the generated workflows to @v8 and removes the pre-push hook
an earlier release generated. Under --storage branch it also adds the
publish-base job, which publishes each default-branch commit's map from CI.
styleproof-map keeps a local capture local; pass --upload when a script of
yours publishes to the map store. See the
8.0.0 changelog.
Upgrade the npm package and the report Action to major 9 together, then refresh and verify the generated workflows:
npm install --save-dev styleproof@^9
npx styleproof-init --upgrade
npx styleproof-init --check--upgrade moves the generated workflows to @v9. Update any hand-written
uses: BenSheridanEdwards/StyleProof@v8 or styleproof-approve-reusable.yml@v8
pin to @v9 too: the Action runs its own copy of StyleProof, so an @v8 pin
keeps the 8.x gate. Then re-capture the baseline once. See the migration list
in the 9.0.0 changelog.
Empty hash home routes (#/, #!/, including a query) count as the page without a fragment, so home links do not capture the root twice.
For a defineCrawlCapture root that lands on a hash route such as /#/home,
replace the old index key with home in crawl expected / exclude lists
and acknowledgement files. Hash-route fragments stay verbatim: if your app
treats #/settings and #/settings/ as aliases, render one canonical link or
use defineCrawlCapture's match option to select one form.
npx styleproof setupThat one command detects npm, pnpm, Yarn, or Bun; installs StyleProof and
Playwright; installs Chromium; scaffolds the capture spec, a dedicated
production-build Playwright config, and one GitHub workflow; then verifies
every machine-owned file against the installed release. The default scaffold is
the smallest honest gate: one pull_request job captures base and head in the
same run and diffs them — maps live as workflow artifacts and the gate runs
entirely in CI — and it runs advisory: it reports evidence on every
PR without blocking. Preview the exact operations without writing with
npx styleproof setup --dry-run. 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.
Opt-in flags reproduce the heavier architectures:
--workflow split— a read-onlypull_requestcapture job plus a trustedworkflow_runreport job. Required when the repository accepts fork or Dependabot PRs, whose tokens are read-only. Fork verdicts are advisory: the fork's code produces both map sets, so a fork PR'sStyleProofstatus stayspendinguntil a maintainer ticks Approve all changes — never green automatically.--storage branch— the SHA-keyedstyleproof-mapsstore branch, a CI job that publishes each default-branch commit's map, so CI restores maps instead of recapturing them.--mode certify/--mode review-gate— fail on any diff, or hold theStyleProofstatus red until a reviewer approves (scaffolds the approval workflow). Under--workflow split, the failing report job runs on the default branch, so certify also sets the PR'sStyleProofstatus red on any diff; certify approves nothing, so a fork PR's status stayspending.split + branch + review-gatereproduces the pre-v7 scaffold exactly.
Generated workflows cancel superseded capture/report jobs for the same PR or head
repository and branch. Failed captures and cancelled runs end with a failure
status requesting a re-run, without rewriting the report comment. Late reports
preserve the comment for the current PR head and a proven later run's status on
the same commit. Run npx styleproof-init --upgrade to adopt this scheduling and
capture-failure handling in existing workflows. Failed captures write a status
only while the PR is open at the captured head. Captures awaiting workflow
approval report that approval is needed before a re-run. Cross-run comment
receipts must come from github-actions[bot]; same-run attempt receipts retain
their existing bot or verified writer checks.
Existing scaffolds keep their architecture: a marker in the generated workflow
records the chosen mode, and styleproof-init --check / --upgrade verify
that mode's file set instead of rewriting it. To switch an existing scaffold,
pass the new flag to --upgrade, for example
styleproof-init --upgrade --mode review-gate. A plain re-run with a different
flag stops before writing anything.
In cache-first CI, ancestor baseline reuse requires no capture-relevant changes.
Every supported styleproof.config.ts / .mjs / .js / .json is relevant,
including package configs; source paths retain Unicode and control characters,
and renames check both locations. A relevant change forces a fresh base capture.
After a cold base capture, CI can reuse head npm dependencies when the consumer started at head,
package metadata is unchanged, and npm's hidden lockfile matches (allowing platform-excluded optional packages).
This does not verify install scripts ran; cached or --ignore-scripts installs are used as-is.
Parallel head captures run largest base maps first; parallel: false and report ordering stay unchanged.
The TypeScript config file itself supports ESM syntax regardless of package type;
local ESM .ts helper imports still require a module project.
Once the config body starts, a loading error is reported without retrying it with
type stripping or suggesting a different Node version.
The map CLI evaluates JavaScript and TypeScript config in a separate process, so
cold-base npm checkouts can use their own Playwright installation without
conflicting with the head CLI. JSON config loads without an extra process. Config
errors retain concise messages without a Node stack trace.
For CommonJS projects, config-loading children suppress Node's module-type warnings
for the generated configs and its type-stripping warning. The filter travels in
NODE_OPTIONS, so it also reaches the Playwright run and any webServer command it
starts; there it hides the type-stripping warning and, on early Node 22.x, Node's
file-less "To load an ES module" hint. Other warnings and your NODE_OPTIONS remain
in effect, and a config's own warnings now print instead of being hidden.
JavaScript config (styleproof.config.mjs or .js) also supplies coverage and
product-state policy to capture specs, diff, and report. It takes precedence over
sibling JSON; evaluation errors and invalid policy fail closed. A .js file
follows its package's Node module format, and dynamic policy sees the caller's
working directory.
Config discovery checks styleproof.config.ts, styleproof.config.mjs, styleproof.config.js, then styleproof.config.json, nearest first. The start folder and its config are always accepted. Trusted parent folders on POSIX are owned by you or root and are not world-writable; a parent whose metadata cannot be read is also unsafe. Group-write is allowed, so members of that group can plant a config. The first unsafe ancestor ends discovery; if a config exists there or above it, a stat-only styleproof: warning: names the skipped config, unsafe folder and reason once per process without reading or evaluating the file. It says the file was not loaded, built-in defaults apply, and to run from the folder that holds the config; on POSIX, alternatively fix ownership or remove world-write permission. A config file found in a parent folder must pass the same owner and world-write checks as its folder; otherwise that warning names it as an unsafe config file and discovery ends there. A symlinked config is checked at its target file, not the target's folder. Any .git file or directory in an accepted folder ends discovery. macOS ACLs are not considered. A discovered .ts, .mjs or .js config is evaluated and runs with your permissions; a .json config is parsed. On Windows, no owner or write check applies to parent folders or their config files. The nearest folder at or above the start folder that holds any .git file or directory counts as the repository root, and every folder from the start folder up to it is searched. Outside a repository only the start folder is searched, with the same warning if a parent config is skipped, naming the Windows rule. Missing-config and missing-spec errors name the skipped file instead of claiming no config was found. The .git entry is not validated, so anyone who can create a .git in a shared ancestor (for example C:\Users\Public or D:\) and a styleproof.config.* in that folder or any folder between it and the start folder can get that config loaded, without the skipped-config warning, by StyleProof started in a non-git folder below it. On Windows, keep a config in the folder you run from (the nearest config wins, so no ancestor config is read), run inside the project's own git repository (its .git ends discovery), and avoid running from below folders other users can write to. These Windows rules have unit tests that set process.platform to win32 on macOS or Linux: the outside-a-repository skip, the .git boundary walk up from a nested folder, the stop at the nearest .git, and the planted-.git case, including the missing owner and write checks. None of these Windows rules is verified natively, including Windows ACLs and upward discovery across drive-letter or UNC paths; the Windows CI smoke job loads only a config in the start folder. Run from the folder that holds the config to preserve loading in Windows non-git projects, root container jobs below runner-owned checkouts, world-writable checkouts, or parents owned by another non-root user.
See the config key table for project defaults.
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 # CI orchestration: restore probes, cold captureCapture workers default to max(ceil(cpus / 2), cpus - 2), using Node's
os.availableParallelism() (12 CPUs → 10 workers; 4 → 2; 2 or 1 → 1).
Set STYLEPROOF_WORKERS to a positive integer to override this. Explicit
Playwright --workers / -j arguments take precedence over the environment;
otherwise the environment overrides top-level workers in the Playwright config.
Without an environment override, config workers are preserved, and an unloadable
config also keeps Playwright's own worker selection.
Cold-base CI captures reuse the head’s StyleProof tarball or Git spec (local tarball paths resolve against the head package directory). Git branch and tag refs are resolved again and may change; use a full commit SHA to pin the build. Registry declarations, directory artifacts, link: specs, and missing consumer manifests use the running CLI’s exact version.
If the map capture process is terminated by a signal, CI stops and cleans up its temporary worktrees. An interrupted baseline does not start a head dependency install or capture; the command exits with the signal's conventional exit code (130 for SIGINT, 143 for SIGTERM).
Diff and report operations reuse parsed maps across their audits, including catalogues larger than the 128-map process cache. The operation cache is released on success or failure.
Run styleproof --help for the whole journey or styleproof <command> --help
for command-specific options. The existing styleproof-* binaries remain as
backwards-compatible aliases for one major version.
Capture directories recognize only <key>@<width>.json and <key>@<width>.json.gz
map filenames (a nonempty key and integer width). Other JSON files are ignored.
Writing diff or audit JSON beside captures changes the directory's evidence digest
for later diff or report runs; keep outputs outside capture folders when matching evidence receipts.
npx styleproof capture # capture this commit's computed styles
npx styleproof compare # compare against the base branchcapture writes the current commit's map under .styleproof/maps/current
with a manifest; compare restores base and head and fails closed when the
evidence cannot certify. Maps travel as workflow artifacts by default —
never committed to the PR branch — or via the opt-in styleproof-maps store
branch. Nothing under .styleproof/ belongs in a commit.
compare --capture-base opts into capturing missing maps locally, installing dependencies in a temporary detached base worktree under <repo>/.worktrees/ and removing it before processing the head. Without a stored head bundle for HEAD, head is captured in the current checkout, preserving uncommitted edits; otherwise the stored bundle wins. Stop any app server first so both captures use their own builds. The flag is off by default and cannot be enabled through config or used with two explicit directories.
During base install/capture, compare ignores SIGINT/SIGTERM and follows the child; Ctrl-C (process-group SIGINT/SIGTERM) exits 130/143 after cleanup. SIGTERM sent only to compare is ignored: container/systemd stops that signal only PID 1 wait for the child or escalate to SIGKILL. Interrupts at other moments, SIGHUP, SIGKILL or a crash can leave <repo>/.worktrees/styleproof-base-* behind; the next --capture-base run removes only hour-old, current-user scratch dirs with confirmed dead owners. The base checkout is nested under the repository: Node module resolution, postcss/browserslist config and pnpm workspace detection can pick up head root files when the base lacks its own.
compare restores the base map from the map store, so on a local checkout without
one it stops and prints the local steps. Capture this checkout as head, then the
base from a clean worktree, so uncommitted changes never leak into the base
(switching branches would carry them along and hide them from the diff):
npx styleproof capture --dir head
git worktree add --detach .styleproof/base-checkout origin/main
(cd .styleproof/base-checkout && npm ci && npx styleproof capture --base-dir ../maps --dir base)
git worktree remove --force .styleproof/base-checkout
npx styleproof-diff .styleproof/maps/base .styleproof/maps/headStop any server already running on the app's port first: capture refuses it so each capture builds and serves its own commit.
For the hand-wired Action form, the fork/Dependabot split, the map-store and report branches, the CI base publisher, and every config key and flag, see docs/REFERENCE.md.
The report Action defaults to comparison-base: pull-request-base: it expects
captures from the trusted PR base tip B and captured head H, without a comparison
API call. To compare the fork point instead, explicitly set
comparison-base: merge-base on an Action ref containing this feature. It derives
A from GitHub's immutable B...H comparison, then requires genuine A/H captures.
This does not change the capture workflow or relabel existing maps. See
merge-base setup for producer/verifier
agreement, private-repository permissions, stale-history limits and fork approval.
Action jobs reaching the approval gate (review-gate, advisory, migration, require-approval)
require actions: read to verify the issue-comment approval run.
The scaffold's default gate is advisory — the evidence posts on every PR without blocking. These are the two gate modes you opt into:
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.
Starting in 10.0.0, a completed review-gate run passes the job even while
changes await approval. Require the StyleProof status check (or your
status-context name) in branch protection or a ruleset to block merges. Ticking
Approve all changes turns that status green without re-running the job.
Repositories without branch protection, including free private plans, no longer
get a red job for unapproved changes. Config blocking: true and false remain
accepted and no longer affect the job conclusion; blocking: 'advisory' keeps
its advisory meaning. Capture/setup failures and evidence gates still fail the job.
In both the reusable and example approval workflows, re-running an approve run
by anyone other than the original ticker is refused;
tick the approval box again. A StyleProof re-run carries approval forward only
for the same commit, reviewable diff and trust state. Approvals recorded before
upgrading do not carry forward; tick again once. Repositories that copied example/styleproof-approve.yml or pin the reusable approve workflow separately from the Action must update it with the Action, or re-runs after a tick will not keep the approval.
Unticking preserves existing failure diagnostics. During an in-flight StyleProof re-run, it writes a withdrawn status so the old approval receipt cannot carry forward.
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.
When running multiple Action instances, pass each instance's comment-marker
to the reusable approval workflow as well (default <!-- styleproof-report -->).
Approval uses it to select the matching comment and report artifact.
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.
A mixed spec/ad-hoc pair is an unverified diagnostic; its JSON records
sourceBinding.compatibility: "runtime-only" (see
Match a design pixel-for-pixel).
A passing check is more than "no style changed". Four gates qualify it, and the report leads with their verdicts:
- Coverage — the
expectedregistry 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-unasserteddiagnostic mode). - Determinism — the ledger records how each capture proved itself
(
self-checked/replayed); a green from anunprovenor unknown capture blocks — including areplayedrun where any surface had no replay HAR and was captured live (the map records it) — because a clean diff of two nondeterministic (or pre-ledger) reads could just be luck. Pass--allow-unassertedonly for explicit diagnostic compares (certifiesFully: falsein JSON). When stylesheet-free UA defaults differ between base and head, the CLI warns on stderr with the affected surface count and up to three distinct tag.property changes, plus an overflow count. Only tags and properties recorded on both sides are compared, since defaults are collected lazily. This host drift warning is advisory; findings remain except for proven placeholder reflow echoes. - Inventory — with
inventory: true(on instyleproof-initscaffolds), 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 thestyleproof-diffCLI and in the Action, both modes — until acknowledged instyleproof.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": falseinstyleproof.config.json. Withoutinventory: truethe check does not run, and StyleProof says so: the report reads⚠ not checkedrather than✓ navigable set unchanged, and the Action warns that the gate could not run. An empty inventory diff is indistinguishable from "nothing was removed", so a ✓ there would claim a guarantee the run never earned. - 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 instyleproof.data-residue.json, and a stale declaration also fails. Failures from the surviving document remain attributable after a canceled navigation or download followed by SPA navigation. Opt down withdataResidue: 'warn'. See Failed data request. - Product-state identity — a pair with matching
productState {id, revision}is comparable and can certify (the only certifying path). Pairs with no declared product state stay on the visual-review path until you arm the declare gate: addstyleproof.product-state.json({"<surface>": "<why>"}), pass--legacy-pairs, or setproductState.requireIdentity: true/--require-state-identity. StyleProof's own live dogfood stampshome@*with matching identity and keeps the ledger file armed but empty so new undeclared pairs cannot stay green. Armed and undeclared → fail closed (CERTIFICATION_FAILED) in the CLI, Action verdict, and PR comment. Armed and declared → explicit advisory, never a certified green. Details: docs/product-state-comparability.md.
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).
StyleProof certifies only what it captured: a surface that was never captured
has no base and no head map, so its change can never appear in a diff and the
gate stays green having never looked at it. The coverage guard
(expected/exclude), the confidence ledger, the inventory guard, the
data-residue gate, and product-state identity exist to make every one of those
gaps loud instead of silent — full rules in
docs/REFERENCE.md.
The sharpest form of the boundary: maps prove only the states the spec
exercises. A restyle confined to a conditional render branch the fixture never
drove — a fault overlay, an empty state — produces byte-identical honest
captures on both sides. Every conditional branch whose styling matters needs a
surface that exercises it; see
the un-exercised-state gap
and what the crawler can and cannot reach.
With captureReach and changeReach: 'gate', a changed CSS rule no captured
state matched fails certify mode and needs approval in review-gate mode.
Captured forced :checked, :disabled, and :placeholder-shown reads count as reached
when their layer and a compared owner are recorded; omitted layers do not establish forced reach.
Exact resting form-state matches remain valid with layer metadata present, including inside
selector functions. Only top-level form-state tokens are relaxed, with every relaxed candidate compared.
A changed stylesheet no capture loaded, such as a lazy route's or code-split chunk's CSS, is not checked yet: the report labels it Not checked, gives capture guidance, and the gate does not hold it.
A matched rule whose declarations lost the cascade, or changed no computed value StyleProof records, in every captured state still reads reached.
Reader failures preserve the capture and record reach unknown with no rule evidence, listed under changeReach: 'warn' and held only under opt-in changeReach: 'gate'.
Rules matched only outside compared elements stay unmeasured and also hold
the opt-in gate. Global state and pseudo-element probes exclude the head subtree and
framework-ignored elements, including the hover sink. Full-page capture preserves inline style attributes, including
on nested scrollers under strict style CSP, so its temporary expansion does not invalidate reach.
captureScriptReach: true separately opts into J2 script evidence (off by default):
defineStyleMapCapture({ surfaces: SURFACES, captureScriptReach: true }). Each
capture recognises Playwright bindings and clock scripts on Playwright 1.61.x
(verified with 1.61.1), including init wrappers and direct evaluations carrying stack traces.
Init scripts injected into frames created by capture or by app code carry that caller's stack.
On other versions, unrecognised injected scripts stay listed as named unmeasured entries.
StyleProof init scripts and reads are excluded by their source URL.
App anonymous and eval scripts stay named unmeasured. Each
surface@width stores original-source executed, mixed and unexecuted anchors plus
content hashes, stable source keys and the captured git-relative projectRootPrefix in StyleMap.scriptReach. An executed anchor means its block was entered; code after any expression that threw in that block is not proved.
Calling a generator or async generator can mark its body executed without iteration. A file whose raw text contains a generator-like pattern has its ran evidence downgraded to unmeasured; this may include files with no generator. The raw patterns are the word \byield\b, function GAP *, or * GAP KEY GAP ? GAP <GENERIC> GAP ( with ? and <GENERIC> optional. GAP permits whitespace, block comments and line comments. KEY accepts optional # plus (?:[\p{ID_Continue}$\\]|\{[\dA-Fa-f]+\})+ with Unicode semantics, including fixed and braced escapes with arbitrarily many leading hex zeros, numeric literals including .5 and 0x…, quoted keys '(?:\\[^]|[^'\\])*' / "(?:\\[^]|[^"\\])*", or computed keys equivalent to \[[^]{0,512}?\]. Generic contents are equivalent to <[^]{0,512}?>, including parentheses and newlines. Bounds count Unicode characters. A generator with no yield whose computed key or type-parameter list exceeds 512 characters is not detected. Quoted keys accept escaped CR, LF, CRLF, U+2028 and U+2029 line continuations. All source positions are considered; strings, comments, regex bodies and templates are not excluded. Matching raw text, including yield in a string/comment or multiplication-like text, intentionally flags. The whole-file downgrade applies before corroboration, including changes outside a generator and actually iterated bodies. Negative or missing evidence retains its existing status. Whitespace/comment gap ends are precomputed once; key and suffix results are cached, and computed/generic lookaheads inspect at most 512 characters. The scan is linear in file size with those fixed bounds, without a backtracking whole-header regex.
TypeScript .ts, .mts, .cts sources and embedded lang="ts"/lang="typescript" or type="text/typescript" scripts (and Astro script blocks by default) use TypeScript token boundaries; angle assertions cannot create JSX text execution proof.
It does not prove every expression evaluated. J2 residuals are default parameter values,
destructuring defaults, logical assignment RHS (||=, &&=, ??=), and later links
of short-circuited optional chains. The J2 join keeps these limits visible as unknown when execution is the only proof. Unmapped code supplies no execution proof. Project-root-relative source keys preserve package directories (config directory, then git root);
outside-root sources and collisions read unknown. Relative map sources that climb above
the URL root drop only the escaping .. segments if their own sourcesContent entry
is present and sourceRoot is absent or relative, yielding a content hash.
sourceRoot itself cannot climb above the URL root. A clipped key requires a regular
local project file inside the real project root whose SHA-256 matches that source's
embedded content. Only clipped original files are read, locally and with an 8 MiB
cap. Captures without the matching project files, including remote URL captures
with a different project root, record clipped sources as unknown. Missing content,
hash mismatches and scheme-prefixed traversal stay unknown. Clipped keys still invalidate on collision. Any script-evidence drift during a
self-check records script reach unknown, including storage-dependent first and repeat visits.
Stored URLs omit queries/fragments; source keys omit checkout paths and bundler namespaces. Enable source maps in the capture
build; missing maps read unmeasured. The window runs from navigation through
the map read, so transient loading branches count as executed. Execution alone
does not prove a value rendered. J2 joins changed script ranges against the actual base
and head map manifests' SHAs, including a reused ancestor baseline. Removed ranges
use base evidence; added ranges use head evidence. The report names execution, source file, line, side and reason under reach.scripts. Markdown and HTML merge only adjacent whole-line findings and show at most 100 table rows. JSON retains bounded J2 detail with full status counts. Executed values stay unknown without exact-range J1/J3 rendering proof;
never/partly run expressions are never-rendered. Missing maps, hashes, git or SHAs
stay unmeasured. Coarse mappings retain mixed evidence unless another anchor on the same source and original line represents the hidden state; empty changed lines and line-ending-only changes stay unmeasured. Token length changes and joins retain both sides. Empty edits use a touching token, preferring the left neighbour; unmatched sides and operator-only edits use line scope. Numeric, private-name, unambiguous regex and JSX-text ranges retain their starts. When a range has no anchor, the nearest preceding anchor in that source supplies only labeled negative evidence, including across uninstrumented lines. Only a token-start anchor proves that script token ran; closing delimiters and other punctuation supply negative evidence only. Every tokenful part of a range is accounted for. JSX text takes precedence over comment/template syntax; attribute expressions cannot turn into text tokens. Ambiguous regex/comment tails and quoted or slash-prefixed tails after JSX child expressions or closing tags supply negative evidence only. Template substitutions need their own starts; a live template head cannot prove a folded-away expression ran. An executed preceding start reads unmeasured because it cannot prove this code survived bundling or ran. Leading tokens before a range's first mapped start also retain this negative-only evidence, so a later executed fallback cannot hide an idle template tail. Mixed evidence can be cleared by execution only from the same script URL. Capture explicitly marks resolved vendor-only maps; these add no J2 gap, while missing/failed maps and ambiguous older evidence still do. JSON gives style findings priority, compacts only whole-line units and shares a 64 KB budget across J2/J3 units, gap records and path lists, with named truncation counts. Markdown bounds these lists too and queues reach after chrome and content detail. J2 is always warn-only, including under changeReach: 'gate',
and its unresolved units or gaps prevent reach-complete. Changed paths J2 does not judge remain counted in a run-level gap with a short preview and bounded path detail, including unsupported templates and files without text hunks. Comparison uses the root prefix recorded by capture; missing or mismatched provenance keeps changed lines unmeasured under their git paths. Early returns and self-check rewrites retain provenance; failed root lookups stay named. J3 joins changed class-token occurrences with gained/lost classes on paired compared elements.
A string is a class source when every word is in the served CSS class vocabulary, or when it is passed to a class attribute/helper; ordinary prose is skipped.
An occurrence is never-rendered only when its token appears on no captured element on its own side (head for additions, base for removals), including unpaired elements, and every map on that side records no capture omissions. Volatile elements, a configured ignore list, untraversed open/closed author roots or embedded documents, omitted head content, html children outside head/body, route-announcer author children, unmatched surfaces, or legacy/version-1 omission facts leave absence unproven (unknown). Omission facts are collected and written only with captureReach; default capture output is unchanged. Version-2 facts require CDP and document-boundary verification; older version-2 maps lacking the latter remain unknown; self-check marks changing omission facts unstable and never clears an unknown from a cleaner read. Any presence means unknown ("token present on a captured element; not tied to this occurrence"), even with no class-list delta. A class-preserving refactor therefore stays unknown. No per-occurrence render provenance is recorded, so no J3 unit reaches or upgrades J2. Explicit evidence follows a positive syntax allow-list over the existing lexer, with a bounded bracket/work stack and no new runtime dependencies: class attributes and .className = values, direct class-helper arguments and their array elements/object keys, ternary result branches and logical (&&/||/??) right-hand results inside positive values, classList.add arguments and the second classList.replace argument. Calls/tags, predicates, lookups, callbacks, unary expressions, type positions and ordinary assignments cannot inherit a positive role. Type mode covers nested generic, tuple, conditional and parenthesized types as well as inline annotations, including compact closer/initializer boundaries; runtime initializers, function bodies, parenthesized arrow returns, ASI statements and labeled blocks resume class discovery. Unsupported lexical forms can still omit a candidate and its gap; an absent J3 row does not establish that a line was checked. Queries/removals/toggles, dataset/data-class and vocabulary-only candidates stay J3-only. Known cva class values and simple framework bindings are supported; plain class properties remain an explicit lexical ambiguity for Vue h() props. Replacement matching preserves unchanged token/role/slot sequences across whole hunks, including extracted constants on other lines. Template depth limits always name the unmeasured remainder, even outside class contexts. reach.classes, classCounts and named J3 gaps stay warn-only at both levels and prevent reach-complete. Unresolved explicit expressions are summarized per file and side with a full occurrence count and ten sampled ranges; multi-megabyte files retain bounded report detail. See script evidence. The
Change Reach 0.2 receipt records capture cost and a limited
six-PR noise replay, with its exclusions and labelling limits.
If settling times out with requests in flight, capture continues with a warning and records the request
count in metadata.settleTimedOutWithRequestsInFlight. Settle the page in go() or raise
stabilize.timeout if the capture shows a loading state.
Every flag, config key, Action input, workflow file, hook behavior, exit code, and optional layer lives in docs/REFERENCE.md. Deeper contracts: what it catches · component manifests · inventory guard · product-state comparability · report delivery · setup server contract · forced-state limits · consumer pin bump.
Failed capture setup steps report their one-based step number, action, and author-written
selector (except for goto). Driver URLs, field values, and original causes are omitted.
Repository CI runs the full browser suite in four file-level shards alongside the
unit matrix on Node 22.13.0 (the supported floor), 22, and 24. The e2e (node 22)
aggregate compares completed test IDs against a separately collected full
inventory and requires the five-run determinism receipt. Missing, duplicated,
skipped, failed, or retried tests fail verification.
The e2e-shard-* artifacts are kept for 1 day and used only by the e2e (node 22)
check. Local pnpm run test:e2e still runs the complete suite.
Pull-request CI skips browser lanes only after proving that the checked-out
two-parent merge has the event's base as its first parent and exactly the same
root Git tree as that base. scripts/browser-lanes.mjs makes this decision
without listing filenames or querying the changed-file API. Any tracked content,
name, mode or gitlink difference runs the lanes, including prose and tooling
changes. Unknown or invalid evidence runs them too. Push CI always runs them,
including empty-tree pushes. The live StyleProof dogfood job always runs for
eligible PRs because it compares the PR head with its base, not the merge tree.
pnpm run demo:check compares demo Markdown and HTML plus decoded crop pixels.
The local pre-push check uses the same proof policy over pushed endpoint trees
and the staged index. Git reads disable replacement objects and remove local Git
environment overrides. An up-to-date or deletion-only push may skip browsers
only with an unchanged staged tree; first pushes and missing metadata run them.
A file deletion still changes the tree. Missing Chromium or malformed push
records block the push; unreadable Git evidence runs browsers or blocks it.
The browser command checks the current checkout. Install Chromium with
pnpm exec playwright install chromium before pushing changes.
See CONTRIBUTING
for the dev loop, and .agents/README.md
(where AGENTS.md and CLAUDE.md point) for the operating rules and agent tooling. The repo
is wired for Claude Code with GitNexus
(code-intelligence graph — MCP server in .mcp.json, skills in
.claude/skills/gitnexus/) and Graphify (/graphify knowledge graph). The
GitNexus index (.gitnexus/) and Graphify output (graphify-out/) are gitignored;
build the index with npx gitnexus analyze --skip-agents-md.
MIT © Ben Sheridan-Edwards







