Queue-North-Website/scripts/lib/css-audit.js

590 lines
24 KiB
JavaScript

// 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 logic is that file's, unchanged. The prose is not byte-identical:
// this repository does not use em dashes, so the comments and the two report
// strings were repunctuated. Diff it on words, not bytes. 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 <form> containing <input name="id"> 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 `<details>`. 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 `<details>` 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, with no scrollbar and 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 <label> is aimed at through the label, so the label is
// what gets measured. Skipping it outright, which this did first, means
// wrapping a 16px checkbox in a label silences the check without making the
// target any bigger, and the fix for the one real instance of that was
// exactly such a wrapper. A checker you can satisfy by adding an element is
// not a checker.
const label = el.closest("label");
const measured = label ? info.get(label) : null;
const rect = measured ? measured.rect : rec.rect;
if (label && !measured) continue;
const parent = el.parentElement && el.parentElement.closest(TAPPABLE);
if (parent) continue;
// Two rules, because a link and a button are not the same shape of target.
//
// A control must be at least **32px thick and 44px long**, not 44x44.
//
// 44x44 is WCAG 2.5.5, the AAA figure, and applying it to anything matching
// `button` produced ninety findings that all said the same thing: the admin
// renders lists whose rows are controls. A milestone row is 300px wide and
// 20px tall; taking it to 44 doubles the height of a twenty-item list, and
// that is a decision about how much the board shows per screen rather than
// a fix. 2.5.8 (AA) sets the floor at 24.
//
// So the rule is about the shape of a finger, not a square: you need
// thickness in whichever axis is scarce, and length in the other. An icon
// button is 44x44 and passes on both counts; a list row at 300x32 passes;
// a row at 300x20 fails on thickness; a 40x40 icon button fails on length,
// which is what caught `size="icon"` being `size-10`. Decided deliberately,
// and 32 rather than 24 so there is margin above the AA floor.
//
// A text link is judged on height and on its smaller dimension only. The
// first version demanded 44px in both axes and duly reported "FAQ", 27px
// wide, 44px tall, with 24px of gap either side, as a defect on nine
// routes. Padding a three-letter word out to 44px to satisfy a checker is
// the checker driving the design. WCAG 2.5.8 sets 24px as the floor and
// exempts inline links in text for exactly this reason; the axis that is
// actually scarce in a horizontal nav row is the vertical one.
//
// A link inside a sentence is exempt, and this is not a loophole: WCAG
// 2.5.8 says so in as many words. Its height is the line height of the
// prose around it; the only way to give it 44px is to break the paragraph.
// Nine of these were being reported on the FAQ and the legal pages.
if (el.tagName === "A") {
const parent = el.parentElement;
const around = parent ? parent.textContent.trim().length : 0;
if (around > (el.textContent || "").trim().length + 3) continue;
}
const isControl = el.matches("button, summary, input, select, textarea, [role=button]");
const thickness = Math.min(rect.width, rect.height);
const length = Math.max(rect.width, rect.height);
// Row-shaped: twice as wide as it is tall. A list row, not a thing you aim
// at. This is what separates "the milestone row" from "the icon button",
// and it has to be measured rather than guessed from the tag, because both
// are `<button>`.
// 1.5x, not 2x. A 60x32 link in a dashboard widget is a row by every
// sensible reading and missed a 2x test by four pixels. "FAQ" at 27x44 is
// still nowhere near it, which is the case this threshold exists to keep out.
const rowShaped = rect.width >= rect.height * 1.5;
let short;
let narrow;
if (isControl && rowShaped) {
// A control that is a row. Thickness is what a thumb needs; the length
// takes care of itself.
short = thickness < 32 - EPS;
narrow = length < 44 - EPS;
} else if (isControl) {
// A compact control: an icon button, a checkbox. You aim at a point, so
// it needs the full 44 in both axes. The 32px relaxation above is for
// rows and must not leak here: it would accept `size="icon"` back at
// 40x44, which is the exact defect this caught a few commits ago.
short = thickness < 44 - EPS;
narrow = length < 44 - EPS;
} else if (rowShaped) {
// A link that is a row obeys the row rule. The docs file list and the
// dashboard's widget links are links by tag and rows by shape.
short = rect.height < 32 - EPS;
narrow = false;
} else {
// A link that is a word in a nav. Judged on height, because the vertical
// axis is the scarce one in a horizontal row, and on 24px of thickness.
// demanding 32 here would report "FAQ" at 27px wide, which is the
// checker driving the design again.
short = rect.height < 44 - EPS;
narrow = thickness < 24 - EPS;
}
if (!short && !narrow) continue;
push({
kind: "touch_target",
severity: "high",
path: pathOf(el),
text: text(el),
detail: { width: Math.round(rect.width), height: Math.round(rect.height) },
says:
`${Math.round(rect.width)}x${Math.round(rect.height)}px, ` +
(short
? `thinner than the ${isControl && !rowShaped ? 44 : rowShaped ? 32 : 44}px a touch target needs`
: `shorter than the ${isControl ? 44 : 24}px minimum`),
});
}
// --- 5. Text too small to read --------------------------------------------
for (const [el, { cs }] of info) {
const own = Array.from(el.childNodes).some(
(n) => n.nodeType === 3 && n.textContent.trim().length > 3,
);
if (!own) continue;
const size = parseFloat(cs.fontSize);
if (size >= 12 - 0.01) continue;
// Visually hidden. `sr-only` clips text to a 1px box so a screen reader
// still reads it and nobody sees it, so its font-size is not a legibility
// question, and 28 of the 87 findings here were the Ripley quote's
// `sr-only` companion saying the same thing on every admin screen.
const box = info.get(el).rect;
if (box.width <= 1 || box.height <= 1) continue;
// Small uppercase tracked text is a label, not prose.
//
// This codebase writes badges, eyebrows and machine values as 10-11px mono
// uppercase with positive letter-spacing: a deliberate typographic
// register, used in over two hundred places. Reporting every one of them at
// "high" produces a list nobody will ever work through, which is how a
// report stops being read. What actually harms a reader is small *prose*,
// so that is what this reports. There is no WCAG minimum font size to
// appeal to here; this is a judgement, and it is written down so the next
// person can disagree with it on purpose.
const tracked = parseFloat(cs.letterSpacing) > 0;
if (cs.textTransform === "uppercase" && tracked) continue;
push({
kind: "tiny_text",
severity: "high",
path: pathOf(el),
text: text(el),
detail: { fontSize: cs.fontSize },
says: `${cs.fontSize} text`,
});
}
// --- 6. Media wider than the box holding it -------------------------------
for (const el of document.body.querySelectorAll("img, video, iframe, canvas, svg")) {
const rec = info.get(el);
const parent = el.parentElement && info.get(el.parentElement);
if (!rec || !parent) continue;
if (rec.rect.width <= parent.rect.width + EPS) continue;
push({
kind: "media_overflow",
severity: "high",
path: pathOf(el),
text: el.getAttribute("alt") || el.getAttribute("src") || "",
detail: {
mediaWidth: Math.round(rec.rect.width),
containerWidth: Math.round(parent.rect.width),
},
says: `${Math.round(rec.rect.width - parent.rect.width)}px wider than its container`,
});
}
// --- 7. Two sticky boxes fighting over the same edge ----------------------
//
// The project header is `sticky top-0 z-20` under a shell bar that is also
// stuck at 0 with an opaque background and z-40. Nothing errors; the header
// just slides underneath and is never seen again.
const sticky = [];
for (const [el, { cs, rect }] of info) {
if (cs.position !== "sticky" && cs.position !== "fixed") continue;
if (cs.top === "auto") continue;
// A decoration cannot occlude anything: it does not take clicks and it is
// not what the reader is looking for. The navigation progress bar is
// `pointer-events-none fixed top-0 z-[100] h-[3px]`, and without this it
// reported the entire admin header as hidden behind it on all 25 screens.
if (cs.pointerEvents === "none") continue;
sticky.push({ el, top: parseFloat(cs.top) || 0, z: parseInt(cs.zIndex, 10) || 0, rect, cs });
}
for (const a of sticky) {
for (const b of sticky) {
if (a === b || a.el.contains(b.el) || b.el.contains(a.el)) continue;
if (Math.abs(a.top - b.top) > EPS) continue;
if (a.z >= b.z) continue;
// Same offset and behind only matters if the thing in front actually
// covers it, in both axes.
//
// Vertically, a quarter is enough: what a stuck header loses first is its
// top, which is where its title is. A 50% threshold, which this used
// first, let the real case through, because the project header is
// 294px tall and the bar over it is 132px, so it was "only" 45% hidden.
//
// Horizontally is not optional. The admin sidebar is `fixed inset-y-0
// z-40` and overlaps every sticky header on the page vertically while
// sitting entirely to their left, which without this reads as the whole
// admin being permanently occluded by its own navigation.
const vertical =
Math.min(a.rect.bottom, b.rect.bottom) - Math.max(a.rect.top, b.rect.top);
const horizontal =
Math.min(a.rect.right, b.rect.right) - Math.max(a.rect.left, b.rect.left);
if (vertical < Math.max(24, a.rect.height * 0.25)) continue;
if (horizontal < a.rect.width * 0.5) continue;
// a is behind b at the same offset: a is the one that disappears.
push({
kind: "sticky_occluded",
severity: "high",
path: pathOf(a.el),
text: text(a.el),
detail: { top: a.top, zIndex: a.z, coveredBy: pathOf(b.el), coveredByZ: b.z },
says: `sticky at top:${a.top}px with z-index ${a.z}, behind another stuck at the same offset with z-index ${b.z}`,
});
break;
}
}
// --- 8. The selected tab is scrolled out of sight -------------------------
for (const el of document.body.querySelectorAll('[aria-selected="true"], [data-state="active"]')) {
const rec = info.get(el);
if (!rec) continue;
const scroller = scrollerOf(el);
if (!scroller) continue;
const box = info.get(scroller);
if (!box) continue;
if (rec.rect.left >= box.rect.left - EPS && rec.rect.right <= box.rect.right + EPS) continue;
push({
kind: "active_tab_offscreen",
severity: "high",
path: pathOf(el),
text: text(el),
detail: { tabLeft: Math.round(rec.rect.left), visibleFrom: Math.round(box.rect.left), visibleTo: Math.round(box.rect.right) },
says: "the selected tab is outside the visible part of its scroller, so nothing on screen looks selected",
});
}
// There is no check here for `100vh`.
//
// There was, and it could never fire: `getComputedStyle` resolves `100vh` to
// a pixel value, so nothing in the browser can tell it apart from a height
// that was written in pixels. A check that cannot fail is worse than no
// check, because it reads as coverage. The rule it was reaching for ("use dvh,
// because 100vh is the viewport with the mobile URL bar hidden") is a property
// of the source, so it lives in `tests/responsive-guards.test.ts` where a
// source grep is the honest instrument.
return findings;
};