Messeseite: „EN“ führt zur englischen Messeseite, nicht zur Startseite

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 <title> über
SplitText ins aria-label der Überschrift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
JUSNandClaude Opus 5 committed 2026-09-10 21:17:09 +02:00
1 parent ecf66c7340
commit a37e48e4ef
8 files changed
+202 -23

No files matched your search

+18 -11
View File
@@ -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. */
+41 -4
View File
@@ -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>
);
}
+45 -3
View File
@@ -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
+9 -1
View File
@@ -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(() => {
+22 -2
View File
@@ -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;
+10 -1
View File
@@ -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>
+28 -1
View File
@@ -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">
+29
View File
@@ -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