check / check (push) Successful in 1m49s
The viewport harness's tap-target check required at least 10 visible pin buttons while 26 render, so pin buttons missing from up to 16 rows went unnoticed. It now expects one per WAN host row. The host row count the harness gathers, which the app-rendered check also reads, counts only the WAN host rows, since the local host rows have no pin button. Each control's minimum is now worked out from the gathered facts. TODO.md's harness entry no longer says every check guards itself: the overflow, viewport-edge and clipped-text checks rely on app-rendered. Model: opus-5-5
187 lines
7.6 KiB
JavaScript
187 lines
7.6 KiB
JavaScript
// Layout facts collected from inside the page.
|
|
//
|
|
// This function is serialised and evaluated in the browser, so it must be
|
|
// entirely self-contained: no imports, no closures over module scope. It
|
|
// only *measures*; every pass/fail decision is made back in node by
|
|
// checks.js, so failures can be reported with real numbers attached.
|
|
|
|
export function collectLayoutFacts(options) {
|
|
const describe = (el) => {
|
|
const id = el.id ? "#" + el.id : "";
|
|
const classes =
|
|
typeof el.className === "string" && el.className.trim()
|
|
? "." + el.className.trim().split(/\s+/).slice(0, 3).join(".")
|
|
: "";
|
|
return el.tagName.toLowerCase() + id + classes;
|
|
};
|
|
|
|
const round = (n) => Math.round(n * 10) / 10;
|
|
|
|
// Overflow propagates up every ancestor, so a single wide element
|
|
// reports as body, #app, the row, and so on. Depth lets the report
|
|
// name the deepest — that is, the actual — offender.
|
|
const depthOf = (el) => {
|
|
let depth = 0;
|
|
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
depth++;
|
|
}
|
|
return depth;
|
|
};
|
|
|
|
const isVisible = (el) => {
|
|
const style = getComputedStyle(el);
|
|
if (style.display === "none") return false;
|
|
if (style.visibility === "hidden") return false;
|
|
const rect = el.getBoundingClientRect();
|
|
return rect.width > 0 && rect.height > 0;
|
|
};
|
|
|
|
const innerWidth = window.innerWidth;
|
|
const clientWidth = document.documentElement.clientWidth;
|
|
// Under mobile emulation Chrome lets window.innerWidth *grow* to the
|
|
// width of overflowing content, exactly as a phone zooms out to fit a
|
|
// too-wide page. Measuring against it would therefore hide the
|
|
// overflow it is supposed to expose: at a 320px device width a page
|
|
// that spills to 350 reports innerWidth 350 and looks clean. Every
|
|
// comparison below is against the layout viewport instead.
|
|
const viewportWidth = Math.min(innerWidth, clientWidth);
|
|
const elements = Array.from(document.querySelectorAll("body *"));
|
|
|
|
// Elements sticking out past the right (or left) edge of the viewport.
|
|
// The document-level scrollWidth check says *that* the page overflows;
|
|
// this says *what* is doing it.
|
|
const overflowing = [];
|
|
// Elements clipping their own text. Deliberate ellipsis truncation
|
|
// (Tailwind's `truncate`) is opt-in and excluded: it is a design
|
|
// choice, not breakage.
|
|
const clipped = [];
|
|
// Elements whose content spills out of their own box without being
|
|
// clipped, past the right edge of the viewport. A block element is
|
|
// only ever as wide as its container, so text overflowing it has no
|
|
// element rect of its own to catch — but it is exactly what drags
|
|
// documentElement.scrollWidth past the viewport width, so without
|
|
// this the page-level overflow failure has nothing to point at.
|
|
const contentOverflowing = [];
|
|
|
|
for (const el of elements) {
|
|
if (!isVisible(el)) continue;
|
|
const rect = el.getBoundingClientRect();
|
|
if (rect.right > viewportWidth + 1 || rect.left < -1) {
|
|
overflowing.push({
|
|
el: describe(el),
|
|
depth: depthOf(el),
|
|
left: round(rect.left),
|
|
right: round(rect.right),
|
|
});
|
|
}
|
|
const style = getComputedStyle(el);
|
|
const clips =
|
|
style.overflowX === "hidden" || style.overflowX === "clip";
|
|
const ellipsis = style.textOverflow === "ellipsis";
|
|
const hasText = el.textContent.trim().length > 0;
|
|
const spills =
|
|
el.clientWidth > 0 && el.scrollWidth > el.clientWidth + 1;
|
|
if (clips && !ellipsis && hasText && spills) {
|
|
clipped.push({
|
|
el: describe(el),
|
|
scrollWidth: el.scrollWidth,
|
|
clientWidth: el.clientWidth,
|
|
});
|
|
}
|
|
if (
|
|
!clips &&
|
|
spills &&
|
|
rect.left + el.scrollWidth > viewportWidth + 1
|
|
) {
|
|
contentOverflowing.push({
|
|
el: describe(el),
|
|
depth: depthOf(el),
|
|
scrollWidth: el.scrollWidth,
|
|
clientWidth: el.clientWidth,
|
|
reach: round(rect.left + el.scrollWidth),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Interactive controls. The measured target is the nearest thing that
|
|
// is genuinely tappable — for a checkbox that is the <label> wrapping
|
|
// it, which is larger than the box itself and is what a finger hits.
|
|
const tapTargets = [];
|
|
for (const selector of options.interactiveSelectors) {
|
|
for (const el of document.querySelectorAll(selector)) {
|
|
if (!isVisible(el)) continue;
|
|
const target = el.closest("button, a, label, select") || el;
|
|
const rect = target.getBoundingClientRect();
|
|
tapTargets.push({
|
|
selector,
|
|
el: describe(target),
|
|
width: round(rect.width),
|
|
height: round(rect.height),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Host rows. The question is not "did it get narrower" but "did it
|
|
// reflow": the info block and the sparkline must end up stacked
|
|
// vertically and full width in the narrow layout, and side by side in
|
|
// the wide one. Both the computed flex-direction and the actual
|
|
// geometry are recorded so a row that claims to be a column but is
|
|
// still laid out side by side cannot slip through.
|
|
const rows = [];
|
|
for (const row of document.querySelectorAll(".host-row")) {
|
|
const inner = row.firstElementChild;
|
|
if (!inner) continue;
|
|
const sparkline = inner.querySelector(".sparkline-container");
|
|
const info = sparkline ? sparkline.previousElementSibling : null;
|
|
if (!sparkline || !info) continue;
|
|
const innerStyle = getComputedStyle(inner);
|
|
const innerRect = inner.getBoundingClientRect();
|
|
const infoRect = info.getBoundingClientRect();
|
|
const sparkRect = sparkline.getBoundingClientRect();
|
|
rows.push({
|
|
index: row.dataset.index,
|
|
flexDirection: innerStyle.flexDirection,
|
|
containerWidth: round(innerRect.width),
|
|
info: {
|
|
left: round(infoRect.left),
|
|
right: round(infoRect.right),
|
|
bottom: round(infoRect.bottom),
|
|
width: round(infoRect.width),
|
|
},
|
|
sparkline: {
|
|
left: round(sparkRect.left),
|
|
top: round(sparkRect.top),
|
|
width: round(sparkRect.width),
|
|
},
|
|
});
|
|
}
|
|
|
|
const localRows = Array.from(
|
|
document.querySelectorAll("#local-hosts .host-row"),
|
|
);
|
|
|
|
return {
|
|
innerWidth,
|
|
// innerWidth includes any classic scrollbar, clientWidth does not.
|
|
// Reported separately so the overflow check can hold itself to the
|
|
// narrower of the two rather than to whichever one is more
|
|
// forgiving.
|
|
documentClientWidth: document.documentElement.clientWidth,
|
|
documentScrollWidth: document.documentElement.scrollWidth,
|
|
overflowing,
|
|
contentOverflowing,
|
|
clipped,
|
|
tapTargets,
|
|
rows,
|
|
// WAN host rows only: each has a pin button, and the tap-target
|
|
// check expects one per row. The local host rows have none.
|
|
rowCount: document.querySelectorAll("#wan-hosts .host-row").length,
|
|
numericLatencies: Array.from(
|
|
document.querySelectorAll(".latency-value"),
|
|
).filter((el) => /\d/.test(el.textContent)).length,
|
|
gatewayDetected: localRows.some((row) =>
|
|
row.textContent.includes("Local Gateway"),
|
|
),
|
|
};
|
|
}
|