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. " ;
}
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
// --- Datenstände der Cron-Skripte (fehlen nur beim Erstlauf) ---
foreach ([ 'instagram' => 'bin/instagram-sync.php' , 'matchcenter' => 'bin/matchcenter-sync.php' ] as $name => $script ) {
$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 } ? " ;
}
}
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. " ;
}
}
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.' ;
}
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 );