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

590 lines
24 KiB
JavaScript
Raw Normal View History

fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
// 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
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
// 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
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
// 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
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
* 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.
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
// 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
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
// 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,
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
* 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)
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
* 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`,
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
});
}
// --- 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
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
// 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.
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
//
// 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
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
// 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
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
// 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
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
// 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.
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
// 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, ` +
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
(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
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
// 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
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
// 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
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
// 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.
fix(ui): header, Cisco logo and tap targets, found by rendering on devices Every page on ten emulated phones and tablets: 190 loads, 21 blocking and 530 high findings, on pages that had passed Batch 11 and Batch 16. #214 was fixed in v0.9.5 and released still broken. The check was a desktop window sized to 768, which has a scrollbar, so the layout viewport was ~753px and the md breakpoint the fix was about never engaged. At a true 768 the brand, six nav links and the CTA want 787px against 736px of container, so no amount of gap tightening closes it. The desktop row now starts at lg, and 768 to 1023 gets the Sheet menu, which is the better tablet experience regardless: 44px rows instead of 17px ones, and submenus that do not depend on hover. The CTA takes shrink-0 and whitespace-nowrap, so the next row that does not fit overflows visibly instead of being squeezed past the edge while the total still measures as fitting. The Cisco mark fills 66% of its 700x700 canvas, so both tiles scaled it up inside overflow-hidden and cut 13px off the trademark on / and 24px on /about. Cropping the viewBox to the artwork renders it at 62x46 on /, the same size as the 8x8 logo beside it, unscaled and unclipped. 491 standalone links were 17 to 26px tall. .tap-target grows the hit box and takes it back out of the layout, and every list using it moves to space-y-4: at space-y-2 neighbouring boxes overlap and measure 33px while not being there. Footer links also take block, so the row is the target. A 39x36 word is judged as a compact target wanting 44px; a 200x36 row wants 32. scripts/device-sweep.mjs and scripts/lib/css-audit.js are the instrument. They stay out of verify.sh: playwright is global here and the sweep needs a running server, and a guard that cannot run on a clean clone is one that gets skipped. After the fixes: zero blocking, zero high across all ten devices. Each defect was then re-introduced and reported again (clipped x1, media_overflow x1, past_viewport x19, touch_target x342). closes #238 closes #239 closes #240 closes #241 Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-10 07:09:32 -05:00
return findings;
};