feat: add class-variance-authority for card styling and enhance color palettes
Deploy / verify (push) Failing after 1m40s
Deploy / deploy (push) Has been skipped

- Introduced `class-variance-authority` to manage card component styles, allowing for flexible variant and layout options.
- Added a new color palette for "Fire" to the CSS for improved theming.
- Updated `Card` and `PostCard` components to utilize the new styling system, enhancing visual consistency and responsiveness.
- Refined layout and styling in various components for better user experience.
This commit is contained in:
Peter Meier
2026-04-20 22:09:43 +02:00
parent a18c0a8cf7
commit d327798ce6
9 changed files with 191 additions and 119 deletions
+13
View File
@@ -51,6 +51,19 @@
--color-himmel-800: #1a2c36;
--color-himmel-900: #0e181e;
/* Palette: Fire (Tertiary) */
--color-fire-50: #fdf2f1;
--color-fire-100: #fbe5e3;
--color-fire-200: #f7c9c5;
--color-fire-300: #f2a8a2;
--color-fire-400: #eb8078;
--color-fire-500: #e35651;
--color-fire-600: #d93d36;
--color-fire-700: #c82e27;
--color-fire-800: #b5231e;
--color-fire-900: #981b18;
--color-fire-950: #5d0f0b;
/* Palette: Stein (Neutrals) */
--color-stein-0: #ffffff;
--color-stein-50: #f7f8f7;
+63 -6
View File
@@ -1,11 +1,68 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { cva, type VariantProps } from "class-variance-authority";
/**
* tile + stack/inline: Org-Karten (Padding im Shell).
* 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", {
variants: {
variant: {
tile: "",
callout:
"flex flex-col gap-1.5 border border-dashed border-wald-300 bg-wald-50 p-3",
},
layout: {
stack: "",
inline: "",
post: "",
},
},
compoundVariants: [
{
variant: "tile",
layout: "stack",
class:
"flex flex-col gap-2 border border-gray-200 bg-white p-4 text-slate-950",
},
{
variant: "tile",
layout: "inline",
class:
"flex flex-row items-start gap-3 border border-gray-200 bg-white p-4 text-slate-950",
},
{
variant: "tile",
layout: "post",
class:
"flex flex-col gap-0 border border-gray-200 bg-white p-0 text-slate-950",
},
],
defaultVariants: { variant: "tile", layout: "stack" },
});
const cardAsLink = cva("", {
variants: {
variant: {
callout: "cursor-pointer transition-all no-underline text-inherit",
tile: "transition-all no-underline hover:border-gray-300",
},
},
defaultVariants: { variant: "tile" },
});
type SurfaceProps = VariantProps<typeof cardSurface>;
type CardLinkVariant = NonNullable<SurfaceProps["variant"]>;
let {
children,
href,
target,
rel,
variant = "tile",
layout = "stack",
class: className = "",
}: {
children?: Snippet;
@@ -13,20 +70,20 @@
target?: string;
rel?: string;
class?: string;
} = $props();
} & SurfaceProps = $props();
const base = "relative flex flex-col gap-2 rounded-lg border border-stein-200 bg-white p-4 shadow-sm min-w-0 overflow-hidden";
const interactive = $derived(
href ? "hover:border-stein-300 hover:shadow-md transition-all no-underline text-inherit" : "",
const surface = $derived(cardSurface({ variant, layout }));
const linkSurface = $derived(
href ? cardAsLink({ variant: (variant ?? "tile") as CardLinkVariant }) : "",
);
</script>
{#if href}
<a {href} {target} {rel} class="{base} {interactive} {className}">
<a {href} {target} {rel} class="{surface} {linkSurface} {className}">
{@render children?.()}
</a>
{:else}
<div class="{base} {className}">
<div class="{surface} {className}">
{@render children?.()}
</div>
{/if}
+1 -1
View File
@@ -121,7 +121,7 @@
{link.label}
</a>
{/each}
<div><span class="opacity-20">|</span></div>
<div class="w-1 h-4"></div>
{#each socialLinks as social}
<a
href={social.href}
+6 -4
View File
@@ -3,6 +3,7 @@
import PostMeta from "./PostMeta.svelte";
import EventBadges from "./EventBadges.svelte";
import RustyImage from "./RustyImage.svelte";
import Card from "./Card.svelte";
type PostWithImage = PostEntry & {
_resolvedImageUrl?: string;
@@ -45,9 +46,10 @@
);
</script>
<a
href={href}
class="transition-all flex flex-col text-slate-950 no-underline overflow-hidden border border-gray-200 bg-white relative {isPastEvent ? 'opacity-70 grayscale' : ''}"
<Card
{href}
layout="post"
class={isPastEvent ? "opacity-70 grayscale" : ""}
>
<div class="relative aspect-3/2 w-full overflow-hidden bg-gray-100">
{#if rawImg}
@@ -110,4 +112,4 @@
{/if}
</div>
</div>
</a>
</Card>
+1 -1
View File
@@ -99,7 +99,7 @@
<div class="w-full">
{#if hasImage}
<div
class="w-full overflow-hidden relative flex justify-center items-center shadow-lg border-b border-white bg-zinc-200"
class="w-full overflow-hidden relative flex justify-center items-center shadow-lg bg-zinc-200"
style={wrapperStyle}
>
{#if hasResponsive && responsive}
@@ -84,7 +84,7 @@
? "bg-amber-100 text-amber-900"
: variant === "info"
? "bg-sky-100 text-sky-900"
: "bg-wald-100 text-wald-900",
: "bg-wald-500 text-wald-50",
);
const iconName = $derived(
variant === "urgent" ? "mdi:alert-circle" : "mdi:calendar-clock",
@@ -99,30 +99,35 @@
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-1.5 text-xs sm:text-sm">
<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 gap-1.5 rounded-md bg-white/70 px-2 py-0.5 text-sm sm:text-base font-bold whitespace-nowrap shrink-0">
<Icon icon={iconName} class="size-4 shrink-0" aria-hidden="true" />
{dateStr}
<span class="inline-flex items-center rounded-md 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-1 rounded-full bg-black/80 text-white px-1.5 py-0 text-[0.6rem] font-bold uppercase tracking-wide">
<span
class="ml-0 inline-flex min-w-0 max-w-0 scale-[0.96] items-center overflow-hidden whitespace-nowrap rounded-md 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}
<Icon icon={iconName} class="size-4 shrink-0" aria-hidden="true" />
{#if countdownStr}
<span class="rounded-full bg-white/60 px-2 py-0.5 text-xs font-semibold uppercase tracking-wide whitespace-nowrap shrink-0">
{countdownStr}
</span>
{/if}
<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">{text}</span>
{/if}
{#if href}
<Icon icon="mdi:arrow-right" class="size-4 shrink-0 ml-auto" aria-hidden="true" />
<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}
@@ -131,12 +136,12 @@
href={href}
target={newTab ? "_blank" : undefined}
rel={newTab ? "noopener noreferrer" : undefined}
class="block no-underline text-inherit hover:no-underline transition-all hover:brightness-95 {variantClasses}"
class="group block no-underline text-inherit hover:no-underline transition-all hover:brightness-95 {variantClasses}"
>
{@render inner()}
</a>
{:else}
<div class="{variantClasses}">
<div class="group {variantClasses}">
{@render inner()}
</div>
{/if}
@@ -56,10 +56,6 @@
)
);
const addOrganisationCardClass = $derived(
`border-dashed border-wald-300 bg-wald-50! p-3! gap-1.5!${addOrganisationCtaLink ? " cursor-pointer" : ""}`,
);
let scroller: HTMLDivElement | undefined = $state();
function scrollByPage(dir: 1 | -1) {
if (!scroller) return;
@@ -68,8 +64,8 @@
</script>
{#snippet defaultCard(o: OrganisationEntry, linkUrl: string | undefined, logoField: ReturnType<typeof organisationLogoField>, orgIndex: number)}
<Card href={linkUrl} class="p-3! gap-1.5! h-full">
<div class="h-11 w-11 rounded-md border border-stein-100 bg-stein-50 flex items-center justify-center overflow-hidden shrink-0">
<Card href={linkUrl} class="h-full">
<div class="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden bg-gray-100">
{#if logoField}
<RustyImage
src={logoField.url}
@@ -84,7 +80,7 @@
/>
{:else}
{@const fishMaskId = `org-logo-fallback-mask-${orgIndex}`}
<svg class="h-8 w-8 text-stein-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
<svg class="h-8 w-8 text-gray-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
<defs>
<mask id={fishMaskId}>
<g fill="none">
@@ -103,19 +99,20 @@
<Badge color={o.badge.color}>{o.badge.label}</Badge>
</div>
{/if}
<h5 class="min-w-0 text-sm font-semibold leading-snug text-stein-900 truncate">{o.name ?? ""}</h5>
<h5 class="mb-1 min-w-0 truncate text-sm font-medium leading-tight text-slate-950">{o.name ?? ""}</h5>
{#if o.description}
<p class="text-xs text-stein-600 line-clamp-2">{o.description}</p>
<p class="line-clamp-2 text-xs font-light text-slate-600">{o.description}</p>
{/if}
</Card>
{/snippet}
{#snippet ctaCard()}
<Card
variant="callout"
href={addOrganisationCtaLink?.url}
target={addOrganisationCtaLink?.newTab ? "_blank" : undefined}
rel={addOrganisationCtaLink?.newTab ? "noopener noreferrer" : undefined}
class="{addOrganisationCardClass} h-full"
class="h-full"
>
{#if block.addOrganisationTitle}
<h5 class="text-sm font-semibold text-wald-800 leading-snug">{block.addOrganisationTitle}</h5>
@@ -138,7 +135,7 @@
<div class="relative">
<div
bind:this={scroller}
class="flex gap-3 overflow-x-auto scroll-smooth snap-x snap-mandatory pb-2 -mx-2 px-2 [scrollbar-width:thin]"
class="flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory pb-2 [scrollbar-width:thin]"
>
{#each orgs as org, orgIndex}
{#if typeof org === "object" && org !== null}
@@ -151,7 +148,9 @@
? o.link.url.trim()
: undefined}
{@const logoField = organisationLogoField(o)}
<div class="shrink-0 snap-start basis-1/2 lg:basis-1/3">
<div
class="shrink-0 snap-start w-[calc((100%-1rem)/2)] lg:w-[calc((100%-2rem)/3)]"
>
{@render defaultCard(o, linkUrl, logoField, orgIndex)}
</div>
{/if}
@@ -177,27 +176,16 @@
{/if}
</div>
{#if showAddOrganisationCard}
<div class="mt-4 rounded-md border border-dashed border-wald-300 bg-wald-50 p-3 text-sm text-wald-800">
{#if block.addOrganisationTitle}
<div class="font-semibold">{block.addOrganisationTitle}</div>
{/if}
{#if ctaHtml}
<div class="markdown text-xs text-wald-700 [&>p]:mb-0">{@html ctaHtml}</div>
{/if}
{#if addOrganisationCtaLink}
<a
href={addOrganisationCtaLink.url}
target={addOrganisationCtaLink.newTab ? "_blank" : undefined}
rel={addOrganisationCtaLink.newTab ? "noopener noreferrer" : undefined}
class="mt-1 inline-block font-medium underline-offset-2 hover:underline"
>
{addOrganisationCtaLink.linkName}
</a>
{/if}
<div class="mt-2 min-w-0">
{@render ctaCard()}
</div>
{/if}
{:else}
<div class={compact ? "grid gap-2 sm:grid-cols-2 lg:grid-cols-3" : "grid gap-4 sm:grid-cols-2 lg:grid-cols-3"}>
<div
class={compact
? "grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3"
: "grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"}
>
{#each orgs as org, orgIndex}
{#if typeof org === "object" && org !== null}
{@const o = org as OrganisationEntry}
@@ -210,8 +198,8 @@
: undefined}
{@const logoField = organisationLogoField(o)}
{#if compact}
<Card href={linkUrl} class="flex! flex-row! items-start! gap-3! p-3! gap-y-0!">
<div class="h-10 w-10 rounded-md border border-stein-100 bg-stein-50 flex items-center justify-center overflow-hidden shrink-0">
<Card href={linkUrl} layout="inline" class="h-full">
<div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden bg-gray-100">
{#if logoField}
<RustyImage
src={logoField.url}
@@ -226,7 +214,7 @@
/>
{:else}
{@const fishMaskId = `org-logo-fallback-mask-c-${orgIndex}`}
<svg class="h-7 w-7 text-stein-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
<svg class="h-7 w-7 text-gray-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
<defs>
<mask id={fishMaskId}>
<g fill="none">
@@ -240,10 +228,10 @@
</svg>
{/if}
</div>
<div class="min-w-0 flex-1 flex flex-col gap-0.5">
<h5 class="text-sm font-semibold text-stein-900 leading-snug">{o.name ?? ""}</h5>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<h5 class="text-sm font-medium leading-tight text-slate-950">{o.name ?? ""}</h5>
{#if o.description}
<p class="text-xs text-stein-600 line-clamp-2">{o.description}</p>
<p class="line-clamp-2 text-xs font-light text-slate-600">{o.description}</p>
{/if}
</div>
</Card>