"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). */} )} {/* 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. */}
); } export default Hero;