One reading column, and a document outline that survives
MEASURE_TEXT_EDGESThe failure it prevents
A heading indented 24px from the paragraph beneath it reads as a mistake, and nobody can say why it happened — the source looks correct, because two nested layout wrappers each contributed their own padding. Separately, a mounted widget brings its own <h1> and now the page has two, so a screen reader announces a new document starting halfway down, and the outline goes h2 → h4 because someone picked a heading by its font size.
The prompt
Add two rendered checks to the browser harness. Both measure the page a reader actually gets, not the classes the source asked for.
**Check 1 — one reading column.**
The invariant: every block of running text that flows down the page starts at the same left edge. Media may be wider and overhang it — text may not. Do **not** check that the right container class was used; a container nested inside another container compounds both paddings and lands 16px off at tablet widths while reading as perfectly correct in source. Measure the edges.
The probe collects, from inside the app's main content root, every `h1, h2, h3, p, li` and returns `Math.round(getBoundingClientRect().left)` with the tag and the first 48 characters of text. Skip:
- anything inside a declared media/widget/device wrapper, a `figure`, or a `figcaption` — that is the widget's own composition, not the page's text column;
- `width < 80` or `height < 8` (icons, empty nodes, screen-reader-only text);
- `visibility: hidden` / `display: none`;
- text shorter than 12 characters (a badge is not a paragraph).
**The exemption that does the real work: text inside a composed layout.** Walk from the element up to `document.body` — **not** to the nearest section or article wrapper, because apps render list rows as `article` and stopping there makes every grid row look like a stray paragraph. If any ancestor is a grid with more than one column track, or a row-direction flex with more than one visible child, the element is exempt: its cell starts where the grid puts it, and holding a table cell to the page's reading column is asking a table to be a paragraph.
Cluster the remaining lefts with a 1px tolerance (sub-pixel rounding is not a misalignment). The largest cluster is the column. Fail when the number of clusters exceeds `CONTRACT.readingColumns`, printing each stray as `x=316 (+24) h2 "How it works"` — the signed delta from the dominant edge is what makes it a five-minute fix instead of an afternoon.
**Portability, stated honestly:** "exactly one left edge" is true for a single-column editorial or marketing page and false for a dashboard, a docs site with a sidebar, or alternating full-bleed sections. So the count comes from the contract, per route if necessary — `readingColumns: 1` for content routes, `2` for the docs shell. **Declare the columns, then measure; never assert one column globally.** Record the clusters in the facts bag as `[{left, count}]` so the shape of the page is visible in a diff even when the check passes.
**Check 2 — the document outline.**
From the same content root, read every `h1, h2, h3, h4` in document order with its level and its first 60 characters. Then:
- **Warn** on any skipped level (`h2 → h4`), naming the heading text. Say in the message that heading level is document structure, not type scale — if it needs to be smaller, style it.
- **Fail** on more than one `h1`, listing all of them. Explain the consequence in the finding: a second top-level heading is usually a mounted component contributing its own outline, and both a screen reader and a crawler read it as a new document starting mid-page.
- Record the whole outline as a fact — `"h1 h2 h2 h3 h3 h2"` — so restructuring a page shows up as a one-line diff between runs.
Use `aria-label` in preference to `textContent` when present, and trim/collapse whitespace, so a heading wrapped in per-letter animation spans still reports readable text rather than `H\nO\nW`.
**Do not** implement either check by reading JSX or class names. Both of these are facts about rendered geometry and rendered DOM order, and the whole reason they are in the browser pass is that source cannot answer them.
**Hand back:** the two probes, the two checks, the facts they record, and a run against every route in this project — including the routes that pass, with their measured edge clusters and heading strings printed as NOTE inventory.What it produces
MEASURE_TEXT_EDGES and MEASURE_OUTLINE probes plus two checks: a text-column check that fails with each stray block's signed pixel delta against a per-route declared column count, and a heading check that warns on skipped levels and fails on multiple h1 — both recording their measurements into the facts bag.
How you prove it works
Four fixtures. (1) Wrap one paragraph in an extra padding-left:24px div — the check must fail printing +24 and that paragraph's text. (2) A page where everything shares one edge — must report one cluster and pass, or the check is noisy. (3) A two-column grid whose cells legitimately start at different x — must NOT be flagged; if it is, the composed-layout exemption is broken and every table on the site will fail. (4) A page with <h1> then <h3> — the probe must return levels [1,3] and the check must warn; add a second <h1> and it must fail listing both.