diff --git a/CLAUDE.md b/CLAUDE.md
index 3a361b5..952a054 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -38,6 +38,7 @@ selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern.
| Startseiten-Inhalte | `data/home.json` | Texte in `pages/home.php` |
| Partner/Sponsoren | `data/partners.json` | — |
| 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.
@@ -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
Seiten → als Komponente extrahieren.
- **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 `` in fester Reihenfolge: tokens → reset → base → layout → components →
utilities. Kein `@import`, kein Inline-Style. Komponenten-Styles in `components.css` mit
Banner-Kommentaren (`/* === hero === */`).
@@ -61,6 +63,17 @@ Per-Page-Meta (Title/Description/OG) lebt in der jeweiligen Page-Datei (`app/pag
## 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 `#-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 `#-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`.
- 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.
@@ -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.
- 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/.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 = Dateiname auf icons.getbootstrap.com ohne `.svg`).
+
## Checkliste für jede neue Seite/Komponente
- [ ] Semantisches HTML (Landmarks, Headings-Hierarchie ohne Sprünge)
diff --git a/public/assets/css/components.css b/public/assets/css/components.css
index ffc393b..12ee397 100644
--- a/public/assets/css/components.css
+++ b/public/assets/css/components.css
@@ -331,7 +331,7 @@
width: 100%;
padding: 10px 12px;
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);
color: var(--clr-text);
transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
@@ -340,7 +340,7 @@
.form__field input:hover,
.form__field select: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 */
@@ -416,19 +416,25 @@
}
.form__field--checkbox input {
- width: 1.25rem;
- height: 1.25rem;
- margin-top: 0.2rem;
+ /* 24px — Mindest-Zielgröße (WCAG 2.5.8) */
+ width: 1.5rem;
+ height: 1.5rem;
+ margin-top: 0.1rem;
accent-color: var(--clr-accent);
}
.form__field--checkbox label {
- font-size: var(--fs-400);
- color: var(--clr-text-muted);
+ font-size: var(--fs-500);
}
+/* Leer: unsichtbar, aber im Accessibility-Tree — display:none würde die
+ aria-live-Region abmelden und Screenreader verpassen die erste Meldung. */
.form__status:empty {
- display: none;
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
}
.form__success {
@@ -601,25 +607,21 @@
outline-offset: 3px;
}
-/* Pause-Symbol (zwei Balken) im Abspiel-Zustand … */
-.ad-banner__toggle::before {
- content: "";
- display: block;
- width: 4px;
- height: 13px;
- border-left: 4px solid currentColor;
- border-right: 4px solid currentColor;
+.ad-banner__toggle-icon {
+ font-size: 1.15rem;
}
-/* … Play-Dreieck, wenn pausiert. */
-.ad-banner__toggle[aria-pressed="true"]::before {
- width: 0;
- height: 0;
- margin-left: 3px;
- border: 0;
- border-left: 13px solid currentColor;
- border-top: 8px solid transparent;
- border-bottom: 8px solid transparent;
+/* Pause-Icon im Abspiel-Zustand, Play-Icon wenn pausiert. */
+.ad-banner__toggle .ad-banner__toggle-icon--play {
+ display: none;
+}
+
+.ad-banner__toggle[aria-pressed="true"] .ad-banner__toggle-icon--pause {
+ display: none;
+}
+
+.ad-banner__toggle[aria-pressed="true"] .ad-banner__toggle-icon--play {
+ display: inline-block;
}
/* Indikatorleiste — die aktive Leiste füllt sich über --banner-interval. */
diff --git a/public/assets/css/tokens.css b/public/assets/css/tokens.css
index 2eb6626..3d91882 100644
--- a/public/assets/css/tokens.css
+++ b/public/assets/css/tokens.css
@@ -18,6 +18,7 @@
--clr-text-faint: #8c8c8c; /* 4.5:1 auf --clr-bg (AA) */
--clr-glass-bg: rgb(255 255 255 / 0.11);
--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-success: #8ce99a;