# 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. The app is desktop-first today (all narrow rules live in `max-width` blocks); a `min-width`-only, mobile-first set is handled as its inverse, and a set that mixes the two makes the run fail loudly rather than test the right widths with the wrong expectation. 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, _and_ each selector in the control list matched at least the number of visible elements it declares. The second half is what stops the check passing vacuously: with size alone, a renamed class would take its controls out of the measured set and the check would report "all 0 controls are at least 44x44" and pass. 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`.