Formular-Regeln in CLAUDE.md; A11y-Fixes: Feldränder 3:1-Kontrast (neuer Token), Checkbox 24px + größeres Label, aria-live-Region nie display:none

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:59:34 +02:00
parent ee0d6866ea
commit b95a0bce2d
3 changed files with 49 additions and 26 deletions

View File

@@ -38,6 +38,7 @@ selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern.
| Startseiten-Inhalte | `data/home.json` | Texte in `pages/home.php` | | Startseiten-Inhalte | `data/home.json` | Texte in `pages/home.php` |
| Partner/Sponsoren | `data/partners.json` | — | | Partner/Sponsoren | `data/partners.json` | — |
| Instagram-Cache | `data/instagram.json` + `public/assets/img/instagram/` | **maschinenverwaltet von `bin/instagram-sync.php` — nie von Hand editieren** | | Instagram-Cache | `data/instagram.json` + `public/assets/img/instagram/` | **maschinenverwaltet von `bin/instagram-sync.php` — nie von Hand editieren** |
| Icons | `public/assets/icons/` (Bootstrap Icons, lokal) via `icon()`-Helper | Inline-SVG-Pfade von Hand ins Markup, Emoji/Unicode-Glyphen, gezeichnete CSS-Icons, Icon-Font/CDN |
Per-Page-Meta (Title/Description/OG) lebt in der jeweiligen Page-Datei (`app/pages/*.php`) im `$meta`-Array. Per-Page-Meta (Title/Description/OG) lebt in der jeweiligen Page-Datei (`app/pages/*.php`) im `$meta`-Array.
@@ -50,7 +51,8 @@ Per-Page-Meta (Title/Description/OG) lebt in der jeweiligen Page-Datei (`app/pag
prüfen ob eine Komponente existiert und erweitert werden kann. Wiederkehrende Inhalte auf neuen prüfen ob eine Komponente existiert und erweitert werden kann. Wiederkehrende Inhalte auf neuen
Seiten → als Komponente extrahieren. Seiten → als Komponente extrahieren.
- **Helpers** (`app/helpers.php`): `e()` (Escaping — IMMER für dynamische Ausgaben), `url()`, `asset()` - **Helpers** (`app/helpers.php`): `e()` (Escaping — IMMER für dynamische Ausgaben), `url()`, `asset()`
(Versionierung via filemtime), `json_load('name')` (liest `data/name.json`, static cache), `component()`. (Versionierung via filemtime), `json_load('name')` (liest `data/name.json`, static cache), `component()`,
`icon('name', 'klasse', 'label?')` (Inline-SVG aus `public/assets/icons/`, siehe Abschnitt Icons).
- **CSS:** 6 Dateien als `<link>` in fester Reihenfolge: tokens → reset → base → layout → components → - **CSS:** 6 Dateien als `<link>` in fester Reihenfolge: tokens → reset → base → layout → components →
utilities. Kein `@import`, kein Inline-Style. Komponenten-Styles in `components.css` mit utilities. Kein `@import`, kein Inline-Style. Komponenten-Styles in `components.css` mit
Banner-Kommentaren (`/* === hero === */`). Banner-Kommentaren (`/* === hero === */`).
@@ -61,6 +63,17 @@ Per-Page-Meta (Title/Description/OG) lebt in der jeweiligen Page-Datei (`app/pag
## Formulare ## Formulare
- **Felder ausschließlich über `component('form-field', ['field' => …])`** (`app/components/form-field.php`) —
nie Feld-Markup von Hand. Die Komponente liefert Label, Pflichtfeld-Stern, Hilfetext und das
Fehler-Element `#<id>-error` (per `aria-describedby` verknüpft) in einheitlichem Markup.
- **Jedes neue Formular braucht:** (1) Status-Region `role="status" aria-live="polite" tabindex="-1"
data-form-status` als erstes Form-Kind, (2) Hinweiszeile „Pflichtfelder sind mit * markiert“,
(3) Honeypot-Feld `website` (visually-hidden) + Hidden-Field `ft` mit `form_token()`,
(4) serverseitige Action unter `app/actions/` mit Whitelist-Validierung + PRG-Redirect.
- `form.js` übernimmt automatisch Inline-Feldfehler (Browser-Validierungsmeldung, `aria-invalid`,
Fokus aufs erste ungültige Feld) — Konvention dafür ist die `#<id>-error`-ID aus der Komponente.
- A11y-Vorgaben: Feldränder mit `--clr-glass-border-strong` (≥3:1 Kontrast, WCAG 1.4.11), Checkbox
min. 24px (WCAG 2.5.8), Status-Region nie per `display:none` verstecken (fliegt aus dem Accessibility-Tree).
- Versand via PHPMailer über Brevo SMTP (`smtp-relay.brevo.com:587`, STARTTLS), Config aus `config/config.php`. - Versand via PHPMailer über Brevo SMTP (`smtp-relay.brevo.com:587`, STARTTLS), Config aus `config/config.php`.
- Spam-Schutz ohne externe Dienste: Honeypot-Feld + HMAC-signierter Timestamp (Time-Trap, `app_secret`) - Spam-Schutz ohne externe Dienste: Honeypot-Feld + HMAC-signierter Timestamp (Time-Trap, `app_secret`)
+ serverseitige Validierung mit Whitelists. POST-Redirect-GET; `form.js` macht optional fetch. + serverseitige Validierung mit Whitelists. POST-Redirect-GET; `form.js` macht optional fetch.
@@ -74,6 +87,13 @@ Per-Page-Meta (Title/Description/OG) lebt in der jeweiligen Page-Datei (`app/pag
- Rot nie für Fließtext auf dunklem Grund (Kontrast!) — nur als Button-/Akzent-Fläche mit weißem Text. - Rot nie für Fließtext auf dunklem Grund (Kontrast!) — nur als Button-/Akzent-Fläche mit weißem Text.
- Alle Werte als CSS Custom Properties in `tokens.css`; Breakpoints: 1249 (Burger) / 1024 / 992 / 768 / 600 / 500. - Alle Werte als CSS Custom Properties in `tokens.css`; Breakpoints: 1249 (Burger) / 1024 / 992 / 768 / 600 / 500.
## Icons
- **Bootstrap Icons** (MIT), lokal & self-hosted unter `public/assets/icons/<name>.svg` — die erlaubte Icon-Quelle. Kein Icon-Font, kein CDN, keine gezeichneten CSS-/Unicode-Icons.
- Ausgabe **nur** über den Helper: `icon('pause-fill')` (dekorativ, `aria-hidden`), `icon('pause-fill', 'meine-klasse')` (mit Klasse), `icon('envelope', '', 'E-Mail')` (semantisch, `role="img"` + `aria-label`). Inline-SVG, currentColor erbt die Schriftfarbe.
- Größe = `font-size` des Elements (Icon ist `1em`), Farbe = `currentColor`. Basisklasse `.icon` in `utilities.css`.
- Nur tatsächlich genutzte Icons werden abgelegt (kein Komplett-Set). Neues Icon: `php bin/icons-add.php <name>` (Name = Dateiname auf icons.getbootstrap.com ohne `.svg`).
## Checkliste für jede neue Seite/Komponente ## Checkliste für jede neue Seite/Komponente
- [ ] Semantisches HTML (Landmarks, Headings-Hierarchie ohne Sprünge) - [ ] Semantisches HTML (Landmarks, Headings-Hierarchie ohne Sprünge)

View File

@@ -331,7 +331,7 @@
width: 100%; width: 100%;
padding: 10px 12px; padding: 10px 12px;
background: var(--clr-glass-bg); background: var(--clr-glass-bg);
border: 1px solid var(--clr-glass-border); border: 1px solid var(--clr-glass-border-strong);
border-radius: var(--radius-btn); border-radius: var(--radius-btn);
color: var(--clr-text); color: var(--clr-text);
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition); transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
@@ -340,7 +340,7 @@
.form__field input:hover, .form__field input:hover,
.form__field select:hover, .form__field select:hover,
.form__field textarea:hover { .form__field textarea:hover {
border-color: rgb(255 255 255 / 0.4); border-color: rgb(255 255 255 / 0.55);
} }
/* Eigener, deutlich sichtbarer Fokus-Ring statt des globalen Outlines */ /* Eigener, deutlich sichtbarer Fokus-Ring statt des globalen Outlines */
@@ -416,19 +416,25 @@
} }
.form__field--checkbox input { .form__field--checkbox input {
width: 1.25rem; /* 24px — Mindest-Zielgröße (WCAG 2.5.8) */
height: 1.25rem; width: 1.5rem;
margin-top: 0.2rem; height: 1.5rem;
margin-top: 0.1rem;
accent-color: var(--clr-accent); accent-color: var(--clr-accent);
} }
.form__field--checkbox label { .form__field--checkbox label {
font-size: var(--fs-400); font-size: var(--fs-500);
color: var(--clr-text-muted);
} }
/* Leer: unsichtbar, aber im Accessibility-Tree — display:none würde die
aria-live-Region abmelden und Screenreader verpassen die erste Meldung. */
.form__status:empty { .form__status:empty {
display: none; position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
} }
.form__success { .form__success {
@@ -601,25 +607,21 @@
outline-offset: 3px; outline-offset: 3px;
} }
/* Pause-Symbol (zwei Balken) im Abspiel-Zustand … */ .ad-banner__toggle-icon {
.ad-banner__toggle::before { font-size: 1.15rem;
content: "";
display: block;
width: 4px;
height: 13px;
border-left: 4px solid currentColor;
border-right: 4px solid currentColor;
} }
/* … Play-Dreieck, wenn pausiert. */ /* Pause-Icon im Abspiel-Zustand, Play-Icon wenn pausiert. */
.ad-banner__toggle[aria-pressed="true"]::before { .ad-banner__toggle .ad-banner__toggle-icon--play {
width: 0; display: none;
height: 0; }
margin-left: 3px;
border: 0; .ad-banner__toggle[aria-pressed="true"] .ad-banner__toggle-icon--pause {
border-left: 13px solid currentColor; display: none;
border-top: 8px solid transparent; }
border-bottom: 8px solid transparent;
.ad-banner__toggle[aria-pressed="true"] .ad-banner__toggle-icon--play {
display: inline-block;
} }
/* Indikatorleiste — die aktive Leiste füllt sich über --banner-interval. */ /* Indikatorleiste — die aktive Leiste füllt sich über --banner-interval. */

View File

@@ -18,6 +18,7 @@
--clr-text-faint: #8c8c8c; /* 4.5:1 auf --clr-bg (AA) */ --clr-text-faint: #8c8c8c; /* 4.5:1 auf --clr-bg (AA) */
--clr-glass-bg: rgb(255 255 255 / 0.11); --clr-glass-bg: rgb(255 255 255 / 0.11);
--clr-glass-border: rgb(255 255 255 / 0.22); --clr-glass-border: rgb(255 255 255 / 0.22);
--clr-glass-border-strong: rgb(255 255 255 / 0.4); /* Formularfelder: ≥3:1 Kontrast (WCAG 1.4.11) */
--clr-error: #ff8a8a; /* Formularfehler, lesbar auf dunkel */ --clr-error: #ff8a8a; /* Formularfehler, lesbar auf dunkel */
--clr-success: #8ce99a; --clr-success: #8ce99a;