first publish
This commit is contained in:
14
src/components/layout/Container.tsx
Normal file
14
src/components/layout/Container.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ContainerProps {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Page frame: max-width 1680 px, page margin clamp(20px, 5vw, 80px). */
|
||||
export function Container({ className, children }: ContainerProps) {
|
||||
return <div className={cn("mx-auto w-full max-w-[105rem] px-[clamp(20px,5vw,80px)]", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export default Container;
|
||||
44
src/components/layout/Folio.tsx
Normal file
44
src/components/layout/Folio.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Rule, type RuleProps } from "@/components/motion/Rule";
|
||||
import { TransitionLink } from "@/components/motion/TransitionLink";
|
||||
|
||||
export interface FolioProps {
|
||||
/**
|
||||
* „01“ – only where the folio IS the page's counter (the home hero counts the
|
||||
* six sections). Service pages omit it: their sections carry their own
|
||||
* „01 / 06“ and a second, differently-denominated fraction 150 px away reads
|
||||
* as a bug, not as a folio.
|
||||
*/
|
||||
current?: string;
|
||||
/** „06“ */
|
||||
total?: string;
|
||||
/** „Positionierung“ → `#positionierung` */
|
||||
next?: { label: string; href: string };
|
||||
className?: string;
|
||||
/** Passed to the hairline – the hero draws it outward from the handoff x. */
|
||||
origin?: RuleProps["origin"];
|
||||
play?: RuleProps["play"];
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
/** Gold hairline + 44 px row: „01 / 06“ left, „Positionierung ↓“ right. */
|
||||
export function Folio({ current, total, next, className, origin = "left", play = "view", delay }: FolioProps) {
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<Rule variant="gold" origin={origin} play={play} delay={delay} />
|
||||
<div className="flex min-h-11 flex-wrap items-center justify-between gap-x-6 gap-y-1 py-1 font-mono text-xs tracking-[0.06em] tabular-nums text-fg-muted">
|
||||
<span>{current && total ? `${current} / ${total}` : null}</span>
|
||||
{next && (
|
||||
<TransitionLink
|
||||
href={next.href}
|
||||
className="-my-3.5 inline-block py-3.5 text-fg transition-colors duration-300 hover:text-accent focus-visible:text-accent"
|
||||
>
|
||||
{next.label} <span aria-hidden="true">↓</span>
|
||||
</TransitionLink>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Folio;
|
||||
111
src/components/layout/Footer.tsx
Normal file
111
src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { legal as legalDe, site } from "@/lib/site";
|
||||
import { routesByLang, type Lang } from "@/lib/i18n";
|
||||
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
|
||||
import { BrandMark } from "@/components/brand/BrandMark";
|
||||
import { Wordmark } from "@/components/brand/Wordmark";
|
||||
import { Rule } from "@/components/motion/Rule";
|
||||
import { TransitionLink } from "@/components/motion/TransitionLink";
|
||||
import { Container } from "./Container";
|
||||
import { GRID } from "./Section";
|
||||
|
||||
const heading = "mb-5 font-mono text-[11px] uppercase tracking-[0.12em] text-grey-warm";
|
||||
/* -my-2.5/py-2.5 keeps the visual rhythm and lifts the tap target to 40 px. */
|
||||
const link =
|
||||
"-my-2.5 inline-block py-2.5 font-sans text-sm text-fg transition-colors duration-300 hover:text-gold focus-visible:text-gold";
|
||||
|
||||
/**
|
||||
* Server Component, identical on every page. Paints its own ink ground
|
||||
* (`data-theme="dark"`, globals.css), gold hairline on top, wordmark +
|
||||
* two columns, bottom row with copyright · legal · claim (desktop).
|
||||
*/
|
||||
export interface FooterProps {
|
||||
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
|
||||
chrome?: Chrome;
|
||||
/** Impressum/Datenschutz der jeweiligen Sprache. */
|
||||
legal?: readonly { href: string; label: string }[];
|
||||
/** Aktuelle Sprache – bestimmt das Ziel der Wortmarke. */
|
||||
lang?: Lang;
|
||||
}
|
||||
|
||||
export function Footer({ chrome = chromeDe, legal = legalDe, lang = "de" }: FooterProps = {}) {
|
||||
const year = new Date().getFullYear();
|
||||
return (
|
||||
/* The legal links are the last thing on a phone – keep them clear of the home indicator
|
||||
and the browser's bottom bar (the row's own padding is eaten by the link's -my-3). */
|
||||
<footer data-theme="dark" className="relative pb-[max(2.5rem,env(safe-area-inset-bottom))] text-fg lg:pb-0">
|
||||
<Rule variant="gold" />
|
||||
<Container>
|
||||
<div className={`${GRID} gap-y-12 py-16 lg:py-24`}>
|
||||
<div className="col-span-12 lg:col-span-7 lg:pr-8">
|
||||
<Wordmark size="footer" href={routesByLang[lang].home} mark={<BrandMark className="h-10 w-auto" idPrefix="ftr" />} chrome={chrome} />
|
||||
</div>
|
||||
|
||||
<nav aria-label={chrome.footer.inhaltHeading} className="col-span-6 lg:col-span-3">
|
||||
<h2 className={heading}>{chrome.footer.inhaltHeading}</h2>
|
||||
<ul className="flex flex-col gap-5">
|
||||
{chrome.footer.inhalt.map((item) => (
|
||||
<li key={item.href}>
|
||||
<TransitionLink href={item.href} label={item.label} className={link}>
|
||||
{item.label}
|
||||
</TransitionLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="col-span-6 lg:col-span-2">
|
||||
<h2 className={heading}>{chrome.footer.kontaktHeading}</h2>
|
||||
<ul className="flex flex-col gap-5">
|
||||
<li>
|
||||
<a href={`mailto:${site.email}`} className={link}>
|
||||
{site.email}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={site.phoneHref} className={`${link} font-mono tabular-nums`}>
|
||||
{site.phoneDisplay}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={site.linkedin} target="_blank" rel="noopener noreferrer" className={link}>
|
||||
{chrome.footer.linkedin} <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={chrome.footer.hercareer.href} target="_blank" rel="noopener noreferrer" className={link}>
|
||||
{chrome.footer.hercareer.label} <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-12 flex-wrap items-center justify-between gap-x-8 gap-y-3 border-t border-line-dark py-3 font-mono text-[11px] tracking-[0.04em] text-grey-warm">
|
||||
<span>
|
||||
© {year} {site.name} · {chrome.footer.copyrightSuffix}
|
||||
</span>
|
||||
<span>
|
||||
{legal.map((l, i) => (
|
||||
<span key={l.href}>
|
||||
{i > 0 && <span aria-hidden="true"> · </span>}
|
||||
<TransitionLink
|
||||
href={l.href}
|
||||
label={l.label}
|
||||
className="-my-3 inline-block py-3 transition-colors duration-300 hover:text-gold"
|
||||
>
|
||||
{l.label}
|
||||
</TransitionLink>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
{/* The Kontakt sign-off already closes the page on phones – no second claim there. */}
|
||||
<span className="hidden font-sans text-[11px] font-medium uppercase tracking-[0.28em] text-gold lg:inline">
|
||||
{chrome.footer.claim}
|
||||
</span>
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
169
src/components/layout/Header.tsx
Normal file
169
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { flags, legal as legalDe, nav as navDe } from "@/lib/site";
|
||||
import { routesByLang, type Lang } from "@/lib/i18n";
|
||||
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
|
||||
import { Wordmark } from "@/components/brand/Wordmark";
|
||||
import { TransitionLink } from "@/components/motion/TransitionLink";
|
||||
import { subscribe } from "@/components/motion/transition-store";
|
||||
import { Container } from "./Container";
|
||||
import { MENU_TOGGLE_ID, OverlayMenu } from "./OverlayMenu";
|
||||
import { LangSwitch } from "./LangSwitch";
|
||||
|
||||
export interface HeaderProps {
|
||||
/** The 28 px monogram – layout.tsx passes `<BrandMark/>` (Server Component). */
|
||||
mark?: ReactNode;
|
||||
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
|
||||
chrome?: Chrome;
|
||||
/** Die drei Leistungslinks der jeweiligen Sprache. */
|
||||
nav?: readonly { href: string; label: string }[];
|
||||
/** Aktuelle Sprache – steuert den Umschalter und das Ziel der Wortmarke. */
|
||||
lang?: Lang;
|
||||
/** Impressum/Datenschutz der jeweiligen Sprache – für das Overlay-Menü. */
|
||||
legal?: readonly { href: string; label: string }[];
|
||||
}
|
||||
|
||||
const navText = "font-sans text-[0.75rem] uppercase tracking-[0.14em]";
|
||||
const underline =
|
||||
"relative after:absolute after:-bottom-1 after:left-0 after:h-px after:w-full after:origin-left after:scale-x-0 after:bg-accent after:transition-transform after:duration-300 hover:after:scale-x-100 focus-visible:after:scale-x-100 aria-[current=page]:after:scale-x-100";
|
||||
|
||||
function samePath(a: string, b: string) {
|
||||
const n = (s: string) => (s.endsWith("/") ? s : `${s}/`);
|
||||
return n(a) === n(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed header, single row (the Masthead lives in each page hero).
|
||||
* < 1024: Wordmark | „Menü“. 1024–1279: Wordmark | Sprachumschalter + „Menü“ (the
|
||||
* three nav links need ~450 px and would collide with the wordmark).
|
||||
* 1280–1439: Wordmark | 3 nav links (tighter caps) | one CTA – the „Kontakt“
|
||||
* text link steps aside so the wordmark keeps its air and the CTA can carry
|
||||
* the one label the site uses for this action (spec §5.1).
|
||||
* ≥ 1440: Wordmark | 3 nav links | „Kontakt“ + outline CTA.
|
||||
* Ink chrome (the page below breathes, the bar does not); after 80 px:
|
||||
* 92 % ground + blur(8px) + hairline. While it is still transparent it reads
|
||||
* on the page's first block, so on an ivory-first page (Impressum) globals.css
|
||||
* flips it to ink type via `[data-scrolled]`. Writes `--header-h` on <html>.
|
||||
*/
|
||||
export function Header({ mark, chrome = chromeDe, nav = navDe, legal = legalDe, lang = "de" }: HeaderProps) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const pathname = usePathname();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
const update = () => {
|
||||
raf = 0;
|
||||
setScrolled(window.scrollY > 80);
|
||||
};
|
||||
const onScroll = () => {
|
||||
if (!raf) raf = requestAnimationFrame(update);
|
||||
};
|
||||
update();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const write = () => document.documentElement.style.setProperty("--header-h", `${el.offsetHeight}px`);
|
||||
write();
|
||||
const ro = new ResizeObserver(write);
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
document.documentElement.style.removeProperty("--header-h");
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Expose the open state on <html> (scroll-progress hides itself while the menu is open).
|
||||
useEffect(() => {
|
||||
document.documentElement.toggleAttribute("data-menu-open", open);
|
||||
return () => document.documentElement.removeAttribute("data-menu-open");
|
||||
}, [open]);
|
||||
|
||||
// Close the menu when a page transition starts (route changes are handled by OverlayMenu).
|
||||
useEffect(() => subscribe((s) => s.phase === "cover" && setOpen(false)), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
ref={ref}
|
||||
data-theme={open ? "light" : "dark"}
|
||||
data-scrolled={scrolled ? "" : undefined}
|
||||
className={cn(
|
||||
"fixed inset-x-0 top-0 z-50 border-b text-fg transition-[background-color,border-color] duration-300",
|
||||
scrolled && !open
|
||||
? "border-line bg-[color-mix(in_srgb,var(--bg)_92%,transparent)] backdrop-blur-[8px]"
|
||||
: "border-transparent bg-transparent",
|
||||
)}
|
||||
>
|
||||
|
||||
<Container>
|
||||
<div className="grid h-[60px] grid-cols-12 items-center gap-x-[clamp(16px,2vw,32px)] lg:h-[72px]">
|
||||
<div data-menu-inert="" className="col-span-6 flex min-w-0 items-center xl:col-span-3">
|
||||
<Wordmark size="nav" href={routesByLang[lang].home} mark={mark} chrome={chrome} />
|
||||
</div>
|
||||
|
||||
<nav data-menu-inert="" aria-label={chrome.header.navAriaLabel} className="hidden items-center justify-center gap-6 xl:col-span-5 xl:flex xl:gap-4 wide:gap-6 2xl:gap-8">
|
||||
{nav.map((item) => (
|
||||
<TransitionLink
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
label={item.label}
|
||||
aria-current={samePath(pathname, item.href) ? "page" : undefined}
|
||||
/* 1280–1439 the three labels need ~570 px in a 465 px cell – tighter caps buy it back. */
|
||||
className={cn(navText, underline, "whitespace-nowrap xl:text-[0.6875rem] xl:tracking-[0.08em] wide:text-[0.75rem] wide:tracking-[0.14em]")}
|
||||
>
|
||||
{item.label}
|
||||
</TransitionLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="col-span-6 flex items-center justify-end gap-6 xl:col-span-4 2xl:gap-8">
|
||||
{/* Steht an der Stelle, an der bis zum 06.09.2026 „Kontakt“ stand. */}
|
||||
{flags.showLangSwitch && <LangSwitch lang={lang} className="hidden lg:flex" />}
|
||||
<a
|
||||
data-menu-inert=""
|
||||
href={chrome.header.cta.href}
|
||||
className="hidden whitespace-nowrap border border-gold bg-[image:var(--gold-sheen)] bg-[length:250%_100%] bg-[position:0%_50%] px-[1.4em] py-[1.05em] font-sans text-[0.75rem] font-medium uppercase leading-none tracking-[0.16em] text-ink transition-[background-position] duration-[600ms] ease-[var(--ease-out-quart)] hover:bg-[position:100%_50%] focus-visible:bg-[position:100%_50%] xl:inline-block 2xl:px-[1.6em] 2xl:tracking-[0.2em]"
|
||||
>
|
||||
{/* One label for this action everywhere – the hero button says the same (spec §5.1). */}
|
||||
{chrome.header.cta.label}
|
||||
</a>
|
||||
{/* WCAG 2.5.3: the visible label stays „Menü“ in both states, so the accessible
|
||||
name has to contain it – „Schließen“ alone made „Klick Menü“ impossible for
|
||||
a speech-input user once the sheet was open. The STATE is on `aria-expanded`,
|
||||
not on the name. The id makes the toggle part of the dialog's focus trap. */}
|
||||
<button
|
||||
id={MENU_TOGGLE_ID}
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls="menue"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label={open ? chrome.header.menu.close : chrome.header.menu.openLabel}
|
||||
className="-mx-2 -my-3 flex items-center gap-3 px-2 py-3 font-sans text-[0.8125rem] font-medium uppercase tracking-[0.16em] text-fg xl:hidden"
|
||||
>
|
||||
<span aria-hidden="true">{chrome.header.menu.open}</span>
|
||||
<span aria-hidden="true" className="font-mono text-base leading-none">
|
||||
{open ? "×" : "+"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</header>
|
||||
<OverlayMenu open={open} onClose={() => setOpen(false)} chrome={chrome} legal={legal} lang={lang} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
70
src/components/layout/LangSwitch.tsx
Normal file
70
src/components/layout/LangSwitch.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { counterpart, langSwitch, otherLang, type Lang } from "@/lib/i18n";
|
||||
import { TransitionLink } from "@/components/motion/TransitionLink";
|
||||
|
||||
/**
|
||||
* Sprachumschalter im Kopf. Steht an der Stelle, an der vorher „Kontakt“ stand.
|
||||
*
|
||||
* Zeigt beide Sprachen: die aktive als stiller Zustand, die andere als Link auf das
|
||||
* Gegenstück derselben Seite (`counterpart()`), nicht pauschal auf die Startseite.
|
||||
*
|
||||
* Zur Flagge: Eine Flagge steht für ein Land, nicht für eine Sprache – deshalb steht
|
||||
* neben ihr immer das Sprachkürzel, und der zugängliche Name nennt die Sprache
|
||||
* ausgeschrieben. Wer die Flaggen weglassen will, entfernt hier zwei Zeilen.
|
||||
*/
|
||||
export function LangSwitch({ lang, className }: { lang: Lang; className?: string }) {
|
||||
const pathname = usePathname() ?? "/";
|
||||
const other = otherLang(lang);
|
||||
const href = counterpart(pathname, other);
|
||||
|
||||
const base =
|
||||
"inline-flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.14em] transition-colors duration-300";
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<span className={cn(base, "text-accent")} aria-current="true" title={langSwitch[lang].title}>
|
||||
<Flag lang={lang} />
|
||||
{langSwitch[lang].short}
|
||||
</span>
|
||||
<span aria-hidden="true" className="h-3 w-px bg-line" />
|
||||
<TransitionLink
|
||||
href={href}
|
||||
label={langSwitch[other].label}
|
||||
hrefLang={other}
|
||||
aria-label={langSwitch[other].title}
|
||||
className={cn(base, "-my-3 py-3 text-fg-muted hover:text-accent focus-visible:text-accent")}
|
||||
>
|
||||
<Flag lang={other} />
|
||||
{langSwitch[other].short}
|
||||
</TransitionLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 16 × 11 px, randlos, damit die Fahne neben 11-px-Versalien nicht dominiert. */
|
||||
function Flag({ lang }: { lang: Lang }) {
|
||||
const common = { width: 16, height: 11, viewBox: "0 0 16 11", "aria-hidden": true as const, className: "shrink-0" };
|
||||
if (lang === "de") {
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect width="16" height="11" fill="#0C0C0C" />
|
||||
<rect y="3.667" width="16" height="3.667" fill="#DD0000" />
|
||||
<rect y="7.334" width="16" height="3.666" fill="#FFCE00" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg {...common}>
|
||||
<rect width="16" height="11" fill="#012169" />
|
||||
<path d="M0 0 16 11M16 0 0 11" stroke="#FFF" strokeWidth="2.2" />
|
||||
<path d="M0 0 16 11M16 0 0 11" stroke="#C8102E" strokeWidth="1.1" />
|
||||
<path d="M8 0v11M0 5.5h16" stroke="#FFF" strokeWidth="3.7" />
|
||||
<path d="M8 0v11M0 5.5h16" stroke="#C8102E" strokeWidth="2.2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default LangSwitch;
|
||||
56
src/components/layout/Masthead.tsx
Normal file
56
src/components/layout/Masthead.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
|
||||
import { Rule } from "@/components/motion/Rule";
|
||||
|
||||
export interface MastheadProps {
|
||||
/** Centre slot, e.g. „Sales Mentoring & Potential Development“. */
|
||||
center: string;
|
||||
className?: string;
|
||||
/** Draw the hairlines on mount (hero load choreography) instead of static. */
|
||||
draw?: boolean;
|
||||
delay?: number;
|
||||
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
|
||||
chrome?: Chrome;
|
||||
}
|
||||
|
||||
const caps = "font-sans text-[0.72rem] font-medium uppercase leading-none tracking-[0.28em] max-sm:text-[0.66rem] max-sm:tracking-[0.2em]";
|
||||
|
||||
/**
|
||||
* Engraved stationery: three slots in ONE register (Jost 500 caps) between two
|
||||
* gold hairlines – role (accent, always the dominant line) | page name /
|
||||
* centre (muted) | „Deutsch & Englisch“ (muted). The language slot used to be
|
||||
* 12 px DM Mono and therefore optically louder than the 10.5 px tracked role.
|
||||
*
|
||||
* ≥ 1280 all three sit in one row; the centre shrinks to 0.62 rem so the
|
||||
* 37-character home line („Sales Mentoring & Potential Development“) never
|
||||
* wraps inside the 44 px row. 640–1279 the centre drops to its own second
|
||||
* row; below 640 the language slot goes (it is the least load-bearing of the
|
||||
* three) so the page name keeps the orientation the masthead is there for.
|
||||
*/
|
||||
export function Masthead({ center, className, draw = false, delay = 0, chrome = chromeDe }: MastheadProps) {
|
||||
const rule = (extra: number) =>
|
||||
draw ? <Rule variant="gold" play="load" delay={delay + extra} /> : <div aria-hidden="true" className="rule-gold" />;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
{rule(0)}
|
||||
<div className="grid min-h-11 grid-cols-1 items-center gap-x-4 gap-y-2 py-3 sm:grid-cols-[1fr_auto] xl:grid-cols-3 xl:gap-y-0 xl:py-0">
|
||||
<span className={cn(caps, "text-accent xl:order-1")}>{chrome.masthead.role}</span>
|
||||
<span className={cn(caps, "hidden text-fg-muted sm:block sm:justify-self-end xl:order-3")}>
|
||||
{chrome.masthead.languages}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
caps,
|
||||
"leading-[1.5] text-fg-muted sm:col-span-2 xl:order-2 xl:col-span-1 xl:whitespace-nowrap xl:text-center xl:leading-none xl:text-[0.62rem] xl:tracking-[0.18em] wide:text-[0.72rem] wide:tracking-[0.28em]",
|
||||
)}
|
||||
>
|
||||
{center}
|
||||
</span>
|
||||
</div>
|
||||
{rule(0.15)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Masthead;
|
||||
235
src/components/layout/OverlayMenu.tsx
Normal file
235
src/components/layout/OverlayMenu.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { flags, legal as legalDe, site } from "@/lib/site";
|
||||
import type { Lang } from "@/lib/i18n";
|
||||
import { LangSwitch } from "./LangSwitch";
|
||||
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
|
||||
import { gsap, EASE, MM, getLenis, refreshSoon } from "@/lib/gsap";
|
||||
import { TransitionLink } from "@/components/motion/TransitionLink";
|
||||
import { Container } from "./Container";
|
||||
|
||||
export interface OverlayMenuProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
|
||||
chrome?: Chrome;
|
||||
/** Impressum/Datenschutz der jeweiligen Sprache. */
|
||||
legal?: readonly { href: string; label: string }[];
|
||||
/** Aktuelle Sprache – für den Umschalter im Fuß des Menüs. */
|
||||
lang?: Lang;
|
||||
}
|
||||
|
||||
/** The header's „Menü“ button – part of the dialog's operable set (see the trap below). */
|
||||
export const MENU_TOGGLE_ID = "menue-toggle";
|
||||
|
||||
/**
|
||||
* Fullscreen ivory sheet from the top (0.5 s power3.inOut): numbered ledger
|
||||
* 01–05, contact row at the bottom. `data-lenis-prevent`, `lenis.stop()`,
|
||||
* focus trap, Escape closes, closes on route change, rest of the page `inert`.
|
||||
*
|
||||
* The trap spans the panel AND the header's toggle: the toggle stays visible on
|
||||
* top of the sheet (header z-50 > panel z-40), so it has to stay reachable by Tab
|
||||
* as well as by mouse. Every other header control is `inert` while the sheet is
|
||||
* open, so the mouse-operable and the keyboard-operable set are the same one.
|
||||
*/
|
||||
export function OverlayMenu({ open, onClose, chrome = chromeDe, legal = legalDe, lang = "de" }: OverlayMenuProps) {
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const pathname = usePathname();
|
||||
const onCloseRef = useRef(onClose);
|
||||
/* The panel has to outlive `open` for the length of its exit tween. State adjusted
|
||||
during render (the sanctioned pattern), not in an effect – an effect would cost a
|
||||
cascading render on every open. */
|
||||
const [exiting, setExiting] = useState(false);
|
||||
const [prevOpen, setPrevOpen] = useState(open);
|
||||
if (prevOpen !== open) {
|
||||
setPrevOpen(open);
|
||||
setExiting(!open);
|
||||
}
|
||||
const mounted = open || exiting;
|
||||
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
});
|
||||
|
||||
// Close on route change.
|
||||
const lastPath = useRef(pathname);
|
||||
useEffect(() => {
|
||||
if (lastPath.current !== pathname) {
|
||||
lastPath.current = pathname;
|
||||
onCloseRef.current();
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
/* Enter / exit on GSAP (the site's only animation runtime). The panel is a single
|
||||
element, so an <AnimatePresence> is not load-bearing – `exiting` keeps it in the
|
||||
DOM until the exit tween has run. */
|
||||
useLayoutEffect(() => {
|
||||
const el = panel.current;
|
||||
if (!mounted || !el) return;
|
||||
const rows = gsap.utils.toArray<HTMLElement>("[data-menu-row]", el);
|
||||
const foot = el.querySelector<HTMLElement>("[data-menu-foot]");
|
||||
const mm = gsap.matchMedia();
|
||||
|
||||
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
|
||||
const reduce = Boolean(ctx.conditions?.reduce);
|
||||
if (open) {
|
||||
gsap.set(el, { yPercent: reduce ? 0 : -100 });
|
||||
gsap.set(rows, { yPercent: reduce ? 0 : 100 });
|
||||
gsap.set(foot, { opacity: reduce ? 1 : 0 });
|
||||
if (reduce) return;
|
||||
const tl = gsap.timeline();
|
||||
tl.to(el, { yPercent: 0, duration: 0.5, ease: EASE.inOut }, 0)
|
||||
.to(rows, { yPercent: 0, duration: 0.6, ease: EASE.out, stagger: 0.06 }, 0.25)
|
||||
.to(foot, { opacity: 1, duration: 0.6 }, 0.6);
|
||||
return;
|
||||
}
|
||||
/* Reduced motion still goes through the tween, at zero duration: `onComplete`
|
||||
must fire off the current render, never synchronously inside this effect. */
|
||||
gsap.to(el, {
|
||||
yPercent: -100,
|
||||
duration: reduce ? 0 : 0.4,
|
||||
ease: EASE.inOut,
|
||||
onComplete: () => setExiting(false),
|
||||
});
|
||||
});
|
||||
|
||||
return () => mm.revert();
|
||||
}, [open, mounted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const lenis = getLenis();
|
||||
lenis?.stop();
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
const toggle = document.getElementById(MENU_TOGGLE_ID) as HTMLElement | null;
|
||||
/* Everything the reader can still SEE but must no longer reach: the rest of the
|
||||
page plus the header's own links, which float above the ivory sheet. */
|
||||
const others = Array.from(
|
||||
document.querySelectorAll<HTMLElement>("main, footer, header [data-menu-inert]"),
|
||||
);
|
||||
others.forEach((el) => {
|
||||
el.inert = true;
|
||||
});
|
||||
|
||||
const inPanel = () =>
|
||||
Array.from(panel.current?.querySelectorAll<HTMLElement>("a[href], button:not([disabled])") ?? []);
|
||||
/* The toggle first: it sits above the sheet, so Shift+Tab from the first ledger row
|
||||
lands on it and Tab from the last row cycles back to it. */
|
||||
const focusables = () => (toggle ? [toggle, ...inPanel()] : inPanel());
|
||||
const t = window.setTimeout(() => inPanel()[0]?.focus(), 50);
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
const items = focusables();
|
||||
if (!items.length) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const inside = active ? items.includes(active) : false;
|
||||
if (e.shiftKey && (active === first || !inside)) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && (active === last || !inside)) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(t);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
others.forEach((el) => {
|
||||
el.inert = false;
|
||||
});
|
||||
lenis?.start();
|
||||
previous?.focus?.();
|
||||
refreshSoon();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panel}
|
||||
id="menue"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={chrome.header.menu.ariaLabel}
|
||||
data-theme="light"
|
||||
data-lenis-prevent=""
|
||||
className="fixed inset-0 z-40 flex flex-col overflow-y-auto overscroll-contain bg-ivory text-ink"
|
||||
>
|
||||
<Container className="flex min-h-full flex-1 flex-col pb-10 pt-[calc(var(--header-h,60px)+24px)]">
|
||||
<ol className="border-t border-line-light">
|
||||
{chrome.menu.items.map((item) => (
|
||||
<li key={item.href} className="overflow-hidden border-b border-line-light">
|
||||
<div data-menu-row="">
|
||||
<TransitionLink
|
||||
href={item.href}
|
||||
label={item.label}
|
||||
onClick={() => onCloseRef.current()}
|
||||
className="group flex items-baseline gap-5 py-5 lg:gap-8 lg:py-7"
|
||||
>
|
||||
<span className="font-mono text-xs tracking-[0.06em] tabular-nums text-gold-deep">{item.numeral}</span>
|
||||
<span className="font-display text-[clamp(2rem,1.4rem+3vw,4rem)] leading-none text-ink transition-[font-style] group-hover:italic">
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="ml-auto text-gold-deep transition-transform duration-300 group-hover:translate-x-2"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
</TransitionLink>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<div
|
||||
data-menu-foot=""
|
||||
className="mt-auto flex flex-col gap-4 pt-12 lg:flex-row lg:items-end lg:justify-between"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<a href={site.phoneHref} className="font-display text-[1.75rem] leading-none text-ink">
|
||||
{site.phoneDisplay}
|
||||
</a>
|
||||
<a href={`mailto:${site.email}`} className="font-sans text-sm text-grey-deep">
|
||||
{site.email}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 font-mono text-xs tracking-[0.06em] text-grey-deep">
|
||||
{/* Unter 1024 px versteckt der Kopf den Umschalter (dort ist kein Platz neben
|
||||
der Wortmarke). Ohne diese Zeile gäbe es auf dem Telefon gar keinen Weg in
|
||||
die andere Sprache – er steht deshalb genau dort, wo er im Kopf fehlt. */}
|
||||
{flags.showLangSwitch && <LangSwitch lang={lang} className="lg:hidden" />}
|
||||
<a href={site.linkedin} target="_blank" rel="noopener noreferrer" className="hover:text-gold-deep">
|
||||
{chrome.menu.linkedin} <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
<span>
|
||||
{legal.map((l, i) => (
|
||||
<span key={l.href}>
|
||||
{i > 0 && <span aria-hidden="true"> · </span>}
|
||||
<TransitionLink href={l.href} label={l.label} onClick={() => onCloseRef.current()} className="hover:text-gold-deep">
|
||||
{l.label}
|
||||
</TransitionLink>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OverlayMenu;
|
||||
80
src/components/layout/Section.tsx
Normal file
80
src/components/layout/Section.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Container } from "./Container";
|
||||
import { SectionGutter } from "./SectionGutter";
|
||||
|
||||
export interface SectionProps {
|
||||
id: string;
|
||||
theme: "dark" | "light";
|
||||
/** „01“ */
|
||||
numeral?: string;
|
||||
/** „06“ */
|
||||
total?: string;
|
||||
/** „Positionierung“ – the running head under the numeral. */
|
||||
label?: string;
|
||||
/** The `< lg` mono counter „0X / 06“ above the content (off where a folio already carries it). */
|
||||
mobileMeta?: boolean;
|
||||
/**
|
||||
* Vertical rhythm. `chapter` (default) opens a new chapter with its own
|
||||
* eyebrow + Bodoni H2; `block` is a sub-block of the chapter above
|
||||
* (Rahmen → Ablauf → FAQ) and halves the gap that precedes it, so the
|
||||
* page reads as a spread instead of a component list with one fixed margin.
|
||||
*/
|
||||
space?: "chapter" | "block";
|
||||
className?: string;
|
||||
as?: "section" | "div" | "article";
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** Gutter of the 12-column grid – reuse in children that nest their own grid. */
|
||||
export const GRID = "grid grid-cols-12 gap-x-[clamp(16px,2vw,32px)]";
|
||||
|
||||
/**
|
||||
* `<section data-theme id>` with Container + 12-column grid. Children place
|
||||
* themselves with `col-span-*` / `col-start-*` (content cols 3–9, marginalia
|
||||
* 10–12 on ≥ 1024). With a `numeral` the section opens with <SectionGutter/>:
|
||||
* the sticky Bodoni numeral + running head in cols 1–2 from 1024, the mono
|
||||
* counter „0X / 06“ above the content below it – identical on every route.
|
||||
*
|
||||
* Top-level sections paint their own opaque ground (globals.css:
|
||||
* `main > [data-theme]`); the seam between two grounds is the 1 px gold
|
||||
* hairline alone (spec §2.3). Nested sections stay transparent and inherit
|
||||
* the ground of their themed wrapper.
|
||||
*/
|
||||
export function Section({
|
||||
id,
|
||||
theme,
|
||||
numeral,
|
||||
total,
|
||||
label,
|
||||
mobileMeta = true,
|
||||
space = "chapter",
|
||||
className,
|
||||
as = "section",
|
||||
children,
|
||||
}: SectionProps) {
|
||||
const Tag = as;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
id={id}
|
||||
data-theme={theme}
|
||||
className={cn(
|
||||
"relative bg-transparent pb-[clamp(4rem,10vw,9rem)] text-fg",
|
||||
space === "block" ? "pt-[clamp(2rem,5vw,4.5rem)]" : "pt-[clamp(4rem,10vw,9rem)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Container className="relative">
|
||||
<div className={GRID}>
|
||||
{numeral && (
|
||||
<SectionGutter numeral={numeral} total={total} label={label ?? ""} mobileMeta={mobileMeta} />
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</Container>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
export default Section;
|
||||
54
src/components/layout/SectionGutter.tsx
Normal file
54
src/components/layout/SectionGutter.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Numeral } from "@/components/ui/Numeral";
|
||||
|
||||
export interface SectionGutterProps {
|
||||
/** „01“ … „06“ */
|
||||
numeral: string;
|
||||
/** „06“ */
|
||||
total?: string;
|
||||
/** Running head under the numeral, e.g. „Positionierung“. */
|
||||
label: string;
|
||||
/**
|
||||
* The `< lg` mono counter above the content. Section 01 suppresses it – the
|
||||
* hero folio directly above already carries „01 / 06 · Positionierung ↓“.
|
||||
*/
|
||||
mobileMeta?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums text-fg-muted";
|
||||
|
||||
/**
|
||||
* The gutter of a numbered home section (cols 1–2 from 1024): the sticky Bodoni
|
||||
* <Numeral/> and the small mono running head in ONE sticky wrapper, so the pair
|
||||
* always travels together (they used to have two different sticky anchors and
|
||||
* drifted ~350 px apart). Anchor is the spec's `calc(50vh − .45em)`.
|
||||
*
|
||||
* The counter („04 / 06“) stays on the mobile line and in the hero folio – next
|
||||
* to a 147 px Bodoni „04“ it would only repeat the numeral, so the desktop line
|
||||
* carries the label alone.
|
||||
*
|
||||
* Rendered by <Section/> itself whenever a `numeral` is passed – there is exactly
|
||||
* ONE chapter gutter on the site now. It used to coexist with an inline copy inside
|
||||
* <Section/> (a 48 px Bodoni numeral on mobile, a mono running head sticky under the
|
||||
* header on desktop), so the home page and the three service pages showed the same
|
||||
* furniture in two different treatments with two different sticky anchors.
|
||||
*/
|
||||
export function SectionGutter({ numeral, total = "06", label, mobileMeta = true, className }: SectionGutterProps) {
|
||||
return (
|
||||
<>
|
||||
{mobileMeta && <p className={cn("col-span-12 mb-8 lg:hidden", MONO)}>{`${numeral} / ${total}`}</p>}
|
||||
<div className={cn("hidden lg:col-span-2 lg:col-start-1 lg:row-span-2 lg:row-start-1 lg:block", className)}>
|
||||
<div className="lg:sticky lg:top-[calc(50vh_-_0.45em)]">
|
||||
{/* Decorative – the accessible section number is the mono line above. */}
|
||||
<Numeral value={numeral} sticky={false} />
|
||||
<span aria-hidden="true" className={cn("mt-5 block whitespace-nowrap", MONO)}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionGutter;
|
||||
69
src/components/layout/Shell.tsx
Normal file
69
src/components/layout/Shell.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user