Files
clawbot c36dc36819
All checks were successful
check / check (push) Successful in 22s
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.

Every check guards its own presence, so none can pass against a page it
is not measuring. The tap-target check in particular would otherwise be
inert: an empty undersized set means both "all controls are big enough"
and "the selectors have gone stale", and the size comparison alone
cannot tell those apart. Each selector therefore declares the minimum
number of visible instances the page must contain, per selector rather
than in total, so one stale selector out of four fails rather than only
all four at once.

Layout expectation is likewise refused rather than guessed. A width is
narrow when a max-width block matches (desktop-first, what the app does
today) or, for a min-width-only mobile-first set, when it falls below
every breakpoint; a set mixing both cannot be resolved from the
conditions alone, because which block owns the reflow is a property of
the rules inside it, so the run fails with an explanation instead of
testing the right widths against the wrong expectation.

The harness was observed failing before being trusted, four times: a
planted 900px fixed-width element in a host row; the mobile reflow rule
neutered; the tap-target threshold lowered so nothing was undersized and
.pin-btn then renamed, which took the check from 8/8 green at every
touch viewport to failing at all six, naming the stale selector; and a
second media block added so the breakpoint set mixed max and min, which
aborted the run. All reverted.

Against the current layout it reports two real defects, filed as #42
(horizontal overflow at 320px) and #43 (tap targets below 44x44).
2026-08-09 15:22:54 +00:00
..

Responsive-layout harness

Automated verification of the responsive layout that landed in #5. Run it with:

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-overflowdocumentElement.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.