first publish

This commit is contained in:
2026-09-06 17:21:52 +02:00
commit b8e41b4d55
147 changed files with 19367 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export interface ContainerProps {
className?: string;
children: ReactNode;
}
/** Page frame: max-width 1680 px, page margin clamp(20px, 5vw, 80px). */
export function Container({ className, children }: ContainerProps) {
return <div className={cn("mx-auto w-full max-w-[105rem] px-[clamp(20px,5vw,80px)]", className)}>{children}</div>;
}
export default Container;

View File

@@ -0,0 +1,44 @@
import { cn } from "@/lib/utils";
import { Rule, type RuleProps } from "@/components/motion/Rule";
import { TransitionLink } from "@/components/motion/TransitionLink";
export interface FolioProps {
/**
* „01“ only where the folio IS the page's counter (the home hero counts the
* six sections). Service pages omit it: their sections carry their own
* „01 / 06“ and a second, differently-denominated fraction 150 px away reads
* as a bug, not as a folio.
*/
current?: string;
/** „06“ */
total?: string;
/** „Positionierung“ → `#positionierung` */
next?: { label: string; href: string };
className?: string;
/** Passed to the hairline the hero draws it outward from the handoff x. */
origin?: RuleProps["origin"];
play?: RuleProps["play"];
delay?: number;
}
/** Gold hairline + 44 px row: „01 / 06“ left, „Positionierung ↓“ right. */
export function Folio({ current, total, next, className, origin = "left", play = "view", delay }: FolioProps) {
return (
<div className={cn("w-full", className)}>
<Rule variant="gold" origin={origin} play={play} delay={delay} />
<div className="flex min-h-11 flex-wrap items-center justify-between gap-x-6 gap-y-1 py-1 font-mono text-xs tracking-[0.06em] tabular-nums text-fg-muted">
<span>{current && total ? `${current} / ${total}` : null}</span>
{next && (
<TransitionLink
href={next.href}
className="-my-3.5 inline-block py-3.5 text-fg transition-colors duration-300 hover:text-accent focus-visible:text-accent"
>
{next.label} <span aria-hidden="true"></span>
</TransitionLink>
)}
</div>
</div>
);
}
export default Folio;

View File

@@ -0,0 +1,111 @@
import { legal as legalDe, site } from "@/lib/site";
import { routesByLang, type Lang } from "@/lib/i18n";
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
import { BrandMark } from "@/components/brand/BrandMark";
import { Wordmark } from "@/components/brand/Wordmark";
import { Rule } from "@/components/motion/Rule";
import { TransitionLink } from "@/components/motion/TransitionLink";
import { Container } from "./Container";
import { GRID } from "./Section";
const heading = "mb-5 font-mono text-[11px] uppercase tracking-[0.12em] text-grey-warm";
/* -my-2.5/py-2.5 keeps the visual rhythm and lifts the tap target to 40 px. */
const link =
"-my-2.5 inline-block py-2.5 font-sans text-sm text-fg transition-colors duration-300 hover:text-gold focus-visible:text-gold";
/**
* Server Component, identical on every page. Paints its own ink ground
* (`data-theme="dark"`, globals.css), gold hairline on top, wordmark +
* two columns, bottom row with copyright · legal · claim (desktop).
*/
export interface FooterProps {
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
chrome?: Chrome;
/** Impressum/Datenschutz der jeweiligen Sprache. */
legal?: readonly { href: string; label: string }[];
/** Aktuelle Sprache bestimmt das Ziel der Wortmarke. */
lang?: Lang;
}
export function Footer({ chrome = chromeDe, legal = legalDe, lang = "de" }: FooterProps = {}) {
const year = new Date().getFullYear();
return (
/* The legal links are the last thing on a phone keep them clear of the home indicator
and the browser's bottom bar (the row's own padding is eaten by the link's -my-3). */
<footer data-theme="dark" className="relative pb-[max(2.5rem,env(safe-area-inset-bottom))] text-fg lg:pb-0">
<Rule variant="gold" />
<Container>
<div className={`${GRID} gap-y-12 py-16 lg:py-24`}>
<div className="col-span-12 lg:col-span-7 lg:pr-8">
<Wordmark size="footer" href={routesByLang[lang].home} mark={<BrandMark className="h-10 w-auto" idPrefix="ftr" />} chrome={chrome} />
</div>
<nav aria-label={chrome.footer.inhaltHeading} className="col-span-6 lg:col-span-3">
<h2 className={heading}>{chrome.footer.inhaltHeading}</h2>
<ul className="flex flex-col gap-5">
{chrome.footer.inhalt.map((item) => (
<li key={item.href}>
<TransitionLink href={item.href} label={item.label} className={link}>
{item.label}
</TransitionLink>
</li>
))}
</ul>
</nav>
<div className="col-span-6 lg:col-span-2">
<h2 className={heading}>{chrome.footer.kontaktHeading}</h2>
<ul className="flex flex-col gap-5">
<li>
<a href={`mailto:${site.email}`} className={link}>
{site.email}
</a>
</li>
<li>
<a href={site.phoneHref} className={`${link} font-mono tabular-nums`}>
{site.phoneDisplay}
</a>
</li>
<li>
<a href={site.linkedin} target="_blank" rel="noopener noreferrer" className={link}>
{chrome.footer.linkedin} <span aria-hidden="true"></span>
</a>
</li>
<li>
<a href={chrome.footer.hercareer.href} target="_blank" rel="noopener noreferrer" className={link}>
{chrome.footer.hercareer.label} <span aria-hidden="true"></span>
</a>
</li>
</ul>
</div>
</div>
<div className="flex min-h-12 flex-wrap items-center justify-between gap-x-8 gap-y-3 border-t border-line-dark py-3 font-mono text-[11px] tracking-[0.04em] text-grey-warm">
<span>
© {year} {site.name} · {chrome.footer.copyrightSuffix}
</span>
<span>
{legal.map((l, i) => (
<span key={l.href}>
{i > 0 && <span aria-hidden="true"> · </span>}
<TransitionLink
href={l.href}
label={l.label}
className="-my-3 inline-block py-3 transition-colors duration-300 hover:text-gold"
>
{l.label}
</TransitionLink>
</span>
))}
</span>
{/* The Kontakt sign-off already closes the page on phones no second claim there. */}
<span className="hidden font-sans text-[11px] font-medium uppercase tracking-[0.28em] text-gold lg:inline">
{chrome.footer.claim}
</span>
</div>
</Container>
</footer>
);
}
export default Footer;

View File

@@ -0,0 +1,169 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { flags, legal as legalDe, nav as navDe } from "@/lib/site";
import { routesByLang, type Lang } from "@/lib/i18n";
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
import { Wordmark } from "@/components/brand/Wordmark";
import { TransitionLink } from "@/components/motion/TransitionLink";
import { subscribe } from "@/components/motion/transition-store";
import { Container } from "./Container";
import { MENU_TOGGLE_ID, OverlayMenu } from "./OverlayMenu";
import { LangSwitch } from "./LangSwitch";
export interface HeaderProps {
/** The 28 px monogram layout.tsx passes `<BrandMark/>` (Server Component). */
mark?: ReactNode;
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
chrome?: Chrome;
/** Die drei Leistungslinks der jeweiligen Sprache. */
nav?: readonly { href: string; label: string }[];
/** Aktuelle Sprache steuert den Umschalter und das Ziel der Wortmarke. */
lang?: Lang;
/** Impressum/Datenschutz der jeweiligen Sprache für das Overlay-Menü. */
legal?: readonly { href: string; label: string }[];
}
const navText = "font-sans text-[0.75rem] uppercase tracking-[0.14em]";
const underline =
"relative after:absolute after:-bottom-1 after:left-0 after:h-px after:w-full after:origin-left after:scale-x-0 after:bg-accent after:transition-transform after:duration-300 hover:after:scale-x-100 focus-visible:after:scale-x-100 aria-[current=page]:after:scale-x-100";
function samePath(a: string, b: string) {
const n = (s: string) => (s.endsWith("/") ? s : `${s}/`);
return n(a) === n(b);
}
/**
* Fixed header, single row (the Masthead lives in each page hero).
* < 1024: Wordmark | „Menü“. 10241279: Wordmark | Sprachumschalter + „Menü“ (the
* three nav links need ~450 px and would collide with the wordmark).
* 12801439: 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}
/* 12801439 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;

View File

@@ -0,0 +1,70 @@
"use client";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { counterpart, langSwitch, otherLang, type Lang } from "@/lib/i18n";
import { TransitionLink } from "@/components/motion/TransitionLink";
/**
* Sprachumschalter im Kopf. Steht an der Stelle, an der vorher „Kontakt“ stand.
*
* Zeigt beide Sprachen: die aktive als stiller Zustand, die andere als Link auf das
* Gegenstück derselben Seite (`counterpart()`), nicht pauschal auf die Startseite.
*
* Zur Flagge: Eine Flagge steht für ein Land, nicht für eine Sprache deshalb steht
* neben ihr immer das Sprachkürzel, und der zugängliche Name nennt die Sprache
* ausgeschrieben. Wer die Flaggen weglassen will, entfernt hier zwei Zeilen.
*/
export function LangSwitch({ lang, className }: { lang: Lang; className?: string }) {
const pathname = usePathname() ?? "/";
const other = otherLang(lang);
const href = counterpart(pathname, other);
const base =
"inline-flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.14em] transition-colors duration-300";
return (
<div className={cn("flex items-center gap-2", className)}>
<span className={cn(base, "text-accent")} aria-current="true" title={langSwitch[lang].title}>
<Flag lang={lang} />
{langSwitch[lang].short}
</span>
<span aria-hidden="true" className="h-3 w-px bg-line" />
<TransitionLink
href={href}
label={langSwitch[other].label}
hrefLang={other}
aria-label={langSwitch[other].title}
className={cn(base, "-my-3 py-3 text-fg-muted hover:text-accent focus-visible:text-accent")}
>
<Flag lang={other} />
{langSwitch[other].short}
</TransitionLink>
</div>
);
}
/** 16 × 11 px, randlos, damit die Fahne neben 11-px-Versalien nicht dominiert. */
function Flag({ lang }: { lang: Lang }) {
const common = { width: 16, height: 11, viewBox: "0 0 16 11", "aria-hidden": true as const, className: "shrink-0" };
if (lang === "de") {
return (
<svg {...common}>
<rect width="16" height="11" fill="#0C0C0C" />
<rect y="3.667" width="16" height="3.667" fill="#DD0000" />
<rect y="7.334" width="16" height="3.666" fill="#FFCE00" />
</svg>
);
}
return (
<svg {...common}>
<rect width="16" height="11" fill="#012169" />
<path d="M0 0 16 11M16 0 0 11" stroke="#FFF" strokeWidth="2.2" />
<path d="M0 0 16 11M16 0 0 11" stroke="#C8102E" strokeWidth="1.1" />
<path d="M8 0v11M0 5.5h16" stroke="#FFF" strokeWidth="3.7" />
<path d="M8 0v11M0 5.5h16" stroke="#C8102E" strokeWidth="2.2" />
</svg>
);
}
export default LangSwitch;

View File

@@ -0,0 +1,56 @@
import { cn } from "@/lib/utils";
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
import { Rule } from "@/components/motion/Rule";
export interface MastheadProps {
/** Centre slot, e.g. „Sales Mentoring & Potential Development“. */
center: string;
className?: string;
/** Draw the hairlines on mount (hero load choreography) instead of static. */
draw?: boolean;
delay?: number;
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
chrome?: Chrome;
}
const caps = "font-sans text-[0.72rem] font-medium uppercase leading-none tracking-[0.28em] max-sm:text-[0.66rem] max-sm:tracking-[0.2em]";
/**
* Engraved stationery: three slots in ONE register (Jost 500 caps) between two
* gold hairlines role (accent, always the dominant line) | page name /
* centre (muted) | „Deutsch & Englisch“ (muted). The language slot used to be
* 12 px DM Mono and therefore optically louder than the 10.5 px tracked role.
*
* ≥ 1280 all three sit in one row; the centre shrinks to 0.62 rem so the
* 37-character home line („Sales Mentoring & Potential Development“) never
* wraps inside the 44 px row. 6401279 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;

View File

@@ -0,0 +1,235 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { flags, legal as legalDe, site } from "@/lib/site";
import type { Lang } from "@/lib/i18n";
import { LangSwitch } from "./LangSwitch";
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
import { gsap, EASE, MM, getLenis, refreshSoon } from "@/lib/gsap";
import { TransitionLink } from "@/components/motion/TransitionLink";
import { Container } from "./Container";
export interface OverlayMenuProps {
open: boolean;
onClose: () => void;
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
chrome?: Chrome;
/** Impressum/Datenschutz der jeweiligen Sprache. */
legal?: readonly { href: string; label: string }[];
/** Aktuelle Sprache für den Umschalter im Fuß des Menüs. */
lang?: Lang;
}
/** The header's „Menü“ button part of the dialog's operable set (see the trap below). */
export const MENU_TOGGLE_ID = "menue-toggle";
/**
* Fullscreen ivory sheet from the top (0.5 s power3.inOut): numbered ledger
* 0105, 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;

View File

@@ -0,0 +1,80 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Container } from "./Container";
import { SectionGutter } from "./SectionGutter";
export interface SectionProps {
id: string;
theme: "dark" | "light";
/** „01“ */
numeral?: string;
/** „06“ */
total?: string;
/** „Positionierung“ the running head under the numeral. */
label?: string;
/** The `< lg` mono counter „0X / 06“ above the content (off where a folio already carries it). */
mobileMeta?: boolean;
/**
* Vertical rhythm. `chapter` (default) opens a new chapter with its own
* eyebrow + Bodoni H2; `block` is a sub-block of the chapter above
* (Rahmen → Ablauf → FAQ) and halves the gap that precedes it, so the
* page reads as a spread instead of a component list with one fixed margin.
*/
space?: "chapter" | "block";
className?: string;
as?: "section" | "div" | "article";
children: ReactNode;
}
/** Gutter of the 12-column grid reuse in children that nest their own grid. */
export const GRID = "grid grid-cols-12 gap-x-[clamp(16px,2vw,32px)]";
/**
* `<section data-theme id>` with Container + 12-column grid. Children place
* themselves with `col-span-*` / `col-start-*` (content cols 39, marginalia
* 1012 on ≥ 1024). With a `numeral` the section opens with <SectionGutter/>:
* the sticky Bodoni numeral + running head in cols 12 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;

View File

@@ -0,0 +1,54 @@
import { cn } from "@/lib/utils";
import { Numeral } from "@/components/ui/Numeral";
export interface SectionGutterProps {
/** „01“ … „06“ */
numeral: string;
/** „06“ */
total?: string;
/** Running head under the numeral, e.g. „Positionierung“. */
label: string;
/**
* The `< lg` mono counter above the content. Section 01 suppresses it the
* hero folio directly above already carries „01 / 06 · Positionierung ↓“.
*/
mobileMeta?: boolean;
className?: string;
}
const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums text-fg-muted";
/**
* The gutter of a numbered home section (cols 12 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;

View File

@@ -0,0 +1,69 @@
import type { ReactNode } from "react";
import { SmoothScroll } from "@/components/providers/SmoothScroll";
import { BrandMark } from "@/components/brand/BrandMark";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
import { Curtain } from "@/components/motion/Curtain";
import { ScrollProgress } from "@/components/motion/ScrollProgress";
import { chromeByLang } from "@/content/chrome-by-lang";
import { fontVariables } from "@/lib/fonts";
import { HTML_LANG, type Lang } from "@/lib/i18n";
/**
* Das ganze Dokument von `<html>` bis `</body>` für eine Sprache.
*
* Beide Wurzel-Layouts (`app/(de)/layout.tsx`, `app/en/layout.tsx`) bestehen nur noch
* aus einem Aufruf dieser Komponente. Zwei Wurzel-Layouts sind nötig, weil `<html lang>`
* die Sprache des Dokuments angibt (WCAG 3.1.1) und im App Router nur das Wurzel-Layout
* das `<html>`-Element schreibt. Ein Wechsel zwischen den Sprachen ist deshalb ein
* vollständiger Seitenaufbau statt einer Client-Navigation für einen Sprachwechsel
* genau richtig, denn Schrift, Metadaten und Hülle wechseln ohnehin komplett.
*/
export function Shell({ lang, children }: { lang: Lang; children: ReactNode }) {
const { chrome, nav, legal } = chromeByLang[lang];
return (
<html lang={HTML_LANG[lang]} className={`${fontVariables} h-full`} suppressHydrationWarning>
<body className="grain min-h-full flex flex-col">
{/* Marks the document as JS-capable before first paint: initial motion states live
under `html.js` only. The failsafe matters as much as the class: everything the
class hides is only ever un-hidden by the module bundle, and a static export
re-hashes its chunks on every deploy a browser on cached HTML that 404s the
bundle would sit on a blank page forever. If nothing has booted after 2.5 s the
class drops and the page renders as plain HTML. <SmoothScroll/> clears the timer. */}
<script
dangerouslySetInnerHTML={{
__html:
"document.documentElement.classList.add('js');window.__unhide=setTimeout(function(){document.documentElement.classList.remove('js')},2500)",
}}
/>
<a
href="#inhalt"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:bg-ink focus:text-gold focus:px-4 focus:py-2 focus:font-mono focus:text-sm"
>
{chrome.skipLink}
</a>
{/* No <MotionConfig reducedMotion="user"> any more: every animation on the site is
built on gsap.matchMedia and gates itself on `MM.reduce`, so the provider bought
nothing and it was one of four reasons a second animation runtime shipped. */}
<SmoothScroll>
<Header
mark={<BrandMark className="h-7 w-auto" idPrefix="hdr" />}
chrome={chrome}
nav={nav}
legal={legal}
lang={lang}
/>
{children}
<Footer chrome={chrome} legal={legal} lang={lang} />
<Curtain chrome={chrome}>
<BrandMark className="h-12 w-auto" idPrefix="curtain" />
</Curtain>
<ScrollProgress />
</SmoothScroll>
</body>
</html>
);
}
export default Shell;