perf: LCP-Fix, Banner-Crops, SSR-Cache, Bulk-Windkarte
Deploy / verify (push) Successful in 1m3s
Deploy / deploy (push) Successful in 1m47s

- TopBanner: JS-Fade-Gate entfernt (opacity-0 bis Hydration schob LCP
  von ~3s auf ~8.6s) — Bild malt jetzt progressiv über den LQIP
- buildBannerResponsive: Breakpoint-Varianten (1/1, 16/9, 24/9) mit
  media-Attributen, quality 60; CMS braucht explizites h neben ar,
  sonst kein Crop (Mobile bekam 1.92:1 unscharf hochskaliert)
- Layout-Preload: ein Link pro Breakpoint-Variante (WebP)
- Layout-Bootstrap-Batch + Logo-SVG-Fetch TTL-gecacht (liefen pro
  SSR-Request); invalidateCollection versteht CSV-Key-Heads
- WindkarteBlock: 40 Einzel-Fetches -> 1 List-Request mit Filter
- app.html: preconnect cms.pm86.de, api.iconify.design, analytics
- u-url Microformat: echte canonicalUrl statt href="#" (SEO-Audit)
- Icon-Subset: 'cloud' ergänzt (kein Runtime-Fetch an iconify mehr)
- StrommixBlock: nutzloses transition-colors entfernt (CLS-Audit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-06-12 09:50:01 +02:00
parent 41597f87b5
commit d9519a867e
13 changed files with 164 additions and 89 deletions
+8 -24
View File
@@ -8,7 +8,7 @@
import type { FullwidthBannerEntry } from "$lib/cms";
interface ResponsiveBannerImage {
sources: { type: string; srcset: string }[];
sources: { type: string; srcset: string; media?: string }[];
fallback: string;
width: number;
height: number;
@@ -79,18 +79,9 @@
const showBanner = $derived(hasImage || hasText || hasPageTitle);
const altText = $derived(banner?.headline ?? headline ?? "");
let imgEl = $state<HTMLImageElement | null>(null);
let imgLoaded = $state(false);
const currentSrc = $derived(responsive?.fallback ?? resolvedImages[0] ?? "");
$effect(() => {
// reset on src change, then probe cache
void currentSrc;
imgLoaded = false;
queueMicrotask(() => {
if (imgEl?.complete && imgEl.naturalWidth > 0) imgLoaded = true;
});
});
// Kein JS-gesteuertes Fade-in: `opacity-0` im SSR-HTML hält das Banner bis
// nach der Hydration unsichtbar und schob den LCP von ~3s auf ~8s. Das Bild
// malt jetzt progressiv über den LQIP-Background — sichtbar ab erstem Paint.
</script>
{#if showBanner}
@@ -103,23 +94,19 @@
{#if hasResponsive && responsive}
{#key responsive.fallback}
<picture class="block w-full max-w-[1920px] mx-auto">
{#each responsive.sources as s (s.type)}
<source type={s.type} srcset={s.srcset} sizes={sizes} />
{#each responsive.sources as s (s.media ? `${s.type}|${s.media}` : s.type)}
<source type={s.type} media={s.media} srcset={s.srcset} sizes={sizes} />
{/each}
<img
src={responsive.fallback}
alt={altText}
class="w-full object-cover max-h-[400px] lg:max-h-[600px] aspect-square sm:aspect-video lg:aspect-24/9 text-transparent transition-opacity duration-500"
class:opacity-0={!imgLoaded}
class:opacity-100={imgLoaded}
class="w-full object-cover max-h-[400px] lg:max-h-[600px] aspect-square sm:aspect-video lg:aspect-24/9 text-transparent"
width={responsive.width}
height={responsive.height}
style={imgStyle}
loading="eager"
fetchpriority="high"
decoding="async"
bind:this={imgEl}
onload={() => (imgLoaded = true)}
/>
</picture>
{/key}
@@ -128,15 +115,12 @@
<img
src={resolvedImages[0]}
alt={altText}
class="block w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] mx-auto aspect-square sm:aspect-video lg:aspect-24/9 text-transparent transition-opacity duration-500"
class:opacity-0={!imgLoaded}
class:opacity-100={imgLoaded}
class="block w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] mx-auto aspect-square sm:aspect-video lg:aspect-24/9 text-transparent"
width={1920}
height={600}
style={imgStyle}
loading="eager"
fetchpriority="high"
onload={() => (imgLoaded = true)}
/>
{/key}
{/if}
@@ -375,7 +375,7 @@
<svelte:element
this={explanationHref ? "a" : "div"}
href={explanationHref || undefined}
class="block rounded-xs border border-l-4 bg-white shadow-sm transition-colors {accentClasses.box} {explanationHref ? 'no-underline! hover:brightness-95' : ''}"
class="block rounded-xs border border-l-4 bg-white shadow-sm {accentClasses.box} {explanationHref ? 'no-underline! hover:brightness-95' : ''}"
>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-3 text-sm">
{#if !live}
+11 -16
View File
@@ -69,22 +69,17 @@ let areas = $state<WindArea[]>([]);
.map((a) => (typeof a === "string" ? a : a._slug))
.filter(Boolean);
if (slugs.length > 0) {
const results = await Promise.all(
slugs.map((slug) =>
fetch(`${CMS_BASE}/api/content/wind_area/${slug}`, {
signal: AbortSignal.timeout(6_000),
}).then((r) => (r.ok ? r.json() : null))
)
);
areas = results.filter((r): r is WindArea => r !== null);
} else {
const res = await fetch(`${CMS_BASE}/api/content/wind_area?_limit=100`, {
signal: AbortSignal.timeout(6_000),
});
if (res.ok) {
const data = await res.json();
areas = data.items ?? [];
const res = await fetch(`${CMS_BASE}/api/content/wind_area?_limit=200`, {
signal: AbortSignal.timeout(6_000),
});
if (res.ok) {
const data = await res.json();
const allAreas: WindArea[] = data.items ?? [];
if (slugs.length > 0) {
const slugSet = new Set(slugs);
areas = allAreas.filter((a) => slugSet.has(a._slug));
} else {
areas = allAreas;
}
}
} catch {