Design-System: Typo-Skala schließen, h4–h6 auf Abel, Icon-Größen tokenisieren
Zweiter Teil der Konsolidierung. Vorher standen 31 font-size-Werte außerhalb
der Skala — beim Durchgehen stellte sich heraus, dass 20 davon gar keine
Textgrößen sind: der icon()-Helper rendert 1em, also steuerte font-size dort
die Icon-Größe. Dafür gibt es jetzt eine eigene, kleine Skala (--icon-s/m/l/xl
in rem, --icon-inline-sm/--icon-inline/--icon-inline-lg in em für Icons, die im
Textfluss mitwachsen sollen). Elf Ad-hoc-Werte fallen damit auf sieben Stufen.
h4–h6 laufen nicht mehr über Coolvetica. Sie lagen mit 26px und 21px unter der
--fs-650-Grenze aus CLAUDE.md, ab der der Condensed-Schnitt in Uppercase
unleserlich wird — die Regel war also nur dokumentiert, nicht durchsetzbar.
Jetzt tragen h1–h3 Coolvetica (alle über der Grenze) und h4–h6 Abel mit
Sperrung. Bezeichnend: das einzige <h4> im Markup hatte sich genau dieses
Muster bereits von Hand gebaut. Die Basisregel liefert es jetzt, die Komponente
gibt ihre drei Duplikat-Deklarationen ab — sichtbar ändert sich dadurch nichts.
Neue Stufen schließen die Lücken: --fs-550 (fluider Lead), --fs-690 (Karten-
Überschrift) und --fs-1000 (Display-Hero). Vier fast deckungsgleiche clamp()-
Rampen für Karten-Überschriften (bento__heading, bento__stat-value,
group-card__name, team-card__name) laufen jetzt über --fs-690; team-card__name
wächst dabei am Desktop um ~5px, die anderen drei bleiben im 1px-Bereich.
Die Wappen-Initialen im Matchcenter hingen an zwei Mini-Schriftgrößen (10,4 und
11,2px) unterhalb der Skala. Sie sind in Wahrheit ~0,4 × Kreisdurchmesser,
folgen jetzt also --crest-size statt einer eigenen font-size. Nebenbei fallen
drei redundante Doppel-Selektoren weg: .crest--initials trägt immer auch .crest.
Ebenfalls tokenisiert: 27 letter-spacing-Werte mit elf verschiedenen Zahlen auf
vier Stufen (--ls-tight/caps/wide/wider), maximale Abweichung 0,02em.
Danach steht kein font-size- und kein letter-spacing-Wert mehr außerhalb von
tokens.css.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:17:13 +02:00
< ? php
declare ( strict_types = 1 );
/**
* Deploy - Selbsttest — NUR CLI . Prüft alles , was nach dem Hochladen stimmen muss ,
* bevor die Seite Besucher sieht : Konfiguration , Secrets , Schreibrechte ,
* Composer - Abhängigkeiten , PHP - Version , Datenstände .
*
* Prüft NICHT den SMTP - Login ( Netzwerkzugriff ) — dafür : php bin / smtp - test . php
*
* Aufruf : php bin / preflight . php ( Exit 0 = startklar , 1 = Fehler offen )
*/
if ( PHP_SAPI !== 'cli' ) {
exit ( 1 );
}
require dirname ( __DIR__ ) . '/app/bootstrap.php' ;
$errors = [];
$warnings = [];
// --- Konfiguration & Secrets (dieselbe Prüfung, die auch die Formular-Actions macht) ---
foreach ( config_problems () as $problem ) {
$errors [] = $problem ;
}
if (( string ) config ( 'env' ) !== 'production' ) {
$errors [] = " env steht auf ' " . ( string ) config ( 'env' ) . " ' — in Produktion 'production' (sonst zeigt die Seite PHP-Fehler). " ;
}
$baseUrl = ( string ) config ( 'base_url' );
if ( ! str_starts_with ( $baseUrl , 'https://' )) {
$errors [] = " base_url ist nicht https ( { $baseUrl } ) — Canonical, OG und Sitemap wären falsch. " ;
}
if ( str_ends_with ( $baseUrl , '/' )) {
$warnings [] = 'base_url endet auf "/" — abs_url() erzeugt dann doppelte Slashes.' ;
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung
Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News,
Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover,
Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen
kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei
hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das
Skript selbst, statt sie von Hand zu prüfen.
Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus
über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit
Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie
doppelt gepflegt wird.
News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head.
Behobene Fehler:
- Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das
Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen
(Ergebnis-Rückblick, Vorschau, Cover).
- --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px
Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links
unter dem Logo.
- base_url in der Produktions-Config ohne www, während .htaccess auf www
umleitet; preflight prüft das jetzt.
- .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen.
Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php
(Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit
Warnung, wenn base_url nicht auf die echte Domain zeigt).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
// base_url MUSS der Host sein, den public/.htaccess erzwingt. Läuft es
// auseinander, zeigen Canonical, OG und Sitemap auf Adressen, die der Server
// sofort per 301 weiterleitet — und gedruckte QR-Codes bekommen einen
// unnötigen Umweg eingebacken. Genau dieser Fehler stand schon einmal drin
// (base_url ohne www, .htaccess mit).
$htaccess = PUBLIC_PATH . '/.htaccess' ;
if ( is_file ( $htaccess )
&& preg_match ( '~RewriteRule \^ https://([a-z0-9.-]+)%\{REQUEST_URI\}~i' , ( string ) file_get_contents ( $htaccess ), $forced ) === 1 ) {
$forcedHost = strtolower ( $forced [ 1 ]);
$configHost = strtolower (( string ) parse_url ( $baseUrl , PHP_URL_HOST ));
if ( $configHost !== '' && $configHost !== $forcedHost && ! str_contains ( $configHost , 'localhost' )) {
$errors [] = " base_url nutzt ' { $configHost } ', public/.htaccess leitet aber alles auf ' { $forcedHost } ' um — Canonical, OG und Sitemap zeigten auf weitergeleitete Adressen. " ;
}
}
Design-System: Typo-Skala schließen, h4–h6 auf Abel, Icon-Größen tokenisieren
Zweiter Teil der Konsolidierung. Vorher standen 31 font-size-Werte außerhalb
der Skala — beim Durchgehen stellte sich heraus, dass 20 davon gar keine
Textgrößen sind: der icon()-Helper rendert 1em, also steuerte font-size dort
die Icon-Größe. Dafür gibt es jetzt eine eigene, kleine Skala (--icon-s/m/l/xl
in rem, --icon-inline-sm/--icon-inline/--icon-inline-lg in em für Icons, die im
Textfluss mitwachsen sollen). Elf Ad-hoc-Werte fallen damit auf sieben Stufen.
h4–h6 laufen nicht mehr über Coolvetica. Sie lagen mit 26px und 21px unter der
--fs-650-Grenze aus CLAUDE.md, ab der der Condensed-Schnitt in Uppercase
unleserlich wird — die Regel war also nur dokumentiert, nicht durchsetzbar.
Jetzt tragen h1–h3 Coolvetica (alle über der Grenze) und h4–h6 Abel mit
Sperrung. Bezeichnend: das einzige <h4> im Markup hatte sich genau dieses
Muster bereits von Hand gebaut. Die Basisregel liefert es jetzt, die Komponente
gibt ihre drei Duplikat-Deklarationen ab — sichtbar ändert sich dadurch nichts.
Neue Stufen schließen die Lücken: --fs-550 (fluider Lead), --fs-690 (Karten-
Überschrift) und --fs-1000 (Display-Hero). Vier fast deckungsgleiche clamp()-
Rampen für Karten-Überschriften (bento__heading, bento__stat-value,
group-card__name, team-card__name) laufen jetzt über --fs-690; team-card__name
wächst dabei am Desktop um ~5px, die anderen drei bleiben im 1px-Bereich.
Die Wappen-Initialen im Matchcenter hingen an zwei Mini-Schriftgrößen (10,4 und
11,2px) unterhalb der Skala. Sie sind in Wahrheit ~0,4 × Kreisdurchmesser,
folgen jetzt also --crest-size statt einer eigenen font-size. Nebenbei fallen
drei redundante Doppel-Selektoren weg: .crest--initials trägt immer auch .crest.
Ebenfalls tokenisiert: 27 letter-spacing-Werte mit elf verschiedenen Zahlen auf
vier Stufen (--ls-tight/caps/wide/wider), maximale Abweichung 0,02em.
Danach steht kein font-size- und kein letter-spacing-Wert mehr außerhalb von
tokens.css.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:17:13 +02:00
// --- Abhängigkeiten ---
if ( ! is_file ( ROOT_PATH . '/vendor/autoload.php' )) {
$errors [] = 'vendor/ fehlt — auf dem Server "composer install --no-dev --optimize-autoloader" laufen lassen.' ;
} elseif ( ! class_exists ( PHPMailer\PHPMailer\PHPMailer :: class )) {
$errors [] = 'PHPMailer ist nicht ladbar — vendor/ unvollständig?' ;
}
if ( version_compare ( PHP_VERSION , '8.1' , '<' )) {
$errors [] = 'PHP ' . PHP_VERSION . ' ist zu alt — mindestens 8.1 (der Code nutzt never-Return-Types und enums-Syntax).' ;
}
// --- Schreibrechte (Logs, Rate-Limit-Zähler, Sync-Caches) ---
foreach ([ 'logs' , 'ratelimit' , 'cache' ] as $dir ) {
$path = STORAGE_PATH . '/' . $dir ;
if ( ! is_dir ( $path )) {
$errors [] = " storage/ { $dir } / fehlt. " ;
} elseif ( ! is_writable ( $path )) {
$errors [] = " storage/ { $dir } / ist nicht beschreibbar (Rate-Limiting und Logging fielen sonst still aus). " ;
}
}
foreach ([ 'img/instagram' , 'img/crests' ] as $dir ) {
$path = PUBLIC_PATH . '/assets/' . $dir ;
if ( ! is_dir ( $path ) || ! is_writable ( $path )) {
$warnings [] = " public/assets/ { $dir } / fehlt oder ist nicht beschreibbar — der Sync kann dort keine Bilder ablegen. " ;
}
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung
Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News,
Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover,
Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen
kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei
hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das
Skript selbst, statt sie von Hand zu prüfen.
Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus
über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit
Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie
doppelt gepflegt wird.
News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head.
Behobene Fehler:
- Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das
Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen
(Ergebnis-Rückblick, Vorschau, Cover).
- --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px
Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links
unter dem Logo.
- base_url in der Produktions-Config ohne www, während .htaccess auf www
umleitet; preflight prüft das jetzt.
- .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen.
Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php
(Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit
Warnung, wenn base_url nicht auf die echte Domain zeigt).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
// --- Dateiablage /dateien (Files Gallery) ---
// Die App muss in ihr Upload- und Storage-Verzeichnis schreiben können, sonst bricht
// schon der erste Aufruf ab (sie legt Cache-Verzeichnisse selbst an).
foreach ([ 'uploads' , 'system' ] as $dir ) {
$path = STORAGE_PATH . '/dateien/' . $dir ;
if ( ! is_dir ( $path )) {
$errors [] = " storage/dateien/ { $dir } / fehlt — anlegen und beschreibbar machen. " ;
} elseif ( ! is_writable ( $path )) {
$errors [] = " storage/dateien/ { $dir } / ist nicht beschreibbar — Uploads und Thumbnails scheitern. " ;
}
}
// Ohne echten Hash ist der Zugang gesperrt (fail closed) — das soll auffallen, bevor
// jemand vor dem Login-Formular steht und sich wundert.
if ( config ( 'dateien' ) === null ) {
$errors [] = " Der Block 'dateien' fehlt in config/config.php — die Dateiablage hätte keine Zugänge (siehe config.example.php). " ;
} else {
foreach ([ 'verein' , 'admin' ] as $user ) {
$hash = ( string ) config ( " dateien.users. { $user } .password_hash " , '' );
if ( $hash === '' || $hash === 'CHANGE_ME' ) {
$errors [] = " dateien.users. { $user } .password_hash fehlt — dieser Zugang ist gesperrt. " ;
} elseif ( password_get_info ( $hash )[ 'algoName' ] === 'unknown' ) {
$warnings [] = " dateien.users. { $user } .password_hash sieht wie Klartext aus — besser hashen: php -r \" echo password_hash('…', PASSWORD_DEFAULT); \" " ;
}
}
if ( in_array (( string ) config ( 'dateien.license_key' , '' ), [ '' , 'CHANGE_ME' ], true )) {
$warnings [] = 'dateien.license_key fehlt — die Dateiablage läuft, zeigt aber den Hinweis der unlizenzierten Version.' ;
}
}
// Der Patch und die self-hosted Assets entscheiden darüber, ob die App überhaupt
// mit Login startet und ob sie ohne CDN auskommt. Details: bin/filesgallery-update.php
$galleryApp = PUBLIC_PATH . '/dateien/index.php' ;
if ( ! is_file ( $galleryApp )) {
$errors [] = 'public/dateien/index.php fehlt — die Dateiablage ist nicht ausgeliefert.' ;
} else {
$source = ( string ) file_get_contents ( $galleryApp );
if ( ! str_contains ( $source , " \$ localconfigpath = __DIR__ . '/_filesconfig.php' " )) {
$errors [] = 'public/dateien/index.php ist ungepatcht — die App würde OHNE Login starten. php bin/filesgallery-update.php' ;
}
// Wer Einstellungen direkt in die vendorte Datei schreibt, verliert sie beim nächsten
// Update — und Secrets hätten dort ohnehin nichts zu suchen (Regel 5). Die App warnt
// selbst davor (index.php:33), also fangen wir es hier ab.
if ( preg_match ( '/^\s*\'(license_key|username|password)\' => \'.+\',/m' , $source , $edited ) === 1 ) {
$errors [] = " public/dateien/index.php wurde direkt editiert (' { $edited [ 1 ] } ') — Wert nach config/config.php (dateien.*) verschieben und die Zeile leeren, sonst ist er nach dem nächsten Update weg. " ;
}
$version = preg_match ( '/public static \$version = \'([0-9][^\']*)\'/' , $source , $m ) === 1 ? $m [ 1 ] : '?' ;
if ( ! is_file ( PUBLIC_PATH . " /assets/filesgallery/files.photo.gallery@ { $version } /css/files.css " )) {
$errors [] = " Assets der Dateiablage fehlen (Version { $version } ) — sonst lädt sie vom CDN und die CSP blockt alles: php bin/filesgallery-update.php --assets " ;
}
}
// Es gilt immer die kleinere von zwei Grenzen: unsere aus _filesconfig.php und die von
// PHP. Steht PHP niedriger, wird jedes größere Video abgewiesen, ohne dass man es
// unserer Konfiguration ansehen könnte — deshalb hier vergleichen, nicht nur ausgeben.
$phpUpload = ini_parse_quantity (( string ) ini_get ( 'upload_max_filesize' ));
$phpPost = ini_parse_quantity (( string ) ini_get ( 'post_max_size' ));
// Bewusst gelesen und nicht ausgeführt: _filesconfig.php setzt display_errors und
// Session-Parameter — Nebenwirkungen, die in diesem Skript nichts zu suchen haben.
$ourMax = 0 ;
$galleryConfigFile = PUBLIC_PATH . '/dateien/_filesconfig.php' ;
if ( is_file ( $galleryConfigFile )
&& preg_match ( " /'upload_max_filesize' \ s*=> \ s*( \ d+)/ " , ( string ) file_get_contents ( $galleryConfigFile ), $um ) === 1 ) {
$ourMax = ( int ) $um [ 1 ];
}
$mb = static fn ( int | float $bytes ) : string => round ( $bytes / 1048576 ) . ' MB' ;
echo 'INFO Uploadgrenze: ' . ( $ourMax > 0 ? $mb ( $ourMax ) : 'unbekannt' )
. " laut _filesconfig.php, diese PHP-CLI erlaubt { $mb ( $phpUpload ) } (post_max_size { $mb ( $phpPost ) } ) \n " ;
if ( $ourMax > 0 && $phpUpload < $ourMax ) {
// Achtung: das hier ist die CLI-Konfiguration. Für Uploads zählt die des Webservers
// (FPM + .user.ini), die abweichen darf — deshalb Warnung und nicht Fehler.
$warnings [] = " Diese PHP-CLI deckelt Uploads auf { $mb ( $phpUpload ) } , unsere Config erlaubt { $mb ( $ourMax ) } . Für Uploads zählt aber die Webserver-Konfiguration: public/dateien/.user.ini setzt { $mb ( $ourMax ) } . Verbindlich prüfen mit einem echten großen Upload über /dateien (oder phpinfo() im Browser). " ;
}
if ( $phpPost <= $phpUpload ) {
$warnings [] = " post_max_size ( { $mb ( $phpPost ) } ) ist nicht größer als upload_max_filesize ( { $mb ( $phpUpload ) } ) — Uploads am oberen Rand scheitern dann ohne Fehlermeldung. " ;
}
Instagram: Scraper raus, Beitraege einzeln ueber oEmbed aufnehmen
bin/instagram-sync.php lieferte seit dem 03.09.2026 bei jedem Lauf "Keine Posts
ermittelbar". Von einem privaten Anschluss gegengeprueft: feed/user und
web_profile_info antworten mit 401 require_login, das Profil-HTML traegt keine
Beitragsdaten mehr, auch /embed/ liefert nur noch eine App-Huelle. Es lag also
nicht an der Server-IP, und der Scraper ist geloescht, damit ihn niemand
wiederbelebt. Graph API (Meta-Konto noetig) und kommerzielle Dienste wurden
verworfen.
Neu: bin/instagram-add.php. --profil liest die Beitragsliste ueber headless
Chrome aus dem gerenderten Profil-DOM (im HTML stehen die Codes nicht) und holt
zu jedem neuen Beitrag Bild, Caption und Datum ueber Instagrams oEmbed-Endpunkt;
das Datum steckt in der media_id. Daneben einzelne URLs, --liste, --entfernen und
--bild als Notfall. Der Shortcode ist Primaerschluessel und damit die komplette
Dedupe-Logik. Die Struktur von data/instagram.json bleibt exakt gleich, deshalb
war an Startseite, News-Feed und Stadionzeitung nichts zu aendern.
Kein Cron mehr: der Job faellt aus public/cron.php, auf dem Webspace gibt es kein
Chrome. Die Chrome-Suche liegt jetzt gemeinsam in bin/chrome-lib.php, genutzt von
bin/logo-build.php und bin/instagram-add.php.
data/instagram.json ist ab jetzt versioniert. Solange sie ignoriert war, lief sie
gegen die versionierten Bilder auseinander: lokal lagen 10 Bilder zu 9 Eintraegen,
drei Beitraege fehlten im Feed, zwei Eintraege hatten kein Bild. Repariert, jetzt
16 Eintraege zu 16 Bildern. bin/preflight.php prueft das in beide Richtungen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9mubC8gtpjtJntbhSJBRS
2026-09-17 11:23:12 +02:00
// --- Datenstand des Cron-Skripts (fehlt nur beim Erstlauf) ---
// Nur noch matchcenter: data/instagram.json ist seit dem 17.09.2026 chat-gepflegt
// (bin/instagram-add.php) und nicht „alt“, nur weil eine Woche nichts passiert ist.
foreach ([ 'matchcenter' => 'bin/matchcenter-sync.php' ] as $name => $script ) {
Design-System: Typo-Skala schließen, h4–h6 auf Abel, Icon-Größen tokenisieren
Zweiter Teil der Konsolidierung. Vorher standen 31 font-size-Werte außerhalb
der Skala — beim Durchgehen stellte sich heraus, dass 20 davon gar keine
Textgrößen sind: der icon()-Helper rendert 1em, also steuerte font-size dort
die Icon-Größe. Dafür gibt es jetzt eine eigene, kleine Skala (--icon-s/m/l/xl
in rem, --icon-inline-sm/--icon-inline/--icon-inline-lg in em für Icons, die im
Textfluss mitwachsen sollen). Elf Ad-hoc-Werte fallen damit auf sieben Stufen.
h4–h6 laufen nicht mehr über Coolvetica. Sie lagen mit 26px und 21px unter der
--fs-650-Grenze aus CLAUDE.md, ab der der Condensed-Schnitt in Uppercase
unleserlich wird — die Regel war also nur dokumentiert, nicht durchsetzbar.
Jetzt tragen h1–h3 Coolvetica (alle über der Grenze) und h4–h6 Abel mit
Sperrung. Bezeichnend: das einzige <h4> im Markup hatte sich genau dieses
Muster bereits von Hand gebaut. Die Basisregel liefert es jetzt, die Komponente
gibt ihre drei Duplikat-Deklarationen ab — sichtbar ändert sich dadurch nichts.
Neue Stufen schließen die Lücken: --fs-550 (fluider Lead), --fs-690 (Karten-
Überschrift) und --fs-1000 (Display-Hero). Vier fast deckungsgleiche clamp()-
Rampen für Karten-Überschriften (bento__heading, bento__stat-value,
group-card__name, team-card__name) laufen jetzt über --fs-690; team-card__name
wächst dabei am Desktop um ~5px, die anderen drei bleiben im 1px-Bereich.
Die Wappen-Initialen im Matchcenter hingen an zwei Mini-Schriftgrößen (10,4 und
11,2px) unterhalb der Skala. Sie sind in Wahrheit ~0,4 × Kreisdurchmesser,
folgen jetzt also --crest-size statt einer eigenen font-size. Nebenbei fallen
drei redundante Doppel-Selektoren weg: .crest--initials trägt immer auch .crest.
Ebenfalls tokenisiert: 27 letter-spacing-Werte mit elf verschiedenen Zahlen auf
vier Stufen (--ls-tight/caps/wide/wider), maximale Abweichung 0,02em.
Danach steht kein font-size- und kein letter-spacing-Wert mehr außerhalb von
tokens.css.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:17:13 +02:00
$file = DATA_PATH . '/' . $name . '.json' ;
if ( ! is_file ( $file )) {
$warnings [] = " data/ { $name } .json fehlt — einmal \" php { $script } \" von Hand anstoßen. " ;
} elseif ( filemtime ( $file ) < time () - 3 * 86400 ) {
$warnings [] = " data/ { $name } .json ist älter als 3 Tage — läuft der Cron für { $script } ? " ;
}
}
Instagram: Scraper raus, Beitraege einzeln ueber oEmbed aufnehmen
bin/instagram-sync.php lieferte seit dem 03.09.2026 bei jedem Lauf "Keine Posts
ermittelbar". Von einem privaten Anschluss gegengeprueft: feed/user und
web_profile_info antworten mit 401 require_login, das Profil-HTML traegt keine
Beitragsdaten mehr, auch /embed/ liefert nur noch eine App-Huelle. Es lag also
nicht an der Server-IP, und der Scraper ist geloescht, damit ihn niemand
wiederbelebt. Graph API (Meta-Konto noetig) und kommerzielle Dienste wurden
verworfen.
Neu: bin/instagram-add.php. --profil liest die Beitragsliste ueber headless
Chrome aus dem gerenderten Profil-DOM (im HTML stehen die Codes nicht) und holt
zu jedem neuen Beitrag Bild, Caption und Datum ueber Instagrams oEmbed-Endpunkt;
das Datum steckt in der media_id. Daneben einzelne URLs, --liste, --entfernen und
--bild als Notfall. Der Shortcode ist Primaerschluessel und damit die komplette
Dedupe-Logik. Die Struktur von data/instagram.json bleibt exakt gleich, deshalb
war an Startseite, News-Feed und Stadionzeitung nichts zu aendern.
Kein Cron mehr: der Job faellt aus public/cron.php, auf dem Webspace gibt es kein
Chrome. Die Chrome-Suche liegt jetzt gemeinsam in bin/chrome-lib.php, genutzt von
bin/logo-build.php und bin/instagram-add.php.
data/instagram.json ist ab jetzt versioniert. Solange sie ignoriert war, lief sie
gegen die versionierten Bilder auseinander: lokal lagen 10 Bilder zu 9 Eintraegen,
drei Beitraege fehlten im Feed, zwei Eintraege hatten kein Bild. Repariert, jetzt
16 Eintraege zu 16 Bildern. bin/preflight.php prueft das in beide Richtungen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9mubC8gtpjtJntbhSJBRS
2026-09-17 11:23:12 +02:00
// --- Instagram: Einträge und Bilder müssen zusammenpassen ---
// Genau daran ist es am 17.09.2026 auseinandergelaufen: die Bilder waren versioniert,
// data/instagram.json nicht. Ein Eintrag ohne Bild ist auf der Startseite eine kaputte
// Kachel, ein Bild ohne Eintrag eine Leiche, die niemand mehr findet.
$igPosts = json_load ( 'instagram' )[ 'posts' ] ? ? [];
$igFehlt = [];
foreach ( $igPosts as $igPost ) {
if ( ! empty ( $igPost [ 'image' ]) && ! is_file ( PUBLIC_PATH . '/assets/' . $igPost [ 'image' ])) {
$igFehlt [] = $igPost [ 'image' ];
}
}
if ( $igFehlt !== []) {
$errors [] = 'Instagram-Beiträge ohne Bild: ' . implode ( ', ' , $igFehlt )
. '. Bild nachholen mit: php bin/instagram-add.php https://www.instagram.com/p/<shortcode>/' ;
}
$igCodes = array_column ( $igPosts , 'shortcode' );
foreach ( glob ( PUBLIC_PATH . '/assets/img/instagram/*.jpg' ) ? : [] as $igDatei ) {
if ( ! in_array ( basename ( $igDatei , '.jpg' ), $igCodes , true )) {
$warnings [] = 'Instagram-Bild ohne Eintrag im Feed: ' . basename ( $igDatei )
. ' — entweder aufnehmen oder löschen.' ;
}
}
Stadionzeitung, Veranstaltungen, News-Artikel, Lightbox und Anzeigen-Verteilung
Stadionzeitung: Live-Seiten (Tabellen, Ergebnisse, Vorschau, Termine, News,
Kontakt, Historie, Sportheim, Partner, Momente, Impressum), automatisches Cover,
Swipe-Viewer mit Vollbildmodus und die Druckfassung als Falt-Heft. Anzeigen
kommen jetzt aus dem Bestand und werden gleichmäßig verteilt, nie mehr als zwei
hintereinander (vorher vier Blöcke à fünf). Die Doppelseiten-Regel rechnet das
Skript selbst, statt sie von Hand zu prüfen.
Veranstaltungen: Bereich /veranstaltungen mit Detailseite pro Fest, Lebenszyklus
über das Datum (Ankündigung vor dem Fest, Rückblick danach), Galerie mit
Lightbox. veranstaltungen.json ist dritte Termin-Quelle, damit ein Fest nie
doppelt gepflegt wird.
News-Artikel als dritte dynamische Prefix-Route, gemeinsamer detail-head.
Behobene Fehler:
- Wappen-Kontexte setzten Zeilen-Layout auf .crest statt auf die Zeile; das
Wappen wurde zum Grid, Vereinsname und Tore rutschten darunter zusammen
(Ergebnis-Rückblick, Vorschau, Cover).
- --header-h (60px) unterschätzt die Kopfleiste um 32px (Logo 72px + 20px
Versatz): neues abgeleitetes --header-space, sonst klebten Zurück-Links
unter dem Logo.
- base_url in der Produktions-Config ohne www, während .htaccess auf www
umleitet; preflight prüft das jetzt.
- .lightbox brauchte display:none mit (0,2,0), sonst lag das Overlay offen.
Neue Werkzeuge: bin/anzeige-add.php (Anzeigen-Bestand), bin/veranstaltung-bilder.php
(Plakate und Galerien, Alt-Texte folgen der Quelldatei), bin/qr.php (Druck-QR mit
Warnung, wenn base_url nicht auf die echte Domain zeigt).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 14:55:27 +02:00
// --- Termin-Quellen: events.json vs. veranstaltungen.json ---
// Ein Fest gehört in genau EINE der beiden Dateien (siehe CLAUDE.md): ohne
// eigene Seite in events.json, mit eigener Seite in veranstaltungen.json.
// Steht es in beiden, taucht es doppelt in der Termine-Liste auf und /termine
// baut zwei Event-Knoten für dasselbe Fest — beides fällt im Alltag kaum auf,
// deshalb prüft es die Maschine.
$eventSlugs = array_filter ( array_column ( json_load ( 'events' )[ 'events' ] ? ? [], 'slug' ));
$veranstaltungRows = json_load ( 'veranstaltungen' )[ 'veranstaltungen' ] ? ? [];
$veranstaltungSlugs = array_column ( $veranstaltungRows , 'slug' );
$doppelt = array_intersect ( $eventSlugs , $veranstaltungSlugs );
if ( $doppelt !== []) {
$errors [] = 'Diese Slugs stehen in data/events.json UND data/veranstaltungen.json: '
. implode ( ', ' , $doppelt ) . ' — der Termin erscheint doppelt. Eintrag in events.json löschen.' ;
}
if ( count ( $veranstaltungSlugs ) !== count ( array_unique ( $veranstaltungSlugs ))) {
$errors [] = 'data/veranstaltungen.json enthält doppelte Slugs — die Detailseite zeigt immer nur den ersten Treffer.' ;
}
foreach ( $veranstaltungRows as $i => $row ) {
if ( empty ( $row [ 'slug' ]) || empty ( $row [ 'title' ]) || empty ( $row [ 'date_start' ])) {
$errors [] = " data/veranstaltungen.json, Eintrag # { $i } : slug, title und date_start sind Pflicht. " ;
}
}
// --- Formular-Empfänger auf eigener Domain ---
// app/pages/datenschutz.php sagt zu, dass wir "keinen zusätzlichen
// E-Mail-Dienstleister" einsetzen. Das stimmt nur, solange jeder Empfänger ein
// Postfach auf unserer Domain bei All-Inkl ist. Zeigt club.json auf einen
// fremden Anbieter (die Turnabteilung lief früher auf Gmail), muss die
// Datenschutzerklärung mit — und das fällt von allein nie auf.
$clubMails = array_filter (
json_load ( 'club' ),
static fn ( $v , string $k ) : bool => str_starts_with ( $k , 'email' ) && is_string ( $v ) && $v !== '' ,
ARRAY_FILTER_USE_BOTH
);
foreach ( $clubMails as $key => $address ) {
if ( ! str_ends_with ( strtolower (( string ) $address ), '@tsv08kulmbach.de' )) {
$warnings [] = " club.json → { $key } ( { $address } ) liegt nicht auf tsv08kulmbach.de. "
. ' Formular-Mails an diese Adresse verlassen die All-Inkl-Auftragsverarbeitung —'
. ' dann muss app/pages/datenschutz.php den Anbieter nennen.' ;
}
}
// --- News (data/news.json) ---
// Meldungen mit `artikel` haben eine eigene Seite /news/<slug>. Die Fehler, die
// dabei still ins Nichts laufen, prüft die Maschine: ein Artikel ohne Slug ist
// nicht erreichbar, ein doppelter Slug verdeckt den zweiten Artikel, ein
// unbekannter Blocktyp verschluckt seinen Inhalt beim Rendern.
$newsRows = json_load ( 'news' )[ 'news' ] ? ? [];
$newsSlugs = array_values ( array_filter ( array_column ( $newsRows , 'slug' )));
$newsBlockTypen = [ 'text' , 'zwischentitel' , 'bild' , 'zitat' ];
if ( count ( $newsSlugs ) !== count ( array_unique ( $newsSlugs ))) {
$errors [] = 'data/news.json enthält doppelte Slugs — die Detailseite zeigt immer nur den ersten Treffer.' ;
}
// Kollision mit einem exakten Routen-Slug: der Prefix-Match für 'news/' läuft in
// public/index.php VOR dem routes.php-Lookup. Ein künftiger Eintrag wie
// 'news/archiv' wäre also von der Detailseite verdeckt, und die Ursache dafür
// sieht man an der weißen Seite nicht.
$routeSlugs = array_keys ( require APP_PATH . '/routes.php' );
$verdeckt = array_intersect ( array_map ( static fn ( string $s ) : string => 'news/' . $s , $newsSlugs ), $routeSlugs );
if ( $verdeckt !== []) {
$errors [] = 'Diese News-Slugs verdecken einen Routen-Eintrag: ' . implode ( ', ' , $verdeckt )
. ' — der Prefix-Match greift vor routes.php. Slug der Meldung ändern.' ;
}
$gleichnamig = array_intersect ( $newsSlugs , $veranstaltungSlugs );
if ( $gleichnamig !== []) {
$warnings [] = 'Diese Slugs stehen in data/news.json UND data/veranstaltungen.json: '
. implode ( ', ' , $gleichnamig ) . ' — die URLs kollidieren nicht (verschiedene Präfixe), aber es sind'
. ' wahrscheinlich zwei Seiten für dieselbe Sache.' ;
}
foreach ( $newsRows as $i => $row ) {
if ( empty ( $row [ 'title' ]) || empty ( $row [ 'date' ])) {
$errors [] = " data/news.json, Eintrag # { $i } : title und date sind Pflicht. " ;
}
if ( ! empty ( $row [ 'date' ]) && ! preg_match ( '/^\d{4}-\d{2}-\d{2}$/' , ( string ) $row [ 'date' ])) {
// Der Feed sortiert Datumsangaben als Strings und german_date() fällt
// still auf den Rohwert zurück — ein abweichendes Format sortiert also
// falsch, ohne dass etwas kaputt aussieht.
$errors [] = " data/news.json, Eintrag # { $i } : date muss YYYY-MM-DD sein. " ;
}
if ( empty ( $row [ 'artikel' ])) {
continue ; // Kurzmeldung ohne Detailseite — ein Slug ist hier erlaubt, aber nicht nötig.
}
if ( empty ( $row [ 'slug' ])) {
$errors [] = " data/news.json, Eintrag # { $i } : mit artikel[] braucht die Meldung einen slug, sonst ist die Detailseite nicht erreichbar. " ;
} elseif ( ! preg_match ( '/^[a-z0-9-]+$/' , ( string ) $row [ 'slug' ])) {
$errors [] = " data/news.json, Eintrag # { $i } : slug darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten. " ;
}
foreach ( $row [ 'artikel' ] as $b => $block ) {
$typ = ( string ) ( $block [ 'typ' ] ? ? '' );
if ( ! in_array ( $typ , $newsBlockTypen , true )) {
$errors [] = " data/news.json, Eintrag # { $i } , Block # { $b } : unbekannter typ ' { $typ } ' — erlaubt sind "
. implode ( ', ' , $newsBlockTypen ) . '. Der Block würde beim Rendern verschluckt.' ;
continue ;
}
if ( $typ === 'bild' && empty ( $block [ 'alt' ])) {
$errors [] = " data/news.json, Eintrag # { $i } , Block # { $b } : Bild ohne alt-Text. " ;
}
}
}
// --- Cron-Endpoint (public/cron.php) ---
// Nur relevant, wenn der Tarif keine Cronjobs hat und der Takt von außen kommt.
// Ohne brauchbaren Schlüssel ist der Endpoint zu (403) und nichts synct — das fällt
// sonst wochenlang nicht auf, weil die Seite mit dem alten Cache normal weiterläuft.
$cronKey = ( string ) config ( 'cron.key' , '' );
$cronInterval = ( int ) config ( 'cron.min_interval' , 240 );
if ( $cronKey === '' || $cronKey === 'CHANGE_ME' ) {
$warnings [] = 'cron.key ist nicht gesetzt — public/cron.php weist jeden Aufruf ab. Nur in Ordnung, wenn der Takt über echte Shell-Cronjobs läuft.' ;
} else {
if ( strlen ( $cronKey ) < 24 ) {
$errors [] = 'cron.key ist kürzer als 24 Zeichen — public/cron.php verweigert den Dienst. Erzeugen: php -r \'echo bin2hex(random_bytes(24)), "\n";\'' ;
}
if ( $cronKey === ( string ) config ( 'app_secret' )) {
$errors [] = 'cron.key ist identisch mit app_secret — der Cron-Schlüssel liegt bei einem externen Dienst und darf nicht die Formular-Token aufschließen.' ;
}
if ( $cronInterval >= 1800 ) {
$warnings [] = " cron.min_interval ist { $cronInterval } s — bei halbstündlichem Takt am Spieltag würde jeder zweite Aufruf verworfen. " ;
}
}
Veröffentlichen friert auch die Bilder ein
Der Snapshot fror die Daten ein, verwies aber weiter auf 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 mit der Zeit von selbst zerfallen, und "gedruckt = fixiert" war damit
nur die halbe Wahrheit.
bin/stadionzeitung-publish.php kopiert jetzt alles, worauf der Snapshot zeigt
(img/crests, img/instagram, img/news), als fix-* in den Ausgaben-Ordner und
schreibt die Pfade um. 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; genau daran fiel img/news/neue-website beim ersten
Versuch auf. --entwurf löscht die fix-*-Dateien wieder, sie gehören zum Snapshot.
Flache Dateinamen statt Unterordner, weil die Aufräum-Logik in
stadionzeitung-add.php nur Dateien entfernt und ein Unterordner als Waise bliebe.
bin/preflight.php warnt, wenn eine veröffentlichte Ausgabe noch auf die
flüchtigen Ordner zeigt — sonst verfiele sie still.
Geprüft am Hutschdorf-Heft: 39 Dateien eingefroren, Snapshot verweist nur noch
auf den Ausgaben-Ordner, 88 Bilder in Viewer und Druckfassung erreichbar, keins
fehlt, preflight-Warnung verschwindet. Der Testlauf wurde danach zurückgenommen:
der lokale Matchcenter-Stand ist einen Tag alt, eingefroren wird auf dem Server
nach dem letzten Sync vor dem Druck.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
2026-07-31 17:47:44 +02:00
// --- Veröffentlichte Stadionzeitungen: Bilder wirklich eingefroren? ---
// Der Snapshot friert die Daten ein. Zeigt er noch auf img/crests oder img/instagram,
// hängt die Ausgabe weiter am Sync: der räumt Wappen weg, die der BFV nicht mehr führt
// (eine Saison), und Instagram-Bilder älterer Posts (Wochen). Am 31.07.2026 löschte ein
// Lauf 18 Wappen und 3 Bilder. Danach zeigte eine gedruckte Ausgabe Lücken.
// bin/stadionzeitung-publish.php kopiert sie deshalb in den Ausgaben-Ordner.
// Reparatur einer Alt-Ausgabe: `--entwurf` und neu veröffentlichen.
$szFile = DATA_PATH . '/stadionzeitung.json' ;
if ( is_file ( $szFile )) {
$szData = json_decode (( string ) file_get_contents ( $szFile ), true ) ? : [];
foreach ( $szData [ 'issues' ] ? ? [] as $ausgabe ) {
if (( $ausgabe [ 'status' ] ? ? 'veroeffentlicht' ) !== 'veroeffentlicht' || ! isset ( $ausgabe [ 'snapshot' ])) {
continue ;
}
$rohSnap = ( string ) json_encode ( $ausgabe [ 'snapshot' ]);
$fluechtig = [];
foreach ([ 'img/crests/' , 'img/instagram/' ] as $ordner ) {
// JSON escapt Schrägstriche, deshalb beide Schreibweisen prüfen.
if ( str_contains ( $rohSnap , $ordner ) || str_contains ( $rohSnap , str_replace ( '/' , '\\/' , $ordner ))) {
$fluechtig [] = rtrim ( $ordner , '/' );
}
}
if ( $fluechtig !== []) {
$warnings [] = " Stadionzeitung ' { $ausgabe [ 'slug' ] } ' ist veröffentlicht, ihr Snapshot zeigt aber noch auf "
. implode ( ' und ' , $fluechtig ) . ' — diese Ordner verwalten die Sync-Skripte, die Bilder verschwinden dort mit der Zeit.'
. ' Einfrieren: php bin/stadionzeitung-publish.php ' . $ausgabe [ 'slug' ] . ' --entwurf && php bin/stadionzeitung-publish.php ' . $ausgabe [ 'slug' ];
}
}
}
Sportfest-Ausgabe der Stadionzeitung zum Heimspiel gegen ATS Wartenfels
Neue Live-Seiten, geschaltet vom chat-gepflegten Feld `veranstaltung` (Slug aus
data/veranstaltungen.json) — Programm und Turnierpläne kommen damit aus derselben
Quelle wie die Website, nicht als Kopie ins Heft:
- Programm-Seite (programm[], rendert zugleich auf /veranstaltungen/sportfest-2026)
- vier Turnierplan-Seiten (turniere[]) mit leerer Ergebnisspalte und leerer
Abschlusstabelle zum Eintragen am Platz
- Gegnerseite je Mannschaft mit Heimspiel am Fest statt nur der tragenden:
stadionzeitung_gegner_fixtures() ist die einzige Stelle, die das entscheidet,
Seitenplanung und Snapshot lesen beide dort. `gegner` ist deshalb eine Map je
Team-Key; die Vorschau überspringt jedes Spiel mit eigener Gegnerseite
- Danke-Seite mit Partner-Logo-Wand aus data/partners.json und QR auf
/partner-werden, dazu das Anzeigen-Feld `platzierung` für Motive mit festem
Platz auf einer Live-Seite (Emons über der Logo-Wand). Vorher klebte der Dank
als leere Fläche unten an einem Anzeigen-Bild
- Cover einer Sonderausgabe: das Fest als Titelgeschichte statt einer der drei
Begegnungen, dazu das Hauptplakat schräg gelegt an Stelle des Spielerfotos
Ausgabe 2026-09-04-ats-wartenfels als ENTWURF angelegt, 48 Seiten (12 Bögen),
Tabellen auf gerader Seite, längster Anzeigenblock zwei. Veröffentlicht wird auf
dem Server, dort liegt der frische Matchcenter-Stand.
Nebenbei behoben: bin/anzeige-add.php räumte beim Anlegen alle Motive weg, deren
Slug mit demselben Text beginnt (`emons` löschte `emons-partnerdank` und
`emons-stuebinger`). Muster jetzt auf die Breiten-Endung eingeschränkt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KubsajzuV6N6QsSd3xKNjC
2026-09-03 10:57:52 +02:00
// --- Stadionzeitung ↔ Veranstaltung (Sportfest-Ausgabe) ---
// Eine Ausgabe mit `veranstaltung`-Feld zieht Programm, Turnierpläne und die
// Gegnerseiten je Heimspiel aus data/veranstaltungen.json. Ein Tippfehler im Slug
// oder im Turnier-Key rendert dort eine LEERE A5-Seite — ohne Fehlermeldung, weil
// die Komponenten bewusst still zurückkehren. Deshalb hier laut.
if ( is_file ( $szFile )) {
$szData = $szData ? ? ( json_decode (( string ) file_get_contents ( $szFile ), true ) ? : []);
$vBySlug = array_column ( $veranstaltungRows , null , 'slug' );
foreach ( $szData [ 'issues' ] ? ? [] as $ausgabe ) {
$vSlug = ( string ) ( $ausgabe [ 'veranstaltung' ] ? ? '' );
if ( $vSlug === '' ) {
continue ;
}
if ( ! isset ( $vBySlug [ $vSlug ])) {
$errors [] = " Stadionzeitung ' { $ausgabe [ 'slug' ] } ': veranstaltung ' { $vSlug } ' fehlt in data/veranstaltungen.json. " ;
continue ;
}
$turniere = $vBySlug [ $vSlug ][ 'turniere' ] ? ? [];
foreach ( $turniere as $i => $turnier ) {
foreach ([ 'key' , 'title' , 'date' , 'teams' , 'spiele' ] as $feld ) {
if ( empty ( $turnier [ $feld ])) {
$warnings [] = " data/veranstaltungen.json, ' { $vSlug } ' → turniere[ { $i } ]: Feld ' { $feld } ' fehlt oder ist leer. " ;
}
}
}
$keys = array_column ( $turniere , 'key' );
foreach ( $ausgabe [ 'images' ] ? ? [] as $seite ) {
if (( $seite [ 'type' ] ? ? '' ) === 'live' && ( $seite [ 'page' ] ? ? '' ) === 'turnier'
&& ! in_array (( string ) ( $seite [ 'turnier' ] ? ? '' ), $keys , true )) {
$errors [] = " Stadionzeitung ' { $ausgabe [ 'slug' ] } ': Turnierseite ' " . ( $seite [ 'turnier' ] ? ? '' ) . " ' hat keinen Eintrag in turniere[] von ' { $vSlug } '. " ;
}
}
}
}
2026-10-02 11:10:57 +02:00
// --- Stadionzeitung: Galerie-Fotos vorhanden? ---
// Die Galerie-Bilder liegen im Ausgaben-Ordner, den bin/stadionzeitung-add.php bei
// jedem Lauf leert und neu füllt. Fehlt eine Datei (Lauf ohne galerie/ im Ordner,
// Bild nicht mitcommittet), zeigt die Seite ein kaputtes Bild.
if ( is_file ( $szFile )) {
$szData = $szData ? ? ( json_decode (( string ) file_get_contents ( $szFile ), true ) ? : []);
foreach ( $szData [ 'issues' ] ? ? [] as $ausgabe ) {
2026-10-24 11:52:57 +02:00
if ( is_array ( $ausgabe [ 'beitrag' ] ? ? null )) {
$beitrag = $ausgabe [ 'beitrag' ];
foreach ([ 'heading' , 'source' , 'source_url' , 'image_credit' ] as $feld ) {
if ( trim (( string ) ( $beitrag [ $feld ] ? ? '' )) === '' ) {
$errors [] = " Stadionzeitung ' { $ausgabe [ 'slug' ] } ': Beitrag ohne { $feld } . " ;
}
}
$quelle = ( string ) ( $beitrag [ 'source_url' ] ? ? '' );
if ( ! filter_var ( $quelle , FILTER_VALIDATE_URL ) || parse_url ( $quelle , PHP_URL_SCHEME ) !== 'https' ) {
$errors [] = " Stadionzeitung ' { $ausgabe [ 'slug' ] } ': Beitrag mit ungültiger Quellen-URL. " ;
}
$foto = $beitrag [ 'image' ] ? ? [];
if ( empty ( $foto [ 'base' ]) || empty ( $foto [ 'widths' ]) || empty ( $foto [ 'alt' ])) {
$errors [] = " Stadionzeitung ' { $ausgabe [ 'slug' ] } ': Beitragsfoto oder Alt-Text fehlt. " ;
}
foreach ( $foto [ 'widths' ] ? ? [] as $w ) {
if ( ! is_file ( PUBLIC_PATH . '/assets/' . ( $foto [ 'base' ] ? ? '' ) . " - { $w } .jpg " )) {
$errors [] = " Stadionzeitung ' { $ausgabe [ 'slug' ] } ': Beitragsfoto fehlt ( { $w } px). " ;
}
}
if ( ! is_file ( PUBLIC_PATH . '/assets/img/stadionzeitung/' . $ausgabe [ 'slug' ] . '/qr-beitrag.png' )) {
$errors [] = " Stadionzeitung ' { $ausgabe [ 'slug' ] } ': Beitrags-QR fehlt. " ;
}
}
2026-10-16 22:50:26 +02:00
if ( is_array ( $ausgabe [ 'rueckblick' ] ? ? null )) {
$rueckblick = $ausgabe [ 'rueckblick' ];
$foto = $rueckblick [ 'image' ] ? ? [];
if ( empty ( $foto [ 'base' ]) || empty ( $foto [ 'widths' ])) {
$errors [] = " Stadionzeitung ' { $ausgabe [ 'slug' ] } ': Foto zum Spielrückblick fehlt. " ;
}
foreach ( $foto [ 'widths' ] ? ? [] as $w ) {
if ( ! is_file ( PUBLIC_PATH . '/assets/' . ( $foto [ 'base' ] ? ? '' ) . " - { $w } .jpg " )) {
$errors [] = " Stadionzeitung ' { $ausgabe [ 'slug' ] } ': Rückblick-Foto fehlt ( { $w } px). " ;
}
}
if ( trim (( string ) ( $rueckblick [ 'credit' ] ? ? '' )) === '' ) {
$errors [] = " Stadionzeitung ' { $ausgabe [ 'slug' ] } ': Bildnachweis zum Spielrückblick fehlt. " ;
}
}
2026-10-02 11:10:57 +02:00
foreach ( $ausgabe [ 'galerie' ][ 'bilder' ] ? ? [] as $bild ) {
foreach ( $bild [ 'widths' ] ? ? [] as $w ) {
$datei = PUBLIC_PATH . '/assets/' . ( $bild [ 'base' ] ? ? '' ) . " - { $w } .jpg " ;
if ( ! is_file ( $datei )) {
$errors [] = " Stadionzeitung ' { $ausgabe [ 'slug' ] } ': Galerie-Foto fehlt ( " . ( $bild [ 'base' ] ? ? '?' ) . " - { $w } .jpg). " ;
}
}
}
}
}
2026-07-31 16:50:43 +02:00
// --- Inline-Styles (die CSP verwirft sie stillschweigend) ---
// Diese Prüfung existiert wegen eines echten Ausfalls am 31.07.2026: der
// Banner-Slider setzte sein Hintergrundbild per style="--banner-bg: url(…)". Auf
// echtem Apache greift die CSP aus public/.htaccess (default-src 'self', KEIN
// style-src), der Browser verwarf das Attribut, die Slides blieben schwarz. Lokal
// war das unsichtbar, weil `php -S` keine .htaccess liest — der Fehler konnte also
// erst in Produktion auffallen. Genau davor schützt diese Wache.
// Hinweis: per JavaScript gesetzte Styles (element.style.…) sind NICHT betroffen,
// die CSP prüft nur vom HTML-Parser gelesene style-Attribute und <style>-Blöcke.
/* Zeilenweise und ohne Tag - Kontext geprüft : ein Muster , das beim öffnenden Tag
ansetzt und mit [ ^> ] * weitersucht , bricht sofort ab — PHP - Templates enthalten
über das schließende PHP - Tag ständig eine spitze Klammer . Ein style - Attribut in
einer PHP - Datei ist praktisch immer echtes Markup , diese Prüfung genügt also .
Dieser Kommentar ist bewusst ein Blockkommentar : die Zeichenfolge des schließenden
PHP - Tags beendet den PHP - Modus AUCH in einem //-Kommentar. Genau daran ist diese
Prüfung beim Bauen einmal gescheitert ( preflight gab ab dort seinen eigenen
Quelltext aus , und php - l meldete nichts , weil das gültiges PHP mit HTML ist ) . */
$inlineStyles = [];
$phpDateien = new RecursiveIteratorIterator ( new RecursiveDirectoryIterator ( ROOT_PATH . '/app' ));
foreach ( $phpDateien as $datei ) {
if ( ! $datei -> isFile () || $datei -> getExtension () !== 'php' ) {
continue ;
}
foreach ( file ( $datei -> getPathname ()) ? : [] as $nr => $zeile ) {
$roh = ltrim ( $zeile );
// Kommentarzeilen überspringen — dieser Abschnitt selbst nennt style=" ja auch.
if ( $roh === '' || str_starts_with ( $roh , '*' ) || str_starts_with ( $roh , '//' ) || str_starts_with ( $roh , '/*' )) {
continue ;
}
if ( preg_match ( '/\sstyle\s*=\s*"/i' , $zeile ) === 1 ) {
$kurz = str_replace ( ROOT_PATH . '/' , '' , $datei -> getPathname ());
$inlineStyles [] = $kurz . ':' . ( $nr + 1 );
break ;
}
}
}
if ( $inlineStyles !== []) {
$errors [] = 'Inline-Style im Markup — die CSP (kein style-src) verwirft ihn, die Regel greift NUR in Produktion: '
. implode ( ', ' , $inlineStyles )
. '. Bilder als <img> ausgeben, Farben als Token in tokens.css plus Klasse in components.css.' ;
}
Logo-Downloadseite /logo mit Erzeuger-Skript
Damit die Abteilungen das Wappen selbst holen können, statt jedes Mal zu fragen:
drei Varianten (farbig, einfarbig schwarz, einfarbig weiß) in SVG, PDF, PNG und JPG,
dazu ein ZIP über alles und die Vereinsfarben. Verlinkt nur in der Fußzeile.
Bisher lag das Logo genau einmal im Projekt, als eine einzige SVG. Es gab kein PNG,
kein JPG, kein PDF und keine einfarbige Fassung.
bin/logo-build.php erzeugt alle Dateien aus einer Master-SVG unter storage/logo-quellen/.
Gerastert wird mit headless Chrome, weil GD kein SVG lesen kann und weder Inkscape noch
rsvg-convert noch ImageMagick verfügbar sind; das PDF ist dabei echter Vektor. Das Skript
läuft einmalig lokal, nichts davon im Request-Pfad. bin/preflight.php meldet als Fehler,
wenn eine Download-Datei fehlt: die Komponente ließe den Knopf sonst still weg.
Die beiden einfarbigen Fassungen sind exakte Negative voneinander, nur Linien, alle
Flächen frei. EPS gibt es bewusst nicht, dafür fehlt jedes Werkzeug; SVG und PDF decken
die Druckereien ab. Frühere Vereinslogos sind vorbereitet, aber im Projekt liegt bisher
kein einziges historisches Wappen vor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J9mubC8gtpjtJntbhSJBRS
2026-09-17 10:10:08 +02:00
// --- Logo-Downloads (/logo) ---
// Ein fehlender Download sieht man der Seite nicht an: die Komponente lässt den Knopf
// still weg, die Seite bleibt heil und niemand merkt, dass eine Variante fehlt. Deshalb
// ein Fehler und keine Warnung. Erzeugt werden die Dateien mit bin/logo-build.php.
$logo = json_load ( 'logo' );
$logoDir = PUBLIC_PATH . '/assets/downloads/logo' ;
$logoFehlt = [];
foreach ( array_merge ( $logo [ 'varianten' ] ? ? [], $logo [ 'retro' ] ? ? []) as $variante ) {
if ( empty ( $variante [ 'key' ])) {
continue ;
}
$basis = 'tsv08-kulmbach-logo-' . $variante [ 'key' ];
// Mindestens diese drei muss es geben; die übrigen Breiten hängen an der Quelle
// (ein gescanntes Altlogo liefert weder SVG noch PDF, siehe bin/logo-build.php).
foreach ([ $basis . '-1000.png' , $basis . '-1000.jpg' ] as $datei ) {
if ( ! is_file ( $logoDir . '/' . $datei )) {
$logoFehlt [] = $datei ;
}
}
}
if ( $logo !== [] && ! is_file ( $logoDir . '/tsv08-kulmbach-logo-paket.zip' )) {
$logoFehlt [] = 'tsv08-kulmbach-logo-paket.zip' ;
}
if ( $logoFehlt !== []) {
$errors [] = 'Logo-Downloads fehlen (/logo zeigt den Knopf dann einfach nicht an): '
. implode ( ', ' , $logoFehlt ) . '. Erzeugen mit: php bin/logo-build.php' ;
}
// Master neuer als das Erzeugte? Dann steht auf der Seite ein altes Logo.
$logoMaster = glob ( STORAGE_PATH . '/logo-quellen/*.svg' ) ? : [];
$logoNeuestesZiel = 0 ;
foreach ( glob ( $logoDir . '/*' ) ? : [] as $datei ) {
$logoNeuestesZiel = max ( $logoNeuestesZiel , ( int ) filemtime ( $datei ));
}
foreach ( $logoMaster as $datei ) {
if (( int ) filemtime ( $datei ) > $logoNeuestesZiel ) {
$warnings [] = 'Logo-Master ist neuer als die Download-Dateien ('
. basename ( $datei ) . ') — php bin/logo-build.php neu laufen lassen.' ;
break ;
}
}
Design-System: Typo-Skala schließen, h4–h6 auf Abel, Icon-Größen tokenisieren
Zweiter Teil der Konsolidierung. Vorher standen 31 font-size-Werte außerhalb
der Skala — beim Durchgehen stellte sich heraus, dass 20 davon gar keine
Textgrößen sind: der icon()-Helper rendert 1em, also steuerte font-size dort
die Icon-Größe. Dafür gibt es jetzt eine eigene, kleine Skala (--icon-s/m/l/xl
in rem, --icon-inline-sm/--icon-inline/--icon-inline-lg in em für Icons, die im
Textfluss mitwachsen sollen). Elf Ad-hoc-Werte fallen damit auf sieben Stufen.
h4–h6 laufen nicht mehr über Coolvetica. Sie lagen mit 26px und 21px unter der
--fs-650-Grenze aus CLAUDE.md, ab der der Condensed-Schnitt in Uppercase
unleserlich wird — die Regel war also nur dokumentiert, nicht durchsetzbar.
Jetzt tragen h1–h3 Coolvetica (alle über der Grenze) und h4–h6 Abel mit
Sperrung. Bezeichnend: das einzige <h4> im Markup hatte sich genau dieses
Muster bereits von Hand gebaut. Die Basisregel liefert es jetzt, die Komponente
gibt ihre drei Duplikat-Deklarationen ab — sichtbar ändert sich dadurch nichts.
Neue Stufen schließen die Lücken: --fs-550 (fluider Lead), --fs-690 (Karten-
Überschrift) und --fs-1000 (Display-Hero). Vier fast deckungsgleiche clamp()-
Rampen für Karten-Überschriften (bento__heading, bento__stat-value,
group-card__name, team-card__name) laufen jetzt über --fs-690; team-card__name
wächst dabei am Desktop um ~5px, die anderen drei bleiben im 1px-Bereich.
Die Wappen-Initialen im Matchcenter hingen an zwei Mini-Schriftgrößen (10,4 und
11,2px) unterhalb der Skala. Sie sind in Wahrheit ~0,4 × Kreisdurchmesser,
folgen jetzt also --crest-size statt einer eigenen font-size. Nebenbei fallen
drei redundante Doppel-Selektoren weg: .crest--initials trägt immer auch .crest.
Ebenfalls tokenisiert: 27 letter-spacing-Werte mit elf verschiedenen Zahlen auf
vier Stufen (--ls-tight/caps/wide/wider), maximale Abweichung 0,02em.
Danach steht kein font-size- und kein letter-spacing-Wert mehr außerhalb von
tokens.css.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU
2026-07-27 10:17:13 +02:00
// --- Ausgabe ---
foreach ( $warnings as $w ) {
echo " WARNUNG { $w } \n " ;
}
foreach ( $errors as $e ) {
echo " FEHLER { $e } \n " ;
}
if ( $errors === []) {
echo $warnings === [] ? " Alles startklar. \n " : " Keine Fehler — nur die Warnungen oben prüfen. \n " ;
echo " Nächster Schritt: php bin/smtp-test.php (Login testen) und je eine Testmail über die drei Formulare. \n " ;
exit ( 0 );
}
echo " \n " . count ( $errors ) . " Fehler offen — noch nicht live gehen. \n " ;
exit ( 1 );