Zweiter Teil der Konsolidierung. Vorher standen 31 font-size-Werte außerhalb der Skala — beim Durchgehen stellte sich heraus, dass 20 davon gar keine Textgrößen sind: der icon()-Helper rendert 1em, also steuerte font-size dort die Icon-Größe. Dafür gibt es jetzt eine eigene, kleine Skala (--icon-s/m/l/xl in rem, --icon-inline-sm/--icon-inline/--icon-inline-lg in em für Icons, die im Textfluss mitwachsen sollen). Elf Ad-hoc-Werte fallen damit auf sieben Stufen. h4–h6 laufen nicht mehr über Coolvetica. Sie lagen mit 26px und 21px unter der --fs-650-Grenze aus CLAUDE.md, ab der der Condensed-Schnitt in Uppercase unleserlich wird — die Regel war also nur dokumentiert, nicht durchsetzbar. Jetzt tragen h1–h3 Coolvetica (alle über der Grenze) und h4–h6 Abel mit Sperrung. Bezeichnend: das einzige <h4> im Markup hatte sich genau dieses Muster bereits von Hand gebaut. Die Basisregel liefert es jetzt, die Komponente gibt ihre drei Duplikat-Deklarationen ab — sichtbar ändert sich dadurch nichts. Neue Stufen schließen die Lücken: --fs-550 (fluider Lead), --fs-690 (Karten- Überschrift) und --fs-1000 (Display-Hero). Vier fast deckungsgleiche clamp()- Rampen für Karten-Überschriften (bento__heading, bento__stat-value, group-card__name, team-card__name) laufen jetzt über --fs-690; team-card__name wächst dabei am Desktop um ~5px, die anderen drei bleiben im 1px-Bereich. Die Wappen-Initialen im Matchcenter hingen an zwei Mini-Schriftgrößen (10,4 und 11,2px) unterhalb der Skala. Sie sind in Wahrheit ~0,4 × Kreisdurchmesser, folgen jetzt also --crest-size statt einer eigenen font-size. Nebenbei fallen drei redundante Doppel-Selektoren weg: .crest--initials trägt immer auch .crest. Ebenfalls tokenisiert: 27 letter-spacing-Werte mit elf verschiedenen Zahlen auf vier Stufen (--ls-tight/caps/wide/wider), maximale Abweichung 0,02em. Danach steht kein font-size- und kein letter-spacing-Wert mehr außerhalb von tokens.css. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
193 lines
16 KiB
Markdown
193 lines
16 KiB
Markdown
# 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: All-Inkl-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` |
|
||
| Mannschaften (Kader, Trainer, Hero, FAQ) | `data/teams.json` | Spieler-/Trainernamen in Team-Templates |
|
||
| Seiteninhalte (Texte, Hero, FAQ) | `data/<slug>.json` (z. B. `fussball`, `jugend`, `turnen`, `verein`, `mitmachen`, `historie`, `partner-werden`, `sportheimbuchung`) | Texte/Hero/FAQ hart in `pages/*.php` |
|
||
| Ansprechpartner/Personen (geteilte Quelle) | `data/ansprechpartner.json` (per Abteilung auf Team-/Jugend-/Verein-Seiten gezogen) | Namen/Funktionen/Kontakte in Templates duplizieren |
|
||
| Banner-Slider (Startseite) | `data/banners.json` | Slides in `pages/home.php` oder der Komponente |
|
||
| Partner/Sponsoren | `data/partners.json` | — |
|
||
| Instagram-Cache | `data/instagram.json` + `public/assets/img/instagram/` | **maschinenverwaltet von `bin/instagram-sync.php` — nie von Hand editieren** |
|
||
| Matchcenter (Spiele/Ergebnisse/Tabellen) | `data/matchcenter.json` + `public/assets/img/crests/` | **maschinenverwaltet von `bin/matchcenter-sync.php` — nie von Hand editieren**. Team-/Wettbewerbs-IDs in `config.php` (`matchcenter.teams`) |
|
||
| 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.
|
||
|
||
**Strukturierte Daten (JSON-LD):** Seiten setzen `$meta['schema']` = Array von schema.org-Knoten;
|
||
`app/components/jsonld.php` hängt sie an den globalen `@graph` (Organization `#club`) an — nie ein
|
||
`<script type="application/ld+json">` von Hand ins Markup. Knoten **nur** über die Helper in
|
||
`helpers.php` bauen: `breadcrumb_schema()`, `faq_schema()`, `page_schema()` (Breadcrumb + optional FAQ),
|
||
`team_schema()` (SportsTeam + Breadcrumb + FAQ, verweist via `#club` auf den Club),
|
||
`sportsevent_nodes()` (SportsEvent-Knoten für kommende Spiele, Matchcenter) und
|
||
`job_posting_schema()` (JobPosting `VOLUNTEER` für Ehrenamtsstellen, `/mitmachen`, verweist via `#club`).
|
||
Sichtbare FAQ + FAQPage-Schema aus derselben `data/*.json`-Quelle, damit Inhalt und Markup nie auseinanderlaufen.
|
||
|
||
## 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()` (interne
|
||
Pfade), `abs_url()` (absolute URLs für Canonical/OG), `asset()` (Versionierung via filemtime),
|
||
`json_load('name')` (liest `data/name.json`, static cache), `component()`, `config('key')` (Werte aus
|
||
`config/config.php`), `icon('name', 'klasse', 'label?')` (Inline-SVG aus `public/assets/icons/`, siehe
|
||
Abschnitt Icons), `form_token()`/`form_token_valid()` (Time-Trap-Token, siehe Formulare),
|
||
`img_intrinsic_attrs('pfad')` (width/height-Attribute aus der Bilddatei für CLS-freie `<img>`,
|
||
wenn die Maße nicht schon statisch bekannt sind), `match_when('2026-08-01T14:00')` (Anstoß aus
|
||
`matchcenter.json` → `iso` für `<time datetime>`/JS-Ticker + deutscher Countdown-Text, gelesen als
|
||
Europe/Berlin, weil PHP global auf UTC läuft).
|
||
- **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
|
||
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.
|
||
- **Hero-Regel:** Die Startseite hat **einen** großen Hero (`display: true`, mit `kicker`/`tagline`/Video).
|
||
**Alle Unterseiten-Heros sind einheitlich „Überschrift + Teaser"** — nur `title` + `text`, dazu
|
||
`compact: true` und `align: 'bottom-left'`. **Kein `kicker`, keine `tagline`** auf Unterseiten (hält es
|
||
klar und einfach). `ctas` nur als begründete Ausnahme. Gilt für JSON- wie Inline-Heros.
|
||
- **Neue Seite anlegen** = genau 3 Schritte: `app/pages/<slug>.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, das
|
||
Fehler-Element `#<id>-error` (per `aria-describedby` verknüpft) und schreibt nach einem
|
||
abgewiesenen POST die alten Werte zurück (`form_old()`, inkl. `selected`/`checked`).
|
||
- **Jedes neue Formular braucht:** (1) Status-Region `role="status" aria-live="polite" tabindex="-1"
|
||
data-form-status` als erstes Form-Kind — mit `autofocus`, wenn `form_has_errors()`,
|
||
(2) Honeypot-Feld `company_url` (visually-hidden) + Hidden-Field `ft` mit `form_token()`,
|
||
(3) serverseitige Action unter `app/actions/` mit Whitelist-Validierung, (4) Fehlermeldungs-Map
|
||
mit den Schlüsseln `validation` / `ratelimit` / `busy` / `mail` (identisch in `form.js`).
|
||
- **Antwortmuster der Actions** (in dieser Reihenfolge, siehe `contact-submit.php` als Referenz):
|
||
Erfolg und Volumen-Abweisungen → PRG-Redirect (`?sent=1` / `?error=…`), fetch-Requests → JSON.
|
||
**Feldfehler ohne JS → `render_form_invalid($slug, $old, $errors)`**: rendert die Seite mit
|
||
HTTP 422 samt Eingaben und Feldfehlern neu, statt per Redirect alle Eingaben zu verlieren.
|
||
`$errors` ist Feld-**ID** → Meldung (passt auf `#<id>-error`), `$old` ist Feld-**Name** → Wert.
|
||
- `form.js` zeigt Inline-Feldfehler (Browser-Validierung vor dem Absenden, Server-Feldfehler aus
|
||
`fields` in der JSON-Antwort) — 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 ausschließlich über `send_mail()`** (`helpers.php`) — die einzige Versandstelle der Seite,
|
||
PHPMailer über All-Inkl-SMTP (`w01ca75d.kasserver.com:587`, STARTTLS, Postfach
|
||
`noreply@tsv08kulmbach.de`), Timeout 10 s (der Versand hängt im Request-Pfad!), Fehler nach
|
||
`storage/logs/mail.log`. Nie einen eigenen PHPMailer in einer Action aufbauen.
|
||
Verbindung/Login ohne Mailversand testen: `php bin/smtp-test.php`.
|
||
- Spam-Schutz ohne externe Dienste — mehrschichtig in jeder `app/actions/*`-Action:
|
||
(1) `config_problems()` als Not-Aus (ohne echte `config.php` liefe die Seite auf der Vorlage,
|
||
deren `app_secret` öffentlich im Repo steht), (2) Honeypot-Feld `company_url`, (3) HMAC-signierter
|
||
Timestamp / Time-Trap (`ft`-Token via `form_token()`/`form_token_valid()`, `app_secret`),
|
||
(4) serverseitige Whitelist-Validierung, (5) Link-Count-Check im Freitext, (6) **Rate-Limiting**
|
||
via `rate_limit_ok()` mit Zählern unter `storage/ratelimit/`: pro IP+Route (5/10 min),
|
||
Token-Replay-Sperre (3× pro Token+IP) und globaler Tages-Cap (200/Tag über alle Formulare).
|
||
Abgewiesene Versuche → `log_spam()` nach `storage/logs/spam.log`.
|
||
- **Reihenfolge und Ton der Abweisungen sind Absicht:** Bot-Signale (Honeypot, Token, Link-Flut)
|
||
antworten mit einem stillen „OK“ (kein Feedback-Kanal für Bots). Volumen-Grenzen antworten
|
||
**ehrlich** (`ratelimit` / `busy`) — ein stilles „Danke“ ohne Versand verschluckt sonst echte
|
||
Anfragen. Die Volumen-Grenzen stehen **nach** der Validierung, damit Tippfehler kein Kontingent
|
||
verbrauchen.
|
||
- Logs unter `storage/logs/`: `mail.log` (Mailversand-Fehler), `spam.log` (abgewiesene Form-Versuche),
|
||
`instagram.log` / `matchcenter.log` (Sync-Läufe), `php-errors.log`. Fehler nie an Besucher leaken.
|
||
Rotation per Cron: `bin/log-rotate.php` (>2 MB → `.log.1`, max. zwei Generationen).
|
||
- **Vor jedem Deploy `php bin/preflight.php`** — prüft Config/Secrets, `env`, Schreibrechte,
|
||
`vendor/`, PHP-Version und die Cron-Datenstände. Exit 1 = noch nicht live gehen.
|
||
Datenschutzerklärung (`app/pages/datenschutz.php`) mitziehen, wenn ein Formular neue Felder
|
||
bekommt — sie beschreibt die drei Formulare einzeln.
|
||
|
||
## 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/<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
|
||
|
||
- [ ] 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).
|
||
Responsive `widths`-Varianten mit `php bin/img-resize.php` erzeugen (nicht von Hand skalieren)
|
||
- [ ] 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.
|
||
|
||
## Matchcenter (BFV-Widget-Ersatz)
|
||
|
||
`bin/matchcenter-sync.php` (nur CLI) holt Spielplan, Ergebnisse und Tabelle der drei Aktivenmannschaften
|
||
aus der öffentlichen **BFV-Widget-JSON-API** (`widget-prod.bfv.de`, reverse-engineert aus dem alten
|
||
`<BFVWidget.HTML5…>`-Embed — kein HTML-Scraping), lädt die Vereinswappen lokal nach
|
||
`public/assets/img/crests/` und schreibt `data/matchcenter.json` atomar. Endpunkte: `…/api/service/widget/v1/team/{teamId}/matches`,
|
||
`…/api/service/widget/v1/competition/{compoundId}/table`, Wappen `app.bfv.de/export.media/-/action/getLogo/format/0/id/{clubId}`.
|
||
Browser-User-Agent + `Referer` nötig (sonst HTTP 418). Eigene-Verein-Erkennung exakt über `permanentId`.
|
||
Bei Fehlern bleibt der letzte gute Cache unangetastet (Seite degradiert, bricht nie; ohne Cache rendert
|
||
`matchcenter-empty`). Cron 2×/Tag + am Wochenende ½-stündlich nachmittags (siehe `config.example.php`).
|
||
Das „Nächste Spiele"-Band (`next-matches`) zeigt links den Aufmacher (`match-feature`: das zeitlich
|
||
nächste Spiel aller Mannschaften) und rechts die folgenden Termine als verlinkte `match-row`-Zeilen
|
||
(`match-list--plain`, ohne Kartenfläche). **Ein Bauprinzip fürs ganze Band:** der Aufmacher ist die
|
||
große Variante der Terminzeile — Heim über Gast, linksbündig, dieselbe Struktur, nur größer.
|
||
Genau **drei Schriftrollen**, nicht mehr: Meta-Zeile „Mannschaft · Liga · Heim/Auswärts" (Abel,
|
||
uppercase, gesperrt) · Vereinsnamen (Coolvetica, einziges Heading-Element) · Anstoßzeile mit Countdown
|
||
via `match_when()` (Abel). Keine Badges, Icons oder Buttons — die Wirkung kommt aus der Größe, Rot nur
|
||
als Oberkante des Bandes. Die eigene Mannschaft wird über Helligkeit markiert, nie über `font-weight`
|
||
(Coolvetica hat nur einen Schnitt → sonst Fake-Bold). Kein Slider —
|
||
`assets/js/carousel.js` treibt nur noch den Banner-Slider der Startseite.
|
||
Besucher laden alle Matchcenter-Inhalte ausschließlich von unserer Domain.
|