first publish
This commit is contained in:
commit
b8e41b4d55
147 files changed
+19367
No files matched your search
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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" />;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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`,
|
||||
};
|
||||
}
|
||||
@@ -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]}`,
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
Reference in new issue
Block a user