Banner-Hintergründe CSP-fest: <img> statt Inline-Variable

Beim ersten Deploy blieben die Slides des Banner-Sliders schwarz. Ursache war
kein fehlendes Bild, sondern die CSP: public/.htaccess setzt
default-src 'self' ohne style-src, der Browser verwirft damit jedes
style-Attribut — und die Komponente lieferte Bild und Akzentfarbe genau so
(style="--banner-bg: url(…); --banner-accent: #6cbbbc"). Lokal war das
unsichtbar, weil php -S keine .htaccess liest.

Der Hintergrund ist jetzt ein <img class="ad-banner__bg"> über die bestehende
img-Komponente (CSP erlaubt img-src) und bringt srcset mit: das Handy lädt die
640er statt der 1600er, die erste Slide lädt eager mit fetchpriority. Der
dunkle Leseverlauf liegt auf ::before statt im background-image.

Die Akzentfarbe kann als beliebiger Hex-Wert nicht ohne Inline-Style in die
Seite. Sie steht deshalb als Token in tokens.css (wo Farben laut CLAUDE.md
hingehören), components.css bildet den Namen darauf ab, und banners.json nennt
nur noch "accent": "breadcrumb".

bin/preflight.php bricht ab, wenn unter app/ wieder ein style-Attribut
auftaucht — der Fehler wäre sonst erneut erst in Produktion sichtbar. Beim
Bauen dieser Wache zweimal selbst hineingetappt: ein Muster mit [^>]* bricht an
jedem schließenden PHP-Tag ab, und die Zeichenfolge dieses Tags beendet den
PHP-Modus auch in einem //-Kommentar (preflight gab ab dort seinen eigenen
Quelltext aus, php -l meldete nichts).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzeVVgMCrzCDJAKeLEdKr7
This commit is contained in:
2026-07-31 16:50:43 +02:00
parent e8296a465b
commit a3cfd4941d
8 changed files with 169 additions and 33 deletions

View File

@@ -6,11 +6,23 @@ declare(strict_types=1);
* Werbe-/Sponsoren-Banner-Slider. Daten: data/banners.json (Single Source of Truth).
* Layout je Slide: 2/3 Titel + Text + CTA, 1/3 Firmenlogo.
* Pro Slide optional: "variant" (CSS-Modifier, z. B. "house" für Eigenwerbung),
* "tag" (Badge-Text, Default "Anzeige"), "background" (Bild), "cta.internal"
* "tag" (Badge-Text, Default "Anzeige"), "cta.internal"
* (interner Link ohne target=_blank/rel=sponsored, für eigene Seiten/Anker),
* "cta_secondary" (zweiter Button als Outline), "logo_light" (helle Logo-Platte
* für dunkle Firmenlogos), "accent" (CSS-Farbe; überschreibt das Vereins-Rot
* der Buttons für diese Slide, z. B. Sponsor-CI).
* für dunkle Firmenlogos).
*
* "background" ist ein Varianten-Set {base, widths} und wird als <img> gerendert,
* NICHT als CSS-Hintergrund. Grund: die CSP (default-src 'self', kein style-src)
* verwirft jedes style-Attribut, eine Inline-Variable --banner-bg käme also nie an.
* Das fiel lokal nie auf, weil `php -S` keine .htaccess und damit keine CSP kennt —
* erst auf echtem Apache blieben die Slides schwarz (31.07.2026). Als <img> ist es
* ohnehin besser: srcset lädt am Handy die 640er statt der 1600er. Ein String bleibt
* als Einzeldatei zulässig (Bestandsschutz, kein srcset).
*
* "accent" ist ein NAME, keine Farbe ("breadcrumb") — die Farbe steht als Token in
* tokens.css, die Klasse ad-banner__slide--accent-<name> in components.css. Ein
* beliebiger Hex-Wert aus der JSON käme nur per Inline-Style in die Seite, und den
* verwirft die CSP. Neuer Sponsor mit eigener CI = ein Token plus eine Klasse.
* Auto-Rotation + Fortschritts-Indikatoren übernimmt der generische Antrieb
* assets/js/carousel.js (pausiert bei Hover/Fokus, respektiert prefers-reduced-motion).
* Ohne JS: nur der erste Banner ist sichtbar (Graceful Degradation).
@@ -34,25 +46,30 @@ $total = count($banners);
$variant = $banner['variant'] ?? null;
$accent = $banner['accent'] ?? null;
// Slug-Filter für beides: der Wert landet in einem Klassennamen, nie ungeprüft.
$slug = static fn (string $v): string => (string) preg_replace('/[^a-z0-9-]+/', '', strtolower($v));
$classes = 'ad-banner__slide';
if ($bg) {
$classes .= ' ad-banner__slide--bg';
}
if ($accent) {
$classes .= ' ad-banner__slide--accent';
$classes .= ' ad-banner__slide--accent ad-banner__slide--accent-' . $slug((string) $accent);
}
if ($variant) {
$classes .= ' ad-banner__slide--' . preg_replace('/[^a-z0-9-]+/', '', strtolower($variant));
$classes .= ' ad-banner__slide--' . $slug((string) $variant);
}
$styleParts = [];
if ($bg) {
$styleParts[] = '--banner-bg: url(' . asset($bg) . ')';
// Hintergrundbild als Bild-Prop für die img-Komponente. alt="" — der Hintergrund
// ist Dekoration, die Aussage der Slide steht als Text daneben.
$bgImage = null;
if (is_array($bg) && !empty($bg['base'])) {
$bgImage = ['base' => $bg['base'], 'widths' => $bg['widths'] ?? [1600], 'alt' => ''];
} elseif (is_string($bg) && $bg !== '') {
$datei = PUBLIC_PATH . '/assets/' . ltrim($bg, '/');
[$w, $h] = is_file($datei) ? (getimagesize($datei) ?: [0, 0]) : [0, 0];
$bgImage = ['src' => $bg, 'width' => $w, 'height' => $h, 'alt' => ''];
}
if ($accent) {
$styleParts[] = '--banner-accent: ' . $accent;
}
$style = $styleParts !== [] ? ' style="' . e(implode('; ', $styleParts)) . '"' : '';
?>
<?php
// Rendert einen CTA-Link; externe Links öffnen im neuen Tab (rel=sponsored),
@@ -69,7 +86,17 @@ $total = count($banners);
. '</a>';
};
?>
<li class="<?= e($classes) ?>"<?= $style ?> data-carousel-slide role="group" aria-roledescription="Folie" aria-label="<?= ($i + 1) ?> von <?= $total ?>"<?= $i === 0 ? '' : ' hidden' ?>>
<li class="<?= e($classes) ?>" data-carousel-slide role="group" aria-roledescription="Folie" aria-label="<?= ($i + 1) ?> von <?= $total ?>"<?= $i === 0 ? '' : ' hidden' ?>>
<?php if ($bgImage !== null): ?>
<?php /* Erstes Kind und absolut hinter dem Inhalt. Die erste Slide lädt eager
(sie ist above the fold und trägt das LCP), die folgenden lazy. */ ?>
<?php component('img', [
'image' => $bgImage,
'class' => 'ad-banner__bg',
'sizes' => '(max-width: 1200px) 100vw, 1200px',
'eager' => $i === 0,
]); ?>
<?php endif; ?>
<?php if (!empty($banner['logo'])): ?>
<div class="ad-banner__logo<?= !empty($banner['logo_light']) ? ' ad-banner__logo--light' : '' ?>">
<img src="<?= e(asset($banner['logo'])) ?>" alt="<?= e($banner['company'] ?? $banner['title']) ?>"<?= img_intrinsic_attrs($banner['logo']) ?> loading="lazy" decoding="async">