Files
Anelia-Wolf/src/components/home/Hero.tsx
T
2026-09-06 17:21:52 +02:00

321 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 {
/** `<BrandMark idPrefix="hero" handoff/>` – 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 `<Rule origin={x}/>`, 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<HTMLElement>("[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<SVGPathElement>("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<SVGPathElement>("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<HTMLDivElement>(null);
const h1 = useRef<HTMLDivElement>(null);
const markWrap = useRef<HTMLDivElement>(null);
const folio = useRef<HTMLDivElement>(null);
const [origin, setOrigin] = useState<number | null>(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 (
<Section
id={hero.id}
theme="dark"
className="grid min-h-svh pt-[calc(var(--header-h,7.5rem)+0.75rem)] pb-0 lg:pt-[calc(var(--header-h,7.5rem)+0.5rem)] [&>div]:grid"
>
{/* 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. */}
<div
ref={root}
className="col-span-12 grid grid-cols-12 grid-rows-[auto_auto_auto_auto_auto_auto] gap-x-[clamp(16px,2vw,32px)] lg:grid-rows-[auto_auto_auto_1fr_auto]"
>
{/* Portrait – variant A. Desktop: cols 8–12, rows 2–4, bleeds to the page edge. */}
{photo && (
<div className="relative z-0 col-span-12 row-start-5 mt-10 lg:col-span-5 lg:col-start-8 lg:row-start-2 lg:row-end-5 lg:mt-0 lg:-mr-[clamp(20px,5vw,80px)]">
<div className="relative h-full">
<Door
src={photo}
alt={hero.alt}
width={1600}
height={2000}
sizes="(min-width: 1024px) 45vw, 100vw"
priority
parallax
play="load"
delay={T.door}
className="max-h-[48svh] w-full lg:h-full lg:max-h-none"
imgClassName="[filter:sepia(.12)_saturate(.9)_contrast(1.02)]"
/>
{/* Scrims: top third + left edge (headline legibility). */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-[linear-gradient(var(--ink)_0%,transparent_45%)]"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 hidden bg-[linear-gradient(90deg,var(--ink)_0%,rgba(15,14,12,0.72)_30%,transparent_60%)] lg:block"
/>
{showCaption && (
<p className={cn(MONO_11, "absolute right-4 bottom-4 text-ivory/80")}>
<PlaceholderText text={hero.caption} />
</p>
)}
</div>
</div>
)}
{/* Monogram: bottom-aligned to the folio hairline; the neck hands off into it. */}
<div
ref={markWrap}
className={cn(
"relative z-[1] col-span-12 row-start-2 mt-6 h-[14svh] text-ivory lg:row-start-2 lg:row-end-5 lg:mt-0 lg:mb-4 lg:self-end",
"max-lg:[&_span[aria-hidden=true]]:hidden",
/* WolfMark's 7-unit stroke is ~18 px at hero size – far from the
spec's hairline mark. Overridden here, not in WolfMark.tsx. */
"[&_.wolfmark-v]:[stroke-width:3] [&_.wolfmark-wolf]:[stroke-width:2.6]",
hasPhoto
? "lg:col-span-3 lg:col-start-6 lg:h-[28vh] lg:justify-self-start"
: "lg:col-span-6 lg:col-start-7 lg:h-[min(45vh,24vw)] lg:justify-self-end",
)}
>
<DrawMark
play="load"
sheen
handoff
handoffLength={handoffLength}
delay={T.draw}
className="block h-full [&_svg]:h-full [&_svg]:w-auto"
>
{mark}
</DrawMark>
</div>
{/* H1 */}
<div
ref={h1}
className="relative z-[2] col-span-12 row-start-3 mt-6 lg:col-span-8 lg:col-start-1 lg:row-start-3 lg:mt-0 lg:self-end"
>
{/* 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. */}
<Lines
as="h1"
play="load"
delay={T.lines}
stagger={0.09}
className="max-w-[12ch] font-display text-[clamp(3rem,min(1.4rem_+_7.4vw,26vh_-_108px),8rem)] font-normal leading-[0.94] tracking-[-0.015em] text-ivory [text-wrap:balance]"
>
{hero.h1.before}
<em className="italic">{hero.h1.em}</em>
{hero.h1.after}
</Lines>
</div>
{/* 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. */}
<div className="relative z-[2] col-span-12 row-start-4 mt-6 pb-2 lg:col-span-6 lg:col-start-1 lg:row-start-4 lg:mt-[2.2vh] lg:pb-[3vh] wide:mt-6 wide:pb-8">
<Reveal play="load" delay={T.cta} as="p" className="max-w-[38ch] font-sans text-[clamp(1.125rem,min(1.1rem_+_0.6vw,3.1vh),1.5rem)] font-light leading-[1.45] text-fg-muted wide:max-w-[46ch]">
{hero.subline}
</Reveal>
<Reveal play="load" delay={T.cta + 0.1} className="mt-6 flex flex-wrap items-start gap-x-8 gap-y-4 lg:mt-[2.2vh] wide:mt-6">
<Magnet>
{/* 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). */}
<Button
href={hero.cta.href}
label={hero.cta.label}
suffix={hero.cta.suffix}
className="[&>span[aria-hidden]]:max-w-0 [&>span[aria-hidden]]:transition-[max-width] [&>span[aria-hidden]]:duration-[350ms] [&>span[aria-hidden]]:ease-[var(--ease-out-quart)] hover:[&>span[aria-hidden]]:max-w-[16rem] focus-visible:[&>span[aria-hidden]]:max-w-[16rem] motion-reduce:[&>span[aria-hidden]]:transition-none"
/>
</Magnet>
</Reveal>
</div>
{/* 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. */}
<div ref={folio} className="col-span-12 row-start-6 mt-6 hidden lg:block lg:row-start-5 lg:mt-0">
<Folio
current={hero.folio.current}
total={hero.folio.total}
next={{ label: hero.folio.next.label, href: `/${hero.folio.next.href}` }}
origin={origin ?? "left"}
play="load"
delay={T.folio}
/>
</div>
</div>
</Section>
);
}
export default Hero;