Ziel des QR-Codes auf der Rückwandgrafik. Der gedruckte Code zeigt auf https://her-career.anelia-wolf.com – die Adresse ist damit unveränderlich. Die Seite liegt als /her-career/ im normalen Export, die Subdomain liefert dieselbe Datei aus demselben Docroot aus; DNS, Zertifikat, Vhosts für nginx und Apache und die Prüfbefehle stehen in docs/SUBDOMAIN-HERCAREER.md. Aufbau in beiden Sprachen: Hero (ink) → 01 Messe-Special → 02 Themen → 03 Auf der Messe → 04 Kontakt mit dem Formular. Die vier Themenblöcke der Rückwand liegen auf den drei Leistungsseiten, wie in der Navigation; jede Zeile verlinkt absolut auf die Hauptdomain. Der Wortlaut des Messe-Specials fehlt noch und steht sichtbar als „[…]“ auf der Seite. Der Build-Guard hält den Launch-Flip an, bis er da ist (CONTENT_TODO in src/lib/legal.ts, docs/OFFENE-DATEN.md Punkte 2.7–2.9). Deadline ist nicht der Livegang, sondern der 22.10.2026. - Canonical auf anelia-wolf.com/her-career/, damit die Subdomain-Kopie nicht ein zweites Mal im Index steht - flags.showHerCareer trägt Termine-Ledger, Sitemap-Eintrag und die Meta-Beschreibung mit Stand und Uhrzeit. Ab dem 24.10.2026 auf false - ServiceHero: facts und cta sind optional – ohne beides und ohne Foto entfällt die rechte Spalte - ProcessContact: optionaler Zähler, die Messeseite nummeriert ihre Abschnitte - Kopf-CTA auf dieser Seite ausgeblendet. Das macht eine Regel in globals.css, weil der Kopf außerhalb von <main> liegt und auf der Subdomain der Pfad ohnehin „/“ ist Enthält außerdem die bis dahin nicht committeten Änderungen an Header, LangSwitch, OverlayMenu, Wordmark und globals.css. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
177 lines
8.5 KiB
TypeScript
177 lines
8.5 KiB
TypeScript
"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.
|
||
Zwei Formen, weil neben Wortmarke und „Menü“ auf einem Telefon kein Paar
|
||
Platz hat: unter 1024 px nur die Zielsprache als ein Knopf, darüber beide.
|
||
Im Overlay-Menü steht er nicht mehr – dort fiel er auf kurzen Geräten
|
||
unter die Kante, ohne dass man ihm ansah, dass da noch etwas kommt. */}
|
||
{flags.showLangSwitch && <LangSwitch lang={lang} variant="compact" className="lg:hidden" />}
|
||
{flags.showLangSwitch && <LangSwitch lang={lang} className="hidden lg:flex" />}
|
||
<a
|
||
data-menu-inert=""
|
||
/* Haken für globals.css: Auf der Messeseite entfällt dieser Knopf. */
|
||
data-header-cta=""
|
||
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} />
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default Header;
|