feat(strommix): variant system + wald palette + UX polish
Deploy / verify (push) Successful in 52s
Deploy / deploy (push) Successful in 52s

Add full/compact variants, prominent share, source badge, big
negative-price banner with conditional disclaimer, cards-style
context row, fade-in animation, and remove em-dashes from
visible strings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-05 00:43:18 +02:00
parent 080e69dcac
commit 3ded972642
3 changed files with 280 additions and 129 deletions
+8
View File
@@ -337,6 +337,9 @@ export interface OrganisationsBlockData {
export interface LiveStrommixBlockData {
_type?: "live_strommix";
_slug?: string;
/** `full` = card with argument + breakdown disclosure. `compact` = single
* horizontal strip for sidebar / homepage placement. Default: `full`. */
variant?: "full" | "compact";
intro_headline?: string;
intro_text?: string;
threshold_dunkelflaute?: number;
@@ -352,6 +355,11 @@ export interface LiveStrommixBlockData {
argument_hoch?: string;
enable_france_comparison?: boolean;
disclaimer?: string;
/** Day-ahead price (€/MWh) below which `disclaimer` is rendered.
* Default 10 — disclaimer about EEG-Vergütung at negative prices is
* irrelevant at 130 €/MWh and gets hidden. Set very high (e.g. 9999)
* to always show. */
disclaimer_threshold_eur_mwh?: number;
/** Reference (resolved: { _slug, _type, ...page-fields } or just slug string). */
explanation_page?: string | { _slug?: string; title?: string };
layout?: BlockLayout;
+269 -129
View File
@@ -12,6 +12,7 @@
const t = useTranslate();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const isCompact = $derived(block.variant === "compact");
// Live data + lifecycle
let live = $state<StrommixResponse | null>(null);
@@ -89,6 +90,30 @@
block.disclaimer ? (marked.parse(block.disclaimer) as string) : "",
);
/** The default disclaimer is about EEG-Vergütung when prices go
* negative — irrelevant when the day-ahead price sits at 130 €/MWh.
* Show it only when the situation it describes can plausibly arise:
* current price below the threshold (default 10 €/MWh) or already
* negative. Editor can override the cutoff via
* `block.disclaimer_threshold_eur_mwh`; setting it to a very high
* number forces the disclaimer to always show. */
const disclaimerThreshold = $derived(
typeof block.disclaimer_threshold_eur_mwh === "number"
? block.disclaimer_threshold_eur_mwh
: 10,
);
const showDisclaimer = $derived.by(() => {
if (!disclaimerHtml) return false;
const p = live?.priceDeEurMwh;
if (p == null) return false;
return p < disclaimerThreshold;
});
/** When the negative-price banner shows the disclaimer, the footer
* shouldn't repeat it. Otherwise the same paragraph appears twice. */
const showFooterDisclaimer = $derived(
showDisclaimer && (live?.priceDeEurMwh ?? 0) >= 0,
);
const enableFr = $derived(block.enable_france_comparison !== false);
function fmtMw(mw: number): string {
@@ -137,37 +162,47 @@
: "success";
});
/** Bucket → windwiderstand-design-palette. fire/erde/himmel/wald
* statt der vorherigen Tailwind-defaults. Dunkelflaute = fire (rot
* warn), niedrig = erde (warm-erdig warnung), mittel = himmel
* (kühl-neutral), hoch = wald (grün, hier wäre erneuerbar
* ausreichend). */
const accentClasses = $derived.by(() => {
switch (bucketAccent) {
case "danger":
return {
box: "border-red-300 bg-red-50",
pct: "text-red-800",
label: "text-red-700",
box: "border-fire-300 bg-fire-50",
pct: "text-fire-800",
label: "text-fire-700",
badge: "bg-fire-700 text-fire-50",
};
case "warning":
return {
box: "border-amber-300 bg-amber-50",
pct: "text-amber-800",
label: "text-amber-700",
box: "border-erde-300 bg-erde-50",
pct: "text-erde-800",
label: "text-erde-700",
badge: "bg-erde-700 text-erde-50",
};
case "info":
return {
box: "border-sky-300 bg-sky-50",
pct: "text-sky-800",
label: "text-sky-700",
box: "border-himmel-300 bg-himmel-50",
pct: "text-himmel-800",
label: "text-himmel-700",
badge: "bg-himmel-700 text-himmel-50",
};
case "success":
return {
box: "border-emerald-300 bg-emerald-50",
pct: "text-emerald-800",
label: "text-emerald-700",
box: "border-wald-300 bg-wald-50",
pct: "text-wald-800",
label: "text-wald-700",
badge: "bg-wald-700 text-wald-50",
};
default:
return {
box: "border-neutral-200 bg-neutral-50",
pct: "text-neutral-800",
label: "text-neutral-700",
box: "border-stein-200 bg-stein-50",
pct: "text-stein-800",
label: "text-stein-700",
badge: "bg-stein-700 text-stein-50",
};
}
});
@@ -197,6 +232,54 @@
data-block-type="live_strommix"
data-block-slug={block._slug}
>
{#if isCompact}
<!-- Compact variant — single horizontal strip for sidebars or
homepage placement. No argument, no breakdown disclosure, no
quelle-attribution. Optional `explanation_page` makes the whole
strip linkable so visitors can jump to the full version. -->
{@const compactInner = (() => {
const pct = renewablePct != null ? Math.round(renewablePct) : null;
return { pct };
})()}
<svelte:element
this={explanationHref ? "a" : "div"}
href={explanationHref || undefined}
class="block rounded-md border border-l-4 bg-white shadow-sm transition-colors {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}
<span class="text-stein-500">{t(T.strommix_loading)}</span>
{:else}
{#if compactInner.pct != null}
{#key live.measuredAtUnix}
<span class="strommix-pct text-3xl sm:text-4xl font-extrabold tabular-nums leading-none {accentClasses.pct}">
{compactInner.pct}%
</span>
{/key}
<span class="text-xs sm:text-sm text-stein-700">
{t(T.strommix_wind_solar_share)}
<span class="font-semibold {accentClasses.label}"> · {statusLabel}</span>
</span>
{:else}
<span class="text-sm text-fire-700">
{t(T.strommix_load_missing)}
</span>
{/if}
<span class="ml-auto hidden sm:inline-flex items-center gap-3 text-xs text-stein-600 tabular-nums">
<span>{t(T.strommix_conventional_now)}: {fmtMw(live.conventionalMw)}</span>
<span>·</span>
<span>{fmtPrice(live.priceDeEurMwh)}</span>
</span>
{#if explanationHref}
<span class="text-xs text-stein-500 inline-flex items-center gap-1">
<Icon icon="mdi:chevron-right" class="size-3.5" aria-hidden="true" />
{t(T.strommix_compact_more)}
</span>
{/if}
{/if}
</div>
</svelte:element>
{:else}
{#if block.intro_headline || introHtml}
<header class="mb-4">
{#if block.intro_headline}
@@ -211,11 +294,13 @@
<div
class="rounded-xs border-2 shadow-sm overflow-hidden {accentClasses.box}"
>
<!-- Header bar -->
<!-- Header bar — title + source badge + last-updated stamp.
Source-Badge zeigt klar woher die zahlen kommen ohne dass
man die Detail-Aufklappkachel öffnen muss. -->
<div
class="flex items-center justify-between px-4 py-2 border-b border-current/10 text-xs uppercase tracking-wide font-semibold {accentClasses.label}"
class="flex flex-wrap items-center gap-x-3 gap-y-1 justify-between px-4 py-2 border-b border-current/10 text-xs font-semibold {accentClasses.label}"
>
<span class="inline-flex items-center gap-1.5">
<span class="inline-flex items-center gap-1.5 uppercase tracking-wide">
<Icon
icon="mdi:transmission-tower"
class="size-4"
@@ -223,12 +308,24 @@
/>
{t(T.strommix_header)}
</span>
{#if live?.measuredAtUnix}
<span class="font-normal normal-case"
>{t(T.strommix_stand, {
time: fmtTime(live.measuredAtUnix),
})}</span
>
{#if live}
<span class="inline-flex items-center gap-2 normal-case font-normal">
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] uppercase tracking-wide {accentClasses.badge}">
<Icon icon="mdi:database-outline" class="size-3" aria-hidden="true" />
{#if live.dataSource === "smard"}
SMARD
{:else if live.dataSource === "smard+energy-charts"}
SMARD + EC
{:else}
Energy-Charts
{/if}
</span>
{#if live.measuredAtUnix}
<span>
{t(T.strommix_stand, { time: fmtTime(live.measuredAtUnix) })}
</span>
{/if}
</span>
{/if}
</div>
@@ -238,28 +335,34 @@
{t(T.strommix_load_error, { error: loadError })}
</div>
{:else if !live}
<div class="p-6 text-center text-neutral-500 text-sm">
<div class="p-6 text-center text-stein-500 text-sm">
{t(T.strommix_loading)}
</div>
{:else}
<div class="p-6 text-center">
<div class="p-6 sm:p-8 text-center">
{#if renewablePct != null}
<div
class="text-6xl sm:text-7xl font-extrabold tabular-nums {accentClasses.pct}"
>
{Math.round(renewablePct)}%
</div>
<div class="mt-1 text-sm text-neutral-700">
<!-- Key change keys the % display so a fresh fetch
triggers a brief CSS fade-in transition. The
number is the dominant visual; everything else
supports it. -->
{#key live.measuredAtUnix}
<div
class="strommix-pct text-7xl sm:text-8xl font-extrabold tabular-nums leading-none {accentClasses.pct}"
>
{Math.round(renewablePct)}%
</div>
{/key}
<div class="mt-2 text-sm sm:text-base text-stein-700">
{t(T.strommix_wind_solar_share)}
</div>
<div
class="mt-3 text-base font-semibold {accentClasses.label}"
class="mt-3 text-lg font-semibold {accentClasses.label}"
>
{statusLabel}
</div>
{#if argumentHtml}
<div
class="prose prose-sm mt-3 max-w-prose mx-auto text-neutral-800"
class="prose prose-xs mt-2 max-w-md mx-auto text-xs leading-snug text-stein-700 [&_p]:my-1"
>
{@html argumentHtml}
</div>
@@ -267,144 +370,151 @@
{:else}
<!-- Production data arrived but load is missing → no percentage,
but we still show what we have so the widget isn't blank. -->
<div class="text-sm text-amber-700">
<div class="text-sm text-fire-700">
{t(T.strommix_load_missing)}
</div>
{/if}
</div>
<!-- Context row -->
<div
class="grid grid-cols-2 divide-x divide-current/10 border-t border-current/10 text-sm"
>
<div class="px-4 py-3">
<div
class="text-xs text-neutral-600 uppercase tracking-wide"
>
{t(T.strommix_conventional_now)}
</div>
<div class="text-lg font-semibold tabular-nums">
{fmtMw(live.conventionalMw)}
</div>
<div class="text-[11px] text-neutral-500 mt-0.5">
{t(T.strommix_conventional_formula)}
</div>
{/if}
</div>
<!-- Context cards: konventionell + saldo, je eigenes box-block.
Stack mobile, side-by-side ab sm. Kein farbakzent damit haupt-
karte mit %-zahl optisch dominiert. -->
{#if live}
<div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="rounded-xs border border-stein-200 bg-white px-4 py-3 text-sm shadow-sm">
<div class="text-xs text-stein-600 uppercase tracking-wide">
{t(T.strommix_conventional_now)}
</div>
<div class="px-4 py-3">
<div
class="text-xs text-neutral-600 uppercase tracking-wide"
>
{importDirection === "Import"
? t(T.strommix_import)
: importDirection === "Export"
? t(T.strommix_export)
: t(T.strommix_saldo)}
<div class="text-2xl font-semibold tabular-nums text-stein-900">
{fmtMw(live.conventionalMw)}
</div>
<div class="text-[11px] text-stein-500 mt-1">
{t(T.strommix_conventional_formula)}
</div>
</div>
<div class="rounded-xs border border-stein-200 bg-white px-4 py-3 text-sm shadow-sm">
<div class="text-xs text-stein-600 uppercase tracking-wide">
{importDirection === "Import"
? t(T.strommix_import)
: importDirection === "Export"
? t(T.strommix_export)
: t(T.strommix_saldo)}
</div>
<div class="text-2xl font-semibold tabular-nums text-stein-900">
{#if importDirection === "Import"}{/if}
{#if importDirection === "Export"}{/if}
{fmtGw(live.netFlowGw)}
</div>
{#if live.crossborderMeasuredAtUnix}
<div class="text-[11px] text-stein-500 mt-1">
{t(T.strommix_stand, {
time: fmtTime(live.crossborderMeasuredAtUnix),
})}
{#if cbStale}<span class="text-fire-700">
· {t(T.strommix_delayed)}</span
>{/if}
</div>
<div class="text-lg font-semibold tabular-nums">
{#if importDirection === "Import"}{/if}
{#if importDirection === "Export"}{/if}
{fmtGw(live.netFlowGw)}
{/if}
</div>
</div>
<!-- Price card — separater block, full-width damit der vergleich
DE/FR + slot-info nicht in den 2-spaltigen flow oben gepresst
werden muss. -->
<div class="mt-3 rounded-xs border border-stein-200 bg-white px-4 py-3 text-sm shadow-sm">
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<span class="font-semibold">
{t(T.strommix_price_de, { price: fmtPrice(live.priceDeEurMwh) })}
</span>
{#if enableFr && live.priceFrEurMwh != null}
<span class="text-stein-600">
{t(T.strommix_price_fr_inline, { price: fmtPrice(live.priceFrEurMwh) })}
</span>
{/if}
{#if live.priceMeasuredAtUnix}
<span class="text-[11px] text-stein-500">
· {t(T.strommix_slot, { time: fmtTime(live.priceMeasuredAtUnix) })}
</span>
{/if}
</div>
</div>
<!-- Negativpreis-warnung als eigener prominenter banner statt
inline-badge. Greift wenn Day-Ahead-Preis negativ — sehr
aussagekräftiger marktzustand der nicht versteckt werden
sollte. -->
{#if (live.priceDeEurMwh ?? 0) < 0}
<div class="mt-3 flex items-start gap-3 rounded-xs border-2 border-fire-500 bg-fire-50 px-4 py-3 text-sm shadow-sm">
<Icon
icon="mdi:alert-circle"
class="size-6 shrink-0 text-fire-700"
aria-hidden="true"
/>
<div class="min-w-0">
<div class="text-base font-bold text-fire-800">
{t(T.strommix_negative_price)}: {fmtPrice(live.priceDeEurMwh)}
</div>
{#if live.crossborderMeasuredAtUnix}
<div class="text-[11px] text-neutral-500 mt-0.5">
{t(T.strommix_stand, {
time: fmtTime(live.crossborderMeasuredAtUnix),
})}
{#if cbStale}<span class="text-amber-700">
· {t(T.strommix_delayed)}</span
>{/if}
{#if showDisclaimer}
<div class="mt-1 text-xs leading-snug text-fire-900/80 prose prose-xs max-w-none [&_p]:my-0">
{@html disclaimerHtml}
</div>
{/if}
</div>
</div>
<!-- Price row -->
<div class="border-t border-current/10 px-4 py-3 text-sm">
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<span class="font-semibold"
>{t(T.strommix_price_de, {
price: fmtPrice(live.priceDeEurMwh),
})}</span
>
{#if enableFr && live.priceFrEurMwh != null}
<span class="text-neutral-600"
>{t(T.strommix_price_fr_inline, {
price: fmtPrice(live.priceFrEurMwh),
})}</span
>
{/if}
{#if live.priceMeasuredAtUnix}
<span class="text-[11px] text-neutral-500">
· {t(T.strommix_slot, {
time: fmtTime(live.priceMeasuredAtUnix),
})}
</span>
{/if}
{#if (live.priceDeEurMwh ?? 0) < 0}
<span
class="ml-auto inline-flex items-center gap-1 text-amber-800 font-semibold"
>
<Icon
icon="mdi:alert-circle-outline"
class="size-4"
aria-hidden="true"
/>
{t(T.strommix_negative_price)}
</span>
{/if}
</div>
</div>
{/if}
</div>
{/if}
<!-- Production breakdown: aufklappbar damit user die Summe nachvollziehen
kann. Werte sind aufeinander abgestimmt (gleicher SMARD-15-min-Slot). -->
{#if live}
<details class="mt-3 text-xs">
<summary
class="cursor-pointer text-neutral-600 hover:text-neutral-900"
class="cursor-pointer text-stein-600 hover:text-stein-900"
>
{t(T.strommix_check_values)}
</summary>
<div
class="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 rounded-md border border-neutral-200 bg-neutral-50 px-3 py-2 tabular-nums sm:grid-cols-3"
class="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 rounded-md border border-stein-200 bg-stein-50 px-3 py-2 tabular-nums sm:grid-cols-3"
>
<div class="text-neutral-500">
<div class="text-stein-500">
{t(T.strommix_field_wind_onshore)}
</div>
<div>{fmtMw(live.windOnshoreMw)}</div>
<div class="text-neutral-500">
<div class="text-stein-500">
{t(T.strommix_field_wind_offshore)}
</div>
<div>{fmtMw(live.windOffshoreMw)}</div>
<div class="text-neutral-500">{t(T.strommix_field_solar)}</div>
<div class="text-stein-500">{t(T.strommix_field_solar)}</div>
<div>{fmtMw(live.solarMw)}</div>
<div class="text-neutral-500">
<div class="text-stein-500">
{t(T.strommix_field_lignite)}
</div>
<div>{fmtMw(live.ligniteMw)}</div>
<div class="text-neutral-500">
<div class="text-stein-500">
{t(T.strommix_field_hard_coal)}
</div>
<div>{fmtMw(live.hardCoalMw)}</div>
<div class="text-neutral-500">{t(T.strommix_field_gas)}</div>
<div class="text-stein-500">{t(T.strommix_field_gas)}</div>
<div>{fmtMw(live.gasMw)}</div>
<div class="text-neutral-500">
<div class="text-stein-500">
{t(T.strommix_field_nuclear)}
</div>
<div>{fmtMw(live.nuclearMw)}</div>
<div class="text-neutral-500">
<div class="text-stein-500">
{t(T.strommix_field_biomass)}
</div>
<div>{fmtMw(live.biomassMw)}</div>
<div class="text-neutral-500">{t(T.strommix_field_hydro)}</div>
<div class="text-stein-500">{t(T.strommix_field_hydro)}</div>
<div>{fmtMw(live.hydroMw)}</div>
<div class="text-neutral-500 font-semibold">
<div class="text-stein-500 font-semibold">
{t(T.strommix_field_load)}
</div>
<div class="font-semibold">{fmtMw(live.loadMw)}</div>
</div>
<p class="mt-1 text-[11px] text-neutral-500">
<p class="mt-1 text-[11px] text-stein-500">
{t(T.strommix_source)}
{#if live.dataSource === "smard"}
{t(T.strommix_source_smard)}
@@ -413,7 +523,7 @@
{:else}
{t(T.strommix_source_energy_charts)}
{/if}
{t(T.strommix_source_resolution, {
· {t(T.strommix_source_resolution, {
time: fmtTime(live.measuredAtUnix),
})}
{t(T.strommix_conventional_explanation)}
@@ -421,12 +531,14 @@
</details>
{/if}
<!-- Footer: disclaimer + explanation link + stale flag -->
{#if disclaimerHtml || explanationHref || isStale}
<!-- Footer: disclaimer (only when the low-price condition it describes
actually applies AND not already shown in the negative-price
banner) + explanation link + stale flag. -->
{#if showFooterDisclaimer || explanationHref || isStale}
<footer
class="mt-3 text-xs text-neutral-600 flex flex-wrap items-start gap-x-4 gap-y-1"
class="mt-3 text-xs text-stein-600 flex flex-wrap items-start gap-x-4 gap-y-1"
>
{#if disclaimerHtml}
{#if showFooterDisclaimer}
<div class="flex-1 min-w-0 prose prose-xs">
{@html disclaimerHtml}
</div>
@@ -437,7 +549,7 @@
</a>
{/if}
{#if isStale}
<span class="inline-flex items-center gap-1 text-amber-700">
<span class="inline-flex items-center gap-1 text-fire-700">
<Icon
icon="mdi:cloud-off-outline"
class="size-3.5"
@@ -448,4 +560,32 @@
{/if}
</footer>
{/if}
{/if}
</div>
<style>
/* Smooth fade-in when the % value gets re-keyed on a new fetch.
Subtle — the visitor notices the update without being
distracted. */
.strommix-pct {
animation: strommix-pct-in 250ms ease-out both;
}
@keyframes strommix-pct-in {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.strommix-pct {
animation: none;
}
}
/* Color/border transitions on the bucket-tinted card so the
accent shifts smoothly when the bucket flips between fetches. */
.live-strommix [class*="border-fire"],
.live-strommix [class*="border-erde"],
.live-strommix [class*="border-himmel"],
.live-strommix [class*="border-wald"],
.live-strommix [class*="border-stein"] {
transition: background-color 350ms ease, border-color 350ms ease, color 350ms ease;
}
</style>
+3
View File
@@ -204,6 +204,9 @@ const TRANSLATION_KEYS = [
"strommix_conventional_explanation",
"strommix_stored_data",
"strommix_how_calculated",
// Compact variant for homepage / sidebar.
"strommix_compact_label", // {{pct}}, {{status}}
"strommix_compact_more",
] as const;
export type TranslationKey = (typeof TRANSLATION_KEYS)[number];