Compare commits
1 Commits
c36dc36819
...
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
|
- 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). Every check carries a presence guard so none of them
|
`src/styles.css` (#13). Found two real layout defects, filed as #42 and #43
|
||||||
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,16 +25,9 @@ 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
|
||||||
@@ -86,7 +79,7 @@ main() {
|
|||||||
-f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
|
-f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
|
||||||
"$BROWSER")"
|
"$BROWSER")"
|
||||||
|
|
||||||
timeout 900 docker run --rm --init --name "$HARNESS" \
|
timeout 900 docker run --rm --init \
|
||||||
--network "$NETWORK" \
|
--network "$NETWORK" \
|
||||||
--user "$(id -u):$(id -g)" \
|
--user "$(id -u):$(id -g)" \
|
||||||
-v "$ROOT:/app" \
|
-v "$ROOT:/app" \
|
||||||
|
|||||||
@@ -27,13 +27,10 @@ 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. The app is
|
the new breakpoint is covered without this directory being touched. Four further
|
||||||
desktop-first today (all narrow rules live in `max-width` blocks); a
|
viewports are fixed anchors, each with a stated reason: a 320px floor, a 1280px
|
||||||
`min-width`-only, mobile-first set is handled as its inverse, and a set that
|
desktop baseline, and two phone-landscape sizes straddling the breakpoint for
|
||||||
mixes the two makes the run fail loudly rather than test the right widths with
|
the rotation case.
|
||||||
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
|
||||||
|
|
||||||
@@ -48,11 +45,7 @@ sizes straddling the breakpoint for 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, _and_ each selector in the control list matched at least the
|
touch viewports. See below.
|
||||||
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,29 +14,13 @@
|
|||||||
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.
|
||||||
// Each carries the smallest number of *visible* instances the page has to
|
export const INTERACTIVE_SELECTORS = [
|
||||||
// contain for the tap-target oracle to be measuring anything at all.
|
"#pause-btn",
|
||||||
//
|
"#interval-select",
|
||||||
// Without those floors the check is inert: `undersized` is empty both when
|
".pin-btn",
|
||||||
// every control is large enough and when the selectors have gone stale and
|
"#debug-toggle",
|
||||||
// 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
|
||||||
@@ -154,17 +138,6 @@ 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,
|
||||||
);
|
);
|
||||||
@@ -181,21 +154,11 @@ export function evaluateChecks(facts, viewport, probes) {
|
|||||||
existing.count += 1;
|
existing.count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const detail = [];
|
check(
|
||||||
if (missing.length > 0) {
|
`tap-targets-${MIN_TAP_TARGET_PX}px`,
|
||||||
detail.push(
|
undersized.length === 0,
|
||||||
"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
|
||||||
? `${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}: ` +
|
: `${undersized.length} of ${facts.tapTargets.length} controls below ${MIN_TAP_TARGET_PX}x${MIN_TAP_TARGET_PX}: ` +
|
||||||
summarise(
|
summarise(
|
||||||
[...bySelector.values()],
|
[...bySelector.values()],
|
||||||
@@ -204,11 +167,6 @@ 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,52 +73,13 @@ 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.
|
// layout. The app keeps all of its narrow-viewport rules inside
|
||||||
//
|
// `max-width` blocks, so a width is narrow exactly when one of those
|
||||||
// Two breakpoint styles can be answered from the condition list alone:
|
// blocks matches. Note that `max-width: 768px` matches *at* 768: getting
|
||||||
//
|
// this inclusive boundary wrong in either direction is precisely what the
|
||||||
// - Desktop-first, which is what the app ships today: the wide layout is
|
// three-widths-per-breakpoint sweep exists to catch.
|
||||||
// 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) {
|
||||||
const kinds = new Set(conditions.map((c) => c.type));
|
return conditions.some((c) => c.type === "max" && width <= c.px);
|
||||||
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