feat: add Hero component to design system

- Responsive hero for home + category tops (390→1280px), composed from
  Button, Badge, and the Icon/Logo primitives already in the DS.
- Two layouts — split (copy left / product right on lg, stacked below)
  and centered — and three surface tones — cream, paper, brand. On the
  brand-green surface the secondary CTA is rendered with a cream outline
  pill since Button's ghost/secondary variants read dark-on-dark there.
- Decorative disc + soft glow behind the product cutout give the image
  a focal point without needing a photographed backdrop.
- Headline can be passed as a string prop or as a slot (so consumers
  can mix in the italic `text-brand-soft` emphasis used on the home page).
- Ships with a /design/hero showcase page that renders the component in
  the DevicePreview iframe across mobile / tablet / desktop, plus
  layout and tone tabs and a usage snippet. Preview route is
  /design/preview/hero so the iframe can include the live Navbar.
- i18n keys added to DE + EN; AT inherits from DE.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-21 11:44:15 +01:00
parent 469ef529b6
commit a07058d656
6 changed files with 378 additions and 0 deletions

View File

@@ -0,0 +1,198 @@
<script setup>
import { computed } from 'vue'
import Button from './Button.vue'
import Badge from './Badge.vue'
const props = defineProps({
eyebrow: { type: String, default: '' },
headline: { type: String, default: '' },
subheadline: { type: String, default: '' },
image: { type: String, default: '' },
imageAlt: { type: String, default: '' },
badge: { type: String, default: '' },
badgeVariant: {
type: String,
default: 'accent',
validator: (v) =>
['neutral', 'brand', 'accent', 'subtle', 'success', 'warning', 'danger'].includes(v),
},
ctaLabel: { type: String, default: '' },
ctaHref: { type: String, default: '' },
secondaryLabel: { type: String, default: '' },
secondaryHref: { type: String, default: '' },
variant: {
type: String,
default: 'split',
validator: (v) => ['split', 'centered'].includes(v),
},
tone: {
type: String,
default: 'cream',
validator: (t) => ['cream', 'paper', 'brand'].includes(t),
},
})
defineEmits(['cta', 'secondary'])
const tones = {
cream: {
surface: 'bg-cream',
text: 'text-ink',
sub: 'text-muted',
disc: 'bg-accent-soft/60',
glow: 'bg-accent/15',
},
paper: {
surface: 'bg-paper',
text: 'text-ink',
sub: 'text-muted',
disc: 'bg-cream',
glow: 'bg-brand-soft-wash',
},
brand: {
surface: 'bg-brand',
text: 'text-cream',
sub: 'text-cream/80',
disc: 'bg-brand-soft/30',
glow: 'bg-accent/10',
},
}
const tone = computed(() => tones[props.tone])
const isBrandTone = computed(() => props.tone === 'brand')
const primaryVariant = computed(() => (isBrandTone.value ? 'accent' : 'primary'))
const layout = computed(() => {
if (props.variant === 'centered') {
return {
root: 'flex flex-col items-center text-center gap-10 md:gap-12',
copy: 'max-w-2xl mx-auto items-center text-center',
actions: 'justify-center',
media: 'mt-4 md:mt-6',
mediaSize: 'w-[260px] sm:w-[320px] md:w-[400px] lg:w-[480px]',
}
}
return {
root: 'grid gap-10 md:gap-14 lg:grid-cols-[1.05fr_1fr] lg:items-center',
copy: 'max-w-xl mx-auto lg:mx-0 items-center text-center lg:items-start lg:text-left',
actions: 'justify-center lg:justify-start',
media: '',
mediaSize: 'w-[260px] sm:w-[340px] md:w-[420px] lg:w-full lg:max-w-[520px]',
}
})
</script>
<template>
<section
:class="[
'relative overflow-hidden',
'px-6 py-16 sm:px-8 sm:py-20 md:px-12 md:py-24 lg:px-16 lg:py-28',
tone.surface,
tone.text,
]"
>
<div :class="['relative mx-auto w-full max-w-6xl', layout.root]">
<!-- Copy -->
<div :class="['relative z-[1] flex flex-col gap-6', layout.copy]">
<p v-if="eyebrow" class="eyebrow">{{ eyebrow }}</p>
<h1
class="font-display font-normal leading-[1.04] tracking-tight"
style="font-size: clamp(2.5rem, 6vw, 4.5rem);"
>
<slot name="headline">{{ headline }}</slot>
</h1>
<p
v-if="subheadline || $slots.subheadline"
:class="['text-lg leading-relaxed max-w-xl', tone.sub]"
>
<slot name="subheadline">{{ subheadline }}</slot>
</p>
<div
v-if="ctaLabel || secondaryLabel || $slots.actions"
:class="['mt-2 flex flex-wrap items-center gap-3', layout.actions]"
>
<slot name="actions">
<a v-if="ctaLabel && ctaHref" :href="ctaHref" class="inline-flex">
<Button :variant="primaryVariant" size="lg">
<slot name="cta">{{ ctaLabel }}</slot>
</Button>
</a>
<Button
v-else-if="ctaLabel"
:variant="primaryVariant"
size="lg"
@click="$emit('cta')"
>
<slot name="cta">{{ ctaLabel }}</slot>
</Button>
<template v-if="secondaryLabel">
<!-- Brand tone needs a cream-outlined pill; Button's ghost/secondary
render dark-on-dark on the brand green. -->
<component
:is="secondaryHref ? 'a' : 'button'"
v-if="isBrandTone"
:type="secondaryHref ? undefined : 'button'"
:href="secondaryHref || undefined"
class="inline-flex items-center justify-center rounded-pill border border-cream/50 px-[34px] py-[17px] text-[16px] font-semibold tracking-label text-cream transition-colors duration-base hover:border-cream hover:bg-cream-wash-strong"
@click="secondaryHref ? null : $emit('secondary')"
>{{ secondaryLabel }}</component>
<a v-else-if="secondaryHref" :href="secondaryHref" class="inline-flex">
<Button variant="secondary" size="lg">{{ secondaryLabel }}</Button>
</a>
<Button
v-else
variant="secondary"
size="lg"
@click="$emit('secondary')"
>{{ secondaryLabel }}</Button>
</template>
</slot>
</div>
</div>
<!-- Media -->
<div
v-if="image || $slots.media"
:class="['relative flex items-center justify-center', layout.media]"
>
<!-- Decorative disc behind product -->
<div
aria-hidden="true"
:class="[
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
'w-[85%] aspect-square rounded-full blur-2xl',
tone.glow,
]"
/>
<div
aria-hidden="true"
:class="[
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
'w-[70%] aspect-square rounded-full',
tone.disc,
]"
/>
<div :class="['relative', layout.mediaSize]">
<Badge
v-if="badge"
:variant="badgeVariant"
class="absolute -top-3 -left-3 z-[1] shadow-sm"
>{{ badge }}</Badge>
<slot name="media">
<img
:src="image"
:alt="imageAlt || headline"
loading="eager"
decoding="async"
class="relative w-full h-auto object-contain drop-shadow-[0_20px_40px_rgba(28,58,40,0.18)]"
/>
</slot>
</div>
</div>
</div>
</section>
</template>

View File

@@ -30,6 +30,7 @@ const de = {
'ds.nav.inputs': 'Eingabefelder',
'ds.nav.cards': 'Karten',
'ds.nav.products': 'Produktkarten',
'ds.nav.hero': 'Hero',
'ds.nav.navbar': 'Navigation',
'ds.nav.language': 'Sprachwahl',
@@ -176,6 +177,18 @@ const de = {
'ds.product.outOfStock': 'Nicht verfügbar',
'ds.product.added': 'Hinzugefügt',
// Hero
'ds.hero.title': 'Hero',
'ds.hero.description': 'Ganzflächiger Bühnenbereich für die Startseite und Kategorie-Auftakte. Eyebrow, Display-Headline mit optionaler Kursiv-Hervorhebung, Unterzeile, Produkt-Cutout auf dekorativer Scheibe und ein Primary/Secondary-CTA-Paar. Responsiv von 390 bis 1280 px, drei Flächen­töne, zwei Layouts.',
'ds.hero.variant.label': 'Layout',
'ds.hero.variant.split': 'Split',
'ds.hero.variant.centered': 'Zentriert',
'ds.hero.eyebrow': 'Neu im Shop',
'ds.hero.headline.a': 'Kaiser-Natron',
'ds.hero.headline.em': 'für alles',
'ds.hero.headline.b': 'was glänzen soll.',
'ds.hero.sub': 'Reinigt, backt und neutralisiert Gerüche. Die 250-g-Großpackung für den Haushalt, der sich auf das Original verlässt.',
// Navbar section
'ds.navbar.title': 'Navigation',
'ds.navbar.description': 'Logo + Nav + Warenkorb, in drei Flächentönen. Auf Mobilgeräten behält die obere Leiste das Logo, Menü + Warenkorb wechseln in ein ergonomisches bodennahes rechtes Floating-Cluster. Das Menü öffnet ein vollflächiges markengrünes Overlay.',
@@ -244,6 +257,7 @@ const en = {
'ds.nav.inputs': 'Inputs',
'ds.nav.cards': 'Cards',
'ds.nav.products': 'Product cards',
'ds.nav.hero': 'Hero',
'ds.nav.navbar': 'Navbar',
'ds.nav.language': 'Language',
@@ -377,6 +391,17 @@ const en = {
'ds.product.outOfStock': 'Out of stock',
'ds.product.added': 'Added',
'ds.hero.title': 'Hero',
'ds.hero.description': 'Full-bleed stage for the home page and category tops. Eyebrow, display headline with optional italic emphasis, subheadline, product cutout on a decorative disc, and a primary/secondary CTA pair. Responsive from 390 to 1280px, three surface tones, two layouts.',
'ds.hero.variant.label': 'Layout',
'ds.hero.variant.split': 'Split',
'ds.hero.variant.centered': 'Centered',
'ds.hero.eyebrow': 'New in shop',
'ds.hero.headline.a': 'Kaiser Natron',
'ds.hero.headline.em': 'for everything',
'ds.hero.headline.b': 'that should shine.',
'ds.hero.sub': 'Cleans, bakes, and neutralises odours. The 250 g large pack for the household that trusts the original.',
'ds.navbar.title': 'Navbar',
'ds.navbar.description': 'Logo + nav + cart, in three surface tones. On mobile, the top bar keeps the logo and the menu + cart move to an ergonomic bottom-right floating cluster. The menu opens a full-screen brand-green overlay.',
'ds.navbar.tone': 'Navbar tone',

View File

@@ -32,6 +32,7 @@ const groups = computed(() => [
{ name: 'ds-inputs', label: t('ds.nav.inputs') },
{ name: 'ds-cards', label: t('ds.nav.cards') },
{ name: 'ds-products', label: t('ds.nav.products') },
{ name: 'ds-hero', label: t('ds.nav.hero') },
{ name: 'ds-navbar', label: t('ds.nav.navbar') },
{ name: 'ds-language', label: t('ds.nav.language') },
],

View File

@@ -0,0 +1,103 @@
<script setup>
import { ref, computed } from 'vue'
import SectionShell from './SectionShell.vue'
import DevicePreview from '@/design-system/components/DevicePreview.vue'
import { useI18n } from '@/i18n/index.js'
const { t } = useI18n()
const variants = computed(() => [
{ id: 'split', label: t('ds.hero.variant.split') },
{ id: 'centered', label: t('ds.hero.variant.centered') },
])
const tones = computed(() => [
{ id: 'cream', label: t('ds.navbar.tone.cream'), swatch: 'var(--color-cream)' },
{ id: 'paper', label: t('ds.navbar.tone.paper'), swatch: '#ffffff' },
{ id: 'brand', label: t('ds.navbar.tone.brand'), swatch: 'var(--color-brand)' },
])
const variant = ref('split')
const tone = ref('cream')
const src = computed(
() => `/design/preview/hero?variant=${variant.value}&tone=${tone.value}`,
)
</script>
<template>
<SectionShell
:eyebrow="t('ds.eyebrow.components')"
:title="t('ds.hero.title')"
:description="t('ds.hero.description')"
wide
>
<section>
<DevicePreview :src="src" initial="desktop" :height="760">
<template #controls>
<div
role="tablist"
:aria-label="t('ds.hero.variant.label')"
class="inline-flex items-center p-1 gap-0.5 rounded-pill border border-line bg-paper"
>
<button
v-for="v in variants"
:key="v.id"
type="button"
role="tab"
:aria-selected="variant === v.id"
:class="[
'px-3 py-1.5 text-[12px] font-semibold tracking-label rounded-pill transition-colors duration-base',
variant === v.id ? 'bg-brand text-accent' : 'text-muted hover:text-brand',
]"
@click="variant = v.id"
>{{ v.label }}</button>
</div>
<div
role="tablist"
:aria-label="t('ds.navbar.tone')"
class="inline-flex items-center p-1 gap-0.5 rounded-pill border border-line bg-paper"
>
<button
v-for="v in tones"
:key="v.id"
type="button"
role="tab"
:aria-selected="tone === v.id"
:class="[
'inline-flex items-center gap-2 px-3 py-1.5 text-[12px] font-semibold tracking-label rounded-pill transition-colors duration-base',
tone === v.id ? 'bg-brand text-accent' : 'text-muted hover:text-brand',
]"
@click="tone = v.id"
>
<span
class="w-2.5 h-2.5 rounded-full border border-line-strong"
:style="{ backgroundColor: v.swatch }"
/>
{{ v.label }}
</button>
</div>
</template>
</DevicePreview>
</section>
<section>
<h2 class="eyebrow mb-5">{{ t('ds.heading.usage') }}</h2>
<div class="rounded-md border border-line bg-paper p-6 font-mono text-[12px] text-ink">
<pre class="whitespace-pre-wrap">&lt;Hero
variant="split"
tone="cream"
eyebrow="Neu"
headline="Kaiser-Natron Pulver"
subheadline="Reinigt. Backt. Neutralisiert."
image="/products/cutouts/…-removebg-preview.png"
image-alt="Kaiser-Natron Pulver 250 g"
badge="Bestseller"
cta-label="In den Warenkorb"
secondary-label="Mehr erfahren"
@cta="addToCart(sku)"
@secondary="router.push('/anwendungen')"
/&gt;</pre>
</div>
</section>
</SectionShell>
</template>

View File

@@ -0,0 +1,44 @@
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import Navbar from '@/design-system/components/Navbar.vue'
import Hero from '@/design-system/components/Hero.vue'
import { useI18n } from '@/i18n/index.js'
const route = useRoute()
const { t } = useI18n()
const variant = computed(() =>
['split', 'centered'].includes(route.query.variant) ? route.query.variant : 'split',
)
const tone = computed(() =>
['cream', 'paper', 'brand'].includes(route.query.tone) ? route.query.tone : 'cream',
)
const navVariant = computed(() => (tone.value === 'brand' ? 'brand' : tone.value))
const imgPulver250 =
'/products/cutouts/kaiser-natron-pulver-250-g-gro%C3%9Fpackung-removebg-preview.png'
</script>
<template>
<div :class="['min-h-screen', tone === 'brand' ? 'bg-brand' : 'bg-surface']">
<Navbar :variant="navVariant" :cart-count="0" />
<Hero
:variant="variant"
:tone="tone"
:eyebrow="t('ds.hero.eyebrow')"
:subheadline="t('ds.hero.sub')"
:image="imgPulver250"
image-alt="Kaiser-Natron Pulver 250 g Großpackung"
:badge="t('ds.badges.featured')"
:cta-label="t('ds.buttons.addToCart')"
:secondary-label="t('ds.buttons.learnMore')"
>
<template #headline>
{{ t('ds.hero.headline.a') }}
<em class="italic font-light text-brand-soft">{{ t('ds.hero.headline.em') }}</em>
{{ t('ds.hero.headline.b') }}
</template>
</Hero>
</div>
</template>

View File

@@ -13,6 +13,12 @@ const routes = [
component: () => import('@/pages/design/previews/NavbarPreview.vue'),
meta: { layout: 'none', preview: true },
},
{
path: '/design/preview/hero',
name: 'ds-preview-hero',
component: () => import('@/pages/design/previews/HeroPreview.vue'),
meta: { layout: 'none', preview: true },
},
{
path: '/design',
component: () => import('@/pages/design/DesignLayout.vue'),
@@ -29,6 +35,7 @@ const routes = [
{ path: 'inputs', name: 'ds-inputs', component: () => import('@/pages/design/InputsSection.vue') },
{ path: 'cards', name: 'ds-cards', component: () => import('@/pages/design/CardsSection.vue') },
{ path: 'products', name: 'ds-products', component: () => import('@/pages/design/ProductsSection.vue') },
{ path: 'hero', name: 'ds-hero', component: () => import('@/pages/design/HeroSection.vue') },
{ path: 'navbar', name: 'ds-navbar', component: () => import('@/pages/design/NavbarSection.vue') },
{ path: 'language', name: 'ds-language', component: () => import('@/pages/design/LanguageSwitcherSection.vue') },
{ path: 'icons', name: 'ds-icons', component: () => import('@/pages/design/IconsSection.vue') },