"use client";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { flags } from "@/lib/site";
import { gsap, MM } from "@/lib/gsap";
import { splitPlaceholders } from "@/lib/legal";
import { hero as heroDe, type HomeContent } from "@/content/home";
import { Section } from "@/components/layout/Section";
import { Folio } from "@/components/layout/Folio";
import { Lines } from "@/components/motion/Lines";
import { Reveal } from "@/components/motion/Reveal";
import { Door } from "@/components/motion/Door";
import { Magnet } from "@/components/motion/Magnet";
import { DrawMark } from "@/components/brand/DrawMark";
import { Button } from "@/components/ui/Button";
import { PlaceholderText } from "@/components/ui/Facts";
export interface HeroProps {
/** `` – Server Component, rendered by page.tsx. */
mark: ReactNode;
/** `getPhotos().hero` – `null` renders the documented variant B (monogram is the image). */
photo: string | null;
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
content?: HomeContent["hero"];
}
/* Load choreography (spec §6.1), seconds from mount. Masthead hairlines 0 / 0.15 are
Masthead's own; the folio hairline waits for the neck stroke (draw 0.7 + handoff 1.2–1.4). */
const T = { lines: 0.3, door: 0.5, draw: 0.7, cta: 1.2, folio: 2.0 } as const;
/** Fallback length of the handoff hairline until it is measured (px). */
const HANDOFF = 40;
const MONO_11 = "font-mono text-[11px] tracking-[0.06em]";
/**
* Handoff x for ``, measured from the folio's left edge.
* DrawMark writes `--handoff-x` on its wrapper (fallback WolfMark); a client
* SVG with `path#handoff` is measured at the path's end point.
*/
function measureHandoffX(markWrap: HTMLElement, folioEl: HTMLElement): number | null {
const folioLeft = folioEl.getBoundingClientRect().left;
const drawWrap = markWrap.querySelector("[style*='--handoff-x']");
if (drawWrap) {
const v = parseFloat(drawWrap.style.getPropertyValue("--handoff-x"));
if (!Number.isNaN(v)) return drawWrap.getBoundingClientRect().left + v - folioLeft;
}
const path = markWrap.querySelector("path.wolfmark-handoff");
const ctm = path?.ownerSVGElement?.getScreenCTM();
if (path && ctm) {
const end = path.getPointAtLength(path.getTotalLength());
const sp = new DOMPoint(end.x, end.y).matrixTransform(ctm);
return sp.x - folioLeft;
}
return null;
}
/**
* Samples the mark's outline in SVG user space – once. The geometry never
* changes, only the CTM does, so a resize re-projects these points instead of
* walking `getPointAtLength` again (~500 forced geometry calls per event).
*/
function sampleMarkPoints(markWrap: HTMLElement): { x: number; y: number }[] {
const svg = markWrap.querySelector("svg");
if (!svg) return [];
const pts: { x: number; y: number }[] = [];
svg.querySelectorAll("path").forEach((path) => {
let len = 0;
try {
len = path.getTotalLength();
} catch {
return;
}
if (!len) return;
for (let i = 0; i <= 60; i++) {
const p = path.getPointAtLength((len * i) / 60);
pts.push({ x: p.x, y: p.y });
}
});
return pts;
}
/**
* Screen y of the mark's lowest painted point – the V vertex the handoff
* hairline hangs from. The SVG box bottom is ~5 % below it (viewBox padding),
* so a fixed length always stopped short of the folio rule.
*/
function markBottomY(markWrap: HTMLElement, pts: { x: number; y: number }[]): number | null {
const svg = markWrap.querySelector("svg");
const ctm = svg?.getScreenCTM();
if (!svg || !ctm || !pts.length) return null;
let maxY = -Infinity;
for (const p of pts) {
const y = new DOMPoint(p.x, p.y).matrixTransform(ctm).y;
if (y > maxY) maxY = y;
}
return Number.isFinite(maxY) ? maxY : null;
}
/**
* Cover (ink, ≥ 100svh). Rows: (leer) · 1fr · H1 · Subline + CTA · Folio.
* Die Mastkopf-Zeile wurde am 06.09.2026 auf Kundenwunsch gestrichen; die erste
* Rasterzeile bleibt leer, damit die übrigen Zeilenzuweisungen unverändert gelten.
* Variant A (photo): portrait cols 8–12 bleeding to the page edge with scrims,
* monogram 28vh in cols 6–8 overlapping its left edge, neck handoff into the
* folio hairline, which draws outward from that x. Variant B (no photo):
* monogram 56vh cols 7–12, right-aligned, same handoff. Mobile: masthead,
* monogram 14svh, H1, CTAs, portrait 3:4 (≤ 48svh) – no folio row (it costs
* 69 px the phone cover does not have; Positionierung carries „01 / 06“).
*/
export function Hero({ mark, photo, content: hero = heroDe }: HeroProps) {
const root = useRef(null);
const h1 = useRef(null);
const markWrap = useRef(null);
const folio = useRef(null);
const [origin, setOrigin] = useState(null);
const [handoffLength, setHandoffLength] = useState(HANDOFF);
const hasPhoto = Boolean(photo);
/* Handoff x → folio rule origin, and the exact gap from the mark's lowest
point down to that rule, so the neck stroke actually reaches the hairline
instead of ending as a stub ~25 px above it (desktop only; on mobile the
mark sits above the H1). */
useLayoutEffect(() => {
let pts: { x: number; y: number }[] | null = null;
const measure = () => {
const m = markWrap.current;
const f = folio.current;
if (!m || !f || !window.matchMedia("(min-width: 1024px)").matches) return;
const x = measureHandoffX(m, f);
if (x !== null && x > 0) setOrigin(Math.round(x));
if (!pts) pts = sampleMarkPoints(m);
const bottom = markBottomY(m, pts);
if (bottom !== null) {
const gap = f.getBoundingClientRect().top - bottom;
setHandoffLength(Math.round(Math.min(72, Math.max(16, gap))));
}
};
measure();
/* One measurement per frame: a window drag fires `resize` dozens of times a second
and each pass forces layout for the whole monogram. */
let raf = 0;
const onResize = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
measure();
});
};
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
if (raf) cancelAnimationFrame(raf);
};
}, []);
/* Scroll: headline drifts up 6 % over the hero (desktop, no reduced motion). */
useLayoutEffect(() => {
const el = root.current;
const head = h1.current;
if (!el || !head) return;
const ctx = gsap.context(() => {
const mm = gsap.matchMedia();
mm.add({ desktop: MM.desktop, ok: MM.ok }, (c) => {
if (!c.conditions?.desktop || !c.conditions?.ok) return;
gsap.to(head, {
yPercent: -6,
ease: "none",
scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: 0.6 },
});
});
}, el);
return () => ctx.revert();
}, []);
const captionHasPlaceholder = splitPlaceholders(hero.caption).some((s) => s.placeholder);
const showCaption = !captionHasPlaceholder || flags.showPlaceholders;
return (
{/* From lg the FOURTH row (subline + CTA) is the flexible one, not the second:
the slack has to sit between the CTA block and the folio, otherwise a
shorter CTA row pushes the headline down into the monogram. */}
{/* Portrait – variant A. Desktop: cols 8–12, rows 2–4, bleeds to the page edge. */}
{photo && (
{/* Scrims: top third + left edge (headline legibility). */}
{showCaption && (
)}
)}
{/* Monogram: bottom-aligned to the folio hairline; the neck hands off into it. */}
{mark}
{/* H1 */}
{/* The H1 is capped by viewport HEIGHT as well as width: `26vh - 108px`
is neutral at 1440×900 (the cover's reference frame) and pulls the
three Bodoni lines back on 800/768 px laptops, where the vw term
alone grows faster than the fold shrinks. */}
{hero.h1.before}
{hero.h1.em}
{hero.h1.after}
{/* Subline + CTA. Vertical rhythm from lg is viewport-relative so the cover
keeps fitting `min-h-svh` at 768–900 px viewport heights; the row runs
cols 1–8 below 1440 so button + text link stay on one line there too. At
1440×900 – the cover's reference frame – the original 24/32 px rhythm is
restored, which also keeps the headline clear of the monogram. */}
{hero.subline}
{/* The hover suffix („( 30 Min · kostenfrei )“) must NOT reserve its
width in flow – it added ~190 px of empty outline and pushed the
text link onto its own line. It grows out of the masked cell on
hover/focus instead (spec §6.1: button + text link, one row). */}
{/* Folio: hairline draws outward from the handoff x, „01 / 06“ · „Positionierung ↓“.
Hidden below lg – the folio row costs 69 px that the phone cover does not have,
and Positionierung directly below carries its own „01 / 06“ mobile counter. */}