// 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