feat(design-system): Storybook + Button-Atom-Vereinheitlichung + SectionHeader
Deploy / verify (push) Successful in 1m39s
Deploy / deploy (push) Successful in 2m9s

Storybook (SvelteKit-Framework, Tailwind v4, MSW, Translation-Decorator):
Stories für Atoms/Molecules/Organisms + alle CMS-Blocks.

Button-Atom: 5 .btn-*-Varianten (primary/secondary/tertiary/outline/warning),
class-Merge-Prop, href→<a>, Loading-Spinner neben Label, rounded-md/font-medium.
Alle rohen .btn-*- und Hand-Style-Buttons app-weit aufs Atom migriert.

SectionHeader-Molecule (kicker/number/title/subtitle/action) + Integration in
7 CMS-Blocks (post_overview, organisations, organisations_map, searchable_text,
image_gallery, youtube_video_gallery, files) via neuem section_header-Schema.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-07-11 23:35:57 +02:00
parent 289cdb3951
commit 03e84c1f28
93 changed files with 5473 additions and 173 deletions
+16
View File
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Badge from './Badge.svelte';
const meta = {
title: 'Atoms/Badge',
component: Badge,
tags: ['autodocs'],
args: { label: 'Neu' },
} satisfies Meta<typeof Badge>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const AlsLink: Story = { args: { label: 'Mehr', href: '#' } };
export const Aktiv: Story = { args: { label: 'Aktiv', active: true } };
+21
View File
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Breadcrumbs from './Breadcrumbs.svelte';
const meta = {
title: 'Atoms/Breadcrumbs',
component: Breadcrumbs,
tags: ['autodocs'],
} satisfies Meta<typeof Breadcrumbs>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
items: [
{ label: 'Start', href: '/' },
{ label: 'Themen', href: '/themen' },
{ label: 'Windkraft im Wald' },
],
},
};
+40
View File
@@ -0,0 +1,40 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Button from './Button.svelte';
const { Story } = defineMeta({
title: 'Atoms/Button',
component: Button,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'tertiary', 'outline', 'warning'],
},
size: { control: 'select', options: ['sm', 'md', 'lg'] },
disabled: { control: 'boolean' },
loading: { control: 'boolean' },
href: { control: 'text' },
},
});
</script>
<!-- Fünf Varianten (Quelle: .btn-* aus app.css) -->
<Story name="Primary" args={{ variant: 'primary' }}>Speichern</Story>
<Story name="Secondary" args={{ variant: 'secondary' }}>Abbrechen</Story>
<Story name="Tertiary" args={{ variant: 'tertiary' }}>Mehr Infos</Story>
<Story name="Outline" args={{ variant: 'outline' }}>Filter</Story>
<Story name="Warning" args={{ variant: 'warning' }}>Termin melden</Story>
<!-- Drei Größen -->
<Story name="Klein (sm)" args={{ variant: 'primary', size: 'sm' }}>Klein</Story>
<Story name="Mittel (md)" args={{ variant: 'primary', size: 'md' }}>Mittel</Story>
<Story name="Groß (lg)" args={{ variant: 'primary', size: 'lg' }}>Jetzt einwenden</Story>
<!-- Zustände -->
<Story name="Loading" args={{ variant: 'primary', loading: true }}>Senden</Story>
<Story name="Disabled" args={{ variant: 'primary', disabled: true }}>Gesperrt</Story>
<Story name="Disabled Outline" args={{ variant: 'outline', disabled: true }}>Gesperrt</Story>
<!-- href → rendert <a> statt <button> -->
<Story name="Als Link" args={{ variant: 'primary', href: '/mitmachen' }}>Zur Aktion</Story>
+68 -31
View File
@@ -1,59 +1,96 @@
<script lang="ts">
type Variant = 'primary' | 'secondary' | 'ghost';
type Size = 'sm' | 'md' | 'large';
/**
* Button — einzige Quelle: die .btn-*-Varianten aus app.css.
* Rendert <a> wenn href gesetzt ist, sonst <button>.
* Loading: Spinner NEBEN dem Label (kein Breitensprung), aria-busy.
*/
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes, HTMLAnchorAttributes } from 'svelte/elements';
type Variant = 'primary' | 'secondary' | 'tertiary' | 'outline' | 'warning';
type Size = 'sm' | 'md' | 'lg';
let {
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
href = undefined,
type = 'button' as 'button' | 'submit' | 'reset',
label = '',
children = undefined,
class: className = '',
...rest
}: {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'large';
variant?: Variant;
size?: Size;
disabled?: boolean;
loading?: boolean;
href?: string;
type?: 'button' | 'submit' | 'reset';
label?: string;
children?: Snippet;
} = $props();
/** Zusätzliche Layout-Klassen (w-full, justify-*, min-h-*, …). Werden an
* base/size/variant ANGEHÄNGT — überschreiben die Varianten-Styles nicht.
* Aus rest herausgezogen, sonst würde {...rest} das class-Attribut kapern. */
class?: string;
} & HTMLButtonAttributes &
HTMLAnchorAttributes = $props();
/* Basis: identisch zur .btn-*-Basis in app.css (font-medium = 500,
rounded-md, 200ms). Kein eigener Focus-Ring — der globale
*:focus-visible (wald-500) übernimmt. */
const base =
'inline-flex items-center justify-center gap-2 rounded-xs font-semibold transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-wald-300 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-40';
'inline-flex items-center justify-center gap-1.5 rounded-md font-medium no-underline whitespace-nowrap transition-colors duration-200 disabled:cursor-not-allowed';
const sizeClasses = $derived(
{ sm: 'h-9 px-4 py-2 text-sm', md: 'h-12 min-w-[120px] px-6 py-3 text-base', large: 'h-14 px-8 py-4 text-lg' }[
size
]
/* Größen setzen NUR Maße — Varianten setzen NUR Farben. Kein Konflikt. */
const sizeClasses: Record<Size, string> = {
sm: 'px-2 py-1 text-xs gap-1',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
/* 1:1 aus app.css. Disabled: explizite Stein-Farben statt opacity —
auf Wald-Grün ist opacity-40 kaum als deaktiviert erkennbar. */
const variantClasses: Record<Variant, string> = {
primary:
'shadow-sm bg-btn-bg text-btn-txt hover:bg-btn-hover-bg active:bg-wald-700 disabled:bg-stein-200 disabled:text-stein-400 disabled:shadow-none',
secondary:
'shadow-sm border border-stein-300 bg-white text-stein-700 hover:bg-stein-100 hover:border-stein-400 active:bg-stein-200 disabled:bg-stein-50 disabled:text-stein-300 disabled:border-stein-200 disabled:shadow-none',
tertiary:
'shadow-sm bg-himmel-700 text-white hover:bg-himmel-800 active:bg-himmel-900 disabled:bg-stein-200 disabled:text-stein-400 disabled:shadow-none',
outline:
'border border-stein-300 text-stein-700 hover:bg-stein-100 active:bg-stein-200 disabled:text-stein-300 disabled:border-stein-200 disabled:hover:bg-transparent',
warning:
'shadow-sm border border-gelb-500 bg-gelb-300 text-gelb-900 hover:bg-gelb-400 active:bg-gelb-500 disabled:bg-stein-100 disabled:text-stein-400 disabled:border-stein-200 disabled:shadow-none',
};
const allClasses = $derived(
[base, sizeClasses[size], variantClasses[variant], className].filter(Boolean).join(' '),
);
const variantClasses = $derived(
{
primary: 'bg-wald-500 text-white hover:bg-wald-600 active:bg-wald-700',
secondary:
'border-[1.5px] border-wald-500 bg-transparent text-wald-500 hover:bg-wald-50 active:bg-wald-100',
ghost: 'bg-transparent px-4 py-3 text-stein-700 hover:bg-stein-100 active:bg-stein-200',
}[variant]
);
const allClasses = $derived([base, sizeClasses, variantClasses].filter(Boolean).join(' '));
const isDisabled = $derived(disabled || loading);
</script>
<button
{type}
{disabled}
class={allClasses}
>
{#snippet inner()}
{#if loading}
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
<span>Laden...</span>
{:else if children}
<span
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
aria-hidden="true"
></span>
{/if}
{#if children}
{@render children()}
{:else}
{label}
{/if}
</button>
{/snippet}
{#if href && !isDisabled}
<a {href} class={allClasses} {...rest}>
{@render inner()}
</a>
{:else}
<button {type} disabled={isDisabled} aria-busy={loading || undefined} class={allClasses} {...rest}>
{@render inner()}
</button>
{/if}
+17
View File
@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Checkbox from './Checkbox.svelte';
const meta = {
title: 'Atoms/Checkbox',
component: Checkbox,
tags: ['autodocs'],
args: { label: 'Ich stimme zu' },
} satisfies Meta<typeof Checkbox>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Unchecked: Story = {};
export const Checked: Story = { args: { checked: true } };
export const Disabled: Story = { args: { disabled: true } };
export const Klein: Story = { args: { size: 'sm', label: 'Kompakt' } };
+16
View File
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Radio from './Radio.svelte';
const meta = {
title: 'Atoms/Radio',
component: Radio,
tags: ['autodocs'],
args: { name: 'auswahl', value: 'a', label: 'Option A', group: 'a' },
} satisfies Meta<typeof Radio>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Ausgewaehlt: Story = {};
export const NichtAusgewaehlt: Story = { args: { group: 'b' } };
export const Disabled: Story = { args: { disabled: true } };
+21
View File
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import RustyImage from '$lib/components/RustyImage.svelte';
// Bild-Primitive: baut aus roher CMS-URL eine transformierte /cms-images-URL
// (AVIF/WebP-`<picture>`, Retina-srcset, CLS=0). Läuft über den Storybook-Proxy.
const CMS_ASSET =
'https://cms.pm86.de/api/assets/pages/img_2814-ar4x2.webp?_environment=windwiderstand';
const meta = {
title: 'Atoms/RustyImage',
component: RustyImage,
tags: ['autodocs'],
args: { src: CMS_ASSET, width: 640, aspect: '16/9', alt: 'Steigerwald' },
} satisfies Meta<typeof RustyImage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Breitbild: Story = {};
export const Quadratisch: Story = { args: { aspect: '1/1', width: 400 } };
export const Portrait: Story = { args: { aspect: '3/4', width: 360 } };
+19
View File
@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import SearchField from './SearchField.svelte';
const meta = {
title: 'Atoms/SearchField',
component: SearchField,
tags: ['autodocs'],
args: { placeholder: 'Beiträge durchsuchen …' },
} satisfies Meta<typeof SearchField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Hell: Story = { args: { tone: 'light' } };
export const Dunkel: Story = {
args: { tone: 'dark' },
parameters: { backgrounds: { default: 'stein' } },
};
export const MitWert: Story = { args: { value: 'Windkraft' } };
+23
View File
@@ -0,0 +1,23 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Select from './Select.svelte';
const meta = {
title: 'Atoms/Select',
component: Select,
tags: ['autodocs'],
args: {
label: 'Sortierung',
options: [
{ value: 'newest', label: 'Neueste zuerst' },
{ value: 'oldest', label: 'Älteste zuerst' },
{ value: 'az', label: 'AZ' },
],
},
} satisfies Meta<typeof Select>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {};
export const MitAuswahl: Story = { args: { value: 'oldest' } };
export const Disabled: Story = { args: { disabled: true } };
+32
View File
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import SidebarNav from './SidebarNav.svelte';
const meta = {
title: 'Atoms/SidebarNav',
component: SidebarNav,
tags: ['autodocs'],
} satisfies Meta<typeof SidebarNav>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {
args: {
groups: [
{
label: 'Verfahren',
links: [
{ href: '/verfahren/ablauf', label: 'Ablauf', active: true },
{ href: '/verfahren/fristen', label: 'Fristen' },
],
},
{
label: 'Mitmachen',
links: [
{ href: '/mitmachen/einwenden', label: 'Einwenden' },
{ href: '/mitmachen/spenden', label: 'Spenden' },
],
},
],
},
};
+15
View File
@@ -0,0 +1,15 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Slider from './Slider.svelte';
const meta = {
title: 'Atoms/Slider',
component: Slider,
tags: ['autodocs'],
args: { min: 0, max: 100, value: 50, label: 'Umkreis (km)' },
} satisfies Meta<typeof Slider>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {};
export const Disabled: Story = { args: { disabled: true } };
+21
View File
@@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import TabBar from './TabBar.svelte';
const meta = {
title: 'Atoms/TabBar',
component: TabBar,
tags: ['autodocs'],
args: {
tabs: [
{ id: 'karte', label: 'Karte' },
{ id: 'liste', label: 'Liste' },
{ id: 'info', label: 'Info' },
],
selectedId: 'karte',
},
} satisfies Meta<typeof TabBar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {};
+22
View File
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import TextInput from './TextInput.svelte';
const meta = {
title: 'Atoms/TextInput',
component: TextInput,
tags: ['autodocs'],
args: { label: 'Name', placeholder: 'Vor- und Nachname' },
} satisfies Meta<typeof TextInput>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {};
export const MitHilfetext: Story = {
args: { label: 'E-Mail', placeholder: 'name@example.org', helpText: 'Wird nicht veröffentlicht.' },
};
export const Fehler: Story = {
args: { label: 'E-Mail', value: 'ungültig', invalid: true, error: 'Bitte gültige E-Mail eingeben.' },
};
export const Disabled: Story = { args: { disabled: true, value: 'gesperrt' } };
export const Klein: Story = { args: { size: 'sm', label: 'PLZ', placeholder: '00000' } };
+17
View File
@@ -0,0 +1,17 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Textarea from './Textarea.svelte';
const meta = {
title: 'Atoms/Textarea',
component: Textarea,
tags: ['autodocs'],
args: { label: 'Nachricht', placeholder: 'Ihre Nachricht …', rows: 4 },
} satisfies Meta<typeof Textarea>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Standard: Story = {};
export const MitHilfetext: Story = { args: { helpText: 'Max. 500 Zeichen.' } };
export const Fehler: Story = { args: { invalid: true, error: 'Pflichtfeld.' } };
export const Disabled: Story = { args: { disabled: true, value: 'gesperrt' } };
+16
View File
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from '@storybook/sveltekit';
import Toggle from './Toggle.svelte';
const meta = {
title: 'Atoms/Toggle',
component: Toggle,
tags: ['autodocs'],
args: { label: 'Vergangene ausblenden' },
} satisfies Meta<typeof Toggle>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Aus: Story = {};
export const An: Story = { args: { checked: true } };
export const Disabled: Story = { args: { disabled: true } };
+33
View File
@@ -0,0 +1,33 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Tooltip from './Tooltip.svelte';
const { Story } = defineMeta({
title: 'Atoms/Tooltip',
component: Tooltip,
tags: ['autodocs'],
argTypes: {
placement: { control: 'select', options: ['top', 'bottom'] },
},
});
</script>
<!-- Tooltip umschließt einen Trigger (children). Zum Sehen: über den Button
hovern/fokussieren. -->
<Story name="Oben" args={{ content: 'Erklärung erscheint hier', placement: 'top' }}>
<button
type="button"
class="rounded-sm border border-stein-300 bg-white px-3 py-1.5 text-sm"
>
Hover mich
</button>
</Story>
<Story name="Unten" args={{ content: 'Tooltip unterhalb', placement: 'bottom' }}>
<button
type="button"
class="rounded-sm border border-stein-300 bg-white px-3 py-1.5 text-sm"
>
Hover mich
</button>
</Story>