Compare commits
1 Commits
feat/viewp
...
1e290a63cf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e290a63cf |
4
TODO.md
4
TODO.md
@@ -25,9 +25,7 @@ files, so merging it also closes most compliance gaps.
|
||||
- 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). Every check carries a presence guard so none of them
|
||||
can pass against a page it is not actually measuring. Found two real layout
|
||||
defects, filed as #42 and #43
|
||||
`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
|
||||
|
||||
@@ -25,16 +25,9 @@ RUN_ID="$$-$(date +%s)"
|
||||
NETWORK="netwatch-viewport-$RUN_ID"
|
||||
SERVER="netwatch-viewport-server-$RUN_ID"
|
||||
BROWSER="netwatch-viewport-browser-$RUN_ID"
|
||||
HARNESS="netwatch-viewport-harness-$RUN_ID"
|
||||
ARTIFACT_DIR="$ROOT/tmp/viewport"
|
||||
|
||||
# Every container is named and removed here, including the harness itself:
|
||||
# `timeout` below kills the `docker run` client, not the container it
|
||||
# started, and an unnamed survivor keeps the --internal network in use so
|
||||
# `docker network rm` fails too. This host runs many sessions at once and
|
||||
# neither may be left behind.
|
||||
cleanup() {
|
||||
docker rm -f "$HARNESS" > /dev/null 2>&1 || true
|
||||
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
|
||||
@@ -86,7 +79,7 @@ main() {
|
||||
-f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
|
||||
"$BROWSER")"
|
||||
|
||||
timeout 900 docker run --rm --init --name "$HARNESS" \
|
||||
timeout 900 docker run --rm --init \
|
||||
--network "$NETWORK" \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-v "$ROOT:/app" \
|
||||
|
||||
@@ -27,13 +27,10 @@ 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.
|
||||
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
|
||||
|
||||
@@ -48,11 +45,7 @@ sizes straddling the breakpoint for the rotation case.
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -14,29 +14,13 @@
|
||||
export const MIN_TAP_TARGET_PX = 44;
|
||||
|
||||
// The controls named in the definition of done, plus the pause button.
|
||||
// Each carries the smallest number of *visible* instances the page has to
|
||||
// contain for the tap-target oracle to be measuring anything at all.
|
||||
//
|
||||
// Without those floors the check is inert: `undersized` is empty both when
|
||||
// every control is large enough and when the selectors have gone stale and
|
||||
// matched nothing, and the pass condition cannot tell those apart. A single
|
||||
// combined floor would not be enough either — 26 pin buttons would cover
|
||||
// for all three singleton controls vanishing at once — so the floor is per
|
||||
// selector, and one stale selector out of four fails the check.
|
||||
export const INTERACTIVE_CONTROLS = [
|
||||
{ selector: "#pause-btn", minCount: 1 },
|
||||
{ selector: "#interval-select", minCount: 1 },
|
||||
// One per pinnable host row. `app-rendered` already requires at least
|
||||
// 10 host rows, so a count below that means the pin buttons stopped
|
||||
// being rendered per row rather than that there were fewer hosts.
|
||||
{ selector: ".pin-btn", minCount: 10 },
|
||||
{ selector: "#debug-toggle", minCount: 1 },
|
||||
export const INTERACTIVE_SELECTORS = [
|
||||
"#pause-btn",
|
||||
"#interval-select",
|
||||
".pin-btn",
|
||||
"#debug-toggle",
|
||||
];
|
||||
|
||||
export const INTERACTIVE_SELECTORS = INTERACTIVE_CONTROLS.map(
|
||||
(control) => control.selector,
|
||||
);
|
||||
|
||||
// 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
|
||||
@@ -154,17 +138,6 @@ export function evaluateChecks(facts, viewport, probes) {
|
||||
);
|
||||
|
||||
if (viewport.touch) {
|
||||
// Presence first: a selector that matches nothing contributes no
|
||||
// undersized targets, so without this the check would report
|
||||
// "all 0 controls are at least 44x44" and pass.
|
||||
const seen = new Map();
|
||||
for (const target of facts.tapTargets) {
|
||||
seen.set(target.selector, (seen.get(target.selector) ?? 0) + 1);
|
||||
}
|
||||
const missing = INTERACTIVE_CONTROLS.filter(
|
||||
(control) => (seen.get(control.selector) ?? 0) < control.minCount,
|
||||
);
|
||||
|
||||
const undersized = facts.tapTargets.filter(
|
||||
(t) => t.width < MIN_TAP_TARGET_PX || t.height < MIN_TAP_TARGET_PX,
|
||||
);
|
||||
@@ -181,21 +154,11 @@ export function evaluateChecks(facts, viewport, probes) {
|
||||
existing.count += 1;
|
||||
}
|
||||
}
|
||||
const detail = [];
|
||||
if (missing.length > 0) {
|
||||
detail.push(
|
||||
"oracle is not measuring the page: " +
|
||||
summarise(
|
||||
missing,
|
||||
(c) =>
|
||||
`${c.selector} matched ${seen.get(c.selector) ?? 0} visible element(s), expected at least ${c.minCount}`,
|
||||
4,
|
||||
),
|
||||
);
|
||||
}
|
||||
detail.push(
|
||||
check(
|
||||
`tap-targets-${MIN_TAP_TARGET_PX}px`,
|
||||
undersized.length === 0,
|
||||
undersized.length === 0
|
||||
? `${facts.tapTargets.length} controls measured, all at least ${MIN_TAP_TARGET_PX}x${MIN_TAP_TARGET_PX}`
|
||||
? `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()],
|
||||
@@ -204,11 +167,6 @@ export function evaluateChecks(facts, viewport, probes) {
|
||||
4,
|
||||
),
|
||||
);
|
||||
check(
|
||||
`tap-targets-${MIN_TAP_TARGET_PX}px`,
|
||||
missing.length === 0 && undersized.length === 0,
|
||||
detail.join("; "),
|
||||
);
|
||||
}
|
||||
|
||||
const badRows = facts.rows
|
||||
|
||||
@@ -73,52 +73,13 @@ export function mediaConditionsFromMarkup(sources) {
|
||||
}
|
||||
|
||||
// Whether a given width should be rendering the app's narrow (stacked)
|
||||
// layout.
|
||||
//
|
||||
// Two breakpoint styles can be answered from the condition list alone:
|
||||
//
|
||||
// - Desktop-first, which is what the app ships today: the wide layout is
|
||||
// unconditional and every narrow rule lives in a `max-width` block, so
|
||||
// a width is narrow exactly when one of those blocks matches. Note that
|
||||
// `max-width: 768px` matches *at* 768 — getting this inclusive boundary
|
||||
// wrong in either direction is what the three-widths-per-breakpoint
|
||||
// sweep exists to catch.
|
||||
// - Mobile-first, which is what Tailwind's `sm:`/`md:` prefixes are: the
|
||||
// stacked layout is the unconditional base and a `min-width` block is
|
||||
// what widens it, so a width is narrow exactly when it sits below every
|
||||
// `min-width` breakpoint.
|
||||
//
|
||||
// A mix of the two cannot be resolved from the breakpoints alone — which
|
||||
// block owns the host-row reflow is a property of the rules inside it, not
|
||||
// of the condition — so this throws rather than guessing. Guessing is how
|
||||
// the wrong expectation gets applied at the right widths and the whole
|
||||
// sweep quietly verifies nothing.
|
||||
// layout. The app keeps all of its narrow-viewport rules inside
|
||||
// `max-width` blocks, so a width is narrow exactly when one of those
|
||||
// blocks matches. Note that `max-width: 768px` matches *at* 768: getting
|
||||
// this inclusive boundary wrong in either direction is precisely what the
|
||||
// three-widths-per-breakpoint sweep exists to catch.
|
||||
export function expectsStackedLayout(width, conditions) {
|
||||
const kinds = new Set(conditions.map((c) => c.type));
|
||||
for (const kind of kinds) {
|
||||
if (kind !== "max" && kind !== "min") {
|
||||
throw new Error(
|
||||
`unsupported media condition type "${kind}" in ` +
|
||||
"expectsStackedLayout (test/viewport/viewports.js)",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (kinds.has("max") && kinds.has("min")) {
|
||||
throw new Error(
|
||||
"the app now mixes max-width and min-width breakpoints (" +
|
||||
conditions
|
||||
.map((c) => `${c.type}-width ${c.px}px in ${c.source}`)
|
||||
.join(", ") +
|
||||
"), so which layout a width should be showing can no longer " +
|
||||
"be inferred from the breakpoint list; teach " +
|
||||
"expectsStackedLayout in test/viewport/viewports.js which " +
|
||||
"block owns the host-row reflow",
|
||||
);
|
||||
}
|
||||
if (kinds.has("min")) {
|
||||
return !conditions.some((c) => width >= c.px);
|
||||
}
|
||||
return conditions.some((c) => width <= c.px);
|
||||
return conditions.some((c) => c.type === "max" && width <= c.px);
|
||||
}
|
||||
|
||||
// Viewports that are not derived from a breakpoint. Each one is here for
|
||||
|
||||
Reference in New Issue
Block a user