# TSV 08 Kulmbach — Website Neubau von tsv08kulmbach.de. Vereinsseite (Fußball + Turnen, gegründet 1908) als sauberer, selbst gehosteter Stack ohne Abhängigkeiten von Drittanbietern. ## Stack & Umgebung - **Frontend:** Natives HTML, Vanilla CSS, Vanilla JS. Kein Framework, keine Build-Pipeline, kein Node. - **Backend:** Vanilla PHP ≥ 8.1, Composer nur für `phpmailer/phpmailer`. Keine Template-Engine. - **Hosting-Ziel:** Apache Shared Hosting, PHP 8.x, `.htaccess`, Cronjobs verfügbar. - **Lokales Dev:** `php -S localhost:8000 -t public public/index.php` (index.php fungiert als Router-Script, weil `php -S` kein .htaccess kennt). - **Docroot:** `public/`. Falls der Hoster nur das Repo-Root serviert, greift die Root-`.htaccess` (Rewrite → `public/`, Deny für alles andere). ## Harte Regeln (nicht verhandelbar) 1. **Keine externen Einbindungen.** Keine CDNs, keine externen Fonts/Scripts/iframes/Embeds, keine Tracking-/Analytics-Dienste, kein reCAPTCHA. Alles wird self-hosted. Einzige erlaubte ausgehende Verbindungen: Brevo SMTP (Mailversand, serverseitig) und der Instagram-Scraper (CLI/Cron, nie im Request-Pfad). Besucher-Browser kontaktieren ausschließlich unsere Domain. Die CSP (`default-src 'self'`) erzwingt das — nicht aufweichen. 2. **`.context/` und `.context_db/` sind reine Referenz** (alte, unsichere CMS-Seite). Von dort werden nur Assets (Bilder/Fonts/Videos), Texte und Styling-Werte extrahiert. **Niemals Code übernehmen, niemals Dateien von dort direkt verlinken.** Assets immer nach `public/assets/` kopieren/optimieren. 3. **Kein Admin-/Pflege-Backend bauen.** Inhalte werden per Chat gepflegt: Claude editiert `data/*.json` bzw. die Seiten-Dateien. Kein Login, keine Schreib-Endpoints. 4. **Sensible Altdaten nie übernehmen:** Aus dem DB-Dump keine Mail-Logs, Formulareinträge, Passwort-Hashes, Tokens oder personenbezogene Daten migrieren. 5. **Secrets nur in `config/config.php`** (gitignored, außerhalb des Webroots, per .htaccess denied). Niemals hardcoden, loggen oder ausgeben. `config/config.example.php` dokumentiert alle Keys. ## Single Source of Truth (vor JEDER Änderung prüfen) | Was | Einzige Quelle | Niemals | |---|---|---| | Farben, Fonts, Spacing, Radii, Schatten | `public/assets/css/tokens.css` | Hex-Werte/Magic Numbers in anderen CSS-Dateien | | Seiten-Slugs & Routing | `app/routes.php` | URLs woanders hart verdrahten | | Navigation (Labels/Reihenfolge) | `data/navigation.json` | Menüpunkte in Templates | | Vereinsdaten (Name, Adresse, Kontakt, Social, Geo) | `data/club.json` | Adresse/E-Mail irgendwo als Text duplizieren | | 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. ## Architektur-Muster - **Front Controller:** `public/index.php` → Slug-Lookup in `app/routes.php` → Page-Datei setzt `$meta` und emittiert Body via `component()` → `app/layout.php` rendert die Shell. - **Komponenten:** `app/components/*.php` sind dumme Includes, bekommen Props via `component('name', ['key' => $val])`. **Component-first-Regel:** Bevor neues Markup/CSS entsteht, 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()`, `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 === */`). - **JS:** Vanilla, `defer`, progressive enhancement — alles muss ohne JS funktionieren (Formulare = normales POST + Redirect). Ein Feature = eine Datei = eine selbst-initialisierende IIFE. - **Neue Seite anlegen** = genau 3 Schritte: `app/pages/.php` + Eintrag in `app/routes.php` (+ optional `data/navigation.json`). Sitemap & Canonical folgen automatisch. ## 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. - Fehler nach `storage/logs/mail.log`, nie an Besucher leaken. ## Design (aus der alten Seite extrahiert, verifiziert) - Dunkles sportliches Theme: BG `#222` mit Textur, weiße Schrift, **Akzent-Rot `#e20612`** (das ist der echte Markenwert aus der alten DB — nicht #ff6532). - Headings: Coolvetica, uppercase, line-height ~0.85. Body: Abel. Beide self-hosted als woff2. - **Coolvetica nie kleiner als `--fs-650` (28px)** — der schmale Condensed-Schnitt wird in Uppercase darunter unleserlich (mehrfach bestätigt: Footer-Headings, Timeline-Nav). Für kleinere Auszeichnungen Abel verwenden, ggf. mit `letter-spacing` + uppercase. - 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) - [ ] Alt-Texte für Bilder; dekorative Bilder `alt=""` - [ ] Tastatur-bedienbar, `:focus-visible` sichtbar, sinnvolle Tab-Reihenfolge - [ ] Kontrast ≥ 4.5:1 (Fließtext) / 3:1 (große Schrift, UI) - [ ] `prefers-reduced-motion` respektiert (globaler Kill-Switch in reset.css) - [ ] `$meta` gesetzt: title, description (+ og_image falls abweichend) - [ ] Bilder: `srcset`/`sizes`, `width`/`height`, `loading="lazy"` (above-the-fold: eager + fetchpriority) - [ ] Keine Duplikate: Inhalte/Werte aus den Single-Source-Dateien beziehen - [ ] `php -l` sauber; Seite lokal geprüft ## Instagram-Feed (Juicer-Ersatz) `bin/instagram-sync.php` (nur CLI) scraped das öffentliche Profil @tsv08kulmbach, schreibt `data/instagram.json` atomar und lädt Bilder nach `public/assets/img/instagram/`. Bei Fehlern bleibt der letzte gute Cache unangetastet (Seite degradiert, bricht nie; ohne Cache rendert die Komponente eine „Folge uns"-CTA-Karte). Cron: 2×/Tag. Besucher laden Instagram-Inhalte ausschließlich von unserer Domain.