Skip to content

a11y-CI-Job (axe) für vier Kernseiten (#60) - #71

Merged
pseidler89-sudo merged 2 commits into
mainfrom
feat/60-a11y-ci
Jul 26, 2026
Merged

a11y-CI-Job (axe) für vier Kernseiten (#60)#71
pseidler89-sudo merged 2 commits into
mainfrom
feat/60-a11y-ci

Conversation

@pseidler89-sudo

@pseidler89-sudo pseidler89-sudo commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Schließt #60.

Was

Ein eigenständiger CI-Job a11y prüft das gerenderte DOM von vier anonym
erreichbaren Seiten mit pa11y-ci + axe-Runner (WCAG2AA):

Seite Typ Status unauth.
/ Einstieg Pilot-Tenant 200
/umfragen Liste, 4 geseedete Umfragen 200
/anliegen Liste, 3 geseedete Anliegen 200
/anmelden Magic-Link-Formular 200

Das ergänzt das bestehende harte statische jsx-a11y-Gate (npm run lint),
es ersetzt es nicht: Kontraste, Landmarks, Formular-Labels und
Heading-Reihenfolge entstehen erst beim Rendern und sind für statisches Lint
unsichtbar.

Nachgearbeitet nach Gate-B (wichtigster Teil)

Die erste Fassung dieses PRs meldete 0 Befunde, während 6 belegte
axe-Verstöße bestanden
. Zwei Ursachen, beide in pa11y selbst:

  1. levelCapWhenNeedsReview: "warning" wirkt pauschal. choosePa11yLevel
    (pa11y/lib/runners/axe.js) prüft nur das Flag issueNeedsReview, nie die
    Regel-ID — der Deckel trifft die gesamte axe-incomplete-Klasse.
    axe schreibt duplicate-id-aria wegen reviewOnFail: true ebenfalls nach
    incomplete, obwohl der Verstoß belegt ist. Ergebnis: 2 critical-Verstöße
    wurden zu Warnungen, die niemand zählt.
  2. Die Impact-Klasse moderate blockte grundsätzlich nie. pa11y bildet
    moderate auf warning und minor auf notice ab
    (axeImpactToPa11yLevel) und zählt beides per Default nicht
    (pa11y/lib/option.js schiebt warning/notice sonst in die Ignore-Liste).
    heading-order und form-field-multiple-labels konnten so nie rot werden.

Fix: zwei pa11y-ci-Läufe

Ein einzelner Lauf kann die unentscheidbaren Kontrastfälle des Verlaufs-Heros
nicht dämpfen, ohne gleichzeitig belegte Verstöße stumm zu schalten. Deshalb:

Lauf 1 (app/.pa11yci.js) Lauf 2 (app/.pa11yci.streng.js)
levelCapWhenNeedsReview "warning" nicht gesetzt (Default error)
includeWarnings / includeNotices aus an
color-contrast auf allen Seiten aktiv nur auf / aus
fängt belegte violations mit Impact critical/serious, insbesondere echte Kontrastverstöße incomplete jeder Impact-Klasse + moderate/minor-Verstöße

npm run a11y = Preflight && Lauf 1 && Lauf 2; jeder Lauf bricht die Kette
bei Exit ≠ 0.

Keine Lücke durch die Kontrast-Ausnahme in Lauf 2: Lauf 1 lässt
color-contrast überall aktiv, und ein belegter Kontrast-Verstoß hat Impact
serious → Stufe error → blockt dort weiterhin. Nachgestellt mit einem
Dokument, das einen echten Verstoß enthält (#cccccc auf Weiß, 1.6:1), gegen
exakt die Defaults von Lauf 1:

Meldungen, die LAUF 1 zaehlen wuerde: 1
 - error color-contrast | impact serious | needsReview false

Der Fallstrick, der hier abgesichert ist

Die Tenant-Auflösung ist host-basiert (app/src/middleware.ts). localhost
steht in MAIN_HOSTNAMES — ein Server ohne PILOT_TENANT_SLUG liefert dort die
neutrale Landing-Page, nicht die Tenant-App. Ein a11y-Job dagegen wäre grün
und wertlos.

app/scripts/a11y-preflight.ts läuft vor den pa11y-Läufen und bricht hart
ab, wenn eine der vier Bedingungen fehlt:

  1. Readiness per HTTP-Poll mit Timeout — kein sleep als Synchronisation.
  2. Status 200 für alle vier Seiten (kein 3xx/404).
  3. Tenant-Beweis: /umfragen muss den Wortlaut mindestens einer geseedeten
    Umfrage aus db/seeds/polls.json enthalten.
  4. Styling-Beweis (neu): jede Seite führt mindestens ein
    <link rel="stylesheet">, und jedes davon liefert mit 200 und nicht-leerem
    Rumpf aus. Punkt 2 und 3 beweisen nur Inhalt; viele axe-Regeln sind rein
    visuell. Mit blockiertem Stylesheet verschwindet z. B. die bekannte
    link-in-text-block-Violation spurlos — der Lauf wäre wieder grün und
    wertlos.

Gegenprobe zu Punkt 4 (statische Assets weggenommen, Server läuft weiter):

a11y-preflight FEHLGESCHLAGEN: Stylesheets nicht ausgeliefert:
  - /: http://127.0.0.1:3000/_next/static/chunks/3cfq2jle_a3yi.css → 500, 21 Bytes
  - /umfragen: … → 500, 21 Bytes
  - /anliegen: … → 500, 21 Bytes
  - /anmelden: … → 500, 21 Bytes
PREFLIGHT-EXIT=1

Fail-Modus: hart, Bestand regel- und seitengenau eingefroren

Aktuelle Bestandsbefunde auf den vier Seiten (vollständige Inventur über beide
Läufe, ohne jede Ausnahme):

Regel Seite Anzahl axe-Klasse Impact
duplicate-id-aria / 2 incomplete critical
form-field-multiple-labels / 2 incomplete moderate
heading-order /umfragen 1 violation moderate
link-in-text-block /anliegen 1 violation serious
color-contrast / 21 incomplete serious

Die ersten vier Zeilen sind echte UI-Fehler. Sie sind hier regel- und
seitengenau eingefroren — jede Ausnahme steht mit Regel-ID, Seite, Element,
Grund und Abbau-Hinweis in der Config und verschwindet im UI-Folge-PR
ersatzlos. Dieser PR ändert bewusst keine Komponente. color-contrast auf
/ ist keine Altlast, sondern technisch bedingt: .pz-hero hat einen Verlauf
als Hintergrund, dort kann axe den Kontrast prinzipiell nicht berechnen.

Beleg, dass das Gate beißt — dieselbe strenge Config, nur ohne die vier
eingefrorenen Altlasten (Kontrast-Ausnahme bleibt):

Running Pa11y on 4 URLs:
 > http://127.0.0.1:3000/ - 4 errors
 > http://127.0.0.1:3000/umfragen - 1 errors
 > http://127.0.0.1:3000/anliegen - 1 errors
 > http://127.0.0.1:3000/anmelden - 0 errors
✘ 1/4 URLs passed
EXIT=2

mit den Elementen im Klartext: #plz und #plz-funktion (je 2×
duplicate-id-aria bzw. form-field-multiple-labels),
#main-content > main > div > div:nth-child(1) > h3 (heading-order),
#main-content > main > p > a (link-in-text-block).

Mit den Ausnahmen: ✔ 4/4 URLs passed in beiden Läufen, EXIT=0.

Reichweite dieses Gates — ehrlich

  • Geprüft werden vier anonyme Sichten. Die eigentlichen Beteiligungsflüsse
    (/umfrage/[id], /mitmachen, /konto, /verifizieren, Admin) sind nicht
    abgedeckt. Der Job trägt also eine Aussage über diese vier Seiten, keine über
    die Anwendung als Ganzes. Die frühere Formulierung „belastbare
    BITV-Argumentation gegenüber Kommunen" in ci.yml ist entsprechend entschärft.
  • Nur statisches DOM nach dem Laden: keine Tastaturbedienung, keine
    Fokusführung, nichts, was erst nach einer Interaktion entsteht.
  • Nur was axe automatisiert entscheiden kann — Verständlichkeit und
    sinnvolle Alternativtexte bleiben Handarbeit.
  • Auf / ist color-contrast in Lauf 2 abgeschaltet; belegte Kontrastverstöße
    fängt dort Lauf 1 (oben belegt), unentscheidbare Fälle bleiben unentschieden.

Weitere Nacharbeiten

  • .pa11yci.json.pa11yci.js. Basis-URL (A11Y_BASE_URL) und
    Seitenliste lagen doppelt vor (Config + Preflight); driftet eines, prüft der
    Preflight einen anderen Server oder andere Seiten als pa11y, ohne dass es
    auffällt. Jetzt eine Quelle, die beide Configs und der Preflight teilen
    (pa11y-ci lädt .cjs/.js über loadConfigModule). .pa11yci.streng.js
    wirft zusätzlich, wenn eine Ausnahme auf eine Seite zeigt, die gar nicht mehr
    geprüft wird.
  • CI startet npm run start:standalone statt npm run start.
    next.config.ts setzt output: "standalone", Next 16 unterstützt
    next start damit ausdrücklich nicht, und Produktion fährt
    .next/standalone/server.js (Dockerfile, Stage runner). Das neue Skript
    kopiert .next/static und public daneben — genau wie das Dockerfile.
  • concurrency 2 → 1. Zusammen mit dem Inkognito-Browser-Kontext je Ziel
    gab es sporadische Protocol error (Target.closeTarget)-Abbrüche. Die
    Richtung wäre sicher (rot statt grün), aber ein flackerndes Pflicht-Gate wird
    weggeklickt; bei vier URLs bringt Parallelität ohnehin nichts.
  • "ignore": [] ist kein Opt-out aus globalen Ignores — pa11y-ci merged
    URL-Optionen über lodash defaultsDeep, und das merged Arrays indexweise.
    Heute folgenlos (defaults setzt kein ignore), in beiden Configs als
    Kommentar festgehalten.

Lokal verifiziert

Postgres-Container, db:migrate + db:seed, npm run build, Server per
setsid mit PILOT_TENANT_SLUG=taunusstein über npm run start:standalone,
npm run a11y, Cleanup über Prozessgruppe — die CI-Schritte 1:1 nachgestellt:

a11y-preflight bestanden — pa11y prüft echte, gestylte Tenant-Seiten.
Running Pa11y on 4 URLs:  ✔ 4/4 URLs passed     (Lauf 1)
Running Pa11y on 4 URLs:  ✔ 4/4 URLs passed     (Lauf 2, streng)
EXIT=0

npm run lint 0, npm run typecheck 0, npx vitest run:
Test Files 95 passed (95) · Tests 1168 passed (1168).

Nicht Teil dieses PRs

Keine UI-Änderung. Die vier echten Verstöße sind eingefroren und dokumentiert,
nicht gefixt — der Folge-PR macht die doppelten IDs auf / eindeutig (nimmt
form-field-multiple-labels mit), korrigiert die Überschriftenebene auf
/umfragen und den nur farblich markierten Link auf /anliegen, und entfernt
dabei alle vier Ausnahmen ersatzlos. ci und upgrade-path sind unverändert.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BervJUSWK8ymMbcuBuu9d9

pseidler89-sudo and others added 2 commits July 26, 2026 11:30
Prüft das gerenderte DOM von /, /umfragen, /anliegen und /anmelden mit
pa11y-ci + axe-Runner (WCAG2AA). Ergänzt das statische jsx-a11y-Lint-Gate um
genau die Verstoßklasse, die dieses prinzipbedingt nicht sehen kann:
Kontraste, Landmarks, Formular-Labels, Heading-Reihenfolge.

Zentral abgesichert: die Tenant-Auflösung ist host-basiert, localhost ist eine
HAUPT-Domain und liefert ohne PILOT_TENANT_SLUG die neutrale Landing-Page —
ein a11y-Lauf dagegen wäre grün und wertlos. scripts/a11y-preflight.ts erzwingt
vor jedem pa11y-Lauf: HTTP-Readiness-Poll (kein sleep), Status 200 für alle
vier Seiten und eine harte Assertion, dass /umfragen den Wortlaut einer
geseedeten Umfrage aus db/seeds/polls.json enthält.

axe-'incomplete'-Befunde werden auf warning gedeckelt
(levelCapWhenNeedsReview): .pz-hero hat einen Verlaufshintergrund, bei dem axe
den Kontrast grundsätzlich nicht berechnen kann. Ein hartes Gate darauf wäre
nur durch seitenweites Abschalten von color-contrast zu beruhigen — genau der
Regel, die auf der Startseite am meisten wert ist. Belegte Verstöße blocken
weiterhin hart.

Eingefrorene Altlast, regel- UND seitengenau: link-in-text-block auf
/anliegen. Keine UI-Änderung in diesem PR.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BervJUSWK8ymMbcuBuu9d9
…eit #60)

Das Gate meldete 0 Befunde, waehrend 6 belegte axe-Verstoesse bestanden.
Zwei Ursachen, beide in pa11y selbst:

1. `levelCapWhenNeedsReview: "warning"` deckelt die GESAMTE axe-incomplete-
   Klasse, nie regelbezogen (pa11y/lib/runners/axe.js, `choosePa11yLevel`
   prueft nur das Flag `issueNeedsReview`). axe schreibt `duplicate-id-aria`
   wegen `reviewOnFail: true` nach `incomplete` — der Deckel machte aus 2
   belegten critical-Verstoessen unsichtbare Warnungen.
2. pa11y bildet die Impact-Klasse `moderate` pauschal auf `warning` ab und
   zaehlt Warnungen per Default nicht. `heading-order` (/umfragen) und
   `form-field-multiple-labels` (/) konnten deshalb nie blocken.

Fix: zwei pa11y-ci-Laeufe.
  LAUF 1 (.pa11yci.js)        — Deckel an, Kontrastregel auf allen Seiten
                                aktiv; blockt belegte critical/serious-
                                Verstoesse inkl. echter color-contrast-Fehler.
  LAUF 2 (.pa11yci.streng.js) — kein Deckel, includeWarnings + includeNotices;
                                blockt incomplete jeder Impact-Klasse sowie
                                moderate/minor-Verstoesse. `color-contrast` ist
                                dort nur auf `/` aus (Verlaufs-Hero, technisch
                                unentscheidbar) — keine Luecke, LAUF 1 deckt es.

Die dadurch sichtbar gewordenen 3 Bestandsbefunde (duplicate-id-aria 2x und
form-field-multiple-labels 2x auf /, heading-order 1x auf /umfragen) sind
regel- UND seitengenau eingefroren, jeweils mit Regel-ID, Seite, Element,
Grund und Abbau-Hinweis. Sie verschwinden im UI-Folge-PR ersatzlos; dieser PR
aendert bewusst keine Komponente.

Weiter:
- .pa11yci.json → .pa11yci.js: Basis-URL (A11Y_BASE_URL) und Seitenliste lagen
  doppelt vor (Config + Preflight). Jetzt eine Quelle, die beide Configs und
  scripts/a11y-preflight.ts teilen — sonst prueft der Preflight einen anderen
  Server als pa11y, ohne dass es auffaellt.
- Preflight prueft zusaetzlich, dass die Stylesheets wirklich ausgeliefert
  werden. Ohne CSS verschwinden visuelle Befunde (link-in-text-block) spurlos
  und der Lauf waere wieder gruen und wertlos.
- CI startet `npm run start:standalone` statt `npm run start`: next.config.ts
  setzt `output: "standalone"`, Next 16 unterstuetzt `next start` damit nicht,
  und Produktion faehrt .next/standalone/server.js (Dockerfile, Stage runner).
- concurrency 2 → 1: zusammen mit dem Inkognito-Kontext je Ziel gab es
  sporadische `Protocol error (Target.closeTarget)`-Abbrueche. Bei vier URLs
  bringt Parallelitaet nichts, ein flackerndes Pflicht-Gate schon.
- ci.yml benennt die Reichweite ehrlich: vier anonyme Sichten, keine Aussage
  ueber die Beteiligungsfluesse (/umfrage/[id], /mitmachen, /konto,
  /verifizieren, Admin) und nichts Interaktives.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BervJUSWK8ymMbcuBuu9d9
@pseidler89-sudo
pseidler89-sudo merged commit 707025e into main Jul 26, 2026
6 checks passed
@pseidler89-sudo
pseidler89-sudo deleted the feat/60-a11y-ci branch July 26, 2026 12:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant