// 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. export const INTERACTIVE_SELECTORS = [ "#pause-btn", "#interval-select", ".pin-btn", "#debug-toggle", ]; // 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) { 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; } } check( `tap-targets-${MIN_TAP_TARGET_PX}px`, undersized.length === 0, undersized.length === 0 ? `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()], (t) => `${t.selector} ${t.width}x${t.height}${t.count > 1 ? ` (x${t.count})` : ""}`, 4, ), ); } 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; }