#301: stop /settings scrolling horizontally at 320px
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m22s
CI / Auth e2e pack (pull_request) Failing after 8m10s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Successful in 1m11s

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.
This commit is contained in:
Claude Opus 5 2026-08-01 07:17:19 +02:00
parent 5a4a99196e
commit e48b9dd7df
3 changed files with 113 additions and 34 deletions

View File

@ -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<void> {
const report = await page.evaluate(() => {
const doc = document.documentElement;
const limit = doc.clientWidth;
const offenders: string[] = [];
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) {
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();
});
});

View File

@ -385,41 +385,56 @@ function SessionsSection(): React.JSX.Element {
return ( return (
<section className="settings-section"> <section className="settings-section">
<h2>{t('settings:sessions.title')}</h2> <h2>{t('settings:sessions.title')}</h2>
<table className="table"> {/* A table cannot shrink below its min-content width, so the user-agent
<thead> column pushed the whole page into horizontal scrolling at 320px
<tr> (issue #301, WCAG 1.4.10). It scrolls inside its own box instead
<th>{t('settings:sessions.device')}</th> the content stays reachable, which `overflow: hidden` would not. */}
<th>{t('settings:sessions.created')}</th> <div
<th>{t('settings:sessions.lastSeen')}</th> className="table-scroll"
<th> // A scroll container is only operable by keyboard once it is
<span className="visually-hidden">{t('common:tableActions')}</span> // focusable; role+name keep it from being an unlabelled stop.
</th> tabIndex={0}
</tr> role="region"
</thead> aria-label={t('settings:sessions.title')}
<tbody> >
{(sessions.data ?? []).map((session) => ( <table className="table">
<tr key={session.id}> <thead>
<td> <tr>
{session.userAgent ?? '—'} <th>{t('settings:sessions.device')}</th>
{session.current && <span className="badge">{t('settings:sessions.current')}</span>} <th>{t('settings:sessions.created')}</th>
</td> <th>{t('settings:sessions.lastSeen')}</th>
<td>{formatTime(session.createdAt)}</td> <th>
<td>{formatTime(session.lastSeenAt)}</td> <span className="visually-hidden">{t('common:tableActions')}</span>
<td> </th>
{!session.current && (
<button
type="button"
className="linklike"
onClick={() => revoke.mutate(session.id)}
>
{t('settings:sessions.revoke')}
</button>
)}
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {(sessions.data ?? []).map((session) => (
<tr key={session.id}>
<td>
{session.userAgent ?? '—'}
{session.current && (
<span className="badge">{t('settings:sessions.current')}</span>
)}
</td>
<td>{formatTime(session.createdAt)}</td>
<td>{formatTime(session.lastSeenAt)}</td>
<td>
{!session.current && (
<button
type="button"
className="linklike"
onClick={() => revoke.mutate(session.id)}
>
{t('settings:sessions.revoke')}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{others.length > 0 ? ( {others.length > 0 ? (
<button type="button" className="button" onClick={() => revokeOthers.mutate()}> <button type="button" className="button" onClick={() => revokeOthers.mutate()}>
{t('settings:sessions.revokeAll')} {t('settings:sessions.revokeAll')}

View File

@ -966,6 +966,14 @@ button {
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
/* Wrapper for tables that can outgrow a narrow viewport (issue #301). The
table keeps its own scrollbar; `tabindex` makes that scroll area reachable
by keyboard, which a bare overflow container is not. */
.table-scroll {
overflow-x: auto;
max-width: 100%;
}
.table { .table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@ -3761,6 +3769,10 @@ ul[data-type='task_list'] li p:last-of-type {
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
margin: var(--space-2) 0; margin: var(--space-2) 0;
/* Radio + label + accent swatches must be allowed to break onto a second
line at 320px (issue #301, WCAG 1.4.10) the swatches have a fixed size
and cannot shrink, so without this the row sets a floor for the page. */
flex-wrap: wrap;
} }
/* Radio groups in settings sections (issue #180). */ /* Radio groups in settings sections (issue #180). */