From 1e290a63cf9ff4f1c6acc08cba0c1ec3da274691 Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 9 Aug 2026 14:52:52 +0000 Subject: [PATCH] test: automated responsive-layout harness (closes #13) Verifies the mobile layout from #5 with a real browser engine instead of by hand on a phone. make frontend-viewport-test builds dist/, serves it from the same digest-pinned nginx image and the same nginx.conf the shipping container uses, and drives a digest-pinned headless Chrome against it over CDP. Viewport widths are derived from the app's own CSS rather than from a list of phone models: the @media conditions in src/styles.css and any Tailwind responsive prefixes in the markup are parsed, and each breakpoint is tested one pixel below, exactly on, and one pixel above. max-width: 768px matches at 768, and a generic 375px test sails past that boundary entirely. Four anchor viewports are added with stated reasons: a 320px floor, a desktop baseline, and two phone-landscape sizes straddling the breakpoint. Assertions are on computed layout, not screenshots: horizontal overflow, elements past the viewport edge, clipped text (deliberate ellipsis truncation excluded), 44x44 minimum tap targets, and genuine reflow of the host rows checked on both flex-direction and geometry. Probing and gateway detection are asserted to still run at narrow widths, since the early-return mobile path rejected in #8 is what would silently regress. Screenshots are written to tmp/viewport/ as artifacts alongside the results, not as the evidence. puppeteer-core rather than playwright: it is the one variant of either that never downloads or bundles a browser, so the browser stays a digest-pinned image and the npm side is pinned by yarn.lock integrity. The browser container runs on an --internal docker network with no route off the host; the harness answers the app's latency probes itself from a fixed delay table so the rows render a realistic spread of value widths. Kept out of make check: it needs Docker and takes minutes, where make test has to stay under 20 seconds. The harness was observed failing before being trusted, twice: a planted 900px fixed-width element in a host row, and the mobile reflow rule neutered. Both reverted. Against the current layout it reports two real defects, filed as #42 (horizontal overflow at 320px) and #43 (tap targets below 44x44). --- .dockerignore | 1 + .gitignore | 1 + .prettierignore | 1 + Makefile | 9 +- README.md | 14 ++ TODO.md | 7 + package.json | 1 + script/frontend-viewport-test | 95 +++++++++++++ test/viewport/README.md | 105 ++++++++++++++ test/viewport/checks.js | 203 ++++++++++++++++++++++++++ test/viewport/facts.js | 184 ++++++++++++++++++++++++ test/viewport/harness.js | 258 ++++++++++++++++++++++++++++++++++ test/viewport/viewports.js | 185 ++++++++++++++++++++++++ yarn.lock | 154 +++++++++++++++++++- 14 files changed, 1216 insertions(+), 2 deletions(-) create mode 100755 script/frontend-viewport-test create mode 100644 test/viewport/README.md create mode 100644 test/viewport/checks.js create mode 100644 test/viewport/facts.js create mode 100644 test/viewport/harness.js create mode 100644 test/viewport/viewports.js diff --git a/.dockerignore b/.dockerignore index 27fac04..416281f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ node_modules dist +tmp .DS_Store *.log .claude diff --git a/.gitignore b/.gitignore index 9451024..d29c1ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ dist/ +tmp/ .DS_Store *.log diff --git a/.prettierignore b/.prettierignore index d1a0b78..d0374bc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ backend/ dist/ node_modules/ +tmp/ yarn.lock .claude/ diff --git a/Makefile b/Makefile index 353f708..20f313f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ -.PHONY: bootstrap setup dev test lint fmt fmt-check check docker hooks +.PHONY: bootstrap setup dev test lint fmt fmt-check check \ + frontend-viewport-test docker hooks # Standard targets are thin shims; the implementations live in script/ # per the scripts-to-rule-them-all pattern (see the Entrypoints section @@ -28,6 +29,12 @@ fmt-check: check: @script/check +# Responsive-layout verification in a containerised browser. Kept out of +# check: it needs Docker and takes minutes, where make test has to stay +# under 20 seconds. +frontend-viewport-test: + @script/frontend-viewport-test + docker: @script/docker diff --git a/README.md b/README.md index d4dbdd4..1ecaa46 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,25 @@ provide: - `script/fmt` — format all files (writes) - `script/fmt-check` — check formatting (read-only) - `script/check` — run test, lint, and fmt-check +- `script/frontend-viewport-test` — responsive-layout verification of the built + frontend in a containerised headless Chrome (see + [test/viewport/README.md](test/viewport/README.md)). Not part of + `script/check`: it needs Docker and takes minutes. - `script/docker` — build the Docker image tagged via `script/projectname` - `script/cibuild` — CI entrypoint: plain `docker build .` - `script/precommit` — run by the git pre-commit hook; runs `script/check` - `script/install-precommit` — install the git pre-commit hook +## Responsive layout + +The narrow-viewport layout lives in the `max-width: 768px` media block in +`src/styles.css`. It is verified automatically by `make frontend-viewport-test`, +which drives a digest-pinned headless Chrome against the built `dist/` and +asserts on computed layout at widths derived from that CSS — one pixel either +side of every breakpoint it declares, plus a 320px floor, a desktop baseline and +two landscape sizes. See [test/viewport/README.md](test/viewport/README.md) for +what it covers and what it genuinely cannot. + ## Rationale When debugging network issues, it's useful to have a persistent at-a-glance view diff --git a/TODO.md b/TODO.md index 587c805..f6be340 100644 --- a/TODO.md +++ b/TODO.md @@ -22,6 +22,10 @@ files, so merging it also closes most compliance gaps. # Completed Steps +- 2026-08-09: automated responsive-layout harness + (`make frontend-viewport-test`): digest-pinned headless Chrome driven over CDP + against the built `dist/`, viewport widths derived from the breakpoints in + `src/styles.css` (#13). Found two real layout defects, filed as #42 and #43 - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile shims, README Entrypoints section - 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow @@ -39,6 +43,9 @@ files, so merging it also closes most compliance gaps. # Future Steps +- Fix the two layout defects the viewport harness found (#42 horizontal overflow + at 320px, #43 tap targets below 44x44), then wire + `script/frontend-viewport-test` into CI as its own step - Compliance top-up as one small commit: add .editorconfig and add the hooks target to the Makefile - After merge, confirm .gitea/workflows/check.yml is on main and CI is green diff --git a/package.json b/package.json index a5e3225..f4cfeaf 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "autoprefixer": "^10.4.23", "postcss": "^8.5.6", "prettier": "^3.8.1", + "puppeteer-core": "25.5.0", "tailwindcss": "^4.1.18", "vite": "^7.3.1" } diff --git a/script/frontend-viewport-test b/script/frontend-viewport-test new file mode 100755 index 0000000..c7ecd4d --- /dev/null +++ b/script/frontend-viewport-test @@ -0,0 +1,95 @@ +#!/bin/sh +# script/frontend-viewport-test: verify the responsive layout of the built +# frontend in a real browser engine. +# +# Builds dist/, serves it with the same nginx image and the same nginx.conf +# the shipping container uses, points a containerised headless Chrome at it +# over CDP, and asserts on computed layout at every viewport width derived +# from the app's own CSS. See test/viewport/README.md for what this covers +# and what it cannot. +# +# Deliberately not part of script/check: it needs Docker and takes far +# longer than the 20s budget make test has to stay inside. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +# chromedp/headless-shell 151.0.7922.109, 2026-08-09 +BROWSER_IMAGE="chromedp/headless-shell@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04" +# nginx:stable-alpine, 2026-02-22 (the digest Dockerfile ships) +SERVER_IMAGE="nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab" +# node:22-alpine, 2026-02-22 (the digest Dockerfile builds with) +NODE_IMAGE="node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34" + +RUN_ID="$$-$(date +%s)" +NETWORK="netwatch-viewport-$RUN_ID" +SERVER="netwatch-viewport-server-$RUN_ID" +BROWSER="netwatch-viewport-browser-$RUN_ID" +ARTIFACT_DIR="$ROOT/tmp/viewport" + +cleanup() { + docker rm -f "$BROWSER" > /dev/null 2>&1 || true + docker rm -f "$SERVER" > /dev/null 2>&1 || true + docker network rm "$NETWORK" > /dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM + +main() { + cd "$ROOT" + + # Test what ships: the production build, not a dev server. + "$ROOT/script/test" + if [ ! -f "$ROOT/dist/index.html" ]; then + echo "frontend-viewport-test: dist/index.html missing after build" >&2 + exit 1 + fi + + mkdir -p "$ARTIFACT_DIR" + + # An --internal network has no route off the host, so the browser + # cannot reach the real internet no matter what the page asks for. + # Latency probes are answered by the harness instead. This also means + # no port can be published from it, which is why the harness itself + # runs as a third container on the same network rather than on the + # host. + docker network create --internal "$NETWORK" > /dev/null + + docker run -d --rm --name "$SERVER" \ + --network "$NETWORK" --network-alias netwatch \ + -v "$ROOT/dist:/usr/share/nginx/html:ro" \ + -v "$ROOT/nginx.conf:/etc/nginx/conf.d/default.conf:ro" \ + "$SERVER_IMAGE" > /dev/null + + # The image's own entrypoint already exposes CDP on 9222 and passes + # --no-sandbox, so only extra flags belong here; re-specifying the + # debugging port collides with it and leaves the endpoint bound to + # loopback only. --hide-scrollbars keeps innerWidth equal to + # clientWidth, so the overflow assertion has no scrollbar-sized slack + # to hide behind, and matches the overlay scrollbars phones use. + docker run -d --rm --name "$BROWSER" --init --shm-size=1g \ + --network "$NETWORK" \ + "$BROWSER_IMAGE" \ + --hide-scrollbars \ + > /dev/null + + # Chrome refuses DevTools requests whose Host header is neither + # localhost nor an IP address, so dial the container by address rather + # than by its network alias. + browser_ip="$(docker inspect \ + -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \ + "$BROWSER")" + + timeout 900 docker run --rm --init \ + --network "$NETWORK" \ + --user "$(id -u):$(id -g)" \ + -v "$ROOT:/app" \ + -w /app \ + -e NETWATCH_ROOT=/app \ + -e NETWATCH_BASE_URL=http://netwatch:8080 \ + -e "NETWATCH_CDP_URL=http://$browser_ip:9222" \ + -e NETWATCH_ARTIFACT_DIR=/app/tmp/viewport \ + "$NODE_IMAGE" \ + node test/viewport/harness.js +} + +main "$@" diff --git a/test/viewport/README.md b/test/viewport/README.md new file mode 100644 index 0000000..8fbbe4b --- /dev/null +++ b/test/viewport/README.md @@ -0,0 +1,105 @@ +# Responsive-layout harness + +Automated verification of the responsive layout that landed in #5. Run it with: + +```bash +make frontend-viewport-test +``` + +It builds `dist/`, serves it from the same digest-pinned `nginx` image and the +same `nginx.conf` the shipping container uses, drives a digest-pinned headless +Chrome against it over CDP, and asserts on computed layout at every viewport +width derived from the app's own CSS. Screenshots land in `tmp/viewport/` +alongside a `results.json`; they are artifacts for a human to look at when +something fails, not the evidence. The assertions are the evidence. + +The target is deliberately outside `make check`: it needs Docker and takes +minutes, and `make test` has to stay under 20 seconds. + +## How the widths are chosen + +Not from a list of phone models. `viewports.js` parses the `@media` conditions +out of `src/styles.css` and scans `src/main.js` and `index.html` for Tailwind +responsive prefixes, then tests every breakpoint it finds at one pixel below it, +exactly on it, and one pixel above it. A generic 375px "phone" test sails +straight past an off-by-one at a media query boundary; `max-width: 768px` +matches _at_ 768, and the sweep pins down which side of that line each layout is +on. + +Nothing hardcodes 768. Add a second media block or start using `md:` classes and +the new breakpoint is covered without this directory being touched. Four further +viewports are fixed anchors, each with a stated reason: a 320px floor, a 1280px +desktop baseline, and two phone-landscape sizes straddling the breakpoint for +the rotation case. + +## What it asserts + +- **app-rendered** — enough host rows exist and enough of them show a numeric + latency. This one exists so the rest cannot pass vacuously against a blank + page. +- **no-horizontal-overflow** — `documentElement.scrollWidth` fits the layout + viewport, with the widest offending element named. +- **nothing-past-viewport-edge** — no visible element's box extends past the + viewport edge. +- **no-clipped-text** — nothing hides text behind `overflow: hidden`. Deliberate + ellipsis truncation (Tailwind's `truncate`, used on host names and URLs) is + excluded: it is a design choice, not breakage. +- **tap-targets-44px** — every interactive control is at least 44x44 CSS px on + touch viewports. See below. +- **host-rows-stacked / host-rows-side-by-side** — the rows genuinely reflow. + Computed `flex-direction` _and_ the actual geometry are checked, and in the + narrow layout the info block and the sparkline must each occupy essentially + the full row width. A row that merely shrank its 420px column would fail. +- **probing-still-runs / gateway-detection-still-runs** — narrow viewports keep + probing and keep detecting the gateway. The mobile early-return path proposed + in #8 was rejected; this is what would catch it coming back. + +### The tap-target threshold + +44x44 CSS px. That is the figure in Apple's Human Interface Guidelines and in +WCAG 2.2 SC 2.5.5 "Target Size (Enhanced)". WCAG 2.2 SC 2.5.8 (level AA) sets a +lower 24x24 floor, but that floor comes with a spacing exception these controls +do not qualify for — the pin buttons sit directly against the host name they +belong to. + +## Determinism + +The browser container runs on an `--internal` docker network and has no route to +the internet, so the app's latency probes cannot reach anything real. The +harness answers them itself from a fixed delay table, with a deterministic +fraction failed outright, so the rows render a realistic spread of one-, two- +and three-digit latencies plus some unreachable rows. That spread is what the +layout has to survive; 24 identical `---` placeholders would not exercise it. + +## What this cannot verify + +Real limits, so nobody re-parks this issue as needing hardware: + +- **Non-Chromium engines.** This is Chrome. iOS Safari is WebKit and cannot be + emulated by it; Safari-specific bugs (viewport units under a collapsing URL + bar, `-webkit-fill-available`, form control metrics) will not show up here. +- **Real touch input.** `hasTouch` emulation changes what the page is told, not + how a finger behaves. Gesture handling, scroll momentum, double-tap zoom and + hover-state fallbacks on touch are out of scope. +- **Physical pixel density and rendering.** `deviceScaleFactor` is set, but + subpixel antialiasing, OLED colour rendering and actual legibility at a given + physical size are not measurable here. +- **Fonts.** The container has DejaVu, not the platform's own UI monospace. Text + metrics are therefore close to, but not identical to, a real device — a layout + that fits here by a few pixels might not there. +- **On-device performance.** Canvas sparkline redraw cost, battery, and + behaviour on a slow radio are not measured. +- **Browser chrome.** The address bar, safe-area insets and notch cutouts are + not simulated. + +Everything else this issue was actually about — does the layout reflow, does +anything overflow, is content clipped, are the controls big enough — is a +function of viewport width and CSS, and is covered above. + +## Relation to the unit test framework (#21) + +Complementary layers, not two stacks. `vitest` (#21) will exercise module-level +logic in-process with no browser. This harness exercises rendered layout in a +real engine and is the only thing here that can see a media query. Neither +replaces the other; assertions about computed styles and element geometry belong +here, assertions about functions belong in `vitest`. diff --git a/test/viewport/checks.js b/test/viewport/checks.js new file mode 100644 index 0000000..7fe314b --- /dev/null +++ b/test/viewport/checks.js @@ -0,0 +1,203 @@ +// Pass/fail decisions for the responsive-layout harness. +// +// Kept in node rather than in the page so that a failure can be reported +// with the measurements that produced it. Every check runs at every +// viewport; none of them short-circuits, so one failure does not hide the +// rest. + +// Minimum tap target, in CSS pixels. 44x44 is the figure in Apple's Human +// Interface Guidelines and in WCAG 2.2 SC 2.5.5 "Target Size (Enhanced)". +// WCAG 2.2 SC 2.5.8 (level AA) sets a lower 24x24 floor, but that floor +// comes with a spacing exception these controls do not qualify for: the +// pin buttons sit directly against the host name they belong to. Held at +// 44 deliberately. +export const MIN_TAP_TARGET_PX = 44; + +// The controls named in the definition of done, plus the pause button. +export const INTERACTIVE_SELECTORS = [ + "#pause-btn", + "#interval-select", + ".pin-btn", + "#debug-toggle", +]; + +// A host row is only "reflowed" if it stacked *and* went full width. +// A row that merely shrank its 420px info column would keep +// flex-direction: row, and a row that stacked but left the info column at +// its fixed width would fail the width test. +const FULL_WIDTH_FRACTION = 0.9; + +function summarise(items, format, limit = 3) { + const shown = items.slice(0, limit).map(format).join("; "); + const rest = items.length > limit ? ` (+${items.length - limit} more)` : ""; + return shown + rest; +} + +// Collapse an overflow report to the elements actually responsible. +// Identical elements (24 host rows all doing the same thing) are counted +// rather than listed, and the deepest ones come first, since every +// ancestor of an overflowing element also reports as overflowing. +function deepestOffenders(entries) { + const byElement = new Map(); + for (const entry of entries) { + const reach = entry.reach ?? entry.right; + const existing = byElement.get(entry.el); + if (existing) { + existing.count += 1; + existing.reach = Math.max(existing.reach, reach); + } else { + byElement.set(entry.el, { ...entry, reach, count: 1 }); + } + } + return [...byElement.values()].sort( + (a, b) => b.depth - a.depth || b.reach - a.reach, + ); +} + +function checkRowLayout(row, expectStacked) { + if (expectStacked) { + if (row.flexDirection !== "column") { + return `row ${row.index}: flex-direction is ${row.flexDirection}, expected column`; + } + if (row.sparkline.top < row.info.bottom - 1) { + return `row ${row.index}: sparkline top ${row.sparkline.top} is above info bottom ${row.info.bottom} — still side by side`; + } + const minWidth = row.containerWidth * FULL_WIDTH_FRACTION; + if (row.info.width < minWidth) { + return `row ${row.index}: info block is ${row.info.width}px of ${row.containerWidth}px — shrunk, not reflowed`; + } + if (row.sparkline.width < minWidth) { + return `row ${row.index}: sparkline is ${row.sparkline.width}px of ${row.containerWidth}px — shrunk, not reflowed`; + } + return null; + } + + if (row.flexDirection !== "row") { + return `row ${row.index}: flex-direction is ${row.flexDirection}, expected row`; + } + if (row.sparkline.left < row.info.right - 1) { + return `row ${row.index}: sparkline left ${row.sparkline.left} overlaps info right ${row.info.right} — not side by side`; + } + return null; +} + +export function evaluateChecks(facts, viewport, probes) { + const checks = []; + const check = (name, ok, detail) => checks.push({ name, ok, detail }); + + // Guard against the whole harness passing vacuously because the page + // never rendered. Everything below is only meaningful if this holds. + check( + "app-rendered", + facts.rowCount >= 10 && facts.numericLatencies >= 5, + `${facts.rowCount} host rows, ${facts.numericLatencies} showing a numeric latency`, + ); + + const viewportWidth = Math.min(facts.innerWidth, facts.documentClientWidth); + const culprits = deepestOffenders([ + ...facts.overflowing, + ...facts.contentOverflowing, + ]); + check( + "no-horizontal-overflow", + facts.documentScrollWidth <= viewportWidth, + `documentElement.scrollWidth ${facts.documentScrollWidth} vs viewport ${viewportWidth}` + + (culprits.length === 0 + ? "" + : "; widest content: " + + summarise( + culprits, + (c) => + `${c.el} reaches ${Math.round(c.reach)}px${c.count > 1 ? ` (x${c.count})` : ""}`, + )), + ); + + check( + "nothing-past-viewport-edge", + facts.overflowing.length === 0, + facts.overflowing.length === 0 + ? "no element extends past the viewport" + : `${facts.overflowing.length} element(s) past the edge: ` + + summarise( + facts.overflowing, + (o) => `${o.el} spans ${o.left}..${o.right}`, + ), + ); + + check( + "no-clipped-text", + facts.clipped.length === 0, + facts.clipped.length === 0 + ? "no element hides text behind overflow (deliberate ellipsis excluded)" + : `${facts.clipped.length} element(s) clipping text: ` + + summarise( + facts.clipped, + (c) => + `${c.el} scrollWidth ${c.scrollWidth} > clientWidth ${c.clientWidth}`, + ), + ); + + if (viewport.touch) { + const undersized = facts.tapTargets.filter( + (t) => t.width < MIN_TAP_TARGET_PX || t.height < MIN_TAP_TARGET_PX, + ); + const bySelector = new Map(); + for (const target of undersized) { + const existing = bySelector.get(target.selector); + if (!existing || target.width * target.height < existing.area) { + bySelector.set(target.selector, { + ...target, + area: target.width * target.height, + count: (existing?.count ?? 0) + 1, + }); + } else { + existing.count += 1; + } + } + check( + `tap-targets-${MIN_TAP_TARGET_PX}px`, + undersized.length === 0, + undersized.length === 0 + ? `all ${facts.tapTargets.length} controls are at least ${MIN_TAP_TARGET_PX}x${MIN_TAP_TARGET_PX}` + : `${undersized.length} of ${facts.tapTargets.length} controls below ${MIN_TAP_TARGET_PX}x${MIN_TAP_TARGET_PX}: ` + + summarise( + [...bySelector.values()], + (t) => + `${t.selector} ${t.width}x${t.height}${t.count > 1 ? ` (x${t.count})` : ""}`, + 4, + ), + ); + } + + const badRows = facts.rows + .map((row) => checkRowLayout(row, viewport.expectStacked)) + .filter(Boolean); + check( + viewport.expectStacked ? "host-rows-stacked" : "host-rows-side-by-side", + facts.rows.length > 0 && badRows.length === 0, + facts.rows.length === 0 + ? "no host rows were measured" + : badRows.length === 0 + ? `all ${facts.rows.length} rows laid out as expected` + : `${badRows.length} of ${facts.rows.length} rows wrong: ` + + summarise(badRows, (r) => r), + ); + + // The mobile early-return path proposed in #8 was rejected: narrow + // viewports must keep probing and keep detecting the gateway, not + // quietly skip work. + check( + "probing-still-runs", + probes.attempted > 0, + `${probes.attempted} outbound probe requests issued`, + ); + check( + "gateway-detection-still-runs", + facts.gatewayDetected, + facts.gatewayDetected + ? "Local Gateway row present" + : "no Local Gateway row — gateway detection did not run or did not complete", + ); + + return checks; +} diff --git a/test/viewport/facts.js b/test/viewport/facts.js new file mode 100644 index 0000000..f266e6c --- /dev/null +++ b/test/viewport/facts.js @@ -0,0 +1,184 @@ +// Layout facts collected from inside the page. +// +// This function is serialised and evaluated in the browser, so it must be +// entirely self-contained: no imports, no closures over module scope. It +// only *measures*; every pass/fail decision is made back in node by +// checks.js, so failures can be reported with real numbers attached. + +export function collectLayoutFacts(options) { + const describe = (el) => { + const id = el.id ? "#" + el.id : ""; + const classes = + typeof el.className === "string" && el.className.trim() + ? "." + el.className.trim().split(/\s+/).slice(0, 3).join(".") + : ""; + return el.tagName.toLowerCase() + id + classes; + }; + + const round = (n) => Math.round(n * 10) / 10; + + // Overflow propagates up every ancestor, so a single wide element + // reports as body, #app, the row, and so on. Depth lets the report + // name the deepest — that is, the actual — offender. + const depthOf = (el) => { + let depth = 0; + for (let node = el.parentElement; node; node = node.parentElement) { + depth++; + } + return depth; + }; + + const isVisible = (el) => { + const style = getComputedStyle(el); + if (style.display === "none") return false; + if (style.visibility === "hidden") return false; + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }; + + const innerWidth = window.innerWidth; + const clientWidth = document.documentElement.clientWidth; + // Under mobile emulation Chrome lets window.innerWidth *grow* to the + // width of overflowing content, exactly as a phone zooms out to fit a + // too-wide page. Measuring against it would therefore hide the + // overflow it is supposed to expose: at a 320px device width a page + // that spills to 350 reports innerWidth 350 and looks clean. Every + // comparison below is against the layout viewport instead. + const viewportWidth = Math.min(innerWidth, clientWidth); + const elements = Array.from(document.querySelectorAll("body *")); + + // Elements sticking out past the right (or left) edge of the viewport. + // The document-level scrollWidth check says *that* the page overflows; + // this says *what* is doing it. + const overflowing = []; + // Elements clipping their own text. Deliberate ellipsis truncation + // (Tailwind's `truncate`) is opt-in and excluded: it is a design + // choice, not breakage. + const clipped = []; + // Elements whose content spills out of their own box without being + // clipped, past the right edge of the viewport. A block element is + // only ever as wide as its container, so text overflowing it has no + // element rect of its own to catch — but it is exactly what drags + // documentElement.scrollWidth past the viewport width, so without + // this the page-level overflow failure has nothing to point at. + const contentOverflowing = []; + + for (const el of elements) { + if (!isVisible(el)) continue; + const rect = el.getBoundingClientRect(); + if (rect.right > viewportWidth + 1 || rect.left < -1) { + overflowing.push({ + el: describe(el), + depth: depthOf(el), + left: round(rect.left), + right: round(rect.right), + }); + } + const style = getComputedStyle(el); + const clips = + style.overflowX === "hidden" || style.overflowX === "clip"; + const ellipsis = style.textOverflow === "ellipsis"; + const hasText = el.textContent.trim().length > 0; + const spills = + el.clientWidth > 0 && el.scrollWidth > el.clientWidth + 1; + if (clips && !ellipsis && hasText && spills) { + clipped.push({ + el: describe(el), + scrollWidth: el.scrollWidth, + clientWidth: el.clientWidth, + }); + } + if ( + !clips && + spills && + rect.left + el.scrollWidth > viewportWidth + 1 + ) { + contentOverflowing.push({ + el: describe(el), + depth: depthOf(el), + scrollWidth: el.scrollWidth, + clientWidth: el.clientWidth, + reach: round(rect.left + el.scrollWidth), + }); + } + } + + // Interactive controls. The measured target is the nearest thing that + // is genuinely tappable — for a checkbox that is the