diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 4d04484..47d93a3 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -521,6 +521,18 @@ jobs: sleep 2 done + # Two logins per run → reset first (see note above). + - name: Reset login rate limit before a11y pack + run: | + echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ + pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL" + + # WCAG-A/AA-Regressionsschutz (issue #171): axe-Scan der Kernscreens. + - name: Run a11y pack + run: | + E2E_BASE_URL=http://localhost:5173 \ + pnpm --filter @dorfteich/web exec playwright test e2e/a11y.spec.ts + - name: Run setup wizard pack run: | E2E_BASE_URL=http://localhost:5175 E2E_SETUP=1 \ diff --git a/apps/api/src/pages/tasks.service.ts b/apps/api/src/pages/tasks.service.ts index 3298de6..2a58587 100644 --- a/apps/api/src/pages/tasks.service.ts +++ b/apps/api/src/pages/tasks.service.ts @@ -171,7 +171,8 @@ export class TasksService { } return ( `` + - `` + + `` + + `` + `` + `` + `${rows.join('')}
${escapeHtml(t('colTask'))}${escapeHtml(t('colMentions'))}${escapeHtml(t('colDone'))}${escapeHtml(t('colTask'))}${escapeHtml(t('colMentions'))}${escapeHtml(t('colStart'))}${escapeHtml(t('colDue'))}${escapeHtml(t('colPage'))}
` diff --git a/apps/api/src/public/html-shell.ts b/apps/api/src/public/html-shell.ts index 6a91319..84be3e7 100644 --- a/apps/api/src/public/html-shell.ts +++ b/apps/api/src/public/html-shell.ts @@ -45,6 +45,16 @@ export function htmlDocument({ pre { overflow-x: auto; } .public-footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #64748b; font-size: 0.9rem; } + .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; + clip-path: inset(50%); white-space: nowrap; } + /* color-scheme allows dark UA rendering, so give it AA-checked colors + (issue #167, WCAG 1.4.3): text 14.8:1, links 10.1:1, muted 8.5:1. */ + @media (prefers-color-scheme: dark) { + body { background: #10161d; color: #e2e8f0; } + a { color: #93c5fd; } + .public-page__pond, .public-footer { color: #a7b3c0; } + .public-footer { border-top-color: #a7b3c0; } + } diff --git a/apps/web/e2e/a11y.spec.ts b/apps/web/e2e/a11y.spec.ts new file mode 100644 index 0000000..de33d80 --- /dev/null +++ b/apps/web/e2e/a11y.spec.ts @@ -0,0 +1,62 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test, type Page } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +/** + * A11y-Smoke-Pack (issue #171): axe-core-Scan der Kern-Oberflächen gegen + * WCAG 2.1 A/AA. Regressionsschutz für das Audit vom 21.07.2026 (Bericht im + * Workspace, Befunde A11Y-001…024) — die Screens hier waren nach den Fixes + * der Issues #162–#170 verletzungsfrei; jede neue Verletzung bricht den + * Build. Best-Practice-Regeln (axe-Tag best-practice) prüfen wir hier + * bewusst NICHT, nur normative WCAG-Kriterien. + */ + +const BASE = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; +const TAGS = ['wcag2a', 'wcag21a', 'wcag2aa', 'wcag21aa']; + +/** Bewusst tolerierte Regel-IDs — nur mit Begründung ergänzen. */ +const ALLOWED_RULES: string[] = []; + +async function expectClean(page: Page, label: string): Promise { + const results = await new AxeBuilder({ page }).withTags(TAGS).analyze(); + const violations = results.violations.filter((v) => !ALLOWED_RULES.includes(v.id)); + expect( + violations.map((v) => ({ + rule: v.id, + impact: v.impact, + help: v.help, + nodes: v.nodes.slice(0, 5).map((n) => n.target), + })), + `axe-Verletzungen auf ${label}`, + ).toEqual([]); +} + +test('login page passes the axe WCAG A/AA scan', async ({ page }) => { + await page.goto('/login'); + await page.waitForLoadState('networkidle'); + await expectClean(page, '/login'); +}); + +test('reading and editing a page passes the axe WCAG A/AA scan', async ({ browser }) => { + const context = await contextForUser(browser, BASE, 'fixture-user'); + const page = await context.newPage(); + await page.goto('/p/content-fixtures/every-element'); + await page.waitForLoadState('networkidle'); + await expectClean(page, 'Lesemodus every-element'); + + await page.locator('.editor-page__mode-toggle').click(); + await page.locator('.ProseMirror[contenteditable="true"]').waitFor({ timeout: 10_000 }); + await page.waitForTimeout(500); + await expectClean(page, 'Editor every-element'); + await context.close(); +}); + +test('user settings pass the axe WCAG A/AA scan', async ({ browser }) => { + const context = await contextForUser(browser, BASE, 'fixture-user'); + const page = await context.newPage(); + await page.goto('/settings'); + await page.waitForLoadState('networkidle'); + await expectClean(page, '/settings'); + await context.close(); +}); diff --git a/apps/web/e2e/content.spec.ts b/apps/web/e2e/content.spec.ts index 993712f..b2dfd93 100644 --- a/apps/web/e2e/content.spec.ts +++ b/apps/web/e2e/content.spec.ts @@ -62,7 +62,10 @@ test('page lifecycle: create via the sidebar, rename, appears in the sidebar', a const page = await context.newPage(); await page.goto(`/p/${pond.slug}`); await page.getByRole('button', { name: /new page|neue seite/i }).click(); - await page.getByLabel(/title|titel/i).fill(title); + await page + .locator('.sidebar') + .getByLabel(/title|titel/i) + .fill(title); await page.getByRole('button', { name: /create|erstellen/i }).click(); await expect(page.locator('.sidebar__page--active')).toHaveText(title); diff --git a/apps/web/e2e/settings-nav.spec.ts b/apps/web/e2e/settings-nav.spec.ts index 87accf8..aebfdaf 100644 --- a/apps/web/e2e/settings-nav.spec.ts +++ b/apps/web/e2e/settings-nav.spec.ts @@ -28,7 +28,8 @@ test('user settings show the jump nav and clicking scrolls + activates', async ( await expect(nav).toBeVisible(); const links = nav.locator('.settings-nav__link'); // Profile, password, sessions, watches, API tokens, feed tokens, data export. - await expect(links).toHaveCount(7); + // 8 seit #170 (neue Bedienungs-Sektion). + await expect(links).toHaveCount(8); // Jump to the last section: it scrolls into view and becomes active. const last = links.last(); diff --git a/apps/web/e2e/sidebar.spec.ts b/apps/web/e2e/sidebar.spec.ts index f101a5e..9cae1a2 100644 --- a/apps/web/e2e/sidebar.spec.ts +++ b/apps/web/e2e/sidebar.spec.ts @@ -110,7 +110,10 @@ test('new-page flow: button opens a title prompt and the editor opens on create' await page.goto(`/p/${pond.slug}`); await page.getByRole('button', { name: /new page|neue seite/i }).click(); - await page.getByLabel(/title|titel/i).fill(title); + await page + .locator('.sidebar') + .getByLabel(/title|titel/i) + .fill(title); await page.getByRole('button', { name: /create|erstellen/i }).click(); await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/.+`)); diff --git a/apps/web/package.json b/apps/web/package.json index e9854eb..86a3143 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -42,6 +42,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", "@playwright/test": "^1.61.1", "@types/d3-force": "^3.0.10", "@types/react": "^19.0.0", diff --git a/apps/web/src/api-tokens/ApiTokensSection.tsx b/apps/web/src/api-tokens/ApiTokensSection.tsx index d7895bf..22d0a9d 100644 --- a/apps/web/src/api-tokens/ApiTokensSection.tsx +++ b/apps/web/src/api-tokens/ApiTokensSection.tsx @@ -182,7 +182,9 @@ function TokenList({ tokens }: { tokens: ApiTokenView[] }): React.JSX.Element { {t('list.lastUsed')} {t('list.expires')} {t('list.status')} - + + {t('common:tableActions')} + diff --git a/apps/web/src/api-tokens/FeedTokensSection.tsx b/apps/web/src/api-tokens/FeedTokensSection.tsx index 30256d5..1b2e40d 100644 --- a/apps/web/src/api-tokens/FeedTokensSection.tsx +++ b/apps/web/src/api-tokens/FeedTokensSection.tsx @@ -91,7 +91,9 @@ export function FeedTokensSection(): React.JSX.Element { {t('fields.name')} {t('list.created')} {t('list.lastUsed')} - + + {t('common:tableActions')} + diff --git a/apps/web/src/components/Toast.tsx b/apps/web/src/components/Toast.tsx index 64d5983..c24e1eb 100644 --- a/apps/web/src/components/Toast.tsx +++ b/apps/web/src/components/Toast.tsx @@ -18,7 +18,9 @@ interface ToastItem { variant: ToastVariant; } -const TOAST_DURATION_MS = 2500; +// 6 s statt 2,5 s (issue #170, WCAG 2.2.1): kurzlebige Statusmeldungen +// waren für Screenreader-/Vergrößerungs-Nutzer kaum erfassbar. +const TOAST_DURATION_MS = 6000; const ToastContext = createContext(() => {}); diff --git a/apps/web/src/components/forms.tsx b/apps/web/src/components/forms.tsx index ea9626c..c16a986 100644 --- a/apps/web/src/components/forms.tsx +++ b/apps/web/src/components/forms.tsx @@ -1,3 +1,4 @@ +import { cloneElement, isValidElement, useId } from 'react'; import { useTranslation } from 'react-i18next'; import { ApiError } from '../lib/api'; @@ -20,13 +21,28 @@ export function Field({ children: React.ReactNode; }): React.JSX.Element { const { t } = useTranslation(); + const noteId = useId(); + // Tie the hint/error text to the control itself (#168, WCAG 3.3.1): + // screen readers then repeat it when the field receives focus. Only a + // single element child can be wired; fragments render unchanged. + const wired = + isValidElement(children) && (error || hint) + ? cloneElement(children as React.ReactElement>, { + 'aria-describedby': noteId, + ...(error ? { 'aria-invalid': true } : {}), + }) + : children; return (