// Measures a rendered page and reports where the layout is wrong. // // This function is not run here. It is serialised with toString() and evaluated // inside the browser by scripts/device-sweep.mjs, so it may use only what a page // has: no imports, no Node globals, no closure over anything in this file. // // It measures rather than guesses. Every box is compared against its nearest // CLIPPING ancestor instead of document.scrollWidth, which lies the moment any // container carries overflow-x: hidden or clip — and this site's body does, so // a page can slice content off its right edge and still report a scrollWidth // equal to the viewport. That is exactly how the header CTA at iPad portrait // survived a fix and a release. // // It reports eight kinds: clipped, past_viewport, document_scrolls, // media_overflow, sticky_occluded, active_tab_offscreen, tiny_text and // touch_target. Each finding carries a severity, a devtools-pasteable selector // path, and the numbers it was decided on, so a finding can be re-measured // rather than re-argued. // // Provenance: lifted from the Privacy LLC site's scripts/css-qc.mjs, which is // where the thresholds were argued out and where the comments explaining each // one were written. Copied rather than shared because the two repositories have // no common package; if a threshold changes in one, it does not change in the // other. The driver here differs from that one in a way that matters: css-qc // declares Playwright device profiles but only ever calls setViewportSize, so // its deviceScaleFactor, isMobile and hasTouch fields never take effect and it // is a width sweep wearing a phone's clothes. export const audit = function audit() { const EPS = 1; const vw = window.innerWidth; const vh = window.innerHeight; const findings = []; const push = (f) => findings.push(f); /** A selector a human can paste into devtools. Short, not unique-at-all-costs. */ function pathOf(el) { const parts = []; let node = el; while (node && node.nodeType === 1 && parts.length < 4) { let part = node.tagName.toLowerCase(); // `getAttribute`, not `.id`. A
containing has // its `id` property clobbered by that input, so `.id` returns an element // and the path printed as `form#[object HTMLInputElement]`. const id = node.getAttribute("id"); if (id) { parts.unshift(`${part}#${id}`); break; } const cls = (node.getAttribute("class") || "") .split(/\s+/) .filter((c) => c && !c.includes("[") && !c.includes(":")) .slice(0, 2) .join("."); if (cls) part += `.${cls}`; parts.unshift(part); node = node.parentElement; } return parts.join(" > "); } const text = (el) => (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 60); const els = Array.from(document.body.querySelectorAll("*")); const info = new Map(); const ellipsis = new Set(); /** * Inside a closed disclosure, and therefore not on screen at all. * * Chrome does not `display: none` a closed `
` — it skips the * subtree with `content-visibility`, and the descendants keep reporting * layout boxes at their unconstrained size. The signature form in the * documents table measured 149px wide at x=255 on a 320px screen while the * closed `
` around it correctly measured 48px. Reporting that is * reporting content nobody can see, and it is the third distinct class of * false positive this audit had to learn about. */ function inClosedDisclosure(el) { const details = el.closest("details:not([open])"); if (!details) return false; const summary = details.querySelector(":scope > summary"); return !(summary && summary.contains(el)); } for (const el of els) { const rect = el.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) continue; const cs = getComputedStyle(el); if (cs.display === "none" || cs.visibility === "hidden") continue; if (cs.contentVisibility === "hidden") continue; if (inClosedDisclosure(el)) continue; info.set(el, { rect, cs }); if (cs.textOverflow === "ellipsis") ellipsis.add(el); } /** The nearest ancestor that scrolls horizontally on purpose. */ function scrollerOf(el) { let node = el.parentElement; while (node && node !== document.body) { const rec = info.get(node); if (rec && (rec.cs.overflowX === "auto" || rec.cs.overflowX === "scroll")) return node; node = node.parentElement; } return null; } /** The nearest ancestor that cuts content off without letting anyone scroll to it. */ function clipperOf(el) { let node = el.parentElement; while (node && node !== document.documentElement) { const rec = info.get(node); if (!rec) { node = node.parentElement; continue; } if (rec.cs.overflowX === "auto" || rec.cs.overflowX === "scroll") return null; if (rec.cs.overflowX === "hidden" || rec.cs.overflowX === "clip") return node; node = node.parentElement; } return null; } // --- 1. Content past the right edge of the viewport ----------------------- // // Leaves only: an element that overflows and has no overflowing descendant is // the thing that is actually too wide. Reporting its ancestors as well would // bury the one line that names the culprit under the whole chain it pushed. const overViewport = new Set(); for (const [el, { rect }] of info) { if (rect.right <= vw + EPS && rect.left >= -EPS) continue; if (scrollerOf(el)) continue; // Contained by something that clips: the reader does not see this past the // edge, they see it cut off — which check 2 reports, with the clipper named. // Reporting it here as well was the single largest source of noise in the // first run: every `truncate` in the admin has a child span whose rect runs // off the viewport by design, ellipsis and all. const clipper = clipperOf(el); if (clipper) { const box = info.get(clipper); if (box && box.rect.right <= vw + EPS) continue; } overViewport.add(el); } for (const el of overViewport) { if (Array.from(overViewport).some((other) => other !== el && el.contains(other))) continue; const { rect, cs } = info.get(el); push({ kind: "past_viewport", severity: "blocking", path: pathOf(el), text: text(el), detail: { right: Math.round(rect.right), viewport: vw, over: Math.round(rect.right - vw), width: cs.width, minWidth: cs.minWidth, whiteSpace: cs.whiteSpace, position: cs.position, }, says: `${Math.round(rect.right - vw)}px past the right edge`, }); } // --- 2. Content clipped by an ancestor, with no way to scroll to it ------- const clipped = new Map(); for (const [el, { rect }] of info) { const clipper = clipperOf(el); if (!clipper) continue; const box = info.get(clipper); if (!box) continue; if (rect.right <= box.rect.right + EPS && rect.left >= box.rect.left - EPS) continue; // A clipper wider than the viewport is already reported by check 1. if (box.rect.right > vw + EPS) continue; const seen = clipped.get(clipper) || []; seen.push({ el, rect }); clipped.set(clipper, seen); } for (const [clipper, children] of clipped) { const leaves = children.filter( ({ el }) => !children.some((other) => other.el !== el && el.contains(other.el)), ); const box = info.get(clipper); // Overflow has two sides. Picking the right-most leaf and subtracting made // a left-side overflow report as "cut off by -150px", which is not a // sentence. Measure how far each leaf escapes in whichever direction it // escapes, and rank by that. const escape = ({ rect }) => Math.max(0, rect.right - box.rect.right, box.rect.left - rect.left); const worst = leaves.reduce((a, b) => (escape(b) > escape(a) ? b : a), leaves[0]); const side = worst.rect.right - box.rect.right >= box.rect.left - worst.rect.left ? "right" : "left"; // `text-overflow: ellipsis` is a container saying "I will cut text off and // show that I did". That is an affordance, not silent loss — the reader can // see there is more. It stops being one the moment something interactive or // replaced is inside, because a button behind an ellipsis is still a button // nobody can press. const INTERACTIVE = "a[href], button, summary, input, select, textarea, img, video, iframe, [role=button], [role=tab]"; const carries = ({ el }) => (el.textContent || "").trim().length > 0 || el.matches(INTERACTIVE) || el.querySelector(INTERACTIVE); /** * A control is only *swallowed* when little enough of it survives the clip * to stop being aimable. * * The first version of this asked whether a control was present at all, and * that is too coarse for the commonest shape in the admin: a truncated cell * whose text *is* a link. `span.block.truncate > a` reports the anchor's * full 189px box against a 144px cell, so the anchor counted as swallowed — * while on screen 161px of it is visible, ellipsised, and perfectly * clickable. Three blocking findings on the projects board, all of them the * repository link reading `null/Privacy-Period-Tr...`, none of them a fault. * * What the rule is really protecting against is a control the clip puts out * of reach, so measure that: how much of it is left inside the box. Below * the 24px WCAG floor — or its own width, for a control smaller than that — * there is nothing to press and the ellipsis is not an affordance any more. */ const MIN_AIMABLE = 24; const controlsIn = (el) => [ ...(el.matches(INTERACTIVE) ? [el] : []), ...el.querySelectorAll(INTERACTIVE), ]; const swallowed = (control) => { const rect = control.getBoundingClientRect(); const visible = Math.min(rect.right, box.rect.right) - Math.max(rect.left, box.rect.left); return visible < Math.min(MIN_AIMABLE, rect.width); }; const swallowsControls = leaves.some(({ el }) => controlsIn(el).some(swallowed)); // Clipping only costs something when something was in it. A decorative // element parked outside its box is the technique, not a fault: the hover // shimmer on the radar capture button is `absolute inset-0 -translate-x-full` // and lives entirely to the left of the button until you hover it, which // this reported as "148px past the left edge" at every width for twenty // runs. An `aria-hidden` span with no text and no controls has nothing to // lose. if (!leaves.some(carries)) continue; if (ellipsis.has(clipper) && !swallowsControls) continue; push({ kind: "clipped", severity: "blocking", path: pathOf(clipper), text: text(worst.el), detail: { side, clipper: [Math.round(box.rect.left), Math.round(box.rect.right)], child: [Math.round(worst.rect.left), Math.round(worst.rect.right)], over: Math.round(escape(worst)), overflowX: box.cs.overflowX, childPath: pathOf(worst.el), hiddenChildren: leaves.length, }, says: `${leaves.length} element(s) cut off by ${Math.round(escape(worst))}px past the ${side} edge ` + `of an overflow-x:${box.cs.overflowX} box — no scrollbar, no hint`, }); } // --- 3. The weakest signal, kept for completeness ------------------------- const doc = document.documentElement; if (doc.scrollWidth > doc.clientWidth + EPS) { push({ kind: "document_scrolls", severity: "blocking", path: "html", text: "", detail: { scrollWidth: doc.scrollWidth, clientWidth: doc.clientWidth }, says: `the page itself scrolls sideways by ${doc.scrollWidth - doc.clientWidth}px`, }); } // --- 4. Touch targets ------------------------------------------------------ // // 44px is the number both platform guidelines land on. Elements nested inside // a larger tappable ancestor are skipped: the ancestor is the target. const TAPPABLE = "a[href], button, summary, input, select, textarea, [role=button], [role=tab]"; for (const el of document.body.querySelectorAll(TAPPABLE)) { const rec = info.get(el); if (!rec) continue; // A control inside a