From 3fa521eaf1758867ef7cf60de00774e8edf9ceb0 Mon Sep 17 00:00:00 2001 From: fs Date: Mon, 27 Jul 2026 10:17:13 +0200 Subject: [PATCH] =?UTF-8?q?Design-System:=20Typo-Skala=20schlie=C3=9Fen,?= =?UTF-8?q?=20h4=E2=80=93h6=20auf=20Abel,=20Icon-Gr=C3=B6=C3=9Fen=20tokeni?= =?UTF-8?q?sieren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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

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) Claude-Session: https://claude.ai/code/session_01MSsVdooFLgPA8gPFp7HwZU --- CLAUDE.md | 52 +++++++---- app/pages/datenschutz.php | 46 +++++++--- bin/log-rotate.php | 48 +++++++++++ bin/preflight.php | 92 ++++++++++++++++++++ config/config.example.php | 20 ++++- public/.htaccess | 21 ++++- public/assets/css/base.css | 27 +++++- public/assets/css/components.css | 142 +++++++++++++++---------------- public/assets/css/layout.css | 8 +- public/assets/css/tokens.css | 23 +++++ 10 files changed, 365 insertions(+), 114 deletions(-) create mode 100644 bin/log-rotate.php create mode 100644 bin/preflight.php diff --git a/CLAUDE.md b/CLAUDE.md index d7a727b..d409d74 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,28 +88,48 @@ Sichtbare FAQ + FAQPage-Schema aus derselben `data/*.json`-Quelle, damit Inhalt ## Formulare - **Felder ausschließlich über `component('form-field', ['field' => …])`** (`app/components/form-field.php`) — - nie Feld-Markup von Hand. Die Komponente liefert Label, Pflichtfeld-Stern, Hilfetext und das - Fehler-Element `#-error` (per `aria-describedby` verknüpft) in einheitlichem Markup. + nie Feld-Markup von Hand. Die Komponente liefert Label, Pflichtfeld-Stern, Hilfetext, das + Fehler-Element `#-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, (2) Hinweiszeile „Pflichtfelder sind mit * markiert“, - (3) Honeypot-Feld `website` (visually-hidden) + Hidden-Field `ft` mit `form_token()`, - (4) serverseitige Action unter `app/actions/` mit Whitelist-Validierung + PRG-Redirect. -- `form.js` übernimmt automatisch Inline-Feldfehler (Browser-Validierungsmeldung, `aria-invalid`, - Fokus aufs erste ungültige Feld) — Konvention dafür ist die `#-error`-ID aus der Komponente. + data-form-status` als erstes Form-Kind — mit `autofocus`, wenn `form_has_errors()`, + (2) Honeypot-Feld `company_url` (visually-hidden) + Hidden-Field `ft` mit `form_token()`, + (3) serverseitige Action unter `app/actions/` mit Whitelist-Validierung, (4) Fehlermeldungs-Map + mit den Schlüsseln `validation` / `ratelimit` / `busy` / `mail` (identisch in `form.js`). +- **Antwortmuster der Actions** (in dieser Reihenfolge, siehe `contact-submit.php` als Referenz): + Erfolg und Volumen-Abweisungen → PRG-Redirect (`?sent=1` / `?error=…`), fetch-Requests → JSON. + **Feldfehler ohne JS → `render_form_invalid($slug, $old, $errors)`**: rendert die Seite mit + HTTP 422 samt Eingaben und Feldfehlern neu, statt per Redirect alle Eingaben zu verlieren. + `$errors` ist Feld-**ID** → Meldung (passt auf `#-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 `#-error`-ID aus der Komponente. - A11y-Vorgaben: Feldränder mit `--clr-glass-border-strong` (≥3:1 Kontrast, WCAG 1.4.11), Checkbox min. 24px (WCAG 2.5.8), Status-Region nie per `display:none` verstecken (fliegt aus dem Accessibility-Tree). -- Versand via PHPMailer über All-Inkl-SMTP (`w01ca75d.kasserver.com:587`, STARTTLS, Postfach - `noreply@tsv08kulmbach.de`), Config aus `config/config.php`. Verbindung/Login ohne Mailversand - testen: `php bin/smtp-test.php`. +- **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) Honeypot-Feld `website`, (2) HMAC-signierter Timestamp / Time-Trap (`ft`-Token via - `form_token()`/`form_token_valid()`, `app_secret`), (3) serverseitige Whitelist-Validierung, - (4) **Rate-Limiting** via `rate_limit_ok()` mit Zählern unter `storage/ratelimit/`: - pro IP+Route (5/10 min), globaler Tages-Cap (100/Tag) und Token-Replay-Sperre (3×/Token), - (5) Link-Count-Check im Freitext. Abgewiesene Versuche → `log_spam()` nach `storage/logs/spam.log`. - Immer POST-Redirect-GET; `form.js` macht optional fetch. + (1) `config_problems()` als Not-Aus (ohne echte `config.php` liefe die Seite auf der Vorlage, + deren `app_secret` öffentlich im Repo steht), (2) Honeypot-Feld `company_url`, (3) HMAC-signierter + Timestamp / Time-Trap (`ft`-Token via `form_token()`/`form_token_valid()`, `app_secret`), + (4) serverseitige Whitelist-Validierung, (5) Link-Count-Check im Freitext, (6) **Rate-Limiting** + via `rate_limit_ok()` mit Zählern unter `storage/ratelimit/`: pro IP+Route (5/10 min), + Token-Replay-Sperre (3× pro Token+IP) und globaler Tages-Cap (200/Tag über alle Formulare). + Abgewiesene Versuche → `log_spam()` nach `storage/logs/spam.log`. +- **Reihenfolge und Ton der Abweisungen sind Absicht:** Bot-Signale (Honeypot, Token, Link-Flut) + antworten mit einem stillen „OK“ (kein Feedback-Kanal für Bots). Volumen-Grenzen antworten + **ehrlich** (`ratelimit` / `busy`) — ein stilles „Danke“ ohne Versand verschluckt sonst echte + Anfragen. Die Volumen-Grenzen stehen **nach** der Validierung, damit Tippfehler kein Kontingent + verbrauchen. - Logs unter `storage/logs/`: `mail.log` (Mailversand-Fehler), `spam.log` (abgewiesene Form-Versuche), `instagram.log` / `matchcenter.log` (Sync-Läufe), `php-errors.log`. Fehler nie an Besucher leaken. + Rotation per Cron: `bin/log-rotate.php` (>2 MB → `.log.1`, max. zwei Generationen). +- **Vor jedem Deploy `php bin/preflight.php`** — prüft Config/Secrets, `env`, Schreibrechte, + `vendor/`, PHP-Version und die Cron-Datenstände. Exit 1 = noch nicht live gehen. + Datenschutzerklärung (`app/pages/datenschutz.php`) mitziehen, wenn ein Formular neue Felder + bekommt — sie beschreibt die drei Formulare einzeln. ## Design (aus der alten Seite extrahiert, verifiziert) diff --git a/app/pages/datenschutz.php b/app/pages/datenschutz.php index b4b8197..b628be0 100644 --- a/app/pages/datenschutz.php +++ b/app/pages/datenschutz.php @@ -5,9 +5,10 @@ declare(strict_types=1); /** * Datenschutzerklärung — zugeschnitten auf den tatsächlichen Stack: * komplett self-hosted, keine Tracker/Analyse/Werbe-Dienste, keine externen - * Fonts/Maps/Embeds. Einzige Verarbeitungen: Server-Logs (Hoster), - * Kontaktformular (Versand via Brevo SMTP), server-seitig gecachter - * Instagram-Feed (Bilder von unserer Domain). Vereinsdaten aus club.json. + * Fonts/Maps/Embeds. Einzige Verarbeitungen: Server-Logs (Hoster), die drei + * Formulare (Versand über den All-Inkl-Mailserver, also derselbe Auftrags- + * verarbeiter wie beim Hosting), server-seitig gecachter Instagram-Feed + * (Bilder von unserer Domain). Vereinsdaten aus club.json. */ $club = json_load('club'); $addr = $club['address'] ?? []; @@ -125,11 +126,11 @@ $meta = [
-

Kontaktformular & Kontaktaufnahme

+

Formulare & Kontaktaufnahme

- Wenn du uns über das Kontaktformular oder per E-Mail kontaktierst, verarbeiten - wir die von dir angegebenen Daten, um deine Anfrage zu bearbeiten. Über das - Formular sind das: + Wenn du uns über eines unserer Formulare oder per E-Mail kontaktierst, + verarbeiten wir die von dir angegebenen Daten, um deine Anfrage zu bearbeiten. + Über das Kontaktformular sind das:

  • Name (Pflichtangabe)
  • @@ -137,6 +138,20 @@ $meta = [
  • Telefonnummer (freiwillig)
  • dein Interessensgebiet, Betreff und deine Nachricht
+

+ Über den Mitgliedsantrag verarbeiten wir zusätzlich Vorname, + Nachname, Geburtsdatum, Anschrift und die gewählte Beitragsklasse. Diese Angaben + brauchen wir, um die Mitgliedschaft vorzubereiten; die Beitrittserklärung senden + wir dir anschließend per Post zu. Bankdaten erheben wir online bewusst nicht. + Rechtsgrundlage ist hier Art. 6 Abs. 1 lit. b DSGVO (Anbahnung und Durchführung + der Mitgliedschaft). An die von dir angegebene Adresse senden wir außerdem eine + Eingangsbestätigung. +

+

+ Über das Formular Sportheimbuchung verarbeiten wir Name, + E-Mail-Adresse, optional die Telefonnummer sowie Art, Datum und Personenzahl + deiner Veranstaltung, um dir ein Angebot machen zu können. +

Rechtsgrundlage ist deine Einwilligung (Art. 6 Abs. 1 lit. a DSGVO), die du mit dem Absenden erteilst, sowie unser berechtigtes Interesse bzw. die @@ -146,16 +161,19 @@ $meta = [ Aufbewahrungspflichten entgegenstehen.

- Für den technischen Versand der Formular-Nachrichten als E-Mail nutzen wir den - Dienstleister Brevo (Sendinblue GmbH, Köln) als Auftragsverarbeiter. Dabei - werden die im Formular angegebenen Daten an die Mailserver von Brevo - übermittelt. Die Übertragung erfolgt verschlüsselt. + Der technische Versand der Formular-Nachrichten als E-Mail läuft über den + Mailserver unseres Webhosters ALL-INKL.COM (siehe oben) — die Daten bleiben + damit innerhalb der dort genannten Auftragsverarbeitung. Die Übertragung zum + Mailserver ist verschlüsselt (STARTTLS). Einen zusätzlichen + E-Mail-Dienstleister setzen wir nicht ein.

diff --git a/bin/log-rotate.php b/bin/log-rotate.php new file mode 100644 index 0000000..848888d --- /dev/null +++ b/bin/log-rotate.php @@ -0,0 +1,48 @@ +.log.1 (eine + * vorhandene .1 wird ersetzt) und das aktive Log startet leer. Damit bleiben + * höchstens zwei Generationen pro Log liegen — genug zum Nachsehen, ohne dass + * spam.log oder php-errors.log auf dem Shared Hosting unbegrenzt wachsen. + * + * Aufruf: php bin/log-rotate.php [--max-mb=2] + */ +if (PHP_SAPI !== 'cli') { + exit(1); +} + +require dirname(__DIR__) . '/app/bootstrap.php'; + +$maxMb = 2.0; +foreach ($argv as $arg) { + if (preg_match('/^--max-mb=([0-9.]+)$/', $arg, $m) === 1) { + $maxMb = (float) $m[1]; + } +} +$limit = (int) ($maxMb * 1024 * 1024); + +$rotated = 0; +foreach (glob(STORAGE_PATH . '/logs/*.log') ?: [] as $log) { + $size = (int) @filesize($log); + if ($size <= $limit) { + continue; + } + if (!@rename($log, $log . '.1')) { + fwrite(STDERR, 'Rotation fehlgeschlagen: ' . basename($log) . "\n"); + continue; + } + // Neue, leere Datei mit denselben Rechten anlegen, damit PHP/Cron weiterschreiben können. + @touch($log); + @chmod($log, 0664); + echo 'rotiert: ' . basename($log) . ' (' . round($size / 1048576, 1) . " MB)\n"; + $rotated++; +} + +echo $rotated === 0 + ? "Nichts zu rotieren (alle Logs unter {$maxMb} MB).\n" + : "{$rotated} Log(s) rotiert.\n"; diff --git a/bin/preflight.php b/bin/preflight.php new file mode 100644 index 0000000..b4b352a --- /dev/null +++ b/bin/preflight.php @@ -0,0 +1,92 @@ + '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}?"; + } +} + +// --- 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); diff --git a/config/config.example.php b/config/config.example.php index 0a1f25c..be4d4d4 100644 --- a/config/config.example.php +++ b/config/config.example.php @@ -22,9 +22,19 @@ * # damit Ergebnisse und Tabelle zeitnah stehen. * 7,37 13-21 * * 6,0 cd $SITE && $PHP bin/matchcenter-sync.php >> storage/logs/matchcenter.log 2>&1 * - * Beide Skripte sind CLI-only, sperren sich per Lock gegen Parallelläufe und lassen bei - * jedem Fehler den letzten guten Cache stehen — ein fehlgeschlagener Lauf schadet nie. + * # Logrotation: sonntags nachts, damit spam.log/php-errors.log nicht unbegrenzt wachsen. + * 23 3 * * 0 cd $SITE && $PHP bin/log-rotate.php > /dev/null 2>&1 + * + * Alle Skripte sind CLI-only; die Syncs sperren sich per Lock gegen Parallelläufe und lassen + * bei jedem Fehler den letzten guten Cache stehen — ein fehlgeschlagener Lauf schadet nie. * Erstlauf nach dem Deploy einmal von Hand anstoßen (füllt Cache + Bilder/Wappen). + * + * --------------------------------------------------------------------------- + * NACH DEM DEPLOY + * --------------------------------------------------------------------------- + * php bin/preflight.php Config, Secrets, Schreibrechte, vendor/, PHP-Version prüfen + * php bin/smtp-test.php SMTP-Login testen (ohne Mailversand) + * danach je eine Testmail über /kontakt, /mitglied-werden und /sportheimbuchung */ return [ // Kanonische Basis-URL ohne trailing slash (für Canonical, OG, Sitemap). @@ -33,8 +43,12 @@ return [ // 'production' oder 'development' (steuert Fehleranzeige). 'env' => 'production', - // Geheimer Schlüssel für die HMAC-Time-Trap der Formulare. + // Geheimer Schlüssel für die HMAC-Time-Trap der Formulare und den IP-Hash im Spam-Log. // Generieren mit: php -r "echo bin2hex(random_bytes(32));" + // Für Produktion einen EIGENEN Wert erzeugen — nie den aus der lokalen Dev-Config + // übernehmen. Solange hier CHANGE_ME steht, weisen die Formular-Actions jeden + // Versand ab (config_problems()), damit die Time-Trap nie mit einem bekannten + // Schlüssel läuft. 'app_secret' => 'CHANGE_ME', // All-Inkl SMTP (Postfach im KAS anlegen; Host = .kasserver.com). diff --git a/public/.htaccess b/public/.htaccess index fa96563..060ea44 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -1,5 +1,21 @@ -# --- Front Controller --- RewriteEngine On + +# --- Kanonische Adresse: HTTPS erzwingen, nackte Domain auf www --- +# Die Formulare übertragen Name, Adresse und Geburtsdatum — nie über http. +# Der HTTPS-Redirect ist bewusst host-neutral (funktioniert auch auf einer +# Test-/Preview-Domain); nur tsv08kulmbach.de wird hart auf www gezogen. +# Drei unabhängige TLS-Indikatoren, damit kein Redirect-Loop entsteht, falls der +# Hoster die Verschlüsselung anders durchreicht als erwartet. +RewriteCond %{HTTPS} !=on +RewriteCond %{HTTP:X-Forwarded-Proto} !=https +RewriteCond %{SERVER_PORT} !=443 +RewriteCond %{HTTP_HOST} !^(localhost|127\.0\.0\.1)(:[0-9]+)?$ [NC] +RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] + +RewriteCond %{HTTP_HOST} ^tsv08kulmbach\.de$ [NC] +RewriteRule ^ https://www.tsv08kulmbach.de%{REQUEST_URI} [R=301,L] + +# --- Front Controller --- RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^ index.php [L] @@ -10,6 +26,9 @@ RewriteRule ^ index.php [L] Header always set Referrer-Policy "strict-origin-when-cross-origin" Header always set X-Frame-Options "SAMEORIGIN" Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()" + # HSTS nur über HTTPS ausliefern (1 Jahr, ohne includeSubDomains — Subdomains + # wie Webmail liegen nicht in unserer Hand). + Header always set Strict-Transport-Security "max-age=31536000" env=HTTPS # Streng möglich, weil ausnahmslos alles self-hosted ist (CLAUDE.md Regel 1). Header always set Content-Security-Policy "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'" diff --git a/public/assets/css/base.css b/public/assets/css/base.css index fa82d73..d967b40 100644 --- a/public/assets/css/base.css +++ b/public/assets/css/base.css @@ -45,19 +45,40 @@ h3, h4, h5, h6 { - font-family: var(--ff-heading); font-weight: var(--fw-regular); line-height: var(--lh-heading); text-transform: uppercase; text-wrap: balance; } +/* h1–h3 tragen Coolvetica: alle liegen über der 28px-Grenze (--fs-650). */ +h1, +h2, +h3 { + font-family: var(--ff-heading); +} + h1 { font-size: var(--fs-900); } h2 { font-size: var(--fs-800); } h3 { font-size: var(--fs-700); } -h4 { font-size: 1.625rem; } + +/* h4–h6 laufen über Abel statt Coolvetica. Coolvetica ist Condensed mit nur + einem Schnitt und wird in Uppercase unter --fs-650 (28px) unleserlich — diese + Ebenen liegen aber naturgemäß darunter. Abel + Sperrung hält sie lesbar und + trotzdem als Auszeichnung erkennbar; die Regel aus CLAUDE.md kann hier damit + gar nicht mehr verletzt werden. Zeilenhöhe zurück auf Fließtext-Maß, weil + --lh-heading (0.9) nur für die extremen Coolvetica-Größen gedacht ist. */ +h4, h5, -h6 { font-size: var(--fs-600); } +h6 { + font-family: var(--ff-body); + line-height: 1.2; + letter-spacing: var(--ls-caps); +} + +h4 { font-size: var(--fs-650); } +h5 { font-size: var(--fs-600); } +h6 { font-size: var(--fs-500); } a { diff --git a/public/assets/css/components.css b/public/assets/css/components.css index 09dfcb8..5f8f4b5 100644 --- a/public/assets/css/components.css +++ b/public/assets/css/components.css @@ -90,7 +90,7 @@ display: block; margin-top: var(--space-xs); font-size: var(--fs-600); - letter-spacing: 0.14em; + letter-spacing: var(--ls-wide); } .hero__text { @@ -155,14 +155,14 @@ margin-bottom: var(--space-m); font-size: var(--fs-500); text-transform: uppercase; - letter-spacing: 0.18em; + letter-spacing: var(--ls-wider); /* Sperrung optisch kompensieren (Spacing nach letztem Zeichen). */ text-indent: 0.18em; color: rgb(255 255 255 / 0.85); } .hero--display .hero__title { - font-size: clamp(3.5rem, 2rem + 7.5vw, 7.5rem); + font-size: var(--fs-1000); } /* Subline ruhig in Abel, Groß-/Kleinschreibung — konkurriert nicht mehr mit @@ -170,7 +170,7 @@ .hero--display .hero__tagline { margin-top: var(--space-xs); font-family: var(--ff-body); - font-size: clamp(1.25rem, 0.95rem + 1.3vw, 1.75rem); + font-size: var(--fs-550); font-weight: 400; letter-spacing: 0; text-indent: 0; @@ -189,7 +189,7 @@ width: 44px; height: 44px; color: var(--clr-text-muted); - font-size: 1.4rem; + font-size: var(--icon-l); transition: color var(--transition); animation: scroll-cue-bounce 2.4s ease-in-out infinite; } @@ -277,7 +277,7 @@ height: 2rem; background: rgb(0 0 0 / 0.6); border-radius: 50%; - font-size: 0.8rem; + font-size: var(--fs-300); color: var(--clr-text); } @@ -432,7 +432,7 @@ .stats__label { font-size: var(--fs-500); text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: var(--ls-caps); color: var(--clr-text-muted); } @@ -473,7 +473,7 @@ font-family: var(--ff-heading); /* Coolvetica unter ~28px wird unleserlich (Projekt-Faustregel) */ font-size: var(--fs-650); - letter-spacing: 0.06em; + letter-spacing: var(--ls-caps); text-transform: uppercase; text-decoration: none; color: var(--clr-text-muted); @@ -680,7 +680,7 @@ border-radius: 8px; display: grid; place-items: center; - font-size: 1.1rem; + font-size: var(--icon-s); color: #fff; margin-top: var(--space-3xs); } @@ -758,7 +758,7 @@ font-family: var(--ff-body); font-size: var(--fs-300); font-weight: var(--fw-bold); - letter-spacing: 0.12em; + letter-spacing: var(--ls-wide); text-transform: uppercase; color: var(--clr-text-muted); margin: 0 0 var(--space-s); @@ -771,7 +771,7 @@ border-radius: 8px; display: grid; place-items: center; - font-size: 1.25rem; + font-size: var(--icon-m); color: #fff; margin-bottom: var(--space-m); } @@ -817,7 +817,7 @@ .sponsor-channels__check { flex-shrink: 0; - font-size: 1rem; + font-size: var(--icon-s); color: var(--clr-accent); } @@ -942,7 +942,7 @@ .person-card__department { font-size: var(--fs-650); - letter-spacing: 0.04em; + letter-spacing: var(--ls-tight); } .person-card__name { @@ -1109,7 +1109,7 @@ border-radius: 8px; display: grid; place-items: center; - font-size: 1.15rem; + font-size: var(--icon-m); color: #fff; } @@ -1123,7 +1123,7 @@ font-family: var(--ff-body); font-size: var(--fs-300); font-weight: var(--fw-bold); - letter-spacing: 0.12em; + letter-spacing: var(--ls-wide); text-transform: uppercase; color: var(--clr-text-muted); margin: 0 0 var(--space-3xs); @@ -1139,7 +1139,7 @@ .position-card__chevron { flex: none; - font-size: 0.9rem; + font-size: var(--icon-s); color: var(--clr-text-muted); transition: transform var(--transition), color var(--transition); } @@ -1190,7 +1190,7 @@ .position-card__check { flex-shrink: 0; margin-top: 0.2em; - font-size: 1rem; + font-size: var(--icon-s); color: var(--clr-accent); } @@ -1211,7 +1211,7 @@ gap: var(--space-2xs); font-size: var(--fs-300); font-weight: var(--fw-bold); - letter-spacing: 0.08em; + letter-spacing: var(--ls-caps); text-transform: uppercase; color: var(--clr-text-muted); } @@ -1457,7 +1457,7 @@ padding: 0 var(--space-2xs); font-size: var(--fs-500); text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: var(--ls-caps); color: var(--clr-text); } @@ -1490,7 +1490,7 @@ .ad-banner__meta-label { font-size: var(--fs-400); color: var(--clr-text-muted); - letter-spacing: 0.03em; + letter-spacing: var(--ls-tight); } .ad-banner__slide[hidden] { @@ -1660,7 +1660,7 @@ padding: 0.2em 0.7em; font-size: var(--fs-300); text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: var(--ls-caps); color: var(--clr-text-muted); background: rgb(0 0 0 / 0.45); border: 1px solid rgb(255 255 255 / 0.18); @@ -1754,7 +1754,7 @@ width: 2.25rem; height: 2.25rem; padding: 0; - font-size: 1rem; + font-size: var(--icon-s); color: var(--clr-text); background: var(--clr-glass-bg); border: 1px solid var(--clr-glass-border); @@ -1782,7 +1782,7 @@ } .ad-banner__toggle-icon { - font-size: 1rem; + font-size: var(--icon-s); } /* Pause-Icon im Abspiel-Zustand, Play-Icon wenn pausiert. */ @@ -1878,7 +1878,7 @@ .key-facts__icon { display: inline-flex; - font-size: 1.5rem; + font-size: var(--icon-l); color: var(--clr-accent); } @@ -1890,7 +1890,7 @@ .key-facts__label { font-size: var(--fs-400); text-transform: uppercase; - letter-spacing: 0.06em; + letter-spacing: var(--ls-caps); color: var(--clr-text-muted); } @@ -1960,13 +1960,13 @@ align-items: center; gap: var(--space-xs); margin-bottom: var(--space-l); - font-size: clamp(2rem, 1.6rem + 1.2vw, 2.6rem); + font-size: var(--fs-690); line-height: var(--lh-heading); } .bento__hicon { flex: none; - font-size: 1.6rem; + font-size: var(--icon-l); color: var(--clr-accent); } @@ -2012,13 +2012,13 @@ .bento__stat-label { font-size: var(--fs-500); text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: var(--ls-caps); color: rgb(255 255 255 / 0.85); } .bento__stat-value { font-family: var(--ff-heading); - font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem); + font-size: var(--fs-690); line-height: var(--lh-heading); color: #fff; } @@ -2034,7 +2034,7 @@ right: -1rem; bottom: -1.75rem; z-index: 0; - font-size: 9rem; + font-size: var(--fs-deco); line-height: 1; color: var(--clr-text); opacity: 0.06; @@ -2120,7 +2120,7 @@ .turnen-dash__notice-icon { flex: none; margin-top: 0.1em; - font-size: 1.5rem; + font-size: var(--icon-l); } /* Trainingsort: Adresse links, Route-Button rechts (wie team-bento__venue). */ @@ -2322,7 +2322,7 @@ } .group-card__name { - font-size: clamp(2.2rem, 1.7rem + 1.5vw, 2.8rem); + font-size: var(--fs-690); line-height: var(--lh-heading); } @@ -2331,7 +2331,7 @@ padding: var(--space-4xs) var(--space-2xs); font-size: var(--fs-400); text-transform: uppercase; - letter-spacing: 0.07em; + letter-spacing: var(--ls-caps); color: var(--clr-text-muted); border: 1px solid var(--clr-glass-border-strong); border-radius: 999px; @@ -2347,15 +2347,13 @@ } /* Detail-Abschnitte: Akzent-Randstrich links als visueller Anker */ +/* Font-Familie, Uppercase und Sperrung kommen jetzt aus der h4-Basisregel — + hier bleibt nur, was diese Überschrift von der Basis unterscheidet. */ .group-card__detail-heading { margin: var(--space-3xl) 0 0; padding-left: var(--space-xs); border-left: 2px solid var(--clr-accent); - font-family: var(--ff-body); font-size: var(--fs-500); - font-weight: 400; - text-transform: uppercase; - letter-spacing: 0.07em; color: var(--clr-text); } @@ -2398,7 +2396,7 @@ gap: var(--space-2xs); margin: 0 0 var(--space-s); font-size: var(--fs-650); - letter-spacing: 0.01em; + letter-spacing: var(--ls-tight); } .group-card__schedule-icon { @@ -2439,7 +2437,7 @@ margin-bottom: var(--space-3xs); font-size: var(--fs-500); text-transform: uppercase; - letter-spacing: 0.07em; + letter-spacing: var(--ls-caps); color: var(--clr-accent); font-family: var(--ff-body); } @@ -2563,7 +2561,7 @@ .faq__chevron { flex: none; - font-size: 0.9rem; + font-size: var(--icon-s); color: var(--clr-text-muted); transition: transform var(--transition), color var(--transition); } @@ -2696,7 +2694,7 @@ padding: var(--space-3xs) var(--space-xs); font-family: var(--ff-body); font-size: var(--fs-400); - letter-spacing: 0.12em; + letter-spacing: var(--ls-wide); text-transform: uppercase; color: #fff; background: var(--clr-accent); @@ -2777,14 +2775,14 @@ .team-card__name { /* Coolvetica/Heading (siehe CLAUDE.md) — bewusst groß und prägnant. */ - font-size: clamp(1.9rem, 1.4rem + 1.4vw, 2.4rem); + font-size: var(--fs-690); line-height: var(--lh-heading); } .team-card__meta { font-size: var(--fs-500); text-transform: uppercase; - letter-spacing: 0.06em; + letter-spacing: var(--ls-caps); color: var(--clr-text-muted); } @@ -2898,7 +2896,7 @@ .roster-card__role { font-size: var(--fs-400); text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: var(--ls-caps); color: var(--clr-accent); } @@ -3253,7 +3251,7 @@ margin-bottom: var(--space-m); font-size: var(--fs-500); text-transform: uppercase; - letter-spacing: 0.18em; + letter-spacing: var(--ls-wider); } .legal__kicker::before { @@ -3399,7 +3397,7 @@ .department-choice__subtitle { font-size: var(--fs-500); text-transform: uppercase; - letter-spacing: 0.1em; + letter-spacing: var(--ls-wide); color: var(--clr-accent); } @@ -3417,7 +3415,7 @@ } .department-choice__arrow { - font-size: 0.9em; + font-size: var(--icon-inline); } /* === fee-table (Beitragstabellen) === */ @@ -3547,7 +3545,7 @@ .membership-notes__icon { flex: none; margin-top: var(--space-4xs); - font-size: 1.5rem; + font-size: var(--icon-l); line-height: 1; color: var(--clr-accent); } @@ -3578,7 +3576,7 @@ } .membership-download__icon { - font-size: 0.9em; + font-size: var(--icon-inline); } .membership-download__hint, @@ -3631,11 +3629,15 @@ /* Vereinswappen — gemeinsam für Karten und Tabelle; fehlt das Bild, rendert die Komponente einen Initialen-Kreis (.crest--initials) statt eines kaputten . */ /* Weißes Chip hält auch helle/weiße Logos auf dunklem Grund sichtbar. */ +/* Größe läuft über --crest-size: Kontexte setzen nur diese eine Variable, Breite, + Höhe und die Initialen-Schriftgröße folgen daraus. .crest--initials trägt immer + auch .crest, deshalb genügt überall der Basis-Selektor. */ .crest { + --crest-size: 2.25rem; box-sizing: border-box; flex: none; - width: 2.25rem; - height: 2.25rem; + width: var(--crest-size); + height: var(--crest-size); padding: 3px; object-fit: contain; background: #fff; @@ -3647,7 +3649,10 @@ place-items: center; padding: 0; font-family: var(--ff-body); - font-size: var(--fs-300); + /* Initialen als Anteil des Durchmessers statt als eigene Stufe der Typo-Skala: + so bleibt der Kreis in jeder Größe gleich proportioniert, und es entstehen + keine Mini-Schriftgrößen unterhalb von --fs-300. */ + font-size: calc(var(--crest-size) * 0.4); font-weight: var(--fw-bold); line-height: 1; color: var(--clr-text-muted); @@ -3673,7 +3678,7 @@ gap: 0.5em; font-size: var(--fs-400); text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: var(--ls-caps); color: var(--clr-text-muted); } @@ -3694,11 +3699,8 @@ min-width: 0; /* lange Vereinsnamen dürfen umbrechen statt zu überlaufen */ } -.match-feature .crest, -.match-feature .crest--initials { - width: clamp(2.25rem, 1.6rem + 2vw, 3rem); - height: clamp(2.25rem, 1.6rem + 2vw, 3rem); - font-size: var(--fs-400); +.match-feature .crest { + --crest-size: clamp(2.25rem, 1.6rem + 2vw, 3rem); } /* Die Vereinsnamen sind das einzige Coolvetica-Element („wer"), alles Beschreibende @@ -3747,7 +3749,7 @@ font-family: var(--ff-body); font-size: var(--fs-400); text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: var(--ls-caps); color: var(--clr-text-muted); } @@ -3894,7 +3896,7 @@ a.match-row__link:hover { .match-row__meta { font-size: var(--fs-300); text-transform: uppercase; - letter-spacing: 0.06em; + letter-spacing: var(--ls-caps); color: var(--clr-text-faint); } @@ -3912,11 +3914,8 @@ a.match-row__link:hover { min-width: 0; } -.match-row__team .crest, -.match-row__team .crest--initials { - width: 1.6rem; - height: 1.6rem; - font-size: 0.65rem; +.match-row__team .crest { + --crest-size: 1.6rem; } .match-row__name { @@ -4006,7 +4005,7 @@ a.match-row__link:hover { font-weight: var(--fw-bold); font-size: var(--fs-300); text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: var(--ls-tight); color: var(--clr-text-muted); white-space: nowrap; } @@ -4034,11 +4033,8 @@ a.match-row__link:hover { font-weight: var(--fw-regular); } -.league-table tbody .crest, -.league-table tbody .crest--initials { - width: 1.75rem; - height: 1.75rem; - font-size: 0.7rem; +.league-table tbody .crest { + --crest-size: 1.75rem; } /* Lange Vereinsnamen sauber kürzen (voller Name im title-Attribut). */ @@ -4143,7 +4139,7 @@ a.match-row__link:hover { } .matchcenter-empty__icon { - font-size: 2.5rem; + font-size: var(--icon-xl); color: var(--clr-accent); } @@ -4231,7 +4227,7 @@ a.match-row__link:hover { .sportheim-bento__addr-label { font-size: var(--fs-300); font-weight: var(--fw-bold); - letter-spacing: 0.1em; + letter-spacing: var(--ls-wide); text-transform: uppercase; color: var(--clr-text-muted); margin: 0 0 var(--space-xs); diff --git a/public/assets/css/layout.css b/public/assets/css/layout.css index 57fb841..362a13c 100644 --- a/public/assets/css/layout.css +++ b/public/assets/css/layout.css @@ -142,7 +142,7 @@ } .site-nav__chevron { - font-size: 0.7em; + font-size: var(--icon-inline-sm); transition: transform var(--transition); } @@ -508,7 +508,7 @@ width: 48px; height: 48px; color: var(--clr-text); - font-size: 1.25rem; + font-size: var(--icon-m); background: rgb(10 10 10 / 0.82); border: 1px solid var(--clr-glass-border-strong); border-radius: 50%; @@ -607,7 +607,7 @@ } .site-footer__social .icon { - font-size: 1.25em; + font-size: var(--icon-inline-lg); color: var(--clr-text); } @@ -615,7 +615,7 @@ braucht hier mehr Größe + Laufweite für Lesbarkeit */ .site-footer__heading { font-size: var(--fs-650); - letter-spacing: 0.06em; + letter-spacing: var(--ls-caps); margin-bottom: var(--space-s); padding-bottom: var(--space-2xs); position: relative; diff --git a/public/assets/css/tokens.css b/public/assets/css/tokens.css index f2e964d..635784b 100644 --- a/public/assets/css/tokens.css +++ b/public/assets/css/tokens.css @@ -32,18 +32,41 @@ --fs-300: 0.8125rem; /* 13px Buttons klein */ --fs-400: 0.9375rem; /* 15px Footer, Meta */ --fs-500: 1.125rem; /* 18px Navigation */ + --fs-550: clamp(1.25rem, 0.95rem + 1.3vw, 1.75rem); /* 20–28px fluider Lead/Tagline */ --fs-600: 1.3125rem; /* 21px Body */ --fs-650: 1.75rem; /* 28px Zwischengröße (z.B. Footer-Headings) */ --fs-675: 2.125rem; /* 34px Header-Wortmarke */ + --fs-690: clamp(2rem, 1.5rem + 1.6vw, 2.75rem); /* 32–44px Karten-/Kachel-Überschrift */ --fs-700: clamp(2rem, 1.2rem + 3.5vw, 3.125rem); /* 50px h3 */ /* Mobil-Minima bewusst hoch: Coolvetica Condensed wirkt bei gleicher nomineller Größe deutlich kleiner als normale Schnitte. */ --fs-800: clamp(2.625rem, 1.3rem + 4.5vw, 3.75rem); /* 42–60px h2 */ --fs-900: clamp(3.5rem, 1.75rem + 5.5vw, 6rem); /* 56–96px h1 */ + --fs-1000: clamp(3.5rem, 2rem + 7.5vw, 7.5rem); /* 56–120px Display-Hero (nur Startseite) */ + --fs-deco: 9rem; /* dekorative Ziffer hinter Bento-Kacheln */ --lh-heading: 0.9; --lh-body: 1.5; --fw-regular: 400; --fw-bold: 700; + /* Sperrung. Coolvetica ist Condensed und hat nur einen Schnitt: unter 28px + wird es in Uppercase unleserlich (siehe CLAUDE.md). Kleinere Auszeichnungen + laufen deshalb über --ff-body + uppercase + --ls-caps statt --ff-heading. */ + --ls-tight: 0.03em; /* dezente Straffung in Fließtext-Größen */ + --ls-caps: 0.08em; /* Standard für gesperrte Uppercase-Auszeichnungen */ + --ls-wide: 0.12em; /* kleine Labels, Meta-Zeilen */ + --ls-wider: 0.18em; /* sehr kleine Kicker/Overlines */ + + /* --- Icon-Größen --- + Der icon()-Helper rendert 1em, die font-size steuert also die Icon-Größe. + rem-Stufen für eigenständige Icons, em-Stufen für Icons, die im Textfluss + mit ihrer Zeile mitwachsen sollen (Chevrons, Pfeile). */ + --icon-s: 1rem; /* 16px */ + --icon-m: 1.25rem; /* 20px */ + --icon-l: 1.5rem; /* 24px */ + --icon-xl: 2.5rem; /* 40px */ + --icon-inline-sm: 0.75em; + --icon-inline: 0.9em; + --icon-inline-lg: 1.25em; /* --- Spacing --- 4px-Raster. EINZIGE Quelle für padding/margin/gap — keine rohen rem-Werte