From a37e48e4efe5fd84d557a843dc82694ca6ff1588 Mon Sep 17 00:00:00 2001 From: JUSN Date: Thu, 10 Sep 2026 21:12:32 +0200 Subject: [PATCH] =?UTF-8?q?Messeseite:=20=E2=80=9EEN=E2=80=9C=20f=C3=BChrt?= =?UTF-8?q?=20zur=20englischen=20Messeseite,=20nicht=20zur=20Startseite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auf her-career.anelia-wolf.com liegt die Seite unter „/“. usePathname() meldet dort „/“, und counterpart() leitete daraus folgerichtig die englische STARTSEITE ab – wer am Stand gescannt und dann auf „EN“ getippt hat, landete auf anelia-wolf.com/en/. Der Umschalter fragt jetzt den hreflang-Verweis im Kopf des Dokuments: Der hängt an der Seite, nicht an der Adresse, unter der sie ausgeliefert wird. Am selben Wurzelproblem: Der Sprachwechsel ist wegen der zwei Wurzel-Layouts ohnehin ein neues Dokument. Den macht jetzt der Browser statt router.push(), und über die Sprachgrenze wird nicht mehr vorgeladen – auf der Subdomain scheiterte der Vorabruf an CORS, mehrfach pro Seitenaufbau. Dazu zwei Sachen am Kopf der Messeseite: Das Monogramm steht hinter dem Schlusspunkt der Begrüßung, 0,783 em hoch (die Oberlänge der Playfair Display, im Browser gemessen) und auf der Grundlinie – und dafür nicht mehr in der Kopfzeile, wo es sonst zweimal auf einem Bildschirm stünde. Es steht dort dekorativ, sonst wandert der Text seines über SplitText ins aria-label der Überschrift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --- src/app/globals.css | 29 ++++++++------ src/components/brand/BrandMark.tsx | 45 ++++++++++++++++++++-- src/components/layout/LangSwitch.tsx | 48 ++++++++++++++++++++++-- src/components/motion/Curtain.tsx | 10 ++++- src/components/motion/TransitionLink.tsx | 24 +++++++++++- src/components/pages/HerCareerBody.tsx | 11 +++++- src/components/services/ServiceHero.tsx | 29 +++++++++++++- src/lib/i18n.ts | 29 ++++++++++++++ 8 files changed, 202 insertions(+), 23 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index d6deca8..92cadfe 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -218,18 +218,25 @@ body:has(main #messe-special) header [data-collab] { display: inline-flex; } -/* Unter 640 px steht der Zusatz unter der Wortmarke, und dort trägt der Kopf kein - Monogramm mehr: Mit ihm begann „ANELIA WOLF“ um 48 px eingerückt, während das - Malzeichen darunter an der Seitenkante saß – zwei Zeilen, die an keiner Kante - fluchteten. Ohne Monogramm beginnen beide links auf derselben Linie und lesen - sich als eine Marke. Betrifft nur den Kopf der Messeseite auf dem Telefon: Der - Fuß behält sein Monogramm, alle anderen Seiten auch. */ -@media (max-width: 639px) { - body:has(main #messe-special) header .wordmark__mark { - display: none; - } +/* Auf der Messeseite trägt der Kopf kein Monogramm mehr (Kundenwunsch, 10.09.2026). + Seit die Marke hinter dem Schlusspunkt der Begrüßung steht (`h1Mark` in + src/components/services/ServiceHero.tsx), stand sie auf demselben Bildschirm + zweimal – oben klein im Kopf, darunter groß in der Überschrift. Eine der beiden + musste weg, und die große ist die, die gemeint ist. - /* Und die Laufweite von 0.22em auf 0.15em. „ANELIA WOLF“ hat zehn Buchstabenlücken; + Bis dahin galt dieselbe Regel schon unter 640 px, aus einem zweiten Grund: Mit + Monogramm begann „ANELIA WOLF“ um 48 px eingerückt, während das Malzeichen darunter + an der Seitenkante saß – zwei Zeilen, die an keiner Kante fluchteten. Ohne Monogramm + beginnen beide links auf derselben Linie und lesen sich als eine Marke. + + Nur der Kopf der Messeseite: Der Fuß behält sein Monogramm, alle anderen Seiten auch. + Die Hülle („Umblättern“) ebenfalls – sie liegt über der ganzen Seite, nicht in der Zeile. */ +body:has(main #messe-special) header .wordmark__mark { + display: none; +} + +@media (max-width: 639px) { + /* Die Laufweite von 0.22em auf 0.15em. „ANELIA WOLF“ hat zehn Buchstabenlücken; bei 15 px sind das 33 px allein an Sperrung, und die fehlten, damit die Zeile „ANELIA WOLF × herCAREER“ neben Sprachumschalter und Menü auf 335 px passt. Nur hier – auf allen anderen Seiten und ab 640 px bleibt die Wortmarke, wie sie ist. */ diff --git a/src/components/brand/BrandMark.tsx b/src/components/brand/BrandMark.tsx index b90f205..70fbd7f 100644 --- a/src/components/brand/BrandMark.tsx +++ b/src/components/brand/BrandMark.tsx @@ -25,6 +25,18 @@ export interface BrandMarkProps { handoff?: boolean; /** Accessible name. */ title?: string; + /** + * Die Marke steht nur als Bild da, ohne Namen: kein `<title>`, kein `role`/`aria-label`, + * stattdessen `aria-hidden`. Nötig überall dort, wo sie INNERHALB von Text sitzt – in der + * H1 der Messeseite etwa. Zwei Gründe, beide handfest: + * + * 1. Der Name stünde mitten im Satz, den ein Screenreader vorliest. + * 2. Der Text eines `<title>`-Elements zählt zum `textContent` seines Vorfahren, und + * <Lines/> baut mit SplitText (`aria: "auto"`) daraus das aria-label der Überschrift. + * Ohne diesen Schalter hieß sie „Schön, dass Sie gescannt haben. Anelia Wolf – + * Monogramm“ – ein `aria-hidden` am umgebenden <span> ändert daran nichts. + */ + decorative?: boolean; } const ID_CLASS: Record<string, string> = { @@ -40,7 +52,9 @@ function escapeAttr(s: string): string { function prepareSvg( raw: string, - { className, idPrefix, variant, handoff, title }: Required<Pick<BrandMarkProps, "variant" | "handoff" | "title">> & + { className, idPrefix, variant, handoff, title, decorative }: Required< + Pick<BrandMarkProps, "variant" | "handoff" | "title" | "decorative"> + > & Pick<BrandMarkProps, "className" | "idPrefix">, ): string { /* The file comes from a non-technical client via public/brand/ and is injected with @@ -90,6 +104,10 @@ function prepareSvg( return `<path${a}${selfClose}>`; }); + /* Der Name der Marke, wo sie keinen tragen soll – siehe `decorative`. Das <title> + muss WEG, nicht nur versteckt: Es zählt zum textContent der Überschrift. */ + if (decorative) svg = svg.replace(/<title\b[\s\S]*?<\/title>/gi, ""); + svg = svg.replace(/<svg\b([^>]*)>/, (_, attrs: string) => { let a = attrs.replace(/\s(width|height)="[^"]*"/g, ""); if (/\bclass="/.test(a)) { @@ -97,6 +115,11 @@ function prepareSvg( } else if (className) { a += ` class="${className}"`; } + if (decorative) { + a = a.replace(/\srole="[^"]*"/gi, "").replace(/\saria-label="[^"]*"/gi, ""); + a += ' aria-hidden="true"'; + return `<svg${a}>`; + } if (!/\brole=/.test(a)) a += ' role="img"'; if (!/\baria-label=/.test(a)) a += ` aria-label="${escapeAttr(title)}"`; return `<svg${a}>`; @@ -111,16 +134,30 @@ export function BrandMark({ variant = "full", handoff = false, title = "Anelia Wolf – Monogramm", + decorative = false, }: BrandMarkProps) { const raw = getLogoSvg(); if (raw) { - const html = prepareSvg(raw, { className, idPrefix, variant, handoff, title }); + const html = prepareSvg(raw, { className, idPrefix, variant, handoff, title, decorative }); return <span className="contents" dangerouslySetInnerHTML={{ __html: html }} />; } + /* Ersatzmarke, wenn public/brand/logo-mark.svg fehlt. <WolfMark/> wird nicht angefasst + (Projektregel), deshalb bekommt sie hier einen leeren Namen – dann steht in ihrem + <title> kein Text, der in die Überschrift wandern könnte – und die Hülle nimmt sie + aus dem Vorlesetext. */ + const fallbackTitle = decorative ? "" : title; + const hide = decorative ? { "aria-hidden": true as const } : {}; if (variant === "v-only") { return ( - <span className="contents [&_.wolfmark-wolf]:hidden"> - <WolfMark className={className} idPrefix={idPrefix} title={title} /> + <span className="contents [&_.wolfmark-wolf]:hidden" {...hide}> + <WolfMark className={className} idPrefix={idPrefix} title={fallbackTitle} /> + </span> + ); + } + if (decorative) { + return ( + <span className="contents" {...hide}> + <WolfMark className={className} idPrefix={idPrefix} title={fallbackTitle} /> </span> ); } diff --git a/src/components/layout/LangSwitch.tsx b/src/components/layout/LangSwitch.tsx index d369aca..b7f468d 100644 --- a/src/components/layout/LangSwitch.tsx +++ b/src/components/layout/LangSwitch.tsx @@ -1,6 +1,7 @@ "use client"; import { usePathname } from "next/navigation"; +import { useSyncExternalStore } from "react"; import { cn } from "@/lib/utils"; import { counterpart, langSwitch, otherLang, type Lang } from "@/lib/i18n"; import { TransitionLink } from "@/components/motion/TransitionLink"; @@ -19,11 +20,44 @@ export interface LangSwitchProps { const BASE = "inline-flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.14em] transition-colors duration-300"; +/** + * Der Pfad des Gegenstücks, wie ihn das Dokument SELBST nennt. + * + * `<link rel="alternate" hreflang="…">` steht im Kopf jeder zweisprachigen Seite + * (`alternatesFor()` in src/lib/i18n.ts) und hängt an der Seite, nicht an der Adresse, + * unter der sie ausgeliefert wird. Genau darin liegt der Unterschied auf der Messeseite: + * her-career.anelia-wolf.com liefert dieselbe Datei unter „/“ aus + * (docs/SUBDOMAIN-HERCAREER.md). `usePathname()` sagt dort „/“, und aus „/“ leitet + * `counterpart()` folgerichtig die englische STARTSEITE ab – wer am Stand gescannt und + * dann auf „EN“ getippt hat, landete auf anelia-wolf.com/en/ statt auf der englischen + * Messeseite. Der hreflang-Verweis im Kopf derselben Datei zeigt dagegen auf + * /en/her-career/, egal unter welcher Adresse sie liegt. + * + * Übernommen wird nur der PFAD: relativ bleibt der Link auf dem Host, auf dem er + * angeklickt wurde, und die Subdomain schickt ihn mit ihrem 301 auf die Hauptdomain – + * so, wie sie es mit jedem anderen Pfad auch tut. Die absolute Adresse zu übernehmen + * würde in der Entwicklung auf die Live-Site zeigen. + */ +function alternatePath(target: Lang): string | null { + const el = document.querySelector<HTMLLinkElement>(`link[rel="alternate"][hreflang="${target}"]`); + if (!el) return null; + try { + const url = new URL(el.href, window.location.href); + return `${url.pathname}${url.hash}`; + } catch { + return null; + } +} + +/** Der Kopf des Dokuments ändert sich nicht, solange das Dokument steht. */ +const noSubscribe = () => () => {}; + /** * Sprachumschalter im Kopf. Steht an der Stelle, an der vorher „Kontakt“ stand. * - * Zielt auf das Gegenstück derselben Seite (`counterpart()`), nicht pauschal auf die - * Startseite. + * Zielt auf das Gegenstück derselben Seite, nicht pauschal auf die Startseite: erst + * `counterpart()` aus dem Pfad, nach dem ersten Rendern der hreflang-Verweis des + * Dokuments (`alternatePath()` – die Begründung steht dort). * * Zur Flagge: Eine Flagge steht für ein Land, nicht für eine Sprache – deshalb steht * neben ihr immer das Sprachkürzel, und der zugängliche Name nennt die Sprache @@ -32,7 +66,15 @@ const BASE = export function LangSwitch({ lang, className, variant = "pair" }: LangSwitchProps) { const pathname = usePathname() ?? "/"; const other = otherLang(lang); - const href = counterpart(pathname, other); + /* Zwei Quellen, absichtlich: Beim Vorrendern gibt es kein Dokument, das man fragen + könnte – da trägt der Pfad die Antwort. Im Browser steht der hreflang-Verweis im + Kopf, und der weiß es auch dann noch, wenn der Pfad nichts mehr hergibt. Auf allen + Adressen außer der Messe-Subdomain kommt bei beiden dasselbe heraus. */ + const href = useSyncExternalStore( + noSubscribe, + () => alternatePath(other) ?? counterpart(pathname, other), + () => counterpart(pathname, other), + ); /* Kompakt: Die aktive Sprache steht nicht da – sie steht ja im Dokument. Gezeigt wird, wohin es geht. Die negativen Ränder heben das Tippziel auf 54 × 42 px, ohne die diff --git a/src/components/motion/Curtain.tsx b/src/components/motion/Curtain.tsx index eabf20c..e76aa25 100644 --- a/src/components/motion/Curtain.tsx +++ b/src/components/motion/Curtain.tsx @@ -5,6 +5,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { gsap, EASE } from "@/lib/gsap"; import { WolfMark } from "@/components/brand/WolfMark"; import { chrome as chromeDe, type Chrome } from "@/content/chrome"; +import { crossesLangRoot } from "@/lib/i18n"; import { endTransition, setTransitionPhase, subscribe } from "./transition-store"; export interface CurtainProps { @@ -49,6 +50,13 @@ export function Curtain({ children, chrome = chromeDe }: CurtainProps) { onComplete: () => { phase.current = "pushed"; setTransitionPhase("pushed"); + /* Sprachwechsel: den Dokumentwechsel macht der Browser, nicht der Router + (Begründung in crossesLangRoot(), src/lib/i18n.ts). Die Hülle bleibt + liegen, bis das neue Dokument da ist – sie gehört zu diesem hier. */ + if (crossesLangRoot(pathname, href)) { + window.location.assign(href); + return; + } router.push(href, { scroll: false }); window.clearTimeout(timer.current); timer.current = window.setTimeout(() => { @@ -57,7 +65,7 @@ export function Curtain({ children, chrome = chromeDe }: CurtainProps) { }, }); }); - }, [router, chrome.curtain.fallbackLabel]); + }, [router, pathname, chrome.curtain.fallbackLabel]); // Route changed → the sheet continues upward and resets. useEffect(() => { diff --git a/src/components/motion/TransitionLink.tsx b/src/components/motion/TransitionLink.tsx index 9d7aab4..3e39f2c 100644 --- a/src/components/motion/TransitionLink.tsx +++ b/src/components/motion/TransitionLink.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import type { ComponentProps, MouseEvent } from "react"; import { getLenis, hasJs, prefersReducedMotion } from "@/lib/gsap"; +import { crossesLangRoot } from "@/lib/i18n"; import { isTransitioning, startTransition } from "./transition-store"; type NextLinkProps = ComponentProps<typeof Link>; @@ -54,8 +55,13 @@ export function scrollToHash(hash: string, immediate = false) { * modifier/middle clicks, external targets, same-page anchors (smooth * scroll instead), reduced motion, and while a transition is running. */ -export function TransitionLink({ href, label, onClick, scroll, ...rest }: TransitionLinkProps) { +export function TransitionLink({ href, label, onClick, scroll, prefetch, ...rest }: TransitionLinkProps) { const pathname = usePathname(); + /* Für einen Sprachwechsel gibt es keine Route zum Vorladen – er ist ein neues + Dokument (crossesLangRoot(), src/lib/i18n.ts). Auf her-career.anelia-wolf.com + scheitert der Vorabruf zusätzlich an der Herkunft: Der Pfad geht per 301 auf die + Hauptdomain, und der Abruf stirbt an CORS – mehrfach pro Seitenaufbau. */ + const crossesLang = crossesLangRoot(pathname, href); const handleClick = (e: MouseEvent<HTMLAnchorElement>) => { onClick?.(e); @@ -79,13 +85,27 @@ export function TransitionLink({ href, label, onClick, scroll, ...rest }: Transi return; } + /* Sprachwechsel: immer ein neues Dokument, nie ein Router-Sprung (Begründung in + crossesLangRoot(), src/lib/i18n.ts). Mit Bewegung legt sich erst die Hülle + darüber – den Wechsel selbst macht dann <Curtain/>; ohne Bewegung geht es + direkt. Beides ist ein echter Seitenaufbau, kein router.push(). */ + if (crossesLang) { + e.preventDefault(); + if (hasJs() && !prefersReducedMotion()) { + startTransition(href, label); // ein zweiter Klick läuft ins Leere – das ist gewollt + return; + } + window.location.assign(href); + return; + } + if (!hasJs() || prefersReducedMotion()) return; // plain client navigation e.preventDefault(); if (isTransitioning()) return; // ignore double clicks startTransition(href, label); }; - return <Link href={href} scroll={scroll ?? false} onClick={handleClick} {...rest} />; + return <Link href={href} scroll={scroll ?? false} prefetch={crossesLang ? false : prefetch} onClick={handleClick} {...rest} />; } export default TransitionLink; diff --git a/src/components/pages/HerCareerBody.tsx b/src/components/pages/HerCareerBody.tsx index c62ccea..333d5d2 100644 --- a/src/components/pages/HerCareerBody.tsx +++ b/src/components/pages/HerCareerBody.tsx @@ -1,3 +1,4 @@ +import { BrandMark } from "@/components/brand/BrandMark"; import { Section } from "@/components/layout/Section"; import { ServiceHero } from "@/components/services/ServiceHero"; import { ServiceLayout } from "@/components/services/ServiceLayout"; @@ -56,7 +57,15 @@ export function HerCareerBody({ content }: HerCareerBodyProps) { darunter – wer den Code gescannt hat, sieht den Nachlass ohne einen Scroll an einer Sektionsüberschrift vorbei. Die Breite ist begrenzt: über die vollen neun Spalten gezogen wäre er kein Gutschein mehr, sondern ein Banner. */} - <ServiceHero content={content}> + <ServiceHero + content={content} + /* Das Monogramm steht hinter dem Schlusspunkt der Begrüßung – die einzige + Stelle der Seite, an der Marke und Satz eine Zeile teilen. Größe und + Grundlinie regelt <ServiceHero/>; `idPrefix`, weil dieselbe Marke auf der + Seite noch dreimal vorkommt (Kopf, Hülle, Fuß) und die Verlaufs-ids sonst + kollidieren. */ + h1Mark={<BrandMark idPrefix="hc-h1" decorative />} + > <Coupon content={coupon} className="max-w-[34rem]" /> </ServiceHero> diff --git a/src/components/services/ServiceHero.tsx b/src/components/services/ServiceHero.tsx index 35d65ca..9dcd758 100644 --- a/src/components/services/ServiceHero.tsx +++ b/src/components/services/ServiceHero.tsx @@ -43,6 +43,11 @@ export interface ServiceHeroProps { * zu scrollen. */ children?: ReactNode; + /** + * Steht als letztes Zeichen in der H1, hinter dem Schlusspunkt. Auf der Messeseite + * hängt hier das Monogramm – die Begrüßung endet mit der Marke, zu der sie gehört. + */ + h1Mark?: ReactNode; className?: string; } @@ -55,7 +60,7 @@ export interface ServiceHeroProps { * Fließtext gehalten hat – eine Stufe größer und über die Spalten 3–11 statt * 3–9. So trägt die Messeseite ihren Hero mit einem einzigen Satz. */ -export function ServiceHero({ content, photo, children, className }: ServiceHeroProps) { +export function ServiceHero({ content, photo, children, h1Mark, className }: ServiceHeroProps) { /* `masthead` und `eyebrow` stehen weiter in den Inhaltsdateien, werden aber seit dem 06.09.2026 nicht mehr gerendert (Kundenwunsch): die Kopfzeile trug dieselben Angaben doppelt, und über der H1 stand faktisch die Überschrift ein zweites Mal. */ @@ -107,6 +112,28 @@ export function ServiceHero({ content, photo, children, className }: ServiceHero {h1.before} {h1.em && <em className={cn("italic", h1GoldEm && "gold-text")}>{h1.em}</em>} {h1.after} + {h1Mark && ( + /* Auf der Grundlinie, hinter dem Punkt, 0,783 em hoch: Das ist die + Oberlänge der Playfair Display (im Browser an „h“ und „b“ gemessen; + die Versalhöhe liegt bei 0,719 em) – die Marke wird damit genau so + hoch, wie die Schrift dieser Zeile maximal wird, und keinen Punkt + höher. Die viewBox der Marke sitzt eng auf der Zeichnung, deshalb + ist Kastenhöhe gleich sichtbare Höhe. + + Zwischen Punkt und Marke steht KEIN Leerzeichen im Markup – der + Abstand kommt aus `ml`, damit an dieser Stelle keine Umbruchstelle + entsteht und die Marke nicht allein in eine letzte Zeile rutscht. + + Dekorativ (`aria-hidden`): Die Marke steht im Kopf schon mit Namen. + In der Überschrift stünde er ein zweites Mal – und zwar mitten im + Satz, den ein Screenreader vorliest. */ + <span + aria-hidden="true" + className="ml-[0.22em] inline-block align-baseline [&_svg]:h-[0.783em] [&_svg]:w-auto" + > + {h1Mark} + </span> + )} </Lines> {subline && ( <Reveal play="load" delay={1.0} as="p" className="mt-10 max-w-[40ch] font-sans text-xl font-light leading-[1.45] text-fg-muted"> diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 97b874e..3d647c6 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -62,6 +62,35 @@ export function otherLang(lang: Lang): Lang { return lang === "de" ? "en" : "de"; } +/** + * Zu welcher Sprachfassung gehört ein Pfad? Alles unter `/en/` ist Englisch, alles + * andere Deutsch – dieselbe Grenze, die `app/en/` von der Gruppe `app/(de)/` trennt. + */ +export function langOfPath(pathname: string): Lang { + const p = pathname.split("#")[0].split("?")[0]; + return p === "/en" || p.startsWith("/en/") ? "en" : "de"; +} + +/** + * Wechselt ein Klick die Sprachfassung? Dann ist das Ziel ein neues Dokument, und + * den Wechsel macht der Browser (`location.assign`), nicht der Client-Router. + * + * Zwei Gründe, der zweite ist der harte: + * + * 1. Deutsch und Englisch haben eigene Wurzel-Layouts (`<html lang>`), über diese + * Grenze navigiert Next ohnehin mit einem vollständigen Seitenaufbau. + * 2. Die Messeseite wird unter ZWEI Adressen ausgeliefert: anelia-wolf.com/her-career/ + * und her-career.anelia-wolf.com, wo dieselbe Datei unter „/“ liegt und jeder + * andere Pfad per 301 auf die Hauptdomain geht (docs/SUBDOMAIN-HERCAREER.md). + * Von dort löst der Router ein Ziel unter /en/ nicht mehr auf – sein Datenabruf + * wird auf die Hauptdomain umgeleitet, und er landet auf der englischen + * STARTSEITE statt auf der englischen Messeseite. Genau das sah, wer am Stand + * den Code gescannt und dann auf „EN“ getippt hat. + */ +export function crossesLangRoot(from: string, to: string): boolean { + return langOfPath(from) !== langOfPath(to); +} + /** * Gegenstück einer Seite in der anderen Sprache. Nimmt den aktuellen Pfad und * liefert denselben Inhalt in der Zielsprache; unbekannte Pfade landen auf der