diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index fc5e25f..bc66c81 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -620,6 +620,14 @@ jobs: E2E_BASE_URL=http://localhost:5173 \ pnpm --filter @dorfteich/web exec playwright test e2e/a11y.spec.ts + # Das a11y-Pack kostet seit #301 einen Login mehr (der Reflow-Zaun); + # damit reicht das Budget nicht mehr bis in die VS-NfD-Packs → hier + # zusätzlich zurücksetzen (siehe Hinweis oben). + - name: Reset login rate limit before the VS-NfD packs + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + # VS-NfD-Markierungen im Modus `marked` (issue #244). - name: Run VS-NfD marking pack run: | diff --git a/apps/web/e2e/a11y.spec.ts b/apps/web/e2e/a11y.spec.ts index 9f08551..e51f718 100644 --- a/apps/web/e2e/a11y.spec.ts +++ b/apps/web/e2e/a11y.spec.ts @@ -104,3 +104,95 @@ 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 describe = (el: Element): string => { + const cls = + el.className && typeof el.className === 'string' + ? `.${el.className.trim().split(/\s+/).join('.')}` + : ''; + return `${el.tagName.toLowerCase()}${cls}`; + }; + + // Every element whose own content is wider than its box. One of these is + // the source; the ones that scroll it away on purpose are marked. + const overflowing: string[] = []; + for (const el of Array.from(document.querySelectorAll('*'))) { + if (el.scrollWidth > el.clientWidth + 1 && el.clientWidth > 0) { + const overflowX = getComputedStyle(el).overflowX; + overflowing.push( + `${describe(el)} client=${el.clientWidth} scroll=${el.scrollWidth} overflow-x=${overflowX}`, + ); + } + } + + /** Content inside a scroll container may exceed the viewport — that is + * the remedy. But only when the CONTAINER fits: a scroller that is + * itself too wide still pushes the page. */ + const insideFittingScroller = (el: Element): boolean => { + for (let node = el.parentElement; node && node !== doc; node = node.parentElement) { + const ox = getComputedStyle(node).overflowX; + if (ox === 'auto' || ox === 'scroll' || ox === 'hidden') { + return node.getBoundingClientRect().right <= limit + 1; + } + } + return false; + }; + + // Widest reach first, so a long tail of clipped children cannot bury the + // one box that actually pushes the page. + const past = Array.from(document.querySelectorAll('body *')) + .map((el) => ({ el, rect: el.getBoundingClientRect() })) + .filter(({ rect }) => rect.width > 0 && rect.right > limit + 1) + .sort((a, b) => b.rect.right - a.rect.right) + .map( + ({ el, rect }) => + `${describe(el)} right=${Math.round(rect.right)} w=${Math.round(rect.width)}` + + `${insideFittingScroller(el) ? ' [in fitting scroller]' : ' <-- pushes page'}`, + ); + + return { + overflowBy: doc.scrollWidth - limit, + viewport: `html client=${limit} scroll=${doc.scrollWidth} | body client=${document.body.clientWidth} scroll=${document.body.scrollWidth} rect=${Math.round(document.body.getBoundingClientRect().width)}`, + overflowing: overflowing.slice(0, 15), + past: past.slice(0, 40), + }; + }); + const diagnosis = [ + `${label}: horizontaler Überlauf bei 320 px`, + report.viewport, + `eigener Inhaltsüberlauf: ${JSON.stringify(report.overflowing, null, 1)}`, + `Boxen über dem Rand: ${JSON.stringify(report.past, null, 1)}`, + ].join('\n'); + expect({ overflowBy: report.overflowBy }, diagnosis).toEqual({ overflowBy: 0 }); +} + +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/api-tokens/ApiTokensSection.tsx b/apps/web/src/api-tokens/ApiTokensSection.tsx index 22d0a9d..2b34627 100644 --- a/apps/web/src/api-tokens/ApiTokensSection.tsx +++ b/apps/web/src/api-tokens/ApiTokensSection.tsx @@ -172,52 +172,54 @@ function TokenList({ tokens }: { tokens: ApiTokenView[] }): React.JSX.Element { return ( <> - - - - - - - - - - - - - - - {tokens.map((token) => ( - - - - - - - - - +
+
{t('fields.name')}{t('fields.scope')}{t('fields.ponds')}{t('list.created')}{t('list.lastUsed')}{t('list.expires')}{t('list.status')} - {t('common:tableActions')} -
{token.name}{token.scope === 'write' ? t('fields.scopeWrite') : t('fields.scopeRead')} - {token.ponds.length === 0 - ? t('list.allPonds') - : token.ponds.map((pond) => pond.name).join(', ')} - {new Date(token.createdAt).toLocaleDateString()} - {token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleString() : t('list.never')} - {token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : '—'}{t(`list.${statusOf(token)}`)} - {!token.revokedAt && ( - - )} -
+ + + + + + + + + + - ))} - -
{t('fields.name')}{t('fields.scope')}{t('fields.ponds')}{t('list.created')}{t('list.lastUsed')}{t('list.expires')}{t('list.status')} + {t('common:tableActions')} +
+ + + {tokens.map((token) => ( + + {token.name} + {token.scope === 'write' ? t('fields.scopeWrite') : t('fields.scopeRead')} + + {token.ponds.length === 0 + ? t('list.allPonds') + : token.ponds.map((pond) => pond.name).join(', ')} + + {new Date(token.createdAt).toLocaleDateString()} + + {token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleString() : t('list.never')} + + {token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : '—'} + {t(`list.${statusOf(token)}`)} + + {!token.revokedAt && ( + + )} + + + ))} + + + ); } diff --git a/apps/web/src/api-tokens/FeedTokensSection.tsx b/apps/web/src/api-tokens/FeedTokensSection.tsx index 1b2e40d..bf3a1a5 100644 --- a/apps/web/src/api-tokens/FeedTokensSection.tsx +++ b/apps/web/src/api-tokens/FeedTokensSection.tsx @@ -85,32 +85,38 @@ export function FeedTokensSection(): React.JSX.Element { )} {tokens.data && tokens.data.length === 0 &&

{t('feed.empty')}

} {tokens.data && tokens.data.length > 0 && ( - - - - - - - - - - - {tokens.data.map((token) => ( - - - - - +
+
{t('fields.name')}{t('list.created')}{t('list.lastUsed')} - {t('common:tableActions')} -
{token.name}{formatTime(token.createdAt)}{token.lastUsedAt ? formatTime(token.lastUsedAt) : '—'} - -
+ + + + + + - ))} - -
{t('fields.name')}{t('list.created')}{t('list.lastUsed')} + {t('common:tableActions')} +
+ + + {tokens.data.map((token) => ( + + {token.name} + {formatTime(token.createdAt)} + {token.lastUsedAt ? formatTime(token.lastUsedAt) : '—'} + + + + + ))} + + + )} ); 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 ? (