feat(ui): deadline banner with gradient blob bg, swap Lora→Aleo
Deploy / verify (push) Failing after 17s
Deploy / deploy (push) Has been skipped

- DeadlineBannerBlock: redesign nach Tailwind-UI-Vorlage (gray-50, blurry blobs, dismiss button)
- new .gradient-blob-bg utility (radial-gradient blobs via ::before/::after)
- apply blob bg to Header
- @theme: register --font-sans (Inter) + --font-serif (Aleo)
- replace @fontsource-variable/lora with /aleo for blockquote font
- Header: simplify active nav-link styling
- QuoteBlock: formatting + drop borderClass

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-05-05 16:17:08 +02:00
parent 3f62473c32
commit 44197429d6
6 changed files with 2842 additions and 485 deletions
+7 -6
View File
@@ -100,7 +100,7 @@
<header
id="horizontal-navigation"
class="sticky top-0 z-20 border-b border-white/30 shadow-2xl backdrop-blur-md bg-wald-50"
class="gradient-blob-bg sticky top-0 z-20 border-b border-white/30 shadow-2xl backdrop-blur-md bg-wald-50"
>
<div class="flex items-center justify-between container-custom py-2">
<a
@@ -129,9 +129,7 @@
{@const active = isActiveLink(link.href, $page.url.pathname)}
<a
href={link.href}
class="text-[.7rem] py-1 transition-colors hover:text-wald-700 {active
? 'font-bold text-wald-700 border-b-2 border-wald-600'
: 'border-b-2 border-transparent'}"
class="text-[.7rem] py-1 {active ? 'font-bold ' : ''}"
aria-current={active ? "page" : undefined}
>
{link.label}
@@ -242,13 +240,16 @@
aria-label={t(T.header_nav_aria)}
>
{#each links as link}
{@const active = isActiveLink(link.href, $page.url.pathname)}
{@const active = isActiveLink(
link.href,
$page.url.pathname,
)}
<a
href={link.href}
class="block py-4 px-3 rounded-xs transition-colors text-base {active
? 'font-bold text-wald-300 bg-wald-700/20 border-l-4 border-wald-400'
: 'text-stein-100 hover:text-stein-0 hover:bg-stein-0/10 border-l-4 border-transparent'}"
aria-current={active ? 'page' : undefined}
aria-current={active ? "page" : undefined}
onclick={closeMenu}
>
{link.label}
@@ -1,149 +1,147 @@
<script lang="ts">
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { DeadlineBannerBlockData } from "$lib/block-types";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { DeadlineBannerBlockData } from "$lib/block-types";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
let { block }: { block: DeadlineBannerBlockData } = $props();
let { block }: { block: DeadlineBannerBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const variant = $derived(block.variant ?? "accent");
const showCountdown = $derived(block.showCountdown !== false);
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const showCountdown = $derived(block.showCountdown !== false);
type ResolvedItem = {
title?: string;
terminZeit?: string;
description?: string;
link?: DeadlineBannerBlockData["link"];
};
let dismissed = $state(false);
const autoItem = $derived.by((): ResolvedItem | null => {
if (block.mode !== "auto") return null;
const now = Date.now();
const items = (block.items ?? [])
.filter(
(x): x is ResolvedItem =>
typeof x === "object" && x !== null && "terminZeit" in x,
)
.map((x): { x: ResolvedItem; ts: number } => ({ x, ts: Date.parse(x.terminZeit ?? "") }))
.filter(({ ts }) => !isNaN(ts) && ts >= now)
.sort((a, b) => a.ts - b.ts);
return items[0]?.x ?? null;
});
type ResolvedItem = {
title?: string;
terminZeit?: string;
description?: string;
link?: DeadlineBannerBlockData["link"];
};
const text = $derived(
block.mode === "auto" ? autoItem?.title ?? "" : block.text ?? "",
);
const dateIso = $derived(
block.mode === "auto" ? autoItem?.terminZeit ?? "" : block.date ?? "",
);
const linkValue = $derived(
block.mode === "auto" ? autoItem?.link : block.link,
);
const dateObj = $derived.by(() => {
if (!dateIso) return null;
const d = new Date(dateIso);
return isNaN(d.getTime()) ? null : d;
});
const dateStr = $derived.by(() => {
if (!dateObj) return "";
return dateObj.toLocaleDateString("de-DE", {
day: "numeric",
month: "long",
const autoItem = $derived.by((): ResolvedItem | null => {
if (block.mode !== "auto") return null;
const now = Date.now();
const items = (block.items ?? [])
.filter(
(x): x is ResolvedItem =>
typeof x === "object" && x !== null && "terminZeit" in x,
)
.map((x): { x: ResolvedItem; ts: number } => ({
x,
ts: Date.parse(x.terminZeit ?? ""),
}))
.filter(({ ts }) => !isNaN(ts) && ts >= now)
.sort((a, b) => a.ts - b.ts);
return items[0]?.x ?? null;
});
});
const daysLeft = $derived.by(() => {
if (!dateObj) return null;
const ms = dateObj.getTime() - Date.now();
if (ms < 0) return null;
return Math.ceil(ms / (1000 * 60 * 60 * 24));
});
const text = $derived(
block.mode === "auto" ? (autoItem?.title ?? "") : (block.text ?? ""),
);
const dateIso = $derived(
block.mode === "auto"
? (autoItem?.terminZeit ?? "")
: (block.date ?? ""),
);
const linkValue = $derived(
block.mode === "auto" ? autoItem?.link : block.link,
);
const countdownStr = $derived.by(() => {
if (!showCountdown || daysLeft == null) return "";
if (daysLeft === 0) return "Heute";
if (daysLeft === 1) return "Morgen";
return `Noch ${daysLeft} Tage`;
});
const dateObj = $derived.by(() => {
if (!dateIso) return null;
const d = new Date(dateIso);
return isNaN(d.getTime()) ? null : d;
});
function getHref(link: DeadlineBannerBlockData["link"]): string {
if (typeof link === "string" && link) return link;
if (link && typeof link === "object" && link.url) return link.url;
return "";
}
const href = $derived(getHref(linkValue));
const newTab = $derived(
linkValue && typeof linkValue === "object" ? !!linkValue.newTab : false,
);
const dateStr = $derived.by(() => {
if (!dateObj) return "";
return dateObj.toLocaleDateString("de-DE", {
day: "numeric",
month: "long",
});
});
const variantClasses = $derived(
variant === "urgent"
? "bg-erde-100 text-erde-900"
: variant === "info"
? "bg-himmel-100 text-himmel-900"
: "bg-wald-500 text-wald-50",
);
const iconName = $derived(
variant === "urgent" ? "mdi:alert-circle" : "mdi:calendar-clock",
);
const hasContent = $derived(!!(text || dateStr || countdownStr));
const daysLeft = $derived.by(() => {
if (!dateObj) return null;
const ms = dateObj.getTime() - Date.now();
if (ms < 0) return null;
return Math.ceil(ms / (1000 * 60 * 60 * 24));
});
const countdownStr = $derived.by(() => {
if (!showCountdown || daysLeft == null) return "";
if (daysLeft === 0) return "Heute";
if (daysLeft === 1) return "Morgen";
return `Noch ${daysLeft} Tage`;
});
function getHref(link: DeadlineBannerBlockData["link"]): string {
if (typeof link === "string" && link) return link;
if (link && typeof link === "object" && link.url) return link.url;
return "";
}
const href = $derived(getHref(linkValue));
const newTab = $derived(
linkValue && typeof linkValue === "object" ? !!linkValue.newTab : false,
);
const ctaLabel = $derived(
linkValue && typeof linkValue === "object" && linkValue.linkName
? linkValue.linkName
: "Mehr erfahren",
);
const sentence = $derived.by(() => {
const parts: string[] = [];
if (text) parts.push(text);
if (dateStr) parts.push(`am ${dateStr}`);
return parts.join(" ");
});
const hasContent = $derived(!!(sentence || countdownStr));
</script>
{#if hasContent}
<div
class="deadline-banner {layoutClasses}"
data-block-type="deadline_banner"
data-block-slug={block._slug}
>
{#snippet inner()}
<div class="container-custom flex flex-wrap sm:flex-nowrap items-center gap-x-2 gap-y-1 px-4 py-2 text-xs sm:text-sm">
{#if dateStr}
<span class="inline-flex items-center rounded-xs bg-white/50 text-wald-900 px-2 py-0.5 text-sm sm:text-base font-bold whitespace-nowrap shrink-0">
<span class="inline-flex items-center gap-1.5">
<Icon icon={iconName} class="size-4 shrink-0" aria-hidden="true" />
{dateStr}
</span>
{#if countdownStr}
<span
class="ml-0 inline-flex min-w-0 max-w-0 scale-[0.96] items-center overflow-hidden whitespace-nowrap rounded-xs border border-transparent py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide opacity-0 transition-[max-width,margin,opacity,transform,padding,border-color] duration-200 ease-out group-hover:ml-2 group-hover:max-w-48 group-hover:border-wald-900 group-hover:px-2 group-hover:opacity-100 group-hover:scale-100 group-focus-within:ml-2 group-focus-within:max-w-48 group-focus-within:border-wald-900 group-focus-within:px-2 group-focus-within:opacity-100 group-focus-within:scale-100"
>
{countdownStr}
</span>
{/if}
</span>
{:else}
<span class="inline-flex items-center gap-0 shrink-0">
<Icon icon={iconName} class="size-4 shrink-0" aria-hidden="true" />
{#if countdownStr}
<span
class="ml-0 inline-flex min-w-0 max-w-0 shrink-0 scale-[0.96] items-center overflow-hidden whitespace-nowrap rounded-full px-0 py-0.5 text-xs font-semibold uppercase tracking-wide opacity-0 transition-[max-width,margin,opacity,transform,padding] duration-200 ease-out group-hover:ml-2 group-hover:max-w-48 group-hover:px-2 group-hover:opacity-100 group-hover:scale-100 group-focus-within:ml-2 group-focus-within:max-w-48 group-focus-within:px-2 group-focus-within:opacity-100 group-focus-within:scale-100"
>
{countdownStr}
</span>
{/if}
</span>
{/if}
{#if text}
<span class="min-w-0 wrap-break-word line-clamp-2 sm:line-clamp-1 font-semibold text-shadow-sm">{text}</span>
{/if}
</div>
{/snippet}
{#if href}
<a
href={href}
target={newTab ? "_blank" : undefined}
rel={newTab ? "noopener noreferrer" : undefined}
class="group block no-underline text-inherit hover:no-underline transition-all hover:brightness-95 {variantClasses}"
>
{@render inner()}
</a>
{:else}
<div class="group {variantClasses}">
{@render inner()}
</div>
{/if}
</div>
{#if hasContent && !dismissed}
<div
class="deadline-banner {layoutClasses}"
data-block-type="deadline_banner"
data-block-slug={block._slug}
>
<div
class="gradient-blob-bg flex items-center gap-x-6 bg-gray-50 px-6 py-2.5 sm:px-3.5"
>
<span class="hidden flex-1 sm:block" aria-hidden="true"></span>
<p class="my-0! text-sm/6 text-gray-900">
{sentence}{#if countdownStr}{sentence ? " · " : ""}<span
class="font-semibold">{countdownStr}</span
>{/if}{#if href}
{" "}<a
{href}
target={newTab ? "_blank" : undefined}
rel={newTab ? "noopener noreferrer" : undefined}
class="font-semibold whitespace-nowrap text-gray-900 hover:text-gray-700"
>
{ctaLabel}&nbsp;<Icon
icon="mdi:arrow-right"
class="inline-block size-4 align-[-0.15em]"
aria-hidden="true"
/>
</a>
{/if}
</p>
<div class="flex flex-1 justify-end">
<button
type="button"
onclick={() => (dismissed = true)}
class="-m-3 p-3 focus-visible:-outline-offset-4"
>
<span class="sr-only">Schließen</span>
<Icon
icon="mdi:close"
class="size-5 text-gray-900"
aria-hidden="true"
/>
</button>
</div>
</div>
</div>
{/if}
+36 -23
View File
@@ -1,34 +1,47 @@
<script lang="ts">
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { QuoteBlockData } from "$lib/block-types";
import { getBlockLayoutClasses } from "$lib/block-layout";
import type { QuoteBlockData } from "$lib/block-types";
let {
block,
bare = false,
extraClass = "",
}: { block: QuoteBlockData; bare?: boolean; extraClass?: string } = $props();
let {
block,
bare = false,
extraClass = "",
}: {
block: QuoteBlockData;
bare?: boolean;
extraClass?: string;
} = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const isCenter = $derived(block.variant === "center");
const alignClass = $derived(
isCenter ? "text-center" : block.variant === "right" ? "text-right" : "text-left",
);
const borderClass = $derived(isCenter ? "" : "border-l-4 border-wald-700 pl-4");
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const isCenter = $derived(block.variant === "center");
const alignClass = $derived(
isCenter
? "text-center"
: block.variant === "right"
? "text-right"
: "text-left",
);
</script>
{#snippet quoteMarkup()}
<blockquote class="{borderClass} {alignClass} {extraClass}">
<p class="text-lg text-stein-700 mb-2! ">"{block.quote ?? ""}"</p>
{#if block.author}
<cite class="mt-1 block not-italic text-xs text-stein-400">{block.author}</cite>
{/if}
</blockquote>
<blockquote class="{alignClass} {extraClass}">
<p class="text-lg mb-2! font-serif">
{block.quote ?? ""}
</p>
{#if block.author}
<cite class="mt-1 block not-italic text-xs">{block.author}</cite>
{/if}
</blockquote>
{/snippet}
{#if bare}
{@render quoteMarkup()}
{:else}
<div class={layoutClasses} data-block-type="quote" data-block-slug={block._slug}>
{@render quoteMarkup()}
</div>
{:else}
<div
class={layoutClasses}
data-block-type="quote"
data-block-slug={block._slug}
>
{@render quoteMarkup()}
</div>
{/if}