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, nieoutline:noneohne 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.
- Seiten —
lang, sinnvoller Title, genau ein<main>, Skip-Link, logische Fokusreihenfolge, Reflow/Zoom bis 200 %,prefers-reduced-motion.
Festgelegte Muster (Projekt)
- Aktion noch nicht möglich →
aria-disabled="true"+ Begründung peraria-describedby, nichtdisabled. Das Atomatoms/button.twigkennt dafür das Proparia_disabled; das Absenden hältinitInertSubmits()auf (einaria-disabledist 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 denaria-describedbyzeigt. 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 einerole="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 ininitOptionsDropdown(); 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 serverseitigopengerendert, 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; keinaria-expandedvon 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-invalidbei „vergeben"/„ungültig", und die Meldung hängt peraria-describedbyam Eingabefeld. Einerole="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 keinaria-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.
opacitymischt 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. typeam Eingabefeld nie strenger als die serverseitige Prüfung. Eintype="url"über einem Feld, dessen Hilfetext ein fehlendeshttps://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 peraria-describedbyam Feld hängt. Für die Tastatur auf Mobilgeräten istinputmodezuständig, nichttype.- 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, Propopen) 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 machtinitOpenDialogs()daraus einen echten modalen Dialog. - Meldungs-Titel tragen
heading_level(atoms/notice.twig, wiemolecules/card.twig). Der Titel einer Meldung ist eine Überschrift im Dokument: direkt unter der Seiten-H1 gehörth2hin, in einer Card mith3-Titelh4. Der Vorgabewerth3bleibt, 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.
- ARIA-Attribut als Twig-Ausdruck.
{{ active ? 'aria-current="page"' : '' }}wird mitautoescape => 'html'(config/twigbridge.php) zuaria-current="page"; 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. - 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 }}(siehepages/app/users/index.twig). - Live-Region mit
hiddenbeim Laden — siehe oben. - 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-tablegegen--e-table,--e-on-navgegen--e-nav,--e-on-menugegen--e-menu,--e-on-buttongegen--e-button— nicht alles gegen den Seitengrund. Die Paar-Benennung (X/on-X) ist genau dafür da. - Hover-Paare mitprüfen.
--e-button-hoverund--e-on-button-hoversind 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)
-
Barrierefreiheitserklärung — Konformitätsstatus, bekannte nicht-barrierefreie Inhalte + Begründung, Datum der Bewertung, Feedback-Weg. (Noch anzulegen; Inhalte sind Betreibersache — keine Konformität erfinden.)
-
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 — einmailto:-Link reicht, ein Formular ist nicht vorgeschrieben. Die Bereinigung des Editorinhalts lässtmailto:durch;tests/Feature/EditorialContactPageTest.phpsichert 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— Passwortabfrageunavailable.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-smooth → motion-safe:scroll-smooth in beiden Layouts.
Offen und bewusst nicht geändert:
atoms/notice.twigsetzt beitone: 'danger'immerrole="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 (truncateschneidet 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.