#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 report = await page.evaluate(() => {
const doc = document.documentElement; const doc = document.documentElement;
const limit = doc.clientWidth; const limit = doc.clientWidth;
/** Content inside its own scroll container is allowed to be wider than const describe = (el: HTMLElement): string => {
* the viewport that is the prescribed remedy, not the defect. Only const cls =
* what pushes the PAGE is a finding. */ el.className && typeof el.className === 'string'
const insideScroller = (el: HTMLElement): boolean => { ? `.${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) { for (let node = el.parentElement; node && node !== doc; node = node.parentElement) {
const overflowX = getComputedStyle(node).overflowX; 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; return false;
}; };
const offenders: string[] = []; const offenders: string[] = [];
let widest: HTMLElement | null = null;
for (const el of Array.from(document.querySelectorAll<HTMLElement>('body *'))) { for (const el of Array.from(document.querySelectorAll<HTMLElement>('body *'))) {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
// 1 px Toleranz gegen Subpixel-Rundung. // 1 px Toleranz gegen Subpixel-Rundung.
if (rect.width > 0 && rect.right > limit + 1 && !insideScroller(el)) { if (rect.width > 0 && rect.right > limit + 1 && !containedByFittingScroller(el)) {
const cls =
el.className && typeof el.className === 'string'
? `.${el.className.trim().split(/\s+/).join('.')}`
: '';
offenders.push( 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( 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`, `${label}: horizontaler Überlauf bei 320 px`,
).toEqual({ overflowBy: 0, offenders: [] }); ).toEqual({ overflowBy: 0, offenders: [], chain: [] });
} }
test.describe('reflow at 320px', () => { test.describe('reflow at 320px', () => {