script/lint ran prettier --check, the same check script/fmt-check runs, so the JavaScript had no linter. eslint now runs with its recommended rules, set in eslint.config.js, in a new frontend-lint stage of Dockerfile built from the pinned node image and the lockfile. The frontend stage copies a file from it, as the builder stage does from the Go lint stage, so the image cannot build unless eslint passed. script/lint builds both lint stages with --no-cache and runs no linter on the host; script/fmt-check keeps prettier on the host, and script/frontend-check drops its lint step. The viewport harness fixes the two kinds of finding eslint made. bootstrap wants node 22.13.0, as eslint 10 does. Also covers item 2 of #28. Model: opus-5-5
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-overflow —
documentElement.scrollWidthfits 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'struncate, 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: one of each single control, and one pin button per WAN host row. 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-directionand 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.
hasTouchemulation 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.
deviceScaleFactoris 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.