bettercss
by ikraamg
README.md
# csstruth
[](https://github.com/ikraamg/csstruth/actions/workflows/test.yml)
**Hard ground truth for CSS.** csstruth extracts the browser's *actual*
rendered layout — positions, boxes, the cascade — as deterministic, diffable
text, so coding agents (and humans) stop guessing what rendered.
Backend code gets real feedback: tests fail, APIs return status codes,
databases have state. CSS gets… pixels and vibes. Screenshot diffing is fuzzy
and nondeterministic; poking DevTools by hand is slow and unrepeatable.
csstruth gives layout the "backend treatment": structured truth you can
assert against, byte-identical across runs, with every violation traced to the
source rule that caused it.
```
$ csstruth verify http://localhost:3000
VERDICT: PASS
checked 3 viewports: 375x800=clean, 768x800=clean, 1280x800=clean
```
```
$ csstruth check http://localhost:3000 --hover .cta
parent-bleed: a.cta bleeds 100px outside div.rail (child 400px wide, parent 300px)
suspect: width: 400px @ main.css:4
```
```
$ csstruth explain http://localhost:3000 --selector .sidebar --property width
.sidebar width = 240px
✓ width: 300px sidebar.css:1 (.sidebar (0,1,0)) — computed 240px differs from
declared 300px — constrained by max-width: 240px @ main.css:1
✗ width: 100% reset.css:1 (div (0,0,1)) — lost: lower specificity (0,0,1) vs (0,1,0)
```
## How it works
Chromium's DevTools Protocol exposes everything DevTools itself knows: one bulk
`DOMSnapshot` call returns the full DOM with layout boxes and computed styles;
`CSS.getMatchedStylesForNode` returns the complete cascade for any element —
every rule that matched, its specificity, and the stylesheet position it came
from (source-mapped back through your build). csstruth packages that truth
into 12 composable CLI commands (11 of them also exposed as MCP tools —
`watch` is CLI-only, a streaming daemon doesn't fit MCP's request/response
shape) instead of megabytes of protocol JSON.
The core representation is the **LayoutTree**: one line per rendered element,
deterministic (same render → byte-identical text), with warnings inline:
```
body (0,0 1280x623)
header#top (0,0 1280x64) flex row pad:0,24
span.logo (24,25 55x15)
nav (964,13 292x39) flex row gap:8
main (0,95 1280x480) flex row
aside.sidebar (0,95 240x480) pad:16
section.content (240,95 1040x480) pad:24
div.card ×6 (~317x140)
```
Deterministic text is what makes layout *diffable* — "did my CSS change break
anything?" becomes a structural diff with exact px deltas, not a flaky
screenshot comparison.
## The tools
| Tool | What it answers |
|------|-----------------|
| `verify` | **"Is this OK?"** — one call: invariants across a viewport sweep (default 375/768/1280) + optional snapshot diffs. First line is always `VERDICT: PASS` or `VERDICT: FAIL (…)`. |
| `check` | Layout-bug scan: viewport overflow, visible parent bleed, clipped text, unintended overlap, zero-size/tiny tap targets — exact px + the suspect rule at `file:line`. |
| `baseline` | Snapshot the CURRENT violation set as a sorted, diff-friendly file — the fix for adopting `check`/`verify` on a page that isn't (and may not soon be) fully clean. Pass the resulting file to `check`/`verify`'s `--baseline` to report only NEW/RESOLVED violations instead of an all-or-nothing PASS/FAIL. See **Adopting on a brownfield page** below. |
| `fix` | Propose (default) or **apply** mechanical patches for fixable violations (clipped text, tiny tap targets, a fixed px width bleeding/overflowing). DRY-RUN unless `--apply`; writes are confined to `--root` and guarded by a stale-source check. See **Safety** below. |
| `blame` | **"Which commit broke this?"** — walks a git repo's history backwards (newest→oldest, capped at 25 commits by default) checking out each commit into a scratch `git worktree`, until it finds the first commit where the current violation is gone. Names the culprit (`file:line`-style: sha, subject, date, author) plus the layout delta and violations it introduced. STATIC roots only in v1 (no dev-server/build step per checkout); the user's HEAD/index/working tree are never touched. |
| `watch` | **Live diff stream while you edit.** Holds one page open, polling the layout signature every `--interval` ms (default 500); on a change, prints the layout delta and any NEW/RESOLVED violations under a `[HH:MM:SS]` block — silent otherwise. Survives an HMR full-refresh (`page reloaded`, same URL); stops if you navigate elsewhere or the dev server dies. Ctrl+C for a clean exit. CLI only — see **Use `watch` from a background shell** below. |
| `layout` | The LayoutTree of the rendered page (scope with `selector`/`depth`; auto-budgeted on huge pages). |
| `inspect` | One element in depth: box model, non-default styles, stacking context, why it has its width/height. |
| `explain` | Trace any property to its source: which declaration wins (`file:line`, source-mapped), which lost and why, and what layout constraint overrides the declared value (flex-basis, min/max, grid). |
| `snapshot` | Lock the current layout to a named `.tree` file (per-viewport with `--viewports`). |
| `diff` | Structural diff vs a snapshot: what moved/resized/appeared/disappeared, in px. |
| `stability` | Load-time layout-shift report (Cumulative Layout Shift): what moved, when, and by how much, plus unsized `img`/`video` suspects. Timing-dependent (an observation, not a deterministic snapshot). Score is the raw sum over the observation window; the CWV metric uses session windows — multi-burst pages may score higher here. Scoped to the top frame — shifts inside an iframe aren't observed. |
**Interaction states:** pass `--hover/--focus/--active <selector>` (CLI) or the
matching params (MCP) to force pseudo-states without a mouse — combinable with
`--viewports`, because hover effects that fit at desktop routinely bleed at
mobile widths. For JS-driven UI a pseudo-state can't reach (menus, tabs,
anything behind a click), `--click <selector>` (repeatable, real trusted
click) and `--scroll-to <selector_or_y>` run real interaction pre-steps before
capture — layout/inspect/explain/check/verify only, in order: scroll-to →
click(s) → settle. For animated pages, `--settled` fast-forwards every CSS
transition/animation to its end state before capturing (recommended before
`snapshot`/`diff`/`verify` on anything with a CSS animation); `--at-time N`
seeks to a specific ms instead, everywhere except `snapshot`/`diff` (a
specific frame isn't a reproducible baseline). Runs after interact steps and
before forced states.
**Violations are designed to be real bugs.** The invariants are tuned against
real-world pages (intentional overlays, carousels, SVG internals, and
scroll-managed content are exempt). For a genuinely intentional pattern, put
`data-csstruth-ignore` on the element.
**Safety (`fix`):** dry-run is the default — nothing is written unless you
pass `--apply` (CLI) or `apply: true` (MCP), and `--root`/`root` is always
required so a proposed patch's target file is explicit. Only a handful of
violations have a safe, mechanical fix (clipped text, a tiny tap target, a
fixed px width that's bleeding or overflowing); everything else reports "no
mechanical fix — see suspect" instead of guessing. Writes are confined to
`--root`: a stylesheet URL is resolved and the result is verified to stay
inside it, refusing anything that would escape (including through a
source-mapped `sources[]` path). Before writing, each patch re-reads its
target file and checks the suspect declaration still appears within 3 lines
of where it was seen — a mismatch (e.g. someone else edited the file
concurrently) refuses that one patch with a clear reason; unaffected patches
in the same run still apply. `--apply` always re-runs `check` afterward and
reports `before: N violations → after: M violations` plus any **new**
violations the patch introduced, exiting non-zero unless the fix strictly
improved things. Inline `<style>`/`style=""` suspects are never patchable —
refused, naming the `page:line` to hand-edit instead.
**Adopting `verify` on a brownfield page — baselines.** An all-or-nothing
`VERDICT: FAIL` is useless for confirming a targeted fix on a page that has
standing, known-benign violations, and it blocks `verify` from ever gating CI
on a page that isn't already fully clean (the standard linter-adoption
problem). Fix it once:
```bash
$ csstruth baseline http://localhost:3000 --viewports 375x800,768x800,1280x800
baseline written: .csstruth-baseline (15 violations)
```
Then pass `--baseline` to `check`/`verify` (same `--viewports`, so the keys
line up): violations already in the file collapse to one line, only genuinely
NEW violations are itemized and drive the verdict/exit code, and anything
RESOLVED (in the file, no longer present) is celebrated:
```bash
$ csstruth verify http://localhost:3000 --viewports 375x800,768x800,1280x800 --baseline .csstruth-baseline
VERDICT: PASS (2 resolved, 0 new, 13 baseline)
[1280x800] resolved: parent-bleed a.cta
[1280x800] baseline: 13 accepted violations unchanged
checked 3 viewports: ...
```
A genuinely new violation still fails the build, naming only itself — not the
13 you haven't gotten to yet. When you intentionally accept or fix a batch of
violations, `--update-baseline` rewrites the file to match and prints what
was added/removed:
```bash
$ csstruth verify http://localhost:3000 --viewports 375x800,768x800,1280x800 --baseline .csstruth-baseline --update-baseline
...
baseline updated: .csstruth-baseline (0 added, 2 removed)
- [1280x800] parent-bleed a.cta
```
The baseline file is a sorted, diff-friendly text file — one line per
violation, keyed by `(viewport, rule, selector)` with pixel amounts excluded
(they drift run to run) — so a PR that fixes or accepts violations shows up
as a small, reviewable diff. A baseline's viewport labeling must match the
run it's compared against: capture with the same `--viewports` (or neither
side passes it) you'll later pass to `check`/`verify`. Without `--baseline`,
behavior is unchanged.
## Install
```bash
git clone https://github.com/ikraamg/csstruth.git
cd csstruth
npm install && npm run build
```
Requires Node ≥ 20 and Chrome/Chromium. By default csstruth launches its own
isolated headless Chrome, so it never opens tabs in a browser you have open.
To inspect logged-in or app-state pages in your own Chrome, start it with
`--remote-debugging-port=9222` and pass `--attach` (CLI); `--port N` attaches to
an explicit port. The MCP server is always isolated.
Any viewport ≤ 500px wide — the default sweep's `375x800` leg included — is
emulated as a **real phone** (`mobile: true`, `deviceScaleFactor: 2`, touch
enabled), not a desktop window squeezed narrow. This exercises the actual
mobile render: `<meta viewport>` fallback behavior, coarse-pointer/hover media
queries, and touch feature detection. Reported geometry stays in CSS px
regardless of the device pixel ratio, so a static page's element boxes are
byte-identical to the old emulation, and overflow/tap-target detection is fully
preserved. What legitimately changes is genuinely mobile-specific rendering — a
page missing `<meta name=viewport>` renders at the ~980px desktop fallback, as a
real phone would. Pass `--desktop-only` to force the old squeezed-desktop
emulation (`mobile: false`, DPR 1) at every width.
## Use as an MCP server (the agent loop)
```bash
claude mcp add --scope user csstruth -- node /path/to/csstruth/dist/mcp.js
```
or per-project in `.mcp.json`:
```json
{
"mcpServers": {
"csstruth": { "command": "node", "args": ["/path/to/csstruth/dist/mcp.js"] }
}
}
```
The agent loop this enables: dev server renders → agent reads `layout` →
edits CSS → `diff` shows the actual effect in px → `verify` gates "done".
Note: `snapshot`/`diff` resolve a relative `dir` (default `.csstruth`) against
the MCP **server's** working directory. With a globally-registered server, pass
an absolute `dir`.
## Use from the CLI (CI / scripts)
```bash
csstruth verify http://localhost:3000 # the one-call gate, exit 1 on FAIL
csstruth check http://localhost:3000 --viewports 375x800,1280x800
csstruth layout http://localhost:3000 --selector main
csstruth explain http://localhost:3000 --selector .sidebar --property width
csstruth snapshot http://localhost:3000 --name home --dir .csstruth
csstruth diff http://localhost:3000 --name home --dir .csstruth
csstruth blame --root . --page index.html --selector .sidebar
csstruth watch http://localhost:3000
csstruth baseline http://localhost:3000 --file .csstruth-baseline # once, to adopt on a non-clean page
csstruth verify http://localhost:3000 --baseline .csstruth-baseline
```
`check`/`verify` exit 1 on violations — drop them straight into CI. On a page
that isn't fully clean yet, add `--baseline` (see **Adopting on a brownfield
page** above) so CI only fails on NEW violations, not the backlog.
### Use `watch` from a background shell (agents)
`watch` is a long-lived streaming process, which doesn't fit MCP's
request/response shape — it's CLI only. An agent (or a human) drives it by
starting it in a background shell and reading the stream instead of
re-running `diff`/`check` after every edit:
```bash
csstruth watch http://localhost:3000 & # or your shell/agent's background-job equivalent
```
Then read the shell's output as you edit — a quiet stream means nothing
changed; a block under a `[HH:MM:SS]` timestamp names exactly what moved and
which violations appeared or resolved. Stop it with Ctrl+C (or a signal to
the background job) when you're done; it shuts Chrome down cleanly on exit.
## Claude Code skill
`skills/csstruth/SKILL.md` encodes the working doctrine (snapshot before
editing, `explain` before touching a cascade you didn't write, `diff` after
every edit, `verify` before done). Install it user-wide:
```bash
cp -r skills/csstruth ~/.claude/skills/
```
## Development
```bash
npm run typecheck # strict TS over src + test
npm test # 111 tests against real headless Chrome — fixture pages with planted bugs
npm run build # emits dist/
```
Every fixture in `fixtures/` is a page with a deliberately planted layout bug
(or a deliberately clean control); the tests assert csstruth finds exactly
what was planted. The design docs and per-release plans live in
`docs/superpowers/`.
## Limitations
Chromium-only (CDP is the only engine API exposing the full cascade with
source positions). Static layout truth — no animation timing. Framed content
(iframes) is not walked. Heuristic ceilings are commented in the source where
they live.
## License
[MIT](LICENSE)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessUnresponsive