#301: make the reflow guard report the ancestor chain
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m22s
CI / Build container images (pull_request) Successful in 1m13s
CI / Auth e2e pack (pull_request) Failing after 8m14s
CI / Import/export fidelity gate (pull_request) Has been skipped

The previous run came back with an empty offender list and an unchanged
417px overflow: the filter treated everything under a scroll container as
innocent, including the container that was itself too wide. A scroller
only absolves its children when the scroller fits.

It now reports the chain from body down to the widest offender with each
box's width, so the first element wider than the viewport is visible
instead of inferred.
This commit is contained in:
Claude Opus 5 2026-08-01 07:53:34 +02:00
parent 3c1f211f44
commit 55932b0828

View File

@ -120,37 +120,66 @@ async function expectNoHorizontalScroll(page: Page, label: string): Promise<void
const report = await page.evaluate(() => {
const doc = document.documentElement;
const limit = doc.clientWidth;
/** Content inside its own scroll container is allowed to be wider than
* the viewport that is the prescribed remedy, not the defect. Only
* what pushes the PAGE is a finding. */
const insideScroller = (el: HTMLElement): boolean => {
const describe = (el: HTMLElement): string => {
const cls =
el.className && typeof el.className === 'string'
? `.${el.className.trim().split(/\s+/).join('.')}`
: '';
return `${el.tagName.toLowerCase()}${cls}`;
};
/**
* Content inside a scroll container may exceed the viewport that is the
* remedy, not the defect. But only if the CONTAINER itself fits: a
* scroller that is wider than the viewport still pushes the page, and its
* children are then symptoms, not causes.
*/
const containedByFittingScroller = (el: HTMLElement): boolean => {
for (let node = el.parentElement; node && node !== doc; node = node.parentElement) {
const overflowX = getComputedStyle(node).overflowX;
if (overflowX === 'auto' || overflowX === 'scroll' || overflowX === 'hidden') return true;
if (overflowX === 'auto' || overflowX === 'scroll' || overflowX === 'hidden') {
return node.getBoundingClientRect().right <= limit + 1;
}
}
return false;
};
const offenders: string[] = [];
let widest: HTMLElement | null = null;
for (const el of Array.from(document.querySelectorAll<HTMLElement>('body *'))) {
const rect = el.getBoundingClientRect();
// 1 px Toleranz gegen Subpixel-Rundung.
if (rect.width > 0 && rect.right > limit + 1 && !insideScroller(el)) {
const cls =
el.className && typeof el.className === 'string'
? `.${el.className.trim().split(/\s+/).join('.')}`
: '';
if (rect.width > 0 && rect.right > limit + 1 && !containedByFittingScroller(el)) {
offenders.push(
`${el.tagName.toLowerCase()}${cls} (right=${Math.round(rect.right)}, width=${Math.round(rect.width)})`,
`${describe(el)} (right=${Math.round(rect.right)}, width=${Math.round(rect.width)})`,
);
if (!widest || rect.width > widest.getBoundingClientRect().width) widest = el;
}
}
return { scrollWidth: doc.scrollWidth, clientWidth: limit, offenders: offenders.slice(0, 20) };
// Where does the inflation start? The chain from body down to the widest
// offender, with each box's width — the first entry wider than the
// viewport is the element that actually needs constraining.
const chain: string[] = [];
for (let node: HTMLElement | null = widest; node && node !== doc; node = node.parentElement) {
chain.unshift(`${describe(node)} w=${Math.round(node.getBoundingClientRect().width)}`);
}
return {
scrollWidth: doc.scrollWidth,
clientWidth: limit,
offenders: offenders.slice(0, 20),
chain,
};
});
expect(
{ overflowBy: report.scrollWidth - report.clientWidth, offenders: report.offenders },
{
overflowBy: report.scrollWidth - report.clientWidth,
offenders: report.offenders,
chain: report.chain,
},
`${label}: horizontaler Überlauf bei 320 px`,
).toEqual({ overflowBy: 0, offenders: [] });
).toEqual({ overflowBy: 0, offenders: [], chain: [] });
}
test.describe('reflow at 320px', () => {