Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 1 addition & 14 deletions app/.pa11yci.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,21 +109,8 @@ module.exports = {
ignore: [],
},
{
_kommentar_ignore: [
"ALTLAST, eingefroren am 2026-07-26 — abzubauen, nicht auszuweiten.",
"Regel: link-in-text-block · Seite: /anliegen · Element:",
'#main-content > main > p > a → <a href="/<tenant>/konto" ...>Konto unter',
"„Meine Anliegen“</a>. Der Link im Fließtext ist nur farblich (--pz-brand-strong)",
"vom umgebenden Text unterschieden; 'hover:underline' greift erst beim Hover.",
"Grund für die Ausnahme: dieser PR friert den Bestand ein und ändert",
"bewusst KEINE UI (Issue #60). Fix gehört in einen eigenen PR (Unterstreichung",
"im Ruhezustand oder ausreichender Nicht-Farb-Unterschied); danach diese",
"Ausnahme in BEIDEN Configs ersatzlos entfernen.",
"Die Ausnahme gilt regel- UND seitengenau: auf allen anderen URLs blockt",
"link-in-text-block weiterhin hart.",
],
url: url("/anliegen"),
ignore: ["link-in-text-block"],
ignore: [],
},
{
url: url("/anmelden"),
Expand Down
44 changes: 8 additions & 36 deletions app/.pa11yci.streng.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
* Beide Läufe zusammen decken auf den vier geprüften Seiten alle axe-Regeln des
* Standards WCAG2AA ab, in jeder Impact-Klasse, sowohl `violations` als auch
* `incomplete` — mit der einen dokumentierten Ausnahme `color-contrast`/`/` aus
* Punkt 3 und den unten eingefrorenen Altlasten. Was das Gate NICHT sieht:
* Punkt 3. Sonst gibt es keine Ausnahme mehr. Was das Gate NICHT sieht:
* andere Seiten (siehe .pa11yci.js: nur vier anonyme Sichten), alles was
* Interaktion braucht (Fokus-Reihenfolge, Tastaturbedienung, Live-Regionen nach
* einer Aktion), und alles, was automatisierte Prüfung grundsätzlich nicht
Expand All @@ -58,44 +58,16 @@ const AUSNAHMEN = {
* Abbau: nur mit einem einfarbigen Hero-Hintergrund. Kein Ziel für sich —
* echte Kontrast-VERSTÖSSE blockt LAUF 1 auf dieser Seite weiterhin.
*
* `duplicate-id-aria` — ALTLAST, eingefroren am 2026-07-26.
* Regel: duplicate-id-aria · Seite: / · Elemente: id="plz" und
* id="plz-funktion" existieren auf der Startseite je zweimal
* (Hero-Formular und zweites Formular weiter unten).
* Grund: 2 belegte Verstöße (Impact critical). Dieser PR baut das GATE und
* ändert bewusst keine UI-Datei; der Fix ist ein eigener PR.
* Abbau: IDs eindeutig machen (z. B. Suffix je Formular-Instanz) und diese
* Ausnahme dann ERSATZLOS entfernen — der Folge-PR nimmt sie mit raus.
*
* `form-field-multiple-labels` — ALTLAST, eingefroren am 2026-07-26.
* Regel: form-field-multiple-labels · Seite: / · Elemente: dieselben zwei
* PLZ-Felder, die durch die doppelten IDs je zwei <label> einsammeln.
* Grund: Impact moderate, Folgefehler der doppelten IDs. Gleiche Begründung
* wie oben: keine UI-Änderung in diesem PR.
* Abbau: verschwindet mit dem duplicate-id-aria-Fix von selbst; Ausnahme im
* selben Folge-PR ersatzlos entfernen.
* Die vier am 2026-07-26 eingefrorenen Altlasten (`duplicate-id-aria` und
* `form-field-multiple-labels` auf `/`, `heading-order` auf `/umfragen`,
* `link-in-text-block` auf `/anliegen`) sind behoben und ihre Ausnahmen
* ersatzlos entfernt — die Regeln blocken jetzt auf allen Seiten hart.
*/
"/": ["color-contrast", "duplicate-id-aria", "form-field-multiple-labels"],
"/": ["color-contrast"],

/**
* `heading-order` — ALTLAST, eingefroren am 2026-07-26.
* Regel: heading-order · Seite: /umfragen · Element: eine Überschrift
* springt eine Ebene (h2 → h4) statt lückenlos zu staffeln.
* Grund: 1 belegter Verstoß (Impact moderate). Erst durch `includeWarnings`
* in diesem Lauf überhaupt sichtbar geworden. Keine UI-Änderung in
* diesem PR.
* Abbau: Überschriftenebene korrigieren, danach diese Ausnahme ersatzlos
* entfernen.
*/
"/umfragen": ["heading-order"],
"/umfragen": [],

/**
* `link-in-text-block` — ALTLAST, eingefroren am 2026-07-26.
* Identisch zur Ausnahme in .pa11yci.js (dort steht die volle Begründung).
* Muss in BEIDEN Configs stehen, weil beide Läufe die Seite prüfen, und im
* selben Folge-PR aus beiden verschwinden.
*/
"/anliegen": ["link-in-text-block"],
"/anliegen": [],

"/anmelden": [],
};
Expand Down
32 changes: 25 additions & 7 deletions app/src/app/[tenant]/RegionEinstieg.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* Schwelle, ohne Konto. Die Wahl wird gemerkt; „Region ändern" ist später möglich.
*/

import { useState, useTransition } from "react";
import { useId, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import {
regionAusPlz,
Expand Down Expand Up @@ -39,6 +39,24 @@ export function RegionEinstieg({
onErfolg?: () => void;
}) {
const router = useRouter();
/**
* Eindeutige IDs je Instanz (a11y-Gate, Issue #60). Die Komponente steht auf
* der Startseite ZWEIMAL im selben Dokument (Hero + CTA-Wiederholung) und
* zusätzlich auf /umfragen sowie im Konto. Mit statischen IDs (`plz`,
* `plz-funktion`, …) existierte jede ID doppelt: `duplicate-id-aria`
* (Impact critical) plus `form-field-multiple-labels` als Folgefehler.
* Real spürbar ist das an der Label-Zuordnung — `<label for="plz">` zeigt
* IMMER auf das erste Feld im Dokument; ein Klick auf das Label des unteren
* Formulars springt also nach oben, und Screenreader lesen die Beschreibung
* des falschen Feldes vor.
* `useId()` statt fester Suffixe, weil ein Suffix nur so lange trägt, wie die
* Zahl der Instanzen bekannt ist; useId skaliert auf beliebig viele.
*/
const idBasis = useId();
const plzId = `${idBasis}-plz`;
const funktionId = `${idBasis}-plz-funktion`;
const fehlerId = `${idBasis}-plz-fehler`;
const hinweisId = `${idBasis}-plz-hinweis`;
const [plz, setPlz] = useState("");
const [fehler, setFehler] = useState<string | null>(null);
const [hinweis, setHinweis] = useState<string | null>(null);
Expand Down Expand Up @@ -124,17 +142,17 @@ export function RegionEinstieg({
}
>
<form onSubmit={handlePlzSubmit} noValidate>
<label htmlFor="plz" className="block text-sm font-medium" style={{ color: "var(--pz-ink)" }}>
<label htmlFor={plzId} className="block text-sm font-medium" style={{ color: "var(--pz-ink)" }}>
Ihre Postleitzahl
</label>
{/* Funktions-Erklärung (P2 §Empf. 7): was die PLZ bewirkt — der Datenschutz-
Hinweis steht weiterhin unten. */}
<p id="plz-funktion" className="mt-1 text-xs" style={{ color: "var(--pz-muted)" }}>
<p id={funktionId} className="mt-1 text-xs" style={{ color: "var(--pz-muted)" }}>
Ihre PLZ bestimmt, welche Abstimmungen Sie sehen.
</p>
<div className="mt-2 flex gap-2">
<input
id="plz"
id={plzId}
name="plz"
inputMode="numeric"
autoComplete="postal-code"
Expand All @@ -144,7 +162,7 @@ export function RegionEinstieg({
disabled={busy}
className="w-full rounded-lg border border-[color:var(--pz-line)] bg-pz-surface px-3 py-2.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--pz-brand)]"
style={{ color: "var(--pz-ink)" }}
aria-describedby={`plz-funktion${fehler ? " plz-fehler" : hinweis ? " plz-hinweis" : ""}`}
aria-describedby={`${funktionId}${fehler ? ` ${fehlerId}` : hinweis ? ` ${hinweisId}` : ""}`}
/>
<button
type="submit"
Expand Down Expand Up @@ -173,12 +191,12 @@ export function RegionEinstieg({
</button>

{fehler && (
<p id="plz-fehler" className="mt-3 text-sm" style={{ color: "#b42318" }} role="alert">
<p id={fehlerId} className="mt-3 text-sm" style={{ color: "#b42318" }} role="alert">
{fehler}
</p>
)}
{hinweis && (
<div id="plz-hinweis" className="mt-3 rounded-lg border border-dashed border-[color:var(--pz-line)] bg-pz-surface p-3">
<div id={hinweisId} className="mt-3 rounded-lg border border-dashed border-[color:var(--pz-line)] bg-pz-surface p-3">
<p className="text-sm" style={{ color: "var(--pz-body)" }}>{hinweis}</p>
<button
type="button"
Expand Down
22 changes: 18 additions & 4 deletions app/src/app/[tenant]/umfragen/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,29 +155,41 @@ function PollKarte({
);
}

/** Liste, nach Ebene (Ortsteil/Kommune/Kreis/Land) gruppiert. */
/**
* Liste, nach Ebene (Ortsteil/Kommune/Kreis/Land) gruppiert.
*
* `ebene` steuert NUR die Überschriften-Ebene der Gruppentitel, nicht ihr
* Aussehen (a11y-Gate `heading-order`, Issue #60): in der eingeloggten Sicht
* steht die Liste unter einer Sektions-h2 („Für Sie offen") → die Gruppentitel
* sind h3. In der anonymen Sicht folgt die Liste direkt auf die Seiten-h1 → dort
* wäre h3 ein Ebenensprung (h1 → h3), also h2. Die Optik ist in beiden Fällen
* identisch, weil die Klassen dieselben bleiben.
*/
function GruppierteListe({
slug,
items,
cta,
institution,
ebene = 3,
}: {
slug: string;
items: PollMitErgebnis[];
cta: string;
institution: string;
ebene?: 2 | 3;
}) {
const gruppen = gruppiereNachEbene(items);
const GruppenTitel = ebene === 2 ? "h2" : "h3";
return (
<div className="space-y-7">
{gruppen.map((g) => (
<div key={g.typ}>
<h3
<GruppenTitel
className="mb-2.5 text-xs font-semibold uppercase tracking-wide"
style={{ color: "var(--pz-muted)" }}
>
{g.label}
</h3>
</GruppenTitel>
<ul className="space-y-4">
{g.polls.map((p) => (
<li key={p.id}>
Expand Down Expand Up @@ -321,7 +333,9 @@ export default async function UmfragenListePage({ params }: PageProps) {
{aktiveMitErgebnis.length === 0 ? (
<LeerHinweis text="Gerade läuft keine Abstimmung für Ihre Region." />
) : (
<GruppierteListe slug={slugFromPath} items={aktiveMitErgebnis} cta="Anmelden zum Mitstimmen" institution={institution} />
/* Anonyme Sicht: die Gruppentitel folgen direkt auf die Seiten-h1,
also h2 statt h3 — sonst Ebenensprung (`heading-order`, Issue #60). */
<GruppierteListe slug={slugFromPath} items={aktiveMitErgebnis} cta="Anmelden zum Mitstimmen" institution={institution} ebene={2} />
)}

{beendeteMitErgebnis.length > 0 && (
Expand Down
15 changes: 15 additions & 0 deletions app/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@
p {
text-wrap: pretty;
}
/* Fließtext-Links (WCAG 1.4.1 „Use of Color", axe-Regel `link-in-text-block`,
Issue #60): ein Link INNERHALB eines Textblocks darf sich nicht allein durch
die Farbe vom umgebenden Text unterscheiden — er braucht ein Nicht-Farb-
Merkmal im RUHEZUSTAND. `hover:underline` genügt dafür nicht: wer nicht mit
der Maus darüberfährt (Touch, Tastatur, Restsehen), sieht es nie.
Deshalb zentral hier statt pro Aufrufer — so schleppt auch der nächste neu
geschriebene Fließtext-Link den Verstoß nicht wieder ein.
Ausgenommen: Links, die als Button auftreten (.pz-btn) — die tragen Fläche
und Rahmen als Nicht-Farb-Merkmal. Utility-Klassen (z. B. `no-underline`)
stechen diese Regel weiterhin, weil Tailwind-Utilities in einer späteren
Cascade-Layer stehen. */
p a:not(.pz-btn) {
text-decoration-line: underline;
text-underline-offset: 0.2em;
}
}

/* ===========================================================================
Expand Down