Files
Anelia-Wolf/src/components/layout/Header.tsx
T
knsoandClaude Opus 5 33ec0df7e3 Messe-Landingpage /her-career/ zur herCAREER Expo 2026
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>
2026-09-07 22:39:32 +02:00

177 lines
8.5 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 { 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;