ACCESSIBILITY.md — BFSG / WCAG 2.1 AA

WCAG 2.1 Level AA ist die unbedingte Grundlinie dieses Projekts (BFSG). Kein Rechtsrat — ob und wie das Gesetz greift, klärt der Betreiber ggf. juristisch.

Die eine Regel

Keine Komponente ist „fertig", bevor sie die WCAG-2.1-AA-Checks ihrer Atomic-Ebene besteht. Barrierefreiheit ist Teil der Definition of Done — zusammen mit Styling und Struktur, nie ein späterer Durchgang.

Checkliste je Atomic-Ebene

  • Atome — zugänglicher Name, Label-Zuordnung, sichtbarer Fokus (focus-visible + Fokus-Token, nie outline:none ohne Ersatz), Kontrast (4.5:1 Text / 3:1 große Schrift & nicht-textliche UI), Zielgröße, alt.
  • Moleküle — Gruppierung (fieldset/legend), Fehlerkopplung (aria-describedby), required/aria-invalid-Zustände.
  • Organismen — Landmark-Rollen, korrekte Überschriften-Reihenfolge, Tastaturbedienbarkeit von Menüs/Dialogen, Fokus-Management.
  • Seitenlang, sinnvoller Title, genau ein <main>, Skip-Link, logische Fokusreihenfolge, Reflow/Zoom bis 200 %, prefers-reduced-motion.

Festgelegte Muster (Projekt)

  • Aktion noch nicht möglicharia-disabled="true" + Begründung per aria-describedby, nicht disabled. Das Atom atoms/button.twig kennt dafür das Prop aria_disabled; das Absenden hält initInertSubmits() auf (ein aria-disabled ist eine Auskunft an Hilfsmittel und stoppt von sich aus nichts). Beispiel: der Knopf „Shortlink anlegen" bei unbestätigter E-Mail-Adresse — er bleibt stehen, der Grund steht als Meldung darüber UND als kurzer Absatz, auf den aria-describedby zeigt. Zwei Texte, weil ein Screenreader beim Fokus auf den Knopf die gesamte Beschreibung vorliest: Die Meldung erklärt, der Absatz benennt. Der Button bleibt sichtbar, fokussierbar und erklärt sich (z. B. „Theme speichern", solange keine Datei gewählt ist). Der Klick landet im JS und gibt dieselbe Begründung als Text aus. Kontrast: inaktive Bedienelemente sind von 1.4.3 ausgenommen.
  • Drag & Drop ist nie der einzige Weg — betrifft die Ablageflächen für Logo und Theme in den Einstellungen: Es gibt immer einen echten Auslöser für die Dateiauswahl (Tastatur/Screenreader) und ohne JS ein natives input[type=file]. Das Ziehen-Overlay ist rein visuell (aria-hidden); Ergebnisse werden über eine role="status"-Live-Region angesagt.
  • Ein Overlay muss auch den Fokus loslassen. Die Optionen-Klappe der Anlegen-Maske legt sich aufgeklappt über die Seite und verdeckt dabei Ziel-Feld und Knopf. Sie schließt deshalb nicht nur über ihr <summary>, sondern auch per Esc, per Klick daneben und — der Fall, der leicht fehlt — sobald der Fokus sie verlässt: Wer aus dem letzten Feld heraustabbt, landete sonst auf dem Ziel-Feld, das gerade unter dem Overlay liegt. Der Browser scrollt es ins Bild, sichtbar ist es nicht (2.4.11). Bei Esc geht der Fokus zurück auf das <summary> und nicht ins Nichts (2.4.3). Alles in initOptionsDropdown(); ohne JavaScript bleibt die Klappe über ihr <summary> bedienbar, und ihre Felder werden auch zugeklappt mitgesendet. Fehlermeldungen heben das Overlay auf: Liefert der Server die Klappe wegen Fehlern offen aus, steht sie im Fluss statt darüber — ein Overlay, das beim Laden offen ist, verdeckt Felder, ohne dass jemand es geöffnet hat (3.3.1 gegen 2.4.11 abgewogen).
  • Ausklappbares ohne Zwang zu JavaScript<details>/<summary> (z. B. die Wiederherstellungscodes bei der Zwei-Faktor-Abfrage, die Diagnose in den Systemdaten). Enthält der Bereich Validierungsfehler, wird er serverseitig open gerendert, sonst wären die Meldungen unsichtbar (3.3.1).
  • Das Akkordeon der Shortlink-Übersicht (molecules/accordion-section.twig, ein Abschnitt je Bereich) ist derselbe Bau — und zwar aus einem Grund, der über Bequemlichkeit hinausgeht: Ohne JavaScript wäre die Liste sonst zu. Ein Akkordeon aus <button aria-expanded> plus JS liefe bei abgeschaltetem oder fehlgeschlagenem Skript auf eine Übersicht hinaus, in der die eigenen Links nicht mehr vorkommen. Der Ausfall wäre nicht „unschön", sondern total. Drei Punkte am Bau selbst: Die Überschrift steht im <summary> (<h2>), damit der Abschnitt in der Überschriften-Navigation auftaucht (2.4.1) — mit zwanzig Bereichen ist eine Liste sonst nur linear durchlaufbar; kein aria-expanded von Hand, weil <summary> den Zustand bereits trägt und ein zweites Attribut beim nächsten Umbau stehenbliebe und das Gegenteil behauptete; und die Anzahl rechts steht als Text („12 Links“), nicht als nackte Zahl — vorgelesen zwischen Bereichsname und „zugeklappt“ bedeutet eine Zahl allein nichts. Alle Abschnitte sind aufgeklappt vorbelegt und der Zustand wird nicht gemerkt: Ein gemerkter Zustand macht aus einem versehentlichen Klick eine dauerhaft verschwundene Liste, und gesucht wird der Fehler dann bei den Links.
  • Live-Prüfung während der Eingabe (die eigene Kurz-Adresse in pages/app/partials/link-form.twig) sagt ihr Ergebnis nicht nur an, sondern hinterlässt einen Zustand am Feld: aria-invalid bei „vergeben"/„ungültig", und die Meldung hängt per aria-describedby am Eingabefeld. Eine role="status"-Region sagt einmal vor; wer danach ins Feld zurückspringt, hörte sonst nur das Label. Bei „wird geprüft" und „Prüfung nicht möglich" steht kein aria-invalid — eine gegriffene Drosselung ist kein Urteil über die Eingabe.
  • Die Live-Region ist beim Laden vorhanden und leer, nie hidden. Eine Region, die erst beim Befüllen sichtbar gemacht wird, überwachen nicht alle Hilfsmittel — die Ansage fällt dann still aus. Leer heißt: kein Inhalt und keine Abstandsklassen, sonst stünde eine leere Zeile im Formular.
  • Deckkraft nie auf einen Container, der Status-Farben enthält. opacity mischt jeden Vordergrund im Teilbaum mit dem Hintergrund — auch Abzeichen. Die -ink-Töne sind auf knapp über 4,5:1 ausgelegt; bei 60 % landen sie im Hellen bei 2,5–3,1:1 (Messung unten) und fallen durch. Ein Zustand, der ein Ausblassen „verdient", steht ohnehin schon als Text da — dann ist die Deckkraft reiner Verlust.
  • type am Eingabefeld nie strenger als die serverseitige Prüfung. Ein type="url" über einem Feld, dessen Hilfetext ein fehlendes https:// zu ergänzen verspricht, lässt die Eingabe gar nicht erst abschicken: Der Browser blockt mit seiner eigenen Sprechblase, die weder unserer Fehlerdarstellung folgt noch per aria-describedby am Feld hängt. Für die Tastatur auf Mobilgeräten ist inputmode zuständig, nicht type.
  • Ein QR-Code ist ein Bild mit Inhalt, kein Schmuck. Er trägt deshalb einen alt-Text mit der Adresse, die er enthält, und dieselbe Adresse steht zusätzlich als lesbarer Text daneben (1.1.1). Wer nicht scannen kann — kein Telefon zur Hand, keine Kamera, keine Sicht —, kommt so an denselben Inhalt. Aus demselben Grund ist er ein <img> mit eingebettetem SVG und kein Inline-<svg>: Inline erbte er Farben und Filter der Seite, und im dunklen Erscheinungsbild würde daraus hell auf hell.
  • Ein serverseitig geöffneter Dialog (molecules/modal.twig, Prop open) ist ohne JavaScript nicht modal — sichtbar, bedienbar, aber ohne Fokusfalle, und die Seite dahinter bleibt erreichbar. Das ist Absicht und die bessere Hälfte des Tauschs: Die Rückmeldung nach dem Anlegen eines Links hängt damit nicht an JavaScript. Geschlossen wird er über ein <form method="dialog"> — der einzige Weg, den ein Browser ohne Skript kennt. Mit JavaScript macht initOpenDialogs() daraus einen echten modalen Dialog.
  • Meldungs-Titel tragen heading_level (atoms/notice.twig, wie molecules/card.twig). Der Titel einer Meldung ist eine Überschrift im Dokument: direkt unter der Seiten-H1 gehört h2 hin, in einer Card mit h3-Titel h4. Der Vorgabewert h3 bleibt, damit bestehende Seiten sich nicht ändern.

Fallen, die in diesem Projekt schon zugeschlagen haben

Alle vier waren im Browser unsichtbar — die Seite sah funktionierend aus.

  1. ARIA-Attribut als Twig-Ausdruck. {{ active ? 'aria-current="page"' : '' }} wird mit autoescape => 'html' (config/twigbridge.php) zu aria-current=&quot;page&quot;; der Wert trägt dann die Anführungszeichen mit und ist kein gültiges Token mehr. Attribute gehören ins Markup ({% if %}), nie in eine Zeichenkette.
  2. Eine Dialog-id für viele Zeilen. initConfirmDialogs() bindet pro Formular einen Handler auf #<id>-accept. Bei einer geteilten id hängen an diesem einen Knopf so viele Handler, wie die Liste Zeilen hat — abgeschickt wird das zuerst gebundene Formular, nicht das angeklickte. In einer Aufsichtsliste heißt das: der falsche Link wird stillgelegt. Muster: dlg-…-{{ eintrag.id }} (siehe pages/app/users/index.twig).
  3. Live-Region mit hidden beim Laden — siehe oben.
  4. Skip-Link ohne tabindex="-1" am Ziel. Der Sprung bewegt in mehreren Browsern nur den Bildlauf, nicht den Fokus; die nächste Tab-Taste landet wieder in der Kopfnavigation. Die Seite scrollt, also sieht es richtig aus.

Token-Kopplung

Kontrast, Fokusring und Zoom/Reflow hängen an resources/css/tokens.css. Bei jeder Token-Änderung Kontrast erneut prüfen.

Die Element-Ebene (--e-…) vergrößert die Prüffläche. Vorher lagen Fließtext, Überschriften, Labels, Tabellenzellen und Menütexte auf EINEM Wert: Wer den auf AA brachte, hatte alle. Jetzt ist jede Rolle einzeln einstellbar — also auch einzeln verstellbar. Zwei Folgen für jede Prüfung:

  • Text gegen die Fläche prüfen, auf der er wirklich liegt. --e-on-table gegen --e-table, --e-on-nav gegen --e-nav, --e-on-menu gegen --e-menu, --e-on-button gegen --e-button — nicht alles gegen den Seitengrund. Die Paar-Benennung (X / on-X) ist genau dafür da.
  • Hover-Paare mitprüfen. --e-button-hover und --e-on-button-hover sind ein eigenes Paar; ein invertierender Hover kann AA erfüllen und nach einem Eingriff des Betreibers trotzdem daneben liegen.

Der Fokusring bleibt bewusst zentral (--e-focus-ring, angewandt in app.css auf alle interaktiven Elemente) und hat keine Element-Ebene. Wer mit der Tastatur bedient, darf nicht pro Seitenbereich neu lernen, wie „hier stehe ich gerade" aussieht (WCAG 2.4.7). Ebenso ohne Element-Ebene: die vier Statusfarben — sie tragen Bedeutung, und ihre Kontraste sind knapp gerechnet (Tabelle unten).

Redaktionelle Inhalte (prose) sind seit 2.2 angebunden. Das Typography-Plugin brachte eine eigene Graupalette mit, die den Dark Mode nicht kannte: Fließtext auf den Seiten aus der Datenbank stand im dunklen Modus mit rund 2,7:1 auf Schwarz und fiel damit unter 1.4.3 durch. app.css legt die --tw-prose-*-Variablen jetzt auf die Element-Tokens; Modifier wie prose-neutral oder dark:prose-invert gehören deshalb nicht mehr ins Markup — sie setzten genau diese Variablen zurück.

Statusfarben haben deshalb zwei Rollen: --color-<tone> als Füllung (mit text-on-<tone> darauf, ~5:1 in beiden Modi) und --color-<tone>-ink als Vordergrund auf dem Seitenhintergrund. Letzteres ist im Dark Mode aufgehellt, weil die Füllfarbe auf Schwarz nur ~3,9:1 erreicht — als Text also durchfällt und optisch untergeht. Gemessen (WCAG-Kontrast, Stand der aktuellen Tokens):

Ton hell auf Weiß dunkel auf Schwarz (…-ink) hell, bei opacity-60
danger 5,4:1 9,0:1 3,1:1 ✗
success 5,1:1 11,9:1 2,5:1 ✗
warning 5,0:1 12,5:1 2,5:1 ✗
info 5,0:1 10,8:1 2,5:1 ✗

Die letzte Spalte ist der Grund für die Deckkraft-Regel oben: Der Abstand zu 4,5:1 ist in beiden Modi so knapp bemessen, dass jede Transparenz darüber ihn aufbraucht.

Eine betonte Fläche unter einem Formularfeld ist --e-panel — nicht --e-accent. --e-accent kippt mit dem Modus (hell schwarz, dunkel weiß), Formularfelder kippen nicht (moduskonstant weiß). Ein Feld auf einer Accent-Fläche steht im Dark Mode weiß auf weiß und ist nur noch am Rahmen zu erkennen — 1.4.11 wäre über den Rahmen gerade noch erfüllt, aber niemand findet das Feld. --e-panel ist eine Schattierung der Karte und hebt das weiße Feld in beiden Modi ab (Fundstelle: die Ziel-Adresse in der Anlegen-Maske).

Die Deckkraft dieser Fläche ist gerechnet, nicht gewählt — auf ihr steht auch die Fehlermeldung des Feldes:

Text auf --e-panel hell (6 % Ink auf Weiß) dunkel (14 % Ink auf Schwarz)
Label/Hilfe (--e-label, --e-help) 18,4:1 15,6:1
Fehlermeldung (--c-danger-ink) 4,7:1 6,7:1

Mit der Deckkraft von --c-track (10 %) käme die Fehlermeldung im Hellen nur auf 4,3:1 und fiele durch 1.4.3 — das ist der ganze Grund, warum --c-panel ein eigener Rohwert ist und nicht --c-track mitbenutzt. Wer die Fläche dunkler stellt, muss diese Zeile neu rechnen.

BFSG-Pflichtartefakte (öffentlich, erreichbar)

  1. Barrierefreiheitserklärung — Konformitätsstatus, bekannte nicht-barrierefreie Inhalte + Begründung, Datum der Bewertung, Feedback-Weg. (Noch anzulegen; Inhalte sind Betreibersache — keine Konformität erfinden.)

  2. Feedback-/Kontaktweg — erreichbare Möglichkeit, Barrieren zu melden (Formular oder E-Mail), aus der Erklärung und möglichst seitenweit verlinkt.

    Umsetzung in Shortlink: Der Kontaktweg ist eine redaktionelle Seite, keine fest verdrahtete Route. Der Betreiber legt sie unter Einstellungen → Seiten an (Slug frei wählbar, üblich kontakt) und setzt dort eine erreichbare Adresse — ein mailto:-Link reicht, ein Formular ist nicht vorgeschrieben. Die Bereinigung des Editorinhalts lässt mailto: durch; tests/Feature/EditorialContactPageTest.php sichert das ab, damit eine spätere Änderung an der Bereinigung den Feedback-Weg nicht stillschweigend kappt.

    Die Seite muss in der Fußzeile sichtbar sein (Haken „im Footer zeigen") und aus der Barrierefreiheitserklärung heraus verlinkt werden.

Die öffentlichen Seiten sind der harte Teil

Anmeldung, Übersicht und Einstellungen sehen nur Konten des Betreibers. Die drei Seiten unter pages/public/ sehen beliebige Empfänger — Menschen, die den Link in einer Mail oder auf einem Plakat gefunden haben und von dieser Installation nichts wissen:

  • preview.twig — Vorschauseite (Entscheidung 6.6 nennt sie ausdrücklich BFSG-gebunden)
  • unlock.twig — Passwortabfrage
  • unavailable.twig — abgelaufen / Limit erreicht / stillgelegt (HTTP 410)

Für diese drei gilt zusätzlich: Sie müssen ohne JavaScript vollständig bedienbar sein. Alle drei sind es (Formular bzw. reiner Text); die Vorschauseite bewusst mit einem POST-Knopf statt eines Ankers, was daran nichts ändert.

Stand der Prüfung

Zuletzt durchgesehen: die sieben Ansichten und Formulare, die mit dem Shortlink-Umbau neu entstanden sind — pages/public/preview.twig, unlock.twig, unavailable.twig, pages/app/admin/links.twig, pages/app/partials/link-form.twig, link-facts.twig, pages/app/settings/links.twig — samt der Bausteine, die sie benutzen.

Behoben: die vier Fallen oben, die Deckkraft in zwei Tabellen, type="url" in der Anlegen-Maske, die Überschriften-Ebenen der Meldungen, Beschriftung/Wert auf der Vorschauseite als <dl>, der Dreh-Ring der Live-Prüfung bei „Bewegung reduzieren", und scroll-smoothmotion-safe:scroll-smooth in beiden Layouts.

Offen und bewusst nicht geändert:

  • atoms/notice.twig setzt bei tone: 'danger' immer role="alert", auch wenn die Meldung schon beim Laden dasteht (z. B. der Befund-Zähler in der Aufsichtsliste). Eine Meldung, die bei Seitenaufbau bereits vorhanden ist, in einer assertiven Region zu halten, ist übertrieben — ein WCAG-Verstoß ist es nicht. Die Komponente steckt auf vielen Seiten; die Änderung gehört in einen eigenen Durchgang mit Sichtprüfung, nicht als Nebenwirkung dieser Runde.
  • title-Attribute an abgeschnittenen Adressen in Tabellen. Für Hilfsmittel ist der vollständige Text ohnehin im DOM (truncate schneidet nur optisch); Tastatur- und Touch-Bedienung kommen an den Tooltip aber nicht heran. Der vollständige Wert steht jeweils eine Ebene tiefer (Drawer bzw. Detailseite), deshalb belassen.

Ehrlichkeitsgrenze

Maschinell/Markup-Review deckt nur den prüfbaren Teil ab. Vor Release verbleiben: Tastatur-Walkthrough, Screenreader-Durchgang, Zoom/Reflow, echte Fokusreihenfolge. Nie „ist AA-konform" behaupten — nur „besteht die hier angewandten Checks".

Insbesondere ist nichts aus dieser Runde an einem echten Screenreader gegengeprüft: Die Kopplung von Live-Region und Feld, die Ansage der Vorschauseite und die Fokusreihenfolge in der Aufsichtsliste sind aus dem Markup abgeleitet, nicht gehört.

Quelle dieser Seite: ACCESSIBILITY.md im Repository. Sie wird beim Bauen daraus erzeugt — Änderungen gehören in die Markdown-Datei, nicht in diese Seite.