Der Bereich wirkte deplatziert: der Track war auf 620px begrenzt und zeigte eine Folie, wodurch die Karte auf breiten Fenstern in ~840px Totraum schwebte; dazu derselbe Hintergrund wie der Hero direkt darüber, kein Akzent, und ein einsames „:" zwischen den Wappen, das wie ein fehlendes Ergebnis aussah. Neu als abgesetztes Band (.section--sunken, nutzt endlich das dafür vorgesehene, bisher unbenutzte Token --clr-bg-deep) mit roter Oberkante: links der Aufmacher (match-feature), rechts die folgenden Termine als verlinkte match-row-Zeilen ohne Kartenfläche. Der Aufmacher ist die große Variante der Terminzeile — Heim über Gast, linksbündig, gleiche Struktur wie die Zeilen daneben. Bewusst nur drei typografische Rollen (Meta-Zeile · Vereinsnamen in Coolvetica · Anstoßzeile mit Countdown) statt der acht der ersten Fassung, ohne Badges, Icons oder Button. Die eigene Mannschaft wird über Helligkeit markiert, nicht über font-weight — Coolvetica hat nur einen Schnitt, bold wäre synthetisch. match-row bekommt optionale $href/$meta-Props; Raster und Innenabstand liegen jetzt im __link-Wrapper, damit als Link die ganze Zeilenfläche klickbar ist. Neue Helper match_when() (ISO + deutscher Countdown, explizit Europe/Berlin, weil PHP global auf UTC läuft) und match_competition_label(). match-slider.php und match-card.php entfallen — Letztere wurde ausschließlich vom Slider genutzt. carousel.js treibt damit nur noch den Banner-Slider der Startseite und wird im Matchcenter nicht mehr geladen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
14 KiB
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, weilphp -Skein .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)
- 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. .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 nachpublic/assets/kopieren/optimieren.- Kein Admin-/Pflege-Backend bauen. Inhalte werden per Chat gepflegt: Claude editiert
data/*.jsonbzw. die Seiten-Dateien. Kein Login, keine Schreib-Endpoints. - Sensible Altdaten nie übernehmen: Aus dem DB-Dump keine Mail-Logs, Formulareinträge, Passwort-Hashes, Tokens oder personenbezogene Daten migrieren.
- Secrets nur in
config/config.php(gitignored, außerhalb des Webroots, per .htaccess denied). Niemals hardcoden, loggen oder ausgeben.config/config.example.phpdokumentiert 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 inapp/routes.php→ Page-Datei setzt$metaund emittiert Body viacomponent()→app/layout.phprendert die Shell. - Komponenten:
app/components/*.phpsind dumme Includes, bekommen Props viacomponent('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')(liestdata/name.json, static cache),component(),config('key')(Werte ausconfig/config.php),icon('name', 'klasse', 'label?')(Inline-SVG auspublic/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ß ausmatchcenter.json→isofü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 incomponents.cssmit 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, mitkicker/tagline/Video). Alle Unterseiten-Heros sind einheitlich „Überschrift + Teaser" — nurtitle+text, dazucompact: trueundalign: 'bottom-left'. Keinkicker, keinetaglineauf Unterseiten (hält es klar und einfach).ctasnur als begründete Ausnahme. Gilt für JSON- wie Inline-Heros. - Neue Seite anlegen = genau 3 Schritte:
app/pages/<slug>.php+ Eintrag inapp/routes.php(+ optionaldata/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#<id>-error(peraria-describedbyverknüpft) in einheitlichem Markup. - Jedes neue Formular braucht: (1) Status-Region
role="status" aria-live="polite" tabindex="-1" data-form-statusals erstes Form-Kind, (2) Hinweiszeile „Pflichtfelder sind mit * markiert“, (3) Honeypot-Feldwebsite(visually-hidden) + Hidden-Fieldftmitform_token(), (4) serverseitige Action unterapp/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 perdisplay:noneverstecken (fliegt aus dem Accessibility-Tree). - Versand via PHPMailer über All-Inkl-SMTP (
w01ca75d.kasserver.com:587, STARTTLS, Postfachnoreply@tsv08kulmbach.de), Config ausconfig/config.php. Verbindung/Login ohne Mailversand testen:php bin/smtp-test.php. - Spam-Schutz ohne externe Dienste — mehrschichtig in jeder
app/actions/*-Action: (1) Honeypot-Feldwebsite, (2) HMAC-signierter Timestamp / Time-Trap (ft-Token viaform_token()/form_token_valid(),app_secret), (3) serverseitige Whitelist-Validierung, (4) Rate-Limiting viarate_limit_ok()mit Zählern unterstorage/ratelimit/: pro IP+Route (5/10 min), globaler Tages-Cap (100/Tag) und Token-Replay-Sperre (3×/Token), (5) Link-Count-Check im Freitext. Abgewiesene Versuche →log_spam()nachstorage/logs/spam.log. Immer POST-Redirect-GET;form.jsmacht optional fetch. - 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.
Design (aus der alten Seite extrahiert, verifiziert)
- Dunkles sportliches Theme: BG
#222mit 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. mitletter-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-sizedes Elements (Icon ist1em), Farbe =currentColor. Basisklasse.iconinutilities.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-visiblesichtbar, sinnvolle Tab-Reihenfolge - Kontrast ≥ 4.5:1 (Fließtext) / 3:1 (große Schrift, UI)
prefers-reduced-motionrespektiert (globaler Kill-Switch in reset.css)$metagesetzt: title, description (+ og_image falls abweichend)- Bilder:
srcset/sizes,width/height,loading="lazy"(above-the-fold: eager + fetchpriority). Responsivewidths-Varianten mitphp bin/img-resize.phperzeugen (nicht von Hand skalieren) - Keine Duplikate: Inhalte/Werte aus den Single-Source-Dateien beziehen
php -lsauber; 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.