// Pass/fail decisions for the responsive-layout harness. // // Kept in node rather than in the page so that a failure can be reported // with the measurements that produced it. Every check runs at every // viewport; none of them short-circuits, so one failure does not hide the // rest. // Minimum tap target, in CSS pixels. 44x44 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. Held at // 44 deliberately. 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 = 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 // its fixed width would fail the width test. const FULL_WIDTH_FRACTION = 0.9; function summarise(items, format, limit = 3) { const shown = items.slice(0, limit).map(format).join("; "); const rest = items.length > limit ? ` (+${items.length - limit} more)` : ""; return shown + rest; } // Collapse an overflow report to the elements actually responsible. // Identical elements (24 host rows all doing the same thing) are counted // rather than listed, and the deepest ones come first, since every // ancestor of an overflowing element also reports as overflowing. function deepestOffenders(entries) { const byElement = new Map(); for (const entry of entries) { const reach = entry.reach ?? entry.right; const existing = byElement.get(entry.el); if (existing) { existing.count += 1; existing.reach = Math.max(existing.reach, reach); } else { byElement.set(entry.el, { ...entry, reach, count: 1 }); } } return [...byElement.values()].sort( (a, b) => b.depth - a.depth || b.reach - a.reach, ); } function checkRowLayout(row, expectStacked) { if (expectStacked) { if (row.flexDirection !== "column") { return `row ${row.index}: flex-direction is ${row.flexDirection}, expected column`; } if (row.sparkline.top < row.info.bottom - 1) { return `row ${row.index}: sparkline top ${row.sparkline.top} is above info bottom ${row.info.bottom} — still side by side`; } const minWidth = row.containerWidth * FULL_WIDTH_FRACTION; if (row.info.width < minWidth) { return `row ${row.index}: info block is ${row.info.width}px of ${row.containerWidth}px — shrunk, not reflowed`; } if (row.sparkline.width < minWidth) { return `row ${row.index}: sparkline is ${row.sparkline.width}px of ${row.containerWidth}px — shrunk, not reflowed`; } return null; } if (row.flexDirection !== "row") { return `row ${row.index}: flex-direction is ${row.flexDirection}, expected row`; } if (row.sparkline.left < row.info.right - 1) { return `row ${row.index}: sparkline left ${row.sparkline.left} overlaps info right ${row.info.right} — not side by side`; } return null; } export function evaluateChecks(facts, viewport, probes) { const checks = []; const check = (name, ok, detail) => checks.push({ name, ok, detail }); // Guard against the whole harness passing vacuously because the page // never rendered. Everything below is only meaningful if this holds. check( "app-rendered", facts.rowCount >= 10 && facts.numericLatencies >= 5, `${facts.rowCount} host rows, ${facts.numericLatencies} showing a numeric latency`, ); const viewportWidth = Math.min(facts.innerWidth, facts.documentClientWidth); const culprits = deepestOffenders([ ...facts.overflowing, ...facts.contentOverflowing, ]); check( "no-horizontal-overflow", facts.documentScrollWidth <= viewportWidth, `documentElement.scrollWidth ${facts.documentScrollWidth} vs viewport ${viewportWidth}` + (culprits.length === 0 ? "" : "; widest content: " + summarise( culprits, (c) => `${c.el} reaches ${Math.round(c.reach)}px${c.count > 1 ? ` (x${c.count})` : ""}`, )), ); check( "nothing-past-viewport-edge", facts.overflowing.length === 0, facts.overflowing.length === 0 ? "no element extends past the viewport" : `${facts.overflowing.length} element(s) past the edge: ` + summarise( facts.overflowing, (o) => `${o.el} spans ${o.left}..${o.right}`, ), ); check( "no-clipped-text", facts.clipped.length === 0, facts.clipped.length === 0 ? "no element hides text behind overflow (deliberate ellipsis excluded)" : `${facts.clipped.length} element(s) clipping text: ` + summarise( facts.clipped, (c) => `${c.el} scrollWidth ${c.scrollWidth} > clientWidth ${c.clientWidth}`, ), ); 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, ); const bySelector = new Map(); for (const target of undersized) { const existing = bySelector.get(target.selector); if (!existing || target.width * target.height < existing.area) { bySelector.set(target.selector, { ...target, area: target.width * target.height, count: (existing?.count ?? 0) + 1, }); } else { 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( undersized.length === 0 ? `${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}: ` + summarise( [...bySelector.values()], (t) => `${t.selector} ${t.width}x${t.height}${t.count > 1 ? ` (x${t.count})` : ""}`, 4, ), ); check( `tap-targets-${MIN_TAP_TARGET_PX}px`, missing.length === 0 && undersized.length === 0, detail.join("; "), ); } const badRows = facts.rows .map((row) => checkRowLayout(row, viewport.expectStacked)) .filter(Boolean); check( viewport.expectStacked ? "host-rows-stacked" : "host-rows-side-by-side", facts.rows.length > 0 && badRows.length === 0, facts.rows.length === 0 ? "no host rows were measured" : badRows.length === 0 ? `all ${facts.rows.length} rows laid out as expected` : `${badRows.length} of ${facts.rows.length} rows wrong: ` + summarise(badRows, (r) => r), ); // The mobile early-return path proposed in #8 was rejected: narrow // viewports must keep probing and keep detecting the gateway, not // quietly skip work. check( "probing-still-runs", probes.attempted > 0, `${probes.attempted} outbound probe requests issued`, ); check( "gateway-detection-still-runs", facts.gatewayDetected, facts.gatewayDetected ? "Local Gateway row present" : "no Local Gateway row — gateway detection did not run or did not complete", ); return checks; }