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

@@ -119,7 +119,18 @@ Funktion, sie ist die einzige Stelle, an der aus JSON HTML entsteht.
Quelle für Termine-Badges und Hero-Beschriftung) und `upcoming_highlights(3)` (die nächsten
Termine für den Startseiten-Hero, s. Hero-Regel).
- **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
utilities. Kein `@import`, **kein Inline-Style — die CSP erzwingt das, und zwar nur in
Produktion.** `default-src 'self'` ohne `style-src` heißt: der Browser verwirft jedes
`style`-Attribut und jeden `<style>`-Block stillschweigend. Lokal fällt das **nie** auf,
weil `php -S` keine `.htaccess` liest. Genau so blieben die Banner-Slides beim ersten
Deploy schwarz (31.07.2026): die Komponente setzte `style="--banner-bg: url(…)"`.
**Die zwei Auswege, wenn ein Wert aus `data/*.json` ins Aussehen muss:**
ein **Bild** wird ein `<img>` (die CSP erlaubt `img-src 'self' data:`, und `srcset`
gibt es gratis dazu), eine **Farbe** wird ein Token in `tokens.css` plus eine Klasse in
`components.css`, und die JSON nennt nur den Namen (`"accent": "breadcrumb"`).
`bin/preflight.php` bricht mit Fehler ab, wenn irgendwo unter `app/` ein `style`-Attribut
auftaucht. Per JavaScript gesetzte Styles (`element.style.…`) sind nicht betroffen.
Komponenten-Styles in `components.css` mit
Banner-Kommentaren (`/* === hero === */`).
**Kontext-Regeln setzen Custom Properties, kein Layout.** Wenn eine Komponente in einem Kontext
anders aussehen soll, gehört auf `.kontext .komponente` nur die Variable (`--crest-size`,

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">

View File

@@ -296,6 +296,49 @@ if ($cronKey === '' || $cronKey === 'CHANGE_ME') {
}
}
// --- Inline-Styles (die CSP verwirft sie stillschweigend) ---
// Diese Prüfung existiert wegen eines echten Ausfalls am 31.07.2026: der
// Banner-Slider setzte sein Hintergrundbild per style="--banner-bg: url(…)". Auf
// echtem Apache greift die CSP aus public/.htaccess (default-src 'self', KEIN
// style-src), der Browser verwarf das Attribut, die Slides blieben schwarz. Lokal
// war das unsichtbar, weil `php -S` keine .htaccess liest — der Fehler konnte also
// erst in Produktion auffallen. Genau davor schützt diese Wache.
// Hinweis: per JavaScript gesetzte Styles (element.style.…) sind NICHT betroffen,
// die CSP prüft nur vom HTML-Parser gelesene style-Attribute und <style>-Blöcke.
/* Zeilenweise und ohne Tag-Kontext geprüft: ein Muster, das beim öffnenden Tag
ansetzt und mit [^>]* weitersucht, bricht sofort ab — PHP-Templates enthalten
über das schließende PHP-Tag ständig eine spitze Klammer. Ein style-Attribut in
einer PHP-Datei ist praktisch immer echtes Markup, diese Prüfung genügt also.
Dieser Kommentar ist bewusst ein Blockkommentar: die Zeichenfolge des schließenden
PHP-Tags beendet den PHP-Modus AUCH in einem //-Kommentar. Genau daran ist diese
Prüfung beim Bauen einmal gescheitert (preflight gab ab dort seinen eigenen
Quelltext aus, und php -l meldete nichts, weil das gültiges PHP mit HTML ist). */
$inlineStyles = [];
$phpDateien = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(ROOT_PATH . '/app'));
foreach ($phpDateien as $datei) {
if (!$datei->isFile() || $datei->getExtension() !== 'php') {
continue;
}
foreach (file($datei->getPathname()) ?: [] as $nr => $zeile) {
$roh = ltrim($zeile);
// Kommentarzeilen überspringen — dieser Abschnitt selbst nennt style=" ja auch.
if ($roh === '' || str_starts_with($roh, '*') || str_starts_with($roh, '//') || str_starts_with($roh, '/*')) {
continue;
}
if (preg_match('/\sstyle\s*=\s*"/i', $zeile) === 1) {
$kurz = str_replace(ROOT_PATH . '/', '', $datei->getPathname());
$inlineStyles[] = $kurz . ':' . ($nr + 1);
break;
}
}
}
if ($inlineStyles !== []) {
$errors[] = 'Inline-Style im Markup — die CSP (kein style-src) verwirft ihn, die Regel greift NUR in Produktion: '
. implode(', ', $inlineStyles)
. '. Bilder als <img> ausgeben, Farben als Token in tokens.css plus Klasse in components.css.';
}
// --- Ausgabe ---
foreach ($warnings as $w) {
echo "WARNUNG {$w}\n";

View File

@@ -9,8 +9,19 @@
"title": "Hier könnte Ihre Werbung stehen",
"text": "Werden Sie Werbepartner des TSV 08 Kulmbach und zeigen Sie Ihr Unternehmen genau hier: sichtbar für tausende sportbegeisterte Menschen aus Kulmbach und dem Katzbachtal.",
"logo": "img/logo.svg",
"background": "img/pages/fussball-hero-1600.jpg",
"cta": { "label": "Werbepartner werden", "url": "/partner-werden", "internal": true }
"background": {
"base": "img/pages/fussball-hero",
"widths": [
640,
1000,
1600
]
},
"cta": {
"label": "Werbepartner werden",
"url": "/partner-werden",
"internal": true
}
},
{
"company": "Breadcrumb Solutions GmbH",
@@ -18,9 +29,19 @@
"text": "Beratung, Software & Implementierung aus einer Hand. Wir entwickeln Intranets, Kundenportale, Apps und Individualsoftware, inklusive Einführung, sodass sie im Alltag tatsächlich ankommt.",
"logo": "img/banners/breadcrumb-solutions.svg",
"logo_light": true,
"accent": "#6cbbbc",
"background": "img/banners/breadcrumb-hero-1600.jpg",
"cta": { "label": "Neue Website entdecken", "url": "https://www.breadcrumb-solutions.de/" }
"accent": "breadcrumb",
"background": {
"base": "img/banners/breadcrumb-hero",
"widths": [
640,
1000,
1600
]
},
"cta": {
"label": "Neue Website entdecken",
"url": "https://www.breadcrumb-solutions.de/"
}
}
]
}

View File

@@ -2110,21 +2110,49 @@
}
/* Optionaler Hintergrund pro Banner (data: "background").
Dunkler Verlauf über dem Bild hält Text & CTA links lesbar. */
Das Bild ist ein <img class="ad-banner__bg">, KEIN CSS-Hintergrund: die CSP
(default-src 'self', kein style-src) verwirft style-Attribute, eine Inline-Variable
--banner-bg kam auf echtem Apache nie an (31.07.2026, Slides blieben schwarz).
Der dunkle Verlauf, der Text und CTA links lesbar hält, liegt deshalb auf ::before. */
.ad-banner__slide--bg {
background-image:
linear-gradient(to right, rgb(0 0 0 / 0.82), rgb(0 0 0 / 0.45) 60%, rgb(0 0 0 / 0.3)),
var(--banner-bg);
background-size: cover;
background-position: center;
/* Bild an der Innenkante kappen, nicht unter den 1px-Rahmen malen
(sonst schimmert es an der transparenten Rahmenkante durch). */
background-clip: padding-box;
position: relative;
/* Eigener Stapelkontext: das Bild darf hinter den Inhalt, aber nicht hinter
die Slide-Fläche selbst rutschen. */
isolation: isolate;
border-color: transparent;
}
.ad-banner__bg,
.ad-banner__slide--bg::before {
position: absolute;
/* inset bezieht sich auf die Padding-Box — das Bild endet an der Innenkante und
malt nicht unter den 1px-Rahmen (dort schimmerte es sonst durch). */
inset: 0;
border-radius: inherit;
pointer-events: none;
}
.ad-banner__bg {
z-index: -2;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.ad-banner__slide--bg::before {
content: '';
z-index: -1;
background-image: linear-gradient(to right, rgb(0 0 0 / 0.82), rgb(0 0 0 / 0.45) 60%, rgb(0 0 0 / 0.3));
}
/* Pro-Slide-Akzentfarbe (data: "accent") — z. B. Sponsor-CI statt Vereins-Rot.
--banner-accent kommt als Inline-Variable von der Komponente. */
Der Name kommt aus der JSON, die Farbe aus tokens.css: ein Hex-Wert aus der JSON
käme nur per Inline-Style in die Seite, und den verwirft die CSP (siehe oben). */
.ad-banner__slide--accent-breadcrumb {
--banner-accent: var(--clr-banner-accent-breadcrumb);
}
.ad-banner__slide--accent {
--clr-accent: var(--banner-accent);
--clr-accent-dark: color-mix(in srgb, var(--banner-accent) 82%, #000);
@@ -2153,10 +2181,8 @@
@media (max-width: 768px) {
/* Auf Mobil läuft der Verlauf nach unten, damit der gestapelte Text lesbar bleibt. */
.ad-banner__slide--bg {
background-image:
linear-gradient(to bottom, rgb(0 0 0 / 0.82), rgb(0 0 0 / 0.5)),
var(--banner-bg);
.ad-banner__slide--bg::before {
background-image: linear-gradient(to bottom, rgb(0 0 0 / 0.82), rgb(0 0 0 / 0.5));
}
}

View File

@@ -11,6 +11,14 @@
--clr-accent-hsl: 356.7 94.8% 45.5%; /* dasselbe Rot als H S L */
--clr-accent-h: 356.7; /* nur der Farbton — die Dateiablage (/dateien)
leitet daraus ihre Flächen ab, siehe dateien.css */
/* Akzentfarben einzelner Werbe-Banner (Sponsor-CI statt Vereins-Rot).
Sie stehen hier und nicht in data/banners.json, weil ein Hex-Wert aus der JSON
nur per Inline-Style in die Seite käme — und den verwirft die CSP
(default-src 'self', kein style-src). Die JSON nennt nur den Namen
("accent": "breadcrumb"), components.css bildet ihn auf dieses Token ab.
Neuer Sponsor mit eigener CI = eine Zeile hier plus eine Klasse dort. */
--clr-banner-accent-breadcrumb: #6cbbbc;
--clr-bg: #222222; /* Grund-Hintergrund (mit Textur) */
--clr-bg-deep: #161616; /* abgesetzte Sektionen */
--clr-bg-footer: #000000;

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB