Files
tsv08kulmbach-website/CLAUDE.md
fs 592acf02b0 Turn-Anmeldung als eigene FAQ-Frage, Knöpfe in FAQ-Antworten möglich
Der Anmelde-Vorgang der Abteilung (erst Platz anfragen, dann Aufnahmeantrag)
stand nirgends auf der Seite. Auf der alten Seite gab es dafür einen Block
„ANMELDUNG" mit zwei Knöpfen.

Erst als eigene Sektion unter den Beiträgen gebaut (cta-band), auf Wunsch von
Felix wieder entfernt und stattdessen als eigene FAQ-Frage umgesetzt. Zwei
Sektionen mit denselben Knöpfen auf einer Seite schwächen beide Stellen, und
man weiß nicht mehr, welcher der richtige Weg ist. Deshalb ist auch
overview.cta ganz aus data/turnen.json raus statt unbenutzt liegen zu bleiben —
genau so ein toter Block hat heute schon einmal in die Irre geführt (die Notiz
„NOCH unbenutzt" stand monatelang im Docblock von turnen.php).

faq.php bekommt dafür ein optionales ctas-Feld pro Eintrag, gerendert über die
bestehende cta-Komponente, also keine neue Knopf-Variante. .faq__actions
bricht um, weil zwei Knöpfe auf einem schmalen Handy nicht nebeneinander passen.

Die Antwort nennt beide Wege bewusst AUCH in Worten: faq_schema() liest nur q
und a, Knöpfe landen nie im FAQPage-Schema. Stünde in der Antwort nur „so
geht's" plus zwei Knöpfe, hätte Google eine Antwort ohne Ziel. Nachgeprüft:
sechs Fragen im Schema, der neue Antworttext ist HTML- und Markup-frei.

Gemessen bei 390px: kein Überlauf, Knöpfe 130x44 und 193x44 (Trefferfläche
erfüllt). Die fünf Turn-Unterseiten hatten als einzige von 105 PHP-Dateien
keinen Zeilenumbruch am Dateiende, das ist mitkorrigiert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 22:31:57 +02:00

92 KiB
Raw Blame History

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: All-Inkl Shared Hosting, PHP 8.x, .htaccess, SSH (seit 31.07.2026) und Cronjobs, die aber nur URLs aufrufen können — deshalb läuft der Takt über public/cron.php, siehe dort. Deploy-Anleitung Schritt für Schritt: docs/deploy.md.
  • Lokales Dev: php -d upload_max_filesize=250M -d post_max_size=260M -S localhost:8000 -t public public/index.php (index.php fungiert als Router-Script, weil php -S kein .htaccess kennt). Die beiden -d-Schalter brauchst du für die Dateiablage /dateien: der CLI-Server liest keine .user.ini, und mit den PHP-Standardwerten (oft 2 MB) scheitert jeder Video-Upload. Für die Seite selbst sind sie egal.
  • 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 die CLI/Cron-Skripte unter bin/ (Instagram-Scraper, Matchcenter-Sync, Files-Gallery-Update — 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 — mit zwei benannten Ausnahmen: (a) die Dateiablage /dateien (siehe unten), eine fremde, gekapselte App, die ausschließlich in storage/dateien/uploads/ schreibt und keine Website-Inhalte anfasst; (b) der Cron-Endpoint public/cron.php (siehe unten), der nur zwei feste Sync-Skripte startet und keine Eingaben verarbeitet. Nichts davon darf in den eigenen Code wachsen: kein zweiter Login, keine Schreib-Route in app/, keine dritte Ausnahme ohne denselben Aufwand an Begründung.
  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, Slogan, Web-Adresse) data/club.json Adresse/E-Mail irgendwo als Text duplizieren. club.website ist die öffentliche Domain für alles Gedruckte (club_url()/club_domain()) — nicht config('base_url'), die ist umgebungsabhängig
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)
Stadionzeitung (Ausgaben-Archiv) data/stadionzeitung.json + public/assets/img/stadionzeitung/ maschinenverwaltet von bin/stadionzeitung-add.php + bin/stadionzeitung-publish.php (Status/Snapshot) — nie von Hand editieren; einzige Chat-Pflege-Ausnahmen sind vorwort und interview. Der Ausgaben-Ordner wird bei jedem Lauf komplett geleert und neu gefüllt
Sponsoren-Anzeigen (Stadionzeitung) data/anzeigen.json + public/assets/img/anzeigen/ Dauerhafter Bestand, nicht pro Ausgabe: Motive ändern sich selten, das Heft erscheint oft. sponsor, alt (optional), aktiv und die Reihenfolge (= Reihenfolge im Heft) im Chat gepflegt; base/widths schreibt ausschließlich bin/anzeige-add.php. Anzeigen nie in den Ausgaben-Ordner legen
Termine/Events (/termine) data/events.json (Vereins-Termine ohne eigene Detailseite) — Spieltermine kommen weiterhin nur aus data/matchcenter.json, Feste mit eigener Seite nur aus data/veranstaltungen.json; die Termine-Seite mergt alle drei Von Claude im Chat gepflegt wie teams.json/partners.jsonkeine Automatisierung, kein Bin-Skript. Spieltermine nie hier duplizieren. Ein Fest, das eine Detailseite hat, steht nur in veranstaltungen.json (bin/preflight.php prüft das)
Veranstaltungen (/veranstaltungen + Detailseiten) data/veranstaltungen.json + public/assets/img/veranstaltungen/ Termin, Texte, Programm, Abschnitte von Claude im Chat gepflegt — außer plakate und galerie: die schreibt ausschließlich bin/veranstaltung-bilder.php. Adresse nie in die Texte schreiben (steht in club.json)
News (/news + optionale Detailseiten) data/news.json (redaktionelle Meldungen; artikel[] = Fließtext-Blöcke, status = Entwurf/veröffentlicht) — Instagram-Posts und Ergebnisse kommen weiterhin nur aus data/instagram.json/data/matchcenter.json, werden auf der News-Seite nur gemergt Von Claude im Chat gepflegt wie events.json/teams.jsonkeine Automatisierung, kein Bin-Skript. Instagram/Ergebnisse nie hier duplizieren. text bleibt der Teaser (Feed, Meta-Description, Article-Schema), der Artikel steht in artikel[]
Dateiablage /dateien (Konfiguration) public/dateien/_filesconfig.php (Rechte pro Konto: storage/dateien/system/users/admin/config.php) storage/dateien/system/config/config.php von Hand editieren — die App generiert sie selbst und überschreibt sie bei jedem Update. Passwörter/Lizenz gehören in config.php (dateien.*)
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. Ein FAQ-Eintrag darf zusätzlich ctas[] tragen (Knöpfe unter der Antwort, gerendert über die cta-Komponente) — für Antworten, an deren Ende ein Handgriff steht, etwa „so meldest du dich an". Die Antwort muss den Weg trotzdem in Worten nennen: faq_schema() liest nur q und a, bei Google steht sonst eine Antwort ohne Ziel. Und keine zweite Sektion mit denselben Knöpfen daneben — ein cta-band unter den Beiträgen von /turnen war gebaut und wurde am 31.07.2026 wieder entfernt (Entscheidung Felix): dieselbe Sache zweimal auf einer Seite schwächt beide Stellen, und man weiß nicht mehr, welcher der richtige Weg ist. Redaktioneller Text aus data/*.json darf interne Deeplinks als [Label](slug) tragen (auch [Label](#anker) bzw. slug#anker) — inline_links_html() rendert sie als <a>, inline_links_text() hält HTML-freie Ausgaben (FAQPage-Schema) sauber. Genutzt von den FAQ-Antworten (beide FAQ-Komponenten, faq_schema()) und vom Artikelkörper der News-Detailseite. Kein rohes HTML in der JSON, keine externen URLs — die Escaping-Garantie ist der Wert dieser Funktion, sie ist die einzige Stelle, an der aus JSON HTML entsteht.

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. Genau drei bewusste Ausnahmen vom exakten Slug-Lookup, alle als benannte Prefix-Routen in $prefixRoutes (public/index.php, vor dem routes.php-Lookup): stadionzeitung/<ausgabe>app/pages/stadionzeitung-ausgabe.php, veranstaltungen/<slug>app/pages/veranstaltung.php und news/<slug>app/pages/news-artikel.php. Alle drei ermitteln ihren Detail-Slug selbst wieder aus der URL. Keine generelle Wildcard-Fähigkeit — eine vierte Ausnahme braucht denselben Aufwand an Begründung. news/ ist kein neues Muster, sondern das dritte Vorkommen desselben (Detailseite zu einem Eintrag in einer data/*.json); die Alternative, ein exakter routes.php-Eintrag pro Meldung, würde redaktionelle Inhalte ins Routen-Register schreiben und die Single-Source-Regel brechen. Die Übersichts-Slugs (stadionzeitung, veranstaltungen, news) beginnen nicht mit ihrem eigenen Präfix und bleiben davon unberührt. $current bleibt dabei der volle URL-Slug (sonst kanonisierte jede Detailseite auf die Übersicht); dass der Elternpunkt in der Navigation deshalb kein aria-current bekommt, ist bewusst in Kauf genommen.
  • 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), club_url()/club_domain() (absolute URL bzw. Domain ohne Schema auf der ÖFFENTLICHEN Vereinsadresse club.website — für alles, was gedruckt wird oder als Adresse dasteht; nie abs_url() dafür, siehe „Gedruckte Adressen" unten), asset() (Versionierung via filemtime; zweiter Parameter false lässt ?v= weg — nur für Dateien, die auch statisches CSS anfordert, also die Font-Preloads in meta.php, deren URL exakt der @font-face-URL aus base.css entsprechen muss; geänderte Fonts bekommen einen neuen Dateinamen statt einer Version), 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.jsoniso für <time datetime>/JS-Ticker + deutscher Countdown-Text, gelesen als Europe/Berlin, weil PHP global auf UTC läuft; zweiter Parameter false lässt die Uhrzeit weg, für ganztägige Events), department_label('turnen') (Anzeigename eines Vereinsbereichs, gemeinsame Quelle für Termine-Badges und Hero-Beschriftung) und upcoming_highlights(3) (die nächsten Termine für den Startseiten-Hero, s. Hero-Regel).
  • CSS: 6 Dateien als <link> in fester Reihenfolge: tokens → reset → base → layout → components → utilities. Kein @import, kein Inline-Style — die CSP erzwingt das, und zwar nur in Produktion. default-src 'self' ohne style-src heißt: der Browser verwirft jedes style-Attribut und jeden <style>-Block stillschweigend. Lokal fällt das nie auf, weil php -S keine .htaccess liest. Genau so blieben die Banner-Slides beim ersten Deploy schwarz (31.07.2026): die Komponente setzte style="--banner-bg: url(…)". Die zwei Auswege, wenn ein Wert aus data/*.json ins Aussehen muss: ein Bild wird ein <img> (die CSP erlaubt img-src 'self' data:, und srcset gibt es gratis dazu), eine Farbe wird ein Token in tokens.css plus eine Klasse in components.css, und die JSON nennt nur den Namen ("accent": "breadcrumb"). bin/preflight.php bricht mit Fehler ab, wenn irgendwo unter app/ ein style-Attribut auftaucht. Per JavaScript gesetzte Styles (element.style.…) sind nicht betroffen. Komponenten-Styles in components.css mit Banner-Kommentaren (/* === hero === */). Kontext-Regeln setzen Custom Properties, kein Layout. Wenn eine Komponente in einem Kontext anders aussehen soll, gehört auf .kontext .komponente nur die Variable (--crest-size, --card-gap, …); das Layout der Reihe/Fläche gehört auf den Kontext selbst. Custom Properties erben, die Komponente bleibt dumm. Gegenbeispiel, das dreimal passiert ist: .stadionzeitung-ergebnisse__row .crest { display: grid; grid-template-columns: … } machte das Wappen zum Grid statt die Zeile — Wappen lag als Block auf eigener Zeile, Vereinsname und Tore rutschten darunter zusammen. Faustregel: steht in einer … .crest-Regel mehr als --crest-size, ist der Selektor falsch.
  • 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, align: 'center'). Alle Unterseiten-Heros sind einheitlich „Überschrift + Teaser" — nur title + text, dazu compact: true und align: 'center' (Standard seit 27.07.2026 — vorher 'bottom-left', testweise auf allen Verein-Unterseiten umgestellt, dann projektweit übernommen). 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. align: 'bottom-left' bleibt als Variante im hero-Component erhalten, ist aber für neue Unterseiten nicht mehr die Konvention.
  • Termin-Aufmacher im Startseiten-Hero: liegen Termine an, läuft der Hero zweispaltig (hero--duo) — links Titel/Text/CTAs, rechts die Glas-Karte hero-upcoming mit den nächsten drei Terminen, die assets/js/carousel.js alle 5 s überblendet (Dots mit Fortschrittsring + Pause-Taste in der Karte). Quelle ist upcoming_highlights(3) — Spiele und Vereins-Events chronologisch gemischt, aufgebaut auf termine_build_lists(), damit es weiterhin nur eine Merge-Logik für Termine gibt. Zwei Innenvarianten in einer Karte: match-feature (Spiel, mit Wappen) bzw. event-feature (Termin, mit Datums-Chip). Ohne Termine (Sommerpause) bleibt es beim zentrierten Hero ohne Karte, ohne JS ist nur der nächste Termin sichtbar und verlinkt. Beschriftung: nur der erste Slide sagt „nächstes" („Nächstes Spiel · 1. Mannschaft", danach „Spiel · 1. Mannschaft" / „Termin · Verein").
  • 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).
  • Varianten statt Zweitformular: das Kontaktformular hat zwei Ausprägungen, gesteuert vom Hidden-Feld department ('' = allgemein, 'turnen' = Turnabteilung auf /turnen). contact_variant() (helpers.php) ist die einzige Quelle für Auswahlliste, Feldregeln (Betreff-Feld, Geburtsdatum-Feld, Pflichtfelder) und Empfänger; Komponente und Action lesen beide dort. Ein variantenspezifisches Feld braucht drei Stellen: den Schalter in contact_variant(), die bedingte Ausgabe in contact-form.php und in der Action beides — Validierung und ein Zurücksetzen auf leer, wenn die Variante das Feld nicht anbietet (sonst könnte ein manipulierter POST es an einer Variante vorbei einschmuggeln). Ein unbekannter Wert fällt auf die allgemeine Variante zurück, ein manipuliertes Feld erreicht also keinen fremden Empfänger. Die Turn-Variante geht an club.email_turnen, hat die Disziplinen aus data/turnen.json als Auswahl (Pflicht, ersetzt den Betreff) und eine freiwillige Nachricht. Braucht eine Abteilung ein eigenes Formular, wird es eine weitere Variante — kein zweites Formular und keine zweite Action, sonst wäre der komplette Spam-Schutz doppelt zu pflegen.
  • 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).
  • Autofill-Fix in components.css (bei :-webkit-autofill/:autofill): Chrome/Safari rendern automatisch ausgefüllte Felder mit einer eigenen hellen Füllfläche + schwarzer Schrift, die normales background/color nicht übersteuert — nur ein deckender box-shadow inset mit --clr-field-bg-solid (opake Variante von --clr-field-bg in tokens.css, da der Shadow keine Transparenz durchlässt) + -webkit-text-fill-color. Die absurd lange transition-Dauer ist bewusst roh (kein --transition-Token) — sie verhindert, dass Chrome die native Füllung beim periodischen Neuanwenden aufblitzen lässt.
  • 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.
  • 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 vier Formulare einzeln.

Logging

  • Geschrieben wird ausschließlich über log_write($kanal, $level, $meldung) (helpers.php) — Web wie CLI. Kein error_log(), kein file_put_contents() auf eine Logdatei, keine eigene Log-Closure in einem neuen Skript.
  • Kanal = Dateiname unter storage/logs/: mail · spam · instagram · matchcenter · stadionzeitung · veranstaltungen · chronik · filesgallery · logrotate (letzterer nur für den Übersprungen-Hinweis des Cron-Endpoints, der seinen Kanal aus dem Job-Namen bildet) (php-errors.log schreibt PHP selbst, eigenes Format).
  • Level mit klarer Bedeutung: INFO Normalbetrieb (auch ein geblockter Bot — der Schutz arbeitet, das ist kein Fehler) · WARN Auffälligkeit, Betrieb läuft weiter (Einzelbild nicht ladbar, Lock belegt, Cache-Fallback) · ERROR Funktion ausgefallen (Mailversand, Sync-Abbruch, unvollständige Config).
  • Format [ISO-Zeit] LEVEL Meldung, ein Ereignis = eine Zeile (Umbrüche werden ersetzt). Debug-Dumps gehören auf stdout hinter ein Flag, nicht ins Betriebslog.
  • ERROR läuft zusätzlich in error.log — die eine Datei, die „ist gerade etwas kaputt?" beantwortet. Die Kanal-Logs behalten den vollen Verlauf.
  • Nie hineinschreiben: Formularinhalte, Klartext-IPs, Mailadressen, Secrets. IP nur als gesalzener HMAC-Kurzhash (log_spam()). Fehlerdetails nie an Besucher ausgeben.
  • Betrieb: php bin/logs.php (Übersicht: Fehler der letzten 7 Tage, je Kanal Größe/Alter/ Level-Zähler, letzte Zeilen; Exit 1 wenn Fehler vorliegen) · php bin/log-rotate.php per Cron (>2 MB → .log.1, Einträge älter als 90 Tage werden entfernt).

Sprache & Ton (gilt für jeden sichtbaren Text)

Betrifft alle data/*.json, Texte in app/pages/*.php und Alt-Texte. Zwei Vorgaben von Felix (27.07.2026), beide nicht verhandelbar:

  1. Keine Gedankenstriche. Kein als Satzzeichen. Was mit Gedankenstrich klingt, wird ein eigener Satz, ein Komma oder ein Doppelpunkt. Auch optionale Binde­striche in Komposita auflösen: „Zeit in der Kreisliga" statt „Kreisliga-Zeit", „Derby in der Relegation" statt „Relegations-Derby", „unser bester Torjäger" statt „Top-Torjäger". Ausnahmen sind nur orthografisch zwingende Bindestriche: Eigennamen (Hans-Rausch-Sportanlage, Eltern-Kind-Turnen, Step-Aerobic), Liganamen (B-Klasse) und feste Schreibweisen (E-Mail, Co-Trainer).
  2. Wir-Form, nicht die dritte Person. Der Verein spricht als „wir"/„unser", nicht als „der TSV 08 Kulmbach". Also „…aus der wir aufgestiegen waren", nicht „…aus der der TSV 08 aufgestiegen war". Gilt auch für Alt-Texte („Unsere Frauenmannschaft feiert…"). Ausnahmen, bei denen der Vereinsname ausgeschrieben bleibt: FAQ-Fragen (die stellt der Besucher: „Welche Abteilungen hat der TSV 08 Kulmbach?"), /partner-werden (spricht Firmen mit „Sie" an), juristische Stellen (Impressum, Datenschutz, club.legal_name) und der Titel-Suffix „| TSV 08 Kulmbach", den layout.php anhängt.

Umgesetzt: /historie komplett (data/historie.json, $meta in app/pages/historie.php) sowie club.description (steht als Footer-Claim und als Organization-Beschreibung im JSON-LD auf jeder Seite) und das Brand-aria-label in app/components/header.php. Die übrigen data/*.json sind noch nicht durchgezogen.

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. base.css setzt das durch: h1h3 tragen Coolvetica, h4h6 laufen über --ff-body + uppercase + --ls-caps.
  • Coolvetica unter ~40px braucht letter-spacing: var(--ls-heading) (0.012em, also 0,4px bei 34px). Der Schnitt ist condensed: bei 60px (Abschnitts-h2) trägt er ohne Sperrung, bei 34px in einer schmalen Spalte kleben die Versalien und der Titel wird schwer lesbar (aufgefallen an den Beitragskarten, 31.07.2026). Nicht --ls-caps (0.08em) nehmen — das ist für kleine Labels gedacht und lässt eine Überschrift auseinandergezogen wirken. Die globalen h1h3 stehen bewusst auf letter-spacing: normal, gesperrt wird pro Komponente.
  • Rot nie für Fließtext auf dunklem Grund (Kontrast!) — nur als Button-/Akzent-Fläche mit weißem Text. Gemessene Zahl dazu: #e20612 auf der Glas-Karte (rgba(0 0 0 / 0.22) über #222) erreicht 3,50:1 und verfehlt die 4,5:1 für Fließtext; Weiß liegt bei 17,22:1. Genau daran sind die Beitragspreise hängengeblieben, die zuerst rot waren. Hervorhebung über Schriftart und Größe (Coolvetica 28px gegen 21px Fließtext) trägt ohne Farbe.
  • Hervorgehobene Hinweise laufen über component('notice') (app/components/notice.php, Klassen .notice/.notice--info/.notice--stop), nie als eigenes Markup. Die Tonlage trägt Bedeutung und ist keine Geschmacksfrage: --stop (gefüllt rot) heißt „geht nicht" (Wartelisten-Stopp der Turnabteilung), --info (Glas-Fläche, Rot nur im Icon) heißt „geht" (neue Kinder dürfen zum Schnuppern kommen). Eine Einladung in Alarmrot liest sich wie eine Absage, und wenn alles rot ist, stumpft Rot ab und der echte Stopp verliert seine Wirkung. --info ist der Default, Rot die Ausnahme. Der Text und die Tonlage stehen in der data/*.json der Seite (notice, notice_title, notice_tone, notice_icon), nie im Template; eingebettet wird über eine Zusatzklasse, die nur Position bzw. Breite setzt (.turnen-dash__notice = grid-area, .contact__notice = Formularbreite). Eine Überschrift ist Pflicht, wenn der Hinweis rot ist: ohne sie liest sich die rote Fläche wie eine Fehlermeldung.
  • Alle Werte als CSS Custom Properties in tokens.css; Breakpoints: 1249 (Burger) / 1024 / 992 / 768 / 600 / 500.

Token-Disziplin (gilt für jede CSS-Änderung)

Keine rohen Werte in base/layout/components/utilities.css — es gibt für alles eine Stufe:

Was Token Anmerkung
padding / margin / gap --space-4xs … --space-7xl (14 Stufen, 4px-Raster) --space-4xs (2px) nur für optische Korrekturen (Icon-/Baseline-Nudges), nie fürs Layout
Innenabstand von Karten-Flächen --pad-card (20…32px) / --pad-card-lg (24…40px) Keine festen Werte. Auf einem 309px-Handy stapeln sich Container- und Kartenabstand sonst so, dass dem Eingabefeld nur 71% der Bildschirmbreite bleiben. Die Obergrenzen entsprechen den früheren festen Werten, am Desktop ändert sich dadurch nichts. Ein harter Breakpoint (etwa „ab 310px ohne Abstand") wäre die Alternative, sieht aber bei 311px wieder falsch aus
font-size (Text) --fs-300 … --fs-1000 --fs-550/--fs-690 sind die fluiden Zwischenstufen (Lead, Karten-Überschrift)
font-size (Icons) --icon-s/m/l/xl (rem), --icon-inline-sm/--icon-inline/--icon-inline-lg (em) em-Stufen für Icons, die mit ihrer Textzeile mitwachsen sollen
letter-spacing --ls-tight/caps/wide/wider
Fokus --focus-ring / --focus-offset
Trefferfläche --tap-min (44px)

Ausnahmen, bewusst roh: em-Werte, die mit der font-size skalieren sollen (Button-Padding --btn-pad-*, Badge-Innenabstände) — die gehören nicht auf ein festes px-Raster.

Buttons & Trefferflächen

  • Varianten in utilities.css: .btn (primary) · .btn--outline · .btn--ghost (tertiär, ohne Fläche) · .btn--icon (quadratisch, nur Icon + aria-label) · .btn--sm / .btn--lg. .btn ist inline-flex mit gap — Icon + Label brauchen kein Zusatz-Markup und keinen eigenen Abstand.
  • Tabs (Filterleiste wie Panel-Tabs) laufen über die gemeinsame .tab-Basis in components.css; aktiv wird über aria-pressed oder aria-selected erkannt. Nie eine dritte Tab-Optik bauen.
  • Jedes Control ≥ --tap-min (44px, WCAG 2.5.5). Soll es optisch kleiner bleiben, streckt .tap-target die Trefferfläche unsichtbar per ::after (bei .btn--sm automatisch). Voraussetzung: kein overflow: hidden, ::after frei. In Reihen (Nav, Footer) stattdessen min-height verwenden — überlappende Pseudo-Flächen würden sonst den Nachbarn verdecken.
  • Ausnahme: die Formular-Checkbox bleibt bei 24px (WCAG 2.5.8). <input> ist ein Replaced Element, ::after greift dort nicht zuverlässig; das klickbare <label> liefert die große Fläche.
  • Komponenten-Deltas zu .btn brauchen (0,2,0) — z. B. .btn.ad-banner__nav-btn. utilities.css lädt nach components.css, bei gleicher Spezifität gewinnt sonst die .btn-Basis.

Fokus

base.css setzt eine globale :focus-visible-Regel mit --focus-ring (2px weiß, 15,9:1 auf --clr-bg). Komponenten überschreiben sie nur mit Begründung — zulässig sind ein negativer outline-offset (Ring soll inset liegen, z. B. .tab) und zusätzliche Effekte ohne eigene outline. Nie outline: none und nie Akzentrot als alleiniger Indikator: Rot erreicht auf der Feldfläche nur 2,8:1 und verfehlt WCAG 1.4.11 (≥3:1).

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)
  • Keine rohen Werte: Spacing, font-size, letter-spacing, Fokus und Trefferflächen über die Token aus tokens.css (siehe „Token-Disziplin"). Interaktive Elemente ≥ --tap-min
  • 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

Cron-Endpoint public/cron.php (Ausnahme 3b) — der Takt-Geber

Die KAS-Cronjobs können ausschließlich URLs aufrufen, keine Shell-Befehle (geprüft 31.07.2026: das Anlege-Formular hat nur ein Feld „Protokoll / Pfad" mit https://). Der Tarif hat inzwischen SSH und Cronjobs, aber ein php bin/… ist dort nicht eintragbar. Deshalb ruft der KAS-Cron diesen Endpoint auf, und er startet das Skript serverseitig. Der Ablauf steht in docs/deploy.md, Teil D, die drei Zeitpunkte in config/config.example.php.

Das ist besser als der ursprünglich geplante externe Cron-Dienst: der Aufrufer ist der Hoster selbst, der Schlüssel verlässt den Webspace nie, es ist überhaupt kein Dritter beteiligt. cron.key muss also gesetzt sein — steht er leer, läuft kein Sync mehr.

Ein externer Cron-Dienst ruft, falls je nötig, dieselben URLs auf: https://…/cron.php?job=matchcenter · ?job=instagram · ?job=logrotate. Schlüssel aus config('cron.key'), per Header X-Cron-Key oder als key-Parameter (im KAS notgedrungen als Parameter, das Formular kennt keine Header).

Warum es drei Jobs sind: logrotate kam am 31.07.2026 dazu, weil dieser Endpoint der einzige Weg ist, überhaupt etwas getaktet laufen zu lassen. Ohne ihn liefe die Logpflege nie automatisch und die 2-MB-Grenze samt 90-Tage-Frist wäre toter Code. Der Job passt in denselben Rahmen wie die Syncs: kein Parameter aus der URL, keine Verbindung nach außen, er schreibt nur in storage/logs. Eine vierte Ausnahme braucht wieder denselben Aufwand an Begründung. bin/log-rotate.php trägt dafür denselben Riegel wie die Syncs (PHP_SAPI !== 'cli' && !defined('CRON_HTTP')) und require_once.

Warum dieser Weg und nicht GitHub Actions mit FTP-Upload: dort müsste ein Dritter Schreibzugang zum ganzen Webspace kennen. Hier kennt er einen Schlüssel, der ausschließlich „starte einen der beiden Syncs" kann. Kleinerer Radius bei einem Leak, und es braucht kein Git-Remote und keine Upload-Logik für Bilder. Besucher-getriggerter Sync wurde verworfen (siehe „nie im Request-Pfad"): ohne Besucher keine Aktualisierung, und Fire-and-Forget ist auf Shared Hosting unzuverlässig.

Die Grenzen sind der Punkt — keine davon entfernen:

  • Job-Whitelist im Endpoint, nie ein Skriptname aus der URL.
  • Not-Aus wie in den Actions: leerer Schlüssel, CHANGE_ME oder < 24 Zeichen → 403. Ohne das wäre der Endpoint auf config.example.php offen, deren Werte im Repo stehen.
  • cron.min_interval (Default 240 s, Marker unter storage/cache/cron-<job>.last). Das eigentliche Risiko eines geleakten Links ist nicht Datenverlust — die Syncs schreiben atomar und behalten bei Fehlern den letzten guten Cache — sondern dass BFV oder Instagram unsere Server-IP sperren. Der Marker wird bei jedem angenommenen Versuch gesetzt, nicht erst bei Erfolg.
  • Antwort immer leer. 204 = gelaufen oder wegen Mindestabstand übersprungen, 403 = Schlüssel/Job falsch. Der 403 ist Absicht statt eines stillen 204: ein falsch eingerichteter Cron-Dienst fällt sonst wochenlang nicht auf, weil die Seite mit dem alten Cache normal weiterläuft.
  • ob_start() + Shutdown-Handler verwerfen jede Skriptausgabe — die Syncs beenden sich per exit(), ohne das könnten Debug-Zeilen oder PHP-Warnungen im HTTP-Body landen.

Zwei Eingriffe in den Sync-Skripten, beide minimal: der CLI-Riegel lautet jetzt PHP_SAPI !== 'cli' && !defined('CRON_HTTP'), und der Bootstrap-Include ist require_onceapp/bootstrap.php definiert Konstanten und lädt helpers.php, ein zweiter Durchlauf wäre ein Fatal. $argv wird im Endpoint gesetzt, weil es im Web-SAPI nicht existiert.

bin/preflight.php prüft Schlüssellänge, Verwechslung mit app_secret und ob min_interval den gewünschten Takt sperrt. Der damals hier notierte Ausstieg („wenn der Tarif je Cronjobs bekommt, cron.key leeren") ist am 31.07.2026 genau so eingetreten und vollzogen.

Offen: ob der Instagram-Scraper von der Server-IP überhaupt durchkommt. Instagram sperrt Rechenzentren; hier lief er nur über einen privaten Anschluss. Einmal auf dem Server prüfen. Falls er dort blockt, hilft auch GitHub Actions nicht (noch strenger gesperrt) — dann bleibt ein Lauf von einem privaten Anschluss oder der „Aktuelles"-Block wird redaktionell gepflegt.

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). Im Band selbst kein Slider — der generische assets/js/carousel.js treibt nur den Banner-Slider und den Termin-Aufmacher im Startseiten-Hero (hero-upcoming, s. Hero-Regel), der match-feature als eine seiner beiden Innenvarianten nutzt. Besucher laden alle Matchcenter-Inhalte ausschließlich von unserer Domain.

Stadionzeitung (Ausgaben-Archiv)

Digitale Fassung der Stadionzeitung zu jedem Heimspiel — Archiv-Übersicht (/stadionzeitung) als Liste über die volle Breite, Cover links und die Angaben rechts daneben (dasselbe Muster wie .veranstaltung-card, und aus demselben Grund: der Bestand ist klein). Vorher war das ein horizontales Regal mit 280px-Karten; bei einer veröffentlichten Ausgabe stand die Karte allein links auf einer 1280px breiten Seite (geändert 31.07.2026). Der Titel der Zeile lautet „Stadionzeitung zum Heimspiel gegen " (nicht nur „Gegen "): so ist die Zeile für sich verständlich, auch als Link-Text für Screenreader. Als Teaser darunter steht die Coverzeile des Interviews (interview.teaser, dieselbe Quelle wie „Im Heft" auf dem Cover) — ohne Interview entfällt sie. Pro Ausgabe ein Swipe-Viewer (/stadionzeitung/<ausgabe-slug>, dynamische Route, siehe „Architektur-Muster"). Bewusst kein PDF auf der Website — die Ausgabe entsteht ohnehin in Canva, jede Seite wird dort direkt als Bild (PNG/JPG) statt als PDF exportiert. Ein PDF-Export der Muster-Datei war ~80 MB (hochauflösende Druck-Scans) und hätte serverseitig Ghostscript/Imagick gebraucht, wofür es sonst nirgends im Projekt einen Präzedenzfall gibt (nur GD, siehe bin/img-resize.php) — mit Bildern aus Canva entfällt das komplett.

Workflow (Chat-Arbeit, kein Cron): Ordner mit den Seiten-Bildern von Felix bekommen, erwarteter Inhalt:

<ordner>/vorwort.*           optional, Fallback-Bild — nur genutzt, solange für diese Ausgabe
                              noch kein Vorwort-Text hinterlegt ist (siehe unten)
<ordner>/rueckseite.*        optional
<ordner>/spieler.*           optional, freigestelltes Spielerfoto (PNG mit Transparenz) fürs
                              automatische Cover — wird auf den Bildinhalt zugeschnitten
                              (crop_transparent_png_to_content(), Alpha-Bounding-Box + kleiner
                              Rand: Freistellungs-Exporte lassen oft viel leeren, transparenten
                              Rand stehen, sonst wirkt der Spieler auf dem Cover kleiner als nötig)
<ordner>/verfasser.*         optional, Porträtfoto für die Signatur im automatischen Vorwort

Keine Anzeigen im Ablieferordner (seit 31.07.2026): die kommen aus dem dauerhaften Bestand data/anzeigen.json, gepflegt mit php bin/anzeige-add.php <bild-oder-ordner> (Dateiname = Slug, eldorado.jpgimg/anzeigen/eldorado). Ein noch vorhandener anzeigen/-Unterordner wird ignoriert, das Skript sagt es aber. Vorher lag jede Anzeige als Kopie unter jeder Ausgabe (19 × 3 Breiten pro Heft) und ihr Alt-Text musste jedes Mal neu geschrieben werden; er steht jetzt einmal beim Sponsor. Kein cover.* mehr — die Titelseite wird automatisch generiert (siehe unten). Dann php bin/stadionzeitung-add.php <ordner> "<Gegner>" <YYYY-MM-DD> <team-key> [slug]<team-key> (z. B. erste-mannschaft, damen, siehe data/matchcenter.json) bestimmt, welche Mannschaft die Ausgabe „trägt" (Cover-Team/Liga, Termine-Bezug) — Tabellen bekommen seit der Mehr-Tabellen-Vorlage ALLE Mannschaften aus matchcenter.json, je eine Seite. Das Skript erzeugt responsive Breiten je Seite (resize_image_variants(), dieselbe Logik wie bin/img-resize.php) und schreibt data/stadionzeitung.json atomar. Erneuter Aufruf mit demselben Slug ersetzt die Ausgabe vollständig (Korrekturen). Kein Original-PDF wird öffentlich angeboten, kein Download-Link — nur die Web-Ansicht. Der Ablauf pro Heimspiel ist damit: anlegen (Entwurf) → gemeinsam prüfen (direkte URL, Live-Daten) → php bin/stadionzeitung-publish.php <slug> (friert ein, macht sichtbar) → Druckfassung ?druck=1 drucken → nie mehr anfassen.

Feste Vorlage (seit Interview 29.07.2026, Anzeigen-Verteilung überarbeitet 31.07.2026) — der Inhalt ist eine Liste von Blöcken, die Anzeigen füllen die Lücken dazwischen (⌷ = Lücke):

Cover → [Vorwort → Interview (nur wenn die Ausgabe eins trägt)] ⌷ Ergebnis-Rückblick ⌷
  [je Mannschaft eine Tabelle, Reihenfolge = matchcenter.json — EIN Block, dazwischen nie eine
  Anzeige; Erste + Zweite bilden im gefalteten Heft eine Doppelseite] ⌷ Vorschau ⌷ Termine ⌷
  News ⌷ Ansprechpartner ⌷ Mitglied-werden-Poster ⌷ Historie-Teaser (Website-Verweis +
  Material-Aufruf an club.email_historie) ⌷ Sportheim-Seite (Vermietungs-CTA im Stil der alten
  Canva-Seite) ⌷ Partner-werden-Seite (Anzeigen-Akquise im eigenen Heft, Sie-Form) ⌷
  Momente-Seite (2×2-Galerie der neuesten Instagram-Fotos + Folge-uns-QR, Snapshot beim
  Veröffentlichen) ⌷ Impressum (aus club.json) → Rückseite

Alle Seiten außer Rückseite und Vorwort-Fallback sind Live-Seiten. Nach dem Cover, im Aufschlag und vor der Rückseite steht keine Lücke (Begründung unten). Doppelseiten-Regel fürs Falt-Heft: aufgeschlagen liegen die Seiten (2,3), (4,5), … nebeneinander (gerade Seite links). Die erste Tabellenseite muss deshalb auf einer GERADEN Seitenzahl liegen, damit die Tabellen nebeneinander stehen. Das rechnet das Skript selbst (seit 31.07.2026): es verschiebt dafür eine Anzeige zwischen einer Lücke vor und einer nach dem Tabellen-Block und protokolliert es. Früher war das Handarbeit („notfalls eine Anzeige verschieben") und wurde vergessen. Bei ungerader Tabellenzahl (drei Mannschaften) bilden Erste und Zweite die Doppelseite, die dritte steht neben der Folgeseite — paarweise geht bei drei Tabellen arithmetisch nicht.

Anzeigen-Verteilung (Beschluss 31.07.2026): Der Inhalt ist eine Liste von Blöcken, die Anzeigen werden gleichmäßig in die Lücken dazwischen verteilt, nie mehr als zwei hintereinander (vorher: vier Blöcke à 5, das las sich wie „Heft zu Ende"). Verteilt wird gestreut, nicht vorne gebündelt. Drei Zonen bleiben anzeigenfrei: nach dem Cover und im Aufschlag 2/3 (Vorwort + Interview sind EIN Block), zwischen zwei Tabellenseiten (sonst zerreißt die Doppelseite) und zwischen Impressum und Rückseite. Reichen die Lücken nicht (mehr als 2 × Lücken Anzeigen), warnt das Skript statt still Dreier-Blöcke zu bauen. Ehrliche Grenze: „Inhalt, Anzeige, Inhalt" im Wechsel geht nicht auf — bei 19 Anzeigen und 12 Lücken bekommen 7 Lücken zwei. Zwei hintereinander ist das Optimum, nicht eins. Und weil ein Zweier-Paar auf einer Doppelseite landen kann, schlägt man das Heft gelegentlich auf zwei Anzeigen auf. Ein images[]-Eintrag hat "type": "image" (wie gehabt, base/widths/alt) oder "type": "live" (page: cover | vorwort | interview | tabelle | termine | news | ergebnisse | vorschau | kontakt | mitglied | historie | sportheim | partner | momente | impressum, kein Bild; tabelle zusätzlich mit team: Team-Key aus matchcenter.json, ohne team greift der Team-Key der Ausgabe). Fehlt type (alte Phase-1-Ausgaben) → Fallback image, bricht nicht. Gerendert wird jede Seite (Bild wie Live) über app/components/stadionzeitung-seite.php — die eine gemeinsame Render-Stelle für Viewer und Druckfassung, nie zwei Implementierungen derselben Seite.

Entwurf / Veröffentlicht (status-Feld): bin/stadionzeitung-add.php legt jede Ausgabe als entwurf an — unsichtbar in Archiv und Sitemap, noindex, aber unter ihrer direkten URL erreichbar (zum Gegenlesen ohne Login-System; der Ausgaben-Kopf zeigt einen „Entwurf"-Badge). Im Entwurf rechnen die Live-Seiten (Tabellen/News/Ergebnisse/Vorschau) bei jedem Aufruf frisch aus denselben Quellen wie /fussball//news/Matchcenter. php bin/stadionzeitung-publish.php <slug> friert genau diesen Stand als snapshot-Feld ein (stadionzeitung_build_snapshot(), dieselbe Berechnung wie die Entwurfs-Vorschau — stadionzeitung_live_data() in helpers.php entscheidet snapshot vs. live) und setzt status: veroeffentlicht. Ab dann ändert sich die Ausgabe nie mehr („gedruckt = fixiert"); sinnvoller Zeitpunkt: Spieltagmorgen nach dem letzten Matchcenter-Sync — auf dem Server, dort liegt der frische Sync-Stand (data/matchcenter.json ist gitignored, lokal also älter). --entwurf zieht zurück (Snapshot wird verworfen).

Mit dem Snapshot werden auch die BILDER eingefroren (seit 31.07.2026): das Skript kopiert alles, worauf er zeigt (img/crests/, img/instagram/, img/news/), als fix-* in den Ausgaben-Ordner und schreibt die Pfade um. Grund: vorher fror nur die Daten ein, die Pfade zeigten weiter in Ordner, die die Sync-Skripte verwalten — matchcenter-sync räumt Wappen weg, die der BFV nicht mehr führt (eine Saison), instagram-sync ältere Posts (Wochen). Am 31.07.2026 nachgewiesen: ein Lauf löschte 18 Wappen und 3 Bilder. Eine archivierte Ausgabe wäre also von selbst zerfallen. img/news ist mit dabei, weil ein Artikelbild im Chat ersetzt werden kann. Varianten-Sets ({base, widths}) werden über alle Breiten kopiert und nur umgeschrieben, wenn alle da sind — ein halbes srcset wäre schlimmer als der alte Pfad. Fehlt eine Quelle, bleibt der Pfad stehen und es gibt eine WARN (die crest-Komponente fällt auf Initialen zurück; ein fehlendes Bild darf das Veröffentlichen nicht verhindern). Flache fix-*-Dateinamen statt eines Unterordners, weil die Aufräum-Logik in stadionzeitung-add.php nur Dateien entfernt und ein Unterordner als Waise bliebe. --entwurf löscht sie wieder, sie gehören zum Snapshot. bin/preflight.php warnt, wenn eine veröffentlichte Ausgabe noch auf img/crests oder img/instagram zeigt — Reparatur: --entwurf, dann neu veröffentlichen. Ein erneuter stadionzeitung-add.php-Lauf setzt eine veröffentlichte Ausgabe zurück auf Entwurf (Seiten ersetzt = Stand muss neu geprüft und veröffentlicht werden). Ausgaben ohne status-Feld gelten als veröffentlicht (Bestandsschutz). Die Kontakt-Seite ist bewusst NICHT im Snapshot: sie liest immer data/ansprechpartner.json (ein Snapshot würde nur veraltete Nummern konservieren).

Termine (automatisch generiert, eingefroren): schlichte weiße Live-Seite wie Tabelle/News, im selben Aufbau wie das Vorwort (Kicker + Überschrift über einblendendem Vereinsfoto, hier oben statt unten) — app/components/stadionzeitung-termine.php. Zeigt die fünf zum Spieltag nächsten Termine (Spiele + Vereins-Events gemischt, termine_build_lists()), aber bewusst kein Live-Abruf: bin/stadionzeitung-add.php ruft termine_build_lists($date) mit dem Spieltag als Bezugsdatum auf und schreibt das Ergebnis einmalig ins termine_snapshot-Feld der Ausgabe. Eine gedruckte Publikation zeigt dauerhaft, was am Spieltag anstand — nicht, was von „heute" aus gesehen (dem Tag, an dem jemand die alte Ausgabe später aufruft) ansteht. termine_build_lists() akzeptiert dafür ein optionales $asOf-Datum (Default: leer = echtes Heute, für /termine unverändert); gesetzt filtert es zusätzlich $mc['upcoming'] auf Spiele ab diesem Datum, weil die Matchcenter-Daten selbst nur den aktuellen Sync-Stand kennen. Icon-Liste (rundes Badge, Akzentfarbe getönt, calendar-event.svg) statt des Datums-Chips von /termine — bewusst andere Optik für die Stadionzeitung, gleiche Datumshelfer (german_date_range()) und dieselbe Merge-Logik dahinter.

Vorwort (automatisch generiert, sobald Text vorliegt): schlichte weiße Live-Seite wie Tabelle/Termine/News — app/components/stadionzeitung-vorwort.php. Der Text selbst ist kein Skript-Input: er wird wie data/events.json/news.json im Chat gepflegt — Felix schickt Überschrift/Absätze/Grußformel/Unterzeichner, Claude trägt sie manuell ins vorwort-Feld der Ausgabe in data/stadionzeitung.json ein (heading, paragraphs[], closing, signee_name, signee_role). Damit ein erneuter bin/stadionzeitung-add.php-Lauf (z. B. um eine Anzeige zu korrigieren) dieses Feld nicht überschreibt, liest das Skript den bisherigen Wert vor dem Ersetzen aus und schreibt ihn unverändert zurück. Solange noch kein vorwort-Feld existiert, rendert die Ausgabe stattdessen das gelieferte vorwort.*-Bild (Fallback, wie bisher). Porträtfoto optional über verfasser.* (normales Foto, kein Alpha — anders als spieler.* einfach per resize_image_variants() verkleinert), Pfad landet script-verwaltet im verfasser_photo-Feld. Zusatzschriften nur hier (Stadionzeitung, sonst nirgends): neben Coolvetica/Abel gibt es genau zwei weitere Schnitte, beide OFL und self-hosted — --ff-serif (PT Serif) für redaktionellen Fließtext (Vorwort, Interview-Antworten, Cover-Teaser) und --ff-signature (Mr Dafoe) ausschließlich für die Unterschrift des Vorwort-Unterzeichners (Signatur-Optik wie die Canva-Vorlage; keine Versalien, kein letter-spacing — das zerreißt die verbundenen Buchstaben). Dafont-Schriften vorher immer auf die Lizenz prüfen: „Free for personal use" reicht für die Stadionzeitung NICHT (öffentliche Vereinspublikation mit Sponsorenanzeigen + Webfont) — deshalb wurde die gewünschte dafont-„Signature" durch das OFL-Pendant Mr Dafoe ersetzt (Felix' Wahl aus sechs Kandidaten, 29.07.2026). Fließtext läuft über --ff-serif (PT Serif, public/assets/fonts/pt-serif.woff2 + pt-serif-italic.woff2, self-hosted wie Coolvetica/Abel, OFL-Lizenz) statt --ff-body — ein Vorwort ist ein redaktioneller Brief, keine UI-Fläche, eine Lese-Serif passt hier besser. Die Überschrift bleibt bewusst nicht in Versalien (anders als der globale h1h6-Standard aus base.css) — dafür lokal text-transform: none auf .stadionzeitung-vorwort__heading.

Interview (chat-gepflegt wie das Vorwort): die Seite hinter dem Vorwort, auf die die Teaser-Zeile des Covers verweist („Im Heft: Unser Trainer … über die Neuzugänge") — app/components/stadionzeitung-interview.php. Der Inhalt ist KEIN Skript-Input: Felix schickt Fragen/Antworten, Claude trägt sie ins interview-Feld der Ausgabe ein (teaser = Coverzeile, heading, person_name, person_role, intro?, fragen[] mit frage/antwort, photo?). bin/stadionzeitung-add.php trägt das Feld über erneute Läufe weiter (wie vorwort) und plant die Live-Seite nur ein, wenn das Feld existiert — Ausgaben ohne Interview haben weder Seite noch Teaser-Zeile. Fragen in Abel fett, Antworten in --ff-serif (dieselbe Begründung wie beim Vorwort).

Cover (automatisch generiert, Phase 3): einzige fotografische Stadionzeitungs-Seite — app/components/stadionzeitung-cover.php. Echtes Magazin-Cover statt Website-Baustein: kein Stadionfoto mehr als Hintergrund, stattdessen ein ruhiger Verlauf in Vereinsfarben, auf dem das freigestellte Spielerfoto der Star ist (großformatig, randabfallend unten-mittig — ohne geliefertes Foto trägt der Verlauf allein die Seite, kein leeres Loch). Riesiger Zeitschriften-Masthead „Stadionzeitung" + Vereinslogo oben, darunter die Begegnung als große freie Typografie direkt auf dem Verlauf (kein Kasten/keine Karte — Wappen beider Teams, Vereinsnamen, Team/Liga/Datum), Fußzeile mit Slogan (data/club.jsonslogan) + QR-Code zur digitalen Ausgabe (qr_image) + Linktext. Wappen: eigenes + Gegner über einen Anstoß-Tag-Abgleich in data/matchcenter.json next_matches/last_results — kein Treffer (z. B. bei einer BFV-fremden Spielgemeinschaft) → Initialen-Fallback der crest-Komponente greift automatisch. Beide Felder (player_image/qr_image) sind pro Ausgabe optional in data/stadionzeitung.json und werden von bin/stadionzeitung-add.php gesetzt (QR immer, Spielerfoto nur wenn spieler.* geliefert wurde).

Cover-Kopf (Front-Matter-Anmutung): kleine rote Kicker-Zeile ganz oben (Saison + Ausgabedatum, gesperrte Versalien) darüber, dann Vereinslogo und der riesige Masthead „Stadionzeitung" auf gleicher Höhe nebeneinander (Lockup, nicht gestapelt), darunter eine dezente kursive Tagline. Vereins-Hero-Bild (data/home.json, dieselbe Quelle wie die Startseite) als Hintergrund mit Abdunklungs-Verlauf, das freigestellte Spielerfoto sitzt sehr großformatig rechts randabfallend und reicht fast bis auf Masthead-Höhe. Unten links gestapelt statt zentriert: Begegnung (Wappen + Vereinsnamen + Team/Liga), darunter optional die Teaser-Zeile „Im Heft" (Serif kursiv mit rotem Balken, erscheint automatisch bei gesetztem interview-Feld), dann minimaler QR (ohne Vereins-Slogan und ohne Trennzeichen zwischen den Wappen — beide 29.07.2026 bewusst entfernt, ruhigeres Cover) (kleiner Code + eine Zeile — die frühere QR-Plakette und die rote Trennlinie sind bewusst raus, Felix 29.07.2026: ruhiger, aufgeräumter). Alles läuft in einer schmalen linken Spalte, damit es dem rechts randabfallenden Spielerfoto nie in die Quere kommt (zweispaltiges Cover-Layout statt Überlappung). Vereinsfarbe Rot bewusst als wiederkehrendes Gestaltungsmittel (Kicker, Teaser-Balken), nicht nur im Logo. Dünne Passepartout-Rahmenlinie + Passermarken (Schnittmarken) in den vier Ecken geben den „druckfertig"-Effekt eines echten Zeitschriftencovers.

Selbst-Containment (ALLE Stadionzeitungs-Seiten, seit 29.07.2026): alle Innenmaße der Live-Seiten laufen über CSS Container Queries — .stadionzeitung-page ist der Container (container-type: inline-size), Schriftgrößen/Abstände in cqi (Referenz: 480px Seitenbreite, 1cqi = 4,8px), NICHT über die Website-Schriftgrößen-Tokens aus tokens.css. Eine Seite sieht dadurch in jedem Kontext (Karten-Vorschau ~280px, Swipe-Viewer ~480px, Druck-Blattvorschau, bedrucktes Blatt ~140mm) proportional exakt gleich aus — mit rem-Maßen lief der Inhalt je nach Kontext über die feste A5-Fläche (reproduzierbar: abgeschnittene Vorwort-Signatur, beschnittene Tabellen-Legende). Bewusste Ausnahme von der Token-Disziplin (siehe oben): ein bedrucktes A5-Blatt ist kein responsives Website-Element und braucht eine eigene Skala. letter-spacing bleibt trotzdem über --ls-* (dimensionslose em-Vielfache, kein Konflikt). Falle beim Bauen (31.07.2026): ein zusätzlicher Kommentar wurde versehentlich HINTER das schließende */ des bestehenden gesetzt. Damit stand roher Text vor der Regel, der CSS-Parser verwarf den kompletten Regelblock — und die A5-Höhe im Swipe-Viewer war wirkungslos, ohne dass irgendetwas einen Fehler meldete. Die Seiten fielen dadurch auf Inhaltshöhe zurück und waren 350 bis 559px hoch statt einheitlich 515. php -l prüft CSS nicht; wer hier etwas ändert, zählt /* gegen */ und misst eine Seitenhöhe nach.

Falle beim Containment: width: auto + max-height kollabiert eine inline-size-contained Box auf ihr Padding (Inhalt zählt nicht mehr) — im Swipe-Modus bekommen .stadionzeitung-page und .stadionzeitung-cover deshalb eine definite Breite (min(calc(72vh * 148 / 210), 100%)), nie width: auto. Beide stehen dafür in EINER Regel, und das ist der Punkt: das Cover hatte als eigene Klasse zunächst nur eine „harmlose Höhen-Deckelung" mit width: auto und blieb am iPhone unsichtbar, während Chrome am Desktop es zeigte (31.07.2026). Der Unterschied entsteht, weil .stadionzeitung-viewer__page display: flex ist: auf einem Flex-Kind heißt width: auto „richte dich nach dem Inhalt", Chrome rettet sich über aspect-ratio plus gestreckte Höhe, Safari nicht. Chrome kann diesen Fehler nicht reproduzieren — wer hier etwas ändert, prüft es an einem echten iOS-Gerät, nicht im Desktop-Browser. Das Cover war der Anfang dieses Prinzips: Kontrast der Schlagzeile kommt aus der flachen Verlauf-Fläche selbst, bewusst ohne text-shadow: Chrome rendert text-shadow auf mehrzeiligem, umbrechendem Text (lange Gegner-Namen) beim Druck-Export nachweislich als soliden Kasten statt eines weichen Schattens (reproduzierbar geprüft) — ein Foto-Hintergrund hätte diesen Trick gebraucht, der flache Verlauf braucht ihn erst gar nicht.

QR-Code-Vendoring: kein Composer-Paket (Regel: nur phpmailer/phpmailer), kein Laufzeit-Dienst (Regel 1) — stattdessen vendor-manual/phpqrcode/phpqrcode.php, 1:1 vendort von t0k4rt/phpqrcode (LGPL-3, GD-basiert, kein neues PHP-Modul). Bewusst vendor-manual/ statt vendor/ (das bleibt Composer/phpmailer vorbehalten). Helper generate_qr_png() (app/helpers.php) ist die einzige Einbindestelle, fängt die Parameter-Reihenfolge-Deprecations der Bibliothek (alter PHP-5-Code) lokal per error_reporting() ab — dasselbe Muster wie bei der vendorten Files-Gallery (_filesconfig.phpdisplay_errors = 0). Gibt false bei Fehlern zurück statt zu werfen: QR ist ein „nice to have" aufs Cover, kein Show-Stopper für die ganze Ausgabe. Dritter Parameter $scale = Pixel pro Modul (Default 6, gut 200px — passend für die A5-Seiten, viel zu klein für Druck).

Kurz-URL /stadionzeitung/aktuell für gedruckte QR-Codes. Reservierter Slug in der stadionzeitung/-Prefix-Route: leitet auf die neueste veröffentlichte Ausgabe weiter (stadionzeitung_latest()), ohne veröffentlichte Ausgabe aufs Archiv. Damit braucht ein Plakat oder Aushang nur EINEN Code, der über Jahre gilt. Kollision unmöglich, Ausgaben-Slugs beginnen immer mit dem Datum. Drei Dinge sind dabei load-bearing und dürfen nicht „vereinfacht" werden: 302, nie 301 (ein 301 wird dauerhaft gecacht — das Plakat zeigte für immer auf die erste Ausgabe, unreparierbar auf jedem Gerät, das ihn einmal gesehen hat) · Cache-Control: no-store · exit statt return (sonst hängt layout.php einen HTML-Body an die Weiterleitung). Nicht in der Sitemap: es ist eine Weiterleitung, keine Seite.

Gedruckte Adressen: club.website statt base_url (seit 31.07.2026). base_url ist bewusst umgebungsabhängig, weil Canonical, OG, Sitemap und JSON-LD zur ausliefernden Adresse passen müssen. Gedrucktes kennt kein „lokal": QR-Codes, die Internet-Zeile im Heft-Impressum, die Cover-Zeile neben dem QR und die Linkzeilen unter den CTA-QRs der Live-Seiten laufen deshalb über club_url()/club_domain() (Quelle: club.website in data/club.json). Vorher trugen sie localhost:8000 ins Druckmaterial und hätten nach dem Produktivgang alle neu erzeugt werden müssen. Die Domain ist Vereinsidentität, keine Umgebungseinstellung — deshalb club.json und nicht config. Fehlt das Feld, fällt club_url() auf abs_url() zurück (nie ein kaputter Link).

QR-Codes für Drucksachen: php bin/qr.php <pfad|url> [ziel.png] [--scale=24] (Default nach storage/qr/, gitignored). Getrennt von den Stadionzeitungs-Codes, die bin/stadionzeitung-add.php selbst klein für die A5-Seiten erzeugt; interne Pfade laufen über club_url(), sind also unabhängig von der Umgebung schon richtig. Jeden Druck-QR vor der Freigabe einmal mit dem Handy scannen. Das ist nicht Vorsicht, sondern die einzige Prüfmöglichkeit: die vendorte Bibliothek arbeitet nicht reproduzierbar (dieselbe URL ergibt unterschiedliche Pixel — anderes Maskenmuster, beides gültig), ein Vergleich zweier PNGs beweist deshalb nichts. Nachweisbar ist nur der Code-Pfad, nicht der Bildinhalt.

Live-Seiten wiederverwendbar: termine_build_lists() und news_build_feed() (app/helpers.php) sind die einzige Merge-Logik für Termine bzw. News — /termine, /news UND die Stadionzeitungs-Live-Seiten rufen dieselbe Funktion auf, nie eine zweite Implementierung.

Swipe-Viewer (app/components/stadionzeitung-viewer.php + assets/js/stadionzeitung-viewer.js): ohne JS liegen alle Seiten normal gestapelt da (voll lesbar, sequentiell). Mit JS wird der Track zum horizontalen Swiper — natives CSS-Scroll-Snap, kein eigener Touch-Gesten-Code (im Projekt gibt es dafür kein Vorbild und braucht auch keins). JS liefert nur Seitenzähler, Pfeiltasten und Vor-/Zurück-Buttons obendrauf.

Kein Dark Mode auf Stadionzeitungs-Seiten: jede Seite ist ein bedrucktes A5-Blatt und bleibt hell — digital wie gedruckt identisch, unabhängig vom sonst dunklen Website-Theme (Ausnahme, nicht die Regel). .stadionzeitung-page (components.css) überschreibt dafür lokal dieselben Custom Properties aus tokens.css, die league-table/termine-list/news-feed ohnehin verwenden (Token-Disziplin) — kein Einzel-Override pro Komponente. Das Cover ist die eine begründete Ausnahme von der Ausnahme: ein fotografisches Magazin-Cover (Verlauf in Vereinsfarben + optional das freigestellte Spielerfoto), keine weiße Content-Seite — eigene Klasse .stadionzeitung-cover, nicht .stadionzeitung-page (Details siehe unten). Bild-, Live- wie Cover-Seiten laufen im Swipe-Viewer auf A5-Seitenverhältnis (148:210), damit sich das Durchblättern gleichmäßig anfühlt.

Druck — die Druckfassung ?druck=1 (seit Interview 29.07.2026): das Heft wird als gefaltetes A5-Heft produziert (A4 quer beidseitig drucken, Blätter stapeln, mittig falten). Erreichbar nur über die URL: an die Ausgaben-Route ein ?druck=1 anhängen. Der frühere „Druckfassung"-Button im Viewer ist am 31.07.2026 entfernt worden (Felix): die Druckfassung ist Werkzeug für die Heft-Produktion, kein Besucher-Angebot, und als sichtbarer Knopf neben Blättern und Vollbild wurde er versehentlich geklickt. Nicht als Link wieder einbauen — wer sie braucht, kennt die URL. app/components/stadionzeitung-druck.php montiert dort die Seiten in Broschüren-Reihenfolge (Sattelheftung: Blatt 1 vorne = letzte Seite + Cover, hinten = Seite 2 + vorletzte, …). Die Montage liegt bewusst im PHP, nicht im Druck-CSS: CSS kann Seiten nicht über Blattgrenzen umsortieren. Die Seitenzahl braucht dafür ein Vielfaches von 4stadionzeitung-add.php warnt bei Lücken (Regel: beim Erzeugen gemeinsam sauber lösen), die Druckfassung füllt notfalls mit weißen Leerseiten VOR der Rückseite auf. Auf dem Bildschirm zeigt die Route eine Blatt-Vorschau (A4-quer-Flächen mit „Blatt 1 · Vorderseite"-Labels) samt Anleitung (beidseitig, an der kurzen Kante spiegeln); assets/js/stadionzeitung-druck.js liefert nur den „Jetzt drucken"-Button (window.print(), progressive enhancement — ohne JS Strg/Cmd+P). Kein serverseitiges PDF-Tool (passt zur Ghostscript/Imagick-Vermeidung oben).

Alle Bögen laufen RANDABFALLEND (@page { margin: 0 }, Beschluss 31.07.2026 — vorher galt hier „Bürodrucker mit weißem Rand ist der Anspruch, bewusst gleichmäßiges Passepartout"). Der Wechsel kam aus einem gemessenen Fehler: die Innenblätter hatten einen 5mm-Rahmen, die zwei Umschlagbögen nicht, und der Blatt-Inhalt war über eine aspect-ratio bemaßt und nur 199,1mm hoch statt 210mm. Die fehlenden 10,9mm sammelten sich immer unten, weil der Inhalt oben ausgerichtet ist — gemessen an Blatt 4: mit margin: 5mm oben 4,8 / unten 5,8mm, mit margin: 0 oben 0,0 / unten 10,9mm. Ein Rand kaschierte den Fehler, behob ihn aber nicht. Randabfallend ist die saubere Lösung, weil zwei A5 exakt ein A4 quer sind (2 × 148,5 = 297mm, Überschuss null): es bleibt nichts zu verteilen. Nachgemessen im erzeugten PDF: oben und unten je 0,0mm auf jedem Bogen. Verbleibendes Weiß steckt in den Anzeigenmotiven selbst (viele Sponsoren gestalten auf weißem Grund, 100% weiße Randpixel) und ist nicht unser Layout. Den Rand liefert der Bürodrucker aus seinem Hardware-Minimum (~4 bis 5mm); unser Fließtext verliert dabei nichts, die Live-Seiten haben 6,7cqi = 9,9mm Innenabstand. Keine @page-Randbreite wieder einführen, ohne die Blatt-Höhe gegenzurechnen — sonst ist der Überschuss zurück.

Es gibt bewusst auch keinen Falz-Steg mehr: die beiden Seiten stoßen mittig aneinander, der Innenabstand der Seiten (9,9mm) hält den Inhalt aus dem Knick. Vorher hatten nur die Innenbögen 2% Steg, der Umschlagbogen nicht — dadurch waren die Seiten dort 441px statt 448,5px breit, was im Heft als „das Programm ist schmaler als das Vorwort drüber" auffiel. Soll der Steg je zurück, dann für ALLE Bögen, nie nur für einen Teil.

Drei Seiten haben eine eigene aspect-ratio und heben per negativem Margin das Seiten-Padding auf, damit ihr Foto bis an die Kante läuft: Vorwort, Sportheim und Termine. Alle drei brauchen deshalb min-height: calc(100% + var(--space-l) * 2) und stehen dafür in einer gemeinsamen Regel — beim Vorwort allein behoben fehlte sie den anderen zwei, und die Sportheim-Seite war dadurch 625,7px hoch in einer 636,4px hohen Seite (10,7px weißer Streifen unter dem Foto). Eine vierte Seite mit diesem Konstrukt gehört in denselben Selektor.

@media print (:has()-Scope auf Viewer/Druckfassung, andere Seiten bleiben unangetastet) regelt nur Layout — Farben sind digital bereits hell. Der normale Viewer druckt als einfacher Seiten-Stapel (Fallback); fürs Heft immer die Druckfassung nehmen. Bei Änderungen an Komponenten, die in einer Ausgabe auftauchen können (Tabelle, Termine-Liste, News-Feed), die Druckvorschau neu prüfen.

Veranstaltungen (Feste mit eigener Detailseite)

Für jedes Fest eine eigenständige Seite, die über die Zeit wächst: erst nur das Plakat mit der Ankündigung, dann das Programm, nach dem Fest die Bilder. Übersicht /veranstaltungen („Was ansteht" oben, „Rückblick" darunter), pro Fest /veranstaltungen/<slug> (dynamische Prefix-Route, siehe „Architektur-Muster"). Quelle ist data/veranstaltungen.json. Bewusst generisch, nicht „Sportfest": Frühschoppen, Weihnachtsfeier und das Jubiläum 2028 laufen über denselben Mechanismus.

Abgrenzung zu /termine (die eine Regel, die nicht verhandelbar ist): ein Termin ohne eigene Seite lebt in data/events.json, ein Termin mit Seite in data/veranstaltungen.jsonniemals in beiden, sonst steht er doppelt in der Liste und /termine baut zwei Event-Knoten für dasselbe Fest. bin/preflight.php prüft Slug-Kollisionen, doppelte Slugs und Pflichtfelder. termine_build_lists() mergt seit dem Umbau drei Quellen (Spiele, Events, Veranstaltungen); veranstaltungen_load() ist die einzige Ladestelle und filtert Entwürfe zentral heraus — die Detailseite lädt bewusst ungefiltert.

Feldnamen sind Absicht: title, date_start, date_end, time, location, department, text heißen exakt wie in events.json. Dadurch laufen event_schema() und app/components/event-feature.php (Termin-Karte im Startseiten-Hero) unverändert auf einer Veranstaltung — kein zweiter Normalizer, kein zweiter Schema-Bauer. text bleibt ein String (Teaser für Karte, Meta-Description und Event-Beschreibung), die Absätze stehen in ankuendigung[]/rueckblick[]. Nur Veranstaltungen bekommen zusätzlich @id und url in den Event-Knoten (event_schema($v, 'veranstaltungen/<slug>')), damit /termine, die Übersicht und die Detailseite nachweislich dasselbe Event beschreiben.

Lebenszyklus über das Datum, kein Feld von Hand: termin_is_upcoming() entscheidet Ankündigung vs. Rückblick — und dieselbe Funktion trennt auf /termine kommend von vergangen. Eine Stelle, deshalb können die Seiten nicht widersprechen. Maßgeblich ist der letzte Tag: ein dreitägiges Fest ist am Samstag noch „kommend" (vorher verglich /termine nur date_start, ein laufendes Fest galt ab Tag zwei als vergangen). Die Seite bleibt bewusst mager (Beschluss 30.07.2026). Der Inhalt steht auf den Plakaten, die Seite erzählt ihn nicht daneben nochmal. Feste Reihenfolge, und jeder Block erscheint nur, wenn sein Feld gepflegt ist:

Kopf (Titel, Zeitraum, Ort)
Plakate            (Überschrift visually-hidden — Plakate erklären sich selbst)
Galerie            (nach dem Fest steht sie VOR den Plakaten, die dann klein als Andenken folgen)
Infoblock          veranstaltung-info: links Zeitraum/Beginn/Adresse, rechts die Karte —
                   NUR solange das Fest aussteht. Hinterher wäre es Doppelung: Datum und
                   Ort stehen im Kopf, und eine Anfahrtskarte zu einem Fest von letztem
                   Jahr hilft niemandem. Eine Rückblick-Seite endet mit den Plakaten.
optional           abschnitte[] · programm · helfer · partner

ankuendigung/rueckblick sind optional: ohne Text gibt es keine Einleitungssektion. abschnitte[].phase (ankuendigung | rueckblick | immer, Default immer) steuert, was wann steht. Das Programm als Liste ist die Ausnahme, nicht die Regel — normalerweise trägt es das Programmplakat; gepflegt lohnt es nur, wenn die Zeiten auch für Screenreader und Suchmaschinen lesbar sein sollen (im Bild sind sie es nicht).

Entwurf/Veröffentlicht wie bei der Stadionzeitung (veranstaltung_is_published(), fehlendes status = veröffentlicht): ein Entwurf fehlt in Übersicht, Termine-Liste, Startseiten-Hero und Sitemap und trägt noindex, ist aber unter seiner direkten URL erreichbar (Gegenlesen ohne Login-System, „Entwurf"-Badge im Kopf). Der Filter sitzt in veranstaltungen_load(), damit auch der Stadionzeitungs-Snapshot keinen unveröffentlichten Termin einfriert — der ist die einzige Stelle, die sich nicht mehr zurücknehmen lässt.

Kein Hero auf der Detailseite (wie bei der Stadionzeitungs-Ausgabe, begründete Ausnahme von der Hero-Regel): ein Archiv-Eintrag hat kein eigenes Titelbild, und das einzige vorhandene Bild ist ein Plakat im Hochformat, das als cover-beschnittener Hero-Hintergrund zerstört würde. Stattdessen ein schmaler Kopf (.veranstaltung-head) mit Rückweg, h1, Datum/Ort und Entwurf-Badge. Die Übersichtsseite behält den regulären Unterseiten-Hero.

Plakate sind keine Fotos. plakate[] (Ankündigung, Programm, …) tragen Information und stehen deshalb weit vorne, im Hochformat, unbeschnitten und zum Vergrößern (veranstaltung-plakate). galerie[] sind die Fotos vom Fest und stehen im Rückblick oben (veranstaltung-galerie). Das erste Plakat ist das Hauptplakat: es liefert das Kartenbild, das og_image und das image im Event-Knoten.

Karte: statisches Bild, KEIN Leaflet (geprüft 30.07.2026). Leaflet selbst wäre unkritisch (BSD, self-hostbar), aber es lädt seine Kacheln zur Laufzeit von einem fremden Tile-Server — genau das verbietet Regel 1, und img-src 'self' data: in der CSP würde sie blocken, die Karte bliebe grau. Stattdessen liefert app/components/veranstaltung-info.php das selbst gehostete OSM-Rendering img/pages/sportheim-map.jpg aus, mit Namensnennung „Kartendaten © OpenStreetMap-Mitwirkende" (die ODbL verlangt sie) und einem Klick nach Google Maps — ein ausgehender Link, keine Einbettung: der Browser des Besuchers erreicht Google erst, wenn jemand klickt. Wer echtes Zoomen will, muss die Kacheln vorproduzieren und selbst ausliefern (Zoom 14 bis 17, ~40 bis 80 PNG); OSM verbietet dabei das systematische Abziehen ihrer eigenen Kacheln. Zwei Felder steuern den Block: karte: "club" (auf unserem Gelände → Adresse kommt aus data/club.json, Karte wird gezeigt; fehlt das Feld, steht nur der Ortsname und es gibt keine Karte — ein Fest woanders hätte sonst eine falsche Karte daneben) und anfahrt[] (kurze Absätze zum Parken). Die Adresse nie in die Texte tippen, sie kommt aus club.json.

Wiederverwendete Komponenten, keine Neubauten: section (die freien abschnitte[], gleiche Form wie home.jsonsections[]; ohne Bild läuft sie jetzt einspaltig als split--plain) · cta-band (Helfer-Aufruf, helfer wird 1:1 als band übergeben) · partner-grid (neue Props only = Auswahl über den Partnernamen und anchor; passt kein Name, stehen bewusst wieder alle da statt einer leeren Logo-Wand) · img. Neu sind veranstaltungen-cards, veranstaltung-info, veranstaltung-plakate, veranstaltung-programm, veranstaltung-galerie und lightbox-overlay (das gemeinsame Overlay-Markup, damit Plakate und Galerie nicht zwei Fassungen davon haben).

Bilder ausschließlich über php bin/veranstaltung-bilder.php <slug> <ordner> (CLI, Kanal veranstaltungen, Lock, atomarer Schreibvorgang). Erwarteter Ordnerinhalt: plakate/* und bilder/* (jeweils natsort = Reihenfolge, das erste Plakat ist das Hauptplakat). Es rendert über resize_image_variants() nach public/assets/img/veranstaltungen/<slug>/ und schreibt nur plakate und galerie. Plakate und Fotos werden dabei unterschiedlich behandelt: Plakate [640, 1000, 1600] bei Qualität 82, weil sie Text tragen und man in der Großansicht Uhrzeiten lesen will; Fotos nur [640, 1200] bei 78, weil die dritte Stufe bei 40 Fotos über die Hälfte des Bildvolumens kostet und dort nichts bringt (gemessen an Sportfest 2025: 23 MB gegen 9 MB). Drei Punkte, die dabei tragen:

  • Der Slug muss schon in data/veranstaltungen.json stehen. Umgekehrt zur Stadionzeitung: der Eintrag entsteht im Chat, das Skript reichert ihn an. Genau daraus folgt, dass chat-gepflegte Felder nie verloren gehen — es ändert zwei Felder und rührt den Rest nicht an, auch status nicht (nachgelieferte Fotos dürfen keine veröffentlichte Seite offline nehmen).
  • Der Ordner ist immer der komplette Bestand. Ein erneuter Lauf ersetzt Plakate und Galerie vollständig und löscht die alten Dateien. Wer drei Fotos nachliefert, verliert die anderen zwanzig. Absicht: nur so lassen sich Bilder auch entfernen und umsortieren.
  • Alt-Texte kommen aus dem Chat (Wir-Form, siehe „Sprache & Ton") und hängen an source, dem Originaldateinamen. Ein neu eingeschobenes Foto verschiebt die Nummerierung, die Alt-Texte folgen trotzdem ihrem Bild. Neue Fotos bekommen den Veranstaltungstitel als Platzhalter; das Skript nennt sie am Ende namentlich, damit die Texte in derselben Chat-Runde entstehen.

Lightbox (public/assets/js/lightbox.js): generischer [data-lightbox-gallery]-Treiber, das dritte JS-Muster neben carousel.js und stadionzeitung-viewer.js. Ohne JS ist jedes Vorschaubild ein normaler Link auf die größte Variante der Datei; das Overlay liefert PHP (component('lightbox-overlay', …), gehört in denselben [data-lightbox-gallery]-Container wie die Links) und ist hidden, bis JS es übernimmt. Fokus wandert ins Overlay und kehrt beim Schließen auf das zuletzt gezeigte Vorschaubild zurück, Fokusfalle über die Knöpfe, Esc und Pfeiltasten, body.lightbox-open hält den Hintergrund fest. Bei nur einem Bild entfallen Zähler und Vor/Zurück. Die Großansicht erbt ihr srcset vom angeklickten Vorschaubild — so gibt es nur eine Bildliste im Markup und das Handy lädt die kleine Variante. Scrim-Farbe: --clr-lightbox-bg. Sichtbare Bildunterschriften gibt es bewusst nicht (Beschluss 30.07.2026), der Alt-Text trägt die Beschreibung. Falle: .lightbox[hidden] braucht display: none mit (0,2,0) — [hidden] aus reset.css hat dieselbe Spezifität wie .lightbox und verliert, weil components.css später lädt; ohne die Regel liegt das Overlay beim Laden offen über der Seite (einmal passiert).

News-Artikel (optionale Detailseiten)

Längere Meldungen bekommen eine eigene Seite /news/<slug> in Blog-Optik, kurze bleiben eine Feed-Zeile auf /news. Quelle ist weiterhin data/news.json, chat-gepflegt, kein Bin-Skript, kein Cron.

Der Inhalt entscheidet, nicht der Slug. news_has_article() (app/helpers.php) ist die einzige Stelle, die „hat Detailseite" beantwortet (slug und artikel gepflegt) — Feed, Archiv, Sitemap und bin/preflight.php fragen ausschließlich dort. Bewusst nicht am slug festgemacht: den schreibt man beim Anlegen reflexartig mit, sonst entstünden versehentlich Seiten mit zwei Sätzen Inhalt. Eine Meldung ohne artikel liefert unter ihrer URL 404, nicht eine leere Seite.

Felder: text bleibt der Teaser (Feed-Zeile, Meta-Description, Article.description) — nicht in teaser umbenennen, events.json und veranstaltungen.json benutzen dasselbe Wort für dieselbe Rolle. artikel[] ist eine Block-Liste, jeder Block trägt typ: text (absaetze[]) · zwischentitel (texth2, die h1 steht im Kopf) · bild (base/widths/alt/caption?) · zitat (text/quelle?). Ein unbekannter typ wird beim Rendern übersprungen und von bin/preflight.php gemeldet — ein Tippfehler soll vor dem Deploy auffallen, nicht durch einen fehlenden Absatz auf der Seite. Fließtext läuft durch inline_links_html(), interne Deeplinks [Label](slug) also erlaubt, rohes HTML nicht.

Entwurf/Veröffentlicht wie bei Veranstaltungen und Stadionzeitung (news_is_published(), fehlendes status = veröffentlicht): ein Entwurf fehlt in Feed, Archiv, Article-Schema und Sitemap und trägt noindex, ist aber unter seiner direkten URL mit „Entwurf"-Badge lesbar. Der Filter sitzt in news_load() (einzige gefilterte Ladestelle), die Detailseite lädt ungefiltert über news_find(). Das gilt absichtlich auch für den Stadionzeitungs-Snapshot: der zieht seine News aus news_build_feed() und friert sie beim Veröffentlichen ein, ist also die eine Stelle, die sich nicht zurücknehmen lässt.

Meldungen werden nicht gekappt. config('news.max_items') begrenzt in news_build_feed() nur Instagram und Ergebnisse — die wachsen von allein (Sync 2×/Tag, jedes Wochenende neue Spiele), und davor schützt die Grenze. Redaktionelle Meldungen stehen immer alle im Feed, sonst wäre ein älterer Artikel irgendwann von der Website aus nicht mehr verlinkt. Es gibt bewusst keinen zweiten Archiv-Abschnitt dafür (30.07.2026 gebaut und am selben Tag verworfen): bei wenigen Artikeln pro Jahr listete er nur ein zweites Mal, was oben schon stand.

Weiterlesen-Zeile im Feed: news_build_feed() liefert link_text, news-feed.php rendert es — die Komponente leitet nichts aus source ab, sie soll die Quellen nicht kennen (gleiche Prop wie in upcoming_highlights()). Sonst brauchte news-feed.php keinen Eingriff: die <a>-Variante samt Hover gab es für Instagram und Ergebnisse schon.

Zwei geteilte Bausteine, entstanden mit dieser Seite: app/components/detail-head.php — der schmale Kopf (Rückweg, h1, Entwurf-Badge, Datum/Ort) für Veranstaltungs- und News-Detailseite, Klassen .detail-head*. Der Kopf der Stadionzeitungs-Ausgabe bleibt bewusst eine eigene Fassung (Viewer-Chrome: Drei-Zonen-Grid, Titel als <p>, Druck-Knopf, eigene @media print-Regeln). .prose in components.css — die Lesespalte (72ch) samt Typografie für langen Fließtext, genutzt vom Artikelkörper und von .legal__body (Impressum, Datenschutz tragen die Klasse zusätzlich).

Bilder über php bin/img-resize.php wie überall, Zielordner public/assets/img/news/. Für neue Artikel [640, 1000, 1600] erzeugen (das Bestandsbild neue-website hat nur 640/1000, weil das Original nicht mehr vorliegt und resize_image_variants() nie hochskaliert). Kein bin/news-bilder.php: News-Bilder sind Einzelstücke, kein Ordnerbestand wie Plakate und Festgalerien.

Damit der Verein Fotos und Videos von Events selbst abliefern kann: https://…/dateien/ ist eine vendorte Fremd-App (Files Gallery, Vollversion-Lizenz, public/dateien/index.php) — die benannte Ausnahme zu Regel 3. Zwei Zugänge: verein (hochladen, Ordner anlegen, herunterladen — bewusst kein Löschen/Umbenennen, damit ein Fehlklick am gemeinsamen Konto nicht die Fotos anderer trifft) und admin (alles außer allow_settings). Nicht in der Navigation, nicht in der Sitemap, Disallow in robots.txt, noindex per Header und Meta. Das Einarbeiten der Bilder in die Seite bleibt Chat-Arbeit (bin/img-resize.php).

Drei Entscheidungen tragen den Aufbau — keine davon anfassen, ohne den Grund zu kennen:

  1. root und storage_path liegen außerhalb des Docroots (storage/dateien/uploads bzw. …/system), dazu load_files_proxy_php. Die App lässt per Default jede Dateiendung zum Upload zu (keine Blacklist, index.php:4078-4092); liegt das Verzeichnis nicht im Web-Baum, ist eine hochgeladene .php prinzipiell nicht ausführbar und alles läuft durch PHP (index.php:1607). upload_allowed_file_types ist die zweite Schicht, nicht die erste.
  2. public/dateien/_filesconfig.php ist der einzige Integrationspunkt. Sie wird als erstes geladen (index.php:142), gewinnt gegen die selbst generierte Storage-Config (index.php:239) und läuft noch vor session_start() und Login — deshalb stehen dort auch Session-Härtung, das Login-Rate-Limit (rate_limit_ok(), Log nach spam.log) und display_errors = 0 (die App ist PHP-7-Code; eine Deprecation-Notice landet sonst mitten im Bild-Stream und zerstört Thumbnails). Das Vereins-Konto muss das globale username/password der App bleiben: ohne globales Passwort sind Konten unter system/users/ nur zusätzliche Logins und die Ablage wäre offen lesbar (index.php:328-337).
  3. Assets self-hosted unter public/assets/filesgallery/ (Regel 1 — die App lädt sonst CSS und 14 Scripts von jsDelivr). Die Pfade enthalten die Version, ein Update muss sie also mitziehen.

Uploadgrenze an zwei Stellen, es gilt die kleinere: upload_max_filesize in _filesconfig.php (250 MB, auf Videoclips gerechnet) und die PHP-Grenzen aus public/dateien/.user.ini. .user.ini statt .htaccess, weil All-Inkl PHP als FastCGI/FPM fährt (php_value wäre dort wirkungslos). Lokal übernehmen das die -d-Schalter im Dev-Kommando. bin/preflight.php vergleicht beide Werte und warnt, wenn PHP niedriger steht. Erlaubt sind Bilder (inkl. HEIC), mp4/mov/m4v/webm, PDF und svg. Video-Vorschaubilder brauchen ffmpeg auf dem Server — fehlt es, funktionieren Upload und Wiedergabe trotzdem, es gibt nur ein Icon. SVG hat keine Vorschau (GD kann sie nicht rastern), wird aber im Listing geführt.

SVG ist der einzige erlaubte Typ, der Code tragen kann (<script>, onload) — und die App liefert Dateien inline aus (index.php:781). Deshalb zwei Schichten, beide gegen echtes Apache 2.4 bzw. über die App getestet: (1) _filesconfig.php prüft jeden SVG-Upload auf Skript, Event-Attribute, <!ENTITY>, <foreignObject> und Verweise auf fremde Hosts und weist ihn ab — ein Inkscape-Export mit Metadaten, internem <use href="#…"> und eingebettetem data:-Bild geht durch; (2) public/dateien/.htaccess setzt für SVG-Antworten Content-Security-Policy: sandbox (nur für SVG, damit die PDF-Vorschau des Browsers weiter funktioniert). Wer die Endungsliste ändert, muss beides mitziehen.

Ordnerstruktur: Jahr → Abteilung (2026/Fußball, …/Jugend, …/Turnen, …/Feste und Aktionen), angelegt mit php bin/dateien-init.php [Jahr] — idempotent, im Januar für das neue Jahr erneut aufrufen. Das Jahr außen hält die oberste Ebene kurz, die Abteilungen innen sind dieselben wie die Bereiche der Website (dann ist beim Hochladen klar, wohin das Material gehört). Tiefer wird nichts vorgegeben; Ereignisse bündeln die Hochladenden selbst als 2026-08-15 Sommerfest. Das Skript läuft auf dem Server — Ordner per FTP hochzuladen erzeugt bei Umlauten Doppelgänger (macOS NFD vs. Linux NFC).

uploads/ ist versioniert (Beschluss 31.07.2026, vorher gitignored): das Material des Vereins ist damit gesichert und beim Entwickeln lokal da, und ein frischer Klon ist wirklich vollständig. Der Ordner ist aber das einzige Verzeichnis im Repo, in das andere Menschen schreiben — daraus folgt die eine Regel, die das Ganze zusammenhält:

Erst auf dem Server committen und pushen, dann lokal pullen, dann entwickeln. Nie umgekehrt.

Hält man sie ein, kann kein Deploy hochgeladene Fotos überschreiben (ein pull fasst neue Dateien auf dem Server ohnehin nicht an, aber ein reset/checkout schon). Zweiter Preis, dauerhaft: Git vergisst nichts — eine gelöschte Datei bleibt in der Historie, das Repo schrumpft nie wieder. Deshalb Videos sparsam: zwei Sportfest-Clips machten allein 85 der ersten 174 MB, und für die Website wären sie ohnehin neu zu kodieren. Wird es unbequem, ist der Schnitt *.mp4 auszunehmen und Videos separat zu sichern; die Fotos und die .quelle.txt-Belege der Chronik sind der wertvolle Teil.

Genau ein Patch in der Fremd-Datei (index.php:122, $localconfigpath absolut statt relativ) — ohne ihn startet die App bei abweichendem Arbeitsverzeichnis ohne Login. Update ausschließlich über php bin/filesgallery-update.php (holt App + passende Assets, setzt den Patch, --check prüft nur); php bin/preflight.php prüft dasselbe vor jedem Deploy. index.php niemals in install.php umbenennen (erzwingt allow_settings, und das Settings-UI schreibt und includet geliefertes PHP).

Optik über die beiden Hooks der App: storage/dateien/system/include/head.html (lädt tokens.css + public/assets/css/dateien.css) und …/include/footer.html. Die css/custom.css-Hooks der App funktionieren hier nicht — sie brauchen eine öffentliche URL, unser storage_path hat keine (index.php:717). dateien.css setzt fast nur Variablen der App (--hue, --hsl-primary-*, --font-family), damit ein Update nichts zerreißt. Die eigene CSP für diesen Pfad steht in public/dateien/.htaccess (braucht 'unsafe-inline' für Scripts/Styles: die App interpoliert Inline-Code per PHP und kennt keine Nonces) — sie gilt nur dort, die Seiten behalten die strikte.

Chronik 2028 (Materialsammlung fürs Jubiläum)

Sammelt Fundstücke für die Vereinschronik zum 120-jährigen Bestehen. Reine Recherche-Werkzeuge, kein Website-Code — nichts davon läuft im Request-Pfad, die Seite kennt die Chronik nicht.

Der öffentliche Zulauf dazu ist die Sektion contribute auf /historie (Bild/Text-Sektion nach der Timeline) mit einem mailto:-CTA auf club.email_historie. Bewusst kein Formular: das Material sind Dateien, und Anhänge kann keines unserer Formulare annehmen. Was per Mail eintrifft, wird mit php bin/chronik-add.php --datei=<pfad> aufgenommen — die Freigabe zur Veröffentlichung fragt die Sektion mit ab, app/pages/datenschutz.php beschreibt Archivierung und Veröffentlichung getrennt.

Der Rückweg vom Register in die Timeline: belegte Ereignisse dürfen als Station in data/historie.jsonimage ist im timeline-Component optional, Stationen ohne Foto rendern als Notiz. Genau dafür ist das da: Von Presse-Fundstücken sind die Fakten frei (Jahr, Liga, Ergebnis, Torschütze), das Foto nicht — es gehört dem Verlag und darf nicht mit. Vor dem Eintragen die Jahreszahl im Register gegenprüfen: der Aufstieg in die Kreisliga war 2019, 2018 ist das Jahr der verlorenen Relegation (0:3 gegen Kirchahorn) — beides lag im Verein schon vertauscht in Erinnerung.

Was Wo
Fundstücke (Bilder, Textbelege, Scans) storage/dateien/uploads/Vereinshistorie/ (gitignored)
Register — eine Zeile pro Fundstück docs/chronik/register.csv (im Repo, braucht Versionsgeschichte)
Rechercheplan: was ist durchsucht, was fehlt docs/chronik/quellenplan.md
Gemeinsame Logik der Skripte bin/chronik-lib.php (CLI-only, bewusst nicht in app/helpers.php)
  • Ordner anlegen: php bin/chronik-init.php — Jahrzehnt-Bündel (1908-19292010-2028), Festschriften-und-Dokumente/, Undatiert/, dazu 00_LIESMICH.txt und den Entwurf der Verlagsanfrage. Idempotent, muss wie dateien-init.php auf dem Server laufen (NFD/NFC).
  • Fundstück aufnehmen: php bin/chronik-add.php <URL> bzw. --liste=urls.txt (Zeilen URL | Datum | Titel) oder --datei=<pfad> für Scans und schon vorhandene Dateien. --bericht zeigt Bestand und Lücken pro Zeitraum, --rechte schreibt die Rechteliste neu.
  • Der Dateiname trägt die Metadaten, weil die Files Gallery keine Felder dafür hat: 1972-07-15_Einweihung-Sportheim_BR.jpg. Datum vorn → alphabetisch = chronologisch, unbekannte Teile als 00. Kollisionen bekommen -2, -3 (sonst überschreibt Fund zwei Fund eins).
  • Die Rechtelage entscheidet, wie viel Text bleibt (chronik_rights() ordnet sie dem Host zu): eigenes Vereinsmaterial wird im Volltext gesichert — die früheren Vereinsseiten tsv1908kulmbach.de und turneninkulmbach.info sind offline, ihre Inhalte existieren nur noch im Internet Archive. Fremde Presseartikel nur als Nachweis (Überschrift, Datum, Anriss, URL). Zu jedem Fund entsteht eine .quelle.txt; was freigegeben werden muss, sammelt Vereinshistorie/00_RECHTE-ZU-KLAEREN.txt (generiert, nicht von Hand pflegen — stattdessen Spalte klaeren im Register auf nein setzen und --rechte laufen lassen).
  • Neue Quelle → Eintrag in chronik_rights() und chronik_source_tag() in chronik-lib.php. Ohne Eintrag landet der Fund auf „Ungeklärt".
  • Verwechslungsgefahr: In Kulmbach gibt es auch ATS Kulmbach (Bayernliga 1981/82) und 1. KSC Kulmbach. Wikipedia-Bayernliga-Treffer betreffen den ATS, nicht uns. Jeder Fund muss den Vereinsnamen wörtlich enthalten.