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
This commit is contained in:
2026-07-27 10:17:13 +02:00
parent 87055bd14b
commit 3fa521eaf1
10 changed files with 365 additions and 114 deletions

View File

@@ -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 `#<id>-error` (per `aria-describedby` verknüpft) in einheitlichem Markup.
nie Feld-Markup von Hand. Die Komponente liefert Label, Pflichtfeld-Stern, Hilfetext, das
Fehler-Element `#<id>-error` (per `aria-describedby` verknüpft) und schreibt nach einem
abgewiesenen POST die alten Werte zurück (`form_old()`, inkl. `selected`/`checked`).
- **Jedes neue Formular braucht:** (1) Status-Region `role="status" aria-live="polite" tabindex="-1"
data-form-status` als erstes Form-Kind, (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 `#<id>-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 `#<id>-error`), `$old` ist Feld-**Name** → Wert.
- `form.js` zeigt Inline-Feldfehler (Browser-Validierung vor dem Absenden, Server-Feldfehler aus
`fields` in der JSON-Antwort) — Konvention dafür ist die `#<id>-error`-ID aus der Komponente.
- A11y-Vorgaben: Feldränder mit `--clr-glass-border-strong` (≥3:1 Kontrast, WCAG 1.4.11), Checkbox
min. 24px (WCAG 2.5.8), Status-Region nie per `display:none` verstecken (fliegt aus dem Accessibility-Tree).
- 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)

View File

@@ -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 = [
</section>
<section>
<h2>Kontaktformular &amp; Kontaktaufnahme</h2>
<h2>Formulare &amp; Kontaktaufnahme</h2>
<p>
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 <strong>Kontaktformular</strong> sind das:
</p>
<ul>
<li>Name (Pflichtangabe)</li>
@@ -137,6 +138,20 @@ $meta = [
<li>Telefonnummer (freiwillig)</li>
<li>dein Interessensgebiet, Betreff und deine Nachricht</li>
</ul>
<p>
Über den <strong>Mitgliedsantrag</strong> 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.
</p>
<p>
Über das Formular <strong>Sportheimbuchung</strong> verarbeiten wir Name,
E-Mail-Adresse, optional die Telefonnummer sowie Art, Datum und Personenzahl
deiner Veranstaltung, um dir ein Angebot machen zu können.
</p>
<p>
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.
</p>
<p>
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.
</p>
<p class="legal__muted">
Hinweis: Zum Schutz vor automatisiertem Spam verwenden wir ein verstecktes
Pflichtfeld sowie einen signierten Zeitstempel. Dabei werden keine
zusätzlichen personenbezogenen Daten erhoben und keine externen Dienste
(z.&nbsp;B. reCAPTCHA) eingebunden.
Pflichtfeld und einen signierten Zeitstempel. Außerdem begrenzen wir, wie oft
von einem Anschluss abgesendet werden kann. Dafür speichern wir deine
IP-Adresse nicht im Klartext, sondern nur als Hashwert, der mit einem
geheimen Schlüssel gebildet wird; die Zähler werden spätestens nach 24 Stunden
gelöscht. Externe Dienste (z.&nbsp;B. reCAPTCHA) binden wir nicht ein.
</p>
</section>

48
bin/log-rotate.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/**
* Logrotation für storage/logs/*.log — NUR CLI, per Cron 1×/Woche.
*
* Wird eine Datei größer als das Limit, wandert sie nach <name>.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";

92
bin/preflight.php Normal file
View File

@@ -0,0 +1,92 @@
<?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.';
}
// --- 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.";
}
}
// --- 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}?";
}
}
// --- 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);

View File

@@ -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 = <KAS-Login>.kasserver.com).

View File

@@ -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'"
</IfModule>

View File

@@ -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;
}
/* h1h3 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; }
/* h4h6 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 {

View File

@@ -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 <img>. */
/* 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);

View File

@@ -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;

View File

@@ -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); /* 2028px 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); /* 3244px 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); /* 4260px h2 */
--fs-900: clamp(3.5rem, 1.75rem + 5.5vw, 6rem); /* 5696px h1 */
--fs-1000: clamp(3.5rem, 2rem + 7.5vw, 7.5rem); /* 56120px 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