Matchcenter: „Nächste Spiele" als Band statt Autoplay-Slider

Der Bereich wirkte deplatziert: der Track war auf 620px begrenzt und zeigte
eine Folie, wodurch die Karte auf breiten Fenstern in ~840px Totraum schwebte;
dazu derselbe Hintergrund wie der Hero direkt darüber, kein Akzent, und ein
einsames „:" zwischen den Wappen, das wie ein fehlendes Ergebnis aussah.

Neu als abgesetztes Band (.section--sunken, nutzt endlich das dafür
vorgesehene, bisher unbenutzte Token --clr-bg-deep) mit roter Oberkante:
links der Aufmacher (match-feature), rechts die folgenden Termine als
verlinkte match-row-Zeilen ohne Kartenfläche.

Der Aufmacher ist die große Variante der Terminzeile — Heim über Gast,
linksbündig, gleiche Struktur wie die Zeilen daneben. Bewusst nur drei
typografische Rollen (Meta-Zeile · Vereinsnamen in Coolvetica · Anstoßzeile
mit Countdown) statt der acht der ersten Fassung, ohne Badges, Icons oder
Button. Die eigene Mannschaft wird über Helligkeit markiert, nicht über
font-weight — Coolvetica hat nur einen Schnitt, bold wäre synthetisch.

match-row bekommt optionale $href/$meta-Props; Raster und Innenabstand liegen
jetzt im __link-Wrapper, damit als Link die ganze Zeilenfläche klickbar ist.
Neue Helper match_when() (ISO + deutscher Countdown, explizit Europe/Berlin,
weil PHP global auf UTC läuft) und match_competition_label().

match-slider.php und match-card.php entfallen — Letztere wurde
ausschließlich vom Slider genutzt. carousel.js treibt damit nur noch den
Banner-Slider der Startseite und wird im Matchcenter nicht mehr geladen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 00:39:57 +02:00
parent bee19a2ddc
commit 66c402d9f7
10 changed files with 461 additions and 401 deletions

View File

@@ -70,7 +70,9 @@ Sichtbare FAQ + FAQPage-Schema aus derselben `data/*.json`-Quelle, damit Inhalt
`config/config.php`), `icon('name', 'klasse', 'label?')` (Inline-SVG aus `public/assets/icons/`, siehe
Abschnitt Icons), `form_token()`/`form_token_valid()` (Time-Trap-Token, siehe Formulare),
`img_intrinsic_attrs('pfad')` (width/height-Attribute aus der Bilddatei für CLS-freie `<img>`,
wenn die Maße nicht schon statisch bekannt sind).
wenn die Maße nicht schon statisch bekannt sind), `match_when('2026-08-01T14:00')` (Anstoß aus
`matchcenter.json``iso` für `<time datetime>`/JS-Ticker + deutscher Countdown-Text, gelesen als
Europe/Berlin, weil PHP global auf UTC läuft).
- **CSS:** 6 Dateien als `<link>` in fester Reihenfolge: tokens → reset → base → layout → components →
utilities. Kein `@import`, kein Inline-Style. Komponenten-Styles in `components.css` mit
Banner-Kommentaren (`/* === hero === */`).
@@ -157,5 +159,14 @@ aus der öffentlichen **BFV-Widget-JSON-API** (`widget-prod.bfv.de`, reverse-eng
Browser-User-Agent + `Referer` nötig (sonst HTTP 418). Eigene-Verein-Erkennung exakt über `permanentId`.
Bei Fehlern bleibt der letzte gute Cache unangetastet (Seite degradiert, bricht nie; ohne Cache rendert
`matchcenter-empty`). Cron 2×/Tag + am Wochenende ½-stündlich nachmittags (siehe `config.example.php`).
Der „Nächste Spiele"-Slider teilt sich den Antrieb `assets/js/carousel.js` (generisch, `[data-carousel]`)
mit dem Banner-Slider. Besucher laden alle Matchcenter-Inhalte ausschließlich von unserer Domain.
Das „Nächste Spiele"-Band (`next-matches`) zeigt links den Aufmacher (`match-feature`: das zeitlich
nächste Spiel aller Mannschaften) und rechts die folgenden Termine als verlinkte `match-row`-Zeilen
(`match-list--plain`, ohne Kartenfläche). **Ein Bauprinzip fürs ganze Band:** der Aufmacher ist die
große Variante der Terminzeile — Heim über Gast, linksbündig, dieselbe Struktur, nur größer.
Genau **drei Schriftrollen**, nicht mehr: Meta-Zeile „Mannschaft · Liga · Heim/Auswärts" (Abel,
uppercase, gesperrt) · Vereinsnamen (Coolvetica, einziges Heading-Element) · Anstoßzeile mit Countdown
via `match_when()` (Abel). Keine Badges, Icons oder Buttons — die Wirkung kommt aus der Größe, Rot nur
als Oberkante des Bandes. Die eigene Mannschaft wird über Helligkeit markiert, nie über `font-weight`
(Coolvetica hat nur einen Schnitt → sonst Fake-Bold). Kein Slider —
`assets/js/carousel.js` treibt nur noch den Banner-Slider der Startseite.
Besucher laden alle Matchcenter-Inhalte ausschließlich von unserer Domain.

View File

@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
/**
* Einzelnes Spiel (Begegnung) als Karte. Props:
* $match Eintrag aus data/matchcenter.json (next_matches[]/last_results[]/upcoming[])
* $variant '' (Default) | 'hero' (groß, für den Slider)
* Aufbau (kicker-Stil): Datum oben mittig, darunter Heim | Ergebnis/Anstoß | Gast,
* die Zeit mittig unter dem Ergebnis. Die Heimmannschaft steht immer links — daher
* kein zusätzlicher Heim/Auswärts-Hinweis. Die eigene Mannschaft wird hervorgehoben.
*/
$variant = $variant ?? '';
$m = $match;
$hasResult = isset($m['goals_home'], $m['goals_away']);
$isHome = !empty($m['is_home']);
$classes = 'match-card';
if ($variant !== '') {
$classes .= ' match-card--' . preg_replace('/[^a-z0-9-]+/', '', strtolower($variant));
}
?>
<article class="<?= $classes ?>">
<?php if (!empty($m['date_display'])): ?>
<p class="match-card__date"><?= icon('calendar-event', 'match-card__date-icon') ?><?= e($m['date_display']) ?></p>
<?php endif; ?>
<div class="match-card__fixture">
<div class="match-card__side<?= $isHome ? ' is-own' : '' ?>">
<?php component('crest', ['src' => $m['home_crest'] ?? null, 'name' => $m['home'] ?? '']); ?>
<span class="match-card__club"><?= e($m['home'] ?? '') ?></span>
</div>
<div class="match-card__center">
<?php if ($hasResult): ?>
<span class="match-card__score"><?= (int) $m['goals_home'] ?><span class="match-card__colon">:</span><?= (int) $m['goals_away'] ?></span>
<?php else: ?>
<span class="match-card__vs" aria-label="gegen">:</span>
<?php endif; ?>
<?php if (!empty($m['time_display'])): ?>
<span class="match-card__time"><?= e($m['time_display']) ?></span>
<?php endif; ?>
</div>
<div class="match-card__side<?= !$isHome ? ' is-own' : '' ?>">
<?php component('crest', ['src' => $m['away_crest'] ?? null, 'name' => $m['away'] ?? '']); ?>
<span class="match-card__club"><?= e($m['away'] ?? '') ?></span>
</div>
</div>
</article>

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
/**
* Aufmacher des „Nächste Spiele"-Bands: das zeitlich nächste Spiel — strukturgleich
* mit match-row (Heim über Gast, linksbündig), nur groß. Ein Bauprinzip für das
* ganze Band; die Wirkung kommt aus der Größe, nicht aus Badges oder Icons.
* Drei Schriftrollen: Meta-Zeile (Abel, gesperrt) · Vereinsnamen (Coolvetica) ·
* Anstoßzeile (Abel). Props:
* $match Eintrag aus data/matchcenter.json → upcoming[]
* $team_name Name der eigenen Mannschaft („1. Mannschaft")
* $league Liganame der Mannschaft (für die Meta-Zeile)
* $href Sprungmarke zur Mannschafts-Sektion (optional; macht den
* Mannschaftsnamen in der Meta-Zeile zum Link)
*/
$m = $match;
$isHome = !empty($m['is_home']);
$teamName = (string) ($team_name ?? '');
$league = (string) ($league ?? '');
$href = $href ?? null;
$when = match_when((string) ($m['kickoff'] ?? ''));
// Meta-Zeile: Mannschaft · Wettbewerb · Heim/Auswärts (leere Teile fallen weg).
$competition = match_competition_label((string) ($m['competition'] ?? ''), $league);
$meta = array_values(array_filter([
$teamName,
$competition,
$isHome ? 'Heimspiel' : 'Auswärtsspiel',
], static fn (string $part): bool => $part !== ''));
$datetime = trim(($m['date_display'] ?? '') . ' · ' . ($m['time_display'] ?? ''), ' ·');
?>
<article class="match-feature">
<?php if ($meta !== []): ?>
<p class="match-feature__meta">
<?php foreach ($meta as $i => $part): ?>
<?php if ($i > 0): ?>
<span class="match-feature__sep" aria-hidden="true">·</span>
<?php endif; ?>
<?php if ($i === 0 && $href !== null): ?>
<a href="<?= e($href) ?>"><?= e($part) ?></a>
<?php else: ?>
<span><?= e($part) ?></span>
<?php endif; ?>
<?php endforeach; ?>
</p>
<?php endif; ?>
<div class="match-feature__fixture">
<span class="match-feature__side<?= $isHome ? ' is-own' : '' ?>">
<?php component('crest', ['src' => $m['home_crest'] ?? null, 'name' => $m['home'] ?? '']); ?>
<span class="match-feature__club"><?= e($m['home'] ?? '') ?></span>
</span>
<span class="match-feature__side<?= !$isHome ? ' is-own' : '' ?>">
<?php component('crest', ['src' => $m['away_crest'] ?? null, 'name' => $m['away'] ?? '']); ?>
<span class="match-feature__club"><?= e($m['away'] ?? '') ?></span>
</span>
</div>
<p class="match-feature__when">
<?php if ($when['iso'] !== ''): ?>
<time datetime="<?= e($when['iso']) ?>"><?= e($datetime) ?></time>
<?php else: ?>
<span><?= e($datetime) ?></span>
<?php endif; ?>
<?php if ($when['countdown'] !== ''): ?>
<span class="match-feature__sep" aria-hidden="true">·</span>
<span class="match-feature__countdown" data-countdown="<?= e($when['iso']) ?>"><?= e($when['countdown']) ?></span>
<?php endif; ?>
</p>
</article>

View File

@@ -3,20 +3,38 @@
declare(strict_types=1);
/**
* Kompakte Spielzeile für die Matchcenter-Seitenspalte (kicker-Stil): Datum oben,
* darunter Heim über Gast (Wappen + Name, lange Namen werden mit … gekürzt), rechts
* das Ergebnis bzw. — bei anstehenden Spielen — die Anstoßzeit. Heim steht immer
* oben/links. Props: $match (Eintrag aus data/matchcenter.json).
* Kompakte Spielzeile (kicker-Stil): Datum oben, darunter Heim über Gast (Wappen +
* Name, lange Namen werden mit … gekürzt), rechts das Ergebnis bzw. — bei
* anstehenden Spielen — die Anstoßzeit. Heim steht immer oben. Props:
* $match Eintrag aus data/matchcenter.json (next_matches[]/last_results[]/upcoming[])
* $meta optionale Angabe neben dem Datum (im „Nächste Spiele"-Band: die Mannschaft)
* $href optionales Ziel — dann ist die ganze Zeile ein Link
* Der innere __link-Wrapper trägt Raster und Innenabstand, damit als Link die
* komplette Zeilenfläche klickbar ist (und ohne Link nichts anders aussieht).
*/
$m = $match;
$hasResult = isset($m['goals_home'], $m['goals_away']);
$isHome = !empty($m['is_home']);
$meta = (string) ($meta ?? '');
$href = $href ?? null;
?>
<li class="match-row">
<?php if ($href !== null): ?>
<a class="match-row__link" href="<?= e($href) ?>">
<?php else: ?>
<div class="match-row__link">
<?php endif; ?>
<?php if (!empty($m['date_display']) || $meta !== ''): ?>
<span class="match-row__head">
<?php if (!empty($m['date_display'])): ?>
<span class="match-row__date"><?= e($m['date_display']) ?></span>
<?php endif; ?>
<div class="match-row__teams">
<?php if ($meta !== ''): ?>
<span class="match-row__meta"><?= e($meta) ?></span>
<?php endif; ?>
</span>
<?php endif; ?>
<span class="match-row__teams">
<span class="match-row__team<?= $isHome ? ' is-own' : '' ?>">
<?php component('crest', ['src' => $m['home_crest'] ?? null, 'name' => $m['home'] ?? '']); ?>
<span class="match-row__name" title="<?= e($m['home'] ?? '') ?>"><?= e($m['home'] ?? '') ?></span>
@@ -25,7 +43,7 @@ $isHome = !empty($m['is_home']);
<?php component('crest', ['src' => $m['away_crest'] ?? null, 'name' => $m['away'] ?? '']); ?>
<span class="match-row__name" title="<?= e($m['away'] ?? '') ?>"><?= e($m['away'] ?? '') ?></span>
</span>
</div>
</span>
<span class="match-row__outcome">
<?php if ($hasResult): ?>
<span class="match-row__score"><?= (int) $m['goals_home'] ?>:<?= (int) $m['goals_away'] ?></span>
@@ -35,4 +53,10 @@ $isHome = !empty($m['is_home']);
<span class="match-row__time" aria-hidden="true"></span>
<?php endif; ?>
</span>
<?php if ($href !== null): ?>
<span class="visually-hidden"> zur Mannschaft</span>
</a>
<?php else: ?>
</div>
<?php endif; ?>
</li>

View File

@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
/**
* „Nächste Spiele"-Slider (teamübergreifend). Props: $matches (data/matchcenter.json
* → upcoming[]). Spiegelt die Mechanik des banner-slider (Crossfade, Pause/Play,
* prefers-reduced-motion) über den generischen Antrieb assets/js/carousel.js.
* Ohne JS ist nur die erste Folie sichtbar. Leer → nichts rendern (Off-Season).
*/
$matches = $matches ?? [];
if ($matches === []) {
return;
}
$total = count($matches);
?>
<section class="section match-slider" id="naechste-spiele" aria-roledescription="Karussell" aria-label="Nächste Spiele unserer Mannschaften" data-carousel>
<div class="container">
<h2>Nächste Spiele</h2>
<ul class="match-slider__track" id="match-slider-track" data-carousel-track aria-live="off">
<?php foreach ($matches as $i => $m): ?>
<li class="match-slider__slide" data-carousel-slide role="group" aria-roledescription="Folie" aria-label="<?= ($i + 1) ?> von <?= $total ?>"<?= $i === 0 ? '' : ' hidden' ?>>
<p class="match-slider__team"><?= e($m['team_name'] ?? '') ?></p>
<?php component('match-card', ['match' => $m, 'variant' => 'hero']); ?>
</li>
<?php endforeach; ?>
</ul>
<?php if ($total > 1): ?>
<div class="match-slider__controls" data-carousel-controls hidden>
<button class="match-slider__toggle" type="button" data-carousel-toggle aria-pressed="false" aria-controls="match-slider-track" hidden>
<?= icon('pause-fill', 'match-slider__toggle-icon match-slider__toggle-icon--pause') ?>
<?= icon('play-fill', 'match-slider__toggle-icon match-slider__toggle-icon--play') ?>
<span class="visually-hidden" data-carousel-toggle-label>Automatischen Wechsel pausieren</span>
</button>
<div class="match-slider__dots" role="group" aria-label="Spiel wählen">
<?php foreach ($matches as $i => $m): ?>
<button class="match-slider__dot" type="button" data-carousel-dot aria-label="Spiel <?= ($i + 1) ?> von <?= $total ?>: <?= e(($m['home'] ?? '') . ' gegen ' . ($m['away'] ?? '')) ?>">
<span class="match-slider__progress" data-carousel-progress aria-hidden="true"></span>
</button>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</section>

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
/**
* „Nächste Spiele"-Band (teamübergreifend): abgesetzte dunklere Fläche mit roter
* Oberkante, links der Aufmacher (das zeitlich nächste Spiel aller Mannschaften),
* rechts die folgenden Termine als kompakte, anklickbare Zeilen. Props:
* $matches data/matchcenter.json → upcoming[] (chronologisch)
* $teams data/matchcenter.json → teams[] (liefert Liganame + Sprungmarke)
* Leer → nichts rendern.
*/
$matches = $matches ?? [];
$teams = $teams ?? [];
if ($matches === []) {
return;
}
// Liganame und Sprungmarke je Mannschaft vorbereiten, damit die Karten nicht
// selbst in den Teams suchen müssen. Anker wie in matchcenter-section.php.
$leagues = [];
$anchors = [];
foreach ($teams as $t) {
$key = (string) ($t['key'] ?? '');
if ($key === '') {
continue;
}
$leagues[$key] = (string) ($t['league'] ?? '');
$anchors[$key] = '#team-' . preg_replace('/[^a-z0-9-]+/', '', strtolower($key));
}
$feature = array_shift($matches);
$featureKey = (string) ($feature['team_key'] ?? '');
?>
<section class="section section--sunken next-matches" id="naechste-spiele">
<div class="container">
<h2 class="next-matches__title">Nächste Spiele</h2>
<div class="next-matches__grid">
<?php component('match-feature', [
'match' => $feature,
'team_name' => $feature['team_name'] ?? '',
'league' => $leagues[$featureKey] ?? '',
'href' => $anchors[$featureKey] ?? null,
]); ?>
<?php if ($matches !== []): ?>
<div class="next-matches__list">
<h3 class="next-matches__list-title">Danach</h3>
<ul class="match-list match-list--plain">
<?php foreach ($matches as $m): ?>
<?php component('match-row', [
'match' => $m,
'meta' => $m['team_name'] ?? '',
'href' => $anchors[(string) ($m['team_key'] ?? '')] ?? null,
]); ?>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</div>
</div>
</section>

View File

@@ -125,6 +125,61 @@ function img_intrinsic_attrs(string $rel): string
return $cache[$rel];
}
/**
* Anzeigewerte zu einem Anstoß. $kickoff = Zeit ohne Zonenangabe aus
* data/matchcenter.json (z. B. 2026-08-01T14:00), gelesen als Europe/Berlin —
* PHP läuft hier global auf UTC, deshalb explizit. Liefert:
* iso maschinenlesbar mit Zone (für <time datetime> und den JS-Ticker)
* countdown „heute, 14:00 Uhr" | „morgen, 14:00 Uhr" | „in 6 Tagen" | „in 3 Wochen"
* Unlesbarer Wert → beide Felder leer; bereits angestoßenes Spiel → countdown leer.
*/
function match_when(string $kickoff): array
{
if ($kickoff === '') {
return ['iso' => '', 'countdown' => ''];
}
$tz = new DateTimeZone('Europe/Berlin');
try {
$start = new DateTimeImmutable($kickoff, $tz);
} catch (Exception) {
return ['iso' => '', 'countdown' => ''];
}
$now = new DateTimeImmutable('now', $tz);
if ($start <= $now) {
return ['iso' => $start->format('c'), 'countdown' => ''];
}
// Kalendertage zählen, nicht 24-Stunden-Blöcke: ein Anstoß morgen um 14 Uhr
// ist „morgen", auch wenn es nur 20 Stunden hin sind.
$days = (int) $now->setTime(0, 0)->diff($start->setTime(0, 0))->format('%a');
$time = $start->format('H:i') . ' Uhr';
return [
'iso' => $start->format('c'),
'countdown' => match (true) {
$days === 0 => 'heute, ' . $time,
$days === 1 => 'morgen, ' . $time,
$days < 14 => 'in ' . $days . ' Tagen',
default => 'in ' . (int) round($days / 7) . ' Wochen',
},
];
}
/**
* Anzeigename eines Wettbewerbs aus data/matchcenter.json. Die BFV-API kennt nur
* „Liga" und „Freundschaft": „Liga" wird durch den echten Liganamen ersetzt — ohne
* $league bleibt sie leer, damit Ligaspiele in den Mannschafts-Sektionen nicht
* redundant beschriftet werden (die Sektion nennt die Liga schon). „Freundschaft"
* wird ausgeschrieben, damit erkennbar ist, warum ein Testspiel nicht in der
* Tabelle zählt.
*/
function match_competition_label(string $competition, string $league = ''): string
{
return match ($competition) {
'Liga' => $league,
'Freundschaft' => 'Freundschaftsspiel',
default => $competition,
};
}
/**
* Komponente rendern: component('hero', ['title' => …]).
* Props werden als lokale Variablen extrahiert; Komponenten sind dumme Includes.

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
/**
* Matchcenter: Spielplan, Ergebnisse und Tabellen der Aktivenmannschaften.
* Daten: data/matchcenter.json (maschinenverwaltet von bin/matchcenter-sync.php).
* Struktur: Hero → „Nächste Spiele"-Slider → pro Mannschaft eine Sektion
* Struktur: Hero → „Nächste Spiele"-Band → pro Mannschaft eine Sektion
* (Nächste Spiele · Letzte Ergebnisse · Tabelle). Ohne Cache rendert die Seite
* einen freundlichen Hinweis statt eines Fehlers (Graceful Degradation).
*/
@@ -17,7 +17,7 @@ $meta = [
'title' => 'Matchcenter Spiele, Ergebnisse & Tabellen',
'description' => 'Spielplan, Ergebnisse und Tabellen der Mannschaften des TSV 08 Kulmbach 1. und 2. Mannschaft sowie Damen, automatisch aktuell.',
'og_image' => 'img/pages/fussball-hero-1600.jpg',
'scripts' => ['carousel.js', 'crest.js', 'group-tabs.js'],
'scripts' => ['crest.js', 'group-tabs.js'],
'schema' => array_merge(
page_schema([
['name' => 'Startseite', 'slug' => ''],
@@ -45,7 +45,7 @@ if ($teams === []) {
return;
}
component('match-slider', ['matches' => $upcoming]);
component('next-matches', ['matches' => $upcoming, 'teams' => $teams]);
foreach ($teams as $team) {
component('matchcenter-section', ['team' => $team]);

View File

@@ -7,7 +7,9 @@
position: relative;
display: grid;
align-items: end;
/* svh statt vh: stabil gegenüber ein-/ausfahrender Browser-UI auf Phones. */
min-height: min(92vh, 900px);
min-height: min(92svh, 900px);
padding-block: calc(var(--header-h) + 80px) var(--space-section);
overflow: hidden;
}
@@ -15,9 +17,26 @@
/* Kompakte Variante: schlanker Unterseiten-Header statt vollflächig. */
.hero--compact {
min-height: min(60vh, 560px);
min-height: min(60svh, 560px);
padding-block: calc(var(--header-h) + 64px) 3.5rem;
}
/* Mobile: Höhe am Inhalt statt am Viewport orientieren — weniger Leer-Scroll.
Startseiten-Hero lässt bewusst einen Streifen Folge-Inhalt anblitzen (Scroll-Affordance),
Unterseiten-Heros kollabieren auf Überschrift + Teaser. */
@media (max-width: 768px) {
.hero {
min-height: min(82vh, 700px);
min-height: min(82svh, 700px);
padding-block: calc(var(--header-h) + 48px) 3rem;
}
.hero--compact {
min-height: 0;
padding-block: calc(var(--header-h) + 44px) 2.5rem;
}
}
.hero__media {
position: absolute;
inset: 0;
@@ -78,6 +97,13 @@
margin-top: 1rem;
max-width: 60ch;
text-shadow: 0 1px 12px rgb(0 0 0 / 0.6);
text-wrap: pretty;
}
/* Display-Hero: schmaleres Maß fürs Zentrieren — ausgewogene Zeilen statt
vier Zeilen mit unruhigem Flatterrand. */
.hero--display .hero__text {
max-width: 52ch;
}
.hero__actions {
@@ -103,10 +129,6 @@
text-indent: 0.14em;
}
.hero--center.hero--display .hero__tagline {
text-indent: 0.1em;
}
.hero--center .hero__actions {
justify-content: center;
}
@@ -126,47 +148,35 @@
linear-gradient(180deg, rgb(0 0 0 / 0.4) 0%, rgb(0 0 0 / 0.14) 45%, rgb(0 0 0 / 0.5) 100%);
}
/* Kicker: einziges gesperrtes Versalien-Element im Hero — bewusst ohne
Schmuck-Linien, damit die Typo-Hierarchie ruhig bleibt (Eyebrow → Titel →
Subline → Text). */
.hero__kicker {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.25rem;
font-size: var(--fs-500);
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--clr-text);
}
.hero__kicker::before {
content: '';
width: 3rem;
height: 4px;
background: var(--clr-accent);
border-radius: 2px;
}
/* Kicker zentriert: Text mittig, Akzentlinie beidseitig (— Text —) */
.hero--center .hero__kicker {
justify-content: center;
}
.hero--center .hero__kicker::after {
content: '';
width: 3rem;
height: 4px;
background: var(--clr-accent);
border-radius: 2px;
/* 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);
}
/* Subline ruhig in Abel, Groß-/Kleinschreibung — konkurriert nicht mehr mit
Kicker (gesperrte Versalien) und Coolvetica-Titel. */
.hero--display .hero__tagline {
margin-top: 0.5rem;
font-size: clamp(1.75rem, 1rem + 2.5vw, 2.75rem);
letter-spacing: 0.1em;
color: rgb(255 255 255 / 0.88);
margin-top: 0.75rem;
font-family: var(--ff-body);
font-size: clamp(1.25rem, 0.95rem + 1.3vw, 1.75rem);
font-weight: 400;
letter-spacing: 0;
text-indent: 0;
text-transform: none;
line-height: 1.35;
color: rgb(255 255 255 / 0.92);
}
.hero__scroll-cue {
@@ -212,10 +222,26 @@
margin-top: 2rem;
}
/* Mobil: Ein-Reihen-Swiper statt 2er-Grid — volle Bildgröße, natürliche
Instagram-Geste. Fluchtet mit dem Text-Container; die angeschnittene
Folgekarte ist die Wisch-Affordance, Scrollbar bleibt darum versteckt. */
@media (max-width: 600px) {
.instagram__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: none;
grid-auto-flow: column;
grid-auto-columns: min(72vw, 320px);
gap: 0.75rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
}
.instagram__grid::-webkit-scrollbar {
display: none;
}
.instagram__link {
scroll-snap-align: start;
}
}
@@ -288,6 +314,13 @@
border-block: 1px solid rgb(255 255 255 / 0.1);
}
/* Abgesetztes, dunkleres Band über die volle Breite — das Gegenstück zu
--tinted, wenn eine Sektion sich nach unten statt nach oben abheben soll. */
.section--sunken {
background: var(--clr-bg-deep);
border-block: 1px solid rgb(255 255 255 / 0.08);
}
.section--tinted + .section--tinted {
border-top: 0;
}
@@ -3623,267 +3656,104 @@
border-radius: 50%;
}
/* Einzelne Begegnung. */
.match-card {
/* Aufmacher des „Nächste Spiele"-Bands: strukturgleich mit .match-row (Heim über
Gast, linksbündig), nur groß — ein Bauprinzip für das ganze Band. Bewusst ohne
eigene Fläche, ohne Badges und ohne Icons: die Wirkung kommt aus der Größe.
Drei Schriftrollen — Meta (Abel gesperrt) · Namen (Coolvetica) · Anstoß (Abel). */
.match-feature {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: clamp(1rem, 0.8rem + 0.8vw, 1.4rem);
background: var(--clr-glass-bg);
border: 1px solid var(--clr-glass-border);
border-radius: var(--radius-card);
gap: clamp(1rem, 0.8rem + 0.6vw, 1.5rem);
}
/* Datum oben mittig (kicker-Stil). */
.match-card__date {
.match-feature__meta {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4em;
font-size: var(--fs-300);
flex-wrap: wrap;
align-items: baseline;
gap: 0.5em;
font-size: var(--fs-400);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--clr-text-muted);
}
.match-card__date-icon {
font-size: 0.95em;
}
.match-card__fixture {
display: grid;
/* minmax(0, …) statt 1fr: erlaubt den Spalten, schmaler als ihr Inhalt zu
werden (sonst überläuft die Karte auf sehr schmalen Screens). */
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 0.75rem;
}
.match-card__side {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
min-width: 0; /* verhindert Grid-Blowout der 1fr-Spalten bei langen Namen */
text-align: center;
}
.match-card__club {
font-size: var(--fs-400);
line-height: 1.2;
overflow-wrap: anywhere; /* lange Namen brechen um statt zu überlaufen */
}
.match-card__side.is-own .match-card__club {
font-weight: var(--fw-bold);
color: var(--clr-text);
}
.match-card__center {
display: flex;
flex-direction: column;
align-items: center;
min-width: 3.25rem;
}
.match-card__score {
font-family: var(--ff-heading);
font-size: var(--fs-700);
line-height: 1;
}
.match-card__colon {
margin: 0 0.12em;
}
.match-card__vs {
font-family: var(--ff-heading);
font-size: var(--fs-650);
.match-feature__sep {
color: var(--clr-text-faint);
}
/* Zeit mittig unter dem Ergebnis/Anstoß (kicker-Stil). */
.match-card__time {
margin-top: 0.4rem;
font-size: var(--fs-400);
color: var(--clr-text-muted);
white-space: nowrap;
}
/* Hero-Variante (im Slider): größer und ruhiger. */
.match-card--hero {
padding: clamp(1.5rem, 1rem + 2vw, 2.5rem);
}
.match-card--hero .match-card__score {
font-size: var(--fs-800);
}
.match-card--hero .crest,
.match-card--hero .crest--initials {
width: 3rem;
height: 3rem;
}
.match-card--hero .match-card__club {
font-size: var(--fs-500);
}
/* „Nächste Spiele"-Slider — Mechanik wie .ad-banner (gemeinsamer Antrieb
carousel.js, gemeinsame Keyframe ad-progress). */
.match-slider {
--slider-interval: 6s;
}
.match-slider > .container > h2 {
text-align: center;
}
.match-slider__track {
list-style: none;
margin: 2rem auto 0;
padding: 0;
max-width: 620px;
}
.match-slider__slide[hidden] {
display: none;
}
.match-slider__team {
margin-bottom: 0.85rem;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: var(--fs-300);
color: var(--clr-text-muted);
}
/* Crossfade nur mit JS: Slides im selben Grid-Feld stapeln (konstante Höhe). */
.match-slider.is-enhanced .match-slider__track {
display: grid;
grid-template-columns: minmax(0, 1fr); /* gestapelte Slides nie breiter als der Track */
}
.match-slider.is-enhanced .match-slider__slide {
grid-area: 1 / 1;
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: opacity 0.55s ease, transform 0.55s ease, visibility 0s linear 0.55s;
visibility: hidden;
}
.match-slider.is-enhanced .match-slider__slide.is-active {
opacity: 1;
transform: none;
pointer-events: auto;
visibility: visible;
transition: opacity 0.55s ease, transform 0.55s ease, visibility 0s;
}
.match-slider__controls {
.match-feature__fixture {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1.75rem;
}
.match-slider__controls[hidden] {
display: none;
}
.match-slider__toggle {
display: inline-grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
padding: 0;
color: var(--clr-text);
background: var(--clr-glass-bg);
border: 1px solid var(--clr-glass-border);
border-radius: 999px;
cursor: pointer;
transition: background var(--transition);
}
.match-slider__toggle[hidden] {
display: none;
}
.match-slider__toggle:hover {
background: rgb(255 255 255 / 0.2);
}
.match-slider__toggle:focus-visible {
outline: 2px solid var(--clr-text);
outline-offset: 3px;
}
.match-slider__toggle-icon {
font-size: 1.15rem;
}
.match-slider__toggle .match-slider__toggle-icon--play {
display: none;
}
.match-slider__toggle[aria-pressed="true"] .match-slider__toggle-icon--pause {
display: none;
}
.match-slider__toggle[aria-pressed="true"] .match-slider__toggle-icon--play {
display: inline-block;
}
.match-slider__dots {
display: flex;
flex-wrap: wrap;
justify-content: center;
flex-direction: column;
gap: 0.5rem;
}
.match-slider__dot {
position: relative;
width: 48px;
height: 6px;
padding: 0;
border: 0;
border-radius: 999px;
background: var(--clr-glass-border);
cursor: pointer;
overflow: hidden;
transition: background var(--transition);
.match-feature__side {
display: flex;
align-items: center;
gap: clamp(0.6rem, 0.4rem + 0.6vw, 1rem);
min-width: 0; /* lange Vereinsnamen dürfen umbrechen statt zu überlaufen */
}
.match-slider__dot:hover {
background: rgb(255 255 255 / 0.35);
.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-slider__dot:focus-visible {
outline: 2px solid var(--clr-text);
outline-offset: 3px;
/* Die Vereinsnamen sind das einzige Coolvetica-Element („wer"), alles Beschreibende
bleibt Abel („wann/was"). Coolvetica hat nur einen Schnitt — die eigene Mannschaft
wird deshalb über Helligkeit markiert, nicht über fett (kein Fake-Bold). */
.match-feature__club {
font-family: var(--ff-heading);
font-size: var(--fs-700);
line-height: var(--lh-heading);
text-transform: uppercase;
color: var(--clr-text-muted);
overflow-wrap: anywhere;
}
.match-slider__progress {
position: absolute;
inset: 0;
background: var(--clr-accent);
transform: scaleX(0);
transform-origin: left;
.match-feature__side.is-own .match-feature__club {
color: var(--clr-text);
}
.match-slider__dot.is-active .match-slider__progress {
animation: ad-progress var(--slider-interval) linear forwards;
.match-feature__when {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5em;
font-size: var(--fs-600);
}
.match-slider.is-paused .match-slider__dot.is-active .match-slider__progress {
animation-play-state: paused;
/* Band „Nächste Spiele" — abgesetzte Fläche (.section--sunken) mit roter Oberkante,
damit der Bereich nicht als Hero-Überhang gelesen wird. */
.next-matches {
border-top: 2px solid var(--clr-accent);
}
@media (prefers-reduced-motion: reduce) {
.match-slider__dot.is-active .match-slider__progress {
animation: none;
transform: scaleX(1);
.next-matches__grid {
display: grid;
/* Der Aufmacher trägt mehr Gewicht als die Terminliste. */
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
align-items: start;
gap: clamp(1.25rem, 1rem + 1.5vw, 2.5rem);
margin-top: clamp(1.5rem, 1.2rem + 1vw, 2.25rem);
}
.next-matches__list-title {
margin-bottom: 0.75rem;
/* Coolvetica wäre in dieser Größe unleserlich — Abel mit Sperrung (CLAUDE.md). */
font-family: var(--ff-body);
font-size: var(--fs-400);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--clr-text-muted);
}
@media (max-width: 992px) {
.next-matches__grid {
grid-template-columns: 1fr;
}
}
@@ -3967,12 +3837,23 @@
border-radius: var(--radius-card);
}
/* Im „Nächste Spiele"-Band trägt die Terminliste keine Kartenfläche — sonst stünde
ein gerahmter Block neben dem flächenlosen Aufmacher. Die Trennlinien der Zeilen
reichen; in den Mannschafts-Sektionen bleibt .match-list die Karte.
Zwei Klassen nötig: die Glas-Registry am Dateiende setzt .match-list den
box-shadow und würde einen Ein-Klassen-Selektor sonst überschreiben. */
.match-list.match-list--plain {
background: none;
border: 0;
border-radius: 0;
box-shadow: none;
}
.match-list--plain .match-row__link {
padding-inline: 0;
}
.match-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
column-gap: 0.75rem;
padding: 0.7rem 0.9rem;
border-bottom: 1px solid var(--clr-glass-border);
}
@@ -3980,13 +3861,43 @@
border-bottom: 0;
}
.match-row__date {
/* Raster und Innenabstand liegen im Wrapper: ist die Zeile ein Link, ist damit
die komplette Zeilenfläche klickbar — ohne Link sieht nichts anders aus. */
.match-row__link {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
column-gap: 0.75rem;
padding: 0.7rem 0.9rem;
color: inherit;
text-decoration: none;
}
a.match-row__link:hover {
background: rgb(255 255 255 / 0.06);
}
.match-row__head {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.45rem;
}
.match-row__date {
font-size: var(--fs-300);
color: var(--clr-text-muted);
}
.match-row__meta {
font-size: var(--fs-300);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--clr-text-faint);
}
.match-row__teams {
display: flex;
flex-direction: column;
@@ -4045,6 +3956,25 @@
border-radius: var(--radius-card);
}
/* Ruhender Rahmen um den Scroller: trägt den Wisch-Hinweis (Fade am rechten
Rand), der nicht mitscrollen darf. Nur auf Breiten sichtbar, wo die Tabelle
(min-width 520px) tatsächlich überläuft. */
.table-scroll-frame {
position: relative;
}
@media (max-width: 620px) {
.table-scroll-frame::after {
content: '';
position: absolute;
inset: 1px 1px 1px auto;
width: 32px;
border-radius: 0 var(--radius-card) var(--radius-card) 0;
background: linear-gradient(90deg, transparent, rgb(0 0 0 / 0.55));
pointer-events: none;
}
}
.table-scroll:focus-visible {
outline: 2px solid var(--clr-text);
outline-offset: 2px;
@@ -4386,7 +4316,6 @@
.group-card,
.group-tabs__panel,
.age-group,
.match-card,
.match-list,
.matchcenter-empty,
.roster-card,
@@ -4413,7 +4342,6 @@
.key-facts__item,
.bento__tile,
.group-card,
.match-card,
.matchcenter-empty,
.roster-card,
.team-card,

View File

@@ -19,6 +19,7 @@
--clr-sepia-bg: #f3efe4; /* Timeline-Museumsrahmen (Foto-Mount) */
--clr-sepia-text: #3a372f; /* Timeline-Text auf hellem Sepia-Grund */
--clr-glass-bg: rgb(0 0 0 / 0.22); /* dunkles Glas (dunkler als der Grund) */
--clr-surface-raised: rgb(255 255 255 / 0.055); /* Karte, die auf einem abgesetzten (dunkleren) Band liegt */
--clr-glass-border: rgb(255 255 255 / 0.14); /* sehr dezenter Card-Rand — Glow/Sheen tragen die Kontur */
--clr-glass-border-strong: rgb(255 255 255 / 0.45); /* Back-to-Top, position-/group-card etc.: ≥3:1 (WCAG 1.4.11) */
--clr-field-bg: rgb(255 255 255 / 0.07); /* Formularfelder: sanfte Füllfläche (etwas heller als das Glas) */
@@ -37,8 +38,10 @@
--fs-650: 1.75rem; /* 28px Zwischengröße (z.B. Footer-Headings) */
--fs-675: 2.125rem; /* 34px Header-Wortmarke */
--fs-700: clamp(2rem, 1.2rem + 3.5vw, 3.125rem); /* 50px h3 */
--fs-800: clamp(2.25rem, 1.3rem + 4.5vw, 3.75rem); /* 60px h2 */
--fs-900: clamp(3rem, 1.75rem + 5.5vw, 6rem); /* 96px h1 */
/* 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 */
--lh-heading: 0.9;
--lh-body: 1.5;
--fw-regular: 400;