feat(blocks): SectionGrid/InfoCard/ContactForm + Konsolidierung (Tier 1)
Deploy / verify (push) Successful in 1m31s
Deploy / deploy (push) Successful in 1m33s

Neue Komponenten: SectionGrid, InfoCard (tone/compact), ArrowLink, ContactFormBlock,
SectionGridBlock. CMS-Blöcke section_grid + contact_form (info_card-Collection).
OrganisationsBlock: presentation "grid" (SectionGrid + InfoCard-Tiles).
Kontaktformular-Submit: size md + Send-Icon.

Konsolidierung Tier 1:
- .card-surface + Card-Atom → rounded-lg, .chip → rounded-full, Input-Atome → rounded-md
- tote .btn-*-Klassen aus app.css entfernt (Atom nutzt --color-btn-*-Tokens)
- hardcoded Hex → stein-Tokens (InfoCard, ContactFormBlock)
- redundante rounded-sm auf card-surface-Nutzern entfernt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-07-12 10:49:54 +02:00
parent 8fd0112dcb
commit f77314dc49
18 changed files with 274 additions and 105 deletions
+5 -60
View File
@@ -17,7 +17,7 @@
@layer components {
/* Shared card surface: border, background, shadow, radius */
.card-surface {
@apply rounded-sm border border-stein-200 bg-white shadow-sm;
@apply rounded-lg border border-stein-200 bg-white shadow-sm;
}
/* Diagonal strikethrough for past calendar days */
@@ -383,69 +383,14 @@ main ol:not(.not-prose):where(:not(.not-prose *)) li {
list-style-type: decimal;
}
/* Buttons gemeinsame Basis */
.btn-primary,
.btn-secondary,
.btn-tertiary,
.btn-outline,
.btn-warning {
@apply inline-flex items-center gap-1 no-underline whitespace-nowrap transition-colors duration-200 font-medium px-4 py-2 rounded-md;
}
/* Buttons Wald (Primary) */
.btn-primary {
@apply shadow-sm bg-btn-bg text-btn-txt;
}
.btn-primary:hover {
@apply bg-btn-hover-bg;
}
/* Buttons Stein outline (Secondary) */
.btn-secondary {
@apply border border-stein-300 bg-white text-stein-700 shadow-sm;
}
.btn-secondary:hover {
@apply bg-stein-100 border-stein-400;
}
/* Buttons Himmel (Tertiary) */
.btn-tertiary {
@apply shadow-sm bg-himmel-700 text-white;
}
.btn-tertiary:hover {
@apply bg-himmel-800;
}
/* Buttons Stein-Border klein (Outline) */
.btn-outline {
@apply border border-stein-300 text-stein-700;
}
.btn-outline:hover {
@apply bg-stein-100;
}
/* Buttons Gelb (Signal / Aufmerksamkeit, z. B. Termin melden) */
.btn-warning {
@apply border border-gelb-500 bg-gelb-300 text-gelb-900 shadow-sm;
}
.btn-warning:hover {
@apply bg-gelb-400;
}
/* Größen-Modifier — nach den btn-* definiert damit override greift */
.btn-sm {
@apply px-2 py-1 text-xs gap-1;
}
/* .btn-*-Klassen entfernt (2026-07-12): Buttons laufen jetzt komplett über das
Button-Atom (src/lib/ui/Button.svelte). Die --color-btn-* Tokens bleiben,
weil das Atom `bg-btn-bg`/`text-btn-txt`/`bg-btn-hover-bg` nutzt. */
/* Filter-Chip — Basis für alle Filter-Chips (Kalender, Adressbuch, …).
Aktiv/inaktiv-Farben werden kontextuell per Klasse gesetzt. */
.chip {
@apply inline-flex cursor-pointer items-center whitespace-nowrap rounded-xs border px-2 py-0.5 text-[11px] font-medium transition-colors;
@apply inline-flex cursor-pointer items-center whitespace-nowrap rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors;
}
/* Dekorativer Hintergrund mit zwei verschwommenen Verlaufs-Blobs (Wald → Erde).
+18 -1
View File
@@ -370,7 +370,7 @@ export interface OrganisationsBlockData {
action?: string | { url?: string; linkName?: string; newTab?: boolean };
description?: string;
/** default = Karten-Grid; compact = engere Karten, Logo links; carousel = horizontaler Slider (3 Desktop / 2 Mobile) */
presentation?: "default" | "compact" | "carousel";
presentation?: "default" | "compact" | "carousel" | "grid";
addOrganisationTitle?: string;
addOrganisationMarkdown?: string;
/** Referenz auf link-Collection (aufgelöst: url, linkName, newTab) */
@@ -588,6 +588,8 @@ export interface InfoCardData {
linkLabel?: string;
linkHref?: string;
external?: boolean;
/** Farbton der Kachel: default (weiß) oder green (grün getönt). */
tone?: "default" | "green";
}
/** Sektions-Grid (_type: "section_grid"): SectionHeader + info_card-Kacheln. */
@@ -607,3 +609,18 @@ export interface SectionGridBlockData {
cards?: (string | InfoCardData)[];
layout?: BlockLayout;
}
/** Zweispaltiger Formular-Block (_type: "contact_form"): Form links, Aside rechts. */
export interface ContactFormBlockData {
_type?: "contact_form";
_slug?: string;
headline?: string;
kicker?: string;
number?: string;
action?: string | { url?: string; linkName?: string; newTab?: boolean };
form?: "form-contact" | "form-mitmachen" | "form-newsletter" | "form-newsletter-inline";
asideTitle?: string;
asideText?: string;
asideCards?: (string | InfoCardData)[];
layout?: BlockLayout;
}
+21
View File
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import ArrowLink from './ArrowLink.svelte';
const meta = {
title: 'Molecules/ArrowLink',
component: ArrowLink,
tags: ['autodocs'],
parameters: { layout: 'centered' },
args: { href: '#', label: 'Mehr erfahren' },
argTypes: {
size: { control: 'select', options: ['sm', 'md'] },
external: { control: 'boolean' },
},
} satisfies Meta<typeof ArrowLink>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Medium: Story = { args: { size: 'md', label: 'Zum Adressbuch' } };
export const Klein: Story = { args: { size: 'sm', label: 'cloud.windwiderstand.de' } };
export const Extern: Story = { args: { size: 'sm', label: 'Beitreten', external: true } };
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
/**
* ArrowLink — Text-Link mit Pfeil, himmel-Farbe. Die eine Quelle für die
* „… →"-Links (SectionHeader-CTA, InfoCard, Sektions-Aktionen).
* `size`: sm (kompakt, z.B. in Cards) · md (Sektionsköpfe).
*/
type Size = "sm" | "md";
let {
href,
label,
external = false,
size = "md",
}: {
href: string;
label: string;
external?: boolean;
size?: Size;
} = $props();
const textClass: Record<Size, string> = {
sm: "text-[13px]",
md: "text-[15px]",
};
const arrowClass: Record<Size, string> = {
sm: "size-3.5",
md: "size-4",
};
</script>
<a
{href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
class="inline-flex items-center gap-1.5 font-semibold text-himmel-500 no-underline hover:text-himmel-600 {textClass[size]}"
>
{label}
<svg class={arrowClass[size]} viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</a>
+4
View File
@@ -24,6 +24,7 @@
import AdressbuchBlock from "./blocks/AdressbuchBlock.svelte";
import OrganisationsMapBlock from "./blocks/OrganisationsMapBlock.svelte";
import SectionGridBlock from "./blocks/SectionGridBlock.svelte";
import ContactFormBlock from "./blocks/ContactFormBlock.svelte";
import type { Translations } from "$lib/translations";
import type {
ResolvedBlock,
@@ -53,6 +54,7 @@
StellingnahmeGeneratorBlockData,
OrganisationsMapBlockData,
SectionGridBlockData,
ContactFormBlockData,
} from "$lib/block-types";
let {
@@ -121,6 +123,8 @@
<OrganisationsMapBlock block={block as OrganisationsMapBlockData} />
{:else if type === "section_grid"}
<SectionGridBlock block={block as SectionGridBlockData} />
{:else if type === "contact_form"}
<ContactFormBlock block={block as ContactFormBlockData} />
{:else if type === "stellungnahme_generator"}
{#await import("./blocks/StellingnahmeGeneratorBlock.svelte") then m}
{@const Comp = m.default}
+1 -1
View File
@@ -7,7 +7,7 @@
* tile + post: PostCard Shell ohne Padding, Medien bündig, Text mit p-4 im Kind.
* callout: CTA; layout wird ignoriert.
*/
const cardSurface = cva("relative min-w-0 overflow-hidden rounded-sm", {
const cardSurface = cva("relative min-w-0 overflow-hidden rounded-lg", {
variants: {
variant: {
tile: "",
+1 -1
View File
@@ -16,7 +16,7 @@
</script>
<div
class="not-prose card-surface relative flex h-full min-w-0 flex-col gap-2 overflow-hidden rounded-sm p-4 transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:border-wald-300 hover:shadow-md"
class="not-prose card-surface relative flex h-full min-w-0 flex-col gap-2 overflow-hidden p-4 transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:border-wald-300 hover:shadow-md"
>
<div class="flex flex-col gap-1.5">
<p class="truncate pr-6 text-sm font-medium leading-tight text-stein-900">{contact.name ?? ""}</p>
+59 -25
View File
@@ -5,10 +5,15 @@
*
* `icon` ist entweder ein Iconify-String ("mdi:email") ODER eine Bild-URL
* (https://… oder /…) — wird automatisch erkannt.
* `tone`: default (weiß) oder green (grün getönt, z.B. Aside-Boxen).
* `compact`: kleinere Maße + Icon inline neben dem Titel (spart Höhe).
*/
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import RustyImage from "$lib/components/RustyImage.svelte";
import ArrowLink from "$lib/components/ArrowLink.svelte";
type Tone = "default" | "green";
let {
icon = undefined,
@@ -17,6 +22,8 @@
linkLabel = undefined,
linkHref = undefined,
external = false,
tone = "default",
compact = false,
}: {
icon?: string;
title: string;
@@ -24,38 +31,65 @@
linkLabel?: string;
linkHref?: string;
external?: boolean;
tone?: Tone;
compact?: boolean;
} = $props();
// Bild-URL vs Iconify-String unterscheiden.
const isImage = $derived(!!icon && (/^https?:\/\//.test(icon) || icon.startsWith("/")));
const hasLink = $derived(!!linkLabel && !!linkHref);
</script>
<div class="flex h-full flex-col gap-[14px] rounded-lg border border-[#d5d8d6] bg-white p-[22px]">
{#if icon}
<div class="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-full bg-wald-50 text-wald-500">
{#if isImage}
<RustyImage src={icon} width={44} aspect="1/1" alt="" class="size-full object-cover" />
{:else}
<Icon icon={icon} class="size-[23px]" aria-hidden="true" />
{#snippet iconBadge(sizeClass: string, iconSize: string)}
<div
class="flex shrink-0 items-center justify-center overflow-hidden rounded-full {sizeClass} {isImage
? 'border border-stein-200 bg-white'
: tone === 'green'
? 'bg-white text-wald-600'
: 'bg-wald-50 text-wald-500'}"
>
{#if isImage}
<RustyImage src={icon ?? ""} width={44} aspect="1/1" fit="contain" alt="" class="size-full object-contain p-0.5" />
{:else}
<Icon icon={icon ?? ""} class={iconSize} aria-hidden="true" />
{/if}
</div>
{/snippet}
<div
class="flex h-full flex-col rounded-lg border {compact ? 'gap-2 p-4' : 'gap-[14px] p-[22px]'} {tone ===
'green'
? 'border-wald-100 bg-wald-50'
: 'border-stein-200 bg-white'}"
>
{#if compact}
<!-- Kompakt: Icon hängt links, Text-Spalte (Titel/Desc/Link) rechts bündig. -->
<div class="flex gap-2.5">
{#if icon}<div class="pt-px">{@render iconBadge("size-5", "size-3")}</div>{/if}
<div class="flex min-w-0 flex-1 flex-col gap-2">
<h3 class="text-sm! font-bold! leading-[1.3]! tracking-[-0.01em] text-stein-800">{title}</h3>
{#if description}
<p class="line-clamp-3 text-[13px] font-normal leading-[1.45] text-stein-500">{description}</p>
{/if}
{#if hasLink}
<div class="pt-0.5">
<ArrowLink href={linkHref ?? "#"} label={linkLabel ?? ""} {external} size="sm" />
</div>
{/if}
</div>
</div>
{:else}
{#if icon}{@render iconBadge("size-11", "size-[23px]")}{/if}
<div class="flex flex-col gap-1">
<!-- text-*! nötig: app.css setzt h3-Größe unlayered → Cascade-Layers. -->
<h3 class="text-base! font-bold! leading-[1.3]! tracking-[-0.01em] text-stein-800">{title}</h3>
{#if description}
<p class="line-clamp-3 text-sm font-normal leading-[1.45] text-stein-500">{description}</p>
{/if}
</div>
{/if}
<h3 class="text-base font-bold tracking-[-0.01em] text-[#1f2221]">{title}</h3>
{#if description}
<p class="text-sm font-normal leading-[1.45] text-[#636966]">{description}</p>
{/if}
{#if hasLink}
<a
href={linkHref}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
class="mt-auto inline-flex items-center gap-1.5 text-sm font-semibold text-himmel-500 no-underline hover:text-himmel-600"
>
{linkLabel}
<svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</a>
{#if hasLink}
<div class="mt-auto pt-0.5">
<ArrowLink href={linkHref ?? "#"} label={linkLabel ?? ""} {external} size="sm" />
</div>
{/if}
{/if}
</div>
+1 -1
View File
@@ -38,7 +38,7 @@
<a
{href}
class="group flex gap-3 overflow-hidden rounded-sm card-surface p-0 text-stein-900 no-underline transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-wald-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2"
class="group flex gap-3 overflow-hidden card-surface p-0 text-stein-900 no-underline transition-[transform,box-shadow,border-color] duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-wald-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-500 focus-visible:ring-offset-2"
>
<div class="relative w-28 shrink-0 self-stretch overflow-hidden bg-stein-100 sm:w-32">
{#if rawImg}
+1 -1
View File
@@ -46,7 +46,7 @@
<section>
{#if hasHeader}
<div class="mb-6">
<div class="mb-4">
<SectionHeader
{number}
{kicker}
+2 -7
View File
@@ -9,6 +9,7 @@
* actionLabel="Zum Adressbuch" actionHref="/adressbuch" />
*/
import type { Snippet } from 'svelte';
import ArrowLink from '$lib/components/ArrowLink.svelte';
let {
number = undefined,
@@ -69,13 +70,7 @@
{#if children}
{@render children()}
{:else}
<a
href={actionHref}
class="inline-flex items-center gap-1.5 text-[15px] font-semibold text-himmel-500 no-underline hover:text-himmel-600"
>
{actionLabel}
<svg class="size-4" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</a>
<ArrowLink href={actionHref ?? "#"} label={actionLabel ?? ""} size="md" />
{/if}
</div>
{/if}
@@ -0,0 +1,77 @@
<script lang="ts">
import { marked } from "$lib/markdown-safe";
import { getBlockLayoutClasses } from "$lib/block-layout";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import InfoCard from "$lib/components/InfoCard.svelte";
import InternalComponentBlock from "./InternalComponentBlock.svelte";
import type { ContactFormBlockData, InfoCardData } from "$lib/block-types";
let { block }: { block: ContactFormBlockData } = $props();
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
const actionRef = $derived(
block.action && typeof block.action === "object" ? block.action : null,
);
const hasHeader = $derived(!!block.number || !!block.kicker || !!block.headline);
const asideHtml = $derived(
block.asideText && typeof block.asideText === "string"
? (marked.parse(block.asideText) as string)
: "",
);
const cards = $derived(
(block.asideCards ?? []).filter(
(c): c is InfoCardData => typeof c === "object" && c !== null,
),
);
const formId = $derived(block.form ?? "form-contact");
</script>
<div
class={layoutClasses}
data-block="ContactForm"
data-block-type="contact_form"
data-block-slug={block._slug}
>
{#if hasHeader}
<div class="mb-4">
<SectionHeader
number={block.number}
kicker={block.kicker}
title={block.headline}
actionLabel={actionRef?.linkName}
actionHref={actionRef?.url}
/>
</div>
{/if}
<div class="grid grid-cols-1 gap-8 lg:grid-cols-[1.4fr_1fr] lg:items-start">
<!-- links: Formular -->
<div>
<InternalComponentBlock block={{ _type: "internal_component", component: formId }} />
</div>
<!-- rechts: Aside -->
<aside class="flex flex-col gap-5">
{#if block.asideTitle}
<h3 class="text-xl! font-bold! leading-tight! tracking-[-0.01em] text-stein-800">
{block.asideTitle}
</h3>
{/if}
{#if asideHtml}
<div class="markdown prose prose-stein max-w-none text-stein-500">{@html asideHtml}</div>
{/if}
{#each cards as card}
<InfoCard
icon={card.icon}
title={card.title ?? ""}
description={card.description}
linkLabel={card.linkLabel}
linkHref={card.linkHref}
external={card.external}
tone={card.tone}
compact
/>
{/each}
</aside>
</div>
</div>
@@ -10,6 +10,8 @@
import RustyImage from "$lib/components/RustyImage.svelte";
import Button from "$lib/ui/Button.svelte";
import SectionHeader from "$lib/components/SectionHeader.svelte";
import SectionGrid from "$lib/components/SectionGrid.svelte";
import InfoCard from "$lib/components/InfoCard.svelte";
function organisationLogoField(o: OrganisationEntry) {
return extractCmsImageField(o.logo);
@@ -32,6 +34,8 @@
);
const compact = $derived(block.presentation === "compact");
const carousel = $derived(block.presentation === "carousel");
// "grid" = SectionGrid + InfoCard-Tiles (Logo im Kreis, Name, Beschreibung).
const grid = $derived(block.presentation === "grid");
const orgs = $derived(block.organisations ?? []);
const ctaHtml = $derived(
block.addOrganisationMarkdown
@@ -166,7 +170,7 @@
{/snippet}
<div class={layoutClasses} data-block="Organisations" data-block-type="organisations" data-block-slug={block._slug}>
{#if block.headline}
{#if block.headline && !grid}
{#if compact}
<h3 class="mb-2 text-lg font-semibold text-stein-900">{block.headline}</h3>
{:else}
@@ -183,7 +187,29 @@
{/if}
{/if}
{#if carousel}
{#if grid}
<SectionGrid
number={block.number}
kicker={block.kicker}
title={block.headline}
subtitle={block.description}
actionLabel={actionRef?.linkName}
actionHref={actionRef?.url}
columns={3}
>
{#each orgs as org}
{#if typeof org === "object" && org !== null}
{@const o = org as OrganisationEntry}
{@const logoField = organisationLogoField(o)}
<InfoCard
icon={logoField?.url ?? "mdi:account-group"}
title={o.name ?? ""}
description={o.description}
/>
{/if}
{/each}
</SectionGrid>
{:else if carousel}
<div class="relative">
<div
bind:this={scroller}
@@ -43,6 +43,7 @@
linkLabel={card.linkLabel}
linkHref={card.linkHref}
external={card.external}
tone={card.tone}
/>
{/each}
</SectionGrid>
@@ -2,6 +2,8 @@
import { onMount, onDestroy } from "svelte";
import { env as publicEnv } from "$env/dynamic/public";
import Button from "$lib/ui/Button.svelte";
import Icon from "@iconify/svelte";
import "$lib/iconify-offline";
import TextInput from "$lib/ui/TextInput.svelte";
import Textarea from "$lib/ui/Textarea.svelte";
import Checkbox from "$lib/ui/Checkbox.svelte";
@@ -291,11 +293,15 @@
<Button
type="submit"
variant="primary"
size="sm"
size="md"
loading={formState === "pending"}
disabled={!isValid && submitAttempted}
label={formState === "pending" ? t(T.contact_submitting) : t(T.contact_submit)}
/>
>
{#if formState !== "pending"}
<Icon icon="lucide:send" class="size-4" aria-hidden="true" />
{/if}
{formState === "pending" ? t(T.contact_submitting) : t(T.contact_submit)}
</Button>
{#if !isValid && submitAttempted && !serverError}
<span class="text-xs text-error">{t(T.contact_fix_errors)}</span>
{/if}
+1 -1
View File
@@ -20,7 +20,7 @@
const sizeClasses = $derived(
size === 'sm'
? 'h-9 rounded-sm px-2.5 py-1.5 text-xs shadow-sm'
? 'h-9 rounded-md px-2.5 py-1.5 text-xs shadow-sm'
: 'h-12 rounded-lg px-4 py-3 text-base'
);
</script>
+1 -1
View File
@@ -20,7 +20,7 @@
const sizeClasses = $derived(
size === 'sm'
? 'min-h-[80px] rounded-sm px-2.5 py-1.5 text-xs shadow-sm'
? 'min-h-[80px] rounded-md px-2.5 py-1.5 text-xs shadow-sm'
: 'min-h-[120px] rounded-lg px-4 py-3 text-base'
);
</script>
+5 -1
View File
@@ -399,7 +399,11 @@
<Breadcrumbs items={breadcrumbItems} />
</div>
{/if}
{@render children()}
<!-- Konsolidierter Top-Abstand vorm ersten Content-Element (alle
Seitentypen), zusätzlich zum Breadcrumb-py. -->
<div class="pt-4 md:pt-6">
{@render children()}
</div>
</div>
</main>