From 55932b0828cae7c1deb9ea03540dfe580c53925b Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Sat, 1 Aug 2026 07:53:34 +0200 Subject: [PATCH] #301: make the reflow guard report the ancestor chain 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. --- apps/web/e2e/a11y.spec.ts | 57 +++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/apps/web/e2e/a11y.spec.ts b/apps/web/e2e/a11y.spec.ts index 2d323ab..3a34089 100644 --- a/apps/web/e2e/a11y.spec.ts +++ b/apps/web/e2e/a11y.spec.ts @@ -120,37 +120,66 @@ async function expectNoHorizontalScroll(page: Page, label: string): Promise { 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('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', () => {