Layout shift with the guilty element named
INSTALL_SHIFT_OBSERVERThe failure it prevents
CLS 0.31 is a number nobody can act on. The team sees it in a Lighthouse run, agrees it is bad, and does nothing, because finding which of forty components grew is an afternoon of bisecting. Meanwhile the actual cause — a demo card that animates its own height while it plays — shoves every section below it down the page mid-scroll, which is the single most visible way a page reads as unfinished.
The prompt
Add a layout-shift gate to the browser harness that never reports a bare score. Every failure must name the element that moved and print its geometry before and after.
**Install the observer before the page's own code runs.** Via `page.evaluateOnNewDocument` (not `page.evaluate` after navigation — you will miss every shift during hydration):
```js
window.__shifts = [];
const describe = (node) => {
if (!node || node.nodeType !== 1) return "(unknown)";
const parts = [];
for (let el = node; el && el.nodeType === 1 && parts.length < 4; el = el.parentElement) {
let s = el.tagName.toLowerCase();
if (el.id) s += `#${el.id}`;
else if (typeof el.className === "string" && el.className.trim())
s += "." + el.className.trim().split(/\s+/).slice(0, 2).join(".");
parts.unshift(s);
}
return parts.join(" > ");
};
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue;
window.__shifts.push({
value: entry.value,
sources: (entry.sources ?? []).map((s) => ({
node: describe(s.node),
from: s.previousRect ? { y: s.previousRect.y, h: s.previousRect.height } : null,
to: s.currentRect ? { y: s.currentRect.y, h: s.currentRect.height } : null,
})),
});
}
}).observe({ type: "layout-shift", buffered: true });
```
`buffered: true` is load-bearing — without it you lose everything that shifted before the observer attached. The four-ancestor walk is what turns a score into a bug report. If your app sets a stable test attribute on components, prefer it over the class name inside `describe` — minified class names name nobody.
**Measure three times, with three different instruments, because each one fails differently.**
1. *Proof of life.* After the stepped read-through, sum `window.__shifts` and record it as a **fact, not a check** — `route.clsWhileScrolling`. It exists so that a zero from the real check below cannot silently be a dead observer.
2. *The check.* Scroll back to top, reset `window.__shifts = []`, record `document.documentElement.scrollHeight`, wait `PLAY_MS`, then read the shifts. Default `PLAY_MS = 6000` — long enough for one loop of the longest looping animation in this app. **Make it a flag and write the reason in a comment**: a 12-second hero under-measures at 6s, and a fully static page wastes 6s per route. Fail when total CLS exceeds `CONTRACT.clsBudget` (default 0.01 — ten times stricter than Google's 0.1, and the failure message must say why: if entrances are transform/opacity only, anything above measurement noise is a real reflow, not a budget being spent).
3. *The cross-check.* Compare `scrollHeight` before and after the wait. If it changed by more than 4px, fail independently — a page that grew with no interaction has a component animating its own box. This instrument does not depend on the observer at all, so it survives the observer being wrong.
**The failure message is the product.** Sort the shifts descending, take the worst three, and print for each:
```
FAIL layout-shift /pricing · CLS 0.184
the page moves while its content settles
0.1120 — div#root > main > section.pricing > div.card
height 100 → 400, y 0 → 0
```
A failure that prints only `CLS 0.184` has not done the job — send it back.
**Do not** compute your own shift score from rect diffs. Use Chrome's `layout-shift` entries: they are the same numbers that decide the site's real CLS, so the gate and the field metric cannot disagree.
**Also record, per route, into the JSON facts bag:** `cls`, `clsWhileScrolling`, and the document height delta. These are the numbers you will want to diff between two runs when someone asks whether last week's refactor helped.
**Hand back:** the probe, the check, and a run against this project printing the per-route CLS facts. If every route reports 0, say explicitly that this number is only believable once the probe self-test exists — and point at that as the next task.What it produces
An INSTALL_SHIFT_OBSERVER probe plus a layout-shift check that fails with the culprit's ancestor path and its from→to geometry, backed by three independent instruments and a per-route facts entry.
How you prove it works
Serve a fixture page: <div id="grow" style="height:100px"> followed by a 2000px spacer, and a script that sets #grow to 400px after 800ms. The check must report CLS > 0, must name div#grow, and must print height 100 → 400. Then the control: the identical page with the timeout removed must report exactly 0 — a probe that reports shift on a static page will be muted by the team within a week. Then the third instrument on its own: a page that appends a 300px block after load must fail the document-height check even if you deliberately stub the observer out.