first publish

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

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
import { cn } from "@/lib/utils";
import { TransitionLink } from "@/components/motion/TransitionLink";
export interface ButtonProps {
href: string;
label: string;
/** Mono suffix that rises out of a mask inside the button on hover (≥ 1024); its width is reserved. */
suffix?: string;
/** Mono micro line under the button, e.g. „Telefonisch oder online · Deutsch oder Englisch“. */
micro?: string;
/** `primary` = 1 px accent outline + gold sheen fill on hover; `ghost` = neutral hairline, no fill. */
variant?: "primary" | "ghost";
/** `target="_blank" rel="noopener noreferrer"`. */
external?: boolean;
className?: string;
}
export const BUTTON_BASE =
"group/btn relative inline-flex items-center overflow-hidden whitespace-nowrap border px-[2.1em] py-[1.05em] font-sans text-[0.8125rem] font-medium uppercase leading-none tracking-[0.2em] text-fg transition-[color,background-color,border-color] duration-300";
/* Sheen fill: --gold-sheen at 250 % width, background-position 0 % → 100 % in .6 s. Text turns ink. */
export const BUTTON_PRIMARY =
"border-accent hover:text-ink focus-visible:text-ink before:pointer-events-none before:absolute before:inset-0 before:bg-[image:var(--gold-sheen)] before:bg-[length:250%_100%] before:bg-[position:0%_50%] before:opacity-0 before:transition-[opacity,background-position] before:duration-[600ms] before:ease-[var(--ease-out-quart)] hover:before:bg-[position:100%_50%] hover:before:opacity-100 focus-visible:before:bg-[position:100%_50%] focus-visible:before:opacity-100";
const GHOST = "border-line hover:border-accent focus-visible:border-accent";
function isInternal(href: string) {
return href.startsWith("/");
}
/**
* Outline button (radius 0, no shadow). Internal routes use the curtain
* (`TransitionLink`); mailto/tel/http render a plain `<a>`.
*/
export function Button({ href, label, suffix, micro, variant = "primary", external = false, className }: ButtonProps) {
const cls = cn(BUTTON_BASE, variant === "primary" ? BUTTON_PRIMARY : GHOST, className);
const inner = (
<>
<span className="relative z-10">{label}</span>
{suffix && (
/* The mask sits INSIDE the button and its width is reserved, so the
control never resizes under the pointer – only the line rises. */
<span aria-hidden="true" className="relative z-10 hidden overflow-hidden lg:block">
<span className="block translate-y-full whitespace-nowrap pl-3 font-mono text-[11px] normal-case leading-[1.6] tracking-[0.06em] transition-transform duration-[350ms] ease-[var(--ease-out-quart)] group-hover/btn:translate-y-0 group-focus-visible/btn:translate-y-0">
{suffix}
</span>
</span>
)}
</>
);
const link =
isInternal(href) && !external ? (
<TransitionLink href={href} label={label} className={cls}>
{inner}
</TransitionLink>
) : (
<a href={href} target={external ? "_blank" : undefined} rel={external ? "noopener noreferrer" : undefined} className={cls}>
{inner}
</a>
);
if (!micro) return link;
return (
<span className="inline-flex flex-col items-start gap-3">
{link}
<span className="font-mono text-[11px] tracking-[0.06em] text-fg-muted">{micro}</span>
</span>
);
}
export default Button;
+50
View File
@@ -0,0 +1,50 @@
import { cn } from "@/lib/utils";
import { RevealGroup, RevealItem } from "@/components/motion/Reveal";
import type { ColumnItem } from "@/content/home";
import { TextLink } from "./TextLink";
export type { ColumnItem };
export interface ColumnsProps {
items: readonly ColumnItem[];
/** Middle column offset 48 px downward (deliberate asymmetry, desktop). Default true. */
offsetMiddle?: boolean;
className?: string;
}
/**
* Three editorial columns (gap 48 px): DM Mono label, Bodoni 28 px title,
* Jost 17 px lines separated by hairlines (no bullets, no icons), one
* specific text link. Stacks below 1024 px.
*/
export function Columns({ items, offsetMiddle = true, className }: ColumnsProps) {
return (
<RevealGroup stagger={0.12} className={cn("grid grid-cols-1 gap-x-12 gap-y-14 lg:grid-cols-3", className)}>
{items.map((col, i) => (
<RevealItem
key={col.title}
className={cn("flex flex-col", offsetMiddle && i === 1 && "lg:mt-12")}
>
{col.label && <p className="mb-4 font-mono text-xs tracking-[0.06em] text-accent">{col.label}</p>}
<h3 className="font-display text-[1.75rem] font-normal leading-[1.15] text-fg">{col.title}</h3>
{col.lines && col.lines.length > 0 && (
<ul className="mt-6 border-t border-line">
{col.lines.map((line) => (
<li key={line} className="border-b border-line py-3 font-sans text-[1.0625rem] leading-snug text-fg">
{line}
</li>
))}
</ul>
)}
{col.link && (
<div className="mt-6">
<TextLink href={col.link.href} label={col.link.label} external={col.link.external} />
</div>
)}
</RevealItem>
))}
</RevealGroup>
);
}
export default Columns;
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useId, useState, type FormEvent } from "react";
import { cn } from "@/lib/utils";
import { site, routes } from "@/lib/site";
import { contact as contactDe, type HomeContent } from "@/content/home";
import { BUTTON_BASE, BUTTON_PRIMARY } from "@/components/ui/Button";
import { TextLink } from "@/components/ui/TextLink";
/**
* Kontaktformular der Startseite (Sektion 06).
*
* **Es gibt keinen Server.** Die Website ist ein statischer Export und sagt in der
* Datenschutzerklärung zu, dass sie keine Drittanbieter einbindet und keine Daten
* überträgt. Ein Formular, das irgendwohin „postet“, würde dieses Versprechen brechen
* (und einen Auftragsverarbeitungsvertrag nötig machen). Deshalb baut das Formular aus
* den Eingaben eine fertige E-Mail und übergibt sie dem Mailprogramm der Besucherin.
* Die Daten verlassen den Rechner erst, wenn sie dort auf „Senden“ drückt.
*
* Soll später doch ein echtes Formular her (Eingaben landen direkt im Postfach, ohne
* Mailprogramm), braucht es einen Endpunkt beim Hoster oder einen Formulardienst. Dann
* sind zwei Dinge Pflicht: ein Auftragsverarbeitungsvertrag und ein neuer Absatz in
* `src/content/legal.ts`. Der Umbau beträfe nur `submit()` hier in dieser Datei.
*/
export function ContactForm({
className,
content: form = contactDe.form,
privacyHref = routes.datenschutz,
}: {
className?: string;
/** Ziel des Datenschutz-Links – in der englischen Fassung /en/privacy/. */
privacyHref?: string;
/** Inhalt. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
content?: HomeContent["contact"]["form"];
}) {
const uid = useId();
const [sent, setSent] = useState(false);
const field = (name: string) => `${uid}-${name}`;
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const value = (key: string) => String(data.get(key) ?? "").trim();
const body = [
`${form.fields.name}: ${value("name")}`,
`${form.fields.email}: ${value("email")}`,
value("company") ? `${form.fields.company}: ${value("company")}` : null,
"",
value("message"),
]
.filter((line) => line !== null)
.join("\r\n");
const url = `mailto:${site.email}?subject=${encodeURIComponent(form.subject)}&body=${encodeURIComponent(body)}`;
setSent(true);
window.location.href = url;
};
const label = "mb-2 block font-mono text-[11px] uppercase tracking-[0.16em] text-fg-muted";
const input =
"w-full border-0 border-b border-line bg-transparent pb-3 font-sans text-[1.0625rem] text-fg outline-none transition-colors duration-300 placeholder:text-transparent focus:border-accent";
return (
<form onSubmit={submit} noValidate={false} className={cn("flex flex-col gap-8", className)}>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
<div>
<label htmlFor={field("name")} className={label}>
{form.fields.name}
</label>
<input id={field("name")} name="name" type="text" required autoComplete="name" className={input} />
</div>
<div>
<label htmlFor={field("email")} className={label}>
{form.fields.email}
</label>
<input id={field("email")} name="email" type="email" required autoComplete="email" className={input} />
</div>
</div>
<div>
<label htmlFor={field("company")} className={label}>
{form.fields.company}
</label>
<input id={field("company")} name="company" type="text" autoComplete="organization" className={input} />
</div>
<div>
<label htmlFor={field("message")} className={label}>
{form.fields.message}
</label>
<textarea id={field("message")} name="message" required rows={5} className={cn(input, "resize-y")} />
</div>
<div className="flex flex-col gap-4">
<button type="submit" className={cn(BUTTON_BASE, BUTTON_PRIMARY, "self-start")} aria-describedby={field("note")}>
<span className="relative z-10">{form.submit}</span>
</button>
<p id={field("note")} className="max-w-[52ch] font-mono text-[11px] leading-relaxed tracking-[0.04em] text-fg-muted">
{sent ? form.opened : form.note}{" "}
<TextLink href={privacyHref} label={form.privacy} className="!text-[11px] !tracking-[0.1em]" />
</p>
</div>
</form>
);
}
export default ContactForm;
+52
View File
@@ -0,0 +1,52 @@
import { cn } from "@/lib/utils";
import { contact as contactDe, type HomeContent } from "@/content/home";
import { RevealGroup, RevealItem } from "@/components/motion/Reveal";
export interface ContactLinksProps {
className?: string;
/** `lg:sticky lg:top-[30vh]` (homepage contact block). */
sticky?: boolean;
/** Inhalt. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
content?: HomeContent["contact"]["links"];
}
const UNDERLINE =
"bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat pb-1 transition-[background-size] duration-[400ms] ease-[var(--ease-out-quart)] group-hover/cl:bg-[length:100%_1px] group-focus-visible/cl:bg-[length:100%_1px]";
/**
* Schreib mir / Ruf an / Vernetzen – three large links (DM Mono 11 px label +
* Bodoni value with a growing accent underline). Data from content/home.
*/
export function ContactLinks({ className, sticky = false, content = contactDe.links }: ContactLinksProps) {
const items = [content.write, content.call, content.connect] as const;
return (
<RevealGroup as="ul" stagger={0.08} className={cn("flex flex-col gap-7", sticky && "lg:sticky lg:top-[30vh]", className)}>
{items.map((item) => {
const external = "external" in item && item.external;
return (
<RevealItem key={item.label} as="li" className="flex flex-col gap-2">
<span className="font-mono text-[11px] tracking-[0.06em] text-fg-muted">{item.label}</span>
<a
href={item.href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
className="group/cl inline-flex max-w-full items-baseline gap-2 self-start font-display text-[clamp(1.75rem,1.15rem+1vw,2.125rem)] font-normal leading-[1.15] text-fg [font-variant-numeric:lining-nums]"
>
<span className={cn(UNDERLINE, "min-w-0 text-accent")}>
{/* An address broken at its hyphen reads like a typo – never wrap the value. */}
<span className="whitespace-nowrap text-fg">{item.value}</span>
</span>
{external && (
<span aria-hidden="true" className="text-accent transition-transform duration-300 group-hover/cl:translate-x-1">
↗
</span>
)}
</a>
</RevealItem>
);
})}
</RevealGroup>
);
}
export default ContactLinks;
+66
View File
@@ -0,0 +1,66 @@
import { cn } from "@/lib/utils";
import { Container } from "@/components/layout/Container";
import { GRID } from "@/components/layout/Section";
import { Lines } from "@/components/motion/Lines";
import { Reveal } from "@/components/motion/Reveal";
import { Rule } from "@/components/motion/Rule";
import type { Headline } from "@/content/home";
import { Button, type ButtonProps } from "./Button";
import { ContactLinks } from "./ContactLinks";
export type { Headline };
export interface CtaBandProps {
/** One italic word via `em` (may be empty). */
h2: Headline;
cta: ButtonProps;
/** Mono micro line under the button (falls back to `cta.micro`). */
micro?: string;
id?: string;
className?: string;
}
/** Renders {before, em, after} with exactly one italic `<em>`. */
export function HeadlineText({ before, em, after }: Headline) {
return (
<>
{before}
{em && <em className="italic">{em}</em>}
{after}
</>
);
}
/**
* Dark contact band (~70svh) before the footer: boundary hairline, page-
* specific H2 (Bodoni, 44–96 px), the three contact links, the page's one
* primary CTA. Paints its own ink ground; the seam to the ivory above is the
* gold hairline alone (spec §2.3) – no fade, no gradient.
*/
export function CtaBand({ h2, cta, micro, id = "kontakt", className }: CtaBandProps) {
return (
<section id={id} data-theme="dark" className={cn("relative text-fg", className)}>
<Rule variant="gold" className="relative z-[1]" />
<Container className="relative z-[1] flex min-h-[70svh] flex-col justify-center py-[clamp(6rem,14vh,12rem)]">
<div className={cn(GRID, "gap-y-14")}>
<div className="col-span-12 lg:col-start-3 lg:col-span-6">
<Lines
as="h2"
className="max-w-[14ch] font-display text-[clamp(2.75rem,1.6rem+4.4vw,6rem)] font-normal leading-[1.02] tracking-[-0.01em] text-ivory [text-wrap:balance]"
>
<HeadlineText {...h2} />
</Lines>
<Reveal delay={0.3} className="mt-12">
<Button {...cta} micro={micro ?? cta.micro} />
</Reveal>
</div>
<div className="col-span-12 lg:col-start-9 lg:col-span-4">
<ContactLinks />
</div>
</div>
</Container>
</section>
);
}
export default CtaBand;
+38
View File
@@ -0,0 +1,38 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface EyebrowProps {
as?: "p" | "span" | "h2" | "h3" | "div";
className?: string;
children: ReactNode;
}
/** Shared caps style (Jost 500, 11.5 px, ls .28em) – reused by sign-offs and labels. */
export const EYEBROW_TEXT = "font-sans text-[0.72rem] font-medium uppercase leading-none tracking-[0.28em]";
/**
* Eyebrow label with a 24 × 1 px gold tick in front (`::before`).
* Colour follows the live accent: `--gold` on dark, `--gold-deep` on light.
*
* `items-start` + `leading-[1.5]`: two of the three service heroes wrap this
* label at 390 px, and with `items-center`/`leading-none` the two caps lines
* sat 13 px apart with the tick floating in the gap. The tick now sits on the
* optical centre of the FIRST line, where it belongs.
*/
export function Eyebrow({ as = "p", className, children }: EyebrowProps) {
const Tag = as;
return (
<Tag
className={cn(
EYEBROW_TEXT,
/* Long single words („Berufshaftpflichtversicherung“) must break, never widen the page. */
"inline-flex max-w-full items-start gap-3 leading-[1.5] text-accent hyphens-auto [overflow-wrap:anywhere] before:mt-[0.75em] before:h-px before:w-6 before:shrink-0 before:bg-accent before:content-['']",
className,
)}
>
{children}
</Tag>
);
}
export default Eyebrow;
+102
View File
@@ -0,0 +1,102 @@
import { cn } from "@/lib/utils";
import { flags } from "@/lib/site";
import { isPlaceholder, splitPlaceholders } from "@/lib/legal";
import type { FactItem } from "@/content/home";
export type { FactItem };
export interface FactsProps {
items: readonly FactItem[];
/**
* `stack` = mono lines with gold ticks (hero, top right).
* `table` = two-column label / value table on bone with a hairline frame (Rahmen).
*/
variant?: "stack" | "table";
/** Table heading (DM Mono caps), e.g. „Rahmen“. */
title?: string;
className?: string;
}
/**
* Renders „[…]“ segments in the accent colour (`--gold-deep` on light, `--gold` on dark).
*
* Deliberately flag-agnostic: cutting a placeholder out of a running sentence leaves
* fragments („Auswertungsgespräch .“), so the launch switch is a build guard, not a
* strip – `assertNoPlaceholders` in every content module aborts the build while a
* placeholder is still open (src/lib/legal.ts, list in CONTENT_TODO).
*/
export function PlaceholderText({ text }: { text: string }) {
return (
<>
{splitPlaceholders(text).map((seg, i) =>
seg.placeholder ? (
<span key={i} className="text-accent">
{seg.text}
</span>
) : (
<span key={i}>{seg.text}</span>
),
)}
</>
);
}
/** Drops whole placeholder ROWS when `flags.showPlaceholders` is off (inline placeholders
inside a value are the build guard's job, not this filter's – see PlaceholderText). */
export function visibleFacts(items: readonly FactItem[]): FactItem[] {
return items.filter((f) => flags.showPlaceholders || !(f.placeholder || isPlaceholder(f.value)));
}
/**
* Facts stack (Format · Sprache · Ort · Dauer): DM Mono 12 px, one gold tick
* per line, label muted, value in the foreground colour. Both variants are
* ledgers – tick / label / value are columns with one left edge each, not an
* inline run that starts the value at a different x on every row.
*/
export function Facts({ items, variant = "stack", title, className }: FactsProps) {
const rows = visibleFacts(items);
if (!rows.length) return null;
if (variant === "table") {
return (
<div className={cn("border border-line bg-surface p-6 sm:p-10", className)}>
{title && <p className="mb-6 font-mono text-xs uppercase tracking-[0.12em] text-accent">{title}</p>}
<dl className="font-mono text-xs tracking-[0.06em] tabular-nums">
{rows.map((f) => (
/* Below sm the value column would be ~150 px inside the padded box and long
values („Inhouse-Workshop 1–2 Tage / Trainingsreihe / Online“) break over four
lines – so the label sits above the value there, identically on every page. */
<div key={f.label} className="grid grid-cols-1 gap-x-6 gap-y-1 border-b border-line py-3 last:border-b-0 sm:grid-cols-[1fr_3fr]">
<dt className="text-fg-muted">{f.label}</dt>
<dd className="text-fg">
<PlaceholderText text={f.value} />
</dd>
</div>
))}
</dl>
</div>
);
}
return (
/* Three rails: tick | label (max-content, so every label ends on one edge) | value. */
<dl
className={cn(
"grid grid-cols-[1.5rem_max-content_1fr] gap-x-3 gap-y-3 font-mono text-xs tracking-[0.06em] tabular-nums",
className,
)}
>
{rows.map((f) => (
<div key={f.label} className="contents">
<span aria-hidden="true" className="mt-[0.55em] h-px w-6 self-start bg-accent" />
<dt className="text-fg-muted">{f.label}</dt>
<dd className="text-fg">
<PlaceholderText text={f.value} />
</dd>
</div>
))}
</dl>
);
}
export default Facts;
+39
View File
@@ -0,0 +1,39 @@
import { cn } from "@/lib/utils";
import type { FactItem } from "@/content/home";
import { PlaceholderText, visibleFacts } from "./Facts";
export interface FactsStripProps {
items: readonly FactItem[];
className?: string;
}
/**
* Speaker facts: one full-width row of equal cells separated by 1 px
* vertical hairlines. Each cell = DM Mono label (muted) + Jost value.
* Wraps to 2 / 3 columns below 1024 px.
*/
export function FactsStrip({ items, className }: FactsStripProps) {
const rows = visibleFacts(items);
if (!rows.length) return null;
return (
<dl
className={cn(
"grid grid-cols-2 border-y border-line sm:grid-cols-3 lg:grid-cols-6",
"[&>div]:border-l [&>div]:border-line [&>div:first-child]:border-l-0",
"[&>div:nth-child(2n+1)]:max-sm:border-l-0 [&>div:nth-child(3n+1)]:sm:max-lg:border-l-0",
className,
)}
>
{rows.map((f) => (
<div key={f.label} className="flex flex-col gap-2 px-5 py-6 lg:px-6 lg:py-7">
<dt className="font-mono text-xs tracking-[0.06em] text-fg-muted">{f.label}</dt>
<dd className="font-sans text-[1.0625rem] leading-snug text-fg">
<PlaceholderText text={f.value} />
</dd>
</div>
))}
</dl>
);
}
export default FactsStrip;
+38
View File
@@ -0,0 +1,38 @@
import { cn } from "@/lib/utils";
import type { FaqItem } from "@/content/home";
export type { FaqItem };
export interface FaqProps {
items: readonly FaqItem[];
className?: string;
}
/** DM Mono „+“ that turns into „ד by rotating 45° when the `<details>` is open. */
export const PLUS_GLYPH =
"font-mono text-base leading-none text-accent transition-transform duration-300 ease-[var(--ease-out-quart)] group-open:rotate-45";
/**
* Native `<details>/<summary>` ledger: question Bodoni 28 px, answer Jost 400,
* hairline-separated, no accordion library. Fully usable without JS.
*/
export function Faq({ items, className }: FaqProps) {
if (!items.length) return null;
return (
<div className={cn("border-t border-line", className)}>
{items.map((item) => (
<details key={item.q} className="group border-b border-line">
<summary className="flex cursor-pointer list-none items-baseline justify-between gap-6 py-6 [&::-webkit-details-marker]:hidden">
<span className="font-display text-[1.75rem] font-normal leading-[1.15] text-fg">{item.q}</span>
<span aria-hidden="true" className={cn(PLUS_GLYPH, "shrink-0 self-center")}>
+
</span>
</summary>
<p className="max-w-[36rem] pb-8 font-sans text-[1.0625rem] leading-[1.6] text-fg">{item.a}</p>
</details>
))}
</div>
);
}
export default Faq;
+273
View File
@@ -0,0 +1,273 @@
"use client";
import { useLayoutEffect, useRef, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { gsap, DUR, MM } from "@/lib/gsap";
import { STROKE_IN, WIPE_IN, primeMark } from "@/components/motion/Draw";
import { Container } from "@/components/layout/Container";
import { GRID } from "@/components/layout/Section";
import type { LinkItem } from "@/content/home";
import { Eyebrow, EYEBROW_TEXT } from "./Eyebrow";
import { Numeral } from "./Numeral";
import { TextLink } from "./TextLink";
export interface FormulaLine {
/** Operator vor der Zeile (DM Mono, 0.5em, gold). Genutzt werden nur „+“ und „=“;
* der Typ ist `string`, weil die Inhaltsmodule für die zweite Sprache strukturell
* typisiert sind und Literaltypen dort nicht durchgereicht werden. */
op?: string;
text: string;
/** The one italic word; `goldEm` makes it the page's single `.gold-text` word. */
em?: string;
goldEm?: boolean;
/** Zwei-Satz-Erläuterung rechts neben der Zeile. Optional. */
definition?: string;
/** „Liefert: …“ Optional, nur zusammen mit `definition` sinnvoll. */
delivers?: string;
}
export interface FormulaProps {
lines: readonly FormulaLine[];
/** Vorlesefassung der Operatoren, z. B. `{ "+": "plus ", "=": "gleich " }`. */
opLabels?: Readonly<Record<string, string>>;
/** „Unlock Your Potential — Next Level“ – optional, wird ohne Wert weggelassen. */
signoff?: string;
/** Mobile meta line above the eyebrow, e.g. „03 / 06“ (desktop shows the numeral). */
meta?: string;
link: LinkItem;
/** „Das WOLF-Prinzip“ */
eyebrow?: string;
/** „03“ – Bodoni `.gold-text` numeral in cols 1–2. */
numeral?: string;
/** `<BrandMark/>` (Server Component) – drawn scrubbed 70–95 % on desktop, once at 80 % in view otherwise. */
mark?: ReactNode;
id?: string;
className?: string;
}
/* Positions on a 100-unit scrub timeline (spec §6.4). Line 1 starts at 0 and is
fully revealed by 9 % – the 200 svh stage never opens on an empty screen. */
const AT = { fades: 0, lines: [0, 36, 68], wolf: 70, wolfEnd: 95, signoff: 95 } as const;
const LINE_DUR = 9;
/**
* Das WOLF-Prinzip typeset as a formula. Desktop (≥ 1024, fine pointer, no
* reduced motion): the inner 100svh container is CSS-sticky inside a 200svh section (scrub 0.4)
* and reveals line 1/2/3 via clip-path at 0/36/68 %, definitions alongside,
* a single sheen over the gold word, the wolf drawn 70–95 %, sign-off at 95 %.
* Mobile / reduced motion: natural stacked flow (the 200 svh stage collapses to
* its content height), wolf drawn once.
* Ink only – the themed block around it paints the ground.
*/
export function Formula({ lines, opLabels, signoff, link, eyebrow, numeral, mark, meta, id = "prinzip", className }: FormulaProps) {
const outer = useRef<HTMLElement>(null);
const inner = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const outerEl = outer.current;
const innerEl = inner.current;
if (!outerEl || !innerEl) return;
const q = gsap.utils.selector(outerEl);
const fades = q<HTMLElement>("[data-f='eyebrow'], [data-f='numeral']");
const lineEls = q<HTMLElement>("[data-f='line']");
const defs = q<HTMLElement>("[data-f='def']");
const ops = q<HTMLElement>("[data-f='op']");
const ems = q<HTMLElement>("[data-f='em']");
const signoffEls = q<HTMLElement>("[data-f='signoff']");
const markEl = outerEl.querySelector<HTMLElement>("[data-f='mark']");
const paths = Array.from(outerEl.querySelectorAll<SVGPathElement>("path[data-draw]"));
const markSvg = outerEl.querySelector<SVGSVGElement>("[data-f='mark'] svg");
const showAll = () => {
gsap.set([...fades, ...defs, ...ops, ...signoffEls], { opacity: 1 });
gsap.set(lineEls, { clipPath: "inset(0% 0% 0% 0%)" });
};
const mm = gsap.matchMedia();
mm.add({ reduce: MM.reduce, ok: MM.ok, desktop: MM.desktop }, (ctx) => {
const { reduce, desktop } = ctx.conditions ?? {};
if (reduce || !desktop) {
showAll();
if (!reduce && paths.length && markEl) {
const m = primeMark(markSvg, paths);
gsap.to(m === "wipe" ? markSvg : paths, {
...(m === "wipe" ? WIPE_IN : STROKE_IN),
duration: DUR.draw,
ease: "power2.inOut",
...(m === "wipe" ? {} : { stagger: 0.12 }),
scrollTrigger: { trigger: markEl, start: "top 80%", once: true },
});
}
return;
}
// Desktop: initial states, then one scrubbed, pinned timeline (0–100).
/* Opacity only, never `autoAlpha` (spec A/D3): `visibility: hidden` would take the
sign-off's TextLink – the section's only link – out of the tab order until the
scrub reaches 95 %. It stays focusable; `focus-within` in the markup paints it. */
gsap.set([...fades, ...defs, ...ops, ...signoffEls], { opacity: 0 });
gsap.set(lineEls, { clipPath: "inset(0% 0% 100% 0%)" });
if (ems.length) gsap.set(ems, { backgroundSize: "250% 100%", backgroundPosition: "0% 50%" });
const markMode = primeMark(markSvg, paths);
let sheened = false;
const tl = gsap.timeline({
defaults: { ease: "none" },
scrollTrigger: {
trigger: outerEl,
start: "top top",
end: "bottom bottom",
scrub: 0.4,
invalidateOnRefresh: true,
},
});
tl.to(fades, { opacity: 1, duration: 12, stagger: 3 }, AT.fades);
lineEls.forEach((line, i) => {
const at = AT.lines[i] ?? AT.lines[AT.lines.length - 1] + 25 * (i - AT.lines.length + 1);
const op = line.querySelector<HTMLElement>("[data-f='op']");
tl.to(line, { clipPath: "inset(0% 0% 0% 0%)", duration: LINE_DUR }, at);
if (op) tl.to(op, { opacity: 1, duration: 6 }, at);
if (defs[i]) tl.to(defs[i], { opacity: 1, duration: 10 }, at + LINE_DUR);
});
if (ems.length) {
tl.call(
() => {
if (sheened) return;
sheened = true;
gsap.fromTo(
ems,
{ backgroundPosition: "0% 50%" },
{ backgroundPosition: "100% 50%", duration: 1.2, ease: "power2.inOut" },
);
},
undefined,
AT.lines[2] + 8,
);
}
if (paths.length) {
const span = AT.wolfEnd - AT.wolf;
if (markMode === "wipe") {
tl.to(markSvg, { ...WIPE_IN, duration: span }, AT.wolf);
} else {
const each = span / (1 + (paths.length - 1) * 0.35);
paths.forEach((p, i) => {
tl.to(p, { ...STROKE_IN, duration: each }, AT.wolf + i * each * 0.35);
});
}
}
tl.to(signoffEls, { opacity: 1, duration: 5 }, AT.signoff);
// Keep the timeline exactly 100 units long so positions read as percent.
if (tl.duration() < 100) tl.set({}, {}, 100);
});
return () => mm.revert();
}, [lines.length]);
return (
<section
id={id}
ref={outer}
data-theme="dark"
className={cn("relative text-fg lg:h-[200svh] motion-reduce:lg:h-auto", className)}
>
<div
ref={inner}
className="lg:sticky lg:top-0 lg:flex lg:h-svh lg:flex-col lg:overflow-hidden motion-reduce:lg:static motion-reduce:lg:h-auto motion-reduce:lg:overflow-visible"
>
<Container className="flex flex-1 flex-col py-24 lg:py-0 lg:pb-10 lg:pt-24">
<div className={cn(GRID, "flex-1 gap-y-10")}>
{numeral && (
<div data-f="numeral" data-reveal="" className="hidden lg:col-span-2 lg:block [.js_&]:opacity-0">
<Numeral value={numeral} theme="dark" sticky={false} />
</div>
)}
<div className="col-span-12 flex flex-col lg:col-span-10 lg:col-start-3">
{meta && (
<p className="mb-8 font-mono text-xs tracking-[0.06em] tabular-nums text-fg-muted lg:hidden">{meta}</p>
)}
{/* The section's heading. Without it the home outline jumps from 02 straight
to 04; no `[data-f=line]` may take the role – the three terms are a
formula, and one of them carries `.gold-text`. */}
{eyebrow && (
<div data-f="eyebrow" data-reveal="" className="[.js_&]:opacity-0">
<Eyebrow as="h2">{eyebrow}</Eyebrow>
</div>
)}
<div className="my-auto flex flex-col gap-12 py-14 lg:gap-8 lg:py-4">
{lines.map((line, i) => (
<div key={i} className="grid grid-cols-1 gap-y-4 lg:grid-cols-10 lg:items-end lg:gap-x-8">
<p
data-f="line"
data-reveal=""
className={cn(
"font-display text-[clamp(2.5rem,1.2rem+5.4vw,7.5rem)] font-normal leading-[1.0] tracking-[-0.01em] text-ivory [.js_&]:[clip-path:inset(0_0_100%_0)]",
/* Ohne Erläuterung nimmt die Zeile die volle Breite. */
line.definition || line.delivers ? "lg:col-span-6" : "lg:col-span-10",
)}
>
{line.op && (
<span
data-f="op"
aria-hidden="true"
className="mr-[0.3em] inline-block translate-y-[-0.15em] font-mono text-[0.5em] font-normal text-gold [.js_&]:opacity-0"
>
{line.op}
</span>
)}
{line.op && opLabels?.[line.op] && <span className="sr-only">{opLabels[line.op]}</span>}
{line.text}
{line.em && (
<em data-f={line.goldEm ? "em" : undefined} className={cn("italic", line.goldEm && "gold-text")}>
{line.em}
</em>
)}
</p>
{(line.definition || line.delivers) && (
<div data-f="def" data-reveal="" className="flex flex-col gap-3 lg:col-span-4 lg:pb-[0.35em] [.js_&]:opacity-0">
{line.definition && (
<p className="max-w-[30ch] font-sans text-lg font-light leading-[1.45] text-grey-warm lg:text-base">{line.definition}</p>
)}
{line.delivers && <p className="font-mono text-xs tracking-[0.06em] text-gold">{line.delivers}</p>}
</div>
)}
</div>
))}
</div>
<div className="mt-auto flex flex-col gap-12 lg:flex-row lg:items-end lg:justify-between">
{/* `focus-within:!opacity-100` outranks the scrub's inline opacity, so tabbing
to the link always paints it (and its 2 px focus ring) while the scrub keeps
the spec's 95 % sign-off (§6.4). */}
<div data-f="signoff" data-reveal="" className="flex flex-col gap-5 focus-within:!opacity-100 [.js_&]:opacity-0">
{/* Muted, not gold: the caps text link below must be the only gold caps line here. */}
{signoff && <p className={cn(EYEBROW_TEXT, "text-fg-muted")}>{signoff}</p>}
<TextLink href={link.href} label={link.label} external={link.external} />
</div>
{mark && (
<div
data-f="mark"
aria-hidden="true"
className="w-[40vw] max-w-[18rem] self-end text-ivory lg:h-[20vh] lg:w-auto lg:max-w-none [&_svg]:h-auto [&_svg]:w-full lg:[&_svg]:h-full lg:[&_svg]:w-auto"
>
{mark}
</div>
)}
</div>
</div>
</div>
</Container>
</div>
</section>
);
}
export default Formula;
+50
View File
@@ -0,0 +1,50 @@
import { cn } from "@/lib/utils";
import { Container } from "@/components/layout/Container";
import { GRID } from "@/components/layout/Section";
import { Lines } from "@/components/motion/Lines";
import { Reveal } from "@/components/motion/Reveal";
export interface InterruptionProps {
/** „Mut zum Erfolg.“ */
text: string;
/** DM Mono line below, e.g. „Keynote · herCAREER Expo 2026 · 23.10. · 10:30 · Auditorium 2“. */
mono?: string;
id?: string;
className?: string;
}
/**
* Typographic interruption (keynote page): 100svh ink, one giant Bodoni line
* `clamp(4rem, 2rem + 12vw, 14rem)` in cols 2–12, mono meta in gold below.
* Background stays transparent – <body> paints the ground.
*/
export function Interruption({ text, mono, id, className }: InterruptionProps) {
return (
<section
id={id}
data-theme="dark"
aria-label={text}
className={cn("relative flex min-h-svh items-center bg-transparent py-24 text-fg", className)}
>
<Container className="w-full">
<div className={GRID}>
<div className="col-span-12 lg:col-start-2 lg:col-span-11">
<Lines
as="p"
className="font-display text-[clamp(4rem,2rem+12vw,14rem)] font-normal leading-[0.94] tracking-[-0.015em] text-ivory [text-wrap:balance]"
>
{text}
</Lines>
{mono && (
<Reveal as="p" delay={0.3} className="mt-8 font-mono text-xs tracking-[0.06em] tabular-nums text-gold">
{mono}
</Reveal>
)}
</div>
</div>
</Container>
</section>
);
}
export default Interruption;
+186
View File
@@ -0,0 +1,186 @@
import { cn } from "@/lib/utils";
import { TransitionLink } from "@/components/motion/TransitionLink";
import { RevealGroup, RevealItem } from "@/components/motion/Reveal";
import { termine as termineDe } from "@/content/home";
import type { GoalRow, SituationRow, TermineRow, TocRow, WerdegangRow } from "@/content/home";
import { TEXT_LINK } from "./TextLink";
export type { GoalRow, SituationRow, TermineRow, TocRow, WerdegangRow };
export type LedgerRow = TocRow | WerdegangRow | TermineRow | SituationRow | GoalRow;
interface LedgerBase {
className?: string;
/** Stagger between rows (default 0.1 s). */
stagger?: number;
}
export type LedgerProps =
| (LedgerBase & { variant: "toc"; rows: readonly TocRow[] })
| (LedgerBase & { variant: "werdegang"; rows: readonly WerdegangRow[] })
| (LedgerBase & { variant: "termine"; rows: readonly TermineRow[]; heading?: string })
| (LedgerBase & { variant: "situations"; rows: readonly SituationRow[] })
| (LedgerBase & { variant: "goals"; rows: readonly GoalRow[] });
const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums";
/* ---------------- toc ---------------- */
/**
* Table-of-contents row = ONE <TransitionLink> (keyboard: one tab stop).
* Hover: ground `--surface`, lower hairline turns gold (scaleX from the left,
* 0.6 s), arrow +8 px. No italic flip, nothing reflows – everything is
* always visible.
*/
function TocRowView({ row }: { row: TocRow }) {
return (
<TransitionLink
href={row.link.href}
label={row.title}
className="group relative grid grid-cols-1 gap-y-4 py-7 transition-colors duration-300 hover:bg-surface focus-visible:bg-surface lg:grid-cols-12 lg:gap-x-[clamp(16px,2vw,32px)] lg:py-10"
>
<span className="flex items-baseline gap-4 lg:col-span-5 lg:contents">
<span className={cn(MONO, "text-accent lg:col-span-1")}>{row.numeral}</span>
<span className="font-display text-[clamp(1.75rem,1.4rem+1.6vw,3rem)] font-normal leading-[1.05] text-fg lg:col-span-4">
{row.title}
</span>
</span>
<span className="flex flex-col gap-2 lg:col-span-5">
<span className="font-sans text-[1.0625rem] leading-snug text-fg">{row.oneLiner}</span>
<span className={cn(MONO, "text-fg-muted")}>{row.keywords.join(" · ")}</span>
</span>
<span className={cn(TEXT_LINK, "whitespace-nowrap lg:col-span-2 lg:justify-self-end lg:self-start")}>
<span>{row.link.label}</span>
<span aria-hidden="true" className="transition-transform duration-300 ease-[var(--ease-out-quart)] group-hover:translate-x-2">
→
</span>
</span>
{/* Lower hairline: recolours to gold from the left on hover. */}
<span
aria-hidden="true"
className="rule-gold absolute inset-x-0 bottom-[-1px] origin-left scale-x-0 transition-transform duration-[600ms] ease-[var(--ease-out-expo)] group-hover:scale-x-100 group-focus-visible:scale-x-100"
/>
</TransitionLink>
);
}
/* ---------------- termine ---------------- */
function TermineRowView({ row }: { row: TermineRow }) {
const inner = (
<>
<span className={cn(MONO, "text-[13px] text-fg")}>{row.date}</span>
<span className="font-sans text-[0.8125rem] font-medium uppercase tracking-[0.16em] text-fg-muted">{row.format}</span>
<span className="font-sans text-lg leading-snug text-fg lg:font-display lg:text-[1.75rem] lg:font-normal lg:leading-[1.1]">
{row.title}
</span>
<span className={cn(MONO, "text-fg-muted")}>
{row.venue}
{row.href && (
<>
{" "}
<span aria-hidden="true">↗</span>
</>
)}
</span>
</>
);
const cls =
"grid grid-cols-1 gap-y-1 py-5 lg:grid-cols-[1fr_1fr_2fr_1fr] lg:items-baseline lg:gap-x-8";
if (row.href) {
return (
<a href={row.href} target="_blank" rel="noopener noreferrer" className={cn(cls, "group/row")}>
{inner}
</a>
);
}
return <div className={cls}>{inner}</div>;
}
/* ---------------- Ledger ---------------- */
/**
* Typographic ledgers instead of cards. Rows are hairline-separated and
* stagger in via <RevealGroup/>; the toc variant's rows are links.
*/
export function Ledger(props: LedgerProps) {
const { className, stagger = 0.1 } = props;
if (props.variant === "toc") {
return (
<RevealGroup as="ol" stagger={stagger} className={cn("border-t border-line", className)}>
{props.rows.map((row) => (
<RevealItem key={row.numeral} as="li" className="border-b border-line">
<TocRowView row={row} />
</RevealItem>
))}
</RevealGroup>
);
}
if (props.variant === "werdegang") {
return (
<RevealGroup as="dl" stagger={stagger} className={cn("border-t border-line", className)}>
{props.rows.map((row) => (
<RevealItem
key={row.keyword}
className="grid grid-cols-1 gap-y-1 border-b border-line py-[18px] sm:grid-cols-4 sm:gap-x-6"
>
<dt className={cn(MONO, "uppercase text-accent sm:col-span-1")}>{row.keyword}</dt>
<dd className="font-sans text-[1.0625rem] leading-snug text-fg sm:col-span-3">{row.text}</dd>
</RevealItem>
))}
</RevealGroup>
);
}
if (props.variant === "termine") {
const heading = props.heading ?? termineDe.heading;
return (
<div className={cn("border border-line bg-surface p-6 sm:p-10", className)}>
<p className={cn(MONO, "mb-4 flex items-center gap-3 uppercase tracking-[0.12em] text-accent")}>
<span aria-hidden="true" className="inline-block h-1.5 w-1.5 shrink-0 bg-accent" />
{heading}
</p>
<RevealGroup as="ol" stagger={stagger} className="border-t border-line">
{props.rows.map((row) => (
<RevealItem key={`${row.date}-${row.title}`} as="li" className="border-b border-line">
<TermineRowView row={row} />
</RevealItem>
))}
</RevealGroup>
</div>
);
}
if (props.variant === "situations") {
return (
<RevealGroup as="ol" stagger={stagger} className={cn("border-t border-line", className)}>
{props.rows.map((row) => (
<RevealItem
key={row.numeral}
as="li"
className="grid grid-cols-[2.5rem_1fr] gap-x-4 border-b border-line py-5 sm:grid-cols-[3rem_1fr] sm:gap-x-8"
>
<span className={cn(MONO, "pt-[0.5em] text-accent")}>{row.numeral}</span>
<span className="font-display text-[clamp(1.75rem,1.3rem+1.2vw,2rem)] font-normal italic leading-[1.2] text-fg">
{row.text}
</span>
</RevealItem>
))}
</RevealGroup>
);
}
return (
<RevealGroup as="ul" stagger={stagger} className={cn("border-t border-line", className)}>
{props.rows.map((row) => (
<RevealItem key={row.text} as="li" className="border-b border-line py-4 font-sans text-[1.0625rem] leading-snug text-fg">
{row.text}
</RevealItem>
))}
</RevealGroup>
);
}
export default Ledger;
+66
View File
@@ -0,0 +1,66 @@
import { cn } from "@/lib/utils";
export interface MarginaliaItem {
/** Optional mono label, e.g. „Praxis“ → „Praxis: B2B-Außendienst“. */
label?: string;
value: string;
href?: string;
/** `target="_blank"` + ↗ */
external?: boolean;
}
export interface MarginaliaProps {
items: readonly MarginaliaItem[];
/** Optional caps heading above the list, e.g. „Was du bekommst“. */
title?: string;
/** `lg:sticky lg:top-[30vh]` */
sticky?: boolean;
className?: string;
}
/** Shared mono meta style (DM Mono 12 px, ls .06em, tabular). */
export const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums";
/**
* Verifiable facts in the outer column: DM Mono 12 px, muted, one gold tick
* per line. Links get the growing accent underline; external ones ↗.
*/
export function Marginalia({ items, title, sticky = false, className }: MarginaliaProps) {
return (
<aside className={cn(MONO, "text-fg-muted", sticky && "lg:sticky lg:top-[30vh]", className)}>
{title && <p className="mb-4 uppercase tracking-[0.12em] text-accent">{title}</p>}
<ul className="flex flex-col gap-3">
{items.map((item, i) => {
const text = item.label ? `${item.label}: ${item.value}` : item.value;
return (
<li key={`${item.value}-${i}`} className="flex items-start gap-3">
<span aria-hidden="true" className="mt-[0.55em] h-px w-6 shrink-0 bg-accent" />
{item.href ? (
<a
href={item.href}
target={item.external ? "_blank" : undefined}
rel={item.external ? "noopener noreferrer" : undefined}
/* Tap target via ::before – padding + negative margin on the flex child
collapsed the <li> box and broke the 12 px tick rhythm of the list. */
className="relative inline-block text-fg before:absolute before:inset-x-0 before:-inset-y-3 before:content-[''] bg-[linear-gradient(currentColor,currentColor)] bg-[length:0%_1px] bg-left-bottom bg-no-repeat pb-px transition-[background-size] duration-[400ms] ease-[var(--ease-out-quart)] hover:bg-[length:100%_1px] focus-visible:bg-[length:100%_1px]"
>
{text}
{item.external && (
<>
{" "}
<span aria-hidden="true">↗</span>
</>
)}
</a>
) : (
<span>{text}</span>
)}
</li>
);
})}
</ul>
</aside>
);
}
export default Marginalia;
+28
View File
@@ -0,0 +1,28 @@
import { PlaceholderText } from "./Facts";
export interface MetaLinesProps {
/** „Online · DE/EN · [Dauer]“ */
text: string;
}
/**
* A ledger meta string, one item per line. The DM Mono meta columns are narrow
* (1.5–2 fr), so a single string breaks mid-separator („Online · DE/EN ·“ /
* „[Dauer]“, or a line opening with a lone „·“). Splitting on „ · “ keeps every
* item whole and the separator out of the line breaks.
*/
export function MetaLines({ text }: MetaLinesProps) {
const parts = text.split(/\s+·\s+/).filter((p) => p.length > 0);
if (parts.length < 2) return <PlaceholderText text={text} />;
return (
<>
{parts.map((part, i) => (
<span key={`${part}-${i}`} className="block whitespace-nowrap">
<PlaceholderText text={part} />
</span>
))}
</>
);
}
export default MetaLines;
+79
View File
@@ -0,0 +1,79 @@
import { cn } from "@/lib/utils";
import { moduleLabels } from "@/content/trainings";
import type { ModuleItem } from "@/content/trainings";
import { MetaLines } from "./MetaLines";
import { PLUS_GLYPH } from "./Faq";
export type { ModuleItem };
export interface ModuleTableProps {
modules: readonly ModuleItem[];
className?: string;
}
const MONO = "font-mono text-xs tracking-[0.06em]";
const HEAD = cn(MONO, "uppercase tracking-[0.12em] text-accent");
/**
* Trainings ledger table: header row „Modul | Ziel | Für wen“ – decorative, because
* every cell carries its own label (visible below lg, `sr-only` from lg) – then
* one native `<details>` per module whose `<summary>` is the full row
* (Bodoni 30 px title, Jost goal + audience, mono meta). Opens to the
* hairline-separated „Inhalte“ list; „+“ rotates to „ד via CSS.
*/
export function ModuleTable({ modules, className }: ModuleTableProps) {
const cols = "grid grid-cols-[1fr_auto] gap-x-6 gap-y-3 lg:grid-cols-[minmax(0,3.5fr)_minmax(0,3fr)_minmax(0,2fr)_minmax(0,2.2fr)_2rem] lg:gap-x-8";
return (
<div className={cn("w-full", className)}>
<div className={cn(cols, "hidden border-b border-line pb-3 lg:grid")} aria-hidden="true">
<span className={HEAD}>{moduleLabels.module}</span>
<span className={HEAD}>{moduleLabels.goal}</span>
<span className={HEAD}>{moduleLabels.audience}</span>
<span />
<span />
</div>
{modules.map((m) => (
<details key={m.title} className="group border-b border-line">
<summary className={cn(cols, "cursor-pointer list-none py-7 lg:items-baseline lg:py-8 [&::-webkit-details-marker]:hidden")}>
<span className="font-sans text-xl leading-snug text-fg lg:font-display lg:text-[1.875rem] lg:font-normal lg:leading-[1.1]">
{m.title}
</span>
<span aria-hidden="true" className={cn(PLUS_GLYPH, "self-start lg:order-last lg:justify-self-end")}>
+
</span>
{/* `lg:sr-only`, not `lg:hidden`: from 1024 the column headers above are the only
labelling, and they are decorative (aria-hidden) – the goal and audience runs
would be announced as unlabelled text. The label stays in the DOM at every
width and is only visually suppressed where the header row takes over. */}
<span className="col-span-2 font-sans text-[1.0625rem] leading-snug text-fg lg:col-span-1">
<span className={cn(HEAD, "mr-3 lg:sr-only")}>{moduleLabels.goal}</span>
{m.goal}
</span>
<span className="col-span-2 font-sans text-[1.0625rem] leading-snug text-fg lg:col-span-1">
<span className={cn(HEAD, "mr-3 lg:sr-only")}>{moduleLabels.audience}</span>
{m.audience}
</span>
<span className={cn(MONO, "col-span-2 tabular-nums text-fg-muted lg:col-span-1 lg:text-right")}>
{/* The meta column has no visible header at any width. */}
<span className="sr-only">{moduleLabels.meta}</span>
<MetaLines text={m.meta} />
</span>
</summary>
<div className="pb-8 lg:grid lg:grid-cols-[minmax(0,3fr)_minmax(0,7fr)_2rem] lg:gap-x-8">
<span className={cn(HEAD, "mb-3 block lg:mb-0")}>{moduleLabels.contents}</span>
<ul className="border-t border-line">
{m.contents.map((line) => (
<li key={line} className="border-b border-line py-3 font-sans text-[1.0625rem] leading-snug text-fg">
{line}
</li>
))}
</ul>
</div>
</details>
))}
</div>
);
}
export default ModuleTable;
+47
View File
@@ -0,0 +1,47 @@
import { cn } from "@/lib/utils";
export interface NumeralProps {
/** „01“ */
value: string;
/**
* Ground the numeral sits on. Omit to resolve it from the nearest
* `[data-theme]` ancestor (CSS only): `.gold-text` on dark, flat `--gold-700` on light.
*/
theme?: "dark" | "light";
/** `position: sticky; top: calc(50vh − .45em)` (desktop). Default true. */
sticky?: boolean;
className?: string;
}
const GOLD_TEXT_DARK =
"[[data-theme=dark]_&]:bg-[image:var(--gold-gradient-text)] [[data-theme=dark]_&]:bg-clip-text [[data-theme=dark]_&]:text-gold [[data-theme=dark]_&]:[-webkit-text-fill-color:transparent]";
/**
* Section numeral: Playfair 400, `clamp(4rem, 2rem + 8vw, 10rem)`,
* lining numerals. Decorative – the accessible section number lives in the
* Section's mono meta line, so this is `aria-hidden`.
*/
export function Numeral({ value, theme, sticky = true, className }: NumeralProps) {
const colour =
theme === "dark"
? "gold-text"
: theme === "light"
? "text-gold-700"
: cn("text-gold-700", GOLD_TEXT_DARK);
return (
<span
aria-hidden="true"
className={cn(
"block select-none font-display text-[clamp(4rem,2rem+8vw,10rem)] font-normal leading-[0.9] tracking-[-0.01em] [font-variant-numeric:lining-nums]",
sticky && "lg:sticky lg:top-[calc(50vh_-_0.45em)]",
colour,
className,
)}
>
{value}
</span>
);
}
export default Numeral;
+79
View File
@@ -0,0 +1,79 @@
import { cn } from "@/lib/utils";
import { Door } from "@/components/motion/Door";
import { PlaceholderText } from "./Facts";
export type PortraitAspect = "4/5" | "3/5" | "3/4" | "16/10";
export interface PortraitProps {
/** `null` = photo not delivered → renders nothing (documented variant B). */
src: string | null;
alt: string;
aspect?: PortraitAspect;
/** DM Mono 11 px caption with gold tick, e.g. „Anelia Wolf · Foto: [Name]“. */
caption?: string;
/** `lg:sticky lg:top-[120px]` – stays in view while reading. */
sticky?: boolean;
/** Eager load (hero). */
priority?: boolean;
/** 6 % scroll parallax (desktop, fine pointer). */
parallax?: boolean;
/** 1 px `--line` frame (ivory) – off on ink. Default true. */
frame?: boolean;
sizes?: string;
className?: string;
}
/** Intrinsic dimensions per aspect (2000 px long edge). */
const DIMS: Record<PortraitAspect, { width: number; height: number }> = {
"4/5": { width: 1600, height: 2000 },
"3/5": { width: 1200, height: 2000 },
"3/4": { width: 1500, height: 2000 },
"16/10": { width: 2000, height: 1250 },
};
/**
* Magazine portrait in a `<Door/>` mask: warm grade, hard rectangle, hairline
* frame, mono caption. Never scales on hover, never tilts.
*/
export function Portrait({
src,
alt,
aspect = "4/5",
caption,
sticky = false,
priority = false,
parallax = false,
frame = true,
sizes,
className,
}: PortraitProps) {
if (!src) return null;
const { width, height } = DIMS[aspect];
return (
<figure className={cn(sticky && "lg:sticky lg:top-[120px]", className)}>
<Door
src={src}
alt={alt}
width={width}
height={height}
sizes={sizes}
priority={priority}
parallax={parallax}
play={priority ? "load" : "view"}
className={cn(frame && "border border-line")}
imgClassName="[filter:sepia(.12)_saturate(.9)_contrast(1.02)]"
/>
{caption && (
/* Der Bildnachweis läuft über <PlaceholderText/>: „Foto: [Name der Fotografin]“ ist
ein echter Platzhalter (§ 13 UrhG) und muss wie jeder andere gold auffallen und
im Build-Guard auftauchen – als rohe eckige Klammer wäre er beim Launch untergegangen. */
<figcaption className="mt-3 flex items-center gap-3 font-mono text-[11px] tracking-[0.06em] text-fg-muted">
<span aria-hidden="true" className="h-px w-6 shrink-0 bg-accent" />
<PlaceholderText text={caption} />
</figcaption>
)}
</figure>
);
}
export default Portrait;
+29
View File
@@ -0,0 +1,29 @@
import { cn } from "@/lib/utils";
export interface QuoteProps {
text: string;
author: string;
role?: string;
className?: string;
}
/**
* Pull quote: Bodoni 400 Italic `clamp(1.75rem, 1.2rem + 1.4vw, 2.5rem)`,
* hanging „ via `text-indent: −0.4em`. Name in Jost 500 caps (Bodoni never
* below 28 px), role in DM Mono. No stars, no avatar.
*/
export function Quote({ text, author, role, className }: QuoteProps) {
return (
<figure className={cn("max-w-[34ch]", className)}>
<blockquote className="font-display text-[clamp(1.75rem,1.2rem+1.4vw,2.5rem)] font-normal italic leading-[1.25] text-fg [text-indent:-0.4em]">
„{text}“
</blockquote>
<figcaption className="mt-6 flex flex-wrap items-baseline gap-x-4 gap-y-1">
<span className="font-sans text-sm font-medium uppercase tracking-[0.08em] text-fg">{author}</span>
{role && <span className="font-mono text-xs tracking-[0.06em] text-fg-muted">{role}</span>}
</figcaption>
</figure>
);
}
export default Quote;
+92
View File
@@ -0,0 +1,92 @@
import { cn } from "@/lib/utils";
import { RevealGroup, RevealItem } from "@/components/motion/Reveal";
import { specLabels as specLabelsDe } from "@/content/coaching";
import type { SpecBlock } from "@/content/coaching";
import { PlaceholderText } from "./Facts";
import { MetaLines } from "./MetaLines";
export type { SpecBlock };
/** Die drei Zeilenbeschriftungen – in der Sprache der Seite. */
export type SpecLabels = { goal: string; contents: string; audience: string };
export interface SpecTableProps {
labels?: SpecLabels;
blocks: readonly SpecBlock[];
className?: string;
}
const MONO = "font-mono text-xs tracking-[0.06em]";
const LABEL = cn(MONO, "text-accent");
/**
* Leistungsbausteine as a spec table, not cards: per block one row
* [Bodoni 30 px title, 30 %] [labelled paragraphs Ziel / Inhalte / Für wen, 55 %]
* [mono meta right, 15 %]. Optional `tracks` render as full-width, hairline-
* separated sub-rows below that row (1:1 Coaching). Paper stays paper – no
* zebra fills.
*/
export function SpecTable({ blocks, className, labels = specLabelsDe }: SpecTableProps) {
return (
<RevealGroup stagger={0.12} className={cn("border-t border-line", className)}>
{blocks.map((b) => (
<RevealItem
key={b.title}
className="grid grid-cols-1 gap-y-6 border-b border-line py-10 lg:grid-cols-[3fr_5.5fr_2fr] lg:gap-x-8 lg:py-12"
>
<h3 className="font-sans text-xl leading-snug text-fg lg:font-display lg:text-[1.875rem] lg:font-normal lg:leading-[1.1]">
{b.title}
</h3>
<div className="flex flex-col gap-5">
{b.goal && (
<p className="grid grid-cols-[5.5rem_1fr] gap-x-4">
<span className={cn(LABEL, "pt-[0.35em]")}>{labels.goal}</span>
<span className="font-sans text-[1.0625rem] leading-[1.55] text-fg">
<PlaceholderText text={b.goal} />
</span>
</p>
)}
{b.contents && (
<p className="grid grid-cols-[5.5rem_1fr] gap-x-4">
<span className={cn(LABEL, "pt-[0.35em]")}>{labels.contents}</span>
<span className="font-sans text-[1.0625rem] leading-[1.55] text-fg">
<PlaceholderText text={b.contents} />
</span>
</p>
)}
{b.audience && (
<p className="grid grid-cols-[5.5rem_1fr] gap-x-4">
<span className={cn(LABEL, "pt-[0.35em]")}>{labels.audience}</span>
<span className="font-sans text-[1.0625rem] leading-[1.55] text-fg">
<PlaceholderText text={b.audience} />
</span>
</p>
)}
</div>
<p className={cn(MONO, "tabular-nums text-fg-muted lg:text-right")}>
<MetaLines text={b.meta} />
</p>
{/* Sub-rows span the whole row, so their hairlines have the same length as the row's own
and the last one is the row rule itself – one skeleton for every block. */}
{b.tracks && b.tracks.length > 0 && (
<ul className="border-t border-line lg:col-span-3">
{b.tracks.map((t) => (
<li
key={t}
className="border-b border-line py-3 font-sans text-[1.0625rem] leading-snug text-fg last:border-b-0"
>
{t}
</li>
))}
</ul>
)}
</RevealItem>
))}
</RevealGroup>
);
}
export default SpecTable;
+49
View File
@@ -0,0 +1,49 @@
import { cn } from "@/lib/utils";
import { RevealGroup, RevealItem } from "@/components/motion/Reveal";
import type { StepItem } from "@/content/home";
export type { StepItem };
export interface StepsProps {
items: readonly StepItem[];
className?: string;
}
/**
* Numbered process rows: [DM Mono numeral] [title] [Jost 300 text],
* hairline-separated, padding-block 22 px. Rows stagger in (0.1 s).
* Empty `text` (service-page Ablauf) collapses to numeral + title and the
* title column gets the extra width so four-word steps stay on one line.
*
* Type: Jost 18 px below lg, Bodoni 28 px from lg – Bodoni is never rendered
* below 28 px (hard rule A1).
*/
export function Steps({ items, className }: StepsProps) {
const hasText = items.some((step) => Boolean(step.text));
return (
<RevealGroup as="ol" stagger={0.1} className={cn("border-t border-line", className)}>
{items.map((step) => (
<RevealItem
key={step.numeral}
as="li"
className={cn(
"grid grid-cols-[2.5rem_1fr] gap-x-4 gap-y-2 border-b border-line py-[22px] sm:gap-x-8",
hasText ? "sm:grid-cols-[3rem_minmax(0,16rem)_1fr]" : "sm:grid-cols-[3rem_minmax(0,1fr)]",
)}
>
<span className="pt-[0.35em] font-mono text-xs tracking-[0.06em] tabular-nums text-accent">{step.numeral}</span>
<span className="font-sans text-lg font-normal leading-[1.35] text-fg [overflow-wrap:anywhere] hyphens-auto lg:font-display lg:text-[1.75rem] lg:leading-[1.15]">
{step.title}
</span>
{step.text && (
<p className="col-start-2 font-sans text-[1.0625rem] font-light leading-[1.55] text-fg-muted sm:col-start-3 sm:pt-[0.3em]">
{step.text}
</p>
)}
</RevealItem>
))}
</RevealGroup>
);
}
export default Steps;
+64
View File
@@ -0,0 +1,64 @@
import { cn } from "@/lib/utils";
import { TransitionLink } from "@/components/motion/TransitionLink";
export interface TextLinkProps {
href: string;
label: string;
/** `target="_blank" rel="noopener noreferrer"` + ↗ instead of →. */
external?: boolean;
/** Anchor/arrow glyph override (default → / ↗ / ↓ for `#…`). */
arrow?: string;
className?: string;
}
/** Caps text link style (Jost 500, 13 px, ls .2em) – shared with ledger links.
* `-my-4/py-4` grows the tap target to 45 px without moving the baseline. */
export const TEXT_LINK =
"group/tl -my-4 inline-flex items-baseline gap-2 py-4 font-sans text-[0.8125rem] font-medium uppercase leading-none tracking-[0.2em] text-accent";
/** Underline grows from the left (`background-size 0 → 100%`, 0.4 s). */
export const GROW_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/tl:bg-[length:100%_1px] group-focus-visible/tl:bg-[length:100%_1px]";
const ARROW = "transition-transform duration-300 ease-[var(--ease-out-quart)] group-hover/tl:translate-x-2";
function isInternal(href: string) {
return href.startsWith("/");
}
/**
* Caps link with an arrow glyph. Internal routes go through the curtain
* (`TransitionLink`); mailto/tel/http are plain anchors.
*/
export function TextLink({ href, label, external = false, arrow, className }: TextLinkProps) {
const glyph = arrow ?? (external ? "↗" : href.startsWith("#") ? "↓" : "→");
const inner = (
<>
<span className={GROW_UNDERLINE}>{label}</span>
<span aria-hidden="true" className={ARROW}>
{glyph}
</span>
</>
);
const cls = cn(TEXT_LINK, className);
if (isInternal(href) && !external) {
return (
<TransitionLink href={href} label={label} className={cls}>
{inner}
</TransitionLink>
);
}
return (
<a
href={href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
className={cls}
>
{inner}
</a>
);
}
export default TextLink;