From f938ee9880037c6033175393817c141da2daeb14 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Sat, 1 Aug 2026 07:17:19 +0200 Subject: [PATCH] #301: stop /settings scrolling horizontally at 320px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WCAG 2.1 SC 1.4.10 asks for no two-dimensional scrolling down to 320px, which is also what 400% zoom on a 1280px screen produces. The layout skeleton was already hardened for this in #165; the overflow came from content inside the sections. - The sessions table cannot shrink below its min-content width — four columns, one of them the full user-agent string. It now scrolls inside its own container rather than pushing the page. The container is focusable with a role and a name, because a scroll area that only a mouse can reach trades one barrier for another. - `.settings-checkbox` rows may wrap. The accent swatches have a fixed size and cannot shrink, so an unwrappable row set a floor for the whole page width. Adds a reflow guard to the a11y pack. axe does not cover 1.4.10 — the criterion is not derivable from the DOM — so this is a separate check, and it names the overflowing elements when it trips instead of only reporting that something overflows. --- apps/web/e2e/a11y.spec.ts | 52 ++++++++++++++++++ apps/web/src/pages/SettingsPage.tsx | 83 +++++++++++++++++------------ apps/web/src/styles/base.css | 12 +++++ 3 files changed, 113 insertions(+), 34 deletions(-) diff --git a/apps/web/e2e/a11y.spec.ts b/apps/web/e2e/a11y.spec.ts index 9f08551..92626bb 100644 --- a/apps/web/e2e/a11y.spec.ts +++ b/apps/web/e2e/a11y.spec.ts @@ -104,3 +104,55 @@ for (const scheme of SCHEMES) { }); }); } + +/** + * Reflow (WCAG 2.1 SC 1.4.10, issue #301): bei 320 px CSS-Breite — was 400 % + * Zoom auf 1280 px entspricht — darf die Seite nicht seitenweit horizontal + * scrollen. axe prüft das NICHT, das Kriterium ist nicht maschinell aus dem + * DOM ableitbar; deshalb ein eigener Zaun. + * + * Schlägt er an, nennt er die überstehenden Elemente. Ohne diese Diagnose + * weiß man nur DASS es überläuft und muss im Browser bisektieren. + */ +const NARROW = { width: 320, height: 800 }; + +async function expectNoHorizontalScroll(page: Page, label: string): Promise { + const report = await page.evaluate(() => { + const doc = document.documentElement; + const limit = doc.clientWidth; + const offenders: string[] = []; + 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) { + const cls = + el.className && typeof el.className === 'string' + ? `.${el.className.trim().split(/\s+/).join('.')}` + : ''; + offenders.push( + `${el.tagName.toLowerCase()}${cls} (right=${Math.round(rect.right)}, width=${Math.round(rect.width)})`, + ); + } + } + return { scrollWidth: doc.scrollWidth, clientWidth: limit, offenders: offenders.slice(0, 12) }; + }); + expect( + { overflowBy: report.scrollWidth - report.clientWidth, offenders: report.offenders }, + `${label}: horizontaler Überlauf bei 320 px`, + ).toEqual({ overflowBy: 0, offenders: [] }); +} + +test.describe('reflow at 320px', () => { + test('user settings do not scroll horizontally at 320px', async ({ browser }) => { + const context = await contextForUser(browser, BASE, 'fixture-user'); + const page = await context.newPage(); + await page.setViewportSize(NARROW); + await page.goto('/settings'); + await page.waitForLoadState('networkidle'); + // Die Sitzungstabelle rendert asynchron und ist der breiteste Inhalt — + // ohne sie misst der Zaun eine halb aufgebaute Seite. + await page.locator('.table tbody tr').first().waitFor(); + await expectNoHorizontalScroll(page, '/settings'); + await context.close(); + }); +}); diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 229ce07..67db9be 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -385,41 +385,56 @@ function SessionsSection(): React.JSX.Element { return (

{t('settings:sessions.title')}

- - - - - - - - - - - {(sessions.data ?? []).map((session) => ( - - - - - + {/* A table cannot shrink below its min-content width, so the user-agent + column pushed the whole page into horizontal scrolling at 320px + (issue #301, WCAG 1.4.10). It scrolls inside its own box instead — + the content stays reachable, which `overflow: hidden` would not. */} +
+
{t('settings:sessions.device')}{t('settings:sessions.created')}{t('settings:sessions.lastSeen')} - {t('common:tableActions')} -
- {session.userAgent ?? '—'} - {session.current && {t('settings:sessions.current')}} - {formatTime(session.createdAt)}{formatTime(session.lastSeenAt)} - {!session.current && ( - - )} -
+ + + + + + - ))} - -
{t('settings:sessions.device')}{t('settings:sessions.created')}{t('settings:sessions.lastSeen')} + {t('common:tableActions')} +
+ + + {(sessions.data ?? []).map((session) => ( + + + {session.userAgent ?? '—'} + {session.current && ( + {t('settings:sessions.current')} + )} + + {formatTime(session.createdAt)} + {formatTime(session.lastSeenAt)} + + {!session.current && ( + + )} + + + ))} + + + {others.length > 0 ? (