Files
tsv08kulmbach-website/app/components/banner-slider.php
fs a3cfd4941d 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
2026-07-31 16:50:43 +02:00

152 lines
8.3 KiB
PHP

<?php
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"), "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).
*
* "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).
*/
$bannerData = json_load('banners');
$banners = $bannerData['banners'] ?? [];
if ($banners === []) {
return;
}
$heading = $bannerData['heading'] ?? null;
$subline = $bannerData['subline'] ?? null;
$total = count($banners);
?>
<section class="section ad-banner" id="anzeigen" aria-roledescription="Karussell" aria-label="Anzeigen unserer Sponsoren und Partner" data-carousel>
<div class="container">
<h2 class="visually-hidden">Anzeigen unserer Sponsoren &amp; Partner</h2>
<ul class="ad-banner__track" id="ad-banner-track" data-carousel-track aria-live="off">
<?php foreach ($banners as $i => $banner): ?>
<?php
$bg = $banner['background'] ?? null;
$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 ad-banner__slide--accent-' . $slug((string) $accent);
}
if ($variant) {
$classes .= ' ad-banner__slide--' . $slug((string) $variant);
}
// 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' => ''];
}
?>
<?php
// Rendert einen CTA-Link; externe Links öffnen im neuen Tab (rel=sponsored),
// interne (cta.internal) bleiben normale Links. $btnClass für Primär/Sekundär.
$renderCta = static function (?array $cta, string $btnClass) {
if (empty($cta['url']) || empty($cta['label'])) {
return;
}
$internal = !empty($cta['internal']);
echo '<a class="' . e($btnClass) . ' ad-banner__cta" href="' . e($cta['url']) . '"'
. ($internal ? '' : ' target="_blank" rel="noopener sponsored"') . '>'
. e($cta['label'])
. ($internal ? '' : '<span class="visually-hidden"> (öffnet in neuem Tab)</span>')
. '</a>';
};
?>
<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">
</div>
<?php endif; ?>
<div class="ad-banner__content">
<p class="ad-banner__title"><?= e($banner['title']) ?></p>
<p class="ad-banner__text"><?= e($banner['text']) ?></p>
</div>
<div class="ad-banner__footer">
<?php if (!empty($banner['cta']) || !empty($banner['cta_secondary'])): ?>
<div class="ad-banner__actions">
<?php $renderCta($banner['cta'] ?? null, 'btn'); ?>
<?php $renderCta($banner['cta_secondary'] ?? null, 'btn btn--outline'); ?>
</div>
<?php endif; ?>
<span class="ad-banner__tag"><?= e($banner['tag'] ?? 'Anzeige') ?></span>
</div>
</li>
<?php endforeach; ?>
</ul>
<div class="ad-banner__meta">
<?php if ($heading !== null): ?>
<p class="ad-banner__meta-label"><?= e($heading) ?></p>
<?php endif; ?>
<?php if ($total > 1): ?>
<?php /* Ohne JS verborgen (Steuerung wäre funktionslos); carousel.js blendet sie ein. */ ?>
<div class="ad-banner__controls" data-carousel-controls hidden>
<button class="btn btn--icon ad-banner__nav-btn" type="button" data-carousel-prev aria-label="Vorherige Anzeige">
<?= icon('chevron-left') ?>
</button>
<div class="ad-banner__indicators" role="group" aria-label="Anzeige wählen">
<?php foreach ($banners as $i => $banner): ?>
<button class="ad-banner__dot" type="button" data-carousel-dot aria-controls="ad-banner-track" aria-label="Anzeige <?= ($i + 1) ?> von <?= $total ?>: <?= e($banner['company'] ?? $banner['title']) ?>">
<span class="ad-banner__progress" data-carousel-progress aria-hidden="true"></span>
</button>
<?php endforeach; ?>
</div>
<button class="btn btn--icon ad-banner__nav-btn" type="button" data-carousel-next aria-label="Nächste Anzeige">
<?= icon('chevron-right') ?>
</button>
<?php /* Pause/Play — WCAG 2.2.2. Nur bei aktiver Auto-Rotation sichtbar. */ ?>
<button class="btn btn--icon ad-banner__toggle" type="button" data-carousel-toggle aria-pressed="false" aria-controls="ad-banner-track" hidden>
<?= icon('pause-fill', 'ad-banner__toggle-icon ad-banner__toggle-icon--pause') ?>
<?= icon('play-fill', 'ad-banner__toggle-icon ad-banner__toggle-icon--play') ?>
<span class="visually-hidden" data-carousel-toggle-label>Automatischen Wechsel pausieren</span>
</button>
</div>
<?php endif; ?>
</div>
</div>
</section>