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:
52
CLAUDE.md
52
CLAUDE.md
@@ -88,28 +88,48 @@ Sichtbare FAQ + FAQPage-Schema aus derselben `data/*.json`-Quelle, damit Inhalt
|
|||||||
## Formulare
|
## Formulare
|
||||||
|
|
||||||
- **Felder ausschließlich über `component('form-field', ['field' => …])`** (`app/components/form-field.php`) —
|
- **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
|
nie Feld-Markup von Hand. Die Komponente liefert Label, Pflichtfeld-Stern, Hilfetext, das
|
||||||
Fehler-Element `#<id>-error` (per `aria-describedby` verknüpft) in einheitlichem Markup.
|
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"
|
- **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“,
|
data-form-status` als erstes Form-Kind — mit `autofocus`, wenn `form_has_errors()`,
|
||||||
(3) Honeypot-Feld `website` (visually-hidden) + Hidden-Field `ft` mit `form_token()`,
|
(2) Honeypot-Feld `company_url` (visually-hidden) + Hidden-Field `ft` mit `form_token()`,
|
||||||
(4) serverseitige Action unter `app/actions/` mit Whitelist-Validierung + PRG-Redirect.
|
(3) serverseitige Action unter `app/actions/` mit Whitelist-Validierung, (4) Fehlermeldungs-Map
|
||||||
- `form.js` übernimmt automatisch Inline-Feldfehler (Browser-Validierungsmeldung, `aria-invalid`,
|
mit den Schlüsseln `validation` / `ratelimit` / `busy` / `mail` (identisch in `form.js`).
|
||||||
Fokus aufs erste ungültige Feld) — Konvention dafür ist die `#<id>-error`-ID aus der Komponente.
|
- **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
|
- 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).
|
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
|
- **Versand ausschließlich über `send_mail()`** (`helpers.php`) — die einzige Versandstelle der Seite,
|
||||||
`noreply@tsv08kulmbach.de`), Config aus `config/config.php`. Verbindung/Login ohne Mailversand
|
PHPMailer über All-Inkl-SMTP (`w01ca75d.kasserver.com:587`, STARTTLS, Postfach
|
||||||
testen: `php bin/smtp-test.php`.
|
`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:
|
- Spam-Schutz ohne externe Dienste — mehrschichtig in jeder `app/actions/*`-Action:
|
||||||
(1) Honeypot-Feld `website`, (2) HMAC-signierter Timestamp / Time-Trap (`ft`-Token via
|
(1) `config_problems()` als Not-Aus (ohne echte `config.php` liefe die Seite auf der Vorlage,
|
||||||
`form_token()`/`form_token_valid()`, `app_secret`), (3) serverseitige Whitelist-Validierung,
|
deren `app_secret` öffentlich im Repo steht), (2) Honeypot-Feld `company_url`, (3) HMAC-signierter
|
||||||
(4) **Rate-Limiting** via `rate_limit_ok()` mit Zählern unter `storage/ratelimit/`:
|
Timestamp / Time-Trap (`ft`-Token via `form_token()`/`form_token_valid()`, `app_secret`),
|
||||||
pro IP+Route (5/10 min), globaler Tages-Cap (100/Tag) und Token-Replay-Sperre (3×/Token),
|
(4) serverseitige Whitelist-Validierung, (5) Link-Count-Check im Freitext, (6) **Rate-Limiting**
|
||||||
(5) Link-Count-Check im Freitext. Abgewiesene Versuche → `log_spam()` nach `storage/logs/spam.log`.
|
via `rate_limit_ok()` mit Zählern unter `storage/ratelimit/`: pro IP+Route (5/10 min),
|
||||||
Immer POST-Redirect-GET; `form.js` macht optional fetch.
|
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),
|
- 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.
|
`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)
|
## Design (aus der alten Seite extrahiert, verifiziert)
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ declare(strict_types=1);
|
|||||||
/**
|
/**
|
||||||
* Datenschutzerklärung — zugeschnitten auf den tatsächlichen Stack:
|
* Datenschutzerklärung — zugeschnitten auf den tatsächlichen Stack:
|
||||||
* komplett self-hosted, keine Tracker/Analyse/Werbe-Dienste, keine externen
|
* komplett self-hosted, keine Tracker/Analyse/Werbe-Dienste, keine externen
|
||||||
* Fonts/Maps/Embeds. Einzige Verarbeitungen: Server-Logs (Hoster),
|
* Fonts/Maps/Embeds. Einzige Verarbeitungen: Server-Logs (Hoster), die drei
|
||||||
* Kontaktformular (Versand via Brevo SMTP), server-seitig gecachter
|
* Formulare (Versand über den All-Inkl-Mailserver, also derselbe Auftrags-
|
||||||
* Instagram-Feed (Bilder von unserer Domain). Vereinsdaten aus club.json.
|
* verarbeiter wie beim Hosting), server-seitig gecachter Instagram-Feed
|
||||||
|
* (Bilder von unserer Domain). Vereinsdaten aus club.json.
|
||||||
*/
|
*/
|
||||||
$club = json_load('club');
|
$club = json_load('club');
|
||||||
$addr = $club['address'] ?? [];
|
$addr = $club['address'] ?? [];
|
||||||
@@ -125,11 +126,11 @@ $meta = [
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Kontaktformular & Kontaktaufnahme</h2>
|
<h2>Formulare & Kontaktaufnahme</h2>
|
||||||
<p>
|
<p>
|
||||||
Wenn du uns über das Kontaktformular oder per E-Mail kontaktierst, verarbeiten
|
Wenn du uns über eines unserer Formulare oder per E-Mail kontaktierst,
|
||||||
wir die von dir angegebenen Daten, um deine Anfrage zu bearbeiten. Über das
|
verarbeiten wir die von dir angegebenen Daten, um deine Anfrage zu bearbeiten.
|
||||||
Formular sind das:
|
Über das <strong>Kontaktformular</strong> sind das:
|
||||||
</p>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Name (Pflichtangabe)</li>
|
<li>Name (Pflichtangabe)</li>
|
||||||
@@ -137,6 +138,20 @@ $meta = [
|
|||||||
<li>Telefonnummer (freiwillig)</li>
|
<li>Telefonnummer (freiwillig)</li>
|
||||||
<li>dein Interessensgebiet, Betreff und deine Nachricht</li>
|
<li>dein Interessensgebiet, Betreff und deine Nachricht</li>
|
||||||
</ul>
|
</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>
|
<p>
|
||||||
Rechtsgrundlage ist deine Einwilligung (Art. 6 Abs. 1 lit. a DSGVO), die du
|
Rechtsgrundlage ist deine Einwilligung (Art. 6 Abs. 1 lit. a DSGVO), die du
|
||||||
mit dem Absenden erteilst, sowie unser berechtigtes Interesse bzw. die
|
mit dem Absenden erteilst, sowie unser berechtigtes Interesse bzw. die
|
||||||
@@ -146,16 +161,19 @@ $meta = [
|
|||||||
Aufbewahrungspflichten entgegenstehen.
|
Aufbewahrungspflichten entgegenstehen.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Für den technischen Versand der Formular-Nachrichten als E-Mail nutzen wir den
|
Der technische Versand der Formular-Nachrichten als E-Mail läuft über den
|
||||||
Dienstleister Brevo (Sendinblue GmbH, Köln) als Auftragsverarbeiter. Dabei
|
Mailserver unseres Webhosters ALL-INKL.COM (siehe oben) — die Daten bleiben
|
||||||
werden die im Formular angegebenen Daten an die Mailserver von Brevo
|
damit innerhalb der dort genannten Auftragsverarbeitung. Die Übertragung zum
|
||||||
übermittelt. Die Übertragung erfolgt verschlüsselt.
|
Mailserver ist verschlüsselt (STARTTLS). Einen zusätzlichen
|
||||||
|
E-Mail-Dienstleister setzen wir nicht ein.
|
||||||
</p>
|
</p>
|
||||||
<p class="legal__muted">
|
<p class="legal__muted">
|
||||||
Hinweis: Zum Schutz vor automatisiertem Spam verwenden wir ein verstecktes
|
Hinweis: Zum Schutz vor automatisiertem Spam verwenden wir ein verstecktes
|
||||||
Pflichtfeld sowie einen signierten Zeitstempel. Dabei werden keine
|
Pflichtfeld und einen signierten Zeitstempel. Außerdem begrenzen wir, wie oft
|
||||||
zusätzlichen personenbezogenen Daten erhoben und keine externen Dienste
|
von einem Anschluss abgesendet werden kann. Dafür speichern wir deine
|
||||||
(z. B. reCAPTCHA) eingebunden.
|
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. B. reCAPTCHA) binden wir nicht ein.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
48
bin/log-rotate.php
Normal file
48
bin/log-rotate.php
Normal 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
92
bin/preflight.php
Normal 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);
|
||||||
@@ -22,9 +22,19 @@
|
|||||||
* # damit Ergebnisse und Tabelle zeitnah stehen.
|
* # 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
|
* 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
|
* # Logrotation: sonntags nachts, damit spam.log/php-errors.log nicht unbegrenzt wachsen.
|
||||||
* jedem Fehler den letzten guten Cache stehen — ein fehlgeschlagener Lauf schadet nie.
|
* 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).
|
* 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 [
|
return [
|
||||||
// Kanonische Basis-URL ohne trailing slash (für Canonical, OG, Sitemap).
|
// Kanonische Basis-URL ohne trailing slash (für Canonical, OG, Sitemap).
|
||||||
@@ -33,8 +43,12 @@ return [
|
|||||||
// 'production' oder 'development' (steuert Fehleranzeige).
|
// 'production' oder 'development' (steuert Fehleranzeige).
|
||||||
'env' => 'production',
|
'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));"
|
// 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',
|
'app_secret' => 'CHANGE_ME',
|
||||||
|
|
||||||
// All-Inkl SMTP (Postfach im KAS anlegen; Host = <KAS-Login>.kasserver.com).
|
// All-Inkl SMTP (Postfach im KAS anlegen; Host = <KAS-Login>.kasserver.com).
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# --- Front Controller ---
|
|
||||||
RewriteEngine On
|
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} !-f
|
||||||
RewriteCond %{REQUEST_FILENAME} !-d
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
RewriteRule ^ index.php [L]
|
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 Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
Header always set X-Frame-Options "SAMEORIGIN"
|
Header always set X-Frame-Options "SAMEORIGIN"
|
||||||
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
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).
|
# 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'"
|
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>
|
</IfModule>
|
||||||
|
|||||||
@@ -45,19 +45,40 @@ h3,
|
|||||||
h4,
|
h4,
|
||||||
h5,
|
h5,
|
||||||
h6 {
|
h6 {
|
||||||
font-family: var(--ff-heading);
|
|
||||||
font-weight: var(--fw-regular);
|
font-weight: var(--fw-regular);
|
||||||
line-height: var(--lh-heading);
|
line-height: var(--lh-heading);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
text-wrap: balance;
|
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); }
|
h1 { font-size: var(--fs-900); }
|
||||||
h2 { font-size: var(--fs-800); }
|
h2 { font-size: var(--fs-800); }
|
||||||
h3 { font-size: var(--fs-700); }
|
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,
|
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 {
|
a {
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
display: block;
|
display: block;
|
||||||
margin-top: var(--space-xs);
|
margin-top: var(--space-xs);
|
||||||
font-size: var(--fs-600);
|
font-size: var(--fs-600);
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: var(--ls-wide);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero__text {
|
.hero__text {
|
||||||
@@ -155,14 +155,14 @@
|
|||||||
margin-bottom: var(--space-m);
|
margin-bottom: var(--space-m);
|
||||||
font-size: var(--fs-500);
|
font-size: var(--fs-500);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.18em;
|
letter-spacing: var(--ls-wider);
|
||||||
/* Sperrung optisch kompensieren (Spacing nach letztem Zeichen). */
|
/* Sperrung optisch kompensieren (Spacing nach letztem Zeichen). */
|
||||||
text-indent: 0.18em;
|
text-indent: 0.18em;
|
||||||
color: rgb(255 255 255 / 0.85);
|
color: rgb(255 255 255 / 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero--display .hero__title {
|
.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
|
/* Subline ruhig in Abel, Groß-/Kleinschreibung — konkurriert nicht mehr mit
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
.hero--display .hero__tagline {
|
.hero--display .hero__tagline {
|
||||||
margin-top: var(--space-xs);
|
margin-top: var(--space-xs);
|
||||||
font-family: var(--ff-body);
|
font-family: var(--ff-body);
|
||||||
font-size: clamp(1.25rem, 0.95rem + 1.3vw, 1.75rem);
|
font-size: var(--fs-550);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
text-indent: 0;
|
text-indent: 0;
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
color: var(--clr-text-muted);
|
color: var(--clr-text-muted);
|
||||||
font-size: 1.4rem;
|
font-size: var(--icon-l);
|
||||||
transition: color var(--transition);
|
transition: color var(--transition);
|
||||||
animation: scroll-cue-bounce 2.4s ease-in-out infinite;
|
animation: scroll-cue-bounce 2.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -277,7 +277,7 @@
|
|||||||
height: 2rem;
|
height: 2rem;
|
||||||
background: rgb(0 0 0 / 0.6);
|
background: rgb(0 0 0 / 0.6);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
font-size: 0.8rem;
|
font-size: var(--fs-300);
|
||||||
color: var(--clr-text);
|
color: var(--clr-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +432,7 @@
|
|||||||
.stats__label {
|
.stats__label {
|
||||||
font-size: var(--fs-500);
|
font-size: var(--fs-500);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: var(--ls-caps);
|
||||||
color: var(--clr-text-muted);
|
color: var(--clr-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,7 +473,7 @@
|
|||||||
font-family: var(--ff-heading);
|
font-family: var(--ff-heading);
|
||||||
/* Coolvetica unter ~28px wird unleserlich (Projekt-Faustregel) */
|
/* Coolvetica unter ~28px wird unleserlich (Projekt-Faustregel) */
|
||||||
font-size: var(--fs-650);
|
font-size: var(--fs-650);
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: var(--ls-caps);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--clr-text-muted);
|
color: var(--clr-text-muted);
|
||||||
@@ -680,7 +680,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-size: 1.1rem;
|
font-size: var(--icon-s);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
margin-top: var(--space-3xs);
|
margin-top: var(--space-3xs);
|
||||||
}
|
}
|
||||||
@@ -758,7 +758,7 @@
|
|||||||
font-family: var(--ff-body);
|
font-family: var(--ff-body);
|
||||||
font-size: var(--fs-300);
|
font-size: var(--fs-300);
|
||||||
font-weight: var(--fw-bold);
|
font-weight: var(--fw-bold);
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: var(--ls-wide);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--clr-text-muted);
|
color: var(--clr-text-muted);
|
||||||
margin: 0 0 var(--space-s);
|
margin: 0 0 var(--space-s);
|
||||||
@@ -771,7 +771,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-size: 1.25rem;
|
font-size: var(--icon-m);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
margin-bottom: var(--space-m);
|
margin-bottom: var(--space-m);
|
||||||
}
|
}
|
||||||
@@ -817,7 +817,7 @@
|
|||||||
|
|
||||||
.sponsor-channels__check {
|
.sponsor-channels__check {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 1rem;
|
font-size: var(--icon-s);
|
||||||
color: var(--clr-accent);
|
color: var(--clr-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -942,7 +942,7 @@
|
|||||||
|
|
||||||
.person-card__department {
|
.person-card__department {
|
||||||
font-size: var(--fs-650);
|
font-size: var(--fs-650);
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: var(--ls-tight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.person-card__name {
|
.person-card__name {
|
||||||
@@ -1109,7 +1109,7 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-size: 1.15rem;
|
font-size: var(--icon-m);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1123,7 +1123,7 @@
|
|||||||
font-family: var(--ff-body);
|
font-family: var(--ff-body);
|
||||||
font-size: var(--fs-300);
|
font-size: var(--fs-300);
|
||||||
font-weight: var(--fw-bold);
|
font-weight: var(--fw-bold);
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: var(--ls-wide);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--clr-text-muted);
|
color: var(--clr-text-muted);
|
||||||
margin: 0 0 var(--space-3xs);
|
margin: 0 0 var(--space-3xs);
|
||||||
@@ -1139,7 +1139,7 @@
|
|||||||
|
|
||||||
.position-card__chevron {
|
.position-card__chevron {
|
||||||
flex: none;
|
flex: none;
|
||||||
font-size: 0.9rem;
|
font-size: var(--icon-s);
|
||||||
color: var(--clr-text-muted);
|
color: var(--clr-text-muted);
|
||||||
transition: transform var(--transition), color var(--transition);
|
transition: transform var(--transition), color var(--transition);
|
||||||
}
|
}
|
||||||
@@ -1190,7 +1190,7 @@
|
|||||||
.position-card__check {
|
.position-card__check {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-top: 0.2em;
|
margin-top: 0.2em;
|
||||||
font-size: 1rem;
|
font-size: var(--icon-s);
|
||||||
color: var(--clr-accent);
|
color: var(--clr-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1211,7 +1211,7 @@
|
|||||||
gap: var(--space-2xs);
|
gap: var(--space-2xs);
|
||||||
font-size: var(--fs-300);
|
font-size: var(--fs-300);
|
||||||
font-weight: var(--fw-bold);
|
font-weight: var(--fw-bold);
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: var(--ls-caps);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--clr-text-muted);
|
color: var(--clr-text-muted);
|
||||||
}
|
}
|
||||||
@@ -1457,7 +1457,7 @@
|
|||||||
padding: 0 var(--space-2xs);
|
padding: 0 var(--space-2xs);
|
||||||
font-size: var(--fs-500);
|
font-size: var(--fs-500);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: var(--ls-caps);
|
||||||
color: var(--clr-text);
|
color: var(--clr-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1490,7 +1490,7 @@
|
|||||||
.ad-banner__meta-label {
|
.ad-banner__meta-label {
|
||||||
font-size: var(--fs-400);
|
font-size: var(--fs-400);
|
||||||
color: var(--clr-text-muted);
|
color: var(--clr-text-muted);
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: var(--ls-tight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ad-banner__slide[hidden] {
|
.ad-banner__slide[hidden] {
|
||||||
@@ -1660,7 +1660,7 @@
|
|||||||
padding: 0.2em 0.7em;
|
padding: 0.2em 0.7em;
|
||||||
font-size: var(--fs-300);
|
font-size: var(--fs-300);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: var(--ls-caps);
|
||||||
color: var(--clr-text-muted);
|
color: var(--clr-text-muted);
|
||||||
background: rgb(0 0 0 / 0.45);
|
background: rgb(0 0 0 / 0.45);
|
||||||
border: 1px solid rgb(255 255 255 / 0.18);
|
border: 1px solid rgb(255 255 255 / 0.18);
|
||||||
@@ -1754,7 +1754,7 @@
|
|||||||
width: 2.25rem;
|
width: 2.25rem;
|
||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 1rem;
|
font-size: var(--icon-s);
|
||||||
color: var(--clr-text);
|
color: var(--clr-text);
|
||||||
background: var(--clr-glass-bg);
|
background: var(--clr-glass-bg);
|
||||||
border: 1px solid var(--clr-glass-border);
|
border: 1px solid var(--clr-glass-border);
|
||||||
@@ -1782,7 +1782,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ad-banner__toggle-icon {
|
.ad-banner__toggle-icon {
|
||||||
font-size: 1rem;
|
font-size: var(--icon-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pause-Icon im Abspiel-Zustand, Play-Icon wenn pausiert. */
|
/* Pause-Icon im Abspiel-Zustand, Play-Icon wenn pausiert. */
|
||||||
@@ -1878,7 +1878,7 @@
|
|||||||
|
|
||||||
.key-facts__icon {
|
.key-facts__icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
font-size: 1.5rem;
|
font-size: var(--icon-l);
|
||||||
color: var(--clr-accent);
|
color: var(--clr-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1890,7 +1890,7 @@
|
|||||||
.key-facts__label {
|
.key-facts__label {
|
||||||
font-size: var(--fs-400);
|
font-size: var(--fs-400);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: var(--ls-caps);
|
||||||
color: var(--clr-text-muted);
|
color: var(--clr-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1960,13 +1960,13 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
margin-bottom: var(--space-l);
|
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);
|
line-height: var(--lh-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bento__hicon {
|
.bento__hicon {
|
||||||
flex: none;
|
flex: none;
|
||||||
font-size: 1.6rem;
|
font-size: var(--icon-l);
|
||||||
color: var(--clr-accent);
|
color: var(--clr-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2012,13 +2012,13 @@
|
|||||||
.bento__stat-label {
|
.bento__stat-label {
|
||||||
font-size: var(--fs-500);
|
font-size: var(--fs-500);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: var(--ls-caps);
|
||||||
color: rgb(255 255 255 / 0.85);
|
color: rgb(255 255 255 / 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bento__stat-value {
|
.bento__stat-value {
|
||||||
font-family: var(--ff-heading);
|
font-family: var(--ff-heading);
|
||||||
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
|
font-size: var(--fs-690);
|
||||||
line-height: var(--lh-heading);
|
line-height: var(--lh-heading);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
@@ -2034,7 +2034,7 @@
|
|||||||
right: -1rem;
|
right: -1rem;
|
||||||
bottom: -1.75rem;
|
bottom: -1.75rem;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
font-size: 9rem;
|
font-size: var(--fs-deco);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--clr-text);
|
color: var(--clr-text);
|
||||||
opacity: 0.06;
|
opacity: 0.06;
|
||||||
@@ -2120,7 +2120,7 @@
|
|||||||
.turnen-dash__notice-icon {
|
.turnen-dash__notice-icon {
|
||||||
flex: none;
|
flex: none;
|
||||||
margin-top: 0.1em;
|
margin-top: 0.1em;
|
||||||
font-size: 1.5rem;
|
font-size: var(--icon-l);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Trainingsort: Adresse links, Route-Button rechts (wie team-bento__venue). */
|
/* Trainingsort: Adresse links, Route-Button rechts (wie team-bento__venue). */
|
||||||
@@ -2322,7 +2322,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.group-card__name {
|
.group-card__name {
|
||||||
font-size: clamp(2.2rem, 1.7rem + 1.5vw, 2.8rem);
|
font-size: var(--fs-690);
|
||||||
line-height: var(--lh-heading);
|
line-height: var(--lh-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2331,7 +2331,7 @@
|
|||||||
padding: var(--space-4xs) var(--space-2xs);
|
padding: var(--space-4xs) var(--space-2xs);
|
||||||
font-size: var(--fs-400);
|
font-size: var(--fs-400);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.07em;
|
letter-spacing: var(--ls-caps);
|
||||||
color: var(--clr-text-muted);
|
color: var(--clr-text-muted);
|
||||||
border: 1px solid var(--clr-glass-border-strong);
|
border: 1px solid var(--clr-glass-border-strong);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -2347,15 +2347,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Detail-Abschnitte: Akzent-Randstrich links als visueller Anker */
|
/* 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 {
|
.group-card__detail-heading {
|
||||||
margin: var(--space-3xl) 0 0;
|
margin: var(--space-3xl) 0 0;
|
||||||
padding-left: var(--space-xs);
|
padding-left: var(--space-xs);
|
||||||
border-left: 2px solid var(--clr-accent);
|
border-left: 2px solid var(--clr-accent);
|
||||||
font-family: var(--ff-body);
|
|
||||||
font-size: var(--fs-500);
|
font-size: var(--fs-500);
|
||||||
font-weight: 400;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.07em;
|
|
||||||
color: var(--clr-text);
|
color: var(--clr-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2398,7 +2396,7 @@
|
|||||||
gap: var(--space-2xs);
|
gap: var(--space-2xs);
|
||||||
margin: 0 0 var(--space-s);
|
margin: 0 0 var(--space-s);
|
||||||
font-size: var(--fs-650);
|
font-size: var(--fs-650);
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: var(--ls-tight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-card__schedule-icon {
|
.group-card__schedule-icon {
|
||||||
@@ -2439,7 +2437,7 @@
|
|||||||
margin-bottom: var(--space-3xs);
|
margin-bottom: var(--space-3xs);
|
||||||
font-size: var(--fs-500);
|
font-size: var(--fs-500);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.07em;
|
letter-spacing: var(--ls-caps);
|
||||||
color: var(--clr-accent);
|
color: var(--clr-accent);
|
||||||
font-family: var(--ff-body);
|
font-family: var(--ff-body);
|
||||||
}
|
}
|
||||||
@@ -2563,7 +2561,7 @@
|
|||||||
|
|
||||||
.faq__chevron {
|
.faq__chevron {
|
||||||
flex: none;
|
flex: none;
|
||||||
font-size: 0.9rem;
|
font-size: var(--icon-s);
|
||||||
color: var(--clr-text-muted);
|
color: var(--clr-text-muted);
|
||||||
transition: transform var(--transition), color var(--transition);
|
transition: transform var(--transition), color var(--transition);
|
||||||
}
|
}
|
||||||
@@ -2696,7 +2694,7 @@
|
|||||||
padding: var(--space-3xs) var(--space-xs);
|
padding: var(--space-3xs) var(--space-xs);
|
||||||
font-family: var(--ff-body);
|
font-family: var(--ff-body);
|
||||||
font-size: var(--fs-400);
|
font-size: var(--fs-400);
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: var(--ls-wide);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: var(--clr-accent);
|
background: var(--clr-accent);
|
||||||
@@ -2777,14 +2775,14 @@
|
|||||||
|
|
||||||
.team-card__name {
|
.team-card__name {
|
||||||
/* Coolvetica/Heading (siehe CLAUDE.md) — bewusst groß und prägnant. */
|
/* 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);
|
line-height: var(--lh-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
.team-card__meta {
|
.team-card__meta {
|
||||||
font-size: var(--fs-500);
|
font-size: var(--fs-500);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: var(--ls-caps);
|
||||||
color: var(--clr-text-muted);
|
color: var(--clr-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2898,7 +2896,7 @@
|
|||||||
.roster-card__role {
|
.roster-card__role {
|
||||||
font-size: var(--fs-400);
|
font-size: var(--fs-400);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: var(--ls-caps);
|
||||||
color: var(--clr-accent);
|
color: var(--clr-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3253,7 +3251,7 @@
|
|||||||
margin-bottom: var(--space-m);
|
margin-bottom: var(--space-m);
|
||||||
font-size: var(--fs-500);
|
font-size: var(--fs-500);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.18em;
|
letter-spacing: var(--ls-wider);
|
||||||
}
|
}
|
||||||
|
|
||||||
.legal__kicker::before {
|
.legal__kicker::before {
|
||||||
@@ -3399,7 +3397,7 @@
|
|||||||
.department-choice__subtitle {
|
.department-choice__subtitle {
|
||||||
font-size: var(--fs-500);
|
font-size: var(--fs-500);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: var(--ls-wide);
|
||||||
color: var(--clr-accent);
|
color: var(--clr-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3417,7 +3415,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.department-choice__arrow {
|
.department-choice__arrow {
|
||||||
font-size: 0.9em;
|
font-size: var(--icon-inline);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === fee-table (Beitragstabellen) === */
|
/* === fee-table (Beitragstabellen) === */
|
||||||
@@ -3547,7 +3545,7 @@
|
|||||||
.membership-notes__icon {
|
.membership-notes__icon {
|
||||||
flex: none;
|
flex: none;
|
||||||
margin-top: var(--space-4xs);
|
margin-top: var(--space-4xs);
|
||||||
font-size: 1.5rem;
|
font-size: var(--icon-l);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--clr-accent);
|
color: var(--clr-accent);
|
||||||
}
|
}
|
||||||
@@ -3578,7 +3576,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.membership-download__icon {
|
.membership-download__icon {
|
||||||
font-size: 0.9em;
|
font-size: var(--icon-inline);
|
||||||
}
|
}
|
||||||
|
|
||||||
.membership-download__hint,
|
.membership-download__hint,
|
||||||
@@ -3631,11 +3629,15 @@
|
|||||||
/* Vereinswappen — gemeinsam für Karten und Tabelle; fehlt das Bild, rendert die
|
/* Vereinswappen — gemeinsam für Karten und Tabelle; fehlt das Bild, rendert die
|
||||||
Komponente einen Initialen-Kreis (.crest--initials) statt eines kaputten <img>. */
|
Komponente einen Initialen-Kreis (.crest--initials) statt eines kaputten <img>. */
|
||||||
/* Weißes Chip hält auch helle/weiße Logos auf dunklem Grund sichtbar. */
|
/* 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 {
|
||||||
|
--crest-size: 2.25rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 2.25rem;
|
width: var(--crest-size);
|
||||||
height: 2.25rem;
|
height: var(--crest-size);
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@@ -3647,7 +3649,10 @@
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: var(--ff-body);
|
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);
|
font-weight: var(--fw-bold);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--clr-text-muted);
|
color: var(--clr-text-muted);
|
||||||
@@ -3673,7 +3678,7 @@
|
|||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
font-size: var(--fs-400);
|
font-size: var(--fs-400);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: var(--ls-caps);
|
||||||
color: var(--clr-text-muted);
|
color: var(--clr-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3694,11 +3699,8 @@
|
|||||||
min-width: 0; /* lange Vereinsnamen dürfen umbrechen statt zu überlaufen */
|
min-width: 0; /* lange Vereinsnamen dürfen umbrechen statt zu überlaufen */
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-feature .crest,
|
.match-feature .crest {
|
||||||
.match-feature .crest--initials {
|
--crest-size: clamp(2.25rem, 1.6rem + 2vw, 3rem);
|
||||||
width: clamp(2.25rem, 1.6rem + 2vw, 3rem);
|
|
||||||
height: clamp(2.25rem, 1.6rem + 2vw, 3rem);
|
|
||||||
font-size: var(--fs-400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Die Vereinsnamen sind das einzige Coolvetica-Element („wer"), alles Beschreibende
|
/* Die Vereinsnamen sind das einzige Coolvetica-Element („wer"), alles Beschreibende
|
||||||
@@ -3747,7 +3749,7 @@
|
|||||||
font-family: var(--ff-body);
|
font-family: var(--ff-body);
|
||||||
font-size: var(--fs-400);
|
font-size: var(--fs-400);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: var(--ls-caps);
|
||||||
color: var(--clr-text-muted);
|
color: var(--clr-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3894,7 +3896,7 @@ a.match-row__link:hover {
|
|||||||
.match-row__meta {
|
.match-row__meta {
|
||||||
font-size: var(--fs-300);
|
font-size: var(--fs-300);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: var(--ls-caps);
|
||||||
color: var(--clr-text-faint);
|
color: var(--clr-text-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3912,11 +3914,8 @@ a.match-row__link:hover {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-row__team .crest,
|
.match-row__team .crest {
|
||||||
.match-row__team .crest--initials {
|
--crest-size: 1.6rem;
|
||||||
width: 1.6rem;
|
|
||||||
height: 1.6rem;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.match-row__name {
|
.match-row__name {
|
||||||
@@ -4006,7 +4005,7 @@ a.match-row__link:hover {
|
|||||||
font-weight: var(--fw-bold);
|
font-weight: var(--fw-bold);
|
||||||
font-size: var(--fs-300);
|
font-size: var(--fs-300);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: var(--ls-tight);
|
||||||
color: var(--clr-text-muted);
|
color: var(--clr-text-muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -4034,11 +4033,8 @@ a.match-row__link:hover {
|
|||||||
font-weight: var(--fw-regular);
|
font-weight: var(--fw-regular);
|
||||||
}
|
}
|
||||||
|
|
||||||
.league-table tbody .crest,
|
.league-table tbody .crest {
|
||||||
.league-table tbody .crest--initials {
|
--crest-size: 1.75rem;
|
||||||
width: 1.75rem;
|
|
||||||
height: 1.75rem;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Lange Vereinsnamen sauber kürzen (voller Name im title-Attribut). */
|
/* Lange Vereinsnamen sauber kürzen (voller Name im title-Attribut). */
|
||||||
@@ -4143,7 +4139,7 @@ a.match-row__link:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.matchcenter-empty__icon {
|
.matchcenter-empty__icon {
|
||||||
font-size: 2.5rem;
|
font-size: var(--icon-xl);
|
||||||
color: var(--clr-accent);
|
color: var(--clr-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4231,7 +4227,7 @@ a.match-row__link:hover {
|
|||||||
.sportheim-bento__addr-label {
|
.sportheim-bento__addr-label {
|
||||||
font-size: var(--fs-300);
|
font-size: var(--fs-300);
|
||||||
font-weight: var(--fw-bold);
|
font-weight: var(--fw-bold);
|
||||||
letter-spacing: 0.1em;
|
letter-spacing: var(--ls-wide);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--clr-text-muted);
|
color: var(--clr-text-muted);
|
||||||
margin: 0 0 var(--space-xs);
|
margin: 0 0 var(--space-xs);
|
||||||
|
|||||||
@@ -142,7 +142,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.site-nav__chevron {
|
.site-nav__chevron {
|
||||||
font-size: 0.7em;
|
font-size: var(--icon-inline-sm);
|
||||||
transition: transform var(--transition);
|
transition: transform var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,7 +508,7 @@
|
|||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
color: var(--clr-text);
|
color: var(--clr-text);
|
||||||
font-size: 1.25rem;
|
font-size: var(--icon-m);
|
||||||
background: rgb(10 10 10 / 0.82);
|
background: rgb(10 10 10 / 0.82);
|
||||||
border: 1px solid var(--clr-glass-border-strong);
|
border: 1px solid var(--clr-glass-border-strong);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -607,7 +607,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.site-footer__social .icon {
|
.site-footer__social .icon {
|
||||||
font-size: 1.25em;
|
font-size: var(--icon-inline-lg);
|
||||||
color: var(--clr-text);
|
color: var(--clr-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,7 +615,7 @@
|
|||||||
braucht hier mehr Größe + Laufweite für Lesbarkeit */
|
braucht hier mehr Größe + Laufweite für Lesbarkeit */
|
||||||
.site-footer__heading {
|
.site-footer__heading {
|
||||||
font-size: var(--fs-650);
|
font-size: var(--fs-650);
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: var(--ls-caps);
|
||||||
margin-bottom: var(--space-s);
|
margin-bottom: var(--space-s);
|
||||||
padding-bottom: var(--space-2xs);
|
padding-bottom: var(--space-2xs);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -32,18 +32,41 @@
|
|||||||
--fs-300: 0.8125rem; /* 13px Buttons klein */
|
--fs-300: 0.8125rem; /* 13px Buttons klein */
|
||||||
--fs-400: 0.9375rem; /* 15px Footer, Meta */
|
--fs-400: 0.9375rem; /* 15px Footer, Meta */
|
||||||
--fs-500: 1.125rem; /* 18px Navigation */
|
--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-600: 1.3125rem; /* 21px Body */
|
||||||
--fs-650: 1.75rem; /* 28px Zwischengröße (z.B. Footer-Headings) */
|
--fs-650: 1.75rem; /* 28px Zwischengröße (z.B. Footer-Headings) */
|
||||||
--fs-675: 2.125rem; /* 34px Header-Wortmarke */
|
--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 */
|
--fs-700: clamp(2rem, 1.2rem + 3.5vw, 3.125rem); /* 50px h3 */
|
||||||
/* Mobil-Minima bewusst hoch: Coolvetica Condensed wirkt bei gleicher
|
/* Mobil-Minima bewusst hoch: Coolvetica Condensed wirkt bei gleicher
|
||||||
nomineller Größe deutlich kleiner als normale Schnitte. */
|
nomineller Größe deutlich kleiner als normale Schnitte. */
|
||||||
--fs-800: clamp(2.625rem, 1.3rem + 4.5vw, 3.75rem); /* 42–60px h2 */
|
--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-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-heading: 0.9;
|
||||||
--lh-body: 1.5;
|
--lh-body: 1.5;
|
||||||
--fw-regular: 400;
|
--fw-regular: 400;
|
||||||
--fw-bold: 700;
|
--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 ---
|
/* --- Spacing ---
|
||||||
4px-Raster. EINZIGE Quelle für padding/margin/gap — keine rohen rem-Werte
|
4px-Raster. EINZIGE Quelle für padding/margin/gap — keine rohen rem-Werte
|
||||||
|
|||||||
Reference in New Issue
Block a user