first publish

This commit is contained in:
knso committed 2026-09-06 17:21:52 +02:00
commit b8e41b4d55
147 files changed
+19367

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { LegalPage } from "@/components/legal/LegalPage";
import { datenschutz } from "@/content/legal";
import { alternatesFor } from "@/lib/i18n";
export const metadata: Metadata = {
title: datenschutz.meta.title,
description: datenschutz.meta.description,
alternates: alternatesFor("de", "datenschutz"),
};
/** /datenschutz/ – Spec §8. Copy aus src/content/legal.ts, Platzhalter sichtbar in --gold-deep. */
export default function DatenschutzPage() {
return <LegalPage id="datenschutz" content={datenschutz} />;
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { LegalPage } from "@/components/legal/LegalPage";
import { impressum } from "@/content/legal";
import { alternatesFor } from "@/lib/i18n";
export const metadata: Metadata = {
title: impressum.meta.title,
description: impressum.meta.description,
alternates: alternatesFor("de", "impressum"),
};
/** /impressum/ – Spec §8. Copy aus src/content/legal.ts, Platzhalter sichtbar in --gold-deep. */
export default function ImpressumPage() {
return <LegalPage id="impressum" content={impressum} />;
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata, Viewport } from "next";
import "../globals.css";
import { Shell } from "@/components/layout/Shell";
import { rootMetadata } from "@/lib/metadata";
/**
* Wurzel-Layout der deutschen Fassung (`<html lang="de">`).
*
* Die Gruppe `(de)` taucht in keiner URL auf – die deutschen Adressen bleiben
* unverändert `/`, `/leistungen/…`, `/impressum/`. Sie existiert nur, damit die
* englische Fassung unter `app/en/` ein eigenes Wurzel-Layout mit `lang="en"`
* bekommen kann; das `<html>`-Element schreibt im App Router ausschließlich das
* Wurzel-Layout.
*/
export const metadata: Metadata = rootMetadata("de");
export const viewport: Viewport = {
themeColor: "#0f0e0c",
width: "device-width",
initialScale: 1,
};
export default function DeLayout({ children }: LayoutProps<"/">) {
return <Shell lang="de">{children}</Shell>;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { CoachingBody } from "@/components/pages/CoachingBody";
import { coaching, specLabels } from "@/content/coaching";
import { alternatesFor, OG_LOCALE } from "@/lib/i18n";
import { site } from "@/lib/site";
export const metadata: Metadata = {
title: coaching.meta.title,
description: coaching.meta.description,
alternates: alternatesFor("de", "coaching"),
openGraph: {
title: `${coaching.meta.title} – ${site.name}`,
description: coaching.meta.description,
url: alternatesFor("de", "coaching").canonical,
locale: OG_LOCALE.de,
},
};
/** /leistungen/coaching/ – Aufbau in <CoachingBody/>, Texte in src/content/coaching.ts. */
export default function CoachingPage() {
return <CoachingBody content={coaching} specLabels={specLabels} />;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { KeynotesBody } from "@/components/pages/KeynotesBody";
import { keynotes, talkLabels } from "@/content/keynotes";
import { alternatesFor, OG_LOCALE } from "@/lib/i18n";
import { site } from "@/lib/site";
export const metadata: Metadata = {
title: keynotes.meta.title,
description: keynotes.meta.description,
alternates: alternatesFor("de", "keynotes"),
openGraph: {
title: `${keynotes.meta.title} – ${site.name}`,
description: keynotes.meta.description,
url: alternatesFor("de", "keynotes").canonical,
locale: OG_LOCALE.de,
},
};
/** /leistungen/keynotes/ – Aufbau in <KeynotesBody/>, Texte in src/content/keynotes.ts. */
export default function KeynotesPage() {
return <KeynotesBody content={keynotes} talkLabels={talkLabels} />;
}
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { TrainingsBody } from "@/components/pages/TrainingsBody";
import { about } from "@/content/home";
import { trainings } from "@/content/trainings";
import { alternatesFor, OG_LOCALE } from "@/lib/i18n";
import { site } from "@/lib/site";
export const metadata: Metadata = {
title: trainings.meta.title,
description: trainings.meta.description,
alternates: alternatesFor("de", "trainings"),
openGraph: {
title: `${trainings.meta.title} – ${site.name}`,
description: trainings.meta.description,
url: alternatesFor("de", "trainings").canonical,
locale: OG_LOCALE.de,
},
};
/** /leistungen/trainings/ – Aufbau in <TrainingsBody/>, Texte in src/content/trainings.ts. */
export default function TrainingsPage() {
return <TrainingsBody content={trainings} marginalia={about.marginalia} />;
}
+20
View File
@@ -0,0 +1,20 @@
import type { Metadata } from "next";
import { NotFoundBody } from "@/components/pages/NotFoundBody";
import { notFound } from "@/content/not-found";
export const metadata: Metadata = {
title: notFound.meta.title,
description: notFound.meta.description,
robots: { index: false, follow: false },
};
/**
* 404 innerhalb der deutschen Route-Gruppe – greift, wenn eine Seite `notFound()` ruft.
* Die Datei, die der Hoster bei einer unbekannten Adresse ausliefert (`out/404.html`),
* entsteht dagegen aus src/app/global-not-found.tsx: Bei zwei Wurzel-Layouts kann Next
* keine gemeinsame Fehlerseite mehr zusammensetzen und fiele sonst auf seine eigene,
* ungestaltete Seite zurück. Beide rendern denselben <NotFoundBody/>.
*/
export default function NotFound() {
return <NotFoundBody content={notFound} />;
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { HomeBody } from "@/components/pages/HomeBody";
import { home } from "@/content/home";
import { alternatesFor } from "@/lib/i18n";
import { rootCopy } from "@/lib/metadata";
/* Titel fällt auf den Standard des Layouts zurück („Anelia Wolf – Keynote Speakerin …“). */
export const metadata: Metadata = {
description: rootCopy.de.description,
alternates: alternatesFor("de", "home"),
};
/** Startseite. Aufbau und JSON-LD liegen in <HomeBody/>, damit /en/ denselben Baum rendert. */
export default function Home() {
return <HomeBody lang="de" content={home} />;
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import type { Pairing } from "./fonts";
/**
* Index der Testseite: rechte Randleiste mit allen Nummern, die gerade sichtbare ist
* hervorgehoben. Springt über Lenis, damit der weiche Scroll der Seite nicht dagegen
* arbeitet. Kein Overlay über dem Entwurf – der Name steht in der Kopfzeile jeder
* Variante. TEMPORÄR – siehe fonts.ts.
*/
export function TypeNav({ pairings }: { pairings: readonly Pairing[] }) {
const [active, setActive] = useState(pairings[0]?.id ?? "");
useEffect(() => {
const sections = pairings
.map((p) => document.getElementById(p.id))
.filter((el): el is HTMLElement => Boolean(el));
if (!sections.length) return;
const io = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (visible) setActive(visible.target.id);
},
{ rootMargin: "-40% 0px -40% 0px", threshold: [0, 0.25, 0.5, 1] },
);
sections.forEach((el) => io.observe(el));
return () => io.disconnect();
}, [pairings]);
const go = (id: string) => {
const el = document.getElementById(id);
if (!el) return;
const lenis = (window as unknown as { lenis?: { scrollTo: (t: HTMLElement, o?: object) => void } }).lenis;
if (lenis) lenis.scrollTo(el, { offset: -8 });
else el.scrollIntoView({ behavior: "smooth", block: "start" });
};
return (
<>
<nav
aria-label="Schriftpaarungen"
className="fixed right-3 top-1/2 z-40 hidden -translate-y-1/2 flex-col gap-1 lg:flex"
>
{pairings.map((p) => (
<button
key={p.id}
type="button"
onClick={() => go(p.id)}
aria-current={p.id === active ? "true" : undefined}
title={p.name}
className={cn(
"px-2 py-[3px] font-mono text-[10px] tracking-[0.08em] tabular-nums transition-colors",
p.id === active ? "bg-gold text-ink" : "text-grey-warm hover:text-gold",
)}
>
{p.no}
</button>
))}
</nav>
</>
);
}
export default TypeNav;
+274
View File
@@ -0,0 +1,274 @@
/**
* Schriften für die Test-Seite `/schrift-test/`.
*
* TEMPORÄR. Diese Seite dient nur dazu, den Hero mit verschiedenen Schriftpaarungen
* nebeneinander zu sehen. Sobald die Entscheidung gefallen ist: den ganzen Ordner
* `src/app/schrift-test/` löschen. Damit verschwinden auch alle hier geladenen
* Schriftdateien wieder aus dem Build – die Live-Seite lädt nur Jost, DM Mono und
* die gewählte Display-Schrift.
*
* Alle Schriften kommen über `next/font/google`, werden also beim Bauen
* heruntergeladen und mit ausgeliefert. Auch die Testseite stellt damit KEINE
* externen Anfragen im Browser.
*
* Quelle der Paarungen 01–19: pagecloud.com/blog/best-google-fonts-pairings
* (dort umbenannt: „Source Sans Pro“ → Source Sans 3, „Source Serif Pro“ →
* Source Serif 4, „Muli“ → Mulish). 20–25 sind eigene Vorschläge im Sinne der
* Marke. 00 ist der aktuelle Stand als Vergleichsanker.
*/
import {
Abril_Fatface,
Arvo,
Bodoni_Moda,
Cardo,
Cinzel,
Cormorant_Garamond,
Crimson_Text,
EB_Garamond,
Fauna_One,
Fjalla_One,
Fraunces,
Gloock,
Instrument_Serif,
Italiana,
Josefin_Sans,
Jost,
Karla,
Lato,
Ledger,
Libre_Baskerville,
Libre_Caslon_Display,
Lora,
Lustria,
Marcellus,
Merriweather,
Montserrat,
Mulish,
Newsreader,
Nixie_One,
Oswald,
Philosopher,
Pontano_Sans,
Poppins,
Proza_Libre,
Playfair_Display,
Source_Sans_3,
Source_Serif_4,
Spectral,
Stint_Ultra_Expanded,
Unica_One,
Yeseva_One,
} from "next/font/google";
/* --- Display-Schriften ---------------------------------------------------- */
const bodoni = Bodoni_Moda({ subsets: ["latin"], display: "swap", style: ["normal", "italic"], axes: ["opsz"] });
const playfair = Playfair_Display({ subsets: ["latin"], display: "swap", style: ["normal", "italic"] });
const cormorant = Cormorant_Garamond({ subsets: ["latin"], display: "swap", weight: ["300", "400"], style: ["normal", "italic"] });
const libreBaskerville = Libre_Baskerville({ subsets: ["latin"], display: "swap", weight: ["400", "700"], style: ["normal", "italic"] });
const yeseva = Yeseva_One({ subsets: ["latin"], display: "swap", weight: "400" });
const cardo = Cardo({ subsets: ["latin"], display: "swap", weight: ["400", "700"], style: ["normal", "italic"] });
const spectral = Spectral({ subsets: ["latin"], display: "swap", weight: ["300", "400"], style: ["normal", "italic"] });
const lora = Lora({ subsets: ["latin"], display: "swap", style: ["normal", "italic"] });
const cinzel = Cinzel({ subsets: ["latin"], display: "swap" });
const lustria = Lustria({ subsets: ["latin"], display: "swap", weight: "400" });
const oswald = Oswald({ subsets: ["latin"], display: "swap" });
const fjalla = Fjalla_One({ subsets: ["latin"], display: "swap", weight: "400" });
const abril = Abril_Fatface({ subsets: ["latin"], display: "swap", weight: "400" });
const philosopher = Philosopher({ subsets: ["latin"], display: "swap", weight: ["400", "700"], style: ["normal", "italic"] });
const unica = Unica_One({ subsets: ["latin"], display: "swap", weight: "400" });
const nixie = Nixie_One({ subsets: ["latin"], display: "swap", weight: "400" });
const sourceSerif = Source_Serif_4({ subsets: ["latin"], display: "swap", style: ["normal", "italic"] });
const arvo = Arvo({ subsets: ["latin"], display: "swap", weight: ["400", "700"], style: ["normal", "italic"] });
const stint = Stint_Ultra_Expanded({ subsets: ["latin"], display: "swap", weight: "400" });
const instrument = Instrument_Serif({ subsets: ["latin"], display: "swap", weight: "400", style: ["normal", "italic"] });
const fraunces = Fraunces({ subsets: ["latin"], display: "swap", style: ["normal", "italic"] });
const newsreader = Newsreader({ subsets: ["latin"], display: "swap", style: ["normal", "italic"] });
const marcellus = Marcellus({ subsets: ["latin"], display: "swap", weight: "400" });
const italiana = Italiana({ subsets: ["latin"], display: "swap", weight: "400" });
const gloock = Gloock({ subsets: ["latin"], display: "swap", weight: "400" });
const libreCaslon = Libre_Caslon_Display({ subsets: ["latin"], display: "swap", weight: "400" });
/* --- Brot- und Labelschriften --------------------------------------------- */
const jost = Jost({ subsets: ["latin"], display: "swap" });
const sourceSans = Source_Sans_3({ subsets: ["latin"], display: "swap" });
const prozaLibre = Proza_Libre({ subsets: ["latin"], display: "swap", weight: ["400", "500"] });
const montserrat = Montserrat({ subsets: ["latin"], display: "swap" });
const josefin = Josefin_Sans({ subsets: ["latin"], display: "swap" });
const karla = Karla({ subsets: ["latin"], display: "swap" });
const merriweather = Merriweather({ subsets: ["latin"], display: "swap" });
const faunaOne = Fauna_One({ subsets: ["latin"], display: "swap", weight: "400" });
const lato = Lato({ subsets: ["latin"], display: "swap", weight: ["300", "400"] });
const ebGaramond = EB_Garamond({ subsets: ["latin"], display: "swap" });
const poppins = Poppins({ subsets: ["latin"], display: "swap", weight: ["300", "400"] });
const mulish = Mulish({ subsets: ["latin"], display: "swap" });
const crimson = Crimson_Text({ subsets: ["latin"], display: "swap", weight: ["400", "600"] });
const ledger = Ledger({ subsets: ["latin"], display: "swap", weight: "400" });
const pontano = Pontano_Sans({ subsets: ["latin"], display: "swap" });
export type Emphasis = "italic" | "gold" | "italic-gold";
export type Source = "aktuell" | "pagecloud" | "eigen";
export interface Pairing {
/** Laufende Nummer, zweistellig. */
no: string;
/** Anker-Id der Sektion. */
id: string;
/** „Bodoni Moda + Jost“ */
name: string;
/** Ein Satz: was diese Paarung mit der Marke macht. */
note: string;
source: Source;
displayFamily: string;
bodyFamily: string;
/** Gewicht der Headline. */
displayWeight: number;
/** Optische Korrektur der Headline-Größe (1 = wie aktuell). */
scale: number;
/** Laufweite der Headline. */
tracking: string;
/** Betonungswort: echte Kursive, Gold, oder beides. Schriften ohne Kursive bekommen Gold. */
emphasis: Emphasis;
}
const f = (font: { style: { fontFamily: string } }) => font.style.fontFamily;
export const PAIRINGS: readonly Pairing[] = [
{
no: "00", id: "p00", name: "Bodoni Moda + Jost", source: "aktuell",
note: "Aktueller Stand. Didone-Haarlinien gegen das Futura-Genom der Wortmarke.",
displayFamily: f(bodoni), bodyFamily: f(jost), displayWeight: 400, scale: 1, tracking: "-0.015em", emphasis: "italic",
},
{
no: "01", id: "p01", name: "Playfair Display + Source Sans 3", source: "pagecloud",
note: "Der Klassiker der Liste. Wärmer und runder als Bodoni, dafür deutlich häufiger gesehen.",
displayFamily: f(playfair), bodyFamily: f(sourceSans), displayWeight: 400, scale: 0.95, tracking: "-0.02em", emphasis: "italic-gold",
},
{
no: "02", id: "p02", name: "Cormorant Garamond + Proza Libre", source: "pagecloud",
note: "Sehr feine Renaissance-Antiqua. Elegant, aber die 2025er-Standardwahl für Coaching-Seiten.",
displayFamily: f(cormorant), bodyFamily: f(prozaLibre), displayWeight: 300, scale: 1.1, tracking: "-0.01em", emphasis: "italic",
},
{
no: "03", id: "p03", name: "Libre Baskerville + Montserrat", source: "pagecloud",
note: "Robuste Buchantiqua mit großer x-Höhe. Wirkt seriös und ruhig, weniger Mode.",
displayFamily: f(libreBaskerville), bodyFamily: f(montserrat), displayWeight: 400, scale: 0.82, tracking: "-0.02em", emphasis: "italic",
},
{
no: "04", id: "p04", name: "Yeseva One + Josefin Sans", source: "pagecloud",
note: "Dekorative Display-Antiqua. Viel Charakter, aber schnell nach Boutique statt nach B2B.",
displayFamily: f(yeseva), bodyFamily: f(josefin), displayWeight: 400, scale: 0.9, tracking: "-0.01em", emphasis: "gold",
},
{
no: "05", id: "p05", name: "Cardo + Josefin Sans", source: "pagecloud",
note: "Wissenschaftliche Antiqua mit geometrischer Sans. Nüchtern und wach.",
displayFamily: f(cardo), bodyFamily: f(josefin), displayWeight: 400, scale: 0.95, tracking: "-0.015em", emphasis: "italic",
},
{
no: "06", id: "p06", name: "Spectral + Karla", source: "pagecloud",
note: "Bildschirm-Antiqua mit eigenwilliger Grotesk. Modern, technisch, wenig Pathos.",
displayFamily: f(spectral), bodyFamily: f(karla), displayWeight: 300, scale: 0.92, tracking: "-0.02em", emphasis: "italic",
},
{
no: "07", id: "p07", name: "Lora + Merriweather", source: "pagecloud",
note: "Zwei Antiquas. Sehr lesbar, aber ohne Kontrast zwischen Überschrift und Text.",
displayFamily: f(lora), bodyFamily: f(merriweather), displayWeight: 400, scale: 0.88, tracking: "-0.02em", emphasis: "italic",
},
{
no: "08", id: "p08", name: "Cinzel + Fauna One", source: "pagecloud",
note: "Römische Kapitalis, nur Versalien. Monumental, aber lange Wörter werden schwer.",
displayFamily: f(cinzel), bodyFamily: f(faunaOne), displayWeight: 400, scale: 0.66, tracking: "0.01em", emphasis: "gold",
},
{
no: "09", id: "p09", name: "Lustria + Lato", source: "pagecloud",
note: "Weiche Antiqua mit neutraler Sans. Freundlich, etwas beliebig.",
displayFamily: f(lustria), bodyFamily: f(lato), displayWeight: 400, scale: 0.92, tracking: "-0.015em", emphasis: "gold",
},
{
no: "10", id: "p10", name: "Oswald + EB Garamond", source: "pagecloud",
note: "Schmale Grotesk als Headline, Antiqua im Text. Laut, plakativ, Richtung Sport.",
displayFamily: f(oswald), bodyFamily: f(ebGaramond), displayWeight: 300, scale: 1.0, tracking: "-0.01em", emphasis: "gold",
},
{
no: "11", id: "p11", name: "Fjalla One + Libre Baskerville", source: "pagecloud",
note: "Kräftige Schmalschrift. Sehr präsent, nimmt der Marke die Ruhe.",
displayFamily: f(fjalla), bodyFamily: f(libreBaskerville), displayWeight: 400, scale: 0.95, tracking: "-0.005em", emphasis: "gold",
},
{
no: "12", id: "p12", name: "Abril Fatface + Poppins", source: "pagecloud",
note: "Fette Didone. Ein Statement, aber schwer und nah an Modemagazin-Klischee.",
displayFamily: f(abril), bodyFamily: f(poppins), displayWeight: 400, scale: 0.88, tracking: "-0.02em", emphasis: "gold",
},
{
no: "13", id: "p13", name: "Montserrat + Cardo", source: "pagecloud",
note: "Grotesk als Headline, Antiqua im Text. Umgekehrte Rollenverteilung, sachlich.",
displayFamily: f(montserrat), bodyFamily: f(cardo), displayWeight: 300, scale: 0.82, tracking: "-0.02em", emphasis: "italic",
},
{
no: "14", id: "p14", name: "Philosopher + Mulish", source: "pagecloud",
note: "Humanistische Sans mit Antiqua-Anklängen. Eigenwillig, etwas nostalgisch.",
displayFamily: f(philosopher), bodyFamily: f(mulish), displayWeight: 400, scale: 0.95, tracking: "-0.015em", emphasis: "italic",
},
{
no: "15", id: "p15", name: "Unica One + Crimson Text", source: "pagecloud",
note: "Schmale Display-Grotesk. Technisch und kantig, passt eher zu Architektur.",
displayFamily: f(unica), bodyFamily: f(crimson), displayWeight: 400, scale: 0.95, tracking: "0em", emphasis: "gold",
},
{
no: "16", id: "p16", name: "Nixie One + Ledger", source: "pagecloud",
note: "Schreibmaschinen-Anmutung. Interessant, aber zu spielerisch für Vertriebstrainings.",
displayFamily: f(nixie), bodyFamily: f(ledger), displayWeight: 400, scale: 0.95, tracking: "0em", emphasis: "gold",
},
{
no: "17", id: "p17", name: "Source Serif 4 + Source Sans 3", source: "pagecloud",
note: "Eine Familie in zwei Ausprägungen. Perfekt abgestimmt, dafür ohne Spannung.",
displayFamily: f(sourceSerif), bodyFamily: f(sourceSans), displayWeight: 300, scale: 0.9, tracking: "-0.02em", emphasis: "italic",
},
{
no: "18", id: "p18", name: "Arvo + Lato", source: "pagecloud",
note: "Slab-Serif. Bodenständig und handwerklich, wenig Premium.",
displayFamily: f(arvo), bodyFamily: f(lato), displayWeight: 400, scale: 0.8, tracking: "-0.02em", emphasis: "italic",
},
{
no: "19", id: "p19", name: "Stint Ultra Expanded + Pontano Sans", source: "pagecloud",
note: "Extrem breite Antiqua. Sehr ungewöhnlich, frisst aber die Zeilenlänge.",
displayFamily: f(stint), bodyFamily: f(pontano), displayWeight: 400, scale: 0.6, tracking: "-0.01em", emphasis: "gold",
},
{
no: "20", id: "p20", name: "Instrument Serif + Jost", source: "eigen",
note: "Enge Display-Antiqua mit hohem Kontrast. Redaktionell, sehr aktuell.",
displayFamily: f(instrument), bodyFamily: f(jost), displayWeight: 400, scale: 1.05, tracking: "-0.02em", emphasis: "italic",
},
{
no: "21", id: "p21", name: "Fraunces + Jost", source: "eigen",
note: "Antiqua mit weichen, fast handgeschnittenen Formen. Warm und eigenständig.",
displayFamily: f(fraunces), bodyFamily: f(jost), displayWeight: 300, scale: 0.92, tracking: "-0.02em", emphasis: "italic",
},
{
no: "22", id: "p22", name: "Newsreader + Jost", source: "eigen",
note: "Zeitungsantiqua für den Bildschirm. Ruhig, erwachsen, gut für lange Zeilen.",
displayFamily: f(newsreader), bodyFamily: f(jost), displayWeight: 300, scale: 0.95, tracking: "-0.02em", emphasis: "italic",
},
{
no: "23", id: "p23", name: "Marcellus + Jost", source: "eigen",
note: "Antiqua mit Kapitalis-Anmutung. Klassisch und still, nah am Logo.",
displayFamily: f(marcellus), bodyFamily: f(jost), displayWeight: 400, scale: 0.95, tracking: "-0.005em", emphasis: "gold",
},
{
no: "24", id: "p24", name: "Italiana + Jost", source: "eigen",
note: "Sehr feine, schmale Display-Antiqua. Couture-Nähe, funktioniert nur groß.",
displayFamily: f(italiana), bodyFamily: f(jost), displayWeight: 400, scale: 1.12, tracking: "0.005em", emphasis: "gold",
},
{
no: "25", id: "p25", name: "Libre Caslon Display + Jost", source: "eigen",
note: "Caslon fürs Display. Britisch-verlegerisch, warm ohne weich zu sein.",
displayFamily: f(libreCaslon), bodyFamily: f(jost), displayWeight: 400, scale: 0.9, tracking: "-0.02em", emphasis: "italic",
},
{
no: "26", id: "p26", name: "Gloock + Jost", source: "eigen",
note: "Junge Display-Antiqua mit sehr hohem Strichkontrast. Modisch, kraftvoll, noch selten gesehen.",
displayFamily: f(gloock), bodyFamily: f(jost), displayWeight: 400, scale: 0.95, tracking: "-0.02em", emphasis: "gold",
},
] as const;
/** Für die Kopfzeile der Testseite. */
export const MONO_FAMILY = "var(--font-mono-face)";
+140
View File
@@ -0,0 +1,140 @@
import type { CSSProperties, ReactNode } from "react";
import type { Metadata } from "next";
import { cn } from "@/lib/utils";
import { hero } from "@/content/home";
import { chrome } from "@/content/chrome";
import { BrandMark } from "@/components/brand/BrandMark";
import { Container } from "@/components/layout/Container";
import { PAIRINGS, type Pairing } from "./fonts";
import { TypeNav } from "./TypeNav";
/**
* `/schrift-test/` – TEMPORÄRE Vergleichsseite.
*
* Zeigt den Hero der Startseite in {@link PAIRINGS} Schriftpaarungen untereinander,
* mit identischem Text, identischem Raster und identischen Farben. Nur die Schriften
* wechseln, damit man den Unterschied auch wirklich der Schrift zuschreiben kann.
* Die Größen sind pro Paarung optisch nachjustiert (`scale`), weil zwei Schriften bei
* gleicher Punktgröße unterschiedlich groß wirken.
*
* Nicht Teil der Website: nicht in der Sitemap, `noindex`, nicht verlinkt.
* Nach der Entscheidung `src/app/schrift-test/` löschen.
*/
export const metadata: Metadata = {
title: "Schrift-Test",
description: "Interne Vergleichsseite für Schriftpaarungen im Hero.",
robots: { index: false, follow: false, nocache: true },
};
const MONO = "font-mono text-[11px] tracking-[0.08em]";
function TypeHero({ p, mark }: { p: Pairing; mark: ReactNode }) {
const display: CSSProperties = {
fontFamily: p.displayFamily,
fontWeight: p.displayWeight,
letterSpacing: p.tracking,
fontSize: `calc(clamp(2.75rem, 1.4rem + 6.4vw, 8.5rem) * ${p.scale})`,
};
const body: CSSProperties = { fontFamily: p.bodyFamily };
return (
<section
id={p.id}
data-theme="dark"
className="relative min-h-svh scroll-mt-0 border-t border-line-dark bg-ink text-fg"
>
<Container className="relative flex min-h-svh flex-col pt-[calc(var(--header-h,4.5rem)+1.25rem)] pb-8">
{/* Kopfzeile der Variante – gehört zur Testseite, nicht zum Entwurf */}
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border-b border-line-dark pb-3">
<h2 className={cn(MONO, "uppercase text-gold")}>
{p.no} · {p.name}
</h2>
<p className={cn(MONO, "uppercase text-grey-deep")}>{p.source}</p>
<p className={cn(MONO, "text-grey-warm")}>{p.note}</p>
</div>
<div className="grid flex-1 grid-cols-12 items-center gap-x-[clamp(16px,2vw,32px)] pt-8">
<div className="col-span-12 lg:col-span-7">
{/* Mastkopf */}
<p
className="text-[0.72rem] font-medium uppercase leading-none tracking-[0.28em] text-gold"
style={body}
>
{hero.masthead.role}
</p>
{/* Headline – auf der Testseite ein <p>, damit die Seite genau eine H1 behält */}
<p className="mt-8 max-w-[12ch] leading-[0.96]" style={display}>
{hero.h1.before}
{p.emphasis === "italic" && <em className="italic">{hero.h1.em}</em>}
{p.emphasis === "italic-gold" && <em className="gold-text italic">{hero.h1.em}</em>}
{p.emphasis === "gold" && <span className="gold-text">{hero.h1.em}</span>}
{hero.h1.after}
</p>
<p
className="mt-8 max-w-[42ch] text-[clamp(1.0625rem,1rem+0.4vw,1.25rem)] font-light leading-[1.5] text-grey-warm"
style={body}
>
{hero.subline}
</p>
<div className="mt-8 flex flex-wrap items-center gap-x-8 gap-y-4">
<span
className="border border-gold px-[1.6em] py-[1em] text-[0.75rem] font-medium uppercase leading-none tracking-[0.2em] text-gold"
style={body}
>
{hero.cta.label}
</span>
</div>
</div>
{/* Marke: statisch, ohne Zeichenanimation – sonst liefe sie hier 26 Mal */}
<div className="col-span-12 mt-10 lg:col-span-5 lg:mt-0 lg:justify-self-end">
<div className="h-[22vh] text-ivory lg:h-[34vh] [&_svg]:h-full [&_svg]:w-auto">{mark}</div>
</div>
</div>
{/* Wortmarke in der Brotschrift – der zweite Ort, an dem die Paarung zählt */}
<div className="mt-6 flex items-end justify-between border-t border-line-dark pt-4">
<span
className="text-[1.0625rem] font-light uppercase leading-none tracking-[0.32em] text-ivory"
style={body}
>
{chrome.wordmark.name}
</span>
<span className={cn(MONO, "text-grey-warm")}>{hero.masthead.lang}</span>
</div>
</Container>
</section>
);
}
export default function SchriftTestPage() {
return (
<div data-theme="dark" className="bg-ink text-fg">
<Container className="pt-[calc(var(--header-h,4.5rem)+3rem)] pb-10">
<h1 className="font-display text-[clamp(2rem,1.4rem+2vw,3rem)] font-normal text-ivory">Schrift-Test</h1>
<p className="mt-4 max-w-[60ch] font-sans text-base font-light leading-relaxed text-grey-warm">
Der Hero der Startseite in {PAIRINGS.length} Schriftpaarungen. Gleicher Text, gleiches Raster,
gleiche Farben – nur die Schriften wechseln. Die Größen sind pro Paarung optisch nachjustiert,
weil zwei Schriften bei gleicher Punktgröße unterschiedlich groß wirken.
</p>
<p className="mt-3 max-w-[60ch] font-sans text-sm font-light leading-relaxed text-grey-warm">
Nummer 00 ist der aktuelle Stand. 01 bis 19 stammen aus der Liste von pagecloud, 20 bis 26 sind
eigene Vorschläge. Über jeder Variante stehen Nummer, Namen und eine kurze Einschätzung.
Rechts am Rand liegt der Index zum Springen.
</p>
<p className={cn(MONO, "mt-6 uppercase text-gold")}>
Interne Seite · nicht verlinkt · nicht im Index
</p>
</Container>
{PAIRINGS.map((p) => (
<TypeHero key={p.id} p={p} mark={<BrandMark idPrefix={p.id} title={`Monogramm – ${p.name}`} />} />
))}
<TypeNav pairings={PAIRINGS} />
</div>
);
}
@@ -0,0 +1,117 @@
"use client";
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import type { CompareOption } from "./fonts";
/**
* Umschalter für die vollständige Startseite. TEMPORÄR – siehe ./fonts.ts.
*
* Setzt `--font-display-face` und `--font-body-face` auf <html>, damit auch Kopf- und Fußzeile
* mitwechseln (die stehen im Root-Layout, nicht in dieser Seite).
*
* Zwei Feinheiten, die beim Umschalten wichtig sind:
*
* 1. Die Variablen werden im Klick gesetzt, BEVOR der Zustand wechselt. Die Sektionen
* hängen an GSAP SplitText, das die Zeilenumbrüche misst; würde die Schrift erst im
* Effekt danach wechseln, wäre die Messung mit der alten Schrift gemacht.
* 2. Der Inhalt bekommt den Namen der Variante als `key`. Dadurch wird er neu gemountet,
* SplitText teilt neu auf und die Reveals laufen einmal in der neuen Schrift durch.
* Ohne das blieben die alten Zeilenkästen stehen und der Text würde abgeschnitten.
*/
export function FontCompare({ options, children }: { options: readonly CompareOption[]; children: ReactNode }) {
const [active, setActive] = useState(options[0]);
const [gold, setGold] = useState(true);
const applied = useRef(false);
const apply = useCallback((o: CompareOption) => {
const root = document.documentElement;
root.style.setProperty("--font-display-face", o.displayFamily);
root.style.setProperty("--font-body-face", o.bodyFamily);
}, []);
/* Erststand setzen und beim Verlassen der Seite wieder aufräumen. */
useLayoutEffect(() => {
if (!applied.current) {
apply(options[0]);
applied.current = true;
}
const root = document.documentElement;
return () => {
root.style.removeProperty("--font-display-face");
root.style.removeProperty("--font-body-face");
};
}, [apply, options]);
const choose = (o: CompareOption) => {
if (o.id === active.id) return;
apply(o);
setActive(o);
};
const btn =
"px-3 py-2 font-mono text-[11px] uppercase tracking-[0.1em] transition-colors whitespace-nowrap";
return (
<>
<div key={active.id} data-typo-emphasis={gold ? "gold" : "normal"}>
{children}
</div>
<div
data-theme="dark"
className="fixed inset-x-0 bottom-0 z-[60] border-t border-gold/40 bg-ink/95 backdrop-blur-sm"
>
<div className="mx-auto flex max-w-[105rem] flex-wrap items-center gap-x-6 gap-y-2 px-[clamp(16px,4vw,40px)] py-3">
<span className="font-mono text-[11px] uppercase tracking-[0.18em] text-gold">Schrift</span>
<div className="flex flex-wrap items-center gap-1">
{options.map((o) => (
<button
key={o.id}
type="button"
onClick={() => choose(o)}
aria-pressed={o.id === active.id}
title={o.name}
className={cn(btn, o.id === active.id ? "bg-gold text-ink" : "text-grey-warm hover:text-gold")}
>
{o.no} · {o.short}
</button>
))}
</div>
<span className="hidden h-4 w-px bg-line-dark sm:block" />
<div className="flex items-center gap-1">
<span className="font-mono text-[11px] uppercase tracking-[0.18em] text-grey-deep">Betonung</span>
<button
type="button"
onClick={() => setGold(false)}
aria-pressed={!gold}
className={cn(btn, !gold ? "bg-gold text-ink" : "text-grey-warm hover:text-gold")}
>
{active.hasItalic ? "Kursiv" : "Schlicht"}
</button>
<button
type="button"
onClick={() => setGold(true)}
aria-pressed={gold}
className={cn(btn, gold ? "bg-gold text-ink" : "text-grey-warm hover:text-gold")}
>
Gold
</button>
</div>
<p className="ml-auto hidden max-w-[42ch] font-mono text-[11px] leading-snug text-grey-warm lg:block">
{active.note}
</p>
</div>
</div>
{/* Platz, damit die Leiste den Footer nicht verdeckt */}
<div aria-hidden="true" className="h-16 bg-ink" />
</>
);
}
export default FontCompare;
+53
View File
@@ -0,0 +1,53 @@
/**
* Nur die Schriften, die auf der Vergleichsseite `/schrift-test/seite/` gebraucht werden.
*
* Bewusst eine eigene Datei und nicht `../fonts.ts`: Die große Testseite lädt rund
* 40 Familien. Diese Seite soll nur die drei Kandidaten laden, sonst dauert der
* Aufbau unnötig lange.
*
* TEMPORÄR – zusammen mit `src/app/schrift-test/` löschen, sobald entschieden ist.
*/
import { Bodoni_Moda, Jost, Lato, Lustria, Playfair_Display, Source_Sans_3 } from "next/font/google";
const bodoni = Bodoni_Moda({ subsets: ["latin"], display: "swap", style: ["normal", "italic"], axes: ["opsz"] });
const jost = Jost({ subsets: ["latin"], display: "swap" });
const playfair = Playfair_Display({ subsets: ["latin"], display: "swap", style: ["normal", "italic"] });
const sourceSans = Source_Sans_3({ subsets: ["latin"], display: "swap" });
const lustria = Lustria({ subsets: ["latin"], display: "swap", weight: "400" });
const lato = Lato({ subsets: ["latin"], display: "swap", weight: ["300", "400"] });
export interface CompareOption {
id: string;
no: string;
/** Kurzform für den Umschalter. */
short: string;
/** Vollständige Bezeichnung. */
name: string;
note: string;
/** Wird auf `--font-display-face` gesetzt (Display: Überschriften, Ziffern, Formel, Zitate). */
displayFamily: string;
/** Wird auf `--font-body-face` gesetzt (Fließtext, Labels, Buttons, Wortmarke). */
bodyFamily: string;
/** Hat die Display-Schrift eine echte Kursive? Sonst trägt nur Gold die Betonung. */
hasItalic: boolean;
}
const f = (font: { style: { fontFamily: string } }) => font.style.fontFamily;
export const COMPARE: readonly CompareOption[] = [
{
id: "bodoni", no: "00", short: "Bodoni", name: "Bodoni Moda + Jost",
note: "Der frühere Stand. Didone mit hohem Strichkontrast, Futura-Genom im Text.",
displayFamily: f(bodoni), bodyFamily: f(jost), hasItalic: true,
},
{
id: "playfair", no: "01", short: "Playfair", name: "Playfair Display + Source Sans 3",
note: "Der gebaute Stand. Wärmer und runder als Bodoni, kräftigere Serifen, ruhigerer Fließtext.",
displayFamily: f(playfair), bodyFamily: f(sourceSans), hasItalic: true,
},
{
id: "lustria", no: "09", short: "Lustria", name: "Lustria + Lato",
note: "Weiche Antiqua ohne Kursive, dafür sehr eigener Rhythmus. Freundlicher Ton.",
displayFamily: f(lustria), bodyFamily: f(lato), hasItalic: false,
},
] as const;
+67
View File
@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { getPhotos } from "@/lib/assets";
import { BrandMark } from "@/components/brand/BrandMark";
import { Hero } from "@/components/home/Hero";
import { Positioning } from "@/components/home/Positioning";
import { ServicesIndex } from "@/components/home/ServicesIndex";
import { WolfPrinciple } from "@/components/home/WolfPrinciple";
import { About } from "@/components/home/About";
import { Audience } from "@/components/home/Audience";
import { ProcessContact } from "@/components/home/ProcessContact";
import { FontCompare } from "./FontCompare";
import { COMPARE } from "./fonts";
/**
* `/schrift-test/seite/` – die vollständige Startseite zum Umschalten. TEMPORÄR.
*
* Dieselben Sektionen wie `/`, in denselben Komponenten. Die Schrift wird nicht in den
* Komponenten geändert, sondern über die zwei Variablen `--font-display-face` (Display)
* und `--font-body-face` (Text) auf <html>. Deshalb wechselt wirklich alles mit: Überschriften,
* Ziffern, Formel, Ledger, Buttons, Wortmarke, Kopf- und Fußzeile.
*
* Kein JSON-LD und kein Canonical wie auf der echten Startseite – die Seite soll
* ausdrücklich nicht als zweite Startseite gelten.
*/
export const metadata: Metadata = {
title: "Schrift-Vergleich · Startseite",
description: "Interne Vergleichsseite: die vollständige Startseite in drei Schriftpaarungen.",
robots: { index: false, follow: false, nocache: true },
};
/**
* Betonungswörter (`<em>`) auf Gold umstellen, wenn der Umschalter auf „Gold“ steht.
* Auf dunklem Grund der Verlauf, auf hellem `--gold-deep` – Gold-Verlauf auf Elfenbein
* käme auf 2,4:1 und wäre nicht lesbar.
*/
const EMPHASIS_CSS = `
[data-typo-emphasis="gold"] [data-theme="dark"] em {
color: var(--gold);
background: var(--gold-gradient-text);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
[data-typo-emphasis="gold"] [data-theme="light"] em {
color: var(--gold-deep);
}
`;
export default function SchriftVergleichPage() {
const photos = getPhotos();
const aboutPhoto = photos.portrait ?? photos.hero;
return (
<>
<style dangerouslySetInnerHTML={{ __html: EMPHASIS_CSS }} />
<FontCompare options={COMPARE}>
<Hero mark={<BrandMark idPrefix="hero" handoff />} photo={photos.hero} />
<Positioning />
<ServicesIndex />
<WolfPrinciple mark={<BrandMark idPrefix="prinzip" />} />
<About photo={aboutPhoto} />
<Audience />
<ProcessContact />
</FontCompare>
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Enter-Choreografie jeder Seite. Der Rumpf liegt in einer eigenen Komponente,
* weil es seit der englischen Fassung zwei Wurzel-Layouts gibt und `template.tsx`
* ein Dateikonvention ist, die pro Wurzel einmal existieren muss.
*/
export { PageTemplate as default } from "@/components/motion/PageTemplate";
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { LegalPage } from "@/components/legal/LegalPage";
import { impressumEn } from "@/content/en/legal";
import { alternatesFor } from "@/lib/i18n";
export const metadata: Metadata = {
title: impressumEn.meta.title,
description: impressumEn.meta.description,
alternates: alternatesFor("en", "impressum"),
};
/** /en/imprint/ – Übersetzung; verbindlich bleibt die deutsche Fassung (siehe erster Abschnitt). */
export default function EnImprintPage() {
return <LegalPage id="imprint" content={impressumEn} lang="en" />;
}
+24
View File
@@ -0,0 +1,24 @@
import type { Metadata, Viewport } from "next";
import "../globals.css";
import { Shell } from "@/components/layout/Shell";
import { rootMetadata } from "@/lib/metadata";
/**
* Wurzel-Layout der englischen Fassung (`<html lang="en">`).
*
* `app/en/` liegt neben der Gruppe `(de)` und hat kein Layout über sich – damit ist
* dieses hier ein zweites Wurzel-Layout. Der Wechsel zwischen den Sprachen ist deshalb
* ein vollständiger Seitenaufbau statt einer Client-Navigation (Next.js-Verhalten bei
* mehreren Wurzel-Layouts) – bei einem Sprachwechsel ist das richtig.
*/
export const metadata: Metadata = rootMetadata("en");
export const viewport: Viewport = {
themeColor: "#0f0e0c",
width: "device-width",
initialScale: 1,
};
export default function EnLayout({ children }: LayoutProps<"/en">) {
return <Shell lang="en">{children}</Shell>;
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { HomeBody } from "@/components/pages/HomeBody";
import { homeEn } from "@/content/en/home";
import { alternatesFor } from "@/lib/i18n";
import { rootCopy } from "@/lib/metadata";
/* Title falls back to the layout default („Anelia Wolf – Keynote Speaker, Trainer & Coach“). */
export const metadata: Metadata = {
description: rootCopy.en.description,
alternates: alternatesFor("en", "home"),
};
/** /en/ – same tree as the German start page, English copy. */
export default function EnHome() {
return <HomeBody lang="en" content={homeEn} />;
}
+15
View File
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { LegalPage } from "@/components/legal/LegalPage";
import { datenschutzEn } from "@/content/en/legal";
import { alternatesFor } from "@/lib/i18n";
export const metadata: Metadata = {
title: datenschutzEn.meta.title,
description: datenschutzEn.meta.description,
alternates: alternatesFor("en", "datenschutz"),
};
/** /en/privacy/ – Übersetzung; verbindlich bleibt die deutsche Fassung (siehe erster Abschnitt). */
export default function EnPrivacyPage() {
return <LegalPage id="privacy" content={datenschutzEn} lang="en" />;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { CoachingBody } from "@/components/pages/CoachingBody";
import { coachingEn, specLabelsEn } from "@/content/en/coaching";
import { alternatesFor, OG_LOCALE } from "@/lib/i18n";
import { site } from "@/lib/site";
export const metadata: Metadata = {
title: coachingEn.meta.title,
description: coachingEn.meta.description,
alternates: alternatesFor("en", "coaching"),
openGraph: {
title: `${coachingEn.meta.title} – ${site.name}`,
description: coachingEn.meta.description,
url: alternatesFor("en", "coaching").canonical,
locale: OG_LOCALE.en,
},
};
/** /en/services/coaching/ – layout in <CoachingBody/>, copy in src/content/en/coaching.ts. */
export default function EnCoachingPage() {
return <CoachingBody content={coachingEn} specLabels={specLabelsEn} />;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { KeynotesBody } from "@/components/pages/KeynotesBody";
import { keynotesEn, talkLabelsEn } from "@/content/en/keynotes";
import { alternatesFor, OG_LOCALE } from "@/lib/i18n";
import { site } from "@/lib/site";
export const metadata: Metadata = {
title: keynotesEn.meta.title,
description: keynotesEn.meta.description,
alternates: alternatesFor("en", "keynotes"),
openGraph: {
title: `${keynotesEn.meta.title} – ${site.name}`,
description: keynotesEn.meta.description,
url: alternatesFor("en", "keynotes").canonical,
locale: OG_LOCALE.en,
},
};
/** /en/services/keynotes/ – layout in <KeynotesBody/>, copy in src/content/en/keynotes.ts. */
export default function EnKeynotesPage() {
return <KeynotesBody content={keynotesEn} talkLabels={talkLabelsEn} />;
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { TrainingsBody } from "@/components/pages/TrainingsBody";
import { homeEn } from "@/content/en/home";
import { trainingsEn } from "@/content/en/trainings";
import { alternatesFor, OG_LOCALE } from "@/lib/i18n";
import { site } from "@/lib/site";
export const metadata: Metadata = {
title: trainingsEn.meta.title,
description: trainingsEn.meta.description,
alternates: alternatesFor("en", "trainings"),
openGraph: {
title: `${trainingsEn.meta.title} – ${site.name}`,
description: trainingsEn.meta.description,
url: alternatesFor("en", "trainings").canonical,
locale: OG_LOCALE.en,
},
};
/** /en/services/trainings/ – layout in <TrainingsBody/>, copy in src/content/en/trainings.ts. */
export default function EnTrainingsPage() {
return <TrainingsBody content={trainingsEn} marginalia={homeEn.about.marginalia} />;
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Enter-Choreografie jeder Seite. Der Rumpf liegt in einer eigenen Komponente,
* weil es seit der englischen Fassung zwei Wurzel-Layouts gibt und `template.tsx`
* ein Dateikonvention ist, die pro Wurzel einmal existieren muss.
*/
export { PageTemplate as default } from "@/components/motion/PageTemplate";
+35
View File
@@ -0,0 +1,35 @@
import type { Metadata } from "next";
import "./globals.css";
import { Shell } from "@/components/layout/Shell";
import { NotFoundBody } from "@/components/pages/NotFoundBody";
import { notFound } from "@/content/not-found";
/**
* Die Fehlerseite für unbekannte Adressen – im statischen Export ist das `out/404.html`,
* die Datei, die der Hoster bei jedem Treffer ins Leere ausliefert.
*
* Sie muss das GANZE Dokument rendern (Next umgeht hier jedes Layout), deshalb steht hier
* `globals.css` und <Shell/>. Nötig wurde sie mit dem zweiten Wurzel-Layout für /en/:
* ohne sie hat Next kein Layout, aus dem es eine gemeinsame 404 bauen könnte, und liefert
* seine eigene weiße „404: This page could not be found.“ aus.
*
* Sprache: Deutsch – die Fassung, die der Rest der Adressen spricht. <NotFoundBody/> hängt
* einen englischen Zweizeiler an, weil es im statischen Export nur diese eine Datei gibt.
*/
export const metadata: Metadata = {
title: `${notFound.meta.title} – Anelia Wolf`,
description: notFound.meta.description,
robots: { index: false, follow: false },
};
export default function GlobalNotFound() {
return (
<Shell lang="de">
{/* Kein template.tsx über dieser Seite – das `main#inhalt`, auf das der Sprunglink
zeigt, muss sie deshalb selbst setzen. */}
<main id="inhalt" className="flex-1">
<NotFoundBody content={notFound} />
</main>
</Shell>
);
}
+272
View File
@@ -0,0 +1,272 @@
@import "tailwindcss";
@import "lenis/dist/lenis.css";
/* ------------------------------------------------------------------
Anelia Wolf – Design Tokens
Palette derived from the logo: warm near-black, metallic gold ramp,
ivory paper. Gold is rationed: hairlines, numerals, labels, strokes.
------------------------------------------------------------------ */
:root {
/* Dark ground */
--ink: #0f0e0c;
--ink-2: #191714;
--line-dark: #2a2722;
/* Light ground */
--ivory: #f6f1e8;
--bone: #eee6d8;
--line-light: #dcd3c3;
/* Neutrals */
--grey-warm: #a8a196; /* muted text on dark (7.5:1 on ink) */
--grey-deep: #5e5a52; /* muted text on light (6.1:1 on ivory) */
/* Gold */
--gold: #c9a24d; /* text/UI gold on DARK only (8.05:1 on ink) */
--gold-deep: #7d5f1b; /* text/UI gold on LIGHT only (5.3:1 on ivory) */
--gold-100: #f4e5b8;
--gold-200: #e6c97a;
--gold-500: #c9a24d;
--gold-700: #a37a2c;
--gold-900: #6e501a;
/* Gradients (fixed light direction: top-left) */
--gold-gradient: linear-gradient(135deg, #f4e5b8 0%, #e6c97a 22%, #c9a24d 48%, #a37a2c 74%, #6e501a 100%);
--gold-gradient-text: linear-gradient(100deg, #f4e5b8 0%, #e6c97a 32%, #c9a24d 62%, #a37a2c 100%);
--gold-hairline: linear-gradient(90deg, transparent 0%, #c9a24d 18%, #f4e5b8 50%, #c9a24d 82%, transparent 100%);
--gold-hairline-light: linear-gradient(90deg, #a37a2c 0%, #c9a24d 35%, #e6c97a 50%, #c9a24d 65%, #a37a2c 100%);
--gold-sheen: linear-gradient(115deg, #a37a2c 0%, #c9a24d 30%, #f4e5b8 50%, #c9a24d 70%, #a37a2c 100%);
/* Live theme pair – set per themed block by the `[data-theme]` rules below */
--bg: var(--ink);
--fg: var(--ivory);
--fg-muted: var(--grey-warm);
--accent: var(--gold);
--line: var(--line-dark);
--surface: var(--ink-2);
/* Motion */
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-out-quart: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
color-scheme: dark;
}
/* Light-ground sections flip the live pair */
[data-theme="light"] {
--bg: var(--ivory);
--fg: var(--ink);
--fg-muted: var(--grey-deep);
--accent: var(--gold-deep);
--line: var(--line-light);
--surface: var(--bone);
color-scheme: light;
}
[data-theme="dark"] {
--bg: var(--ink);
--fg: var(--ivory);
--fg-muted: var(--grey-warm);
--accent: var(--gold);
--line: var(--line-dark);
--surface: var(--ink-2);
color-scheme: dark;
}
/* 1440 px: the width where the header row fits nav + „Kontakt“ + the full CTA
label and the masthead centre slot gets its full tracking back. A NAMED
breakpoint, not `min-[1440px]:` – arbitrary min-width variants are emitted
before the named ones, so `min-[1440px]:inline-block` lost against
`lg:inline-block` at 1440. */
@theme {
--breakpoint-wide: 90rem;
}
@theme inline {
--color-ink: var(--ink);
--color-ink-2: var(--ink-2);
--color-ivory: var(--ivory);
--color-bone: var(--bone);
--color-gold: var(--gold);
--color-gold-deep: var(--gold-deep);
--color-gold-100: var(--gold-100);
--color-gold-200: var(--gold-200);
--color-gold-700: var(--gold-700);
--color-gold-900: var(--gold-900);
--color-grey-warm: var(--grey-warm);
--color-grey-deep: var(--grey-deep);
--color-line-dark: var(--line-dark);
--color-line-light: var(--line-light);
--color-bg: var(--bg);
--color-fg: var(--fg);
--color-fg-muted: var(--fg-muted);
--color-accent: var(--accent);
--color-line: var(--line);
--color-surface: var(--surface);
--font-sans: var(--font-body-face), "Source Sans 3", "Segoe UI", system-ui, Arial, sans-serif;
--font-display: var(--font-display-face), "Playfair Display", "Georgia", "Times New Roman", serif;
--font-mono: var(--font-mono-face), "SF Mono", "Menlo", "Consolas", monospace;
--ease-out-expo: var(--ease-out-expo);
--ease-out-quart: var(--ease-out-quart);
--ease-in-out: var(--ease-in-out);
}
/* ------------------------------------------------------------------
Ground & boundary (no JS).
Every top-level themed block paints its OWN opaque ground, so a
section's text can never sit on the neighbouring palette. The seam
itself is the spec's 1 px gold hairline (§2.3) – no gradient, no fade,
nothing soft-focus on a page built from hairlines: blocks that draw
their own <Rule variant="gold"/> as a direct child carry the seam
themselves, every other block gets the identical hairline from here.
The rule is depth-independent on purpose: `main > [data-theme]` silently
left a block on the neighbouring palette as soon as it sat one wrapper
deeper – the exact failure the own-ground decision exists to prevent.
`:not([data-theme] [data-theme])` gives the ground to the OUTERMOST themed
element on any branch, so nesting (e.g. <ServiceLayout/> around its light
<Section/>s, or <WolfPrinciple/> around <Formula/>) still paints once.
------------------------------------------------------------------ */
main [data-theme]:not([data-theme] [data-theme]),
footer[data-theme] {
background-color: var(--bg);
}
@supports selector(:has(*)) {
main > [data-theme="light"] + [data-theme="dark"]:not(:has(> .rule-gold)),
main:has(> [data-theme="light"]:last-child) + footer[data-theme]:not(:has(> .rule-gold)) {
background-image: var(--gold-hairline);
background-repeat: no-repeat;
background-size: 100% 1px;
}
main > [data-theme="dark"] + [data-theme="light"]:not(:has(> .rule-gold)),
main:has(> [data-theme="dark"]:last-child) + footer[data-theme]:not(:has(> .rule-gold)) {
background-image: var(--gold-hairline-light);
background-repeat: no-repeat;
background-size: 100% 1px;
}
}
/* ------------------------------------------------------------------ */
html {
background: var(--ink);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
scroll-behavior: auto;
}
body {
/* Fixed ink base – no JS ever touches colours. Every themed block paints its own ground. */
background: var(--ink);
color: var(--fg);
font-family: var(--font-sans);
font-size: clamp(1.0625rem, 1.02rem + 0.2vw, 1.125rem);
line-height: 1.6;
letter-spacing: 0.005em;
min-height: 100svh;
overflow-x: clip;
}
/* The header is ink chrome, but while it is still transparent it sits ON the
page's first block – on an ivory-first page (Impressum, Datenschutz) its type
has to be ink until the 80 px ground fades in. */
body:has(main > [data-theme="light"]:first-child) header:not([data-scrolled]) {
--fg: var(--ink);
--fg-muted: var(--grey-deep);
--accent: var(--gold-deep);
--line: var(--line-light);
}
/* Legal pages (Spec A/D6): „Datenschutzerklärung“ is one 20-character word and
at 44 px it is wider than the 375 px column – it widened the whole document.
Scoped here because the rule belongs to src/components/legal/LegalPage.tsx,
which is outside this pass' file scope; move it there when that file is touched. */
#impressum h1,
#datenschutz h1 {
font-size: clamp(2rem, 1.2rem + 4vw, 2.75rem);
hyphens: auto;
overflow-wrap: anywhere;
}
/* An SVG sized by height (404 monogram, hero mark) must never widen the page. */
svg {
max-width: 100%;
}
::selection {
background: var(--gold);
color: var(--ink);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 4px;
}
/* Gradient-clipped display text – ONE word per headline, ≥ 40px, dark ground only */
.gold-text {
color: var(--gold);
background: var(--gold-gradient-text);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Betonungswort in Display-Headlines: kursiv und, auf dunklem Grund, zusätzlich in Gold.
Entscheidung vom 06.09.2026 zusammen mit der Schriftwahl (Playfair Display + Source Sans 3).
Auf hellem Grund bleibt die Betonung rein typografisch: Der Gold-Verlauf käme auf Elfenbein
auf 2,4:1 und wäre nicht lesbar. Gold bleibt damit weiter auf die dunklen Flächen rationiert. */
[data-theme="dark"] .font-display em {
color: var(--gold);
background: var(--gold-gradient-text);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Hairlines */
.rule-gold { height: 1px; background: var(--gold-hairline); }
[data-theme="light"] .rule-gold { background: var(--gold-hairline-light); }
/* Static film grain – zero JS, sits above everything, never intercepts input.
Exactly one viewport: the layer is `fixed`, so it never moves relative to the
viewport and needs no bleed – `inset: -50%` at 200 % painted four times the
visible area (≈ 20 MB of GPU texture at 1440×900) for a 4.5 % noise overlay. */
.grain::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 60;
opacity: 0.045;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 300px 300px;
}
/* Anchor targets clear the fixed header. ONE number for every landing: the JS path
(anchorOffset() in TransitionLink) computes −(--header-h + 16px), so a native
`:target` jump and a Lenis jump land on the same pixel. */
main [id] {
scroll-margin-top: calc(var(--header-h, 72px) + 1rem);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
/* Reveal / Lines / Formula end state in CSS as well – not only via gsap.matchMedia.
`clip-path` is in the list because Formula's three lines are gated by one. */
.js [data-reveal] {
opacity: 1 !important;
visibility: visible !important;
transform: none !important;
clip-path: none !important;
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<!-- Anelia Wolf favicon: the wolf head alone, gold on ink (Spec 5.5).
Path geometry = WOLF_MARK_PATHS.wolf / .eye from src/components/brand/WolfMark.tsx
(viewBox 0 0 250 200), cropped and scaled to a 64 x 64 frame. -->
<rect width="64" height="64" fill="#0F0E0C"/>
<g transform="translate(-34.78 3.44) scale(0.42)" fill="none" stroke="#C9A24D" stroke-width="9" stroke-linecap="round" stroke-linejoin="miter" stroke-miterlimit="4">
<path d="M111,70 C117,56 117,42 126,32 L140,12 L155,34 C159,39 164,44 168,47 C180,52 193,59 204,66 C207,68 207,73 204,76 C196,78 188,79 180,80 C172,84 162,92 154,102 C149,108 157.3,116.6 160.2,124"/>
<path d="M160,53 Q165,49.5 170,53 Q165,56.5 160,53 Z" fill="#C9A24D" stroke-width="3" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 870 B

+13
View File
@@ -0,0 +1,13 @@
import type { MetadataRoute } from "next";
import { site } from "@/lib/site";
/** Static export: metadata routes are prerendered; mark explicitly (Next static-exports guide). */
export const dynamic = "force-static";
export default function robots(): MetadataRoute.Robots {
return {
// /schrift-test/ ist die interne Schrift-Vergleichsseite (temporär, siehe src/app/schrift-test/).
rules: { userAgent: "*", allow: "/", disallow: ["/schrift-test/"] },
sitemap: `${site.url}/sitemap.xml`,
};
}
+42
View File
@@ -0,0 +1,42 @@
import type { MetadataRoute } from "next";
import { LANGS, routesByLang, type Lang, type PageKey } from "@/lib/i18n";
import { site } from "@/lib/site";
/** Static export: metadata routes are prerendered; mark explicitly (Next static-exports guide). */
export const dynamic = "force-static";
type ChangeFrequency = NonNullable<MetadataRoute.Sitemap[number]["changeFrequency"]>;
const pages: ReadonlyArray<{ page: PageKey; changeFrequency: ChangeFrequency; priority: number }> = [
{ page: "home", changeFrequency: "monthly", priority: 1 },
{ page: "coaching", changeFrequency: "monthly", priority: 0.8 },
{ page: "trainings", changeFrequency: "monthly", priority: 0.8 },
{ page: "keynotes", changeFrequency: "monthly", priority: 0.8 },
{ page: "impressum", changeFrequency: "yearly", priority: 0.2 },
{ page: "datenschutz", changeFrequency: "yearly", priority: 0.2 },
];
/* Die englische Fassung ist eine Übersetzung, keine eigenständige Seite: gleiche
Änderungsfrequenz, aber niedrigere Priorität, damit Deutsch die Originalfassung bleibt.
Jeder Eintrag nennt über `alternates.languages` beide Fassungen – dasselbe hreflang,
das die Seiten im <head> tragen. */
const PRIORITY_FACTOR: Record<Lang, number> = { de: 1, en: 0.9 };
export default function sitemap(): MetadataRoute.Sitemap {
// Evaluated at build time (no request time in a static export): "last modified" = last build.
const lastModified = new Date();
return LANGS.flatMap((lang) =>
pages.map(({ page, changeFrequency, priority }) => ({
url: `${site.url}${routesByLang[lang][page]}`,
lastModified,
changeFrequency,
priority: Number((priority * PRIORITY_FACTOR[lang]).toFixed(2)),
alternates: {
languages: {
de: `${site.url}${routesByLang.de[page]}`,
en: `${site.url}${routesByLang.en[page]}`,
},
},
})),
);
}
+130
View File
@@ -0,0 +1,130 @@
import { cn } from "@/lib/utils";
import { getLogoSvg } from "@/lib/assets";
import { WolfMark } from "./WolfMark";
/**
* Server Component. Renders the client's `public/brand/logo-mark.svg` inline
* (checked with fs at build time via `getLogoSvg()`) or falls back to the
* in-house <WolfMark/>.
*
* Sanitising: a `<script>` in the delivered file aborts the build; `<foreignObject>`,
* `on*=` handlers and every non-fragment `href` are stripped before injection.
*
* Inline SVG preparation: every <path> gets `data-draw`; ids are mapped to
* the WolfMark classes (`#v-left`/`#v-right` → `.wolfmark-v`, `#wolf` →
* `.wolfmark-wolf`, `#handoff` → `.wolfmark-wolf.wolfmark-handoff`), ids are
* prefixed with `idPrefix`, `width`/`height` are stripped so CSS sizes the mark.
*/
export interface BrandMarkProps {
className?: string;
/** Prefix for gradient/path ids when the mark appears more than once per page. */
idPrefix?: string;
/** `v-only` = the two V strokes without the wolf (404 page). */
variant?: "full" | "v-only";
/** Keep `path#handoff` of the client SVG (hero only). */
handoff?: boolean;
/** Accessible name. */
title?: string;
}
const ID_CLASS: Record<string, string> = {
"v-left": "wolfmark-v",
"v-right": "wolfmark-v",
wolf: "wolfmark-wolf",
handoff: "wolfmark-wolf wolfmark-handoff",
};
function escapeAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function prepareSvg(
raw: string,
{ className, idPrefix, variant, handoff, title }: Required<Pick<BrandMarkProps, "variant" | "handoff" | "title">> &
Pick<BrandMarkProps, "className" | "idPrefix">,
): string {
/* The file comes from a non-technical client via public/brand/ and is injected with
`dangerouslySetInnerHTML` into the header, footer, curtain and hero of EVERY page –
on a site whose whole privacy story is „no external scripts“. A designer export can
legitimately contain a <script> or an event handler, so: a script aborts the build
(loud, not silently patched away), everything else executable is removed. */
if (/<script[\s>]/i.test(raw)) {
throw new Error(
"public/brand/logo-mark.svg enthält ein <script>-Element. Die Datei wird auf jeder Seite inline eingebettet – " +
"bitte eine Export-Variante ohne Skript liefern (in Illustrator/Figma: „SVG ohne Interaktivität“).",
);
}
let svg = raw
.replace(/<\?xml[^>]*\?>/g, "")
.replace(/<!DOCTYPE[^>]*>/gi, "")
.replace(/<!--[\s\S]*?-->/g, "")
/* Executable payloads: inline handlers, foreign HTML, javascript: targets. */
.replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, "")
.replace(/\son[a-z]+\s*=\s*"[^"]*"/gi, "")
.replace(/\son[a-z]+\s*=\s*'[^']*'/gi, "")
.replace(/\s(?:xlink:)?href\s*=\s*"(?!#)[^"]*"/gi, "")
.replace(/\s(?:xlink:)?href\s*=\s*'(?!#)[^']*'/gi, "")
.trim();
const prefix = idPrefix ? `${idPrefix}-` : "";
if (prefix) {
svg = svg
.replace(/\bid="([^"]+)"/g, (_, id: string) => `id="${prefix}${id}"`)
.replace(/url\(#([^)]+)\)/g, (_, id: string) => `url(#${prefix}${id})`)
.replace(/\b(xlink:)?href="#([^"]+)"/g, (_, x: string | undefined, id: string) => `${x ?? ""}href="#${prefix}${id}"`);
}
svg = svg.replace(/<path\b([^>]*?)(\/?)>/g, (_, attrs: string, selfClose: string) => {
let a = attrs;
const idMatch = /\bid="([^"]+)"/.exec(a);
const rawId = idMatch ? idMatch[1].slice(prefix.length) : null;
const cls = rawId ? ID_CLASS[rawId] : undefined;
const isWolf = rawId === "wolf" || rawId === "handoff" || /wolfmark-wolf/.test(a);
if (rawId === "handoff" && !handoff) return "";
if (variant === "v-only" && isWolf) return "";
if (!/\bdata-draw\b/.test(a)) a += ' data-draw=""';
if (cls) {
a = /\bclass="/.test(a) ? a.replace(/\bclass="([^"]*)"/, (__, c: string) => `class="${c} ${cls}"`) : `${a} class="${cls}"`;
}
return `<path${a}${selfClose}>`;
});
svg = svg.replace(/<svg\b([^>]*)>/, (_, attrs: string) => {
let a = attrs.replace(/\s(width|height)="[^"]*"/g, "");
if (/\bclass="/.test(a)) {
a = a.replace(/\bclass="([^"]*)"/, (__, c: string) => `class="${cn(c, className)}"`);
} else if (className) {
a += ` class="${className}"`;
}
if (!/\brole=/.test(a)) a += ' role="img"';
if (!/\baria-label=/.test(a)) a += ` aria-label="${escapeAttr(title)}"`;
return `<svg${a}>`;
});
return svg;
}
export function BrandMark({
className,
idPrefix,
variant = "full",
handoff = false,
title = "Anelia Wolf – Monogramm",
}: BrandMarkProps) {
const raw = getLogoSvg();
if (raw) {
const html = prepareSvg(raw, { className, idPrefix, variant, handoff, title });
return <span className="contents" dangerouslySetInnerHTML={{ __html: html }} />;
}
if (variant === "v-only") {
return (
<span className="contents [&_.wolfmark-wolf]:hidden">
<WolfMark className={className} idPrefix={idPrefix} title={title} />
</span>
);
}
return <WolfMark className={className} idPrefix={idPrefix} title={title} />;
}
export default BrandMark;
+220
View File
@@ -0,0 +1,220 @@
"use client";
import { useLayoutEffect, useRef, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { gsap, DUR, MM } from "@/lib/gsap";
import { STROKE_IN, WIPE_IN, playSheen, primeMark } from "@/components/motion/Draw";
export interface DrawMarkProps {
/** `load` = on mount (hero), `view` = at 80 % in view (once), `scrub` = scroll-tied (Prinzip). */
play?: "load" | "view" | "scrub";
/** One-time champagne sweep over the wolf stroke after drawing. */
sheen?: boolean;
/** Continue the neck stroke downward into the first hairline (hero only). */
handoff?: boolean;
/** Length of the handoff hairline in px when the SVG has no `#handoff` path (24–48). */
handoffLength?: number;
delay?: number;
className?: string;
onComplete?: () => void;
/** Receives the handoff x in px from the wrapper's left edge (for `<Rule origin={x}/>`). */
onHandoff?: (x: number) => void;
/** `<BrandMark/>` (Server Component, passed as children). */
children: ReactNode;
}
/** Timing of the draw sequence in seconds (spec §5.5). */
const T = { vLeft: [0, 0.35], vRight: [0.25, 0.35], wolf: [0.5, 0.7], detail: [1.0, 0.3], handoff: [1.2, 0.2], sheen: 1.5 } as const;
/**
* Client shell around <BrandMark/>: V-left → V-right → wolf → (handoff) → sheen.
* Paths are ordered by the classes `.wolfmark-v` / `.wolfmark-wolf`; an inline
* client SVG without those classes is drawn in DOM order.
*/
export function DrawMark({
play = "load",
sheen = false,
handoff = false,
handoffLength = 40,
delay = 0,
className,
onComplete,
onHandoff,
children,
}: DrawMarkProps) {
const wrap = useRef<HTMLSpanElement>(null);
const line = useRef<HTMLSpanElement>(null);
const onCompleteRef = useRef(onComplete);
const onHandoffRef = useRef(onHandoff);
/* Read imperatively, never a dependency: the hero measures the gap and pushes a new
length on every resize, and re-running the effect would `mm.revert()` the whole
matchMedia scope and replay the monogram draw mid-interaction. */
const handoffLengthRef = useRef(handoffLength);
useLayoutEffect(() => {
onCompleteRef.current = onComplete;
onHandoffRef.current = onHandoff;
});
useLayoutEffect(() => {
handoffLengthRef.current = handoffLength;
const el = line.current;
if (el) el.style.height = `${handoffLength}px`;
}, [handoffLength]);
useLayoutEffect(() => {
const el = wrap.current;
const svg = el?.querySelector("svg");
if (!el || !svg) return;
const all = Array.from(svg.querySelectorAll<SVGPathElement>("path"));
const handoffPath = all.find((p) => p.classList.contains("wolfmark-handoff")) ?? null;
let vs = all.filter((p) => p.classList.contains("wolfmark-v"));
let wolves = all.filter((p) => p.classList.contains("wolfmark-wolf") && p !== handoffPath);
if (!vs.length && !wolves.length) {
// Unknown SVG: everything in DOM order – first two strokes as "V", the rest as "wolf".
const drawable = all.filter((p) => p.hasAttribute("data-draw"));
const seq = drawable.length ? drawable : all;
vs = seq.slice(0, 2);
wolves = seq.slice(2);
}
const mainWolf = wolves[0] ?? null;
const details = wolves.slice(1);
const sequence = [...vs, ...wolves, ...(handoffPath ? [handoffPath] : [])];
const lineEl = line.current;
const useLine = handoff && !handoffPath && !!lineEl;
/* The path geometry is fixed in SVG user space – only the CTM changes on resize.
Sample once (2 × 121 `getPointAtLength` calls), then re-project the cached point. */
let anchor: { x: number; y: number } | null | undefined;
const sampleAnchor = (): { x: number; y: number } | null => {
if (!mainWolf) return null;
const end = mainWolf.getPointAtLength(mainWolf.getTotalLength());
const pts: { x: number; y: number }[] = [];
let maxY = -Infinity;
vs.forEach((v) => {
const len = v.getTotalLength();
for (let i = 0; i <= 120; i++) {
const p = v.getPointAtLength((len * i) / 120);
pts.push({ x: p.x, y: p.y });
if (p.y > maxY) maxY = p.y;
}
});
const pool = pts.filter((p) => p.y >= maxY - 2);
return (pool.length ? pool : [{ x: end.x, y: end.y }]).reduce((a, b) =>
Math.abs(b.x - end.x) < Math.abs(a.x - end.x) ? b : a,
);
};
/** Position of the handoff hairline: the lowest V point nearest the wolf's end. */
const placeLine = (): number | null => {
if (!mainWolf || !lineEl) return null;
try {
const ctm = svg.getScreenCTM();
if (!ctm) return null;
if (anchor === undefined) anchor = sampleAnchor();
if (!anchor) return null;
const rect = el.getBoundingClientRect();
const sp = new DOMPoint(anchor.x, anchor.y).matrixTransform(ctm);
const x = sp.x - rect.left;
const y = sp.y - rect.top;
lineEl.style.left = `${x}px`;
lineEl.style.top = `${y}px`;
lineEl.style.height = `${handoffLengthRef.current}px`;
el.style.setProperty("--handoff-x", `${x}px`);
return x;
} catch {
/* A measurement failure must never strand the mark – the reveal below runs anyway. */
return null;
}
};
const mm = gsap.matchMedia();
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
if (ctx.conditions?.reduce) {
gsap.set(el, { opacity: 1 });
if (useLine) {
const x = placeLine();
gsap.set(lineEl, { scaleY: 1, opacity: 1 });
if (x !== null) onHandoffRef.current?.(x);
}
onCompleteRef.current?.();
return;
}
const mode = primeMark(svg, sequence);
let hx: number | null = null;
if (useLine) {
hx = placeLine();
gsap.set(lineEl, { scaleY: 0, transformOrigin: "50% 0%", opacity: 1 });
}
gsap.set(el, { opacity: 1 });
const tl = gsap.timeline({
paused: play !== "load",
delay: play === "load" ? delay : 0,
onComplete: () => {
if (hx !== null) onHandoffRef.current?.(hx);
if (sheen && play !== "scrub" && mainWolf) playSheen(mainWolf, DUR.sheen);
onCompleteRef.current?.();
},
});
const ease = "power2.inOut";
if (mode === "wipe") {
/* Interlocked filled artwork: one slice down the whole mark (see Draw.tsx). */
tl.to(svg, { ...WIPE_IN, duration: T.wolf[0] + T.wolf[1], ease }, T.vLeft[0]);
} else {
if (vs[0]) tl.to(vs[0], { ...STROKE_IN, duration: T.vLeft[1], ease }, T.vLeft[0]);
if (vs[1]) tl.to(vs[1], { ...STROKE_IN, duration: T.vRight[1], ease }, T.vRight[0]);
vs.slice(2).forEach((v, i) => tl.to(v, { ...STROKE_IN, duration: 0.3, ease }, T.vRight[0] + 0.1 * (i + 1)));
if (mainWolf) tl.to(mainWolf, { ...STROKE_IN, duration: T.wolf[1], ease }, T.wolf[0]);
if (details.length) tl.to(details, { ...STROKE_IN, duration: T.detail[1], ease, stagger: 0.08 }, T.detail[0]);
if (handoffPath) tl.to(handoffPath, { ...STROKE_IN, duration: T.handoff[1], ease: "power2.out" }, T.handoff[0]);
}
/* The hairline lives outside the svg, so it follows either reveal. */
if (useLine) tl.to(lineEl, { scaleY: 1, duration: T.handoff[1] + 0.1, ease: "power2.out" }, T.handoff[0]);
if (play === "view") {
gsap.timeline({ scrollTrigger: { trigger: el, start: "top 80%", once: true, onEnter: () => tl.play() } });
} else if (play === "scrub") {
tl.eventCallback("onComplete", null);
gsap.to(tl, {
progress: 1,
ease: "none",
scrollTrigger: { trigger: el, start: "top 80%", end: "bottom 40%", scrub: 0.6 },
});
}
/* Keep the hairline attached to the mark when the viewport changes – one rAF per
frame, not one full re-projection per resize event. */
let raf = 0;
const onResize = () => {
if (!useLine || raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
placeLine();
});
};
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
if (raf) cancelAnimationFrame(raf);
};
});
return () => mm.revert();
}, [play, sheen, handoff, delay]);
return (
<span ref={wrap} className={cn("relative inline-block [.js_&]:opacity-0", className)}>
{children}
{handoff && (
<span
ref={line}
aria-hidden="true"
className="pointer-events-none absolute w-px bg-gold opacity-0"
style={{ height: handoffLength, left: 0, top: "100%" }}
/>
)}
</span>
);
}
export default DrawMark;
+125
View File
@@ -0,0 +1,125 @@
import type { CSSProperties } from "react";
/** viewBox of the Anelia Wolf monogram. Proportion ~1.25 : 1. */
export const WOLF_MARK_VIEWBOX = "0 0 250 200";
/**
* Path geometry of the mark (viewBox 0 0 250 200).
* The two V's are hollow outline bands: the left V is one closed path
* (outer + inner contour); the right V is an open contour whose left arm is
* taken over by the wolf head. The wolf is ONE continuous open path (nape ->
* ear -> brow -> muzzle -> jaw -> throat, merging into the W's middle
* down-stroke) plus one tiny detail stroke (the eye).
*/
export const WOLF_MARK_PATHS = {
vLeft: "M111,70 L64,190 L10,52 L34,52 L64,130 L92.6,56",
vRight: "M160.2,124 L186,190 L240,52 L216,52 L186,130 L172.9,96",
wolf: "M111,70 C117,56 117,42 126,32 L140,12 L155,34 C159,39 164,44 168,47 C180,52 193,59 204,66 C207,68 207,73 204,76 C196,78 188,79 180,80 C172,84 162,92 154,102 C149,108 157.3,116.6 160.2,124",
eye: "M160,53 Q165,49.5 170,53 Q165,56.5 160,53 Z",
} as const;
const GOLD_STOPS: ReadonlyArray<readonly [number, string]> = [
[0, "#F4E5B8"],
[0.22, "#E6C97A"],
[0.48, "#C9A24D"],
[0.74, "#A37A2C"],
[1, "#6E501A"],
];
export interface WolfMarkProps {
className?: string;
style?: CSSProperties;
/** Accessible name of the mark. */
title?: string;
/** Prefix for the gradient id – set it when the mark appears more than once on a page. */
idPrefix?: string;
/** Stroke width in viewBox units (default 7). */
strokeWidth?: number;
}
/**
* Anelia Wolf monogram: a "W" built from two hollow V outlines whose middle peak
* is a wolf head in profile (facing right) drawn in metallic gold.
* Server-component friendly – no hooks, no client code.
*
* - V outlines use `currentColor` (set `color` on a parent: black on light, ivory on dark).
* - Every gold path carries `data-draw` and `pathLength="1"`, so a draw-on animation
* is simply `stroke-dasharray: 1; stroke-dashoffset: 1 -> 0`.
*/
export function WolfMark({
className,
style,
title = "Anelia Wolf – Monogramm",
idPrefix,
strokeWidth = 7,
}: WolfMarkProps) {
const gradientId = idPrefix ? `${idPrefix}-wolf-gold` : "wolf-gold";
const gold = `url(#${gradientId})`;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={WOLF_MARK_VIEWBOX}
role="img"
aria-label={title}
className={className}
style={style}
>
<title>{title}</title>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1" gradientUnits="objectBoundingBox">
{GOLD_STOPS.map(([offset, color]) => (
<stop key={offset} offset={offset} stopColor={color} />
))}
</linearGradient>
</defs>
{/* The two V's – hollow outline bands */}
<path
className="wolfmark-v"
d={WOLF_MARK_PATHS.vLeft}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinejoin="miter"
strokeLinecap="butt"
/>
<path
className="wolfmark-v"
d={WOLF_MARK_PATHS.vRight}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinejoin="miter"
strokeLinecap="butt"
/>
{/* Wolf head – one continuous gold line + the eye */}
<path
className="wolfmark-wolf"
data-draw=""
pathLength={1}
d={WOLF_MARK_PATHS.wolf}
fill="none"
stroke={gold}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="miter"
strokeMiterlimit={4}
/>
<path
className="wolfmark-wolf wolfmark-wolf-detail"
data-draw=""
pathLength={1}
d={WOLF_MARK_PATHS.eye}
fill={gold}
stroke={gold}
strokeWidth={strokeWidth * 0.35}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default WolfMark;
+73
View File
@@ -0,0 +1,73 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
import { TransitionLink } from "@/components/motion/TransitionLink";
export interface WordmarkProps {
size?: "nav" | "footer";
href?: string;
className?: string;
/** Optional monogram rendered before the name (header 28 px, footer 40 px). */
mark?: ReactNode;
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
chrome?: Chrome;
}
/**
* HTML wordmark: ANELIA WOLF (Jost 300, ls .32em) over a gold hairline and the
* sub-line. One `<a>` with aria-label „Anelia Wolf – Startseite“.
* Nav: 1.0625rem, name only – rule and sub-line are the footer's job (the
* header row has no width to spare between 1024 and 1440). Footer: fluid
* 20–44 px name, sub-line gold at 0.45em / 0.18em tracking (spec §3.2: the
* sub never out-tracks the name).
*/
export function Wordmark({ size = "nav", href = "/", className, mark, chrome = chromeDe }: WordmarkProps) {
const footer = size === "footer";
return (
<TransitionLink
href={href}
aria-label={chrome.wordmark.ariaLabel}
className={cn("wordmark group inline-flex max-w-full items-center", footer ? "gap-4 sm:gap-6" : "gap-4", className)}
>
{mark && (
<span
aria-hidden="true"
className={cn("wordmark__mark shrink-0 [&_svg]:h-full [&_svg]:w-auto", footer ? "h-10" : "h-7")}
>
{mark}
</span>
)}
<span
className={cn(
"wordmark__text flex flex-col",
footer ? "min-w-0 text-[clamp(1.25rem,0.75rem+1.8vw,2.75rem)]" : "text-[1.0625rem]",
)}
>
<span
className={cn(
"wordmark__name whitespace-nowrap pl-[0.32em] font-sans text-[1em] font-light uppercase leading-none tracking-[0.32em]",
"text-fg",
)}
>
{chrome.wordmark.name}
</span>
<span
aria-hidden="true"
className={cn("wordmark__rule rule-gold", footer ? "my-[0.3em]" : "hidden")}
/>
<span
className={cn(
"wordmark__sub pl-[0.22em] font-sans font-normal uppercase",
footer
? "max-sm:whitespace-normal max-sm:leading-[1.5] sm:whitespace-nowrap sm:leading-none text-[0.55em] tracking-[0.18em] text-gold sm:text-[0.45em]"
: "hidden",
)}
>
{chrome.wordmark.sub}
</span>
</span>
</TransitionLink>
);
}
export default Wordmark;
+105
View File
@@ -0,0 +1,105 @@
import { cn } from "@/lib/utils";
import { flags } from "@/lib/site";
import { about as aboutDe, type HomeContent } from "@/content/home";
import { Section } from "@/components/layout/Section";
import { Lines } from "@/components/motion/Lines";
import { Reveal } from "@/components/motion/Reveal";
import { Rule } from "@/components/motion/Rule";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { Ledger } from "@/components/ui/Ledger";
import { Marginalia } from "@/components/ui/Marginalia";
import { Portrait } from "@/components/ui/Portrait";
export interface AboutProps {
/** `getPhotos().portrait` – `null` renders the documented variant B (no photo). */
photo: string | null;
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
content?: HomeContent["about"];
}
/* H2 on ivory: Playfair 400, max 14ch (spec §3.2 / §6.5). */
const H2 =
"font-display text-[clamp(2.25rem,1.4rem+3.2vw,4.75rem)] font-normal leading-[1.02] tracking-[-0.01em] text-fg [text-wrap:balance] max-w-[14ch]";
/* Body: Jost 400, 18 px, max 36rem, paragraph gap 1.25em, German hyphenation. */
const BODY = "max-w-[36rem] font-sans text-[1.125rem] leading-[1.6] text-fg [hyphens:auto]";
/**
* 04 · Über Anelia (ivory – spec §6.5). First person, a portrait that stays
* in view while reading, the Werdegang as a ledger, marginalia with
* verifiable facts. No counters, no logos.
*
* With photo: Portrait cols 3–6 (sticky) · text + ledger cols 7–12 · marginalia cols 10–12.
* Without: text cols 3–8 · ledger cols 3–9 · marginalia under the ledger
* (no monogram vignette – see below).
*/
export function About({ photo, content: about = aboutDe }: AboutProps) {
const paragraphs = about.paragraphs.map((p, i) =>
i === about.paragraphs.length - 1 && flags.showTwins ? `${p} ${about.twins}` : p,
);
const hasPhoto = Boolean(photo);
return (
<div data-theme="light" className="relative bg-transparent text-fg">
<Rule variant="gold" />
<Section
id={about.id}
theme="light"
numeral={about.numeral}
label={about.eyebrow}
className="py-[clamp(4.5rem,10vh,8rem)] md:py-[clamp(7rem,16vh,14rem)]"
>
{hasPhoto && (
<div className="col-span-12 mb-14 lg:col-span-4 lg:col-start-3 lg:mb-0">
<Portrait
src={photo}
alt={about.alt}
aspect="4/5"
caption={about.caption}
sticky
parallax
sizes="(min-width: 1024px) 30vw, 100vw"
className="w-full max-w-[48svh] lg:max-w-none"
/>
</div>
)}
<div className={cn("col-span-12 lg:row-start-1", hasPhoto ? "lg:col-span-6 lg:col-start-7" : "lg:col-span-6 lg:col-start-3")}>
<Eyebrow className="mb-8">{about.eyebrow}</Eyebrow>
<Lines as="h2" className={H2}>
{about.h2.before}
{about.h2.em && <em className="italic">{about.h2.em}</em>}
{about.h2.after}
</Lines>
<div className="mt-10 flex flex-col gap-[1.25em]">
{paragraphs.map((text, i) => (
<Reveal key={i} as="p" delay={i * 0.08} className={BODY}>
{text}
</Reveal>
))}
</div>
</div>
{/* Werdegang ledger: cols 7–12 beside the photo, cols 3–9 without. */}
<div className={cn("col-span-12 mt-12 lg:row-start-2 lg:mt-16", hasPhoto ? "lg:col-span-6 lg:col-start-7" : "lg:col-span-7 lg:col-start-3")}>
<Ledger variant="werdegang" rows={about.werdegang} stagger={0.08} />
</div>
{/* No monogram vignette in variant B: the WOLF-Prinzip closer directly
above already ends on a full-height mark, and section 04 now carries
the sticky Bodoni numeral it was missing – a third W in the same
viewport turned the signature into wallpaper. */}
<Reveal
className={cn(
"col-span-12 mt-12",
hasPhoto ? "lg:col-span-3 lg:col-start-10 lg:row-start-2" : "lg:col-span-7 lg:col-start-3 lg:row-start-3",
)}
>
<Marginalia items={about.marginalia} />
</Reveal>
</Section>
</div>
);
}
export default About;
+57
View File
@@ -0,0 +1,57 @@
import { audience as audienceDe, type HomeContent } from "@/content/home";
import { Section } from "@/components/layout/Section";
import { Lines } from "@/components/motion/Lines";
import { Rule } from "@/components/motion/Rule";
import { Columns } from "@/components/ui/Columns";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { Ledger } from "@/components/ui/Ledger";
/* H2 on ivory: Bodoni 400, max 16ch (spec §3.2). */
const H2 =
"font-display text-[clamp(2.25rem,1.4rem+3.2vw,4.75rem)] font-normal leading-[1.02] tracking-[-0.01em] text-fg [text-wrap:balance] max-w-[16ch]";
/**
* 05 · Für wen + Termine (ivory, Termine block on bone – spec §6.6).
* Three editorial columns with the middle one offset 48 px downward, then the
* verifiable dates ledger (static gold dot, no pulse). „Stimmen“ ships only
* with approved quotes (`flags.showStimmen`, currently off – nothing to render).
*/
export interface AudienceProps {
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
content?: HomeContent["audience"];
}
export function Audience({ content: audience = audienceDe }: AudienceProps = {}) {
const termine = audience.termine;
return (
<div data-theme="light" className="relative bg-transparent text-fg">
<Rule variant="line" />
<Section
id={audience.id}
theme="light"
numeral={audience.numeral}
label={audience.eyebrow}
className="py-[clamp(4rem,9vh,7rem)] md:py-[clamp(6rem,14vh,12rem)]"
>
<div className="col-span-12 lg:col-span-7 lg:col-start-3 lg:row-start-1">
<Eyebrow className="mb-8">{audience.eyebrow}</Eyebrow>
<Lines as="h2" className={H2}>
{audience.h2.before}
{audience.h2.em && <em className="italic">{audience.h2.em}</em>}
{audience.h2.after}
</Lines>
</div>
<div className="col-span-12 mt-12 lg:col-span-10 lg:col-start-3 lg:row-start-2 lg:mt-16">
<Columns items={audience.columns} offsetMiddle />
</div>
<div id={termine.id} className="col-span-12 mt-16 scroll-mt-24 lg:col-span-10 lg:col-start-3 lg:mt-24">
<Ledger variant="termine" rows={termine.rows} heading={termine.heading} />
</div>
</Section>
</div>
);
}
export default Audience;
+320
View File
@@ -0,0 +1,320 @@
"use client";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { flags } from "@/lib/site";
import { gsap, MM } from "@/lib/gsap";
import { splitPlaceholders } from "@/lib/legal";
import { hero as heroDe, type HomeContent } from "@/content/home";
import { Section } from "@/components/layout/Section";
import { Folio } from "@/components/layout/Folio";
import { Lines } from "@/components/motion/Lines";
import { Reveal } from "@/components/motion/Reveal";
import { Door } from "@/components/motion/Door";
import { Magnet } from "@/components/motion/Magnet";
import { DrawMark } from "@/components/brand/DrawMark";
import { Button } from "@/components/ui/Button";
import { PlaceholderText } from "@/components/ui/Facts";
export interface HeroProps {
/** `<BrandMark idPrefix="hero" handoff/>` – Server Component, rendered by page.tsx. */
mark: ReactNode;
/** `getPhotos().hero` – `null` renders the documented variant B (monogram is the image). */
photo: string | null;
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
content?: HomeContent["hero"];
}
/* Load choreography (spec §6.1), seconds from mount. Masthead hairlines 0 / 0.15 are
Masthead's own; the folio hairline waits for the neck stroke (draw 0.7 + handoff 1.2–1.4). */
const T = { lines: 0.3, door: 0.5, draw: 0.7, cta: 1.2, folio: 2.0 } as const;
/** Fallback length of the handoff hairline until it is measured (px). */
const HANDOFF = 40;
const MONO_11 = "font-mono text-[11px] tracking-[0.06em]";
/**
* Handoff x for `<Rule origin={x}/>`, measured from the folio's left edge.
* DrawMark writes `--handoff-x` on its wrapper (fallback WolfMark); a client
* SVG with `path#handoff` is measured at the path's end point.
*/
function measureHandoffX(markWrap: HTMLElement, folioEl: HTMLElement): number | null {
const folioLeft = folioEl.getBoundingClientRect().left;
const drawWrap = markWrap.querySelector<HTMLElement>("[style*='--handoff-x']");
if (drawWrap) {
const v = parseFloat(drawWrap.style.getPropertyValue("--handoff-x"));
if (!Number.isNaN(v)) return drawWrap.getBoundingClientRect().left + v - folioLeft;
}
const path = markWrap.querySelector<SVGPathElement>("path.wolfmark-handoff");
const ctm = path?.ownerSVGElement?.getScreenCTM();
if (path && ctm) {
const end = path.getPointAtLength(path.getTotalLength());
const sp = new DOMPoint(end.x, end.y).matrixTransform(ctm);
return sp.x - folioLeft;
}
return null;
}
/**
* Samples the mark's outline in SVG user space – once. The geometry never
* changes, only the CTM does, so a resize re-projects these points instead of
* walking `getPointAtLength` again (~500 forced geometry calls per event).
*/
function sampleMarkPoints(markWrap: HTMLElement): { x: number; y: number }[] {
const svg = markWrap.querySelector("svg");
if (!svg) return [];
const pts: { x: number; y: number }[] = [];
svg.querySelectorAll<SVGPathElement>("path").forEach((path) => {
let len = 0;
try {
len = path.getTotalLength();
} catch {
return;
}
if (!len) return;
for (let i = 0; i <= 60; i++) {
const p = path.getPointAtLength((len * i) / 60);
pts.push({ x: p.x, y: p.y });
}
});
return pts;
}
/**
* Screen y of the mark's lowest painted point – the V vertex the handoff
* hairline hangs from. The SVG box bottom is ~5 % below it (viewBox padding),
* so a fixed length always stopped short of the folio rule.
*/
function markBottomY(markWrap: HTMLElement, pts: { x: number; y: number }[]): number | null {
const svg = markWrap.querySelector("svg");
const ctm = svg?.getScreenCTM();
if (!svg || !ctm || !pts.length) return null;
let maxY = -Infinity;
for (const p of pts) {
const y = new DOMPoint(p.x, p.y).matrixTransform(ctm).y;
if (y > maxY) maxY = y;
}
return Number.isFinite(maxY) ? maxY : null;
}
/**
* Cover (ink, ≥ 100svh). Rows: (leer) · 1fr · H1 · Subline + CTA · Folio.
* Die Mastkopf-Zeile wurde am 06.09.2026 auf Kundenwunsch gestrichen; die erste
* Rasterzeile bleibt leer, damit die übrigen Zeilenzuweisungen unverändert gelten.
* Variant A (photo): portrait cols 8–12 bleeding to the page edge with scrims,
* monogram 28vh in cols 6–8 overlapping its left edge, neck handoff into the
* folio hairline, which draws outward from that x. Variant B (no photo):
* monogram 56vh cols 7–12, right-aligned, same handoff. Mobile: masthead,
* monogram 14svh, H1, CTAs, portrait 3:4 (≤ 48svh) – no folio row (it costs
* 69 px the phone cover does not have; Positionierung carries „01 / 06“).
*/
export function Hero({ mark, photo, content: hero = heroDe }: HeroProps) {
const root = useRef<HTMLDivElement>(null);
const h1 = useRef<HTMLDivElement>(null);
const markWrap = useRef<HTMLDivElement>(null);
const folio = useRef<HTMLDivElement>(null);
const [origin, setOrigin] = useState<number | null>(null);
const [handoffLength, setHandoffLength] = useState(HANDOFF);
const hasPhoto = Boolean(photo);
/* Handoff x → folio rule origin, and the exact gap from the mark's lowest
point down to that rule, so the neck stroke actually reaches the hairline
instead of ending as a stub ~25 px above it (desktop only; on mobile the
mark sits above the H1). */
useLayoutEffect(() => {
let pts: { x: number; y: number }[] | null = null;
const measure = () => {
const m = markWrap.current;
const f = folio.current;
if (!m || !f || !window.matchMedia("(min-width: 1024px)").matches) return;
const x = measureHandoffX(m, f);
if (x !== null && x > 0) setOrigin(Math.round(x));
if (!pts) pts = sampleMarkPoints(m);
const bottom = markBottomY(m, pts);
if (bottom !== null) {
const gap = f.getBoundingClientRect().top - bottom;
setHandoffLength(Math.round(Math.min(72, Math.max(16, gap))));
}
};
measure();
/* One measurement per frame: a window drag fires `resize` dozens of times a second
and each pass forces layout for the whole monogram. */
let raf = 0;
const onResize = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
measure();
});
};
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
if (raf) cancelAnimationFrame(raf);
};
}, []);
/* Scroll: headline drifts up 6 % over the hero (desktop, no reduced motion). */
useLayoutEffect(() => {
const el = root.current;
const head = h1.current;
if (!el || !head) return;
const ctx = gsap.context(() => {
const mm = gsap.matchMedia();
mm.add({ desktop: MM.desktop, ok: MM.ok }, (c) => {
if (!c.conditions?.desktop || !c.conditions?.ok) return;
gsap.to(head, {
yPercent: -6,
ease: "none",
scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: 0.6 },
});
});
}, el);
return () => ctx.revert();
}, []);
const captionHasPlaceholder = splitPlaceholders(hero.caption).some((s) => s.placeholder);
const showCaption = !captionHasPlaceholder || flags.showPlaceholders;
return (
<Section
id={hero.id}
theme="dark"
className="grid min-h-svh pt-[calc(var(--header-h,7.5rem)+0.75rem)] pb-0 lg:pt-[calc(var(--header-h,7.5rem)+0.5rem)] [&>div]:grid"
>
{/* From lg the FOURTH row (subline + CTA) is the flexible one, not the second:
the slack has to sit between the CTA block and the folio, otherwise a
shorter CTA row pushes the headline down into the monogram. */}
<div
ref={root}
className="col-span-12 grid grid-cols-12 grid-rows-[auto_auto_auto_auto_auto_auto] gap-x-[clamp(16px,2vw,32px)] lg:grid-rows-[auto_auto_auto_1fr_auto]"
>
{/* Portrait – variant A. Desktop: cols 8–12, rows 2–4, bleeds to the page edge. */}
{photo && (
<div className="relative z-0 col-span-12 row-start-5 mt-10 lg:col-span-5 lg:col-start-8 lg:row-start-2 lg:row-end-5 lg:mt-0 lg:-mr-[clamp(20px,5vw,80px)]">
<div className="relative h-full">
<Door
src={photo}
alt={hero.alt}
width={1600}
height={2000}
sizes="(min-width: 1024px) 45vw, 100vw"
priority
parallax
play="load"
delay={T.door}
className="max-h-[48svh] w-full lg:h-full lg:max-h-none"
imgClassName="[filter:sepia(.12)_saturate(.9)_contrast(1.02)]"
/>
{/* Scrims: top third + left edge (headline legibility). */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-[linear-gradient(var(--ink)_0%,transparent_45%)]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 hidden bg-[linear-gradient(90deg,var(--ink)_0%,rgba(15,14,12,0.72)_30%,transparent_60%)] lg:block"
/>
{showCaption && (
<p className={cn(MONO_11, "absolute right-4 bottom-4 text-ivory/80")}>
<PlaceholderText text={hero.caption} />
</p>
)}
</div>
</div>
)}
{/* Monogram: bottom-aligned to the folio hairline; the neck hands off into it. */}
<div
ref={markWrap}
className={cn(
"relative z-[1] col-span-12 row-start-2 mt-6 h-[14svh] text-ivory lg:row-start-2 lg:row-end-5 lg:mt-0 lg:mb-4 lg:self-end",
"max-lg:[&_span[aria-hidden=true]]:hidden",
/* WolfMark's 7-unit stroke is ~18 px at hero size – far from the
spec's hairline mark. Overridden here, not in WolfMark.tsx. */
"[&_.wolfmark-v]:[stroke-width:3] [&_.wolfmark-wolf]:[stroke-width:2.6]",
hasPhoto
? "lg:col-span-3 lg:col-start-6 lg:h-[28vh] lg:justify-self-start"
: "lg:col-span-6 lg:col-start-7 lg:h-[min(45vh,24vw)] lg:justify-self-end",
)}
>
<DrawMark
play="load"
sheen
handoff
handoffLength={handoffLength}
delay={T.draw}
className="block h-full [&_svg]:h-full [&_svg]:w-auto"
>
{mark}
</DrawMark>
</div>
{/* H1 */}
<div
ref={h1}
className="relative z-[2] col-span-12 row-start-3 mt-6 lg:col-span-8 lg:col-start-1 lg:row-start-3 lg:mt-0 lg:self-end"
>
{/* The H1 is capped by viewport HEIGHT as well as width: `26vh - 108px`
is neutral at 1440×900 (the cover's reference frame) and pulls the
three Bodoni lines back on 800/768 px laptops, where the vw term
alone grows faster than the fold shrinks. */}
<Lines
as="h1"
play="load"
delay={T.lines}
stagger={0.09}
className="max-w-[12ch] font-display text-[clamp(3rem,min(1.4rem_+_7.4vw,26vh_-_108px),8rem)] font-normal leading-[0.94] tracking-[-0.015em] text-ivory [text-wrap:balance]"
>
{hero.h1.before}
<em className="italic">{hero.h1.em}</em>
{hero.h1.after}
</Lines>
</div>
{/* Subline + CTA. Vertical rhythm from lg is viewport-relative so the cover
keeps fitting `min-h-svh` at 768–900 px viewport heights; the row runs
cols 1–8 below 1440 so button + text link stay on one line there too. At
1440×900 – the cover's reference frame – the original 24/32 px rhythm is
restored, which also keeps the headline clear of the monogram. */}
<div className="relative z-[2] col-span-12 row-start-4 mt-6 pb-2 lg:col-span-6 lg:col-start-1 lg:row-start-4 lg:mt-[2.2vh] lg:pb-[3vh] wide:mt-6 wide:pb-8">
<Reveal play="load" delay={T.cta} as="p" className="max-w-[38ch] font-sans text-[clamp(1.125rem,min(1.1rem_+_0.6vw,3.1vh),1.5rem)] font-light leading-[1.45] text-fg-muted wide:max-w-[46ch]">
{hero.subline}
</Reveal>
<Reveal play="load" delay={T.cta + 0.1} className="mt-6 flex flex-wrap items-start gap-x-8 gap-y-4 lg:mt-[2.2vh] wide:mt-6">
<Magnet>
{/* The hover suffix („( 30 Min · kostenfrei )“) must NOT reserve its
width in flow – it added ~190 px of empty outline and pushed the
text link onto its own line. It grows out of the masked cell on
hover/focus instead (spec §6.1: button + text link, one row). */}
<Button
href={hero.cta.href}
label={hero.cta.label}
suffix={hero.cta.suffix}
className="[&>span[aria-hidden]]:max-w-0 [&>span[aria-hidden]]:transition-[max-width] [&>span[aria-hidden]]:duration-[350ms] [&>span[aria-hidden]]:ease-[var(--ease-out-quart)] hover:[&>span[aria-hidden]]:max-w-[16rem] focus-visible:[&>span[aria-hidden]]:max-w-[16rem] motion-reduce:[&>span[aria-hidden]]:transition-none"
/>
</Magnet>
</Reveal>
</div>
{/* Folio: hairline draws outward from the handoff x, „01 / 06“ · „Positionierung ↓“.
Hidden below lg – the folio row costs 69 px that the phone cover does not have,
and Positionierung directly below carries its own „01 / 06“ mobile counter. */}
<div ref={folio} className="col-span-12 row-start-6 mt-6 hidden lg:block lg:row-start-5 lg:mt-0">
<Folio
current={hero.folio.current}
total={hero.folio.total}
next={{ label: hero.folio.next.label, href: `/${hero.folio.next.href}` }}
origin={origin ?? "left"}
play="load"
delay={T.folio}
/>
</div>
</div>
</Section>
);
}
export default Hero;
+81
View File
@@ -0,0 +1,81 @@
import { cn } from "@/lib/utils";
import { positioning as positioningDe, type HomeContent } from "@/content/home";
import { Section } from "@/components/layout/Section";
import { Lines } from "@/components/motion/Lines";
import { Reveal, RevealGroup, RevealItem } from "@/components/motion/Reveal";
import { Rule } from "@/components/motion/Rule";
import { Eyebrow } from "@/components/ui/Eyebrow";
/** H2 on ivory: Playfair 400, 36 → 76 px, one italic word max. */
export const H2 =
"max-w-[16ch] font-display text-[clamp(2.25rem,1.4rem+3.2vw,4.75rem)] font-normal leading-[1.02] tracking-[-0.01em] text-fg [text-wrap:balance]";
const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums";
/**
* 01 · Positionierung (ivory, spec §6.2). Numeral cols 1–2 (sticky), eyebrow +
* H2 + editorial paragraph + the three question rows cols 3–9
* cols 4–9. Below 1024 px everything stacks. Die Marginalien-Spalte wurde am
* 06.09.2026 auf Kundenwunsch gestrichen; die Außenspalte bleibt bewusst leer.
*/
export interface PositioningProps {
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
content?: HomeContent["positioning"];
}
export function Positioning({ content: positioning = positioningDe }: PositioningProps = {}) {
const { numeral, eyebrow, h2, paragraph, rows } = positioning;
return (
<Section
id={positioning.id}
theme="light"
numeral={numeral}
label={eyebrow}
className="py-[clamp(72px,10vh,140px)] md:py-[clamp(112px,16vh,224px)]"
>
<div className="col-span-12 lg:col-span-7 lg:col-start-3 lg:row-start-1">
<Reveal>
<Eyebrow>{eyebrow}</Eyebrow>
</Reveal>
<Lines as="h2" className={cn("mt-8", H2)}>
{h2.before}
{h2.em && <em className="italic">{h2.em}</em>}
{h2.after}
</Lines>
{/* The three questions – hairline-separated rows. */}
<div className="mt-10 lg:mt-12">
<Rule />
<RevealGroup as="ol" stagger={0.1}>
{rows.map((row) => (
<RevealItem as="li" key={row.numeral}>
<div className="grid grid-cols-[3rem_1fr] items-baseline gap-x-6 gap-y-2 py-5 sm:grid-cols-[3rem_1fr_auto] sm:gap-x-8">
<span className={cn(MONO, "text-accent")}>{row.numeral}</span>
<span className="font-display text-[2rem] font-normal italic leading-[1.2] text-fg">
{row.question}
</span>
<span className="col-start-2 font-sans text-[1.0625rem] leading-snug text-fg-muted sm:col-start-3 sm:text-right">
{row.answer}
</span>
</div>
<Rule />
</RevealItem>
))}
</RevealGroup>
</div>
{/* Editorial paragraph – Jost 300 (spec A/D10: display type is for
headlines, numerals, formula and quotes only). */}
<Lines
as="p"
className="mt-12 max-w-[60ch] font-sans text-[clamp(1.375rem,1.1rem+1vw,1.75rem)] font-light leading-[1.5] text-fg"
>
{paragraph}
</Lines>
</div>
</Section>
);
}
export default Positioning;
+75
View File
@@ -0,0 +1,75 @@
import { contact as contactDe, type HomeContent } from "@/content/home";
import { Section } from "@/components/layout/Section";
import { Lines } from "@/components/motion/Lines";
import { Reveal } from "@/components/motion/Reveal";
import { Rule } from "@/components/motion/Rule";
import { ContactLinks } from "@/components/ui/ContactLinks";
import { Eyebrow, EYEBROW_TEXT } from "@/components/ui/Eyebrow";
import { ContactForm } from "@/components/ui/ContactForm";
/* H2 on ink: Bodoni, `clamp(2.75rem, 1.6rem + 4.4vw, 6rem)`, ivory (spec §6.7). */
const H2 =
"font-display text-[clamp(2.75rem,1.6rem+4.4vw,6rem)] font-normal leading-[1.0] tracking-[-0.01em] text-fg [text-wrap:balance] max-w-[14ch]";
/**
* 06 · Kontakt (ink). Ein Weg: links das Formular, daneben Mail, Telefon und
* LinkedIn als große Links, darunter der Abbinder. Der Footer (Wortmarke,
* letzte goldene Haarlinie) hängt global in layout.tsx.
*
* Der frühere Dreischritt (Kennenlerngespräch / Analyse / Umsetzung) und der
* separate CTA-Knopf wurden am 06.09.2026 auf Kundenwunsch durch das Formular
* ersetzt; der Haupt-CTA der Seite springt jetzt hierher.
*/
export interface ProcessContactProps {
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
content?: HomeContent["contact"];
/** Ziel des Datenschutz-Links im Formular. */
privacyHref?: string;
}
export function ProcessContact({ content: contact = contactDe, privacyHref }: ProcessContactProps = {}) {
return (
<div data-theme="dark" className="relative bg-transparent text-fg">
<Rule variant="gold" />
<Section
id={contact.id}
theme="dark"
numeral={contact.numeral}
label={contact.eyebrow}
className="pt-[clamp(5rem,11vh,9rem)] pb-[clamp(3.5rem,7vh,6rem)] md:pt-[clamp(8rem,18vh,15rem)] md:pb-[clamp(5rem,10vh,8rem)]"
>
{/* 06 resolves to `.gold-text` – the section ground is dark. */}
<div className="col-span-12 lg:col-span-9 lg:col-start-3 xl:col-span-7">
<Eyebrow className="mb-8">{contact.eyebrow}</Eyebrow>
<Lines as="h2" className={H2}>
{contact.h2.before}
{contact.h2.em && <em className="italic">{contact.h2.em}</em>}
{contact.h2.after}
</Lines>
</div>
{/* Formular: cols 3–12 bis 1280, cols 3–8 neben dem Kontaktblock ab xl.
Es verschickt nichts selbst, sondern übergibt eine fertige E-Mail an das
Mailprogramm – siehe den Kopf von ContactForm.tsx. */}
<Reveal className="col-span-12 mt-12 lg:col-span-10 lg:col-start-3 lg:mt-14 xl:col-span-6">
<ContactForm content={contact.form} privacyHref={privacyHref} />
</Reveal>
{/* Contact block: under the Ablauf up to 1280 (the CTA button is 354 px
wide and overflowed the 4-column cell at 1024), cols 9–12 sticky from xl. */}
<div className="col-span-12 mt-12 lg:col-span-10 lg:col-start-3 lg:mt-14 xl:col-span-4 xl:col-start-9 xl:mt-14">
<div className="flex flex-col gap-12 xl:sticky xl:top-[30vh]">
<ContactLinks content={contact.links} />
</div>
</div>
{/* Sign-off above the colophon (the Footer follows globally). */}
<Reveal as="p" className={`col-span-12 mt-20 lg:col-span-10 lg:col-start-3 lg:mt-32 ${EYEBROW_TEXT} text-gold`}>
{contact.signoff}
</Reveal>
</Section>
</div>
);
}
export default ProcessContact;
+60
View File
@@ -0,0 +1,60 @@
import { cn } from "@/lib/utils";
import { services as servicesDe, type HomeContent } from "@/content/home";
import { Section } from "@/components/layout/Section";
import { Lines } from "@/components/motion/Lines";
import { Reveal } from "@/components/motion/Reveal";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { Ledger } from "@/components/ui/Ledger";
import { H2 } from "./Positioning";
/**
* 02 · Leistungen (ivory, spec §6.3). No theme boundary – a `--line` hairline
* separates it from Positionierung. Numeral cols 1–2, eyebrow + H2 cols 3–8,
* the three services as a table-of-contents ledger cols 3–12 (each row is one
* <TransitionLink> → Umblättern).
*/
export interface ServicesIndexProps {
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
content?: HomeContent["services"];
}
export function ServicesIndex({ content: services = servicesDe }: ServicesIndexProps = {}) {
const { numeral, eyebrow, h2, rows } = services;
return (
<Section
id={services.id}
theme="light"
numeral={numeral}
label={eyebrow}
className="border-t border-line py-[clamp(64px,9vh,120px)] md:py-[clamp(96px,14vh,192px)]"
>
<div className="col-span-12 lg:col-span-6 lg:col-start-3 lg:row-start-1">
<Reveal>
<Eyebrow>{eyebrow}</Eyebrow>
</Reveal>
<Lines as="h2" className={cn("mt-8", H2)}>
{h2.before}
{h2.em && <em className="italic">{h2.em}</em>}
{h2.after}
</Lines>
</div>
<div className="col-span-12 mt-12 lg:col-span-10 lg:col-start-3 lg:row-start-2 lg:mt-16">
{/* The nowrap „ZU DEN KEYNOTES →“ needs ~182 px, but Ledger's default link
cell is two of twelve sub-columns: 171 px at 1280 (the label overprinted
the one-liner) and 189 px even at 1440 (2–4 px of air). Re-balanced here
to one-liner 4 / link 3 at every width from lg – ~116 px of clear gutter
at 1440. The same default belongs in Ledger.tsx' toc row. */}
<Ledger
variant="toc"
rows={rows}
stagger={0.12}
className="lg:[&_a>span:nth-of-type(2)]:col-span-4 lg:[&_a>span:nth-of-type(3)]:col-span-3"
/>
</div>
</Section>
);
}
export default ServicesIndex;
+52
View File
@@ -0,0 +1,52 @@
import type { ReactNode } from "react";
import { Rule } from "@/components/motion/Rule";
import { Formula } from "@/components/ui/Formula";
import { principle as principleDe, type HomeContent } from "@/content/home";
export interface WolfPrincipleProps {
/** `<BrandMark idPrefix="prinzip"/>` (Server Component) – drawn scrubbed 70–95 % on desktop. */
mark: ReactNode;
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
content?: HomeContent["principle"];
}
/**
* 03 · Das WOLF-Prinzip (ink, the page's only pin – spec §6.4).
*
* `<Formula/>` from the UI contract already IS the section: it renders
* `<section id data-theme="dark">` with Container, 12-column grid, the
* `.gold-text` numeral in cols 1–2, eyebrow, the three formula lines with
* definitions, the sign-off, the text link and the monogram. Wrapping it in a
* second `<Section/>` would nest two sections/containers (double page
* margin, duplicate id), so this component owns the ink boundary instead:
* the entering `--gold-hairline` (spec §2.3) plus the themed wrapper that
* <ThemeTween/> reads the palette from (Formula's own section is a nested
* island and is ignored by the tween – same pattern as <ServiceLayout/>).
*/
export function WolfPrinciple({ mark, content: principle = principleDe }: WolfPrincipleProps) {
return (
<div data-theme="dark" className="relative bg-transparent text-fg">
<Rule variant="gold" />
{/* Hanging operators: „Starke Persönlichkeit“ has no +/=, so it used to sit
22 px left of the two terms below it. Every line now gets a 0.62 em
operator cell (= 1.24 em of the 0.5 em operator) and the same padding,
so all three terms share one left edge and the +/= hang in the gutter.
From lg the line box is pulled back by that amount, so the terms stay
flush with the eyebrow and the sign-off in column 3. The same rule
belongs in Formula.tsx; here it comes in via the section className. */}
<Formula
className="[&_[data-f=line]]:pl-[0.62em] lg:[&_[data-f=line]]:-ml-[0.62em] [&_[data-f=op]]:-ml-[1.24em] [&_[data-f=op]]:mr-0 [&_[data-f=op]]:w-[1.24em]"
id={principle.id}
numeral={principle.numeral}
eyebrow={principle.eyebrow}
lines={principle.lines}
opLabels={principle.opLabels}
meta={`${principle.numeral} / 06`}
link={principle.link}
mark={mark}
/>
</div>
);
}
export default WolfPrinciple;
+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface ContainerProps {
className?: string;
children: ReactNode;
}
/** Page frame: max-width 1680 px, page margin clamp(20px, 5vw, 80px). */
export function Container({ className, children }: ContainerProps) {
return <div className={cn("mx-auto w-full max-w-[105rem] px-[clamp(20px,5vw,80px)]", className)}>{children}</div>;
}
export default Container;
+44
View File
@@ -0,0 +1,44 @@
import { cn } from "@/lib/utils";
import { Rule, type RuleProps } from "@/components/motion/Rule";
import { TransitionLink } from "@/components/motion/TransitionLink";
export interface FolioProps {
/**
* „01“ – only where the folio IS the page's counter (the home hero counts the
* six sections). Service pages omit it: their sections carry their own
* „01 / 06“ and a second, differently-denominated fraction 150 px away reads
* as a bug, not as a folio.
*/
current?: string;
/** „06“ */
total?: string;
/** „Positionierung“ → `#positionierung` */
next?: { label: string; href: string };
className?: string;
/** Passed to the hairline – the hero draws it outward from the handoff x. */
origin?: RuleProps["origin"];
play?: RuleProps["play"];
delay?: number;
}
/** Gold hairline + 44 px row: „01 / 06“ left, „Positionierung ↓“ right. */
export function Folio({ current, total, next, className, origin = "left", play = "view", delay }: FolioProps) {
return (
<div className={cn("w-full", className)}>
<Rule variant="gold" origin={origin} play={play} delay={delay} />
<div className="flex min-h-11 flex-wrap items-center justify-between gap-x-6 gap-y-1 py-1 font-mono text-xs tracking-[0.06em] tabular-nums text-fg-muted">
<span>{current && total ? `${current} / ${total}` : null}</span>
{next && (
<TransitionLink
href={next.href}
className="-my-3.5 inline-block py-3.5 text-fg transition-colors duration-300 hover:text-accent focus-visible:text-accent"
>
{next.label} <span aria-hidden="true">↓</span>
</TransitionLink>
)}
</div>
</div>
);
}
export default Folio;
+111
View File
@@ -0,0 +1,111 @@
import { legal as legalDe, site } from "@/lib/site";
import { routesByLang, type Lang } from "@/lib/i18n";
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
import { BrandMark } from "@/components/brand/BrandMark";
import { Wordmark } from "@/components/brand/Wordmark";
import { Rule } from "@/components/motion/Rule";
import { TransitionLink } from "@/components/motion/TransitionLink";
import { Container } from "./Container";
import { GRID } from "./Section";
const heading = "mb-5 font-mono text-[11px] uppercase tracking-[0.12em] text-grey-warm";
/* -my-2.5/py-2.5 keeps the visual rhythm and lifts the tap target to 40 px. */
const link =
"-my-2.5 inline-block py-2.5 font-sans text-sm text-fg transition-colors duration-300 hover:text-gold focus-visible:text-gold";
/**
* Server Component, identical on every page. Paints its own ink ground
* (`data-theme="dark"`, globals.css), gold hairline on top, wordmark +
* two columns, bottom row with copyright · legal · claim (desktop).
*/
export interface FooterProps {
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
chrome?: Chrome;
/** Impressum/Datenschutz der jeweiligen Sprache. */
legal?: readonly { href: string; label: string }[];
/** Aktuelle Sprache – bestimmt das Ziel der Wortmarke. */
lang?: Lang;
}
export function Footer({ chrome = chromeDe, legal = legalDe, lang = "de" }: FooterProps = {}) {
const year = new Date().getFullYear();
return (
/* The legal links are the last thing on a phone – keep them clear of the home indicator
and the browser's bottom bar (the row's own padding is eaten by the link's -my-3). */
<footer data-theme="dark" className="relative pb-[max(2.5rem,env(safe-area-inset-bottom))] text-fg lg:pb-0">
<Rule variant="gold" />
<Container>
<div className={`${GRID} gap-y-12 py-16 lg:py-24`}>
<div className="col-span-12 lg:col-span-7 lg:pr-8">
<Wordmark size="footer" href={routesByLang[lang].home} mark={<BrandMark className="h-10 w-auto" idPrefix="ftr" />} chrome={chrome} />
</div>
<nav aria-label={chrome.footer.inhaltHeading} className="col-span-6 lg:col-span-3">
<h2 className={heading}>{chrome.footer.inhaltHeading}</h2>
<ul className="flex flex-col gap-5">
{chrome.footer.inhalt.map((item) => (
<li key={item.href}>
<TransitionLink href={item.href} label={item.label} className={link}>
{item.label}
</TransitionLink>
</li>
))}
</ul>
</nav>
<div className="col-span-6 lg:col-span-2">
<h2 className={heading}>{chrome.footer.kontaktHeading}</h2>
<ul className="flex flex-col gap-5">
<li>
<a href={`mailto:${site.email}`} className={link}>
{site.email}
</a>
</li>
<li>
<a href={site.phoneHref} className={`${link} font-mono tabular-nums`}>
{site.phoneDisplay}
</a>
</li>
<li>
<a href={site.linkedin} target="_blank" rel="noopener noreferrer" className={link}>
{chrome.footer.linkedin} <span aria-hidden="true">↗</span>
</a>
</li>
<li>
<a href={chrome.footer.hercareer.href} target="_blank" rel="noopener noreferrer" className={link}>
{chrome.footer.hercareer.label} <span aria-hidden="true">↗</span>
</a>
</li>
</ul>
</div>
</div>
<div className="flex min-h-12 flex-wrap items-center justify-between gap-x-8 gap-y-3 border-t border-line-dark py-3 font-mono text-[11px] tracking-[0.04em] text-grey-warm">
<span>
© {year} {site.name} · {chrome.footer.copyrightSuffix}
</span>
<span>
{legal.map((l, i) => (
<span key={l.href}>
{i > 0 && <span aria-hidden="true"> · </span>}
<TransitionLink
href={l.href}
label={l.label}
className="-my-3 inline-block py-3 transition-colors duration-300 hover:text-gold"
>
{l.label}
</TransitionLink>
</span>
))}
</span>
{/* The Kontakt sign-off already closes the page on phones – no second claim there. */}
<span className="hidden font-sans text-[11px] font-medium uppercase tracking-[0.28em] text-gold lg:inline">
{chrome.footer.claim}
</span>
</div>
</Container>
</footer>
);
}
export default Footer;
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { flags, legal as legalDe, nav as navDe } from "@/lib/site";
import { routesByLang, type Lang } from "@/lib/i18n";
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
import { Wordmark } from "@/components/brand/Wordmark";
import { TransitionLink } from "@/components/motion/TransitionLink";
import { subscribe } from "@/components/motion/transition-store";
import { Container } from "./Container";
import { MENU_TOGGLE_ID, OverlayMenu } from "./OverlayMenu";
import { LangSwitch } from "./LangSwitch";
export interface HeaderProps {
/** The 28 px monogram – layout.tsx passes `<BrandMark/>` (Server Component). */
mark?: ReactNode;
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
chrome?: Chrome;
/** Die drei Leistungslinks der jeweiligen Sprache. */
nav?: readonly { href: string; label: string }[];
/** Aktuelle Sprache – steuert den Umschalter und das Ziel der Wortmarke. */
lang?: Lang;
/** Impressum/Datenschutz der jeweiligen Sprache – für das Overlay-Menü. */
legal?: readonly { href: string; label: string }[];
}
const navText = "font-sans text-[0.75rem] uppercase tracking-[0.14em]";
const underline =
"relative after:absolute after:-bottom-1 after:left-0 after:h-px after:w-full after:origin-left after:scale-x-0 after:bg-accent after:transition-transform after:duration-300 hover:after:scale-x-100 focus-visible:after:scale-x-100 aria-[current=page]:after:scale-x-100";
function samePath(a: string, b: string) {
const n = (s: string) => (s.endsWith("/") ? s : `${s}/`);
return n(a) === n(b);
}
/**
* Fixed header, single row (the Masthead lives in each page hero).
* < 1024: Wordmark | „Menü“. 1024–1279: Wordmark | Sprachumschalter + „Menü“ (the
* three nav links need ~450 px and would collide with the wordmark).
* 1280–1439: Wordmark | 3 nav links (tighter caps) | one CTA – the „Kontakt“
* text link steps aside so the wordmark keeps its air and the CTA can carry
* the one label the site uses for this action (spec §5.1).
* ≥ 1440: Wordmark | 3 nav links | „Kontakt“ + outline CTA.
* Ink chrome (the page below breathes, the bar does not); after 80 px:
* 92 % ground + blur(8px) + hairline. While it is still transparent it reads
* on the page's first block, so on an ivory-first page (Impressum) globals.css
* flips it to ink type via `[data-scrolled]`. Writes `--header-h` on <html>.
*/
export function Header({ mark, chrome = chromeDe, nav = navDe, legal = legalDe, lang = "de" }: HeaderProps) {
const ref = useRef<HTMLElement>(null);
const pathname = usePathname();
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
let raf = 0;
const update = () => {
raf = 0;
setScrolled(window.scrollY > 80);
};
const onScroll = () => {
if (!raf) raf = requestAnimationFrame(update);
};
update();
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
window.removeEventListener("scroll", onScroll);
if (raf) cancelAnimationFrame(raf);
};
}, []);
useEffect(() => {
const el = ref.current;
if (!el) return;
const write = () => document.documentElement.style.setProperty("--header-h", `${el.offsetHeight}px`);
write();
const ro = new ResizeObserver(write);
ro.observe(el);
return () => {
ro.disconnect();
document.documentElement.style.removeProperty("--header-h");
};
}, []);
// Expose the open state on <html> (scroll-progress hides itself while the menu is open).
useEffect(() => {
document.documentElement.toggleAttribute("data-menu-open", open);
return () => document.documentElement.removeAttribute("data-menu-open");
}, [open]);
// Close the menu when a page transition starts (route changes are handled by OverlayMenu).
useEffect(() => subscribe((s) => s.phase === "cover" && setOpen(false)), []);
return (
<>
<header
ref={ref}
data-theme={open ? "light" : "dark"}
data-scrolled={scrolled ? "" : undefined}
className={cn(
"fixed inset-x-0 top-0 z-50 border-b text-fg transition-[background-color,border-color] duration-300",
scrolled && !open
? "border-line bg-[color-mix(in_srgb,var(--bg)_92%,transparent)] backdrop-blur-[8px]"
: "border-transparent bg-transparent",
)}
>
<Container>
<div className="grid h-[60px] grid-cols-12 items-center gap-x-[clamp(16px,2vw,32px)] lg:h-[72px]">
<div data-menu-inert="" className="col-span-6 flex min-w-0 items-center xl:col-span-3">
<Wordmark size="nav" href={routesByLang[lang].home} mark={mark} chrome={chrome} />
</div>
<nav data-menu-inert="" aria-label={chrome.header.navAriaLabel} className="hidden items-center justify-center gap-6 xl:col-span-5 xl:flex xl:gap-4 wide:gap-6 2xl:gap-8">
{nav.map((item) => (
<TransitionLink
key={item.href}
href={item.href}
label={item.label}
aria-current={samePath(pathname, item.href) ? "page" : undefined}
/* 1280–1439 the three labels need ~570 px in a 465 px cell – tighter caps buy it back. */
className={cn(navText, underline, "whitespace-nowrap xl:text-[0.6875rem] xl:tracking-[0.08em] wide:text-[0.75rem] wide:tracking-[0.14em]")}
>
{item.label}
</TransitionLink>
))}
</nav>
<div className="col-span-6 flex items-center justify-end gap-6 xl:col-span-4 2xl:gap-8">
{/* Steht an der Stelle, an der bis zum 06.09.2026 „Kontakt“ stand. */}
{flags.showLangSwitch && <LangSwitch lang={lang} className="hidden lg:flex" />}
<a
data-menu-inert=""
href={chrome.header.cta.href}
className="hidden whitespace-nowrap border border-gold bg-[image:var(--gold-sheen)] bg-[length:250%_100%] bg-[position:0%_50%] px-[1.4em] py-[1.05em] font-sans text-[0.75rem] font-medium uppercase leading-none tracking-[0.16em] text-ink transition-[background-position] duration-[600ms] ease-[var(--ease-out-quart)] hover:bg-[position:100%_50%] focus-visible:bg-[position:100%_50%] xl:inline-block 2xl:px-[1.6em] 2xl:tracking-[0.2em]"
>
{/* One label for this action everywhere – the hero button says the same (spec §5.1). */}
{chrome.header.cta.label}
</a>
{/* WCAG 2.5.3: the visible label stays „Menü“ in both states, so the accessible
name has to contain it – „Schließen“ alone made „Klick Menü“ impossible for
a speech-input user once the sheet was open. The STATE is on `aria-expanded`,
not on the name. The id makes the toggle part of the dialog's focus trap. */}
<button
id={MENU_TOGGLE_ID}
type="button"
aria-expanded={open}
aria-controls="menue"
onClick={() => setOpen((v) => !v)}
aria-label={open ? chrome.header.menu.close : chrome.header.menu.openLabel}
className="-mx-2 -my-3 flex items-center gap-3 px-2 py-3 font-sans text-[0.8125rem] font-medium uppercase tracking-[0.16em] text-fg xl:hidden"
>
<span aria-hidden="true">{chrome.header.menu.open}</span>
<span aria-hidden="true" className="font-mono text-base leading-none">
{open ? "×" : "+"}
</span>
</button>
</div>
</div>
</Container>
</header>
<OverlayMenu open={open} onClose={() => setOpen(false)} chrome={chrome} legal={legal} lang={lang} />
</>
);
}
export default Header;
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { counterpart, langSwitch, otherLang, type Lang } from "@/lib/i18n";
import { TransitionLink } from "@/components/motion/TransitionLink";
/**
* Sprachumschalter im Kopf. Steht an der Stelle, an der vorher „Kontakt“ stand.
*
* Zeigt beide Sprachen: die aktive als stiller Zustand, die andere als Link auf das
* Gegenstück derselben Seite (`counterpart()`), nicht pauschal auf die Startseite.
*
* Zur Flagge: Eine Flagge steht für ein Land, nicht für eine Sprache – deshalb steht
* neben ihr immer das Sprachkürzel, und der zugängliche Name nennt die Sprache
* ausgeschrieben. Wer die Flaggen weglassen will, entfernt hier zwei Zeilen.
*/
export function LangSwitch({ lang, className }: { lang: Lang; className?: string }) {
const pathname = usePathname() ?? "/";
const other = otherLang(lang);
const href = counterpart(pathname, other);
const base =
"inline-flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.14em] transition-colors duration-300";
return (
<div className={cn("flex items-center gap-2", className)}>
<span className={cn(base, "text-accent")} aria-current="true" title={langSwitch[lang].title}>
<Flag lang={lang} />
{langSwitch[lang].short}
</span>
<span aria-hidden="true" className="h-3 w-px bg-line" />
<TransitionLink
href={href}
label={langSwitch[other].label}
hrefLang={other}
aria-label={langSwitch[other].title}
className={cn(base, "-my-3 py-3 text-fg-muted hover:text-accent focus-visible:text-accent")}
>
<Flag lang={other} />
{langSwitch[other].short}
</TransitionLink>
</div>
);
}
/** 16 × 11 px, randlos, damit die Fahne neben 11-px-Versalien nicht dominiert. */
function Flag({ lang }: { lang: Lang }) {
const common = { width: 16, height: 11, viewBox: "0 0 16 11", "aria-hidden": true as const, className: "shrink-0" };
if (lang === "de") {
return (
<svg {...common}>
<rect width="16" height="11" fill="#0C0C0C" />
<rect y="3.667" width="16" height="3.667" fill="#DD0000" />
<rect y="7.334" width="16" height="3.666" fill="#FFCE00" />
</svg>
);
}
return (
<svg {...common}>
<rect width="16" height="11" fill="#012169" />
<path d="M0 0 16 11M16 0 0 11" stroke="#FFF" strokeWidth="2.2" />
<path d="M0 0 16 11M16 0 0 11" stroke="#C8102E" strokeWidth="1.1" />
<path d="M8 0v11M0 5.5h16" stroke="#FFF" strokeWidth="3.7" />
<path d="M8 0v11M0 5.5h16" stroke="#C8102E" strokeWidth="2.2" />
</svg>
);
}
export default LangSwitch;
+56
View File
@@ -0,0 +1,56 @@
import { cn } from "@/lib/utils";
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
import { Rule } from "@/components/motion/Rule";
export interface MastheadProps {
/** Centre slot, e.g. „Sales Mentoring & Potential Development“. */
center: string;
className?: string;
/** Draw the hairlines on mount (hero load choreography) instead of static. */
draw?: boolean;
delay?: number;
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
chrome?: Chrome;
}
const caps = "font-sans text-[0.72rem] font-medium uppercase leading-none tracking-[0.28em] max-sm:text-[0.66rem] max-sm:tracking-[0.2em]";
/**
* Engraved stationery: three slots in ONE register (Jost 500 caps) between two
* gold hairlines – role (accent, always the dominant line) | page name /
* centre (muted) | „Deutsch & Englisch“ (muted). The language slot used to be
* 12 px DM Mono and therefore optically louder than the 10.5 px tracked role.
*
* ≥ 1280 all three sit in one row; the centre shrinks to 0.62 rem so the
* 37-character home line („Sales Mentoring & Potential Development“) never
* wraps inside the 44 px row. 640–1279 the centre drops to its own second
* row; below 640 the language slot goes (it is the least load-bearing of the
* three) so the page name keeps the orientation the masthead is there for.
*/
export function Masthead({ center, className, draw = false, delay = 0, chrome = chromeDe }: MastheadProps) {
const rule = (extra: number) =>
draw ? <Rule variant="gold" play="load" delay={delay + extra} /> : <div aria-hidden="true" className="rule-gold" />;
return (
<div className={cn("w-full", className)}>
{rule(0)}
<div className="grid min-h-11 grid-cols-1 items-center gap-x-4 gap-y-2 py-3 sm:grid-cols-[1fr_auto] xl:grid-cols-3 xl:gap-y-0 xl:py-0">
<span className={cn(caps, "text-accent xl:order-1")}>{chrome.masthead.role}</span>
<span className={cn(caps, "hidden text-fg-muted sm:block sm:justify-self-end xl:order-3")}>
{chrome.masthead.languages}
</span>
<span
className={cn(
caps,
"leading-[1.5] text-fg-muted sm:col-span-2 xl:order-2 xl:col-span-1 xl:whitespace-nowrap xl:text-center xl:leading-none xl:text-[0.62rem] xl:tracking-[0.18em] wide:text-[0.72rem] wide:tracking-[0.28em]",
)}
>
{center}
</span>
</div>
{rule(0.15)}
</div>
);
}
export default Masthead;
+235
View File
@@ -0,0 +1,235 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { flags, legal as legalDe, site } from "@/lib/site";
import type { Lang } from "@/lib/i18n";
import { LangSwitch } from "./LangSwitch";
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
import { gsap, EASE, MM, getLenis, refreshSoon } from "@/lib/gsap";
import { TransitionLink } from "@/components/motion/TransitionLink";
import { Container } from "./Container";
export interface OverlayMenuProps {
open: boolean;
onClose: () => void;
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
chrome?: Chrome;
/** Impressum/Datenschutz der jeweiligen Sprache. */
legal?: readonly { href: string; label: string }[];
/** Aktuelle Sprache – für den Umschalter im Fuß des Menüs. */
lang?: Lang;
}
/** The header's „Menü“ button – part of the dialog's operable set (see the trap below). */
export const MENU_TOGGLE_ID = "menue-toggle";
/**
* Fullscreen ivory sheet from the top (0.5 s power3.inOut): numbered ledger
* 01–05, contact row at the bottom. `data-lenis-prevent`, `lenis.stop()`,
* focus trap, Escape closes, closes on route change, rest of the page `inert`.
*
* The trap spans the panel AND the header's toggle: the toggle stays visible on
* top of the sheet (header z-50 > panel z-40), so it has to stay reachable by Tab
* as well as by mouse. Every other header control is `inert` while the sheet is
* open, so the mouse-operable and the keyboard-operable set are the same one.
*/
export function OverlayMenu({ open, onClose, chrome = chromeDe, legal = legalDe, lang = "de" }: OverlayMenuProps) {
const panel = useRef<HTMLDivElement>(null);
const pathname = usePathname();
const onCloseRef = useRef(onClose);
/* The panel has to outlive `open` for the length of its exit tween. State adjusted
during render (the sanctioned pattern), not in an effect – an effect would cost a
cascading render on every open. */
const [exiting, setExiting] = useState(false);
const [prevOpen, setPrevOpen] = useState(open);
if (prevOpen !== open) {
setPrevOpen(open);
setExiting(!open);
}
const mounted = open || exiting;
useEffect(() => {
onCloseRef.current = onClose;
});
// Close on route change.
const lastPath = useRef(pathname);
useEffect(() => {
if (lastPath.current !== pathname) {
lastPath.current = pathname;
onCloseRef.current();
}
}, [pathname]);
/* Enter / exit on GSAP (the site's only animation runtime). The panel is a single
element, so an <AnimatePresence> is not load-bearing – `exiting` keeps it in the
DOM until the exit tween has run. */
useLayoutEffect(() => {
const el = panel.current;
if (!mounted || !el) return;
const rows = gsap.utils.toArray<HTMLElement>("[data-menu-row]", el);
const foot = el.querySelector<HTMLElement>("[data-menu-foot]");
const mm = gsap.matchMedia();
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
const reduce = Boolean(ctx.conditions?.reduce);
if (open) {
gsap.set(el, { yPercent: reduce ? 0 : -100 });
gsap.set(rows, { yPercent: reduce ? 0 : 100 });
gsap.set(foot, { opacity: reduce ? 1 : 0 });
if (reduce) return;
const tl = gsap.timeline();
tl.to(el, { yPercent: 0, duration: 0.5, ease: EASE.inOut }, 0)
.to(rows, { yPercent: 0, duration: 0.6, ease: EASE.out, stagger: 0.06 }, 0.25)
.to(foot, { opacity: 1, duration: 0.6 }, 0.6);
return;
}
/* Reduced motion still goes through the tween, at zero duration: `onComplete`
must fire off the current render, never synchronously inside this effect. */
gsap.to(el, {
yPercent: -100,
duration: reduce ? 0 : 0.4,
ease: EASE.inOut,
onComplete: () => setExiting(false),
});
});
return () => mm.revert();
}, [open, mounted]);
useEffect(() => {
if (!open) return;
const lenis = getLenis();
lenis?.stop();
const previous = document.activeElement as HTMLElement | null;
const toggle = document.getElementById(MENU_TOGGLE_ID) as HTMLElement | null;
/* Everything the reader can still SEE but must no longer reach: the rest of the
page plus the header's own links, which float above the ivory sheet. */
const others = Array.from(
document.querySelectorAll<HTMLElement>("main, footer, header [data-menu-inert]"),
);
others.forEach((el) => {
el.inert = true;
});
const inPanel = () =>
Array.from(panel.current?.querySelectorAll<HTMLElement>("a[href], button:not([disabled])") ?? []);
/* The toggle first: it sits above the sheet, so Shift+Tab from the first ledger row
lands on it and Tab from the last row cycles back to it. */
const focusables = () => (toggle ? [toggle, ...inPanel()] : inPanel());
const t = window.setTimeout(() => inPanel()[0]?.focus(), 50);
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCloseRef.current();
return;
}
if (e.key !== "Tab") return;
const items = focusables();
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement as HTMLElement | null;
const inside = active ? items.includes(active) : false;
if (e.shiftKey && (active === first || !inside)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && (active === last || !inside)) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKey);
return () => {
window.clearTimeout(t);
document.removeEventListener("keydown", onKey);
others.forEach((el) => {
el.inert = false;
});
lenis?.start();
previous?.focus?.();
refreshSoon();
};
}, [open]);
if (!mounted) return null;
return (
<div
ref={panel}
id="menue"
role="dialog"
aria-modal="true"
aria-label={chrome.header.menu.ariaLabel}
data-theme="light"
data-lenis-prevent=""
className="fixed inset-0 z-40 flex flex-col overflow-y-auto overscroll-contain bg-ivory text-ink"
>
<Container className="flex min-h-full flex-1 flex-col pb-10 pt-[calc(var(--header-h,60px)+24px)]">
<ol className="border-t border-line-light">
{chrome.menu.items.map((item) => (
<li key={item.href} className="overflow-hidden border-b border-line-light">
<div data-menu-row="">
<TransitionLink
href={item.href}
label={item.label}
onClick={() => onCloseRef.current()}
className="group flex items-baseline gap-5 py-5 lg:gap-8 lg:py-7"
>
<span className="font-mono text-xs tracking-[0.06em] tabular-nums text-gold-deep">{item.numeral}</span>
<span className="font-display text-[clamp(2rem,1.4rem+3vw,4rem)] leading-none text-ink transition-[font-style] group-hover:italic">
{item.label}
</span>
<span
aria-hidden="true"
className="ml-auto text-gold-deep transition-transform duration-300 group-hover:translate-x-2"
>
→
</span>
</TransitionLink>
</div>
</li>
))}
</ol>
<div
data-menu-foot=""
className="mt-auto flex flex-col gap-4 pt-12 lg:flex-row lg:items-end lg:justify-between"
>
<div className="flex flex-col gap-2">
<a href={site.phoneHref} className="font-display text-[1.75rem] leading-none text-ink">
{site.phoneDisplay}
</a>
<a href={`mailto:${site.email}`} className="font-sans text-sm text-grey-deep">
{site.email}
</a>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 font-mono text-xs tracking-[0.06em] text-grey-deep">
{/* Unter 1024 px versteckt der Kopf den Umschalter (dort ist kein Platz neben
der Wortmarke). Ohne diese Zeile gäbe es auf dem Telefon gar keinen Weg in
die andere Sprache – er steht deshalb genau dort, wo er im Kopf fehlt. */}
{flags.showLangSwitch && <LangSwitch lang={lang} className="lg:hidden" />}
<a href={site.linkedin} target="_blank" rel="noopener noreferrer" className="hover:text-gold-deep">
{chrome.menu.linkedin} <span aria-hidden="true">↗</span>
</a>
<span>
{legal.map((l, i) => (
<span key={l.href}>
{i > 0 && <span aria-hidden="true"> · </span>}
<TransitionLink href={l.href} label={l.label} onClick={() => onCloseRef.current()} className="hover:text-gold-deep">
{l.label}
</TransitionLink>
</span>
))}
</span>
</div>
</div>
</Container>
</div>
);
}
export default OverlayMenu;
+80
View File
@@ -0,0 +1,80 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Container } from "./Container";
import { SectionGutter } from "./SectionGutter";
export interface SectionProps {
id: string;
theme: "dark" | "light";
/** „01“ */
numeral?: string;
/** „06“ */
total?: string;
/** „Positionierung“ – the running head under the numeral. */
label?: string;
/** The `< lg` mono counter „0X / 06“ above the content (off where a folio already carries it). */
mobileMeta?: boolean;
/**
* Vertical rhythm. `chapter` (default) opens a new chapter with its own
* eyebrow + Bodoni H2; `block` is a sub-block of the chapter above
* (Rahmen → Ablauf → FAQ) and halves the gap that precedes it, so the
* page reads as a spread instead of a component list with one fixed margin.
*/
space?: "chapter" | "block";
className?: string;
as?: "section" | "div" | "article";
children: ReactNode;
}
/** Gutter of the 12-column grid – reuse in children that nest their own grid. */
export const GRID = "grid grid-cols-12 gap-x-[clamp(16px,2vw,32px)]";
/**
* `<section data-theme id>` with Container + 12-column grid. Children place
* themselves with `col-span-*` / `col-start-*` (content cols 3–9, marginalia
* 10–12 on ≥ 1024). With a `numeral` the section opens with <SectionGutter/>:
* the sticky Bodoni numeral + running head in cols 1–2 from 1024, the mono
* counter „0X / 06“ above the content below it – identical on every route.
*
* Top-level sections paint their own opaque ground (globals.css:
* `main > [data-theme]`); the seam between two grounds is the 1 px gold
* hairline alone (spec §2.3). Nested sections stay transparent and inherit
* the ground of their themed wrapper.
*/
export function Section({
id,
theme,
numeral,
total,
label,
mobileMeta = true,
space = "chapter",
className,
as = "section",
children,
}: SectionProps) {
const Tag = as;
return (
<Tag
id={id}
data-theme={theme}
className={cn(
"relative bg-transparent pb-[clamp(4rem,10vw,9rem)] text-fg",
space === "block" ? "pt-[clamp(2rem,5vw,4.5rem)]" : "pt-[clamp(4rem,10vw,9rem)]",
className,
)}
>
<Container className="relative">
<div className={GRID}>
{numeral && (
<SectionGutter numeral={numeral} total={total} label={label ?? ""} mobileMeta={mobileMeta} />
)}
{children}
</div>
</Container>
</Tag>
);
}
export default Section;
+54
View File
@@ -0,0 +1,54 @@
import { cn } from "@/lib/utils";
import { Numeral } from "@/components/ui/Numeral";
export interface SectionGutterProps {
/** „01“ … „06“ */
numeral: string;
/** „06“ */
total?: string;
/** Running head under the numeral, e.g. „Positionierung“. */
label: string;
/**
* The `< lg` mono counter above the content. Section 01 suppresses it – the
* hero folio directly above already carries „01 / 06 · Positionierung ↓“.
*/
mobileMeta?: boolean;
className?: string;
}
const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums text-fg-muted";
/**
* The gutter of a numbered home section (cols 1–2 from 1024): the sticky Bodoni
* <Numeral/> and the small mono running head in ONE sticky wrapper, so the pair
* always travels together (they used to have two different sticky anchors and
* drifted ~350 px apart). Anchor is the spec's `calc(50vh − .45em)`.
*
* The counter („04 / 06“) stays on the mobile line and in the hero folio – next
* to a 147 px Bodoni „04“ it would only repeat the numeral, so the desktop line
* carries the label alone.
*
* Rendered by <Section/> itself whenever a `numeral` is passed – there is exactly
* ONE chapter gutter on the site now. It used to coexist with an inline copy inside
* <Section/> (a 48 px Bodoni numeral on mobile, a mono running head sticky under the
* header on desktop), so the home page and the three service pages showed the same
* furniture in two different treatments with two different sticky anchors.
*/
export function SectionGutter({ numeral, total = "06", label, mobileMeta = true, className }: SectionGutterProps) {
return (
<>
{mobileMeta && <p className={cn("col-span-12 mb-8 lg:hidden", MONO)}>{`${numeral} / ${total}`}</p>}
<div className={cn("hidden lg:col-span-2 lg:col-start-1 lg:row-span-2 lg:row-start-1 lg:block", className)}>
<div className="lg:sticky lg:top-[calc(50vh_-_0.45em)]">
{/* Decorative – the accessible section number is the mono line above. */}
<Numeral value={numeral} sticky={false} />
<span aria-hidden="true" className={cn("mt-5 block whitespace-nowrap", MONO)}>
{label}
</span>
</div>
</div>
</>
);
}
export default SectionGutter;
+69
View File
@@ -0,0 +1,69 @@
import type { ReactNode } from "react";
import { SmoothScroll } from "@/components/providers/SmoothScroll";
import { BrandMark } from "@/components/brand/BrandMark";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
import { Curtain } from "@/components/motion/Curtain";
import { ScrollProgress } from "@/components/motion/ScrollProgress";
import { chromeByLang } from "@/content/chrome-by-lang";
import { fontVariables } from "@/lib/fonts";
import { HTML_LANG, type Lang } from "@/lib/i18n";
/**
* Das ganze Dokument – von `<html>` bis `</body>` – für eine Sprache.
*
* Beide Wurzel-Layouts (`app/(de)/layout.tsx`, `app/en/layout.tsx`) bestehen nur noch
* aus einem Aufruf dieser Komponente. Zwei Wurzel-Layouts sind nötig, weil `<html lang>`
* die Sprache des Dokuments angibt (WCAG 3.1.1) und im App Router nur das Wurzel-Layout
* das `<html>`-Element schreibt. Ein Wechsel zwischen den Sprachen ist deshalb ein
* vollständiger Seitenaufbau statt einer Client-Navigation – für einen Sprachwechsel
* genau richtig, denn Schrift, Metadaten und Hülle wechseln ohnehin komplett.
*/
export function Shell({ lang, children }: { lang: Lang; children: ReactNode }) {
const { chrome, nav, legal } = chromeByLang[lang];
return (
<html lang={HTML_LANG[lang]} className={`${fontVariables} h-full`} suppressHydrationWarning>
<body className="grain min-h-full flex flex-col">
{/* Marks the document as JS-capable before first paint: initial motion states live
under `html.js` only. The failsafe matters as much as the class: everything the
class hides is only ever un-hidden by the module bundle, and a static export
re-hashes its chunks on every deploy – a browser on cached HTML that 404s the
bundle would sit on a blank page forever. If nothing has booted after 2.5 s the
class drops and the page renders as plain HTML. <SmoothScroll/> clears the timer. */}
<script
dangerouslySetInnerHTML={{
__html:
"document.documentElement.classList.add('js');window.__unhide=setTimeout(function(){document.documentElement.classList.remove('js')},2500)",
}}
/>
<a
href="#inhalt"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:bg-ink focus:text-gold focus:px-4 focus:py-2 focus:font-mono focus:text-sm"
>
{chrome.skipLink}
</a>
{/* No <MotionConfig reducedMotion="user"> any more: every animation on the site is
built on gsap.matchMedia and gates itself on `MM.reduce`, so the provider bought
nothing – and it was one of four reasons a second animation runtime shipped. */}
<SmoothScroll>
<Header
mark={<BrandMark className="h-7 w-auto" idPrefix="hdr" />}
chrome={chrome}
nav={nav}
legal={legal}
lang={lang}
/>
{children}
<Footer chrome={chrome} legal={legal} lang={lang} />
<Curtain chrome={chrome}>
<BrandMark className="h-12 w-auto" idPrefix="curtain" />
</Curtain>
<ScrollProgress />
</SmoothScroll>
</body>
</html>
);
}
export default Shell;
+209
View File
@@ -0,0 +1,209 @@
import type { ReactNode } from "react";
import { Section } from "@/components/layout/Section";
import { Masthead } from "@/components/layout/Masthead";
import { Eyebrow } from "@/components/ui/Eyebrow";
import type { LegalPage as LegalPageContent, LegalParagraph, LegalSection } from "@/content/legal";
import { hasPlaceholder, splitPlaceholders } from "@/lib/legal";
import { flags, site } from "@/lib/site";
import { chromeByLang } from "@/content/chrome-by-lang";
import { DEFAULT_LANG, type Lang } from "@/lib/i18n";
import { cn } from "@/lib/utils";
/* ------------------------------------------------------------------ */
/* Text rendering: „[…]“-Platzhalter in --gold-deep, URLs / E-Mail / Telefon als Links. */
type Token =
| { kind: "text"; text: string }
| { kind: "placeholder"; text: string }
| { kind: "link"; text: string; href: string; external: boolean };
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const LINK_RE = new RegExp(
`(https?:\\/\\/[^\\s„“"<>]+|[\\w.+-]+@[\\w-]+(?:\\.[\\w-]+)+|${escapeRegExp(site.phoneDisplay)})`,
"g",
);
function linkify(text: string): Token[] {
const out: Token[] = [];
let last = 0;
for (const m of text.matchAll(LINK_RE)) {
const index = m.index ?? 0;
let hit = m[0];
let trail = "";
if (hit.startsWith("http")) {
const t = /[.,;:)]+$/.exec(hit);
if (t) {
trail = t[0];
hit = hit.slice(0, -trail.length);
}
}
if (index > last) out.push({ kind: "text", text: text.slice(last, index) });
if (hit === site.phoneDisplay) {
out.push({ kind: "link", text: hit, href: site.phoneHref, external: false });
} else if (hit.startsWith("http")) {
out.push({ kind: "link", text: hit, href: hit, external: true });
} else {
out.push({ kind: "link", text: hit, href: `mailto:${hit}`, external: false });
}
if (trail) out.push({ kind: "text", text: trail });
last = index + m[0].length;
}
if (last < text.length) out.push({ kind: "text", text: text.slice(last) });
return out;
}
function tokenize(text: string): Token[] {
return splitPlaceholders(text).flatMap<Token>((seg) =>
seg.placeholder ? [{ kind: "placeholder", text: seg.text }] : linkify(seg.text),
);
}
const LINK =
"text-gold-deep underline decoration-[1px] underline-offset-[0.2em] [overflow-wrap:anywhere] transition-opacity duration-300 hover:opacity-70";
function renderText(text: string): ReactNode {
return tokenize(text).map((tok, i) => {
if (tok.kind === "placeholder") {
return (
<span key={i} className="text-gold-deep">
{tok.text}
</span>
);
}
if (tok.kind === "link") {
return (
<a
key={i}
href={tok.href}
className={LINK}
target={tok.external ? "_blank" : undefined}
rel={tok.external ? "noopener noreferrer" : undefined}
>
{tok.text}
</a>
);
}
return tok.text;
});
}
/* ------------------------------------------------------------------ */
/* Blocks: aufeinanderfolgende „– …“-Absätze werden zu einer Liste. */
type Block = { type: "p"; text: string } | { type: "list"; items: string[] };
/**
* Ein Absatz überlebt den Launch-Flip ganz oder gar nicht. Platzhalter aus dem
* Satz zu schneiden hinterließ Reste wie „Anelia Wolf,,“, ein Impressum ohne
* ladungsfähige Anschrift (§ 5 Abs. 1 Nr. 1 DDG) und ein Label ohne USt-IdNr.
* Pflichtabsätze mit offenem Platzhalter kommen hier gar nicht erst an – der
* Build-Guard in src/content/legal.ts bricht vorher ab.
*/
function prepare(raw: LegalParagraph): string | null {
const text = typeof raw === "string" ? raw : raw.text;
if (!flags.showPlaceholders && hasPlaceholder(text)) return null;
return text.trim().length > 0 ? text : null;
}
function toBlocks(paragraphs: readonly LegalParagraph[]): Block[] {
const blocks: Block[] = [];
for (const raw of paragraphs) {
const text = prepare(raw);
if (!text) continue;
if (/^–\s/.test(text)) {
const prev = blocks[blocks.length - 1];
if (prev && prev.type === "list") prev.items.push(text);
else blocks.push({ type: "list", items: [text] });
} else {
blocks.push({ type: "p", text });
}
}
return blocks;
}
function slug(text: string, index: number): string {
const s = text
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return s.length > 0 ? s : `abschnitt-${index + 1}`;
}
const BODY = "max-w-[36rem] font-sans text-[1.0625rem] font-normal leading-[1.65] text-fg [hyphens:auto]";
function LegalBlock({ section, index }: { section: LegalSection; index: number }) {
const id = slug(section.h2, index);
const blocks = toBlocks(section.paragraphs);
if (blocks.length === 0) return null;
return (
<section aria-labelledby={id} className="mt-14 lg:mt-16">
<Eyebrow as="h2" className="font-mono text-xs font-normal tracking-[0.12em]">
<span id={id}>{section.h2}</span>
</Eyebrow>
<div className="mt-5 space-y-[1.25em]">
{blocks.map((block, i) =>
block.type === "p" ? (
<p key={i} className={BODY}>
{renderText(block.text)}
</p>
) : (
<ul key={i} className={cn(BODY, "list-none space-y-[0.35em] pl-[1.1em] -indent-[1.1em]")}>
{block.items.map((item, j) => (
<li key={j}>{renderText(item)}</li>
))}
</ul>
),
)}
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
export interface LegalPageProps {
/** Section id (`impressum` / `datenschutz`). */
id: string;
content: LegalPageContent;
/** Sprache der Seite – die Kopfzeile (Rolle, Sprachen) kommt aus der Hülle. */
lang?: Lang;
}
/**
* Server Component. Ivory page (Spec §8): Masthead with the page name in the
* centre slot, content in cols 3–9, H1 Bodoni 44 px, H2 as mono caps eyebrows
* with gold tick, body Jost 17 px / 1.65, meta line „Stand: …“ in DM Mono.
* No motion beyond the template enter fade. Placeholders „[…]“ stay visible in
* `--gold-deep` while `flags.showPlaceholders` is true.
*/
export function LegalPage({ id, content, lang = DEFAULT_LANG }: LegalPageProps) {
return (
<Section id={id} theme="light" className="pt-[calc(var(--header-h,7.5rem)+1.5rem)]">
<div className="col-span-12">
{/* Rolle und Sprachzeile stehen in der Hülle, nicht im Rechtstext – ohne das
stand über dem englischen Impressum „Keynote Speakerin · Trainerin · Coach“. */}
<Masthead center={content.masthead.center} chrome={chromeByLang[lang].chrome} />
</div>
<div className="col-span-12 pt-14 lg:col-span-7 lg:col-start-3 lg:pt-20">
<h1 className="font-display text-[2.75rem] font-normal leading-[1.05] tracking-[-0.01em] text-fg [text-wrap:balance]">
{content.h1}
</h1>
<p className="mt-5 font-mono text-xs tracking-[0.06em] tabular-nums text-fg-muted">{renderText(content.stand)}</p>
{content.sections.map((section, i) => (
<LegalBlock key={section.h2} section={section} index={i} />
))}
</div>
</Section>
);
}
export default LegalPage;
+107
View File
@@ -0,0 +1,107 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { gsap, EASE } from "@/lib/gsap";
import { WolfMark } from "@/components/brand/WolfMark";
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
import { endTransition, setTransitionPhase, subscribe } from "./transition-store";
export interface CurtainProps {
/** The monogram (48 px). layout.tsx passes `<BrandMark/>`; falls back to `<WolfMark/>`. */
children?: ReactNode;
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
chrome?: Chrome;
}
/**
* The page-turn sheet: fixed ivory, 1 px gold hairline on its top edge,
* monogram + destination label centred. Note the start state lives ONLY in
* GSAP (`yPercent`) – a CSS `translateY(100%)` would be read as the tween's
* base `y` and added on top, which pushed the whole cover tween one viewport
* below the fold (the reader saw a hard cut, then the sheet wiping up over the
* new page). Slides up 0.45 s (power3.inOut),
* pushes the route, keeps rising once the pathname changed, then resets.
* Hard fallback: full navigation when the route has not changed after 900 ms.
*/
export function Curtain({ children, chrome = chromeDe }: CurtainProps) {
const router = useRouter();
const pathname = usePathname();
const sheet = useRef<HTMLDivElement>(null);
const phase = useRef<"idle" | "cover" | "pushed" | "reveal">("idle");
const timer = useRef<number>(0);
const [label, setLabel] = useState<string | null>(null);
useEffect(() => {
return subscribe((s) => {
if (s.phase !== "cover" || !s.href || phase.current !== "idle") return;
const el = sheet.current;
if (!el) return;
const href = s.href;
phase.current = "cover";
setLabel(s.label ?? chrome.curtain.fallbackLabel);
gsap.killTweensOf(el);
gsap.set(el, { y: 0, yPercent: 100, autoAlpha: 1, pointerEvents: "auto" });
gsap.to(el, {
yPercent: 0,
duration: 0.45,
ease: EASE.inOut,
onComplete: () => {
phase.current = "pushed";
setTransitionPhase("pushed");
router.push(href, { scroll: false });
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => {
if (phase.current === "pushed") window.location.assign(href);
}, 900);
},
});
});
}, [router, chrome.curtain.fallbackLabel]);
// Route changed → the sheet continues upward and resets.
useEffect(() => {
if (phase.current !== "pushed") return;
const el = sheet.current;
if (!el) return;
window.clearTimeout(timer.current);
phase.current = "reveal";
setTransitionPhase("reveal");
gsap.to(el, {
yPercent: -100,
duration: 0.45,
ease: EASE.inOut,
delay: 0.05,
onComplete: () => {
gsap.set(el, { y: 0, yPercent: 100, autoAlpha: 0, pointerEvents: "none" });
phase.current = "idle";
setLabel(null);
endTransition();
},
});
}, [pathname]);
useEffect(() => () => window.clearTimeout(timer.current), []);
return (
<div
ref={sheet}
id="curtain"
aria-hidden="true"
data-theme="light"
className="pointer-events-none invisible fixed inset-0 z-[70] flex flex-col items-center justify-center gap-6 bg-ivory text-ink"
>
<span
aria-hidden="true"
className="absolute inset-x-0 top-0 h-px"
style={{ background: "var(--gold-hairline-light)" }}
/>
<span className="block h-12 text-ink [&_svg]:h-12 [&_svg]:w-auto">
{children ?? <WolfMark idPrefix="curtain" className="h-12 w-auto" />}
</span>
<span className="min-h-[1em] font-mono text-xs tracking-[0.06em] text-gold-deep">{label}</span>
</div>
);
}
export default Curtain;
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useLayoutEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { gsap, DUR, EASE, MM, refreshSoon } from "@/lib/gsap";
export interface DoorProps {
src: string;
alt: string;
width: number;
height: number;
sizes?: string;
/** 6 % parallax (`yPercent −6 → 6`, scrub 0.6), desktop + fine pointer only. */
parallax?: boolean;
/** Eager loading for above-the-fold images. */
priority?: boolean;
/** Play immediately (hero timeline) instead of at 85 % in view. */
play?: "view" | "load";
delay?: number;
className?: string;
imgClassName?: string;
}
/**
* Photo behind a "door": `clip-path inset(100% 0 0 0) → inset(0)` (1.1 s,
* expo-out) while the image settles `scale 1.06 → 1`. Plain `<img>` – the
* static export has no image optimiser. Reduced motion = end state.
*/
export function Door({
src,
alt,
width,
height,
sizes,
parallax = false,
priority = false,
play = "view",
delay = 0,
className,
imgClassName,
}: DoorProps) {
const box = useRef<HTMLDivElement>(null);
const img = useRef<HTMLImageElement>(null);
useLayoutEffect(() => {
const boxEl = box.current;
const imgEl = img.current;
if (!boxEl || !imgEl) return;
const mm = gsap.matchMedia();
mm.add({ reduce: MM.reduce, ok: MM.ok, desktop: MM.desktop }, (ctx) => {
const { reduce, desktop } = ctx.conditions ?? {};
if (reduce) {
gsap.set(boxEl, { clipPath: "inset(0% 0% 0% 0%)" });
gsap.set(imgEl, { scale: 1, yPercent: 0 });
return;
}
const tl = gsap.timeline({
delay,
scrollTrigger: play === "view" ? { trigger: boxEl, start: "top 85%", once: true } : undefined,
});
tl.fromTo(
boxEl,
{ clipPath: "inset(100% 0% 0% 0%)" },
{ clipPath: "inset(0% 0% 0% 0%)", duration: DUR.door, ease: EASE.out },
0,
).fromTo(imgEl, { scale: 1.06 }, { scale: 1, duration: DUR.door, ease: EASE.out }, 0);
if (parallax && desktop) {
gsap.fromTo(
imgEl,
{ yPercent: -6 },
{
yPercent: 6,
ease: EASE.none,
scrollTrigger: { trigger: boxEl, start: "top bottom", end: "bottom top", scrub: 0.6 },
},
);
}
});
return () => mm.revert();
}, [parallax, play, delay]);
return (
<div
ref={box}
className={cn("relative overflow-hidden [.js_&]:[clip-path:inset(100%_0_0_0)]", className)}
style={{ aspectRatio: `${width} / ${height}` }}
>
{/* eslint-disable-next-line @next/next/no-img-element -- static export, no optimiser */}
<img
ref={img}
src={src}
alt={alt}
width={width}
height={height}
sizes={sizes}
loading={priority ? "eager" : "lazy"}
fetchPriority={priority ? "high" : "auto"}
decoding="async"
onLoad={refreshSoon}
className={cn(
"absolute left-0 h-full w-full object-cover will-change-transform [.js_&]:[transform:scale(1.06)]",
parallax ? "-top-[6%] h-[112%]" : "top-0",
imgClassName,
)}
/>
</div>
);
}
export default Door;
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { gsap, DUR } from "@/lib/gsap";
/**
* Reveal helpers shared by <DrawMark/> (hero, footer, 404) and <Formula/>.
*
* The generic `<Draw/>` wrapper this file used to export was never mounted –
* every draw on the site goes through <DrawMark/>, which owns the sequence.
* It shipped a `use client` component into every bundle for nothing, so the
* helpers are all that is left here.
*
* Two kinds of artwork have to reveal, and they need different gestures:
*
* - **stroke art** (the in-house <WolfMark/> fallback): each path is drawn on
* its own with a dash offset, V-left → V-right → wolf. The parts are
* separate strokes, so a half-finished mark still reads as a mark.
*
* - **filled art** (the client's `logo-mark.svg`): a dash offset is invisible
* on a filled shape, and the parts are *interlocked* – the wolf's chest is
* the middle stroke of the W, and the right V exists only as two fragments
* around the head. Revealing them one after another shows broken shards.
* So the whole SVG is wiped in as ONE gesture, top → bottom. Clipping the
* root (not each path) keeps the percentages in one coordinate space, so
* every intermediate frame is a clean horizontal slice of the finished mark.
*/
/** A path that carries a fill and no visible stroke cannot be dash-drawn. */
function isFilledPath(path: SVGPathElement): boolean {
const cs = getComputedStyle(path);
const fill = cs.fill;
const hasFill = Boolean(fill) && fill !== "none" && fill !== "rgba(0, 0, 0, 0)" && fill !== "transparent";
const hasStroke =
Boolean(cs.stroke) && cs.stroke !== "none" && cs.stroke !== "rgba(0, 0, 0, 0)" && parseFloat(cs.strokeWidth || "0") > 0;
return hasFill && !hasStroke;
}
/** How a mark reveals: one wipe over the whole SVG, or a dash draw per path. */
export type RevealMode = "wipe" | "stroke";
/** Tween vars that finish either reveal. Pair with the target `primeMark` reported. */
export const WIPE_IN: gsap.TweenVars = { clipPath: "inset(0% 0% 0% 0%)" };
export const STROKE_IN: gsap.TweenVars = { strokeDashoffset: 0 };
/**
* Hides a mark before its reveal and reports how it has to be animated.
* `wipe` → tween `svg` with {@link WIPE_IN}; `stroke` → tween each path with
* {@link STROKE_IN}, in whatever order the caller wants.
*/
export function primeMark(svg: SVGSVGElement | null, paths: SVGPathElement[]): RevealMode {
const drawable = paths.filter(Boolean);
if (svg && drawable.length && drawable.every(isFilledPath)) {
gsap.set(svg, { clipPath: "inset(0% 0% 100% 0%)" });
return "wipe";
}
drawable.forEach((path) => {
path.setAttribute("pathLength", "1");
gsap.set(path, { strokeDasharray: 1, strokeDashoffset: 1 });
});
return "stroke";
}
/**
* One-time light sweep over the mark – "light over metal".
*
* Stroke art gets a second champagne stroke running along the path. Filled art
* gets a champagne copy of the shape wiped across it left → right, which is the
* same gesture for a solid mark. The copy removes itself afterwards.
*/
export function playSheen(path: SVGPathElement, duration = DUR.sheen): gsap.core.Tween {
const filled = isFilledPath(path);
const clone = path.cloneNode(false) as SVGPathElement;
clone.removeAttribute("id");
clone.removeAttribute("data-draw");
clone.removeAttribute("pathLength");
clone.removeAttribute("class");
clone.setAttribute("aria-hidden", "true");
path.parentNode?.insertBefore(clone, path.nextSibling);
const done = () => clone.remove();
if (filled) {
clone.style.fill = "#F4E5B8";
clone.style.opacity = "0.85";
gsap.set(clone, { clipPath: "inset(0% 100% 0% 0%)" });
return gsap.to(clone, {
keyframes: [
{ clipPath: "inset(0% 0% 0% 0%)", duration: duration * 0.55, ease: "power2.out" },
{ clipPath: "inset(0% 0% 0% 100%)", duration: duration * 0.45, ease: "power2.in" },
],
onComplete: done,
});
}
clone.style.stroke = "#F4E5B8";
clone.style.strokeDasharray = "";
clone.style.strokeDashoffset = "";
clone.style.opacity = "0.9";
const length = clone.getTotalLength() || 200;
gsap.set(clone, { strokeDasharray: `12 ${length + 12}`, strokeDashoffset: 12 });
return gsap.to(clone, {
strokeDashoffset: -length,
duration,
ease: "power2.inOut",
onComplete: done,
});
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useLayoutEffect, useRef, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { gsap, SplitText, DUR, EASE, MM, fontsReady, hasJs } from "@/lib/gsap";
export interface LinesProps {
as?: "h1" | "h2" | "h3" | "p" | "div";
play?: "view" | "load";
delay?: number;
stagger?: number;
className?: string;
children: ReactNode;
}
/**
* Line reveal: SplitText (`type: "lines"`, `mask: "lines"`, `autoSplit`), each
* line rises `yPercent 100 → 0` (0.9 s, stagger 0.08, expo-out), once.
* Waits for `document.fonts.ready` (1.5 s timeout). Reduced motion or missing
* `html.js` → static text. SplitText (`aria: "auto"`) sets the aria-label.
* Hidden via opacity only (never `visibility`), so nothing drops out of the
* tab sequence.
*/
export function Lines({ as = "p", play = "view", delay = 0, stagger = 0.08, className, children }: LinesProps) {
const ref = useRef<HTMLDivElement>(null);
const Tag = as as "div";
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
if (!hasJs()) return;
let cancelled = false;
let split: SplitText | null = null;
const mm = gsap.matchMedia();
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
if (ctx.conditions?.reduce) {
gsap.set(el, { opacity: 1 });
return;
}
fontsReady().then(() => {
if (cancelled) return;
split = SplitText.create(el, {
type: "lines",
mask: "lines",
autoSplit: true,
aria: "auto",
linesClass: "line",
onSplit: (self) => {
gsap.set(el, { opacity: 1 });
return gsap.from(self.lines, {
yPercent: 100,
duration: DUR.lines,
ease: EASE.out,
stagger,
delay,
scrollTrigger: play === "view" ? { trigger: el, start: "top 82%", once: true } : undefined,
});
},
});
});
});
return () => {
cancelled = true;
split?.revert();
mm.revert();
};
}, [play, delay, stagger]);
return (
<Tag ref={ref} data-reveal="" className={cn("[.js_&]:opacity-0", className)}>
{children}
</Tag>
);
}
export default Lines;
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { gsap, MM } from "@/lib/gsap";
export interface MagnetProps {
/** Fraction of the pointer offset the child follows (default 0.3). */
strength?: number;
/** Activation radius around the element in px (default 80). */
radius?: number;
className?: string;
children: ReactNode;
}
/**
* Pulls its child toward the cursor within `radius` px and springs it back.
* Desktop + fine pointer + no reduced-motion only; one rAF per pointer move.
*/
export function Magnet({ strength = 0.3, radius = 80, className, children }: MagnetProps) {
const wrap = useRef<HTMLSpanElement>(null);
const inner = useRef<HTMLSpanElement>(null);
useEffect(() => {
const el = wrap.current;
const target = inner.current;
if (!el || !target) return;
const mm = gsap.matchMedia();
mm.add({ desktop: MM.desktop, reduce: MM.reduce }, (ctx) => {
const { desktop, reduce } = ctx.conditions ?? {};
if (!desktop || reduce) return;
const xTo = gsap.quickTo(target, "x", { duration: 0.4, ease: "power3.out" });
const yTo = gsap.quickTo(target, "y", { duration: 0.4, ease: "power3.out" });
let raf = 0;
let active = false;
let px = 0;
let py = 0;
const release = () => {
active = false;
gsap.to(target, { x: 0, y: 0, duration: 0.8, ease: "elastic.out(1, 0.45)", overwrite: true });
};
const update = () => {
raf = 0;
const r = el.getBoundingClientRect();
const inside =
px > r.left - radius && px < r.right + radius && py > r.top - radius && py < r.bottom + radius;
if (inside) {
active = true;
xTo((px - (r.left + r.width / 2)) * strength);
yTo((py - (r.top + r.height / 2)) * strength);
} else if (active) {
release();
}
};
const onMove = (e: PointerEvent) => {
px = e.clientX;
py = e.clientY;
if (!raf) raf = requestAnimationFrame(update);
};
window.addEventListener("pointermove", onMove, { passive: true });
return () => {
window.removeEventListener("pointermove", onMove);
if (raf) cancelAnimationFrame(raf);
};
});
return () => mm.revert();
}, [strength, radius]);
return (
<span ref={wrap} className={cn("inline-block", className)}>
<span ref={inner} className="inline-block will-change-transform">
{children}
</span>
</span>
);
}
export default Magnet;
+147
View File
@@ -0,0 +1,147 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { gsap, EASE, MM, ScrollTrigger, fontsReady, getLenis, refreshSoon } from "@/lib/gsap";
import { anchorOffset } from "@/components/motion/TransitionLink";
/* False during SSR + first hydration: the first page load renders visible
without an enter animation (the hero has its own choreography). Every
client navigation afterwards remounts the template and animates. */
let navigated = false;
/** translateY of <main> while the enter animation runs (rects include it, layout does not). */
function mainShift(): number {
const main = document.getElementById("inhalt");
if (!main) return 0;
return main.getBoundingClientRect().top + window.scrollY - main.offsetTop;
}
/** Document y for `#hash` with the header offset, corrected for the enter transform. */
function hashTop(hash: string): number | null {
const el = document.getElementById(hash.replace(/^#/, ""));
if (!el) return null;
return Math.round(el.getBoundingClientRect().top + window.scrollY - mainShift() + anchorOffset());
}
/** Fonts ready → two frames → re-measure. The one refresh that always has to happen. */
function refreshAfterFonts(): void {
fontsReady().then(() => {
requestAnimationFrame(() => requestAnimationFrame(() => ScrollTrigger.refresh()));
});
}
/**
* Cross-page anchor (`/leistungen/… → /#kontakt`): the new page has not laid
* out when the pathname changes – Lenis' `limit` and the native scrollHeight
* still belong to the previous page, so an immediate scroll gets clamped.
* Wait for fonts + two frames, re-measure (ScrollTrigger + Lenis), jump to
* the computed y; then correct once more when <main> resizes or the enter
* animation ends and the target moved – unless the reader scrolled meanwhile.
*/
function scheduleHashScroll(hash: string): () => void {
let cancelled = false;
let ro: ResizeObserver | null = null;
let expected = -1;
const timers: number[] = [];
const jump = () => {
ScrollTrigger.refresh();
getLenis()?.resize();
const top = hashTop(hash);
if (top === null) return;
const lenis = getLenis();
if (lenis) lenis.scrollTo(top, { immediate: true, force: true });
else window.scrollTo(0, top);
expected = Math.round(window.scrollY);
};
const correct = () => {
if (cancelled || expected < 0) return;
if (Math.abs(window.scrollY - expected) > 2) return; // the reader took over
const top = hashTop(hash);
if (top !== null && Math.abs(top - expected) > 2) jump();
};
fontsReady().then(() => {
if (cancelled) return;
requestAnimationFrame(() => {
if (cancelled) return;
requestAnimationFrame(() => {
if (cancelled) return;
jump();
const main = document.getElementById("inhalt");
if (main && typeof ResizeObserver !== "undefined") {
ro = new ResizeObserver(correct);
ro.observe(main);
}
// After the 0.6 s enter animation and once more for late layout.
timers.push(window.setTimeout(correct, 700), window.setTimeout(() => ro?.disconnect(), 1500));
});
});
});
return () => {
cancelled = true;
ro?.disconnect();
timers.forEach((t) => window.clearTimeout(t));
};
}
export function PageTemplate({ children }: { children: ReactNode }) {
const pathname = usePathname();
const [enter] = useState(() => navigated);
const main = useRef<HTMLElement>(null);
/* Enter fade on GSAP – the site already ships GSAP for every reveal, so a second
animation runtime (framer-motion) for one 0.6 s opacity/y tween is not worth
~39 KB gzip on every route. Reduced motion is handled by matchMedia, as everywhere. */
useLayoutEffect(() => {
const el = main.current;
if (!el || !enter) return;
const mm = gsap.matchMedia();
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
if (ctx.conditions?.reduce) {
gsap.set(el, { opacity: 1, y: 0 });
refreshSoon();
return;
}
gsap.fromTo(
el,
{ opacity: 0, y: 16 },
{ opacity: 1, y: 0, duration: 0.6, ease: EASE.out, onComplete: refreshSoon },
);
});
return () => mm.revert();
}, [enter, pathname]);
useEffect(() => {
const first = !navigated;
navigated = true;
if (first) {
/* Also on the FIRST load: every Reveal / Rule / Door / Formula builds its
ScrollTrigger in useLayoutEffect against fallback-font metrics, and Bodoni +
Jost are `display: swap` – when they land, the whole document reflows and every
start/end is stale (spec A/D5, applied to the entry path too). */
refreshAfterFonts();
return; // the browser handles initial scroll position / hash
}
const hash = window.location.hash;
const lenis = getLenis();
if (lenis) lenis.scrollTo(0, { immediate: true, force: true });
else window.scrollTo(0, 0);
/* No eager refresh here: at this moment the new page has not laid out (that is
exactly what scheduleHashScroll documents), so it would measure a layout about
to be replaced. The enter animation's onComplete and the scheduled jump refresh. */
if (hash) return scheduleHashScroll(hash);
}, [pathname]);
return (
<main ref={main} id="inhalt" className="flex-1">
{children}
</main>
);
}
export default PageTemplate;
+150
View File
@@ -0,0 +1,150 @@
"use client";
import { useLayoutEffect, useRef, type ElementType, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { gsap, DUR, EASE, MM } from "@/lib/gsap";
/* Initial state only under html.js – without JS everything is visible. Opacity only
(never `visibility: hidden`), so links inside stay in the tab sequence. */
const HIDDEN = "[.js_&]:opacity-0 [.js_&]:[transform:translateY(16px)]";
/**
* Keyboard reachability: a block that receives focus before its ScrollTrigger
* fired jumps to its end state (the browser scrolls the focused link into
* view; the reveal must not lag behind or hide the focus ring).
*/
function revealOnFocus(el: HTMLElement, tween: gsap.core.Tween): () => void {
const onFocus = () => {
tween.scrollTrigger?.kill();
tween.progress(1);
};
el.addEventListener("focusin", onFocus, { once: true });
return () => el.removeEventListener("focusin", onFocus);
}
export interface RevealProps {
as?: ElementType;
play?: "view" | "load";
delay?: number;
/** Start offset in px (default 16). */
y?: number;
className?: string;
children: ReactNode;
}
/**
* Block fade: `opacity 0 → 1, y 16 → 0`, 0.6 s expo-out, once
* (`top 90%` ≈ viewport margin −10 %). Reduced motion = end state.
*/
export function Reveal({ as = "div", play = "view", delay = 0, y = 16, className, children }: RevealProps) {
const ref = useRef<HTMLDivElement>(null);
const Tag = as as "div";
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const mm = gsap.matchMedia();
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
if (ctx.conditions?.reduce) {
gsap.set(el, { opacity: 1, y: 0 });
return;
}
const tween = gsap.fromTo(
el,
{ opacity: 0, y },
{
opacity: 1,
y: 0,
duration: DUR.reveal,
ease: EASE.out,
delay,
scrollTrigger: play === "view" ? { trigger: el, start: "top 90%", once: true } : undefined,
},
);
return revealOnFocus(el, tween);
});
return () => mm.revert();
}, [play, delay, y]);
return (
<Tag ref={ref} data-reveal="" className={cn(HIDDEN, className)}>
{children}
</Tag>
);
}
export interface RevealGroupProps {
as?: ElementType;
play?: "view" | "load";
stagger?: number;
delay?: number;
y?: number;
className?: string;
children: ReactNode;
}
/** Staggers all nested `<RevealItem/>`s (0.06 s) from one trigger. */
export function RevealGroup({
as = "div",
play = "view",
stagger = 0.06,
delay = 0,
y = 16,
className,
children,
}: RevealGroupProps) {
const ref = useRef<HTMLDivElement>(null);
const Tag = as as "div";
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const items = Array.from(el.querySelectorAll<HTMLElement>("[data-reveal-item]"));
if (!items.length) return;
const mm = gsap.matchMedia();
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
if (ctx.conditions?.reduce) {
gsap.set(items, { opacity: 1, y: 0 });
return;
}
const tween = gsap.fromTo(
items,
{ opacity: 0, y },
{
opacity: 1,
y: 0,
duration: DUR.reveal,
ease: EASE.out,
stagger,
delay,
scrollTrigger: play === "view" ? { trigger: el, start: "top 90%", once: true } : undefined,
},
);
return revealOnFocus(el, tween);
});
return () => mm.revert();
}, [play, stagger, delay, y]);
return (
<Tag ref={ref} className={className}>
{children}
</Tag>
);
}
export interface RevealItemProps {
as?: ElementType;
className?: string;
children: ReactNode;
}
export function RevealItem({ as = "div", className, children }: RevealItemProps) {
const Tag = as as "div";
return (
<Tag data-reveal-item="" data-reveal="" className={cn(HIDDEN, className)}>
{children}
</Tag>
);
}
export default Reveal;
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useLayoutEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { gsap, DUR, EASE, MM } from "@/lib/gsap";
export interface RuleProps {
/** `gold` = metallic hairline (`.rule-gold`), `line` = neutral `--line`. */
variant?: "gold" | "line";
/** Growth origin; a number is px from the left edge (hero folio: the handoff x). */
origin?: "left" | "center" | "right" | number;
/** `view` = when 85 % in view (once), `load` = immediately on mount. */
play?: "view" | "load";
delay?: number;
className?: string;
}
/**
* 1 px hairline that draws `scaleX 0 → 1` (0.9 s, expo-out), once.
* Initial hidden state only under `html.js`; reduced motion = end state.
*/
export function Rule({ variant = "line", origin = "left", play = "view", delay = 0, className }: RuleProps) {
const ref = useRef<HTMLDivElement>(null);
const transformOrigin = typeof origin === "number" ? `${origin}px 50%` : `${origin} 50%`;
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const mm = gsap.matchMedia();
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
if (ctx.conditions?.reduce) {
gsap.set(el, { scaleX: 1 });
return;
}
gsap.set(el, { scaleX: 0, transformOrigin });
gsap.to(el, {
scaleX: 1,
duration: DUR.rule,
ease: EASE.out,
delay,
scrollTrigger: play === "view" ? { trigger: el, start: "top 85%", once: true } : undefined,
});
});
return () => mm.revert();
}, [play, delay, transformOrigin]);
return (
<div
ref={ref}
aria-hidden="true"
style={{ transformOrigin }}
className={cn(
"h-px w-full [.js_&]:[transform:scaleX(0)]",
variant === "gold" ? "rule-gold" : "bg-line",
className,
)}
/>
);
}
export default Rule;
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { useLayoutEffect, useRef } from "react";
import { gsap, EASE, MM } from "@/lib/gsap";
/**
* 1 px `--accent` line directly under the header (the header writes
* `--header-h` on <html>). Grows with the page scroll; purely decorative.
*
* Scrubbed by ScrollTrigger, not by a second animation runtime: this was the
* only reason `useScroll` (framer-motion) shipped on every route.
*/
export function ScrollProgress() {
const bar = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const el = bar.current;
if (!el) return;
const mm = gsap.matchMedia();
mm.add({ reduce: MM.reduce, ok: MM.ok }, () => {
/* Reduced motion too: the bar reports position, it does not animate for effect –
the scrub follows the reader's own scrolling either way. */
gsap.set(el, { scaleX: 0, transformOrigin: "0 50%" });
gsap.to(el, {
scaleX: 1,
ease: EASE.none,
scrollTrigger: { trigger: document.documentElement, start: "top top", end: "bottom bottom", scrub: true },
});
});
return () => mm.revert();
}, []);
return (
<div
ref={bar}
aria-hidden="true"
className="pointer-events-none fixed inset-x-0 z-50 h-px origin-left scale-x-0 bg-accent transition-opacity duration-300 [[data-menu-open]_&]:opacity-0"
style={{ top: "var(--header-h, 60px)" }}
/>
);
}
export default ScrollProgress;
+91
View File
@@ -0,0 +1,91 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { ComponentProps, MouseEvent } from "react";
import { getLenis, hasJs, prefersReducedMotion } from "@/lib/gsap";
import { isTransitioning, startTransition } from "./transition-store";
type NextLinkProps = ComponentProps<typeof Link>;
export interface TransitionLinkProps extends Omit<NextLinkProps, "href"> {
href: string;
/** Destination name shown on the curtain, e.g. „Trainings & Workshops“. */
label?: string;
}
/** Header height when `--header-h` has not been written yet (60 px < lg, 72 px from lg). */
const HEADER_FALLBACK = 72;
/**
* The site's ONE anchor offset: −(measured header height + 16 px). The header
* publishes its height as `--header-h` on <html>, and globals.css derives the
* native `:target` landing (`scroll-margin-top`) from the same expression – so a
* JS jump and a native jump end on the same pixel (spec A/D5).
*/
export function anchorOffset(): number {
if (typeof document === "undefined") return -(HEADER_FALLBACK + 16);
const raw = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--header-h"));
return -((Number.isFinite(raw) && raw > 0 ? raw : HEADER_FALLBACK) + 16);
}
function normalizePath(p: string): string {
const s = p.split("#")[0].split("?")[0];
return s.endsWith("/") ? s : `${s}/`;
}
/** Smooth-scrolls to `#id` (Lenis when available, else native), respecting the header offset. */
export function scrollToHash(hash: string, immediate = false) {
const id = hash.replace(/^#/, "");
const el = document.getElementById(id);
if (!el) return;
const lenis = getLenis();
const offset = anchorOffset();
if (lenis) {
lenis.scrollTo(el, { offset, immediate, force: true });
} else {
const top = el.getBoundingClientRect().top + window.scrollY + offset;
window.scrollTo({ top, behavior: immediate || prefersReducedMotion() ? "auto" : "smooth" });
}
}
/**
* next/link with the „Umblättern“ curtain. Falls back to a normal link for
* modifier/middle clicks, external targets, same-page anchors (smooth
* scroll instead), reduced motion, and while a transition is running.
*/
export function TransitionLink({ href, label, onClick, scroll, ...rest }: TransitionLinkProps) {
const pathname = usePathname();
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
onClick?.(e);
if (e.defaultPrevented) return;
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
if (rest.target && rest.target !== "_self") return;
if (!href.startsWith("/")) return;
const [path, hash] = href.split("#");
if (normalizePath(path || pathname) === normalizePath(pathname)) {
// Same page: anchor → smooth scroll; plain → to top.
e.preventDefault();
if (hash) {
scrollToHash(hash);
window.history.pushState(null, "", `#${hash}`);
} else {
const lenis = getLenis();
if (lenis) lenis.scrollTo(0, { force: true });
else window.scrollTo({ top: 0, behavior: prefersReducedMotion() ? "auto" : "smooth" });
}
return;
}
if (!hasJs() || prefersReducedMotion()) return; // plain client navigation
e.preventDefault();
if (isTransitioning()) return; // ignore double clicks
startTransition(href, label);
};
return <Link href={href} scroll={scroll ?? false} onClick={handleClick} {...rest} />;
}
export default TransitionLink;
+55
View File
@@ -0,0 +1,55 @@
/**
* Tiny framework-free store for the page transition („Umblättern“).
* TransitionLink calls `startTransition`; Curtain subscribes, animates,
* pushes the route and finally calls `endTransition`.
*/
export type TransitionPhase = "idle" | "cover" | "pushed" | "reveal";
export interface TransitionState {
phase: TransitionPhase;
href: string | null;
label?: string;
}
type Listener = (state: TransitionState) => void;
let state: TransitionState = { phase: "idle", href: null };
const listeners = new Set<Listener>();
function emit() {
listeners.forEach((cb) => cb(state));
}
/** Begins a transition. Returns false when one is already running (double clicks). */
export function startTransition(href: string, label?: string): boolean {
if (state.phase !== "idle") return false;
state = { phase: "cover", href, label };
emit();
return true;
}
/** Internal: Curtain advances the phase. */
export function setTransitionPhase(phase: TransitionPhase) {
state = { ...state, phase };
emit();
}
export function endTransition() {
state = { phase: "idle", href: null };
emit();
}
export function isTransitioning(): boolean {
return state.phase !== "idle";
}
export function getTransitionState(): TransitionState {
return state;
}
export function subscribe(cb: Listener): () => void {
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}
+168
View File
@@ -0,0 +1,168 @@
import { Section } from "@/components/layout/Section";
import { ServiceHero } from "@/components/services/ServiceHero";
import { ServiceLayout } from "@/components/services/ServiceLayout";
import { SectionHeading } from "@/components/services/SectionHeading";
import { ThreeQuestions } from "@/components/services/coaching/ThreeQuestions";
import { Reveal } from "@/components/motion/Reveal";
import { Ledger } from "@/components/ui/Ledger";
import { SpecTable, type SpecLabels } from "@/components/ui/SpecTable";
import { Facts } from "@/components/ui/Facts";
import { Steps } from "@/components/ui/Steps";
import { Marginalia } from "@/components/ui/Marginalia";
import { Faq } from "@/components/ui/Faq";
import { CtaBand } from "@/components/ui/CtaBand";
import { specLabels as specLabelsDe, type CoachingContent } from "@/content/coaching";
import { getPhotos } from "@/lib/assets";
import { flags } from "@/lib/site";
/* Placement of every section head on this page (cols 3–8). The shared
<SectionHeading/> carries no default placement – the caller owns the grid. */
const HEAD = "col-span-12 lg:col-span-6 lg:col-start-3";
export interface CoachingBodyProps {
content: CoachingContent;
/** Zeilenbeschriftungen der Bausteine-Tabelle in der Sprache der Seite. */
specLabels?: SpecLabels;
}
/**
* Der Rumpf von /leistungen/coaching/ bzw. /en/services/coaching/ (Spec §7.1 + §7.2,
* die wärmste Seite). Reihenfolge: Hero (ink) → Ausgangslage → Die drei Fragen →
* Bausteine → Rahmen → Ablauf → FAQ (Flag) → Kontakt-Band (ink).
* Alle Texte kommen als Prop; kein <main> – template.tsx liefert `main#inhalt`.
*/
export function CoachingBody({ content: coaching, specLabels = specLabelsDe }: CoachingBodyProps) {
const photos = getPhotos();
const showFaq = flags.showFaq && coaching.faq.items.length > 0;
/* Section numbering within the page („01 / 06 – Ausgangslage“ …). */
const sections: string[] = [
coaching.situations.id,
coaching.questions.id,
coaching.blocks.id,
coaching.rahmen.id,
coaching.ablauf.id,
...(showFaq ? [coaching.faq.id] : []),
];
const total = String(sections.length).padStart(2, "0");
const numeral = (id: string) => String(sections.indexOf(id) + 1).padStart(2, "0");
return (
<>
{/* text-pretty instead of the shared text-balance: balance orphans the en dash at the
start of line 2 ("Wer bist du / – und wer"). GSAP SplitText normalises the no-break
space in the copy away, so the wrap mode is what has to keep the dash with "du".
The cover has to close: with a three-line H1 the shared hero grows to its natural
928 px and the folio row („01 / 03“, „Drei Situationen ↓“) is sliced by the bottom
edge of a 900 px viewport. `lg:min-h-svh` (the same handle /leistungen/keynotes/
already uses) pins the folio to the fold, and trimming the content row from
`lg:py-20` to 3 rem keeps the natural height below the fold so the floor, not the
copy, decides where the hero ends. `.grid-cols-12` is the hero's content row – the
only 12-column grid inside the section. */}
<ServiceHero content={coaching} className="lg:min-h-svh [&_h1]:text-pretty lg:[&_.grid-cols-12]:py-12" />
<ServiceLayout>
{/* Ausgangslage – situations as an italic ledger */}
<Section
id={coaching.situations.id}
theme="light"
numeral={numeral(coaching.situations.id)}
total={total}
label={coaching.situations.eyebrow}
>
<SectionHeading eyebrow={coaching.situations.eyebrow} h2={coaching.situations.h2} className={HEAD} />
<div className="col-span-12 mt-12 lg:col-span-9 lg:col-start-3 lg:mt-16">
<Ledger variant="situations" rows={coaching.situations.rows} />
</div>
</Section>
{/* Special section: Die drei Fragen */}
<Section
id={coaching.questions.id}
theme="light"
numeral={numeral(coaching.questions.id)}
total={total}
label={coaching.questions.eyebrow}
>
<ThreeQuestions
eyebrow={coaching.questions.eyebrow}
rows={coaching.questions.rows}
photo={photos.portrait}
alt={coaching.questions.alt}
/>
</Section>
{/* Leistungsbausteine – spec table, not cards */}
<Section
id={coaching.blocks.id}
theme="light"
numeral={numeral(coaching.blocks.id)}
total={total}
label={coaching.blocks.eyebrow}
>
<SectionHeading eyebrow={coaching.blocks.eyebrow} h2={coaching.blocks.h2} className={HEAD} />
<div className="col-span-12 mt-12 lg:col-span-10 lg:col-start-3 lg:mt-16">
<SpecTable blocks={coaching.blocks.items} labels={specLabels} />
</div>
</Section>
{/* Rahmen – facts table on bone; placeholder rows follow flags.showPlaceholders */}
<Section
id={coaching.rahmen.id}
theme="light"
numeral={numeral(coaching.rahmen.id)}
total={total}
label={coaching.rahmen.eyebrow}
>
{/* The chapter needs a real heading: <Facts title> is a plain <p>, so section 04
was unreachable by heading navigation (identical fix in TrainingsRahmen). */}
<SectionHeading eyebrow={coaching.rahmen.eyebrow} className={HEAD} />
<Reveal className="col-span-12 mt-10 lg:col-span-10 lg:col-start-3">
<Facts variant="table" items={coaching.rahmen.rows} />
</Reveal>
</Section>
{/* Ablauf – four numbered rows + marginalia „Was du bekommst“ */}
<Section
id={coaching.ablauf.id}
theme="light"
numeral={numeral(coaching.ablauf.id)}
total={total}
label={coaching.ablauf.eyebrow}
>
<SectionHeading eyebrow={coaching.ablauf.eyebrow} className={HEAD} />
<div className="col-span-12 mt-12 lg:col-span-6 lg:col-start-3 lg:mt-16">
<Steps items={coaching.ablauf.steps} />
</div>
<div className="col-span-12 mt-12 lg:col-span-3 lg:col-start-10 lg:mt-16">
<Reveal delay={0.2}>
<Marginalia title={coaching.ablauf.marginalia.title} items={coaching.ablauf.marginalia.items} sticky />
</Reveal>
</div>
</Section>
{/* FAQ – only with delivered answers (flags.showFaq) */}
{showFaq && (
<Section
id={coaching.faq.id}
theme="light"
numeral={numeral(coaching.faq.id)}
total={total}
label={coaching.faq.eyebrow}
>
<SectionHeading eyebrow={coaching.faq.eyebrow} className={HEAD} />
<Reveal className="col-span-12 mt-12 lg:col-span-8 lg:col-start-3 lg:mt-16">
<Faq items={coaching.faq.items} />
</Reveal>
</Section>
)}
</ServiceLayout>
{/* Kontakt band (ink) – same primary CTA as the hero */}
<CtaBand h2={coaching.band.h2} cta={coaching.band.cta} />
</>
);
}
export default CoachingBody;
+89
View File
@@ -0,0 +1,89 @@
import { BrandMark } from "@/components/brand/BrandMark";
import { Hero } from "@/components/home/Hero";
import { Positioning } from "@/components/home/Positioning";
import { ServicesIndex } from "@/components/home/ServicesIndex";
import { WolfPrinciple } from "@/components/home/WolfPrinciple";
import { About } from "@/components/home/About";
import { Audience } from "@/components/home/Audience";
import { ProcessContact } from "@/components/home/ProcessContact";
import type { HomeContent } from "@/content/home";
import { getPhotos } from "@/lib/assets";
import { routesByLang, type Lang } from "@/lib/i18n";
import { rootCopy } from "@/lib/metadata";
import { site } from "@/lib/site";
/**
* Der Rumpf der Startseite – beide Sprachfassungen rendern durch diese Komponente,
* die Route reicht nur ihre Inhaltsdatei herein. Ohne das stünde derselbe Baum
* zweimal im Projekt und liefe garantiert auseinander.
*
* Sieben Abschnitte im Hell-Dunkel-Rhythmus der Spec:
* Hero (ink) → Positionierung → Leistungen (ivory) → WOLF-Prinzip (ink) →
* Über Anelia → Für wen + Termine (ivory) → Kontakt (ink). Kein <main> –
* template.tsx rendert `main#inhalt`.
*/
export function HomeBody({ lang, content }: { lang: Lang; content: HomeContent }) {
const photos = getPhotos();
/* Nur ein Foto vorhanden → die Hero-Datei trägt auch „Über Anelia“ (engerer Schnitt, Spec §9). */
const aboutPhoto = photos.portrait ?? photos.hero;
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd(lang, content)).replace(/</g, "\\u003c") }}
/>
<Hero mark={<BrandMark idPrefix="hero" handoff />} photo={photos.hero} content={content.hero} />
<Positioning content={content.positioning} />
<ServicesIndex content={content.services} />
<WolfPrinciple mark={<BrandMark idPrefix="prinzip" />} content={content.principle} />
<About photo={aboutPhoto} content={content.about} />
<Audience content={content.audience} />
<ProcessContact content={content.contact} privacyHref={routesByLang[lang].datenschutz} />
</>
);
}
/**
* Person + die drei Leistungen (schema.org).
*
* `@id` bleibt in beiden Sprachen dieselbe Person – die deutsche Startseite ist der
* kanonische Ort, sonst stünden zwei Personen mit identischen Kontaktdaten im Graph.
* Die Leistungen bekommen die URL ihrer eigenen Sprachfassung, damit `url` und die
* ausgelieferte Seite zusammenpassen.
*/
function jsonLd(lang: Lang, content: HomeContent) {
const PERSON_ID = `${site.url}/#person`;
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Person",
"@id": PERSON_ID,
name: site.name,
jobTitle: content.hero.masthead.role,
description: rootCopy[lang].description,
url: `${site.url}${routesByLang[lang].home}`,
email: site.email,
telephone: site.phoneDisplay,
sameAs: [site.linkedin],
knowsLanguage: ["de", "en"],
knowsAbout: content.services.rows.flatMap((row) => [...row.keywords]),
mainEntityOfPage: `${site.url}${routesByLang.de.home}`,
},
...content.services.rows.map((row) => ({
"@type": "Service",
"@id": `${site.url}${row.link.href}#service`,
name: row.title,
description: row.oneLiner,
serviceType: [...row.keywords],
url: `${site.url}${row.link.href}`,
provider: { "@id": PERSON_ID },
availableLanguage: ["de", "en"],
areaServed: { "@type": "Country", name: "DE" },
})),
],
};
}
export default HomeBody;
+124
View File
@@ -0,0 +1,124 @@
import { Section } from "@/components/layout/Section";
import { ServiceHero } from "@/components/services/ServiceHero";
import { ServiceLayout } from "@/components/services/ServiceLayout";
import { Occasions } from "@/components/services/keynotes/Occasions";
import { SectionHeading } from "@/components/services/SectionHeading";
import { SignatureTalk, type TalkLabels } from "@/components/services/keynotes/SignatureTalk";
import { SpeakerProfile } from "@/components/services/keynotes/SpeakerProfile";
import { CtaBand } from "@/components/ui/CtaBand";
import { FactsStrip } from "@/components/ui/FactsStrip";
import { Faq } from "@/components/ui/Faq";
import { Interruption } from "@/components/ui/Interruption";
import { Ledger } from "@/components/ui/Ledger";
import { Reveal } from "@/components/motion/Reveal";
import { talkLabels as talkLabelsDe, type KeynotesContent } from "@/content/keynotes";
import { getPhotos, getSpeakerPdf } from "@/lib/assets";
import { flags } from "@/lib/site";
/** Content column of every light section: cols 3–12 on desktop. */
const BODY = "col-span-12 lg:col-span-10 lg:col-start-3";
/** Heading column: cols 3–8. */
const HEAD = "col-span-12 lg:col-span-6 lg:col-start-3";
/** FAQ ledger: cols 3–10 – identical to /coaching/ and /trainings/ so the „+“ sits at one x. */
const FAQ_COL = "col-span-12 lg:col-span-8 lg:col-start-3";
export interface KeynotesBodyProps {
content: KeynotesContent;
/** Zeilenbeschriftungen im Vortragsblock – in der Sprache der Seite. */
talkLabels?: TalkLabels;
}
/**
* Der Rumpf von /leistungen/keynotes/ bzw. /en/services/keynotes/ (Spec §7.1 + §7.4,
* „die Bühnenseite“). Server Component: Foto und PDF werden zur Bauzeit über lib/assets
* geprüft. Kein <main> – template.tsx rendert `main#inhalt`.
*
* Reihenfolge: Hero (ink, optionales Bühnenfoto) → 01 Der Vortrag → Interruption (ink, auf
* oberster Ebene, damit sie ihren eigenen Grund malt) → 02 Anlässe → 03 Publikum →
* 04 Für Veranstalter (Speaker-Facts, Termine, PDF-Zeile nur bei gelieferter Datei) →
* 05 FAQ (Flag) → Kontakt-Band (ink).
*/
export function KeynotesBody({ content: keynotes, talkLabels = talkLabelsDe }: KeynotesBodyProps) {
const { stage } = getPhotos();
const speakerPdf = flags.showSpeakerPdf ? getSpeakerPdf() : null;
const showFaq = flags.showFaq && keynotes.faq.items.length > 0;
const total = showFaq ? "05" : "04";
const { talk, interruption, occasions, takeaways, speakerFacts, termine, veranstalter, faq, band } = keynotes;
return (
<>
{/* One-line H1: without the extra height the folio floats mid-viewport and the
next section's sticky marginal peeks in at scrollY 0. */}
<ServiceHero content={keynotes} photo={stage} className="lg:min-h-svh" />
<ServiceLayout>
<Section id={talk.id} theme="light" numeral="01" total={total} label={talk.eyebrow}>
<SectionHeading eyebrow={talk.eyebrow} h2={talk.h2} className={HEAD} />
<div className={`${BODY} mt-16`}>
<SignatureTalk rows={talk.rows} labels={talkLabels} />
</div>
</Section>
</ServiceLayout>
{/* 100 svh is a stage on desktop; on a phone it would be ~70 % empty. */}
<Interruption
id={interruption.id}
text={interruption.text}
mono={interruption.mono}
className="min-h-[70svh] lg:min-h-svh"
/>
<ServiceLayout>
<Section id={occasions.id} theme="light" numeral="02" total={total} label={occasions.eyebrow}>
<SectionHeading eyebrow={occasions.eyebrow} h2={occasions.h2} className={HEAD} />
<div className={`${BODY} mt-16`}>
<Occasions items={occasions.columns} />
</div>
</Section>
<Section id={takeaways.id} theme="light" numeral="03" total={total} label={takeaways.eyebrow}>
<SectionHeading eyebrow={takeaways.eyebrow} h2={takeaways.h2} className={HEAD} />
<div className={`${BODY} mt-16`}>
<Ledger variant="goals" rows={takeaways.rows} />
</div>
</Section>
<Section id={veranstalter.id} theme="light" numeral="04" total={total} label={veranstalter.eyebrow}>
<SectionHeading eyebrow={veranstalter.eyebrow} h2={veranstalter.h2} className={HEAD} />
<Reveal className={`${BODY} mt-16`}>
<div id={speakerFacts.id}>
<FactsStrip items={speakerFacts.items} />
</div>
</Reveal>
<div id={termine.id} className={`${BODY} mt-16`}>
<Ledger variant="termine" rows={termine.rows} heading={termine.heading} />
</div>
{/* Spec §7.4: die Zeile erscheint nur, wenn die PDF-Datei geliefert wurde. */}
{speakerPdf && (
<div className={`${BODY} mt-16`}>
<SpeakerProfile
label={veranstalter.speakerPdf.label}
text={veranstalter.speakerPdf.text}
href={speakerPdf}
/>
</div>
)}
</Section>
{showFaq && (
<Section id={faq.id} theme="light" numeral="05" total={total} label={faq.eyebrow}>
<SectionHeading eyebrow={faq.eyebrow} className={HEAD} />
<Reveal className={`${FAQ_COL} mt-12`}>
<Faq items={faq.items} />
</Reveal>
</Section>
)}
</ServiceLayout>
<CtaBand h2={band.h2} cta={band.cta} micro={band.micro} />
</>
);
}
export default KeynotesBody;
+57
View File
@@ -0,0 +1,57 @@
import { BrandMark } from "@/components/brand/BrandMark";
import { Section } from "@/components/layout/Section";
import { TextLink } from "@/components/ui/TextLink";
import { notFound as notFoundDe, type NotFoundContent } from "@/content/not-found";
import { notFoundEn } from "@/content/en/not-found";
/**
* 404 (Spec §8 / §5.5): ink, Monogramm 40vh, eine Display-Zeile mit genau einem kursiven
* Wort, kurzer Text, Textlink zur Startseite. Das Monogramm steht vollständig – warum die
* „wolflose“ Variante der Spec mit dem echten Logo nicht funktioniert, steht in
* src/content/not-found.ts.
*
* Zweisprachig, und zwar aus einem technischen Grund: Ein statischer Export kennt genau
* EINE Fehlerseite (`out/404.html`). Wer sich auf einer englischen Adresse vertippt,
* bekommt also dieselbe Datei. Deshalb steht unter dem deutschen Text ein englischer
* Zweizeiler – in `lang="en"` ausgezeichnet, damit ein Screenreader die Sprache wechselt.
*/
export function NotFoundBody({ content = notFoundDe }: { content?: NotFoundContent } = {}) {
const { h1 } = content;
return (
<Section
id="nicht-gefunden"
theme="dark"
className="flex min-h-svh flex-col justify-center pt-[calc(var(--header-h,7.5rem)+1.5rem)]"
>
<div className="col-span-12 lg:col-span-8 lg:col-start-3">
{/* Spec A/D6: on phones the mark is sized by WIDTH (40vh would be ~422 px wide in a
390 px viewport and widen the document); from `sm` it goes back to 40vh height. */}
<BrandMark
idPrefix="nf"
title={content.markTitle}
className="h-auto w-full max-w-[min(100%,52vh)] text-fg sm:h-[40vh] sm:w-auto sm:max-w-full"
/>
<h1 className="mt-12 max-w-[14ch] font-display text-[clamp(2.75rem,1.4rem+5.4vw,7rem)] font-normal leading-[0.96] tracking-[-0.015em] text-fg [text-wrap:balance]">
{h1.before}
<em className="italic">{h1.em}</em>
{h1.after}
</h1>
<p className="mt-8 max-w-[40ch] font-sans text-xl font-light leading-[1.45] text-fg-muted">{content.text}</p>
<div className="mt-10">
<TextLink href={content.link.href} label={content.link.label} />
</div>
{/* Der englische Hinweis – kleiner gesetzt, damit er als Fußnote liest, nicht als
zweite Hälfte der Seite. Die goldene Haarlinie trennt die Sprachen. */}
<div lang="en" className="mt-14 border-t border-line pt-8">
<p className="max-w-[46ch] font-sans text-base font-light leading-[1.5] text-grey-warm">{notFoundEn.text}</p>
<div className="mt-6">
<TextLink href={notFoundEn.link.href} label={notFoundEn.link.label} />
</div>
</div>
</div>
</Section>
);
}
export default NotFoundBody;
+64
View File
@@ -0,0 +1,64 @@
import { Section } from "@/components/layout/Section";
import { ServiceHero } from "@/components/services/ServiceHero";
import { ServiceLayout } from "@/components/services/ServiceLayout";
import { TrainingsSituations } from "@/components/services/trainings/TrainingsSituations";
import { TrainingsModules } from "@/components/services/trainings/TrainingsModules";
import { TrainingsMethod } from "@/components/services/trainings/TrainingsMethod";
import { TrainingsGoals } from "@/components/services/trainings/TrainingsGoals";
import { TrainingsRahmen } from "@/components/services/trainings/TrainingsRahmen";
import { TrainingsAblauf } from "@/components/services/trainings/TrainingsAblauf";
import { TrainingsFaq } from "@/components/services/trainings/TrainingsFaq";
import { TrainingsBand } from "@/components/services/trainings/TrainingsBand";
import type { TrainingsContent } from "@/content/trainings";
import type { HomeContent } from "@/content/home";
import { flags } from "@/lib/site";
const pad = (n: number) => String(n).padStart(2, "0");
export interface TrainingsBodyProps {
content: TrainingsContent;
/** „Über Anelia“-Randspalte im Abschnitt „Wie ich arbeite“ – in der Sprache der Seite. */
marginalia: HomeContent["about"]["marginalia"];
}
/**
* Der Rumpf von /leistungen/trainings/ bzw. /en/services/trainings/ (Spec §7.1 + §7.3).
* Die „Angebots“-Seite, am wenigsten Bewegung: nur <Rule/>, <Lines/>, <Reveal/> – kein Pin,
* kein Draw, kein Blur, kein Foto (das Bühnenfoto gehört zu den Keynotes, §7.1).
* Kein <main>: template.tsx liefert `main#inhalt`.
*/
export function TrainingsBody({ content: trainings, marginalia }: TrainingsBodyProps) {
/* Helle Abschnitte in Lesereihenfolge; FAQ nur mit `flags.showFaq` (Spec §7.1.6). */
const sections = [
{ id: trainings.situations.id, label: trainings.situations.eyebrow, body: <TrainingsSituations content={trainings} /> },
{ id: trainings.modules.id, label: trainings.modules.eyebrow, body: <TrainingsModules content={trainings} /> },
{
id: trainings.how.id,
label: trainings.how.eyebrow,
body: <TrainingsMethod content={trainings} marginalia={marginalia} />,
},
{ id: trainings.goals.id, label: trainings.goals.eyebrow, body: <TrainingsGoals content={trainings} /> },
{ id: trainings.rahmen.id, label: trainings.rahmen.eyebrow, body: <TrainingsRahmen content={trainings} /> },
{ id: trainings.ablauf.id, label: trainings.ablauf.eyebrow, body: <TrainingsAblauf content={trainings} /> },
...(flags.showFaq && trainings.faq.items.length > 0
? [{ id: trainings.faq.id, label: trainings.faq.eyebrow, body: <TrainingsFaq content={trainings} /> }]
: []),
];
const total = pad(sections.length);
return (
<>
<ServiceHero content={trainings} />
<ServiceLayout>
{sections.map((s, i) => (
<Section key={s.id} id={s.id} theme="light" numeral={pad(i + 1)} total={total} label={s.label}>
{s.body}
</Section>
))}
</ServiceLayout>
<TrainingsBand content={trainings} />
</>
);
}
export default TrainingsBody;
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useEffect } from "react";
import Lenis from "lenis";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
/**
* Lenis smooth scrolling, driven by GSAP's ticker so ScrollTrigger and Lenis
* share one clock (no jitter, no double rAF). Disabled for users who prefer
* reduced motion and on coarse pointers (touch), where native scrolling feels best.
*/
export function SmoothScroll({ children }: { children: React.ReactNode }) {
useEffect(() => {
/* The bundle booted, so the reveal states will be cleared by GSAP: cancel the
2.5 s un-hide failsafe the inline script in layout.tsx armed. */
const w = window as unknown as { __unhide?: number };
if (w.__unhide) {
clearTimeout(w.__unhide);
delete w.__unhide;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const touch = window.matchMedia("(pointer: coarse)").matches;
if (reduce || touch) return;
const lenis = new Lenis({
autoRaf: false,
lerp: 0.09,
wheelMultiplier: 0.9,
smoothWheel: true,
});
lenis.on("scroll", ScrollTrigger.update);
const tick = (time: number) => lenis.raf(time * 1000);
gsap.ticker.add(tick);
gsap.ticker.lagSmoothing(0);
// Expose for anchor links / programmatic scroll
(window as unknown as { lenis?: Lenis }).lenis = lenis;
return () => {
gsap.ticker.remove(tick);
lenis.destroy();
delete (window as unknown as { lenis?: Lenis }).lenis;
};
}, []);
return <>{children}</>;
}
@@ -0,0 +1,50 @@
import { cn } from "@/lib/utils";
import { Lines } from "@/components/motion/Lines";
import { Reveal } from "@/components/motion/Reveal";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { HeadlineText } from "@/components/ui/CtaBand";
import type { Headline } from "@/content/home";
/** H2 on ivory: Bodoni 400, 36 → 76 px, lh 1.02, max 16 ch (Spec §3.2). */
export const H2_LIGHT =
"max-w-[16ch] font-display text-[clamp(2.25rem,1.4rem+3.2vw,4.75rem)] font-normal leading-[1.02] tracking-[-0.01em] text-fg [text-wrap:balance]";
export interface SectionHeadingProps {
eyebrow: string;
/** With an H2 the eyebrow is a `<p>`; without, the eyebrow itself is the section's `<h2>`. */
h2?: Headline;
className?: string;
}
/**
* The head of a light service section – ONE component for all three service
* pages (PLAN §2.10 reserves src/components/services/ for exactly this).
* The three per-service copies had drifted into two different H2 clamps, two
* different reveal behaviours and, worse, two of them dropped the heading
* entirely when no H2 was supplied: 5 of 7 trainings chapters and 3 of 6
* coaching chapters shipped with no `<h2>` at all, so heading navigation
* skipped them.
*
* Placement (`col-span-*` / `col-start-*`) comes from the caller.
*/
export function SectionHeading({ eyebrow, h2, className }: SectionHeadingProps) {
if (!h2) {
return (
<Reveal className={className}>
<Eyebrow as="h2">{eyebrow}</Eyebrow>
</Reveal>
);
}
return (
<div className={cn("flex flex-col items-start gap-8", className)}>
<Reveal>
<Eyebrow>{eyebrow}</Eyebrow>
</Reveal>
<Lines as="h2" className={H2_LIGHT}>
<HeadlineText {...h2} />
</Lines>
</div>
);
}
export default SectionHeading;
+117
View File
@@ -0,0 +1,117 @@
import { cn } from "@/lib/utils";
import { Container } from "@/components/layout/Container";
import { Folio } from "@/components/layout/Folio";
import { GRID } from "@/components/layout/Section";
import { Lines } from "@/components/motion/Lines";
import { Reveal } from "@/components/motion/Reveal";
import type { Cta, FactItem, Headline } from "@/content/home";
import { Button } from "@/components/ui/Button";
import { Facts } from "@/components/ui/Facts";
import { Portrait } from "@/components/ui/Portrait";
/** The slice of `coaching` / `trainings` / `keynotes` the hero needs (structural). */
export interface ServiceHeroContent {
/** „01“ / „02“ / „03“ – mono, cols 1–2. */
numeral: string;
masthead: { center: string };
eyebrow: string;
h1: Headline;
/** The italic word may also carry `.gold-text` (keynotes – the page's one gradient word). */
h1GoldEm?: boolean;
subline: string;
cta: Cta;
facts: readonly FactItem[];
/** No fraction here – the page's own section counter is the only counter (see <Folio/>). */
folio: { next?: { label: string; href: string } };
/** Photo alt / caption (keynote stage photo). */
alt?: string;
caption?: string;
}
export interface ServiceHeroProps {
content: ServiceHeroContent;
/** Stage photo (keynotes) – `null`/undefined = no photo. */
photo?: string | null;
className?: string;
}
/**
* Service-page hero (ink, ≥ 60svh): mono numeral, H1 with one italic word,
* subline, the page's primary CTA, facts stack top-right, folio with the next
* section. Header monogram only – no hero draw.
*/
export function ServiceHero({ content, photo, className }: ServiceHeroProps) {
/* `masthead` und `eyebrow` stehen weiter in den Inhaltsdateien, werden aber seit
dem 06.09.2026 nicht mehr gerendert (Kundenwunsch): die Kopfzeile trug dieselben
Angaben doppelt, und über der H1 stand faktisch die Überschrift ein zweites Mal. */
const { numeral, masthead, h1, h1GoldEm, subline, cta, facts, folio, alt, caption } = content;
const hasPhoto = Boolean(photo);
return (
<section
data-theme="dark"
className={cn(
"relative flex min-h-[60svh] flex-col bg-transparent pt-[calc(var(--header-h,7.5rem)+1.5rem)] text-fg",
className,
)}
>
<Container className="flex flex-1 flex-col">
<div className={cn(GRID, "flex-1 gap-y-12 py-14 lg:py-20")}>
<Reveal
play="load"
delay={0.3}
as="p"
className="hidden font-mono text-xs tracking-[0.06em] tabular-nums text-fg-muted lg:col-span-2 lg:block lg:pt-1"
>
{numeral}
</Reveal>
<div className={cn("col-span-12 flex flex-col lg:col-start-3", hasPhoto ? "lg:col-span-5" : "lg:col-span-7")}>
<Lines
as="h1"
play="load"
delay={0.35}
className="max-w-[14ch] font-display text-[clamp(2.75rem,1.4rem+5.4vw,7rem)] font-normal leading-[0.96] tracking-[-0.015em] text-ivory [text-wrap:balance]"
>
{h1.before}
{h1.em && <em className={cn("italic", h1GoldEm && "gold-text")}>{h1.em}</em>}
{h1.after}
</Lines>
<Reveal play="load" delay={1.0} as="p" className="mt-10 max-w-[40ch] font-sans text-xl font-light leading-[1.45] text-fg-muted">
{subline}
</Reveal>
<Reveal play="load" delay={1.2} className="mt-8">
<Button {...cta} />
</Reveal>
</div>
<div
className={cn(
"col-span-12 flex flex-col gap-10",
hasPhoto ? "lg:col-span-5 lg:col-start-8" : "lg:col-span-3 lg:col-start-10",
)}
>
<Reveal play="load" delay={0.6} className={cn(hasPhoto && "lg:self-end")}>
<Facts items={facts} />
</Reveal>
{photo && (
<Portrait
src={photo}
alt={alt ?? masthead.center}
aspect="16/10"
caption={caption}
priority
frame={false}
sizes="(min-width: 1024px) 40vw, 100vw"
/>
)}
</div>
</div>
<Folio next={folio.next} play="load" delay={1.3} />
</Container>
</section>
);
}
export default ServiceHero;
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Rule } from "@/components/motion/Rule";
export interface ServiceLayoutProps {
className?: string;
children: ReactNode;
}
/**
* Light body of a service page: the ink→ivory boundary hairline, then the
* content sections (`<Section theme="light">` each: Ausgangslage, Bausteine,
* Rahmen, Ablauf, FAQ …). The dark contact band (`<CtaBand/>`) sits outside,
* after this wrapper. Paints its own ivory ground; the seam to the ink hero
* above is the gold hairline alone (spec §2.3) – no fade, no gradient.
*/
export function ServiceLayout({ className, children }: ServiceLayoutProps) {
return (
<div data-theme="light" className={cn("relative text-fg", className)}>
<Rule variant="gold" className="relative z-[1]" />
{children}
</div>
);
}
export default ServiceLayout;
@@ -0,0 +1,100 @@
import { cn } from "@/lib/utils";
import { Lines } from "@/components/motion/Lines";
import { Reveal } from "@/components/motion/Reveal";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { Portrait } from "@/components/ui/Portrait";
export interface QuestionRow {
q: string;
a: string;
}
export interface ThreeQuestionsProps {
eyebrow: string;
rows: readonly QuestionRow[];
/** Portrait (4:5, 240 px, sticky) – `null` = documented variant B without photo. */
photo?: string | null;
alt: string;
className?: string;
}
/** Gutter identical to the page grid so the inner rows align with cols 3–12. */
const GUTTER = "gap-x-[clamp(16px,2vw,32px)]";
/**
* „Die drei Fragen“ (Spec §7.2): three double rows. Left the question in
* Bodoni ≥ 40 px, right the answer as a Jost 300 lead – both start on the
* same optical line so the pair reads as one unit. Variant A (photo
* delivered) keeps the tall row and the sticky question next to the sticky
* portrait in cols 10–12; variant B (no photo) is content-tall and static.
* Renders inside a `<Section/>` grid – every child places itself.
*/
export function ThreeQuestions({ eyebrow, rows, photo, alt, className }: ThreeQuestionsProps) {
const hasPhoto = Boolean(photo);
return (
<>
{/* The eyebrow IS the section's heading: the three questions below are `<h3>`, and
without an `<h2>` of their own they were reported as sub-topics of the previous
chapter („Drei Situationen.“) – a heading-level skip across a section boundary. */}
<div className={cn("col-span-12 lg:col-span-8 lg:col-start-3", className)}>
<Reveal>
<Eyebrow as="h2">{eyebrow}</Eyebrow>
</Reveal>
</div>
<div className={cn("col-span-12 mt-10 lg:col-start-3 lg:mt-14", hasPhoto ? "lg:col-span-7" : "lg:col-span-8")}>
<ol className="border-t border-line">
{rows.map((row, i) => (
<li
key={row.q}
className={cn(
"grid grid-cols-1 gap-y-6 border-b border-line",
GUTTER,
/* With the photo the row stays generous (the portrait needs the height); without it
the row is only as tall as its content, so question and answer stay side by side. */
hasPhoto
? "py-[clamp(2.5rem,7vh,5.5rem)] lg:min-h-[50vh] lg:grid-cols-[4fr_3fr]"
: "py-[clamp(2rem,4.5vh,3.5rem)] lg:grid-cols-[5fr_5fr]",
)}
>
{/* Sticky only in variant A: the portrait keeps that row tall. Without the photo the
row is only as tall as its content, so a sticky question could just drift away
from the answer it belongs to. */}
<div className={cn(hasPhoto && "lg:sticky lg:top-[calc(var(--header-h,7.5rem)+2rem)] lg:self-start")}>
<Lines
as="h3"
className="font-display text-[clamp(2.5rem,1.4rem+3.6vw,5rem)] font-normal leading-[1.02] tracking-[-0.01em] text-fg [text-wrap:balance]"
>
{row.q}
</Lines>
</div>
<Reveal
as="p"
delay={0.08 * (i % 3)}
className="max-w-[38ch] font-sans text-[clamp(1.25rem,1.1rem+0.6vw,1.5rem)] font-light leading-[1.45] text-fg lg:pt-[0.55em]"
>
{row.a}
</Reveal>
</li>
))}
</ol>
</div>
{photo && (
<div className="col-span-12 mt-12 lg:col-span-3 lg:col-start-10 lg:mt-14">
<Portrait
src={photo}
alt={alt}
aspect="4/5"
sticky
sizes="(min-width: 1024px) 240px, 60vw"
className="max-w-[240px]"
/>
</div>
)}
</>
);
}
export default ThreeQuestions;
@@ -0,0 +1,45 @@
import { cn } from "@/lib/utils";
import { RevealGroup, RevealItem } from "@/components/motion/Reveal";
import type { ColumnItem } from "@/content/home";
/**
* „Anlässe“ (Keynotes §7.4): drei gleichwertige Bodoni-Titel, jeder über einer
* Haarlinie, darunter je drei Beispielzeilen in Jost 17 px zwischen Haarlinien –
* ohne diesen Körper stand die Spalte als Kopfzeile über leerem Elfenbein (QA R2).
*
* Keine Versatz-Asymmetrie wie in `<Columns/>`: die drei Spalten sind hier
* gleich lang, ein 48-px-Versatz läse als Fehler.
*/
export interface OccasionsProps {
items: readonly ColumnItem[];
className?: string;
}
export function Occasions({ items, className }: OccasionsProps) {
return (
<RevealGroup stagger={0.12} className={cn("grid grid-cols-1 gap-x-12 gap-y-12 lg:grid-cols-3", className)}>
{items.map((col) => (
<RevealItem key={col.title} className="flex flex-col border-t border-line pt-6">
{col.label && <p className="mb-4 font-mono text-xs tracking-[0.06em] text-accent">{col.label}</p>}
<h3 className="max-w-[16ch] font-display text-[1.75rem] font-normal leading-[1.15] text-fg [text-wrap:balance]">
{col.title}
</h3>
{col.lines && col.lines.length > 0 && (
<ul className="mt-8">
{col.lines.map((line) => (
<li
key={line}
className="border-b border-line py-3 font-sans text-[1.0625rem] leading-snug text-fg-muted"
>
{line}
</li>
))}
</ul>
)}
</RevealItem>
))}
</RevealGroup>
);
}
export default Occasions;
@@ -0,0 +1,56 @@
import { cn } from "@/lib/utils";
import { RevealGroup, RevealItem } from "@/components/motion/Reveal";
import { MetaLines } from "@/components/ui/MetaLines";
import { talkLabels as talkLabelsDe, type TalkRow } from "@/content/keynotes";
const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums";
/** Die beiden Zeilenbeschriftungen – in der Sprache der Seite. */
export type TalkLabels = { message: string; audience: string };
export interface SignatureTalkProps {
labels?: TalkLabels;
rows: readonly TalkRow[];
className?: string;
}
/**
* Signature-Talk ledger (replaces Ausgangslage on the keynote page): per talk
* one hairline row cols [numeral 1] [title Bodoni 4] [Kernbotschaft / Für wen 5]
* [meta mono 2]. One row today; further talks are flag-gated in content.
*/
export function SignatureTalk({ rows, className, labels = talkLabelsDe }: SignatureTalkProps) {
return (
<RevealGroup as="ol" stagger={0.1} className={cn("border-t border-line", className)}>
{rows.map((row) => (
<RevealItem
key={row.numeral}
as="li"
className="grid grid-cols-1 gap-y-6 border-b border-line py-8 lg:grid-cols-12 lg:gap-x-[clamp(16px,2vw,32px)] lg:py-12"
>
<div className="flex items-baseline gap-4 lg:contents">
<span className={cn(MONO, "text-accent lg:col-span-1 lg:pt-[0.6em]")}>{row.numeral}</span>
<h3 className="font-display text-[clamp(1.75rem,1.4rem+1.2vw,2.5rem)] font-normal leading-[1.1] text-fg lg:col-span-5 lg:pr-6">
{row.title}
</h3>
</div>
<dl className="flex flex-col gap-5 lg:col-span-4">
<div>
<dt className={cn(MONO, "mb-2 text-accent")}>{labels.message}</dt>
<dd className="max-w-[36rem] font-sans text-[1.0625rem] leading-[1.6] text-fg">{row.message}</dd>
</div>
<div>
<dt className={cn(MONO, "mb-2 text-accent")}>{labels.audience}</dt>
<dd className="max-w-[36rem] font-sans text-[1.0625rem] leading-[1.6] text-fg">{row.audience}</dd>
</div>
</dl>
<p className={cn(MONO, "text-fg-muted lg:col-span-2 lg:pt-[0.6em] lg:text-right")}>
<MetaLines text={row.meta} />
</p>
</RevealItem>
))}
</RevealGroup>
);
}
export default SignatureTalk;
@@ -0,0 +1,40 @@
import { cn } from "@/lib/utils";
import { Reveal } from "@/components/motion/Reveal";
import { TextLink } from "@/components/ui/TextLink";
const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums";
export interface SpeakerProfileProps {
/** „Speaker-Profil (PDF)“ */
label: string;
/** „Kurzvita, Themen, Fotos, Technik“ */
text: string;
/** Public URL of the PDF – `null` = not delivered → plain text row, no link. */
href: string | null;
className?: string;
}
/**
* „Für Veranstalter“ row: one hairline row [mono label] [Jost text]. With the
* PDF delivered (`flags.showSpeakerPdf` + file in /public) the label becomes
* an external text link (↗); otherwise the sentence stands as plain text.
*/
export function SpeakerProfile({ label, text, href, className }: SpeakerProfileProps) {
return (
<Reveal
className={cn(
"grid grid-cols-1 gap-y-2 border-y border-line py-6 sm:grid-cols-4 sm:items-baseline sm:gap-x-6",
className,
)}
>
{href ? (
<TextLink href={href} label={label} external className="sm:col-span-1" />
) : (
<p className={cn(MONO, "text-accent sm:col-span-1")}>{label}</p>
)}
<p className="font-sans text-[1.0625rem] leading-snug text-fg sm:col-span-3">{text}</p>
</Reveal>
);
}
export default SpeakerProfile;
@@ -0,0 +1,29 @@
import { Reveal } from "@/components/motion/Reveal";
import { Marginalia } from "@/components/ui/Marginalia";
import { Steps } from "@/components/ui/Steps";
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
import { SectionHeading } from "@/components/services/SectionHeading";
/**
* Ablauf – four numbered rows (Bedarfsgespräch → Follow-up nach 30 Tagen),
* cols 3–8, plus the „Was du bekommst“ marginalia in cols 10–12 (sticky on
* desktop) so the spread reads like coaching’s Ablauf.
*/
export function TrainingsAblauf({ content = trainingsDe }: { content?: TrainingsContent }) {
const { eyebrow, steps, marginalia } = content.ablauf;
return (
<>
<SectionHeading eyebrow={eyebrow} className="col-span-12 lg:col-span-6 lg:col-start-3" />
<div className="col-span-12 mt-10 lg:col-span-6 lg:col-start-3">
<Steps items={steps} />
</div>
<div className="col-span-12 mt-12 lg:col-span-3 lg:col-start-10 lg:mt-10">
<Reveal delay={0.2}>
<Marginalia title={marginalia.title} items={marginalia.items} sticky />
</Reveal>
</div>
</>
);
}
export default TrainingsAblauf;
@@ -0,0 +1,15 @@
import { CtaBand } from "@/components/ui/CtaBand";
import { site } from "@/lib/site";
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
/**
* Kontakt band „Sprechen wir über dein Team.“ – same primary CTA as the hero.
* Phone prominent: the number sits in the CTA micro line, in addition to the
* large „Ruf an“ tel link that <ContactLinks/> renders on the right.
*/
export function TrainingsBand({ content = trainingsDe }: { content?: TrainingsContent }) {
const { h2, cta, micro } = content.band;
return <CtaBand h2={h2} cta={cta} micro={`${micro} · ${site.phoneDisplay}`} />;
}
export default TrainingsBand;
@@ -0,0 +1,19 @@
import { Reveal } from "@/components/motion/Reveal";
import { Faq } from "@/components/ui/Faq";
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
import { SectionHeading } from "@/components/services/SectionHeading";
/** FAQ – native `<details>` ledger incl. the price question („auf Anfrage“), cols 3–10. */
export function TrainingsFaq({ content = trainingsDe }: { content?: TrainingsContent }) {
const { eyebrow, items } = content.faq;
return (
<>
<SectionHeading eyebrow={eyebrow} className="col-span-12 lg:col-span-6 lg:col-start-3" />
<Reveal className="col-span-12 mt-10 lg:col-span-8 lg:col-start-3">
<Faq items={items} />
</Reveal>
</>
);
}
export default TrainingsFaq;
@@ -0,0 +1,18 @@
import { Ledger } from "@/components/ui/Ledger";
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
import { SectionHeading } from "@/components/services/SectionHeading";
/** „Typische Ziele“ – goals (not promises) as three hairline ledger lines, cols 3–8. */
export function TrainingsGoals({ content = trainingsDe }: { content?: TrainingsContent }) {
const { eyebrow, rows } = content.goals;
return (
<>
<SectionHeading eyebrow={eyebrow} className="col-span-12 lg:col-span-6 lg:col-start-3" />
<div className="col-span-12 mt-10 lg:col-span-6 lg:col-start-3">
<Ledger variant="goals" rows={rows} />
</div>
</>
);
}
export default TrainingsGoals;
@@ -0,0 +1,62 @@
import { Reveal, RevealGroup, RevealItem } from "@/components/motion/Reveal";
import { Marginalia } from "@/components/ui/Marginalia";
import { about as aboutDe, type HomeContent } from "@/content/home";
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
import { SectionHeading } from "@/components/services/SectionHeading";
/**
* Splits „Lead-in: rest“ so the run-in can be set in italics – emphasis is
* always italic, never a heavier weight (Spec §3.2: no bold anywhere).
* The colon stays roman.
*/
function splitLead(text: string): { lead: string | null; rest: string } {
const i = text.indexOf(": ");
if (i < 0) return { lead: null, rest: text };
return { lead: text.slice(0, i), rest: text.slice(i + 2) };
}
/**
* „Wie ich arbeite“ – three short paragraphs cols 3–8 (Jost 300, 20 px),
* marginalia cols 9–12: verifiable trainer facts from content/home
* (role, LinkedIn, languages), sticky on desktop.
*/
export interface TrainingsMethodProps {
content?: TrainingsContent;
/** Die überprüfbaren Trainerinnen-Angaben aus „Über Anelia“ – in der Sprache der Seite. */
marginalia?: HomeContent["about"]["marginalia"];
}
export function TrainingsMethod({ content = trainingsDe, marginalia = aboutDe.marginalia }: TrainingsMethodProps = {}) {
const { eyebrow, paragraphs } = content.how;
return (
<>
<SectionHeading eyebrow={eyebrow} className="col-span-12 lg:col-span-6 lg:col-start-3" />
<RevealGroup
as="div"
stagger={0.1}
className="col-span-12 mt-10 flex flex-col gap-8 lg:col-span-6 lg:col-start-3 lg:mt-12"
>
{paragraphs.map((text) => {
const { lead, rest } = splitLead(text);
return (
<RevealItem key={text} as="p" className="max-w-[40ch] font-sans text-xl font-light leading-[1.5] text-fg">
{lead && (
<>
<em className="italic">{lead}</em>
{": "}
</>
)}
{rest}
</RevealItem>
);
})}
</RevealGroup>
{/* Marginalia rail: cols 10–12 on every service page, never 9–12. */}
<Reveal delay={0.2} className="col-span-12 mt-12 lg:col-span-3 lg:col-start-10 lg:mt-12 lg:pt-1">
<Marginalia items={marginalia} sticky />
</Reveal>
</>
);
}
export default TrainingsMethod;
@@ -0,0 +1,45 @@
import { Reveal } from "@/components/motion/Reveal";
import { ModuleTable } from "@/components/ui/ModuleTable";
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
import { SectionHeading } from "@/components/services/SectionHeading";
/**
* Page-level corrections to <ModuleTable/> (shared component – see report).
* Scoped to xl and above, where the ledger has room; between lg and xl the shared
* table has a separate, pre-existing problem (the Bodoni titles overrun the
* „Ziel“ column) that needs the component itself to move its five-column
* layout from `lg:` to `xl:`.
*
* 1. The „+“ carries `self-start`, so it floated above the right-aligned mono
* meta. The row is already `items-baseline`, so the glyph goes back on
* that baseline and lines up with the meta’s first line.
* 2. From 1360 px – the first width at which the mono meta has real slack –
* the trailing glyph column drops from 2 rem to 1 rem (the free space goes
* to the fr columns), so the „+“ no longer sits ~55 px clear of the meta
* and the right edge of the table resolves.
* Written out in full – Tailwind only ever sees literal class strings.
*/
const MODULE_TABLE_FIX = [
"xl:[&_.self-start]:self-baseline",
"min-[1360px]:[&>div]:grid-cols-[minmax(0,3.5fr)_minmax(0,3fr)_minmax(0,2fr)_minmax(0,2.2fr)_1rem]",
"min-[1360px]:[&_summary]:grid-cols-[minmax(0,3.5fr)_minmax(0,3fr)_minmax(0,2fr)_minmax(0,2.2fr)_1rem]",
].join(" ");
/**
* Leistungsbausteine „Drei Module.“ – ledger table cols 3–12: header row
* „Modul | Ziel | Für wen“, one native `<details>` per module (+ → ×),
* opening to the hairline-separated „Inhalte“ list.
*/
export function TrainingsModules({ content = trainingsDe }: { content?: TrainingsContent }) {
const { eyebrow, h2, items } = content.modules;
return (
<>
<SectionHeading eyebrow={eyebrow} h2={h2} className="col-span-12 lg:col-span-6 lg:col-start-3" />
<Reveal className="col-span-12 mt-12 lg:col-span-8 lg:col-start-3 lg:mt-16">
<ModuleTable modules={items} className={MODULE_TABLE_FIX} />
</Reveal>
</>
);
}
export default TrainingsModules;
@@ -0,0 +1,29 @@
import { Reveal } from "@/components/motion/Reveal";
import { SectionHeading } from "@/components/services/SectionHeading";
import { Facts } from "@/components/ui/Facts";
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
/**
* Rahmen – bone block cols 3–10 (1 px `--line`, padded) with the two-column
* mono facts table (Format · Sprache · Gruppengröße · Vorbereitung).
* Placeholder rows follow `flags.showPlaceholders` inside <Facts/>.
* No page-level grid override – the shared table stacks label above value
* below `sm` on every page, so the Rahmen block is the same object here and
* on /leistungen/coaching/.
*
* The chapter carries a real `<h2>` like every other one: <Facts title> is a
* plain `<p>`, so this section used to be unreachable by heading navigation.
*/
export function TrainingsRahmen({ content = trainingsDe }: { content?: TrainingsContent }) {
const { eyebrow, rows } = content.rahmen;
return (
<>
<SectionHeading eyebrow={eyebrow} className="col-span-12 lg:col-span-6 lg:col-start-3" />
<Reveal className="col-span-12 mt-10 lg:col-span-8 lg:col-start-3">
<Facts variant="table" items={rows} />
</Reveal>
</>
);
}
export default TrainingsRahmen;
@@ -0,0 +1,18 @@
import { Ledger } from "@/components/ui/Ledger";
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
import { SectionHeading } from "@/components/services/SectionHeading";
/** Ausgangslage „Drei Bremsen.“ – H2 cols 3–8, situations ledger cols 3–11 (rows stagger in via Reveal). */
export function TrainingsSituations({ content = trainingsDe }: { content?: TrainingsContent }) {
const { eyebrow, h2, rows } = content.situations;
return (
<>
<SectionHeading eyebrow={eyebrow} h2={h2} className="col-span-12 lg:col-span-6 lg:col-start-3" />
<div className="col-span-12 mt-12 lg:col-span-8 lg:col-start-3 lg:mt-16">
<Ledger variant="situations" rows={rows} />
</div>
</>
);
}
export default TrainingsSituations;
+73
View File
@@ -0,0 +1,73 @@
import { cn } from "@/lib/utils";
import { TransitionLink } from "@/components/motion/TransitionLink";
export interface ButtonProps {
href: string;
label: string;
/** Mono suffix that rises out of a mask inside the button on hover (≥ 1024); its width is reserved. */
suffix?: string;
/** Mono micro line under the button, e.g. „Telefonisch oder online · Deutsch oder Englisch“. */
micro?: string;
/** `primary` = 1 px accent outline + gold sheen fill on hover; `ghost` = neutral hairline, no fill. */
variant?: "primary" | "ghost";
/** `target="_blank" rel="noopener noreferrer"`. */
external?: boolean;
className?: string;
}
export const BUTTON_BASE =
"group/btn relative inline-flex items-center overflow-hidden whitespace-nowrap border px-[2.1em] py-[1.05em] font-sans text-[0.8125rem] font-medium uppercase leading-none tracking-[0.2em] text-fg transition-[color,background-color,border-color] duration-300";
/* Sheen fill: --gold-sheen at 250 % width, background-position 0 % → 100 % in .6 s. Text turns ink. */
export const BUTTON_PRIMARY =
"border-accent hover:text-ink focus-visible:text-ink before:pointer-events-none before:absolute before:inset-0 before:bg-[image:var(--gold-sheen)] before:bg-[length:250%_100%] before:bg-[position:0%_50%] before:opacity-0 before:transition-[opacity,background-position] before:duration-[600ms] before:ease-[var(--ease-out-quart)] hover:before:bg-[position:100%_50%] hover:before:opacity-100 focus-visible:before:bg-[position:100%_50%] focus-visible:before:opacity-100";
const GHOST = "border-line hover:border-accent focus-visible:border-accent";
function isInternal(href: string) {
return href.startsWith("/");
}
/**
* Outline button (radius 0, no shadow). Internal routes use the curtain
* (`TransitionLink`); mailto/tel/http render a plain `<a>`.
*/
export function Button({ href, label, suffix, micro, variant = "primary", external = false, className }: ButtonProps) {
const cls = cn(BUTTON_BASE, variant === "primary" ? BUTTON_PRIMARY : GHOST, className);
const inner = (
<>
<span className="relative z-10">{label}</span>
{suffix && (
/* The mask sits INSIDE the button and its width is reserved, so the
control never resizes under the pointer – only the line rises. */
<span aria-hidden="true" className="relative z-10 hidden overflow-hidden lg:block">
<span className="block translate-y-full whitespace-nowrap pl-3 font-mono text-[11px] normal-case leading-[1.6] tracking-[0.06em] transition-transform duration-[350ms] ease-[var(--ease-out-quart)] group-hover/btn:translate-y-0 group-focus-visible/btn:translate-y-0">
{suffix}
</span>
</span>
)}
</>
);
const link =
isInternal(href) && !external ? (
<TransitionLink href={href} label={label} className={cls}>
{inner}
</TransitionLink>
) : (
<a href={href} target={external ? "_blank" : undefined} rel={external ? "noopener noreferrer" : undefined} className={cls}>
{inner}
</a>
);
if (!micro) return link;
return (
<span className="inline-flex flex-col items-start gap-3">
{link}
<span className="font-mono text-[11px] tracking-[0.06em] text-fg-muted">{micro}</span>
</span>
);
}
export default Button;
+50
View File
@@ -0,0 +1,50 @@
import { cn } from "@/lib/utils";
import { RevealGroup, RevealItem } from "@/components/motion/Reveal";
import type { ColumnItem } from "@/content/home";
import { TextLink } from "./TextLink";
export type { ColumnItem };
export interface ColumnsProps {
items: readonly ColumnItem[];
/** Middle column offset 48 px downward (deliberate asymmetry, desktop). Default true. */
offsetMiddle?: boolean;
className?: string;
}
/**
* Three editorial columns (gap 48 px): DM Mono label, Bodoni 28 px title,
* Jost 17 px lines separated by hairlines (no bullets, no icons), one
* specific text link. Stacks below 1024 px.
*/
export function Columns({ items, offsetMiddle = true, className }: ColumnsProps) {
return (
<RevealGroup stagger={0.12} className={cn("grid grid-cols-1 gap-x-12 gap-y-14 lg:grid-cols-3", className)}>
{items.map((col, i) => (
<RevealItem
key={col.title}
className={cn("flex flex-col", offsetMiddle && i === 1 && "lg:mt-12")}
>
{col.label && <p className="mb-4 font-mono text-xs tracking-[0.06em] text-accent">{col.label}</p>}
<h3 className="font-display text-[1.75rem] font-normal leading-[1.15] text-fg">{col.title}</h3>
{col.lines && col.lines.length > 0 && (
<ul className="mt-6 border-t border-line">
{col.lines.map((line) => (
<li key={line} className="border-b border-line py-3 font-sans text-[1.0625rem] leading-snug text-fg">
{line}
</li>
))}
</ul>
)}
{col.link && (
<div className="mt-6">
<TextLink href={col.link.href} label={col.link.label} external={col.link.external} />
</div>
)}
</RevealItem>
))}
</RevealGroup>
);
}
export default Columns;
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useId, useState, type FormEvent } from "react";
import { cn } from "@/lib/utils";
import { site, routes } from "@/lib/site";
import { contact as contactDe, type HomeContent } from "@/content/home";
import { BUTTON_BASE, BUTTON_PRIMARY } from "@/components/ui/Button";
import { TextLink } from "@/components/ui/TextLink";
/**
* Kontaktformular der Startseite (Sektion 06).
*
* **Es gibt keinen Server.** Die Website ist ein statischer Export und sagt in der
* Datenschutzerklärung zu, dass sie keine Drittanbieter einbindet und keine Daten
* überträgt. Ein Formular, das irgendwohin „postet“, würde dieses Versprechen brechen
* (und einen Auftragsverarbeitungsvertrag nötig machen). Deshalb baut das Formular aus
* den Eingaben eine fertige E-Mail und übergibt sie dem Mailprogramm der Besucherin.
* Die Daten verlassen den Rechner erst, wenn sie dort auf „Senden“ drückt.
*
* Soll später doch ein echtes Formular her (Eingaben landen direkt im Postfach, ohne
* Mailprogramm), braucht es einen Endpunkt beim Hoster oder einen Formulardienst. Dann
* sind zwei Dinge Pflicht: ein Auftragsverarbeitungsvertrag und ein neuer Absatz in
* `src/content/legal.ts`. Der Umbau beträfe nur `submit()` hier in dieser Datei.
*/
export function ContactForm({
className,
content: form = contactDe.form,
privacyHref = routes.datenschutz,
}: {
className?: string;
/** Ziel des Datenschutz-Links – in der englischen Fassung /en/privacy/. */
privacyHref?: string;
/** Inhalt. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
content?: HomeContent["contact"]["form"];
}) {
const uid = useId();
const [sent, setSent] = useState(false);
const field = (name: string) => `${uid}-${name}`;
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const value = (key: string) => String(data.get(key) ?? "").trim();
const body = [
`${form.fields.name}: ${value("name")}`,
`${form.fields.email}: ${value("email")}`,
value("company") ? `${form.fields.company}: ${value("company")}` : null,
"",
value("message"),
]
.filter((line) => line !== null)
.join("\r\n");
const url = `mailto:${site.email}?subject=${encodeURIComponent(form.subject)}&body=${encodeURIComponent(body)}`;
setSent(true);
window.location.href = url;
};
const label = "mb-2 block font-mono text-[11px] uppercase tracking-[0.16em] text-fg-muted";
const input =
"w-full border-0 border-b border-line bg-transparent pb-3 font-sans text-[1.0625rem] text-fg outline-none transition-colors duration-300 placeholder:text-transparent focus:border-accent";
return (
<form onSubmit={submit} noValidate={false} className={cn("flex flex-col gap-8", className)}>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
<div>
<label htmlFor={field("name")} className={label}>
{form.fields.name}
</label>
<input id={field("name")} name="name" type="text" required autoComplete="name" className={input} />
</div>
<div>
<label htmlFor={field("email")} className={label}>
{form.fields.email}
</label>
<input id={field("email")} name="email" type="email" required autoComplete="email" className={input} />
</div>
</div>
<div>
<label htmlFor={field("company")} className={label}>
{form.fields.company}
</label>
<input id={field("company")} name="company" type="text" autoComplete="organization" className={input} />
</div>
<div>
<label htmlFor={field("message")} className={label}>
{form.fields.message}
</label>
<textarea id={field("message")} name="message" required rows={5} className={cn(input, "resize-y")} />
</div>
<div className="flex flex-col gap-4">
<button type="submit" className={cn(BUTTON_BASE, BUTTON_PRIMARY, "self-start")} aria-describedby={field("note")}>
<span className="relative z-10">{form.submit}</span>
</button>
<p id={field("note")} className="max-w-[52ch] font-mono text-[11px] leading-relaxed tracking-[0.04em] text-fg-muted">
{sent ? form.opened : form.note}{" "}
<TextLink href={privacyHref} label={form.privacy} className="!text-[11px] !tracking-[0.1em]" />
</p>
</div>
</form>
);
}
export default ContactForm;
+52
View File
@@ -0,0 +1,52 @@
import { cn } from "@/lib/utils";
import { contact as contactDe, type HomeContent } from "@/content/home";
import { RevealGroup, RevealItem } from "@/components/motion/Reveal";
export interface ContactLinksProps {
className?: string;
/** `lg:sticky lg:top-[30vh]` (homepage contact block). */
sticky?: boolean;
/** Inhalt. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
content?: HomeContent["contact"]["links"];
}
const UNDERLINE =
"bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat pb-1 transition-[background-size] duration-[400ms] ease-[var(--ease-out-quart)] group-hover/cl:bg-[length:100%_1px] group-focus-visible/cl:bg-[length:100%_1px]";
/**
* Schreib mir / Ruf an / Vernetzen – three large links (DM Mono 11 px label +
* Bodoni value with a growing accent underline). Data from content/home.
*/
export function ContactLinks({ className, sticky = false, content = contactDe.links }: ContactLinksProps) {
const items = [content.write, content.call, content.connect] as const;
return (
<RevealGroup as="ul" stagger={0.08} className={cn("flex flex-col gap-7", sticky && "lg:sticky lg:top-[30vh]", className)}>
{items.map((item) => {
const external = "external" in item && item.external;
return (
<RevealItem key={item.label} as="li" className="flex flex-col gap-2">
<span className="font-mono text-[11px] tracking-[0.06em] text-fg-muted">{item.label}</span>
<a
href={item.href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
className="group/cl inline-flex max-w-full items-baseline gap-2 self-start font-display text-[clamp(1.75rem,1.15rem+1vw,2.125rem)] font-normal leading-[1.15] text-fg [font-variant-numeric:lining-nums]"
>
<span className={cn(UNDERLINE, "min-w-0 text-accent")}>
{/* An address broken at its hyphen reads like a typo – never wrap the value. */}
<span className="whitespace-nowrap text-fg">{item.value}</span>
</span>
{external && (
<span aria-hidden="true" className="text-accent transition-transform duration-300 group-hover/cl:translate-x-1">
↗
</span>
)}
</a>
</RevealItem>
);
})}
</RevealGroup>
);
}
export default ContactLinks;
+66
View File
@@ -0,0 +1,66 @@
import { cn } from "@/lib/utils";
import { Container } from "@/components/layout/Container";
import { GRID } from "@/components/layout/Section";
import { Lines } from "@/components/motion/Lines";
import { Reveal } from "@/components/motion/Reveal";
import { Rule } from "@/components/motion/Rule";
import type { Headline } from "@/content/home";
import { Button, type ButtonProps } from "./Button";
import { ContactLinks } from "./ContactLinks";
export type { Headline };
export interface CtaBandProps {
/** One italic word via `em` (may be empty). */
h2: Headline;
cta: ButtonProps;
/** Mono micro line under the button (falls back to `cta.micro`). */
micro?: string;
id?: string;
className?: string;
}
/** Renders {before, em, after} with exactly one italic `<em>`. */
export function HeadlineText({ before, em, after }: Headline) {
return (
<>
{before}
{em && <em className="italic">{em}</em>}
{after}
</>
);
}
/**
* Dark contact band (~70svh) before the footer: boundary hairline, page-
* specific H2 (Bodoni, 44–96 px), the three contact links, the page's one
* primary CTA. Paints its own ink ground; the seam to the ivory above is the
* gold hairline alone (spec §2.3) – no fade, no gradient.
*/
export function CtaBand({ h2, cta, micro, id = "kontakt", className }: CtaBandProps) {
return (
<section id={id} data-theme="dark" className={cn("relative text-fg", className)}>
<Rule variant="gold" className="relative z-[1]" />
<Container className="relative z-[1] flex min-h-[70svh] flex-col justify-center py-[clamp(6rem,14vh,12rem)]">
<div className={cn(GRID, "gap-y-14")}>
<div className="col-span-12 lg:col-start-3 lg:col-span-6">
<Lines
as="h2"
className="max-w-[14ch] font-display text-[clamp(2.75rem,1.6rem+4.4vw,6rem)] font-normal leading-[1.02] tracking-[-0.01em] text-ivory [text-wrap:balance]"
>
<HeadlineText {...h2} />
</Lines>
<Reveal delay={0.3} className="mt-12">
<Button {...cta} micro={micro ?? cta.micro} />
</Reveal>
</div>
<div className="col-span-12 lg:col-start-9 lg:col-span-4">
<ContactLinks />
</div>
</div>
</Container>
</section>
);
}
export default CtaBand;
+38
View File
@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface EyebrowProps {
as?: "p" | "span" | "h2" | "h3" | "div";
className?: string;
children: ReactNode;
}
/** Shared caps style (Jost 500, 11.5 px, ls .28em) – reused by sign-offs and labels. */
export const EYEBROW_TEXT = "font-sans text-[0.72rem] font-medium uppercase leading-none tracking-[0.28em]";
/**
* Eyebrow label with a 24 × 1 px gold tick in front (`::before`).
* Colour follows the live accent: `--gold` on dark, `--gold-deep` on light.
*
* `items-start` + `leading-[1.5]`: two of the three service heroes wrap this
* label at 390 px, and with `items-center`/`leading-none` the two caps lines
* sat 13 px apart with the tick floating in the gap. The tick now sits on the
* optical centre of the FIRST line, where it belongs.
*/
export function Eyebrow({ as = "p", className, children }: EyebrowProps) {
const Tag = as;
return (
<Tag
className={cn(
EYEBROW_TEXT,
/* Long single words („Berufshaftpflichtversicherung“) must break, never widen the page. */
"inline-flex max-w-full items-start gap-3 leading-[1.5] text-accent hyphens-auto [overflow-wrap:anywhere] before:mt-[0.75em] before:h-px before:w-6 before:shrink-0 before:bg-accent before:content-['']",
className,
)}
>
{children}
</Tag>
);
}
export default Eyebrow;
+102
View File
@@ -0,0 +1,102 @@
import { cn } from "@/lib/utils";
import { flags } from "@/lib/site";
import { isPlaceholder, splitPlaceholders } from "@/lib/legal";
import type { FactItem } from "@/content/home";
export type { FactItem };
export interface FactsProps {
items: readonly FactItem[];
/**
* `stack` = mono lines with gold ticks (hero, top right).
* `table` = two-column label / value table on bone with a hairline frame (Rahmen).
*/
variant?: "stack" | "table";
/** Table heading (DM Mono caps), e.g. „Rahmen“. */
title?: string;
className?: string;
}
/**
* Renders „[…]“ segments in the accent colour (`--gold-deep` on light, `--gold` on dark).
*
* Deliberately flag-agnostic: cutting a placeholder out of a running sentence leaves
* fragments („Auswertungsgespräch .“), so the launch switch is a build guard, not a
* strip – `assertNoPlaceholders` in every content module aborts the build while a
* placeholder is still open (src/lib/legal.ts, list in CONTENT_TODO).
*/
export function PlaceholderText({ text }: { text: string }) {
return (
<>
{splitPlaceholders(text).map((seg, i) =>
seg.placeholder ? (
<span key={i} className="text-accent">
{seg.text}
</span>
) : (
<span key={i}>{seg.text}</span>
),
)}
</>
);
}
/** Drops whole placeholder ROWS when `flags.showPlaceholders` is off (inline placeholders
inside a value are the build guard's job, not this filter's – see PlaceholderText). */
export function visibleFacts(items: readonly FactItem[]): FactItem[] {
return items.filter((f) => flags.showPlaceholders || !(f.placeholder || isPlaceholder(f.value)));
}
/**
* Facts stack (Format · Sprache · Ort · Dauer): DM Mono 12 px, one gold tick
* per line, label muted, value in the foreground colour. Both variants are
* ledgers – tick / label / value are columns with one left edge each, not an
* inline run that starts the value at a different x on every row.
*/
export function Facts({ items, variant = "stack", title, className }: FactsProps) {
const rows = visibleFacts(items);
if (!rows.length) return null;
if (variant === "table") {
return (
<div className={cn("border border-line bg-surface p-6 sm:p-10", className)}>
{title && <p className="mb-6 font-mono text-xs uppercase tracking-[0.12em] text-accent">{title}</p>}
<dl className="font-mono text-xs tracking-[0.06em] tabular-nums">
{rows.map((f) => (
/* Below sm the value column would be ~150 px inside the padded box and long
values („Inhouse-Workshop 1–2 Tage / Trainingsreihe / Online“) break over four
lines – so the label sits above the value there, identically on every page. */
<div key={f.label} className="grid grid-cols-1 gap-x-6 gap-y-1 border-b border-line py-3 last:border-b-0 sm:grid-cols-[1fr_3fr]">
<dt className="text-fg-muted">{f.label}</dt>
<dd className="text-fg">
<PlaceholderText text={f.value} />
</dd>
</div>
))}
</dl>
</div>
);
}
return (
/* Three rails: tick | label (max-content, so every label ends on one edge) | value. */
<dl
className={cn(
"grid grid-cols-[1.5rem_max-content_1fr] gap-x-3 gap-y-3 font-mono text-xs tracking-[0.06em] tabular-nums",
className,
)}
>
{rows.map((f) => (
<div key={f.label} className="contents">
<span aria-hidden="true" className="mt-[0.55em] h-px w-6 self-start bg-accent" />
<dt className="text-fg-muted">{f.label}</dt>
<dd className="text-fg">
<PlaceholderText text={f.value} />
</dd>
</div>
))}
</dl>
);
}
export default Facts;
+39
View File
@@ -0,0 +1,39 @@
import { cn } from "@/lib/utils";
import type { FactItem } from "@/content/home";
import { PlaceholderText, visibleFacts } from "./Facts";
export interface FactsStripProps {
items: readonly FactItem[];
className?: string;
}
/**
* Speaker facts: one full-width row of equal cells separated by 1 px
* vertical hairlines. Each cell = DM Mono label (muted) + Jost value.
* Wraps to 2 / 3 columns below 1024 px.
*/
export function FactsStrip({ items, className }: FactsStripProps) {
const rows = visibleFacts(items);
if (!rows.length) return null;
return (
<dl
className={cn(
"grid grid-cols-2 border-y border-line sm:grid-cols-3 lg:grid-cols-6",
"[&>div]:border-l [&>div]:border-line [&>div:first-child]:border-l-0",
"[&>div:nth-child(2n+1)]:max-sm:border-l-0 [&>div:nth-child(3n+1)]:sm:max-lg:border-l-0",
className,
)}
>
{rows.map((f) => (
<div key={f.label} className="flex flex-col gap-2 px-5 py-6 lg:px-6 lg:py-7">
<dt className="font-mono text-xs tracking-[0.06em] text-fg-muted">{f.label}</dt>
<dd className="font-sans text-[1.0625rem] leading-snug text-fg">
<PlaceholderText text={f.value} />
</dd>
</div>
))}
</dl>
);
}
export default FactsStrip;
+38
View File
@@ -0,0 +1,38 @@
import { cn } from "@/lib/utils";
import type { FaqItem } from "@/content/home";
export type { FaqItem };
export interface FaqProps {
items: readonly FaqItem[];
className?: string;
}
/** DM Mono „+“ that turns into „ד by rotating 45° when the `<details>` is open. */
export const PLUS_GLYPH =
"font-mono text-base leading-none text-accent transition-transform duration-300 ease-[var(--ease-out-quart)] group-open:rotate-45";
/**
* Native `<details>/<summary>` ledger: question Bodoni 28 px, answer Jost 400,
* hairline-separated, no accordion library. Fully usable without JS.
*/
export function Faq({ items, className }: FaqProps) {
if (!items.length) return null;
return (
<div className={cn("border-t border-line", className)}>
{items.map((item) => (
<details key={item.q} className="group border-b border-line">
<summary className="flex cursor-pointer list-none items-baseline justify-between gap-6 py-6 [&::-webkit-details-marker]:hidden">
<span className="font-display text-[1.75rem] font-normal leading-[1.15] text-fg">{item.q}</span>
<span aria-hidden="true" className={cn(PLUS_GLYPH, "shrink-0 self-center")}>
+
</span>
</summary>
<p className="max-w-[36rem] pb-8 font-sans text-[1.0625rem] leading-[1.6] text-fg">{item.a}</p>
</details>
))}
</div>
);
}
export default Faq;
+273
View File
@@ -0,0 +1,273 @@
"use client";
import { useLayoutEffect, useRef, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { gsap, DUR, MM } from "@/lib/gsap";
import { STROKE_IN, WIPE_IN, primeMark } from "@/components/motion/Draw";
import { Container } from "@/components/layout/Container";
import { GRID } from "@/components/layout/Section";
import type { LinkItem } from "@/content/home";
import { Eyebrow, EYEBROW_TEXT } from "./Eyebrow";
import { Numeral } from "./Numeral";
import { TextLink } from "./TextLink";
export interface FormulaLine {
/** Operator vor der Zeile (DM Mono, 0.5em, gold). Genutzt werden nur „+“ und „=“;
* der Typ ist `string`, weil die Inhaltsmodule für die zweite Sprache strukturell
* typisiert sind und Literaltypen dort nicht durchgereicht werden. */
op?: string;
text: string;
/** The one italic word; `goldEm` makes it the page's single `.gold-text` word. */
em?: string;
goldEm?: boolean;
/** Zwei-Satz-Erläuterung rechts neben der Zeile. Optional. */
definition?: string;
/** „Liefert: …“ Optional, nur zusammen mit `definition` sinnvoll. */
delivers?: string;
}
export interface FormulaProps {
lines: readonly FormulaLine[];
/** Vorlesefassung der Operatoren, z. B. `{ "+": "plus ", "=": "gleich " }`. */
opLabels?: Readonly<Record<string, string>>;
/** „Unlock Your Potential — Next Level“ – optional, wird ohne Wert weggelassen. */
signoff?: string;
/** Mobile meta line above the eyebrow, e.g. „03 / 06“ (desktop shows the numeral). */
meta?: string;
link: LinkItem;
/** „Das WOLF-Prinzip“ */
eyebrow?: string;
/** „03“ – Bodoni `.gold-text` numeral in cols 1–2. */
numeral?: string;
/** `<BrandMark/>` (Server Component) – drawn scrubbed 70–95 % on desktop, once at 80 % in view otherwise. */
mark?: ReactNode;
id?: string;
className?: string;
}
/* Positions on a 100-unit scrub timeline (spec §6.4). Line 1 starts at 0 and is
fully revealed by 9 % – the 200 svh stage never opens on an empty screen. */
const AT = { fades: 0, lines: [0, 36, 68], wolf: 70, wolfEnd: 95, signoff: 95 } as const;
const LINE_DUR = 9;
/**
* Das WOLF-Prinzip typeset as a formula. Desktop (≥ 1024, fine pointer, no
* reduced motion): the inner 100svh container is CSS-sticky inside a 200svh section (scrub 0.4)
* and reveals line 1/2/3 via clip-path at 0/36/68 %, definitions alongside,
* a single sheen over the gold word, the wolf drawn 70–95 %, sign-off at 95 %.
* Mobile / reduced motion: natural stacked flow (the 200 svh stage collapses to
* its content height), wolf drawn once.
* Ink only – the themed block around it paints the ground.
*/
export function Formula({ lines, opLabels, signoff, link, eyebrow, numeral, mark, meta, id = "prinzip", className }: FormulaProps) {
const outer = useRef<HTMLElement>(null);
const inner = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const outerEl = outer.current;
const innerEl = inner.current;
if (!outerEl || !innerEl) return;
const q = gsap.utils.selector(outerEl);
const fades = q<HTMLElement>("[data-f='eyebrow'], [data-f='numeral']");
const lineEls = q<HTMLElement>("[data-f='line']");
const defs = q<HTMLElement>("[data-f='def']");
const ops = q<HTMLElement>("[data-f='op']");
const ems = q<HTMLElement>("[data-f='em']");
const signoffEls = q<HTMLElement>("[data-f='signoff']");
const markEl = outerEl.querySelector<HTMLElement>("[data-f='mark']");
const paths = Array.from(outerEl.querySelectorAll<SVGPathElement>("path[data-draw]"));
const markSvg = outerEl.querySelector<SVGSVGElement>("[data-f='mark'] svg");
const showAll = () => {
gsap.set([...fades, ...defs, ...ops, ...signoffEls], { opacity: 1 });
gsap.set(lineEls, { clipPath: "inset(0% 0% 0% 0%)" });
};
const mm = gsap.matchMedia();
mm.add({ reduce: MM.reduce, ok: MM.ok, desktop: MM.desktop }, (ctx) => {
const { reduce, desktop } = ctx.conditions ?? {};
if (reduce || !desktop) {
showAll();
if (!reduce && paths.length && markEl) {
const m = primeMark(markSvg, paths);
gsap.to(m === "wipe" ? markSvg : paths, {
...(m === "wipe" ? WIPE_IN : STROKE_IN),
duration: DUR.draw,
ease: "power2.inOut",
...(m === "wipe" ? {} : { stagger: 0.12 }),
scrollTrigger: { trigger: markEl, start: "top 80%", once: true },
});
}
return;
}
// Desktop: initial states, then one scrubbed, pinned timeline (0–100).
/* Opacity only, never `autoAlpha` (spec A/D3): `visibility: hidden` would take the
sign-off's TextLink – the section's only link – out of the tab order until the
scrub reaches 95 %. It stays focusable; `focus-within` in the markup paints it. */
gsap.set([...fades, ...defs, ...ops, ...signoffEls], { opacity: 0 });
gsap.set(lineEls, { clipPath: "inset(0% 0% 100% 0%)" });
if (ems.length) gsap.set(ems, { backgroundSize: "250% 100%", backgroundPosition: "0% 50%" });
const markMode = primeMark(markSvg, paths);
let sheened = false;
const tl = gsap.timeline({
defaults: { ease: "none" },
scrollTrigger: {
trigger: outerEl,
start: "top top",
end: "bottom bottom",
scrub: 0.4,
invalidateOnRefresh: true,
},
});
tl.to(fades, { opacity: 1, duration: 12, stagger: 3 }, AT.fades);
lineEls.forEach((line, i) => {
const at = AT.lines[i] ?? AT.lines[AT.lines.length - 1] + 25 * (i - AT.lines.length + 1);
const op = line.querySelector<HTMLElement>("[data-f='op']");
tl.to(line, { clipPath: "inset(0% 0% 0% 0%)", duration: LINE_DUR }, at);
if (op) tl.to(op, { opacity: 1, duration: 6 }, at);
if (defs[i]) tl.to(defs[i], { opacity: 1, duration: 10 }, at + LINE_DUR);
});
if (ems.length) {
tl.call(
() => {
if (sheened) return;
sheened = true;
gsap.fromTo(
ems,
{ backgroundPosition: "0% 50%" },
{ backgroundPosition: "100% 50%", duration: 1.2, ease: "power2.inOut" },
);
},
undefined,
AT.lines[2] + 8,
);
}
if (paths.length) {
const span = AT.wolfEnd - AT.wolf;
if (markMode === "wipe") {
tl.to(markSvg, { ...WIPE_IN, duration: span }, AT.wolf);
} else {
const each = span / (1 + (paths.length - 1) * 0.35);
paths.forEach((p, i) => {
tl.to(p, { ...STROKE_IN, duration: each }, AT.wolf + i * each * 0.35);
});
}
}
tl.to(signoffEls, { opacity: 1, duration: 5 }, AT.signoff);
// Keep the timeline exactly 100 units long so positions read as percent.
if (tl.duration() < 100) tl.set({}, {}, 100);
});
return () => mm.revert();
}, [lines.length]);
return (
<section
id={id}
ref={outer}
data-theme="dark"
className={cn("relative text-fg lg:h-[200svh] motion-reduce:lg:h-auto", className)}
>
<div
ref={inner}
className="lg:sticky lg:top-0 lg:flex lg:h-svh lg:flex-col lg:overflow-hidden motion-reduce:lg:static motion-reduce:lg:h-auto motion-reduce:lg:overflow-visible"
>
<Container className="flex flex-1 flex-col py-24 lg:py-0 lg:pb-10 lg:pt-24">
<div className={cn(GRID, "flex-1 gap-y-10")}>
{numeral && (
<div data-f="numeral" data-reveal="" className="hidden lg:col-span-2 lg:block [.js_&]:opacity-0">
<Numeral value={numeral} theme="dark" sticky={false} />
</div>
)}
<div className="col-span-12 flex flex-col lg:col-span-10 lg:col-start-3">
{meta && (
<p className="mb-8 font-mono text-xs tracking-[0.06em] tabular-nums text-fg-muted lg:hidden">{meta}</p>
)}
{/* The section's heading. Without it the home outline jumps from 02 straight
to 04; no `[data-f=line]` may take the role – the three terms are a
formula, and one of them carries `.gold-text`. */}
{eyebrow && (
<div data-f="eyebrow" data-reveal="" className="[.js_&]:opacity-0">
<Eyebrow as="h2">{eyebrow}</Eyebrow>
</div>
)}
<div className="my-auto flex flex-col gap-12 py-14 lg:gap-8 lg:py-4">
{lines.map((line, i) => (
<div key={i} className="grid grid-cols-1 gap-y-4 lg:grid-cols-10 lg:items-end lg:gap-x-8">
<p
data-f="line"
data-reveal=""
className={cn(
"font-display text-[clamp(2.5rem,1.2rem+5.4vw,7.5rem)] font-normal leading-[1.0] tracking-[-0.01em] text-ivory [.js_&]:[clip-path:inset(0_0_100%_0)]",
/* Ohne Erläuterung nimmt die Zeile die volle Breite. */
line.definition || line.delivers ? "lg:col-span-6" : "lg:col-span-10",
)}
>
{line.op && (
<span
data-f="op"
aria-hidden="true"
className="mr-[0.3em] inline-block translate-y-[-0.15em] font-mono text-[0.5em] font-normal text-gold [.js_&]:opacity-0"
>
{line.op}
</span>
)}
{line.op && opLabels?.[line.op] && <span className="sr-only">{opLabels[line.op]}</span>}
{line.text}
{line.em && (
<em data-f={line.goldEm ? "em" : undefined} className={cn("italic", line.goldEm && "gold-text")}>
{line.em}
</em>
)}
</p>
{(line.definition || line.delivers) && (
<div data-f="def" data-reveal="" className="flex flex-col gap-3 lg:col-span-4 lg:pb-[0.35em] [.js_&]:opacity-0">
{line.definition && (
<p className="max-w-[30ch] font-sans text-lg font-light leading-[1.45] text-grey-warm lg:text-base">{line.definition}</p>
)}
{line.delivers && <p className="font-mono text-xs tracking-[0.06em] text-gold">{line.delivers}</p>}
</div>
)}
</div>
))}
</div>
<div className="mt-auto flex flex-col gap-12 lg:flex-row lg:items-end lg:justify-between">
{/* `focus-within:!opacity-100` outranks the scrub's inline opacity, so tabbing
to the link always paints it (and its 2 px focus ring) while the scrub keeps
the spec's 95 % sign-off (§6.4). */}
<div data-f="signoff" data-reveal="" className="flex flex-col gap-5 focus-within:!opacity-100 [.js_&]:opacity-0">
{/* Muted, not gold: the caps text link below must be the only gold caps line here. */}
{signoff && <p className={cn(EYEBROW_TEXT, "text-fg-muted")}>{signoff}</p>}
<TextLink href={link.href} label={link.label} external={link.external} />
</div>
{mark && (
<div
data-f="mark"
aria-hidden="true"
className="w-[40vw] max-w-[18rem] self-end text-ivory lg:h-[20vh] lg:w-auto lg:max-w-none [&_svg]:h-auto [&_svg]:w-full lg:[&_svg]:h-full lg:[&_svg]:w-auto"
>
{mark}
</div>
)}
</div>
</div>
</div>
</Container>
</div>
</section>
);
}
export default Formula;
+50
View File
@@ -0,0 +1,50 @@
import { cn } from "@/lib/utils";
import { Container } from "@/components/layout/Container";
import { GRID } from "@/components/layout/Section";
import { Lines } from "@/components/motion/Lines";
import { Reveal } from "@/components/motion/Reveal";
export interface InterruptionProps {
/** „Mut zum Erfolg.“ */
text: string;
/** DM Mono line below, e.g. „Keynote · herCAREER Expo 2026 · 23.10. · 10:30 · Auditorium 2“. */
mono?: string;
id?: string;
className?: string;
}
/**
* Typographic interruption (keynote page): 100svh ink, one giant Bodoni line
* `clamp(4rem, 2rem + 12vw, 14rem)` in cols 2–12, mono meta in gold below.
* Background stays transparent – <body> paints the ground.
*/
export function Interruption({ text, mono, id, className }: InterruptionProps) {
return (
<section
id={id}
data-theme="dark"
aria-label={text}
className={cn("relative flex min-h-svh items-center bg-transparent py-24 text-fg", className)}
>
<Container className="w-full">
<div className={GRID}>
<div className="col-span-12 lg:col-start-2 lg:col-span-11">
<Lines
as="p"
className="font-display text-[clamp(4rem,2rem+12vw,14rem)] font-normal leading-[0.94] tracking-[-0.015em] text-ivory [text-wrap:balance]"
>
{text}
</Lines>
{mono && (
<Reveal as="p" delay={0.3} className="mt-8 font-mono text-xs tracking-[0.06em] tabular-nums text-gold">
{mono}
</Reveal>
)}
</div>
</div>
</Container>
</section>
);
}
export default Interruption;
+186
View File
@@ -0,0 +1,186 @@
import { cn } from "@/lib/utils";
import { TransitionLink } from "@/components/motion/TransitionLink";
import { RevealGroup, RevealItem } from "@/components/motion/Reveal";
import { termine as termineDe } from "@/content/home";
import type { GoalRow, SituationRow, TermineRow, TocRow, WerdegangRow } from "@/content/home";
import { TEXT_LINK } from "./TextLink";
export type { GoalRow, SituationRow, TermineRow, TocRow, WerdegangRow };
export type LedgerRow = TocRow | WerdegangRow | TermineRow | SituationRow | GoalRow;
interface LedgerBase {
className?: string;
/** Stagger between rows (default 0.1 s). */
stagger?: number;
}
export type LedgerProps =
| (LedgerBase & { variant: "toc"; rows: readonly TocRow[] })
| (LedgerBase & { variant: "werdegang"; rows: readonly WerdegangRow[] })
| (LedgerBase & { variant: "termine"; rows: readonly TermineRow[]; heading?: string })
| (LedgerBase & { variant: "situations"; rows: readonly SituationRow[] })
| (LedgerBase & { variant: "goals"; rows: readonly GoalRow[] });
const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums";
/* ---------------- toc ---------------- */
/**
* Table-of-contents row = ONE <TransitionLink> (keyboard: one tab stop).
* Hover: ground `--surface`, lower hairline turns gold (scaleX from the left,
* 0.6 s), arrow +8 px. No italic flip, nothing reflows – everything is
* always visible.
*/
function TocRowView({ row }: { row: TocRow }) {
return (
<TransitionLink
href={row.link.href}
label={row.title}
className="group relative grid grid-cols-1 gap-y-4 py-7 transition-colors duration-300 hover:bg-surface focus-visible:bg-surface lg:grid-cols-12 lg:gap-x-[clamp(16px,2vw,32px)] lg:py-10"
>
<span className="flex items-baseline gap-4 lg:col-span-5 lg:contents">
<span className={cn(MONO, "text-accent lg:col-span-1")}>{row.numeral}</span>
<span className="font-display text-[clamp(1.75rem,1.4rem+1.6vw,3rem)] font-normal leading-[1.05] text-fg lg:col-span-4">
{row.title}
</span>
</span>
<span className="flex flex-col gap-2 lg:col-span-5">
<span className="font-sans text-[1.0625rem] leading-snug text-fg">{row.oneLiner}</span>
<span className={cn(MONO, "text-fg-muted")}>{row.keywords.join(" · ")}</span>
</span>
<span className={cn(TEXT_LINK, "whitespace-nowrap lg:col-span-2 lg:justify-self-end lg:self-start")}>
<span>{row.link.label}</span>
<span aria-hidden="true" className="transition-transform duration-300 ease-[var(--ease-out-quart)] group-hover:translate-x-2">
→
</span>
</span>
{/* Lower hairline: recolours to gold from the left on hover. */}
<span
aria-hidden="true"
className="rule-gold absolute inset-x-0 bottom-[-1px] origin-left scale-x-0 transition-transform duration-[600ms] ease-[var(--ease-out-expo)] group-hover:scale-x-100 group-focus-visible:scale-x-100"
/>
</TransitionLink>
);
}
/* ---------------- termine ---------------- */
function TermineRowView({ row }: { row: TermineRow }) {
const inner = (
<>
<span className={cn(MONO, "text-[13px] text-fg")}>{row.date}</span>
<span className="font-sans text-[0.8125rem] font-medium uppercase tracking-[0.16em] text-fg-muted">{row.format}</span>
<span className="font-sans text-lg leading-snug text-fg lg:font-display lg:text-[1.75rem] lg:font-normal lg:leading-[1.1]">
{row.title}
</span>
<span className={cn(MONO, "text-fg-muted")}>
{row.venue}
{row.href && (
<>
{" "}
<span aria-hidden="true">↗</span>
</>
)}
</span>
</>
);
const cls =
"grid grid-cols-1 gap-y-1 py-5 lg:grid-cols-[1fr_1fr_2fr_1fr] lg:items-baseline lg:gap-x-8";
if (row.href) {
return (
<a href={row.href} target="_blank" rel="noopener noreferrer" className={cn(cls, "group/row")}>
{inner}
</a>
);
}
return <div className={cls}>{inner}</div>;
}
/* ---------------- Ledger ---------------- */
/**
* Typographic ledgers instead of cards. Rows are hairline-separated and
* stagger in via <RevealGroup/>; the toc variant's rows are links.
*/
export function Ledger(props: LedgerProps) {
const { className, stagger = 0.1 } = props;
if (props.variant === "toc") {
return (
<RevealGroup as="ol" stagger={stagger} className={cn("border-t border-line", className)}>
{props.rows.map((row) => (
<RevealItem key={row.numeral} as="li" className="border-b border-line">
<TocRowView row={row} />
</RevealItem>
))}
</RevealGroup>
);
}
if (props.variant === "werdegang") {
return (
<RevealGroup as="dl" stagger={stagger} className={cn("border-t border-line", className)}>
{props.rows.map((row) => (
<RevealItem
key={row.keyword}
className="grid grid-cols-1 gap-y-1 border-b border-line py-[18px] sm:grid-cols-4 sm:gap-x-6"
>
<dt className={cn(MONO, "uppercase text-accent sm:col-span-1")}>{row.keyword}</dt>
<dd className="font-sans text-[1.0625rem] leading-snug text-fg sm:col-span-3">{row.text}</dd>
</RevealItem>
))}
</RevealGroup>
);
}
if (props.variant === "termine") {
const heading = props.heading ?? termineDe.heading;
return (
<div className={cn("border border-line bg-surface p-6 sm:p-10", className)}>
<p className={cn(MONO, "mb-4 flex items-center gap-3 uppercase tracking-[0.12em] text-accent")}>
<span aria-hidden="true" className="inline-block h-1.5 w-1.5 shrink-0 bg-accent" />
{heading}
</p>
<RevealGroup as="ol" stagger={stagger} className="border-t border-line">
{props.rows.map((row) => (
<RevealItem key={`${row.date}-${row.title}`} as="li" className="border-b border-line">
<TermineRowView row={row} />
</RevealItem>
))}
</RevealGroup>
</div>
);
}
if (props.variant === "situations") {
return (
<RevealGroup as="ol" stagger={stagger} className={cn("border-t border-line", className)}>
{props.rows.map((row) => (
<RevealItem
key={row.numeral}
as="li"
className="grid grid-cols-[2.5rem_1fr] gap-x-4 border-b border-line py-5 sm:grid-cols-[3rem_1fr] sm:gap-x-8"
>
<span className={cn(MONO, "pt-[0.5em] text-accent")}>{row.numeral}</span>
<span className="font-display text-[clamp(1.75rem,1.3rem+1.2vw,2rem)] font-normal italic leading-[1.2] text-fg">
{row.text}
</span>
</RevealItem>
))}
</RevealGroup>
);
}
return (
<RevealGroup as="ul" stagger={stagger} className={cn("border-t border-line", className)}>
{props.rows.map((row) => (
<RevealItem key={row.text} as="li" className="border-b border-line py-4 font-sans text-[1.0625rem] leading-snug text-fg">
{row.text}
</RevealItem>
))}
</RevealGroup>
);
}
export default Ledger;
+66
View File
@@ -0,0 +1,66 @@
import { cn } from "@/lib/utils";
export interface MarginaliaItem {
/** Optional mono label, e.g. „Praxis“ → „Praxis: B2B-Außendienst“. */
label?: string;
value: string;
href?: string;
/** `target="_blank"` + ↗ */
external?: boolean;
}
export interface MarginaliaProps {
items: readonly MarginaliaItem[];
/** Optional caps heading above the list, e.g. „Was du bekommst“. */
title?: string;
/** `lg:sticky lg:top-[30vh]` */
sticky?: boolean;
className?: string;
}
/** Shared mono meta style (DM Mono 12 px, ls .06em, tabular). */
export const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums";
/**
* Verifiable facts in the outer column: DM Mono 12 px, muted, one gold tick
* per line. Links get the growing accent underline; external ones ↗.
*/
export function Marginalia({ items, title, sticky = false, className }: MarginaliaProps) {
return (
<aside className={cn(MONO, "text-fg-muted", sticky && "lg:sticky lg:top-[30vh]", className)}>
{title && <p className="mb-4 uppercase tracking-[0.12em] text-accent">{title}</p>}
<ul className="flex flex-col gap-3">
{items.map((item, i) => {
const text = item.label ? `${item.label}: ${item.value}` : item.value;
return (
<li key={`${item.value}-${i}`} className="flex items-start gap-3">
<span aria-hidden="true" className="mt-[0.55em] h-px w-6 shrink-0 bg-accent" />
{item.href ? (
<a
href={item.href}
target={item.external ? "_blank" : undefined}
rel={item.external ? "noopener noreferrer" : undefined}
/* Tap target via ::before – padding + negative margin on the flex child
collapsed the <li> box and broke the 12 px tick rhythm of the list. */
className="relative inline-block text-fg before:absolute before:inset-x-0 before:-inset-y-3 before:content-[''] bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat pb-px transition-[background-size] duration-[400ms] ease-[var(--ease-out-quart)] hover:bg-[length:100%_1px] focus-visible:bg-[length:100%_1px]"
>
{text}
{item.external && (
<>
{" "}
<span aria-hidden="true">↗</span>
</>
)}
</a>
) : (
<span>{text}</span>
)}
</li>
);
})}
</ul>
</aside>
);
}
export default Marginalia;
+28
View File
@@ -0,0 +1,28 @@
import { PlaceholderText } from "./Facts";
export interface MetaLinesProps {
/** „Online · DE/EN · [Dauer]“ */
text: string;
}
/**
* A ledger meta string, one item per line. The DM Mono meta columns are narrow
* (1.5–2 fr), so a single string breaks mid-separator („Online · DE/EN ·“ /
* „[Dauer]“, or a line opening with a lone „·“). Splitting on „ · “ keeps every
* item whole and the separator out of the line breaks.
*/
export function MetaLines({ text }: MetaLinesProps) {
const parts = text.split(/\s+·\s+/).filter((p) => p.length > 0);
if (parts.length < 2) return <PlaceholderText text={text} />;
return (
<>
{parts.map((part, i) => (
<span key={`${part}-${i}`} className="block whitespace-nowrap">
<PlaceholderText text={part} />
</span>
))}
</>
);
}
export default MetaLines;
+79
View File
@@ -0,0 +1,79 @@
import { cn } from "@/lib/utils";
import { moduleLabels } from "@/content/trainings";
import type { ModuleItem } from "@/content/trainings";
import { MetaLines } from "./MetaLines";
import { PLUS_GLYPH } from "./Faq";
export type { ModuleItem };
export interface ModuleTableProps {
modules: readonly ModuleItem[];
className?: string;
}
const MONO = "font-mono text-xs tracking-[0.06em]";
const HEAD = cn(MONO, "uppercase tracking-[0.12em] text-accent");
/**
* Trainings ledger table: header row „Modul | Ziel | Für wen“ – decorative, because
* every cell carries its own label (visible below lg, `sr-only` from lg) – then
* one native `<details>` per module whose `<summary>` is the full row
* (Bodoni 30 px title, Jost goal + audience, mono meta). Opens to the
* hairline-separated „Inhalte“ list; „+“ rotates to „ד via CSS.
*/
export function ModuleTable({ modules, className }: ModuleTableProps) {
const cols = "grid grid-cols-[1fr_auto] gap-x-6 gap-y-3 lg:grid-cols-[minmax(0,3.5fr)_minmax(0,3fr)_minmax(0,2fr)_minmax(0,2.2fr)_2rem] lg:gap-x-8";
return (
<div className={cn("w-full", className)}>
<div className={cn(cols, "hidden border-b border-line pb-3 lg:grid")} aria-hidden="true">
<span className={HEAD}>{moduleLabels.module}</span>
<span className={HEAD}>{moduleLabels.goal}</span>
<span className={HEAD}>{moduleLabels.audience}</span>
<span />
<span />
</div>
{modules.map((m) => (
<details key={m.title} className="group border-b border-line">
<summary className={cn(cols, "cursor-pointer list-none py-7 lg:items-baseline lg:py-8 [&::-webkit-details-marker]:hidden")}>
<span className="font-sans text-xl leading-snug text-fg lg:font-display lg:text-[1.875rem] lg:font-normal lg:leading-[1.1]">
{m.title}
</span>
<span aria-hidden="true" className={cn(PLUS_GLYPH, "self-start lg:order-last lg:justify-self-end")}>
+
</span>
{/* `lg:sr-only`, not `lg:hidden`: from 1024 the column headers above are the only
labelling, and they are decorative (aria-hidden) – the goal and audience runs
would be announced as unlabelled text. The label stays in the DOM at every
width and is only visually suppressed where the header row takes over. */}
<span className="col-span-2 font-sans text-[1.0625rem] leading-snug text-fg lg:col-span-1">
<span className={cn(HEAD, "mr-3 lg:sr-only")}>{moduleLabels.goal}</span>
{m.goal}
</span>
<span className="col-span-2 font-sans text-[1.0625rem] leading-snug text-fg lg:col-span-1">
<span className={cn(HEAD, "mr-3 lg:sr-only")}>{moduleLabels.audience}</span>
{m.audience}
</span>
<span className={cn(MONO, "col-span-2 tabular-nums text-fg-muted lg:col-span-1 lg:text-right")}>
{/* The meta column has no visible header at any width. */}
<span className="sr-only">{moduleLabels.meta}</span>
<MetaLines text={m.meta} />
</span>
</summary>
<div className="pb-8 lg:grid lg:grid-cols-[minmax(0,3fr)_minmax(0,7fr)_2rem] lg:gap-x-8">
<span className={cn(HEAD, "mb-3 block lg:mb-0")}>{moduleLabels.contents}</span>
<ul className="border-t border-line">
{m.contents.map((line) => (
<li key={line} className="border-b border-line py-3 font-sans text-[1.0625rem] leading-snug text-fg">
{line}
</li>
))}
</ul>
</div>
</details>
))}
</div>
);
}
export default ModuleTable;
+47
View File
@@ -0,0 +1,47 @@
import { cn } from "@/lib/utils";
export interface NumeralProps {
/** „01“ */
value: string;
/**
* Ground the numeral sits on. Omit to resolve it from the nearest
* `[data-theme]` ancestor (CSS only): `.gold-text` on dark, flat `--gold-700` on light.
*/
theme?: "dark" | "light";
/** `position: sticky; top: calc(50vh − .45em)` (desktop). Default true. */
sticky?: boolean;
className?: string;
}
const GOLD_TEXT_DARK =
"[[data-theme=dark]_&]:bg-[image:var(--gold-gradient-text)] [[data-theme=dark]_&]:bg-clip-text [[data-theme=dark]_&]:text-gold [[data-theme=dark]_&]:[-webkit-text-fill-color:transparent]";
/**
* Section numeral: Playfair 400, `clamp(4rem, 2rem + 8vw, 10rem)`,
* lining numerals. Decorative – the accessible section number lives in the
* Section's mono meta line, so this is `aria-hidden`.
*/
export function Numeral({ value, theme, sticky = true, className }: NumeralProps) {
const colour =
theme === "dark"
? "gold-text"
: theme === "light"
? "text-gold-700"
: cn("text-gold-700", GOLD_TEXT_DARK);
return (
<span
aria-hidden="true"
className={cn(
"block select-none font-display text-[clamp(4rem,2rem+8vw,10rem)] font-normal leading-[0.9] tracking-[-0.01em] [font-variant-numeric:lining-nums]",
sticky && "lg:sticky lg:top-[calc(50vh_-_0.45em)]",
colour,
className,
)}
>
{value}
</span>
);
}
export default Numeral;
+79
View File
@@ -0,0 +1,79 @@
import { cn } from "@/lib/utils";
import { Door } from "@/components/motion/Door";
import { PlaceholderText } from "./Facts";
export type PortraitAspect = "4/5" | "3/5" | "3/4" | "16/10";
export interface PortraitProps {
/** `null` = photo not delivered → renders nothing (documented variant B). */
src: string | null;
alt: string;
aspect?: PortraitAspect;
/** DM Mono 11 px caption with gold tick, e.g. „Anelia Wolf · Foto: [Name]“. */
caption?: string;
/** `lg:sticky lg:top-[120px]` – stays in view while reading. */
sticky?: boolean;
/** Eager load (hero). */
priority?: boolean;
/** 6 % scroll parallax (desktop, fine pointer). */
parallax?: boolean;
/** 1 px `--line` frame (ivory) – off on ink. Default true. */
frame?: boolean;
sizes?: string;
className?: string;
}
/** Intrinsic dimensions per aspect (2000 px long edge). */
const DIMS: Record<PortraitAspect, { width: number; height: number }> = {
"4/5": { width: 1600, height: 2000 },
"3/5": { width: 1200, height: 2000 },
"3/4": { width: 1500, height: 2000 },
"16/10": { width: 2000, height: 1250 },
};
/**
* Magazine portrait in a `<Door/>` mask: warm grade, hard rectangle, hairline
* frame, mono caption. Never scales on hover, never tilts.
*/
export function Portrait({
src,
alt,
aspect = "4/5",
caption,
sticky = false,
priority = false,
parallax = false,
frame = true,
sizes,
className,
}: PortraitProps) {
if (!src) return null;
const { width, height } = DIMS[aspect];
return (
<figure className={cn(sticky && "lg:sticky lg:top-[120px]", className)}>
<Door
src={src}
alt={alt}
width={width}
height={height}
sizes={sizes}
priority={priority}
parallax={parallax}
play={priority ? "load" : "view"}
className={cn(frame && "border border-line")}
imgClassName="[filter:sepia(.12)_saturate(.9)_contrast(1.02)]"
/>
{caption && (
/* Der Bildnachweis läuft über <PlaceholderText/>: „Foto: [Name der Fotografin]“ ist
ein echter Platzhalter (§ 13 UrhG) und muss wie jeder andere gold auffallen und
im Build-Guard auftauchen – als rohe eckige Klammer wäre er beim Launch untergegangen. */
<figcaption className="mt-3 flex items-center gap-3 font-mono text-[11px] tracking-[0.06em] text-fg-muted">
<span aria-hidden="true" className="h-px w-6 shrink-0 bg-accent" />
<PlaceholderText text={caption} />
</figcaption>
)}
</figure>
);
}
export default Portrait;
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff. Show more