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 `
`, 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 ``-Elements zählt zum `textContent` seines Vorfahren, und
+ * 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 ändert daran nichts.
+ */
+ decorative?: boolean;
}
const ID_CLASS: Record = {
@@ -40,7 +52,9 @@ function escapeAttr(s: string): string {
function prepareSvg(
raw: string,
- { className, idPrefix, variant, handoff, title }: Required> &
+ { className, idPrefix, variant, handoff, title, decorative }: Required<
+ Pick
+ > &
Pick,
): string {
/* The file comes from a non-technical client via public/brand/ and is injected with
@@ -90,6 +104,10 @@ function prepareSvg(
return ``;
});
+ /* Der Name der Marke, wo sie keinen tragen soll – siehe `decorative`. Das
+ muss WEG, nicht nur versteckt: Es zählt zum textContent der Überschrift. */
+ if (decorative) svg = svg.replace(//gi, "");
+
svg = svg.replace(/]*)>/, (_, 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 ``;
+ }
if (!/\brole=/.test(a)) a += ' role="img"';
if (!/\baria-label=/.test(a)) a += ` aria-label="${escapeAttr(title)}"`;
return ``;
@@ -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 ;
}
+ /* Ersatzmarke, wenn public/brand/logo-mark.svg fehlt. wird nicht angefasst
+ (Projektregel), deshalb bekommt sie hier einen leeren Namen – dann steht in ihrem
+ 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 (
-
-
+
+
+
+ );
+ }
+ if (decorative) {
+ return (
+
+
);
}
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.
+ *
+ * ` ` 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(`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;
@@ -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) => {
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 ; 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 ;
+ return ;
}
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. */}
-
+ ; `idPrefix`, weil dieselbe Marke auf der
+ Seite noch dreimal vorkommt (Kopf, Hülle, Fuß) und die Verlaufs-ids sonst
+ kollidieren. */
+ h1Mark={ }
+ >
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 && {h1.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. */
+
+ {h1Mark}
+
+ )}
{subline && (
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 (``), ü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