Compare commits
1 Commits
1e290a63cf
...
feat/viewp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c36dc36819 |
4
TODO.md
4
TODO.md
@@ -25,7 +25,9 @@ files, so merging it also closes most compliance gaps.
|
|||||||
- 2026-08-09: automated responsive-layout harness
|
- 2026-08-09: automated responsive-layout harness
|
||||||
(`make frontend-viewport-test`): digest-pinned headless Chrome driven over CDP
|
(`make frontend-viewport-test`): digest-pinned headless Chrome driven over CDP
|
||||||
against the built `dist/`, viewport widths derived from the breakpoints in
|
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
|
`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
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
||||||
shims, README Entrypoints section
|
shims, README Entrypoints section
|
||||||
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow
|
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow
|
||||||
|
|||||||
@@ -25,9 +25,16 @@ RUN_ID="$$-$(date +%s)"
|
|||||||
NETWORK="netwatch-viewport-$RUN_ID"
|
NETWORK="netwatch-viewport-$RUN_ID"
|
||||||
SERVER="netwatch-viewport-server-$RUN_ID"
|
SERVER="netwatch-viewport-server-$RUN_ID"
|
||||||
BROWSER="netwatch-viewport-browser-$RUN_ID"
|
BROWSER="netwatch-viewport-browser-$RUN_ID"
|
||||||
|
HARNESS="netwatch-viewport-harness-$RUN_ID"
|
||||||
ARTIFACT_DIR="$ROOT/tmp/viewport"
|
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() {
|
cleanup() {
|
||||||
|
docker rm -f "$HARNESS" > /dev/null 2>&1 || true
|
||||||
docker rm -f "$BROWSER" > /dev/null 2>&1 || true
|
docker rm -f "$BROWSER" > /dev/null 2>&1 || true
|
||||||
docker rm -f "$SERVER" > /dev/null 2>&1 || true
|
docker rm -f "$SERVER" > /dev/null 2>&1 || true
|
||||||
docker network rm "$NETWORK" > /dev/null 2>&1 || true
|
docker network rm "$NETWORK" > /dev/null 2>&1 || true
|
||||||
@@ -79,7 +86,7 @@ main() {
|
|||||||
-f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
|
-f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
|
||||||
"$BROWSER")"
|
"$BROWSER")"
|
||||||
|
|
||||||
timeout 900 docker run --rm --init \
|
timeout 900 docker run --rm --init --name "$HARNESS" \
|
||||||
--network "$NETWORK" \
|
--network "$NETWORK" \
|
||||||
--user "$(id -u):$(id -g)" \
|
--user "$(id -u):$(id -g)" \
|
||||||
-v "$ROOT:/app" \
|
-v "$ROOT:/app" \
|
||||||
|
|||||||
@@ -27,10 +27,13 @@ matches _at_ 768, and the sweep pins down which side of that line each layout is
|
|||||||
on.
|
on.
|
||||||
|
|
||||||
Nothing hardcodes 768. Add a second media block or start using `md:` classes and
|
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
|
the new breakpoint is covered without this directory being touched. The app is
|
||||||
viewports are fixed anchors, each with a stated reason: a 320px floor, a 1280px
|
desktop-first today (all narrow rules live in `max-width` blocks); a
|
||||||
desktop baseline, and two phone-landscape sizes straddling the breakpoint for
|
`min-width`-only, mobile-first set is handled as its inverse, and a set that
|
||||||
the rotation case.
|
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
|
## What it asserts
|
||||||
|
|
||||||
@@ -45,7 +48,11 @@ the rotation case.
|
|||||||
ellipsis truncation (Tailwind's `truncate`, used on host names and URLs) is
|
ellipsis truncation (Tailwind's `truncate`, used on host names and URLs) is
|
||||||
excluded: it is a design choice, not breakage.
|
excluded: it is a design choice, not breakage.
|
||||||
- **tap-targets-44px** — every interactive control is at least 44x44 CSS px on
|
- **tap-targets-44px** — every interactive control is at least 44x44 CSS px on
|
||||||
touch viewports. See below.
|
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.
|
- **host-rows-stacked / host-rows-side-by-side** — the rows genuinely reflow.
|
||||||
Computed `flex-direction` _and_ the actual geometry are checked, and in the
|
Computed `flex-direction` _and_ the actual geometry are checked, and in the
|
||||||
narrow layout the info block and the sparkline must each occupy essentially
|
narrow layout the info block and the sparkline must each occupy essentially
|
||||||
|
|||||||
@@ -14,13 +14,29 @@
|
|||||||
export const MIN_TAP_TARGET_PX = 44;
|
export const MIN_TAP_TARGET_PX = 44;
|
||||||
|
|
||||||
// The controls named in the definition of done, plus the pause button.
|
// The controls named in the definition of done, plus the pause button.
|
||||||
export const INTERACTIVE_SELECTORS = [
|
// Each carries the smallest number of *visible* instances the page has to
|
||||||
"#pause-btn",
|
// contain for the tap-target oracle to be measuring anything at all.
|
||||||
"#interval-select",
|
//
|
||||||
".pin-btn",
|
// Without those floors the check is inert: `undersized` is empty both when
|
||||||
"#debug-toggle",
|
// 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 = INTERACTIVE_CONTROLS.map(
|
||||||
|
(control) => control.selector,
|
||||||
|
);
|
||||||
|
|
||||||
// A host row is only "reflowed" if it stacked *and* went full width.
|
// A host row is only "reflowed" if it stacked *and* went full width.
|
||||||
// A row that merely shrank its 420px info column would keep
|
// 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
|
// flex-direction: row, and a row that stacked but left the info column at
|
||||||
@@ -138,6 +154,17 @@ export function evaluateChecks(facts, viewport, probes) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (viewport.touch) {
|
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(
|
const undersized = facts.tapTargets.filter(
|
||||||
(t) => t.width < MIN_TAP_TARGET_PX || t.height < MIN_TAP_TARGET_PX,
|
(t) => t.width < MIN_TAP_TARGET_PX || t.height < MIN_TAP_TARGET_PX,
|
||||||
);
|
);
|
||||||
@@ -154,11 +181,21 @@ export function evaluateChecks(facts, viewport, probes) {
|
|||||||
existing.count += 1;
|
existing.count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
check(
|
const detail = [];
|
||||||
`tap-targets-${MIN_TAP_TARGET_PX}px`,
|
if (missing.length > 0) {
|
||||||
undersized.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(
|
||||||
undersized.length === 0
|
undersized.length === 0
|
||||||
? `all ${facts.tapTargets.length} controls are at least ${MIN_TAP_TARGET_PX}x${MIN_TAP_TARGET_PX}`
|
? `${facts.tapTargets.length} controls measured, all 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}: ` +
|
: `${undersized.length} of ${facts.tapTargets.length} controls below ${MIN_TAP_TARGET_PX}x${MIN_TAP_TARGET_PX}: ` +
|
||||||
summarise(
|
summarise(
|
||||||
[...bySelector.values()],
|
[...bySelector.values()],
|
||||||
@@ -167,6 +204,11 @@ export function evaluateChecks(facts, viewport, probes) {
|
|||||||
4,
|
4,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
check(
|
||||||
|
`tap-targets-${MIN_TAP_TARGET_PX}px`,
|
||||||
|
missing.length === 0 && undersized.length === 0,
|
||||||
|
detail.join("; "),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const badRows = facts.rows
|
const badRows = facts.rows
|
||||||
|
|||||||
@@ -73,13 +73,52 @@ export function mediaConditionsFromMarkup(sources) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Whether a given width should be rendering the app's narrow (stacked)
|
// Whether a given width should be rendering the app's narrow (stacked)
|
||||||
// layout. The app keeps all of its narrow-viewport rules inside
|
// layout.
|
||||||
// `max-width` blocks, so a width is narrow exactly when one of those
|
//
|
||||||
// blocks matches. Note that `max-width: 768px` matches *at* 768: getting
|
// Two breakpoint styles can be answered from the condition list alone:
|
||||||
// this inclusive boundary wrong in either direction is precisely what the
|
//
|
||||||
// three-widths-per-breakpoint sweep exists to catch.
|
// - 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.
|
||||||
export function expectsStackedLayout(width, conditions) {
|
export function expectsStackedLayout(width, conditions) {
|
||||||
return conditions.some((c) => c.type === "max" && width <= c.px);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Viewports that are not derived from a breakpoint. Each one is here for
|
// Viewports that are not derived from a breakpoint. Each one is here for
|
||||||
|
|||||||
Reference in New Issue
Block a user