# Layout Forensics

> Audit and rebuild a web layout from the live rendered page instead of from a screenshot. Measures real geometry, type, spacing, colour contrast, tap targets and reading load out of the browser, computes what is usually left to taste (visual weight per band, first-glance saliency, alignment lanes, spacing-scale conformance), writes acceptance criteria, builds the proposed version as real HTML in the site's own cascade, then re-measures and returns PASS or FAIL per criterion. Use when someone asks what is wrong with a page or section, why a layout feels cluttered, heavy, empty or confusing, where the eye goes, how a section should be rearranged, or when an agent is about to rebuild a section and needs a spec it can be held to. Works from a screenshot alone in a reduced mode, and says so.

- Skill: `seryozh/layout-forensics` (Agent Skill, multi-file: 16 files)
- Install (CLI): `npx skillmds@latest add seryozh/layout-forensics`
- Raw SKILL.md: https://api.skillmd.com/api/skills/seryozh/layout-forensics/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: Seryozh (https://skillmd.com/u/seryozh)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/seryozh/layout-forensics

---


# Layout Forensics

A screenshot tells you what a page looks like. The page itself tells you what it *is*:
every box, every computed size, every colour pair, to two decimal places. This skill
refuses to guess at pixels while the real numbers are one query away, and it never calls a
layout improved until the rebuilt page has been measured again.

Resolve the skill root before running a bundled tool. Set
`LAYOUT_FORENSICS_DIR` to the absolute directory that contains this `SKILL.md`; never assume the
current working directory is the skill directory.

## Cast

Work this task as a forensic examiner of rendered pages, and let the temperament do real work:

- **Never estimates.** Every claim in the report carries a number pulled from the live DOM.
  "Feels cramped" is not a finding; "gap is 11px where the section's other gaps are 24px" is.
- **Does not trust pictures.** A generated or resized image is never a source of
  coordinates. Images are for showing a human what you found.
- **Keeps a chain of custody.** Every measurement records the URL, the viewport width, the
  content hash and the time. A comparison across two different widths is thrown out, not
  explained away.

Say findings in plain language. Keep the pedantry in the method, not in the prose.

## 1. Get a real surface, and name which one it is

Four kinds of surface, and the report must say which one every number came from:

1. **LIVE** — the real page: the project's dev server, or a deployed or staging URL, at a
   stated viewport width. This is the only surface whose numbers describe the actual site.
2. **RECONSTRUCTION** — a local rebuild of the section from its source component, served and
   measured for real. Legitimate when the project will not run, and the geometry is genuinely
   measured, but its fidelity to production is unproven. Label every number
   `RECONSTRUCTION-not-production`, say which source file it was rebuilt from, and never
   present a before/after built on it as proof that the real site improved.
3. **PROPOSAL** — the rebuilt version you are testing. Real numbers, not yet the site.
4. **SCREENSHOT ONLY** — reduced mode. Geometry becomes an estimate; computed styles are
   unavailable, so contrast, tap targets, type sizes and reading load cannot be measured at
   all. Label the whole report `ESTIMATED-from-image` and name what a live page would add.

Never let numbers from two surfaces sit next to each other without labels. Comparing a
LIVE before against a RECONSTRUCTION after proves nothing, and it looks exactly like proof.

## 2. Measure

The simplest path needs no browser tool at all, works the same in every runtime, and gives
you the measurement and a full-page screenshot from the same render:

```bash
python3 "$LAYOUT_FORENSICS_DIR/scripts/measure_headless.py" <url> --selector 'section.proof' \
  --width 1280 --out <out>/before.json --screenshot <out>/page-before.png
```

It launches its own throwaway headless Chrome, waits for the document **and the web fonts**,
evaluates the measurer over the debugging protocol, and prints a short summary. Nothing but
that summary reaches your context.

If you already have a live browser you want to measure instead (a pane, a signed-in session,
a page mid-interaction), run the sink and evaluate the script in that page:

```bash
python3 "$LAYOUT_FORENSICS_DIR/scripts/sink.py" --dir <out-dir> \
  --serve "$LAYOUT_FORENSICS_DIR/scripts" --port 8790
```

```js
fetch('http://127.0.0.1:8790/file?name=measure_page.js')
  .then(r => r.text()).then(src => (0, eval)(src))
  .then(() => window.__LF('SECTION_SELECTOR', 'http://127.0.0.1:8790/save?name=before.json', 1280))
```

The third argument is the width you believe you are measuring at, and the call refuses if the
surface has quietly resized. Use it every time: a pane that narrowed hands back the phone
layout, and that reads as a bug in the design rather than a bug in the measurement.

Runtime notes, since the evaluation step is the one thing that differs everywhere:

- A JavaScript tool that evaluates an expression in the page: use the snippet above as is.
- A Playwright-style client (`page.evaluate`, `tab.playwright.evaluate`): read the file from
  disk and evaluate its text, then call `window.__LF(...)` in a second evaluate.
- Neither: use `measure_headless.py`. That is what it is for.

Three rules about the act of measuring:

- **Never add the measurer to the page.** No `<script src>` tag, no injected element. It
  changes the document you are about to measure, including its content hash, and a
  before/after pair taken with and without it is not comparable. The script refuses if it
  finds itself in the DOM.
- **Stay on one channel.** A headless capture hides scrollbars and lays out about 15px wider
  than a real window. Every measurement records `scrollbar`, and `verify_plan.py` refuses a
  before/after pair that came from different channels.
- **Fonts before boxes.** Measuring before the web fonts resolve gives wrong heights for
  every line of text. The headless path waits; if you are evaluating in your own browser,
  wait for `document.fonts.status === 'loaded'` yourself.

## 3. Read the findings

```bash
python3 "$LAYOUT_FORENSICS_DIR/scripts/analyze_layout.py" <out>/before.json \
  --bands 6 --pretty --json <out>/findings.json
python3 "$LAYOUT_FORENSICS_DIR/scripts/squint.py" <out>/page-before.png \
  --measurement <out>/before.json --out <out>/squint.png --pretty
```

Both tools return `stale_measurement`. If it is not null, the measurement was taken with an
older copy of the measurer than the tools reading it, and it names exactly which checks cannot
run. Re-measure rather than reporting around it: a check that silently became UNSUPPORTED
looks identical to a check that passed.

`analyze_layout.py` returns computed findings only, each with the numbers behind it, an
evidence class (`MEASURED`, `OBSERVED`, `HYPOTHESIS`) and an acceptance criterion. It never
emits taste. `squint.py` blurs and shrinks the screenshot the way a designer squints, ranks
what survives, and names the element under each hot spot. It is a saliency proxy and it is
labelled `HYPOTHESIS` everywhere it appears; it is not eye tracking.

The analyser also returns `near_threshold`: numbers that came close to a bar and did not
cross it, each saying where the bar is and that it did **not** fire. Those are not findings.
Quoting one as a defect is the most tempting way to dress an opinion as a measurement. If a
near-threshold number genuinely matters for this page, put it in the plan as a criterion with
the bar you want. Then it is a standard you chose and can defend, not a finding you invented.

Read the output as evidence, not as a verdict. Two rules that keep this honest:

- **Empty space is not automatically a defect.** It is one when it weakens hierarchy,
  separates things that belong together, or lengthens the page without buying anything.
  A band the analyser flags as empty may be the rest the page needs.
- **A rule the page obeys everywhere else is the standard.** Before calling a value wrong,
  check what the rest of the page does. The analyser reports the base spacing unit and the
  conformance rate for exactly this reason.

Add your own taste judgements after the computed ones, in a separate list, labelled `TASTE`.
Never let the two blur together: the whole point is that the user can act on the first list
without trusting your eye at all.

## 4. Write the acceptance criteria before rebuilding anything

A plan is a JSON file with a target and a list of criteria, written **before** the rebuild so
it cannot be quietly softened afterwards. See [references/rebuild.md](references/rebuild.md)
for the full list of checks and a worked example.

```json
{"target": {"selector": "section.proof", "viewport": {"w": 1280}},
 "criteria": [{"id": "C1", "check": "contrast_min", "value": 4.5},
              {"id": "C2", "check": "interactive_min", "value": 1},
              {"id": "C3", "check": "no_collateral_change", "value": ["proof-facts"]}]}
```

Run it against the current page first. It should mostly fail; if it does not, the criteria
are describing what already exists rather than what needs to change.

## 5. Build the proposed version as real HTML

Not a picture. A page that loads the site's own stylesheet, so the cascade is the real one:

- Reuse the site's CSS by URL, and keep every proposed change in one `<style>` block. That
  block is the implementation diff a builder will copy, so annotate each rule with the
  criterion it closes.
- **Namespace every new class and prove it is new.** Grep the stylesheet for the name first.
  A class that already exists will silently inherit rules you never intended, and the
  rendered result will look almost right, which is worse than looking wrong.
- Preserve the content inventory. If a claim, link, image or sentence is added or removed,
  list it under `authorized_additions` in the plan and say who authorised it.
- Keep the original section untouched on disk. The proposal lives in an output directory
  and gets served separately, so nothing about the real site changes while you experiment.

## 6. Re-measure and let it fail

```bash
python3 "$LAYOUT_FORENSICS_DIR/scripts/verify_plan.py" <out>/plan.json <out>/after.json \
  --baseline <out>/before.json --pretty
```

Every criterion comes back `PASS`, `FAIL` or `UNSUPPORTED`, and the run as a whole comes back
`PASS`, `FAIL`, or `PASS-UNVERIFIED` — everything passed, but against a measurement older than
the tools that read it. Only a plain `PASS` exits zero, so a gate forces the re-measure rather
than accepting a verdict nobody can stand behind. It refuses to compare across a different
selector or viewport width. An unknown check never counts as a pass, and a check with
nothing to measure returns `UNSUPPORTED` rather than passing on an empty set.

When a criterion fails, fix the layout or report the miss with its number. Do not edit the
criterion to match the result you got. If the criterion itself was wrong, say so explicitly
and let the user decide.

Take an after screenshot and rerun `squint.py`. A rebuild that fixed contrast and alignment
but did not change where the eye lands has not fixed attention, and the report must say so.

## 7. Responsive

One measurement proves one width. Re-measure at 390, 768 and 1440 CSS px, then immediately
either side of any breakpoint the CSS declares. Everything unmeasured is
`UNVERIFIED-until-measured-at-<width>`, never "should be fine".

Then put every width in one table before writing a word of the report:

```bash
python3 "$LAYOUT_FORENSICS_DIR/scripts/across_widths.py" <out>/after-*.json --pretty
```

It prints one row per width and names the worst by each dimension separately: most findings,
heaviest bottom, tallest section, narrowest screen. One ranking would hide the case where a
single severe imbalance beats two mild findings. It also refuses two measurements at the same
width, measurements with different content hashes, and reports a 35%+ height jump immediately
across a breakpoint. This exists because a rebuild that fixes the desktop and makes the phone
worse can be reported truthfully and still mislead: quote the width that improved, call the rest
checked. The report leads with the worst row, not the best one.

`verify_plan.py` can prove only `PASS for <selector> at <width>`. For a responsive rebuild, make
one suite JSON with exactly one `page_level` target plus any section targets, and give every
target a measurement and a plan for every declared width. Each plan records
`"written_before_rebuild": true`. Any analyser finding must be absent or allowed by its exact ID
and viewport with a reason. Then run:

```bash
python3 "$LAYOUT_FORENSICS_DIR/scripts/verify_suite.py" <out>/suite.json --pretty
```

Only a plain suite `PASS` may be reported as responsive PASS. The suite rejects missing widths,
mixed content hashes, mixed URLs, stale or truncated measurements, and fails if any per-width
plan fails or any unallowed finding or responsive height cliff remains. `across_widths.py` is the
inspection table; `verify_suite.py` is the gate. The responsive suite needs at least two widths,
and its `page_level` target must geometrically cover the rendered document rather than merely
claiming that label.

If the user asks for desktop only, do desktop only, record the intended mobile reading order
in words, and mark mobile geometry `DEFERRED-by-user`.

## 8. Deliver

1. One line: what is actually wrong with this section.
2. The computed findings, each with its numbers and evidence class.
3. Your taste notes, separately, labelled as taste.
4. The first-glance result, labelled as a proxy, with the elements it landed on.
5. For a rebuild: the plan, the verification table, and the `<style>` block that implements it.
6. Before and after screenshots, the before and after numbers side by side, and the
   across-widths table, leading with the widths that still have findings.
7. What was not measured, and the exact check that would settle each open item.

Read [references/failure-patterns.md](references/failure-patterns.md) before writing the
report: it is the running list of named patterns with the measurable signature of each, and
every new one you meet gets appended with its signature so the next audit catches it
automatically.

Before trusting a change you made to these scripts, run
`python3 "$LAYOUT_FORENSICS_DIR/scripts/selftest.py"`. It
takes a second, needs no browser, and every case in it exists because something really went
wrong once, including two implementations of one rule quietly disagreeing.

## What this skill never does

It never edits the site being audited. An audit produces findings, a plan and a proposal in
an output directory; changing the real page is separate work that the user asks for
separately. It never deploys, never commits, and never presents a generated image as
evidence that the page itself changed.

