first publish
This commit is contained in:
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "anelia-dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--", "--port", "3000"],
|
||||||
|
"port": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
44
AGENTS.md
Normal file
44
AGENTS.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||||
|
|
||||||
|
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
||||||
|
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
# Anelia Wolf – Projektregeln
|
||||||
|
|
||||||
|
Dieser Abschnitt steht bewusst UNTER dem generierten Block oben (`next dev`
|
||||||
|
schreibt den Block neu, dieser Teil bleibt).
|
||||||
|
|
||||||
|
## Harte Regeln
|
||||||
|
|
||||||
|
- `src/components/brand/WolfMark.tsx` wird **nie** bearbeitet.
|
||||||
|
- Kein `localStorage`, kein `sessionStorage`, keine Cookies.
|
||||||
|
- Keine externen Requests, Skripte, Schriften oder iframes – die
|
||||||
|
Datenschutzerklärung sagt das zu (`src/content/legal.ts`, Ziffer 2/3/6).
|
||||||
|
- Radius 0, keine Schatten, kein Fettdruck.
|
||||||
|
- Playfair Display (`font-display`) nie unter 28 px. Unter `lg` weicht Text auf
|
||||||
|
`font-sans` (Source Sans 3) aus, statt die Display-Schrift zu verkleinern.
|
||||||
|
- Betonungswort einer Headline: genau ein `<em>`. Kursiv, und auf `data-theme="dark"`
|
||||||
|
zusätzlich golden – das macht eine Regel in `globals.css`, nicht die Komponente.
|
||||||
|
Auf hellem Grund bleibt es rein typografisch (Gold-Verlauf auf Elfenbein = 2,4:1).
|
||||||
|
- Goldene Schrift nur auf `[data-theme="dark"]`; auf Ivory gilt `--gold-deep`.
|
||||||
|
- Sichtbare Copy lebt ausschließlich in `src/content/*.ts`, nie im JSX.
|
||||||
|
|
||||||
|
## Konventionen
|
||||||
|
|
||||||
|
- Jeder Top-Level-Block malt seinen eigenen Grund (`data-theme` + Regel in
|
||||||
|
`globals.css`); die Naht zwischen zwei Gründen ist die goldene Haarlinie.
|
||||||
|
- Scroll-Reveals animieren **nur** `opacity` (+ `y`) – nie `autoAlpha` oder
|
||||||
|
`visibility`, sonst fallen Links aus der Tab-Reihenfolge.
|
||||||
|
- Alle Animationen laufen über `gsap.matchMedia` mit `MM.reduce`/`MM.ok`;
|
||||||
|
GSAP ist die einzige Animations-Runtime im Bundle.
|
||||||
|
- Ein Ankersprung nutzt genau einen Offset: `anchorOffset()` in
|
||||||
|
`TransitionLink.tsx` bzw. `scroll-margin-top` in `globals.css`.
|
||||||
|
- `ScrollTrigger.refresh()` nur über `refreshSoon()` aus `@/lib/gsap`.
|
||||||
|
- Jede `<section>` braucht eine Überschrift (notfalls das Eyebrow als `<h2>`).
|
||||||
|
- Platzhalter immer über `ph()`; der Build-Guard in `src/lib/legal.ts` stoppt
|
||||||
|
den Launch-Flip, solange irgendwo noch ein „[…]“ steht.
|
||||||
88
README.md
Normal file
88
README.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Anelia Wolf – Website
|
||||||
|
|
||||||
|
Statische Website für Anelia Wolf (Keynote Speakerin, Trainerin, Coach).
|
||||||
|
Zwölf Routen in zwei Sprachen: Startseite, drei Leistungsseiten, Impressum und
|
||||||
|
Datenschutz – deutsch auf `/`, englisch unter `/en/` – plus die 404.
|
||||||
|
|
||||||
|
Next.js 16 (App Router) im **Static Export**, Tailwind v4, GSAP + Lenis, TypeScript.
|
||||||
|
Kein Backend, keine Datenbank, keine Formulare.
|
||||||
|
|
||||||
|
## Entwickeln und bauen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:3000
|
||||||
|
npm run build # erzeugt out/
|
||||||
|
npm run lint
|
||||||
|
npx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
`next.config.ts` setzt `output: "export"` und `trailingSlash: true`. Der Build schreibt
|
||||||
|
den fertigen Ordner **`out/`** – das ist das komplette Deployment: rein statische
|
||||||
|
Dateien für einen beliebigen Webhoster (Apache, nginx, Uberspace, Netlify, …).
|
||||||
|
Kein Node-Prozess auf dem Server, kein `next start`.
|
||||||
|
|
||||||
|
## Was diese Seite bewusst nicht tut
|
||||||
|
|
||||||
|
Diese Regeln sind nicht kosmetisch – die Datenschutzerklärung sagt sie zu:
|
||||||
|
|
||||||
|
- **keine externen Requests**: keine Google Fonts, keine CDNs, keine Videos,
|
||||||
|
keine Karten, keine Social-Plugins, keine Analytics. Schriften werden über
|
||||||
|
`next/font` zur Build-Zeit selbst gehostet.
|
||||||
|
- **kein Storage**: kein localStorage, kein sessionStorage, keine Cookies –
|
||||||
|
deshalb auch kein Cookie-Banner.
|
||||||
|
- **kein Kontaktformular**, kein Newsletter. Kontakt läuft über `mailto:` und Telefon.
|
||||||
|
|
||||||
|
Wer eine dieser Zusagen ändert, muss `src/content/legal.ts` (Ziffer 2, 3 und 6)
|
||||||
|
mit ändern.
|
||||||
|
|
||||||
|
## Wo was liegt
|
||||||
|
|
||||||
|
| Pfad | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `src/app/(de)/` | deutsche Routen (`page.tsx` je Seite) und ihr Wurzel-Layout mit `<html lang="de">` |
|
||||||
|
| `src/app/en/` | englische Routen und ihr Wurzel-Layout mit `<html lang="en">` |
|
||||||
|
| `src/app/global-not-found.tsx` | 404 für unbekannte Adressen – wird zu `out/404.html` |
|
||||||
|
| `src/components/pages/` | Der Aufbau jeder Seite, einmal für beide Sprachen |
|
||||||
|
| `src/content/en/` | die englischen Texte, Datei für Datei dieselbe Struktur |
|
||||||
|
| `src/lib/i18n.ts` | Pfade beider Sprachen, Sprachumschalter, hreflang |
|
||||||
|
| `src/content/*.ts` | **Sämtliche Texte.** Wer Copy ändert, ändert nur hier – nie im JSX. |
|
||||||
|
| `src/lib/site.ts` | Stammdaten (Name, E-Mail, Telefon, LinkedIn), Navigation, Routen, **Flags** |
|
||||||
|
| `src/lib/legal.ts` | Platzhalter-Logik, Build-Guard, `LEGAL_TODO` / `CONTENT_TODO` |
|
||||||
|
| `src/lib/assets.ts` | Build-Zeit-Prüfung der Drop-in-Dateien (fehlt eine, rendert die dokumentierte Variante B) |
|
||||||
|
| `src/components/` | `layout/`, `motion/` (GSAP-Primitiven), `ui/`, `brand/`, `home/`, `services/` |
|
||||||
|
| `public/brand/`, `public/photos/` | Drop-in-Slots für Logo und Fotos (je eigene README) |
|
||||||
|
| `docs/superpowers/` | Spec und Build-Plan |
|
||||||
|
|
||||||
|
## Drop-in-Assets
|
||||||
|
|
||||||
|
Fehlt eine Datei, bricht nichts – die Seite rendert die dokumentierte Alternative:
|
||||||
|
|
||||||
|
| Datei | Fehlt sie, dann … |
|
||||||
|
|---|---|
|
||||||
|
| `public/brand/logo-mark.svg` | typografisches `<WolfMark/>`-Monogramm statt des echten Logos |
|
||||||
|
| `public/brand/logo-full.png` | kein Social-Preview-Bild in den OpenGraph-Daten |
|
||||||
|
| `public/photos/anelia-hero.jpg` | Hero ohne Foto (Monogramm trägt die Fläche) |
|
||||||
|
| `public/photos/anelia-portrait.jpg` | „Über Anelia“ ohne Foto |
|
||||||
|
| `public/photos/anelia-stage.jpg` | Keynotes-Seite ohne Bühnenfoto |
|
||||||
|
| `public/speaker-profil.pdf` | Speaker-Profil-Zeile entfällt (zusätzlich `flags.showSpeakerPdf`) |
|
||||||
|
|
||||||
|
Das gelieferte Logo-SVG wird inline eingebettet. Enthält es ein `<script>`,
|
||||||
|
**bricht der Build ab** – dann eine Export-Variante ohne Interaktivität liefern.
|
||||||
|
|
||||||
|
## Flags (`src/lib/site.ts`)
|
||||||
|
|
||||||
|
| Flag | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `showPlaceholders` | `true` bis zum Launch: offene Angaben stehen sichtbar als „[…]“ in Gold |
|
||||||
|
| `hostOutsideEu` | nur `true`, wenn Hoster/Office-Anbieter außerhalb EU/EWR sitzen (Drittland-Absatz) |
|
||||||
|
| `showFaq`, `showStimmen`, `showSpeakerPdf`, `showSecondTalk`, `show24hPromise`, `showIndustry`, `showTwins` | schalten einzelne Blöcke frei, sobald die Inhalte freigegeben sind |
|
||||||
|
|
||||||
|
## Vor dem Livegang
|
||||||
|
|
||||||
|
1. `LEGAL_TODO` und `CONTENT_TODO` in `src/lib/legal.ts` abarbeiten – Anschrift,
|
||||||
|
USt-IdNr., Hoster, Aufsichtsbehörde, Bildnachweise, Dauer-/Paketangaben.
|
||||||
|
2. Erst dann `flags.showPlaceholders` auf `false` setzen.
|
||||||
|
Steht danach irgendwo noch ein „[…]“ in einer Pflichtangabe, **bricht der Build
|
||||||
|
mit einer Fundstelle ab** – der Flip geht nicht ungelesen live.
|
||||||
|
3. `npm run build` und den Inhalt von `out/` hochladen.
|
||||||
555
docs/HANDOVER.md
Normal file
555
docs/HANDOVER.md
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
# Übergabe – Website Anelia Wolf
|
||||||
|
|
||||||
|
Stand: 03.09.2026 · Übergabe an die betreuende Agentur
|
||||||
|
|
||||||
|
Dieses Dokument beschreibt, **was gebaut wurde**, **wie es betrieben wird** und
|
||||||
|
**was vor dem Livegang noch fehlt**. Die technische Kurzfassung für Entwickler
|
||||||
|
steht in `README.md`, die vollständige Begründung jeder Designentscheidung in
|
||||||
|
`docs/superpowers/specs/2026-09-02-anelia-wolf-website-design.md` (Spec) und
|
||||||
|
`docs/superpowers/plans/2026-09-02-anelia-wolf-build-plan.md` (Bauplan).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Was gebaut wurde
|
||||||
|
|
||||||
|
### 1.1 Umfang
|
||||||
|
|
||||||
|
Eine statische Website in **zwei Sprachen**, ohne Backend, ohne Datenbank,
|
||||||
|
ohne Cookie-Banner. Deutsch ist die Originalfassung und liegt auf den bisherigen
|
||||||
|
Adressen, Englisch liegt vollstaendig unter `/en/`:
|
||||||
|
|
||||||
|
| Route | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `/` | Startseite, sechs nummerierte Sektionen: 01 Positionierung · 02 Leistungen · 03 WOLF-Prinzip · 04 Über Anelia · 05 Für wen (mit Termine-Ledger) · 06 Kontakt |
|
||||||
|
| `/leistungen/coaching/` | Coaching & Analysen |
|
||||||
|
| `/leistungen/trainings/` | Trainings & Workshops |
|
||||||
|
| `/leistungen/keynotes/` | Keynotes & Speaking |
|
||||||
|
| `/impressum/` | Impressum |
|
||||||
|
| `/datenschutz/` | Datenschutzerklärung |
|
||||||
|
| 404 | „Diese Fährte endet hier.“ – deutsch, mit englischem Zweizeiler darunter |
|
||||||
|
| `/en/` | English start page |
|
||||||
|
| `/en/services/coaching/` | Coaching & Assessments |
|
||||||
|
| `/en/services/trainings/` | Training & Workshops |
|
||||||
|
| `/en/services/keynotes/` | Keynotes & Speaking |
|
||||||
|
| `/en/imprint/` | Imprint (Übersetzung, verbindlich bleibt `/impressum/`) |
|
||||||
|
| `/en/privacy/` | Privacy (Übersetzung, verbindlich bleibt `/datenschutz/`) |
|
||||||
|
|
||||||
|
Dazu erzeugt der Build `sitemap.xml`, `robots.txt`, `icon.svg` und `apple-icon.png`.
|
||||||
|
|
||||||
|
### 1.2 Technik
|
||||||
|
|
||||||
|
Next.js 16 (App Router) im **Static Export**, React 19, Tailwind v4,
|
||||||
|
GSAP 3.15 + ScrollTrigger, Lenis (Smooth Scroll), TypeScript. Paketmanager npm.
|
||||||
|
|
||||||
|
**Bewusste Selbstbeschränkungen** – sie sind keine Kosmetik, die
|
||||||
|
Datenschutzerklärung sagt sie ausdrücklich zu:
|
||||||
|
|
||||||
|
- **keine externen Requests**: keine Google Fonts, keine CDNs, keine Karten,
|
||||||
|
keine Videos, keine Social-Plugins, kein Analytics, keine iframes.
|
||||||
|
Schriften werden über `next/font` **zur Build-Zeit selbst gehostet**
|
||||||
|
(`out/_next/static/media/*.woff2`).
|
||||||
|
- **kein Storage**: kein localStorage, kein sessionStorage, keine Cookies.
|
||||||
|
Deshalb ist auch kein Cookie-Banner nötig und keiner eingebaut.
|
||||||
|
- **kein Server hinter dem Kontaktformular**, kein Newsletter. Das Formular in
|
||||||
|
Abschnitt 06 setzt aus den Eingaben eine `mailto:`-Nachricht zusammen und
|
||||||
|
übergibt sie dem E-Mail-Programm der Besucherin – es sendet selbst nichts.
|
||||||
|
|
||||||
|
> Wer eine dieser Zusagen ändert (z. B. ein Analytics-Tool einbaut), **muss**
|
||||||
|
> `src/content/legal.ts` (Ziffer 2, 3 und 6 der Datenschutzerklärung) mit ändern
|
||||||
|
> und braucht in der Regel ein Consent-Banner.
|
||||||
|
|
||||||
|
### 1.3 Design-System
|
||||||
|
|
||||||
|
Alle Tokens stehen an genau einer Stelle: `src/app/globals.css`.
|
||||||
|
|
||||||
|
**Farben** (aus dem Logo abgeleitet):
|
||||||
|
|
||||||
|
| Token | Wert | Verwendung |
|
||||||
|
|---|---|---|
|
||||||
|
| `--ink` | `#0f0e0c` | dunkler Grund |
|
||||||
|
| `--ivory` | `#f6f1e8` | heller Grund |
|
||||||
|
| `--bone` | `#eee6d8` | zweiter heller Grund (Termine) |
|
||||||
|
| `--gold` | `#c9a24d` | Gold **nur auf dunklem Grund** (8,05:1 auf Ink) |
|
||||||
|
| `--gold-deep` | `#7d5f1b` | Gold **nur auf hellem Grund** (5,3:1 auf Ivory) |
|
||||||
|
| `--grey-warm` / `--grey-deep` | `#a8a196` / `#5e5a52` | gedämpfter Text dunkel / hell |
|
||||||
|
|
||||||
|
Gold ist rationiert: Haarlinien, Ziffern, Labels, Striche – nie Fließtext.
|
||||||
|
Die großen Display-Ziffern auf hellem Grund nutzen `--gold-700` (`#a37a2c`,
|
||||||
|
3,5:1 – zulässig, weil sie mit 147 px als Large Text gelten und dekorativ sind).
|
||||||
|
|
||||||
|
**Typografie**: Playfair Display (Display, Headlines/Ziffern/Formel/Zitate),
|
||||||
|
Source Sans 3 (Fließtext), DM Mono (Labels, Meta-Zeilen). Gewählt am 06.09.2026
|
||||||
|
aus dem Vergleich unter `/schrift-test/` (dort Paarung 01).
|
||||||
|
Feste Regel: **die Display-Schrift wird nirgends unter 28 px gesetzt** – kleinere
|
||||||
|
Titel laufen in Source Sans 3. Geprüft, siehe QA-Log.
|
||||||
|
Das Betonungswort einer Headline ist kursiv und auf dunklem Grund zusätzlich golden;
|
||||||
|
auf hellem Grund bleibt es rein typografisch, weil der Gold-Verlauf auf Elfenbein
|
||||||
|
nur auf 2,4:1 käme. Die Regel steht in `globals.css`, nicht in den Komponenten.
|
||||||
|
|
||||||
|
**Form**: Radius 0, keine Schatten, kein Bold. Die Seite ist aus Haarlinien
|
||||||
|
gebaut. Die Grenze zwischen zwei Farbblöcken ist eine 1 px Gold-Haarlinie.
|
||||||
|
|
||||||
|
**Grund/Theme (wichtig für spätere Eingriffe)**: Jedes Themenblock-Element
|
||||||
|
(`[data-theme="dark"|"light"]`) malt **seinen eigenen, deckenden Grund**.
|
||||||
|
Es gibt **kein JavaScript, das Farben umschaltet** – ein früherer Ansatz
|
||||||
|
(„ThemeTween“) wurde entfernt, weil Text kurzzeitig auf der falschen Palette
|
||||||
|
landen konnte. Die Regeln dafür stehen in `globals.css` im Block
|
||||||
|
„Ground & boundary (no JS)“. Neue Sektionen brauchen nur ein
|
||||||
|
`data-theme="dark"` oder `"light"` – Grund und Gold-Naht kommen automatisch.
|
||||||
|
|
||||||
|
### 1.4 Motion
|
||||||
|
|
||||||
|
Alle Bewegungen laufen über GSAP + ScrollTrigger, Smooth Scroll über Lenis.
|
||||||
|
Die Primitiven liegen in `src/components/motion/`:
|
||||||
|
|
||||||
|
| Komponente | Aufgabe |
|
||||||
|
|---|---|
|
||||||
|
| `Reveal`, `Lines` | Einblenden von Blöcken und Zeilen beim Scrollen (nur `opacity`/`y`) |
|
||||||
|
| `Rule` | die Gold-Haarlinie, die sich beim Erscheinen aufzieht |
|
||||||
|
| `Draw` | Strichanimation des Wolf-Monogramms |
|
||||||
|
| `Door`, `Curtain` | Seitenübergänge |
|
||||||
|
| `Magnet` | leichter Cursor-Magnetismus auf Buttons |
|
||||||
|
| `ScrollProgress` | 1 px Fortschrittslinie unter dem Header |
|
||||||
|
| `TransitionLink` | Links mit Übergang + Anker-Offset |
|
||||||
|
|
||||||
|
**Barrierefreiheit der Motion**: Reveals animieren ausschließlich `opacity`
|
||||||
|
(und `y`) – nie `visibility`/`autoAlpha`, damit noch nicht sichtbare Elemente
|
||||||
|
in der Tab-Reihenfolge bleiben. `prefers-reduced-motion` ist überall über
|
||||||
|
`gsap.matchMedia` behandelt; die Formel-Sektion (300 svh Sticky-Bühne) fällt
|
||||||
|
dabei auf ihre natürliche Höhe zusammen. Fokusring: 2 px, sichtbar auf beiden
|
||||||
|
Gründen. Ein „Zum Inhalt springen“-Link liegt vor dem Header.
|
||||||
|
|
||||||
|
### 1.5 Inhalte
|
||||||
|
|
||||||
|
**Sämtliche Texte liegen in `src/content/*.ts`** (`home`, `coaching`,
|
||||||
|
`trainings`, `keynotes`, `legal`, `chrome`, `not-found`). Wer Copy ändert,
|
||||||
|
ändert dort – nie im JSX. Stammdaten (Name, E-Mail, Telefon, LinkedIn,
|
||||||
|
Navigation, Routen) liegen in `src/lib/site.ts`.
|
||||||
|
|
||||||
|
Die englischen Texte liegen daneben in `src/content/en/` – Datei für Datei
|
||||||
|
dieselbe Struktur. Erzwungen wird das über den Typ: jede deutsche Inhaltsdatei
|
||||||
|
exportiert ihre Form (z. B. `export type CoachingContent = Widen<typeof coaching>`),
|
||||||
|
die englische Datei erfüllt genau diesen Typ. Fehlt drüben ein Feld oder heisst
|
||||||
|
es anders, schlägt `npx tsc --noEmit` fehl – die Fassungen können also nicht
|
||||||
|
auseinanderlaufen. `Widen<T>` (`src/content/widen.ts`) macht dabei aus den
|
||||||
|
`as const`-Literaltypen der deutschen Datei wieder `string`; ohne das wäre
|
||||||
|
„Coaching & Analysen“ der Typ und nicht der Wert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.6 Zwei Sprachen
|
||||||
|
|
||||||
|
Deutsch ist die Originalfassung, Englisch die Uebersetzung. Beide sind
|
||||||
|
vollstaendig – es gibt keine Seite, die nur in einer Sprache existiert.
|
||||||
|
|
||||||
|
**Adressen.** Deutsch bleibt auf `/`, `/leistungen/…`, `/impressum/`.
|
||||||
|
Englisch bekommt englische Segmente: `/en/`, `/en/services/…`, `/en/imprint/`.
|
||||||
|
Beides steht an genau einer Stelle: `routesByLang` in `src/lib/i18n.ts`.
|
||||||
|
Wer eine Adresse aendert, aendert sie dort – Navigation, Fuss, Sitemap,
|
||||||
|
hreflang und der Sprachumschalter lesen alle aus dieser Tabelle.
|
||||||
|
|
||||||
|
**Umschalter.** Die beiden Flaggen im Kopf (`LangSwitch.tsx`) fuehren nicht
|
||||||
|
pauschal auf die Startseite, sondern auf das Gegenstueck derselben Seite
|
||||||
|
(`counterpart()`). Unter 1024 px ist im Kopf kein Platz; dort steht der
|
||||||
|
Umschalter im Overlay-Menue. Eine Flagge steht streng genommen fuer ein Land
|
||||||
|
und nicht fuer eine Sprache – deshalb steht das Kuerzel DE/EN daneben und der
|
||||||
|
zugaengliche Name nennt die Sprache ausgeschrieben.
|
||||||
|
|
||||||
|
**Zwei Wurzel-Layouts.** `app/(de)/layout.tsx` schreibt `<html lang="de">`,
|
||||||
|
`app/en/layout.tsx` schreibt `<html lang="en">`. Im App Router schreibt nur das
|
||||||
|
Wurzel-Layout das `<html>`-Element, und die Sprache des Dokuments gehoert dort
|
||||||
|
hin (WCAG 3.1.1) – ein `<div lang="en">` weiter unten waere nicht dasselbe.
|
||||||
|
Die Gruppe `(de)` taucht in keiner URL auf. Beide Layouts bestehen nur aus
|
||||||
|
einem Aufruf von `<Shell lang>` (`src/components/layout/Shell.tsx`), das
|
||||||
|
Dokument steht also trotzdem nur einmal im Projekt. Zwei Folgen davon:
|
||||||
|
|
||||||
|
- Ein Sprachwechsel ist ein vollstaendiger Seitenaufbau statt einer
|
||||||
|
Client-Navigation. Bei einem Sprachwechsel ist das richtig – Metadaten,
|
||||||
|
Huelle und Inhalt wechseln ohnehin komplett.
|
||||||
|
- Next kann keine gemeinsame 404 mehr zusammensetzen und faellt ohne
|
||||||
|
Gegenmassnahme auf seine eigene, weisse Fehlerseite zurueck. Deshalb
|
||||||
|
`experimental.globalNotFound` in `next.config.ts` und
|
||||||
|
`src/app/global-not-found.tsx`; diese Datei rendert das ganze Dokument
|
||||||
|
selbst. Sie erzeugt `out/404.html`.
|
||||||
|
|
||||||
|
**Seitenaufbau nur einmal.** Die Routendateien enthalten nur Metadaten und
|
||||||
|
reichen ihre Inhaltsdatei weiter; der Aufbau steht in
|
||||||
|
`src/components/pages/` (`HomeBody`, `CoachingBody`, `TrainingsBody`,
|
||||||
|
`KeynotesBody`, `NotFoundBody`). Eine Layout-Aenderung wirkt damit in beiden
|
||||||
|
Sprachen, ohne dass jemand daran denken muss.
|
||||||
|
|
||||||
|
**Rechtstexte.** Verbindlich ist die deutsche Fassung. `/en/imprint/` und
|
||||||
|
`/en/privacy/` sagen das im ersten Abschnitt und verlinken die deutsche Seite.
|
||||||
|
Die Platzhalter sind in beiden Fassungen dieselben Angaben, und der Build-Guard
|
||||||
|
prueft beide Dateien – wer nur die deutsche fuellt, kommt nicht am Launch-Flip
|
||||||
|
vorbei.
|
||||||
|
|
||||||
|
**Suchmaschinen.** Jede Seite traegt `canonical` auf sich selbst und
|
||||||
|
`hreflang` auf beide Fassungen plus `x-default` auf Deutsch
|
||||||
|
(`alternatesFor()` in `src/lib/i18n.ts`). Die `sitemap.xml` fuehrt alle zwoelf
|
||||||
|
Seiten mit denselben Alternates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Betrieb: entwickeln, bauen, deployen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # Entwicklungsserver http://localhost:3000
|
||||||
|
npm run lint # ESLint
|
||||||
|
npx tsc --noEmit # Typprüfung
|
||||||
|
npm run build # erzeugt out/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
`next.config.ts` setzt `output: "export"` und `trailingSlash: true`.
|
||||||
|
`npm run build` schreibt den Ordner **`out/`** – das ist das komplette
|
||||||
|
Deployment. Vorgehen:
|
||||||
|
|
||||||
|
1. `npm run build`
|
||||||
|
2. **Inhalt von `out/`** (nicht den Ordner selbst) in das Web-Root des Hosters
|
||||||
|
hochladen – FTP, rsync, Git-Deploy, Netlify-Drop, was auch immer.
|
||||||
|
3. Fertig. **Kein Node-Prozess auf dem Server, kein `next start`,
|
||||||
|
keine Datenbank, keine Umgebungsvariablen.**
|
||||||
|
|
||||||
|
Es funktioniert damit auf jedem Standard-Hoster (Apache, nginx, Uberspace,
|
||||||
|
Hetzner Webhosting, Netlify, Vercel, IONOS …).
|
||||||
|
|
||||||
|
**`trailingSlash: true`** bedeutet: jede Route ist ein Ordner mit
|
||||||
|
`index.html` (`out/leistungen/coaching/index.html`). Das ist genau deshalb so
|
||||||
|
gewählt, damit auch „dumme“ Hoster ohne Rewrite-Regeln die Seiten ausliefern.
|
||||||
|
Bitte den Hoster **nicht** so konfigurieren, dass er Trailing Slashes entfernt.
|
||||||
|
|
||||||
|
**404**: `out/404.html` ist die Fehlerseite. Bei Apache genügt
|
||||||
|
`ErrorDocument 404 /404.html`, Netlify/Vercel finden sie automatisch.
|
||||||
|
Nginx: `error_page 404 /404.html;`.
|
||||||
|
|
||||||
|
**HTTPS**: Pflicht (die Datenschutzerklärung nennt TLS). Zusätzlich empfohlen:
|
||||||
|
`Strict-Transport-Security`, `X-Content-Type-Options: nosniff`,
|
||||||
|
`Referrer-Policy: strict-origin-when-cross-origin`. Da die Seite keine externen
|
||||||
|
Ressourcen lädt, ist eine sehr strenge Content-Security-Policy möglich.
|
||||||
|
|
||||||
|
**Domain**: In `src/lib/site.ts` steht `https://anelia-wolf.com`. Dieser Wert
|
||||||
|
speist Canonical-URLs, OpenGraph und `sitemap.xml`. Bei anderer Domain hier
|
||||||
|
ändern und neu bauen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Bilder und Logo einspielen (Drop-in)
|
||||||
|
|
||||||
|
### Logo: erledigt
|
||||||
|
|
||||||
|
Das Original-Logo der Kundin ist eingebaut. `public/brand/logo-mark.svg` enthält das
|
||||||
|
Monogramm, abgeleitet aus `v3-anelia-wolf-gold.svg`: ohne C2PA-Metadaten (rund 16 KB, die
|
||||||
|
sonst auf jeder Seite mitgeliefert würden), ohne weißes Hintergrundrechteck, ohne Wortmarke,
|
||||||
|
auf die Marke beschnitten, schwarze Flächen auf `currentColor`. Die Original-Koordinaten
|
||||||
|
sind unverändert, deshalb sitzt der Goldverlauf exakt wie im Original. Details stehen in
|
||||||
|
`public/brand/README.md`.
|
||||||
|
|
||||||
|
Zwei Dinge sind dadurch anders als in der ursprünglichen Spec:
|
||||||
|
|
||||||
|
- **Die Marke wird als Ganzes aufgewischt, nicht Strich für Strich gezeichnet.** Das Logo
|
||||||
|
besteht aus Flächen, nicht aus Linien; eine Strichanimation wäre darauf unsichtbar. Vor
|
||||||
|
allem aber sind die Teile verzahnt: Die Brust des Wolfs ist der mittlere Strich des W, und
|
||||||
|
das rechte V existiert nur als zwei Fragmente um den Kopf herum. Nacheinander gezeichnet
|
||||||
|
zerfällt die Marke sichtbar in Bruchstücke. Sie wird deshalb in einer Bewegung von oben
|
||||||
|
nach unten aufgedeckt, sodass jedes Zwischenbild ein sauberer Querschnitt der fertigen
|
||||||
|
Marke ist. Das gezeichnete Ersatz-Monogramm (`WolfMark.tsx`) animiert weiterhin als
|
||||||
|
Strich; `src/components/motion/Draw.tsx` entscheidet das automatisch anhand der Datei.
|
||||||
|
- **Die 404-Seite zeigt das vollständige Monogramm.** Die Spec sah dort das W ohne Wolf vor
|
||||||
|
(„Hier ist der Wolf nicht."). Aus demselben Grund geht das nicht. Die Zeile heißt jetzt
|
||||||
|
„Diese Fährte endet hier."
|
||||||
|
|
||||||
|
`public/brand/logo-full.png` (2400 × 1260 px) ist das Social-Preview-Bild und wurde aus
|
||||||
|
demselben Original erzeugt: Monogramm und Wortmarke auf Tinte. Es wird als OpenGraph-Bild
|
||||||
|
ausgeliefert, also beim Teilen auf LinkedIn.
|
||||||
|
|
||||||
|
Die Wortmarke „ANELIA WOLF" bleibt im Kopf und im Fuß **echter Text** (Source Sans 3, gesperrt) und
|
||||||
|
kein Bild: So bleibt sie bei jeder Fenstergröße scharf, ist vorlesbar und markierbar.
|
||||||
|
|
||||||
|
### Alles Weitere
|
||||||
|
|
||||||
|
Die Seite ist so gebaut, dass **fehlende Assets nichts kaputt machen**: Sie
|
||||||
|
rendert dann eine dokumentierte typografische Alternative. Sobald die Datei im
|
||||||
|
richtigen Ordner mit dem richtigen Namen liegt, wird sie **ohne Codeänderung**
|
||||||
|
verwendet – nur neu bauen.
|
||||||
|
|
||||||
|
### `public/brand/` – siehe `public/brand/README.md`
|
||||||
|
|
||||||
|
| Datei | Anforderung | Fehlt sie, dann … |
|
||||||
|
|---|---|---|
|
||||||
|
| `logo-mark.svg` | nur das W-Monogramm mit Wolfskopf, transparent, **echtes SVG mit Pfaden** | typografisches `<WolfMark/>`-Monogramm als Platzhalter |
|
||||||
|
| `logo-full.png` | komplettes Logo mit Wordmark, 1200 × 630 px (oder das Doppelte) | kein Social-Preview-Bild in den OpenGraph-Daten |
|
||||||
|
|
||||||
|
**Nur SVG** für das Monogramm – die Strichanimation braucht echte Pfade, ein
|
||||||
|
PNG wird ignoriert. Keine Gold-Variante nötig (die Seite färbt über
|
||||||
|
`currentColor`). **Kein `<script>` im SVG**: die Datei wird inline eingebettet,
|
||||||
|
ein Skript darin **bricht den Build ab** (dann ohne Interaktivität exportieren).
|
||||||
|
|
||||||
|
### `public/photos/` – siehe `public/photos/README.md`
|
||||||
|
|
||||||
|
| Datei | Format | Verwendung |
|
||||||
|
|---|---|---|
|
||||||
|
| `anelia-hero.jpg` | Hochkant 4:5, min. 1600 px breit, ruhiger Hintergrund | Startseite Hero |
|
||||||
|
| `anelia-portrait.jpg` | Hochkant 3:4, min. 1400 px breit | „Über Anelia“ |
|
||||||
|
| `anelia-stage.jpg` | Querformat 3:2, Bühnenfoto | Keynotes-Seite |
|
||||||
|
|
||||||
|
### `public/speaker-profil.pdf`
|
||||||
|
|
||||||
|
Optional. Liegt die Datei **und** steht `flags.showSpeakerPdf` auf `true`,
|
||||||
|
erscheint die Speaker-Profil-Zeile auf der Keynotes-Seite. Fehlt beides,
|
||||||
|
wird der ganze Block „Für Veranstalter“ **gar nicht gerendert** – es bleibt
|
||||||
|
kein leerer Rahmen stehen.
|
||||||
|
|
||||||
|
> Wichtig: `src/components/brand/WolfMark.tsx` ist das gezeichnete
|
||||||
|
> Platzhalter-Monogramm und wird **nicht** angefasst. Das echte Logo kommt
|
||||||
|
> ausschließlich über `public/brand/logo-mark.svg` herein.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Flags (`src/lib/site.ts`)
|
||||||
|
|
||||||
|
Über diese Schalter werden Blöcke freigegeben, sobald die Inhalte oder
|
||||||
|
Freigaben vorliegen. Kein Flag verlangt eine Codeänderung an Komponenten.
|
||||||
|
|
||||||
|
| Flag | aktuell | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `showPlaceholders` | `true` | offene Pflichtangaben stehen **sichtbar** als „[…]“ in Gold. Vor Livegang auf `false` – siehe Abschnitt 5. |
|
||||||
|
| `hostOutsideEu` | `false` | nur `true`, wenn Hoster **oder** E-Mail-/Office-Anbieter außerhalb EU/EWR sitzen. Bei `false` wird der Drittland-Absatz der Datenschutzerklärung gar nicht gerendert (eine unrichtige Drittland-Angabe verletzt Art. 13 Abs. 1 lit. f DSGVO). |
|
||||||
|
| `showFaq` | `true` | FAQ-Sektionen auf den Leistungsseiten |
|
||||||
|
| `showStimmen` | `false` | Testimonial-Block – erst mit Zitaten **und** schriftlicher Einwilligung |
|
||||||
|
| `showSpeakerPdf` | `false` | Speaker-Profil-Zeile (braucht zusätzlich das PDF) |
|
||||||
|
| `showSecondTalk` | `false` | zweiter Vortrag „Unlock your Sales-DNA“ |
|
||||||
|
| `show24hPromise` | `false` | Zusage „Antwort innerhalb von 24 Stunden“ |
|
||||||
|
| `showIndustry` | `false` | Branchennennung im Nebentätigkeits-Kontext |
|
||||||
|
| `showTwins` | `true` | Zwillinge-Satz in der Biografie |
|
||||||
|
| `showLangSwitch` | `true` | Sprachumschalter im Kopf und im Overlay-Menü. Auf `false` verschwinden beide Flaggen, die englischen Seiten bleiben über ihre Adresse erreichbar. |
|
||||||
|
|
||||||
|
Nicht bestätigte Aussagen sind bewusst ausgeschaltet: eine Zusage, die nicht
|
||||||
|
gehalten wird (24-h-Antwort), und ein Testimonial ohne Einwilligung sind
|
||||||
|
rechtlich riskanter als eine Leerstelle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Vor dem Livegang: LEGAL_TODO
|
||||||
|
|
||||||
|
Die vollständige, kommentierte Liste steht in **`src/lib/legal.ts`**
|
||||||
|
(`LEGAL_TODO` für Impressum/Datenschutz, `CONTENT_TODO` für die
|
||||||
|
Leistungsseiten). Kurzfassung:
|
||||||
|
|
||||||
|
**Impressum**
|
||||||
|
1. Ladungsfähige Anschrift (Straße, Hausnummer, PLZ, Ort) – **kein Postfach**.
|
||||||
|
2. USt-IdNr. (§ 27a UStG) oder W-IdNr. (§ 139c AO) – oder Abschnitt streichen;
|
||||||
|
Kleinunternehmerregelung ja/nein.
|
||||||
|
3. Berufshaftpflicht vorhanden? Dann Versicherer, Anschrift, Geltungsbereich –
|
||||||
|
sonst Absatz streichen.
|
||||||
|
4. Optionale Geschäftsbezeichnung nur, wenn sie tatsächlich geführt wird.
|
||||||
|
5. Bildnachweis: Fotograf:in, Logo-Designer:in/Agentur, Website-Agentur.
|
||||||
|
6. „Stand“-Datum setzen.
|
||||||
|
7. „WOLF-Prinzip“ bleibt als **„Bezeichnung“** stehen, bis die Marke
|
||||||
|
eingetragen ist (Markenrecherche empfohlen – der Begriff wird von Dritten
|
||||||
|
verwendet). Erst danach darf der Urheberrechtsabsatz Markenschutz behaupten.
|
||||||
|
|
||||||
|
**Datenschutz**
|
||||||
|
8. Hosting-Anbieter (Firma, Anschrift, Land) und Löschfrist der Server-Logfiles
|
||||||
|
(üblich 7–14 Tage) beim Hoster erfragen; **AV-Vertrag abschließen**.
|
||||||
|
9. Sitzt Hoster oder E-Mail-/Office-Anbieter außerhalb EU/EWR?
|
||||||
|
Dann `flags.hostOutsideEu` auf `true` **und** Land + Garantie-Grundlage
|
||||||
|
eintragen.
|
||||||
|
10. E-Mail-/Office-Anbieter eintragen; AV-Vertrag.
|
||||||
|
11. Aufbewahrungsfrist für Anfragen festlegen (z. B. 12 Monate).
|
||||||
|
12. Zuständige Aufsichtsbehörde des Bundeslandes eintragen.
|
||||||
|
13. „Stand“-Datum setzen.
|
||||||
|
14. Vor Livegang prüfen: keine Requests an Dritte, kein Storage im Build
|
||||||
|
(aktuell erfüllt – siehe QA-Log).
|
||||||
|
|
||||||
|
**Bildnachweise** nach § 13 UrhG in den Bild-Captions setzen (Hero,
|
||||||
|
Über Anelia, Keynotes-Bühne). „Foto: [Name]“ als Platzhalter erfüllt die
|
||||||
|
Nennungspflicht nicht.
|
||||||
|
|
||||||
|
**CONTENT_TODO** (sichtbare „[…]“ außerhalb der Rechtstexte): Dauer des
|
||||||
|
Auswertungsgesprächs und Analyse-Tool, Dauer/Paket in der Coaching-
|
||||||
|
Rahmentabelle, Gruppengröße und Umfang der Trainingsreihe.
|
||||||
|
|
||||||
|
### Der Flip
|
||||||
|
|
||||||
|
```
|
||||||
|
1. LEGAL_TODO und CONTENT_TODO abarbeiten (echte Angaben in src/content/*.ts)
|
||||||
|
2. flags.showPlaceholders = false
|
||||||
|
3. npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Schutzmechanismus**: Steht danach noch irgendwo ein „[…]“ in einer
|
||||||
|
Pflichtangabe, **bricht der Build ab und nennt die Fundstelle**
|
||||||
|
(`assertNoPlaceholders` in `src/lib/legal.ts`). Der Flip kann also nicht
|
||||||
|
ungelesen live gehen. Absätze, die ohne ihren Platzhalter sinnlos wären, sind
|
||||||
|
als `optional` markiert und entfallen beim Flip vollständig – es entstehen
|
||||||
|
keine Sätze wie „Anelia Wolf,,“.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Aufgabe nach dem Event (herCAREER, 23.10.2026)
|
||||||
|
|
||||||
|
Auf der Startseite steht in Sektion 05 ein **Termine-Ledger** mit zwei Zeilen
|
||||||
|
(`src/content/home.ts`, Export `termine`):
|
||||||
|
|
||||||
|
- 23.10.2026 · 10:30 – Keynote „Das WOLF-Prinzip – Mut zum Erfolg!“,
|
||||||
|
herCAREER Expo, MOC München, Auditorium 2
|
||||||
|
- 22.–23.10.2026 – Ausstellerin, herCAREER Expo, Halle 3, Stand P.10.2
|
||||||
|
|
||||||
|
**Ab dem 24.10.2026 sind diese Zeilen falsch** („Termine“ im Futur für ein
|
||||||
|
vergangenes Event). Zu tun, mit Termin im Kalender:
|
||||||
|
|
||||||
|
1. Entweder die Zeilen in `src/content/home.ts` auf einen Rückblick umstellen
|
||||||
|
(„Gesprochen bei herCAREER 2026“) oder durch neue Termine ersetzen.
|
||||||
|
2. Sind keine neuen Termine da: den Block durch einen Stimmen-Block ersetzen
|
||||||
|
(`flags.showStimmen`), sobald Zitate mit Einwilligung vorliegen.
|
||||||
|
3. Auch anpassen: `src/content/keynotes.ts` (Meta-Zeile
|
||||||
|
„Keynote · herCAREER Expo 2026 · 23.10. · 10:30“) und
|
||||||
|
`src/content/home.ts` (Marginalie „Bühne“, Fact-Zeile).
|
||||||
|
4. `npm run build`, `out/` hochladen.
|
||||||
|
|
||||||
|
**Offen: Wer pflegt das, und wann?** Das ist Spec §12 Punkt 17 und muss mit
|
||||||
|
der Kundin geklärt werden – am besten als feste Zuständigkeit plus
|
||||||
|
Kalendereintrag für den 24.10.2026.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Offene Fragen an die Kundin (Spec §12)
|
||||||
|
|
||||||
|
Diese Punkte blockieren den Livegang bzw. schalten Blöcke frei. Die
|
||||||
|
vollständige Fassung steht in
|
||||||
|
`docs/superpowers/specs/2026-09-02-anelia-wolf-website-design.md`, Abschnitt 12.
|
||||||
|
|
||||||
|
1. **Logo-Vektor**: Original-SVG/AI des W-Monogramms; Bestätigung, dass der
|
||||||
|
Wolf als Einzelstrich gezeichnet und allein gezeigt werden darf (Favicon,
|
||||||
|
Curtain). Schriftliche Nutzungsrechte vom Designer.
|
||||||
|
2. **Impressum/Datenschutz**: Anschrift, USt-IdNr. oder W-IdNr. (oder keins),
|
||||||
|
Kleinunternehmerregelung, Berufshaftpflicht, Bundesland (Aufsichtsbehörde),
|
||||||
|
Hoster und E-Mail-Anbieter.
|
||||||
|
3. **Nebentätigkeit**: Darf „B2B-Außendienst“ genannt werden? Darf
|
||||||
|
„Medizinprodukte“ genannt werden? Der Arbeitgeber wird nie genannt.
|
||||||
|
4. **Biografie-Freigabe**: Vater als Händler, Zahntechnikerin,
|
||||||
|
Abteilungsleitung, Premium-Store-Management, Zwillinge – jede Angabe einzeln
|
||||||
|
bestätigen; dürfen später Jahreszahlen ergänzt werden?
|
||||||
|
5. **Fotos**: Welche Originale existieren (≥ 2000 px, hoch/quer/Bühne)?
|
||||||
|
Name der Fotografin für den Bildnachweis. Rechte am herCAREER-Bild.
|
||||||
|
6. **Analyse-Tool**: Welches Instrument (DISG / INSIGHTS / persolog / …)?
|
||||||
|
Aktive Lizenz? Darf Name/Badge erscheinen? Dauer des Auswertungsgesprächs.
|
||||||
|
7. **Rahmenwerte**: Sitzungslänge und Pakete im Coaching; Gruppengrößen und
|
||||||
|
Serienformat bei Trainings; Reiseradius und Technik bei Keynotes.
|
||||||
|
8. **Preise**: Spanne oder „auf Anfrage“ für Coaching, Trainingstag, Keynote.
|
||||||
|
9. **FAQ-Antworten** für die drei Leistungsseiten (die Sektion erscheint nur
|
||||||
|
mit Antworten).
|
||||||
|
10. **Zweiter Vortrag**: Darf „Unlock your Sales-DNA“ gelistet werden, mit
|
||||||
|
welchem Inhalt? Weitere Vorträge?
|
||||||
|
11. **Speaker-Profil-PDF**: existiert oder wird erstellt (Kurzvita, Themen,
|
||||||
|
Fotos, Technik)?
|
||||||
|
12. **Zusagen**: „Antwort innerhalb von 24 Stunden“ – ja/nein? Kostenfreies
|
||||||
|
30-Minuten-Kennenlerngespräch bestätigen.
|
||||||
|
13. **Testimonials/Referenzen**: Zitate mit Name, Rolle, Unternehmen und
|
||||||
|
schriftlicher Einwilligung? Logos mit Erlaubnis? herCAREER-Logo-Erlaubnis
|
||||||
|
(sonst bleibt es beim reinen Textlink – aktuell so umgesetzt).
|
||||||
|
14. **Zielgruppen-Gewichtung**: B2B zuerst oder Einzelpersonen zuerst?
|
||||||
|
(Reiner Copy-Tausch: Reihenfolge in „Leistungen“ und Hero-Subline.)
|
||||||
|
15. **Sprache**: zum Launch nur Deutsch; englische Route später?
|
||||||
|
„Deutsch & Englisch“ im Masthead bezieht sich auf ihre Leistungen, nicht
|
||||||
|
auf die Website.
|
||||||
|
16. **Marke**: „WOLF-Prinzip“ wird von Dritten verwendet – Markenrecherche
|
||||||
|
empfohlen. Bis zur Eintragung sagt die Seite „Bezeichnung“, nie „Wortmarke“.
|
||||||
|
17. **Nach dem 23.10.2026**: Wer pflegt den Termine-Block, und wann?
|
||||||
|
(Siehe Abschnitt 6.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. QA-Log
|
||||||
|
|
||||||
|
Geprüft am 03.09.2026 gegen den laufenden Dev-Server und den
|
||||||
|
Produktions-Build.
|
||||||
|
|
||||||
|
### Automatisiert / Build
|
||||||
|
|
||||||
|
| Prüfung | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| `npx tsc --noEmit -p tsconfig.json` | fehlerfrei |
|
||||||
|
| `npm run lint` (ESLint) | fehlerfrei |
|
||||||
|
| `npm run build` | erfolgreich, 12 statische Seiten |
|
||||||
|
| Routen in `out/` | `/`, `/leistungen/coaching/`, `/leistungen/trainings/`, `/leistungen/keynotes/`, `/impressum/`, `/datenschutz/`, `404.html` (+ `_not-found/`) – alle vorhanden |
|
||||||
|
| `localStorage` / `sessionStorage` / `document.cookie` in `out/` | **kein Treffer** (HTML, JS, CSS geprüft) |
|
||||||
|
| Externe Origins in den HTML-Dateien | nur `anelia-wolf.com`, `linkedin.com`, `her-career.com`, `bfdi.bund.de`, `schema.org`, `w3.org` (SVG-Namespace) |
|
||||||
|
| Externe Ressourcen | keine: 0 iframes, alle `<script src>` unter `/_next/`, alle 18 `@font-face` zeigen auf lokale `.woff2` in `_next/static/media/`. Die Treffer `nextjs.org`, `react.dev`, `gsap.com`, `github.com` liegen ausschließlich als Text in Fehlermeldungen und Lizenzkommentaren in Vendor-Bundles – **keine Requests**. |
|
||||||
|
| Genau ein `<h1>` je Seite | erfüllt auf allen 9 gebauten HTML-Dateien |
|
||||||
|
|
||||||
|
### Visuell / im Browser
|
||||||
|
|
||||||
|
| Prüfung | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| Startseite 1440 × 900: Hero, Formel-Bühne → Sektion 04, Kontakt-Sektion | korrekt; Header überlagert keinen Inhalt, Nav vollständig sichtbar |
|
||||||
|
| Startseite 390 × 844 (mobil): Hero | korrekt; „Menü“-Trigger, Masthead zweizeilig, kein Überlauf |
|
||||||
|
| Horizontaler Überlauf (`scrollWidth == innerWidth`) | erfüllt auf `/`, allen drei Leistungsseiten (390 px) sowie Impressum, Datenschutz und 404 (375 px) |
|
||||||
|
| Display-Untergrenze 28 px | erfüllt: kein Display-Text unter 28 px auf Startseite und Leistungsseiten |
|
||||||
|
| Gold nur auf dunklem Grund | erfüllt: `--gold` (`#c9a24d`) nur auf Ink, heller Grund nutzt `--gold-deep` / `--gold-700` |
|
||||||
|
| Farbwechsel an Blockgrenzen | kein Text auf der falschen Palette; jeder Block malt seinen eigenen Grund, Gold-Haarlinie sitzt auf der Naht |
|
||||||
|
| Seitenübergreifende Anker | `/leistungen/coaching/` → Header „Kontakt“ landet auf `/#kontakt`, Sektion 89 px unter Fensterkante = Header 73 px + 16 px Offset; `/leistungen/keynotes/` → Footer „Über Anelia“ landet identisch auf `/#ueber` |
|
||||||
|
| `prefers-reduced-motion` | Hero identisch zur Normalansicht; Seitenhöhe fällt von 10 248 px auf 8 362 px, weil die Formel-Bühne wie vorgesehen zusammenklappt |
|
||||||
|
| Flag-gesteuerte Blöcke | Keynotes-Seite rendert bei `showSpeakerPdf: false` und fehlendem PDF **keinen** leeren „Für Veranstalter“-Block |
|
||||||
|
| 404 mobil | Monogramm über die Breite skaliert, kein Überlauf |
|
||||||
|
|
||||||
|
### Hinweis zu Screenshots aus der Entwicklung
|
||||||
|
|
||||||
|
In Screenshots vom Dev-Server erscheint unten links ein rundes schwarzes
|
||||||
|
Symbol mit „N“. Das ist der **Next.js-Devtools-Indikator** des
|
||||||
|
Entwicklungsservers (`<nextjs-portal>`), **nicht Teil der Website** – im
|
||||||
|
Produktions-Build aus `out/` ist er nicht vorhanden.
|
||||||
|
|
||||||
|
### Nicht geprüft / bewusst offen
|
||||||
|
|
||||||
|
- Reale Endgeräte (nur Chrome-Headless mit Viewport-Emulation getestet),
|
||||||
|
Safari/iOS und Firefox.
|
||||||
|
- Screenreader-Durchlauf mit NVDA/VoiceOver (Struktur, Fokusreihenfolge und
|
||||||
|
Kontraste sind geprüft, ein echter Vorlesedurchlauf steht aus).
|
||||||
|
- Verhalten mit eingespielten echten Fotos und echtem Logo-SVG – die
|
||||||
|
Drop-in-Pfade sind implementiert und dokumentiert, aber mit den finalen
|
||||||
|
Dateien noch nicht durchlaufen.
|
||||||
|
- Alles unter Abschnitt 5 (Rechtstexte) und Abschnitt 7 (offene Fragen).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Kurz-Checkliste für den Livegang
|
||||||
|
|
||||||
|
- [ ] Logo-SVG und Fotos nach `public/brand/` bzw. `public/photos/` legen
|
||||||
|
- [ ] LEGAL_TODO und CONTENT_TODO in `src/lib/legal.ts` abgearbeitet
|
||||||
|
- [ ] `flags.showPlaceholders = false`, `npm run build` läuft durch
|
||||||
|
- [ ] Freigegebene Blöcke per Flag eingeschaltet (FAQ, Stimmen, Speaker-PDF …)
|
||||||
|
- [ ] `hostOutsideEu` korrekt gesetzt, AV-Verträge mit Hoster und E-Mail-Anbieter
|
||||||
|
- [ ] Domain in `src/lib/site.ts` stimmt, HTTPS aktiv, 404-Seite konfiguriert
|
||||||
|
- [ ] `out/` hochgeladen, alle zwölf Routen im Browser geprüft (sechs deutsch, sechs englisch)
|
||||||
|
- [ ] Sprachumschalter auf jeder Seite geprüft: er führt auf dieselbe Seite in der anderen Sprache, nicht auf die Startseite
|
||||||
|
- [ ] Kalendereintrag für den 24.10.2026 (Termine-Block, Abschnitt 6)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Temporäre Testseiten (vor dem Livegang löschen)
|
||||||
|
|
||||||
|
Zwei interne Seiten zur Schriftwahl. Beide sind nicht verlinkt, stehen nicht in der
|
||||||
|
Sitemap, tragen `noindex` und sind in der `robots.txt` gesperrt.
|
||||||
|
|
||||||
|
| Route | Zweck |
|
||||||
|
|---|---|
|
||||||
|
| `/schrift-test/` | Der Hero in 27 Schriftpaarungen untereinander, mit Index am rechten Rand |
|
||||||
|
| `/schrift-test/seite/` | Die vollständige Startseite mit Umschalter zwischen drei Paarungen |
|
||||||
|
|
||||||
|
Die Paarungen 01 bis 19 stammen aus der Liste von pagecloud, 20 bis 26 sind eigene
|
||||||
|
Vorschläge. Auf der Vergleichsseite hängt der Wechsel an zwei CSS-Variablen auf `<html>`:
|
||||||
|
`--font-bodoni` für die Display-Schrift, `--font-jost` für den Text. Deshalb wechseln auch
|
||||||
|
Kopf- und Fußzeile mit, obwohl die im Root-Layout stehen.
|
||||||
|
|
||||||
|
**Löschen:** den Ordner `src/app/(de)/schrift-test/` entfernen und die Zeile mit `disallow`
|
||||||
|
in `src/app/robots.ts` zurücksetzen. Damit verschwinden rund 40 Schriftfamilien und
|
||||||
|
etwa 4,9 MB Schriftdateien wieder aus dem Build. Die eigentlichen Seiten laden davon
|
||||||
|
nichts: Die Startseite lädt drei Schriftdateien, vorher wie nachher.
|
||||||
|
|
||||||
|
Wenn die Entscheidung auf eine andere Paarung fällt, sind es zwei Stellen:
|
||||||
|
`src/lib/fonts.ts` (die drei `next/font`-Aufrufe) und `src/app/globals.css`
|
||||||
|
(die Fallback-Listen in `--font-display` und `--font-sans`).
|
||||||
178
docs/superpowers/plans/2026-09-02-anelia-wolf-build-plan.md
Normal file
178
docs/superpowers/plans/2026-09-02-anelia-wolf-build-plan.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# Anelia Wolf – Build-Plan & Komponenten-Vertrag
|
||||||
|
|
||||||
|
Stand: 2026-09-02 · Grundlage: `docs/superpowers/specs/2026-09-02-anelia-wolf-website-design.md`
|
||||||
|
(Abschnitt A = verbindliche Abweichungen, danach die Jury-Synthese). Dieser Plan ist der Vertrag,
|
||||||
|
nach dem parallel arbeitende Agenten bauen. **Pfade, Exportnamen und Props sind verbindlich.**
|
||||||
|
|
||||||
|
## 0. Bereits vorhanden (nicht neu anlegen, nur erweitern)
|
||||||
|
|
||||||
|
- `next.config.ts` (static export, trailingSlash), `src/app/globals.css` (Tokens, `.grain`, `.gold-text`,
|
||||||
|
`.rule-gold`, `[data-theme]`), `src/app/layout.tsx` (Fonts Jost/DM Mono/Bodoni Moda → `--font-jost`,
|
||||||
|
`--font-mono-face`, `--font-bodoni`; Tailwind `font-sans`/`font-mono`/`font-display`; Skip-Link; `SmoothScroll`).
|
||||||
|
- `src/lib/site.ts` (`site`, `nav`, `legal`), `src/lib/utils.ts` (`cn`), `src/hooks/usePrefersReducedMotion.ts`,
|
||||||
|
`src/components/providers/SmoothScroll.tsx` (Lenis + GSAP-Ticker, `window.lenis`).
|
||||||
|
- `src/components/brand/WolfMark.tsx` (SVG-Näherung des Monogramms; Klassen `.wolfmark-v`, `.wolfmark-wolf`,
|
||||||
|
Pfade mit `data-draw` + `pathLength="1"`, Gradient-Id `${idPrefix}wolf-gold`; Props `className`, `title`,
|
||||||
|
`idPrefix`, `strokeWidth`). **Nicht verändern.**
|
||||||
|
- `public/brand/README.md`, `public/photos/README.md` (Drop-in-Slots), `.claude/launch.json` (`anelia-dev`, Port 3000).
|
||||||
|
|
||||||
|
Pakete: next 16.3, react 19.2, tailwindcss 4.3, motion 13 (`motion/react`), gsap 3.15 (`gsap/ScrollTrigger`,
|
||||||
|
`gsap/SplitText`, `gsap/DrawSVGPlugin` – alle frei), lenis 1.3, clsx, tailwind-merge. Nichts weiteres installieren
|
||||||
|
außer `server-only` (falls gewünscht).
|
||||||
|
|
||||||
|
Tailwind v4: Utilities aus den Tokens: `bg-ink bg-ink-2 bg-ivory bg-bone text-gold text-gold-deep text-grey-warm
|
||||||
|
text-grey-deep border-line-dark border-line-light` und Live-Tokens `bg-bg text-fg text-fg-muted text-accent
|
||||||
|
border-line bg-surface`. Schrift: `font-sans` (Jost), `font-mono` (DM Mono), `font-display` (Bodoni Moda).
|
||||||
|
|
||||||
|
## 1. Globale Regeln für alle Agenten
|
||||||
|
|
||||||
|
1. Next 16 App Router, statischer Export: Server-Komponenten standardmäßig; `"use client"` nur wo Hooks/DOM.
|
||||||
|
Keine `params`/`searchParams`, keine Route Handler, keine `fetch` zur Laufzeit, kein `localStorage`/`sessionStorage`/
|
||||||
|
`document.cookie`, keine externen Skripte/iframes/Fonts. Externe Links `target="_blank" rel="noopener noreferrer"`.
|
||||||
|
2. Alle GSAP-Nutzung in `useEffect`/`useLayoutEffect` mit `gsap.context()` + `ctx.revert()` im Cleanup; Plugins
|
||||||
|
nur über `src/lib/gsap.ts`. `gsap.matchMedia()` mit Bedingungen `reduce: "(prefers-reduced-motion: reduce)"`,
|
||||||
|
`desktop: "(min-width: 1024px) and (pointer: fine)"`.
|
||||||
|
3. Reduced motion: Endzustand sofort sichtbar. Ohne JS: Inhalte sichtbar (Initialzustände nur unter `html.js`
|
||||||
|
setzen – `layout.tsx` fügt ein 1-Zeilen-Inline-Script hinzu, das `document.documentElement.classList.add("js")` setzt).
|
||||||
|
4. Bodoni Moda nie < 28 px; Gold-Text (`text-gold`, `.gold-text`) nur in `data-theme="dark"`; auf hell `text-gold-deep`.
|
||||||
|
Radius 0 überall, keine Schatten, kein Bold (max. 500), ein Italic-Wort pro Headline (`<em>` in `font-display italic`).
|
||||||
|
5. Alle Texte kommen aus `src/content/*.ts` – kein Copy in JSX. Deutsche Anführungszeichen „…“, Gedankenstrich „–“.
|
||||||
|
6. Ein `<h1>` pro Seite, Landmarken (`header`, `main#inhalt`, `footer`), Alt-Texte, sichtbarer Fokus.
|
||||||
|
7. Jeder Agent arbeitet NUR in den ihm zugewiesenen Dateien. Fremde Dateien werden importiert, nicht editiert.
|
||||||
|
Am Ende `npx tsc --noEmit`; Fehler in eigenen Dateien beheben. Fehlende Module aus dem Vertrag (noch nicht
|
||||||
|
gebaut) sind der einzige erlaubte Fehlertyp und werden gemeldet.
|
||||||
|
8. Kein `next dev`/`next build` durch Foundation-/Page-Agenten (nur `tsc`). Build und Browser-QA übernehmen die
|
||||||
|
Integrations-/QA-Agenten.
|
||||||
|
|
||||||
|
## 2. Komponenten-Vertrag
|
||||||
|
|
||||||
|
### 2.1 `src/lib/gsap.ts` (F1)
|
||||||
|
```ts
|
||||||
|
export { gsap, ScrollTrigger, SplitText, DrawSVGPlugin }; // registriert, SSR-sicher (typeof window guard)
|
||||||
|
export const EASE = { out: "expo.out", inOut: "power3.inOut", none: "none" } as const;
|
||||||
|
export const DUR = { micro: 0.3, reveal: 0.6, lines: 0.9, door: 1.1, draw: 1.2, sheen: 0.8, rule: 0.9 } as const;
|
||||||
|
export const MM = { reduce: "(prefers-reduced-motion: reduce)", desktop: "(min-width: 1024px) and (pointer: fine)", touch: "(pointer: coarse)" } as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 `src/lib/site.ts` (F2 erweitert)
|
||||||
|
```ts
|
||||||
|
export const flags = { showPlaceholders: true, showFaq: true, showStimmen: false, showSpeakerPdf: false,
|
||||||
|
showSecondTalk: false, show24hPromise: false, showIndustry: false, showTwins: true } as const;
|
||||||
|
export const routes = { home: "/", coaching: "/leistungen/coaching/", trainings: "/leistungen/trainings/",
|
||||||
|
keynotes: "/leistungen/keynotes/", impressum: "/impressum/", datenschutz: "/datenschutz/" } as const;
|
||||||
|
export const mailto = { kennenlernen: "mailto:info@anelia-wolf.com?subject=Kennenlerngespr%C3%A4ch",
|
||||||
|
training: "mailto:info@anelia-wolf.com?subject=Training%20anfragen", keynote: "mailto:info@anelia-wolf.com?subject=Keynote%20anfragen" } as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 `src/lib/assets.ts` (F2, nur in Server-Komponenten importieren)
|
||||||
|
```ts
|
||||||
|
export function hasPublicFile(relPath: string): boolean; // fs.existsSync(path.join(process.cwd(), "public", relPath))
|
||||||
|
export function getPhotos(): { hero: string | null; portrait: string | null; stage: string | null };
|
||||||
|
export function getLogoSvg(): string | null; // Inhalt von public/brand/logo-mark.svg oder null
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 `src/lib/legal.ts` (F2)
|
||||||
|
```ts
|
||||||
|
export const LEGAL_TODO: string[]; // offene Pflichtangaben
|
||||||
|
export function ph(label: string): string; // liefert "[label]" – Platzhalter-Text
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 Content (F2) – `src/content/*.ts`, alle `as const`, Typen exportieren
|
||||||
|
- `home.ts`: `hero`, `positioning`, `services`, `principle`, `about`, `audience`, `contact` (+ `termine`) – Copy 1:1 aus Spec §6.
|
||||||
|
- `coaching.ts`, `trainings.ts`, `keynotes.ts`: `{ meta: {title, description}, masthead, eyebrow, h1: {before, em, after},
|
||||||
|
subline, cta: {label, href, suffix?, micro?}, facts: {label, value}[], situations: {h2, rows[]}, questions?, modules,
|
||||||
|
rahmen, ablauf, faq, band: {h2, cta, micro} , ... }` – Copy 1:1 aus Spec §7.
|
||||||
|
- `legal.ts`: Impressum- und Datenschutz-Abschnitte (`{ h2, paragraphs[] }[]`) aus `docs/superpowers/specs/legal-templates.md`,
|
||||||
|
Platzhalter über `ph()`; kein OS-Plattform-Link; „Bezeichnung WOLF-Prinzip“.
|
||||||
|
|
||||||
|
### 2.6 Motion-Primitive (F1) – `src/components/motion/`
|
||||||
|
```tsx
|
||||||
|
<Rule variant?: "gold" | "line" = "line"; origin?: "left" | "center" | "right" | number /* px vom linken Rand */;
|
||||||
|
play?: "view" | "load" = "view"; delay?: number; className?: string /> // 1px, scaleX 0→1, once
|
||||||
|
<Lines as?: "h1"|"h2"|"h3"|"p"|"div"; play?: "view" | "load"; delay?; stagger?; className?>children</Lines>
|
||||||
|
// SplitText lines + mask; wartet document.fonts.ready (Timeout 1.5s); reduce → statisch; setzt aria-label
|
||||||
|
<Reveal as?; play?: "view"|"load"; delay?; y?=16; className?>children</Reveal> // motion whileInView, once
|
||||||
|
<RevealGroup stagger?=0.06 delay? className?><RevealItem as? className?>…</RevealItem></RevealGroup>
|
||||||
|
<Draw play?: "load" | "view" | "scrub"; delay?; duration?; sheen?: boolean; onComplete?; className?>{svg}</Draw>
|
||||||
|
// animiert alle [data-draw]-Pfade im Kind (stroke-dasharray/offset, pathLength=1); scrub: ScrollTrigger auf eigenem Element
|
||||||
|
<Door className? parallax?: boolean; priority?: boolean; src alt width height sizes? /> // <img> in clip-path-Maske, scale 1.06→1, 6% Parallax desktop
|
||||||
|
<ThemeTween /> // einmal pro Seite (template.tsx): tweent body-Variablen --bg --fg --fg-muted --accent --line --surface an [data-theme]-Grenzen (start "top 70%", end "top 30%", scrub 0.4)
|
||||||
|
<ScrollProgress /> // 1px --accent Linie unter dem Header
|
||||||
|
<Magnet strength?=0.3 className?>children</Magnet> // nur desktop+no-preference, ref + ein rAF
|
||||||
|
<TransitionLink href label? className? …LinkProps /> // Vorhang + router.push; Modifier/extern/Anker → normal
|
||||||
|
<Curtain /> // einmal in layout.tsx; hört auf transition-store
|
||||||
|
// src/components/motion/transition-store.ts: startTransition(href, label), subscribe(cb)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.7 Brand (F1) – `src/components/brand/`
|
||||||
|
```tsx
|
||||||
|
<BrandMark className? idPrefix? variant?: "full" | "v-only" /> // Server: getLogoSvg() ? inline SVG (Pfade bekommen data-draw, Klassen wie WolfMark) : <WolfMark/>
|
||||||
|
<DrawMark play? sheen? handoff?: boolean className?>{<BrandMark/>}</DrawMark> // Client-Hülle um BrandMark: Draw-Sequenz V-links → V-rechts → Wolf → (handoff-Linie nach unten) → Sheen
|
||||||
|
<Wordmark size?: "nav" | "footer"; href?="/" ; className? /> // HTML-Wordmark ANELIA WOLF + Regel + Unterzeile
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.8 Layout (F1) – `src/components/layout/`
|
||||||
|
```tsx
|
||||||
|
<Header /> // Client; fixed; Zeile 1 Masthead (≥1024, kollabiert nach 80px), Zeile 2 Logo | 3 Nav-Links | Kontakt + Button (≥1280) | Menü (<1024)
|
||||||
|
<Masthead center: string; className? /> // drei Slots (Rolle | center | „Deutsch & Englisch“), Hairlines oben/unten
|
||||||
|
<OverlayMenu open onClose /> // Fullscreen, nummerierter Ledger, Fokus-Trap, Esc, inert
|
||||||
|
<Footer /> // Server; identisch auf allen Seiten
|
||||||
|
<Section id theme: "dark"|"light"; numeral?: string; total?: string; label?: string; className?; as?>children</Section>
|
||||||
|
// <section data-theme id> mit Container + 12er-Grid; sticky Mono-Ziffer links (≥1024) bzw. Zeile oben (<1024); Kinder im Grid (col-span über className)
|
||||||
|
<Container className?>children</Container> // max-w-[105rem] px-[clamp(20px,5vw,80px)]
|
||||||
|
<Folio current total next?: {label, href} className? /> // Hairline + „01 / 06“ + „Positionierung ↓“
|
||||||
|
```
|
||||||
|
`src/app/template.tsx` (F1): `motion.main#inhalt` opacity/y-Enter 0.6s, `onAnimationComplete → ScrollTrigger.refresh()`, mountet `<ThemeTween/>`, scrollt Lenis auf 0 bei Pfadwechsel.
|
||||||
|
`src/app/layout.tsx` (F1 erweitert): Inline-Script `html.js`, `<Header/>`, `{children}`, `<Footer/>`, `<Curtain/>`, `<ScrollProgress/>`, `MotionConfig reducedMotion="user"`.
|
||||||
|
|
||||||
|
### 2.9 UI / Editorial (F2) – `src/components/ui/`
|
||||||
|
```tsx
|
||||||
|
<Eyebrow className?>text</Eyebrow> // Jost 500 caps ls .28em text-accent, 24px-Tick ::before
|
||||||
|
<Numeral value="01" className? /> // Bodoni 400 opsz 96, .gold-text auf dark / text-gold-700 auf light
|
||||||
|
<Marginalia items: {label: string; value: string; href?: string}[] className? />
|
||||||
|
<Ledger variant: "toc"|"werdegang"|"termine"|"situations"|"goals"; rows: LedgerRow[]; className? />
|
||||||
|
// toc-Row: {numeral, title, oneLiner, keywords[], link:{label, href}}; werdegang: {keyword, text}; termine: {date, format, title, venue, href?};
|
||||||
|
// situations: {numeral, text}; goals: {text}. toc-Zeile = eine <TransitionLink>; Hover ohne Reflow.
|
||||||
|
<ModuleTable modules: {title, goal, contents[], audience, meta}[] /> // Kopfzeile + <details> pro Modul, + → × per CSS
|
||||||
|
<SpecTable blocks: {title, goal, contents, audience, meta, tracks?: string[]}[] />
|
||||||
|
<Facts items: {label, value}[] className? /> // Mono-Stack mit Goldticks (Hero rechts oben)
|
||||||
|
<FactsStrip items: {label, value}[] /> // volle Breite, vertikale Hairlines (Speaker-Facts)
|
||||||
|
<Faq items: {q, a}[] /> // native details/summary
|
||||||
|
<Steps items: {numeral, title, text}[] />
|
||||||
|
<ContactLinks /> // Schreib mir / Ruf an / Vernetzen (aus site.ts)
|
||||||
|
<Columns items: {label, title, lines[], link:{label, href}}[] offsetMiddle? />
|
||||||
|
<Formula lines: {op?: "+"|"=", text, em?: string, goldEm?: boolean, definition, delivers}[] signoff link /> // WOLF-Prinzip; Pin nur desktop
|
||||||
|
<Quote text author role? />
|
||||||
|
<Portrait src alt aspect?: "4/5"|"3/5"|"3/4"|"16/10"; caption? sticky? priority? className? /> // nutzt <Door/>; rendert null bei src null
|
||||||
|
<Button href label suffix? micro? variant?: "primary"|"ghost" external? className? /> // Outline, Sheen-Hover, Mono-Suffix aus Maske
|
||||||
|
<TextLink href label external? className? /> // caps + →, Unterstreichung wächst von links
|
||||||
|
<CtaBand h2: {before, em, after}; cta: ButtonProps; micro? /> // dunkles Band inkl. <ContactLinks/>
|
||||||
|
<Interruption text mono? /> // 100svh ink, riesige Display-Zeile
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.10 Services gemeinsam (F2) – `src/components/services/`
|
||||||
|
```tsx
|
||||||
|
<ServiceHero content={coaching|trainings|keynotes} photo?: string|null /> // Masthead(center), Eyebrow, H1 mit <em>, Subline, Button, Facts rechts, Folio 01/03
|
||||||
|
<ServiceLayout>{children}</ServiceLayout> // Sektionen hell mit Section-Wrapper; Kontakt-Band separat
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.11 Seiten (Phase 2)
|
||||||
|
- `src/app/page.tsx` + `src/components/home/{Hero,Positioning,ServicesIndex}.tsx` (P1)
|
||||||
|
- `src/components/home/{WolfPrinciple,About,Audience,ProcessContact}.tsx` (P2)
|
||||||
|
- `src/app/leistungen/coaching/page.tsx` + `src/components/services/coaching/*` (P3)
|
||||||
|
- `src/app/leistungen/trainings/page.tsx` + `src/components/services/trainings/*` (P4)
|
||||||
|
- `src/app/leistungen/keynotes/page.tsx` + `src/components/services/keynotes/*` (P5)
|
||||||
|
- `src/app/impressum/page.tsx`, `src/app/datenschutz/page.tsx`, `src/app/not-found.tsx`, `src/app/sitemap.ts`,
|
||||||
|
`src/app/robots.ts`, `src/app/icon.svg` (P6)
|
||||||
|
|
||||||
|
## 3. Phasen
|
||||||
|
|
||||||
|
1. **Foundation** (parallel): F1 = 2.1, 2.6, 2.7, 2.8 + `template.tsx` + `layout.tsx`-Erweiterung.
|
||||||
|
F2 = 2.2–2.5, 2.9, 2.10 + `src/hooks/useMediaQuery.ts`.
|
||||||
|
2. **Integration 1**: `tsc` + Verträge angleichen, `npm run build` grün mit Platzhalter-Startseite.
|
||||||
|
3. **Pages** (parallel): P1–P6.
|
||||||
|
4. **Integration 2**: `tsc`, `lint`, `build`; alle 7 Routen im `out/`.
|
||||||
|
5. **Visual QA** (Loop bis trocken, max. 3 Runden): Dev-Server über `.claude/launch.json` (`anelia-dev`),
|
||||||
|
Screenshots 1440×900 + 390×844, Findings → Fix-Agenten (disjunkte Dateien) → erneut.
|
||||||
|
6. **Review**: A11y, Performance/Static-Export, Datenschutz (keine externen Requests, kein Storage), Copy-Check
|
||||||
|
gegen verbotene Phrasen → Fixes → finaler Build.
|
||||||
701
docs/superpowers/specs/2026-09-02-anelia-wolf-website-design.md
Normal file
701
docs/superpowers/specs/2026-09-02-anelia-wolf-website-design.md
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
# Anelia Wolf – Website Design-Spec („Die Ausgabe“)
|
||||||
|
|
||||||
|
Stand: 2026-09-02 · webklar (Kenso) mit Claude · Status: verbindliche Build-Grundlage
|
||||||
|
|
||||||
|
Entstanden aus Research (Brand-Fakten, Awwwards/CSSDA 2024–2026, React Bits/21st.dev/Magic UI,
|
||||||
|
Typografie/Palette, Nischen- und Rechts-Research), drei unabhängigen Kreativkonzepten und einer
|
||||||
|
dreiköpfigen Jury (Art Director, Frontend-Engineer, B2B-Einkäuferin). Die Synthese der Jury folgt ab
|
||||||
|
Abschnitt „Final Creative Direction“ unverändert (englische Spec-Prosa, deutsche Copy).
|
||||||
|
Rohdaten: `scratchpad/research-dump.json`, Rechtstext-Vorlagen: `scratchpad/legal-templates.md`.
|
||||||
|
|
||||||
|
## A. Verbindliche Abweichungen gegenüber der Synthese (gelten vorrangig)
|
||||||
|
|
||||||
|
1. **Display-Serif = Bodoni Moda** (variable, `opsz` 6–96, normal + italic), NICHT Cormorant Garamond.
|
||||||
|
Grund: Jury 1 („Cormorant + Champagner-Gold + Elfenbein ist die 2024–26-Coach-Default-Palette“);
|
||||||
|
die Synthese nennt Bodoni Moda selbst als dokumentierten Ein-Zeilen-Swap. Bodoni ist die Didone
|
||||||
|
des Mode-Mastheads (Vogue-Logik = Magazin-Konzept), ihre Haarlinien rhythmisieren mit dem goldenen
|
||||||
|
Einzelstrich des Wolfs, und sie ist deutlich seltener in KI-generierten Seiten. Überall, wo die
|
||||||
|
Synthese „Cormorant“ sagt, gilt: Bodoni Moda 400 (Display `opsz` 96, H2 `opsz` 72, ≥ 28 px `opsz` 36),
|
||||||
|
Italic für genau ein Betonungswort pro Headline und Zitate. **Bodoni nie unter 28 px**; alles
|
||||||
|
darunter (H3 < 28 px, Small-Caps-Namen, Ledger-Titel auf Mobile) in Jost 400/500.
|
||||||
|
2. **Fonts über `next/font/google`** (Jost variable, DM Mono 400/500, Bodoni Moda variable normal+italic
|
||||||
|
mit `axes: ["opsz"]`) – werden im Build eingebettet, kein Google-Request (verifiziert). Kein
|
||||||
|
`next/font/local`, keine woff2 im Repo. CSS-Variablen: `--font-jost`, `--font-mono-face`, `--font-bodoni`;
|
||||||
|
Tailwind-Tokens `font-sans`, `font-mono`, `font-display`.
|
||||||
|
3. **Lenis:** der vorhandene `SmoothScroll`-Provider (`src/components/providers/SmoothScroll.tsx`,
|
||||||
|
ticker-getrieben, `window.lenis`) bleibt; kein `lenis/react`. Route-Wechsel: `lenis.scrollTo(0, {immediate: true})`.
|
||||||
|
4. **Theme-Attribute:** `data-theme="dark" | "light"` (bereits in `globals.css`), nicht „ink/ivory“.
|
||||||
|
Live-Paar `--bg/--fg/--fg-muted/--accent/--line/--surface` + Tailwind-Tokens `bg-bg text-fg text-accent border-line bg-surface`.
|
||||||
|
5. **Korn:** statische CSS-Pseudo-Ebene `.grain` (bereits in `globals.css`), kein Canvas, kein Blend-Mode-Wechsel per JS.
|
||||||
|
6. **Monogramm:** `public/brand/logo-mark.svg` wird zur Build-Zeit geprüft (`fs.existsSync`); vorhanden →
|
||||||
|
inline mit `data-draw`-Pfaden; sonst `src/components/brand/WolfMark.tsx` (eigene SVG-Näherung, Pfade
|
||||||
|
`.wolfmark-v` / `.wolfmark-wolf[data-draw]`, Gradient `#wolf-gold`). Der Wolf-Hals-Handoff in die
|
||||||
|
erste Hairline gilt für beide Varianten (Hairline startet an der unteren Kante des Monogramms).
|
||||||
|
7. **Fotos:** Existenz zur Build-Zeit über `src/lib/assets.ts` (`fs.existsSync("public/photos/…")`);
|
||||||
|
fehlende Bilder → dokumentierte Variante B (ohne Foto). Keine Laufzeit-Fetches.
|
||||||
|
8. **Flags** in `src/lib/site.ts`: `flags = { showPlaceholders: true /* bis Launch */, showFaq: true,
|
||||||
|
showStimmen: false, showSpeakerPdf: false, showSecondTalk: false, show24hPromise: false, showIndustry: false, showTwins: true }`.
|
||||||
|
FAQ wird mit den in der Synthese formulierten Antworten ausgeliefert (Preise = „auf Anfrage“).
|
||||||
|
9. **Platzhalter im Impressum/Datenschutz** werden sichtbar als `[…]` in `--gold-deep` gerendert (nicht
|
||||||
|
ausgeblendet), damit sie vor Launch nicht übersehen werden; Liste in `src/lib/legal.ts` (`LEGAL_TODO`).
|
||||||
|
10. **Component-Libraries:** React-Bits-/Magic-UI-Komponenten werden NICHT installiert, sondern die
|
||||||
|
fünf Primitive (Rule, Lines, Reveal, Draw, Door) werden in-house mit GSAP 3.15 (SplitText, DrawSVG,
|
||||||
|
ScrollTrigger sind frei) und `motion/react` gebaut. Scroll-Progress-Hairline in-house.
|
||||||
|
11. **Routen:** `/`, `/leistungen/coaching/`, `/leistungen/trainings/`, `/leistungen/keynotes/`,
|
||||||
|
`/impressum/`, `/datenschutz/`, 404. Nav-Konstanten in `src/lib/site.ts`.
|
||||||
|
12. **Copy:** die Synthese-Copy ist final; „Medizinprodukte“ bleibt weg (`showIndustry: false`),
|
||||||
|
„B2B-Außendienst“ bleibt drin, Zwillinge-Satz bleibt drin (Freigabe in Offene Fragen), „Unlock your
|
||||||
|
Sales-DNA“ bleibt weg, „Antwort innerhalb von 24 Stunden“ bleibt weg.
|
||||||
|
|
||||||
|
## B. Erfolgskriterien & QA (vor „fertig“)
|
||||||
|
1. `npx tsc --noEmit`, `npm run lint`, `npm run build` grün; `out/` enthält alle 7 Routen.
|
||||||
|
2. Browser-Screenshots 1440×900 und 390×844 jeder Seite (Hero, Mitte, Footer, Menü offen, 404) geprüft.
|
||||||
|
3. `prefers-reduced-motion`: alles sichtbar, kein Pin, kein Vorhang, Wolf fertig gezeichnet.
|
||||||
|
4. Netzwerk: keine Requests außer eigener Origin; kein `localStorage`/`sessionStorage`/`document.cookie` im Bundle.
|
||||||
|
5. Kontrast: Gold-Text nur auf dunkel, `--gold-deep` auf hell; Headline über Foto mit Scrim.
|
||||||
|
6. AI-Tell-Check und Handgemacht-Signale gemäß Abschnitt 11 der Synthese.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ANELIA WOLF — Final Creative Direction
|
||||||
|
|
||||||
|
**Direction:** DIE AUSGABE (final cut) — the editorial concept, with the wolf-to-line handoff, door-mask photography and the offer-table/speaker-facts blocks grafted in from the two other directions.
|
||||||
|
**Decision basis:** 2 of 3 judges chose DIE AUSGABE (engineer 48, strategist 48); the art director chose Der Goldene Faden by one point (44 vs 43) but conditioned the win on a half-day spike of the site-wide thread and named DIE AUSGABE's skeleton as the fallback. The final direction takes the AUSGABE skeleton, imports every "best idea to keep" the judges flagged where it doesn't add a fragile mechanism, and resolves every critique below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Judge critiques → resolutions
|
||||||
|
|
||||||
|
| # | Critique (judge) | Resolution in this spec |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Magazine meta-language ("Kapitel 01 — Vorwort", "Heft II", "Folio 03/06") taxes B2B readers (all three) | The magazine is the **internal** logic only. Reader-facing text never says Kapitel/Heft/Folio/Vorwort. Sections carry a mono numeral (`01`–`06`) and a plain eyebrow ("Positionierung", "Leistungen", "Das WOLF-Prinzip", "Über Anelia", "Für wen", "Kontakt"). Service pages have no roman numerals; the masthead middle slot shows the plain page name. The folio counter shrinks to `01 / 06` in mono, bottom right, no words. |
|
||||||
|
| 2 | "Drei Leserinnen, drei Leser." is designer jargon (strategist) | H2 is **„Für wen ich arbeite.“** |
|
||||||
|
| 3 | "Drei Fragen. Ein Prinzip." followed by "Drei Wege. Ein Prinzip." is a copy tic (strategist) | Positionierung keeps „Drei Fragen. Ein Prinzip.“; Leistungen becomes **„Coaching. Training. Bühne.“** |
|
||||||
|
| 4 | "Ein Impuls, der am Montag noch trägt/beschäftigt" is a trainer cliché (strategist) | Removed everywhere. Replaced by „Ein Impuls, der nachwirkt.“ (once, keynote page) and otherwise concrete nouns. |
|
||||||
|
| 5 | Headline running over the portrait fails contrast on the photo half (engineer) | Portrait gets a horizontal ink scrim in the overlap zone (`linear-gradient(90deg, var(--ink) 0%, rgba(15,14,12,.72) 30%, transparent 60%)`) in addition to the top-third darkening; headline glyphs over the photo are checked at ≥ 4.5:1; below 1280px the headline is capped at columns 1–8 so it no longer crosses the face. |
|
||||||
|
| 6 | Ledger hover "title flips to italic" reflows the row (engineer) | Roman and italic titles are rendered as two stacked layers in one grid cell (`grid-area: 1/1`), crossfaded by opacity; the cell width is the wider of the two. No reflow. |
|
||||||
|
| 7 | Tweening `--bg` on `<body>` every frame + blended grain = full-page composite (engineer, Faden) | Only `--bg`/`--fg` on `<body>` are tweened, only within a 40vh window per boundary (scrub 0.4). The grain layer is its own compositor layer (`position: fixed; inset: 0; transform: translateZ(0); contain: strict; pointer-events: none`). `mix-blend-mode: overlay` is applied only at ≥ 1024px and `(pointer: fine)`; on touch the grain is a plain 5% opacity layer. Must be tested on a mid-range Android before launch. |
|
||||||
|
| 8 | LCP / no-JS: masked hero lines invisible until hydration (engineer) | Server HTML is fully visible. A 3-line inline `<head>` script adds `html.js`. Mask/clip initial states apply only under `html.js`. Hero timeline starts on `document.fonts.ready` with a 1.5 s timeout fallback. Hero portrait carries `priority` + `fetchpriority="high"`. |
|
||||||
|
| 9 | Sticky numeral / eyebrow colour must flip with the body tween (engineer) | Every section carries a static `data-theme="ink|ivory"`. Colour of numerals, eyebrows, hairlines, links and header is driven by `[data-theme]` selectors, never by the tween. The tween only crossfades the page ground. |
|
||||||
|
| 10 | Roman numerals / "Heft I" must never be the primary way to find "Trainings" (engineer) | Removed. Nav, ledger, footer, menu and folio always show the service names in full. |
|
||||||
|
| 11 | Five font files, subset & preload (engineer) | `next/font/local`, 5 woff2 files subset to latin + latin-ext (~210 KB total); preload only Cormorant 300 and Jost variable; DM Mono and Cormorant italic load lazily. Geist is removed from `layout.tsx`. |
|
||||||
|
| 12 | Cormorant + gold + ivory is the default "luxury coach" palette of 2024–26 (art director) | Cormorant is restricted to display ≥ 40 px, one italic word per headline, and quotes. Everything else — nav, eyebrows, buttons, wordmark, ledgers, body, meta — is Jost caps and DM Mono, so the page surface is dominated by Futura-genome caps and tabular mono ledgers, not by serif. H2 on ivory is 400, never 300. Ledger and table share on every page is ≥ 40% of viewports. If the client wants a harder edge after the first mockup, the documented fallback is Bodoni Moda (opsz 96) as a one-line swap in `fonts.ts` — no layout change. |
|
||||||
|
| 13 | Concept risks tipping toward beauty/wedding (art director, Ausgabe's own risk) | Counterweights are mandatory, not optional: dark hero, DM Mono ledgers, hard rectangles (radius 0 everywhere), B2B nouns in the first viewport (Vertriebstraining, Neukunden, Bestandskunden), the spec-table service pages, the Speaker-Facts strip. |
|
||||||
|
| 14 | Hover-only keywords hide information on desktop (art director, Faden) | Ledger keywords are always visible (they were in Ausgabe; the rule is now explicit). Nothing on the site is hover-only content. |
|
||||||
|
| 15 | Thread is the best idea but fragile (all three) | The site-wide thread is **not** built. Its two safe fragments are: (a) `::before` hairline ticks before every eyebrow (no-JS baseline), (b) a local wolf-neck → hairline handoff inside the hero SVG. No document-height SVG, no anchor measuring. |
|
||||||
|
| 16 | W·OLF hero + header migration is the best single Auftritt idea, but FLIP + 3 pins won't ship in 2 days (engineer, strategist) | Not adopted. Documented in §13 as a v2 candidate. The monogram already appears in the header from first paint on all pages. |
|
||||||
|
| 17 | Nebentätigkeit / employer naming, years, "Unlock your Sales-DNA", "Antwort in 24h" (all) | Copy ships without employer, without industry unless approved ("B2B-Außendienst"), without years. The second talk title and the 24-hour promise are gated behind a build flag and listed in §12. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Direction name & narrative
|
||||||
|
|
||||||
|
**DIE AUSGABE — Anelia Wolf, Ausgabe 01**
|
||||||
|
|
||||||
|
Anelia Wolf's logo is already a printed title page: a wordmark in wide-tracked Futura caps, a single gold hairline, a sub-line. None of it is web aesthetics; all of it is print. The website takes the logo literally and is built as the first issue of a magazine with one protagonist — but the reader never needs to know that. What the reader sees is a dark cover with a headline that runs across her portrait, ivory reading pages with sticky numerals in the gutter, a table of contents that is also the service navigation, the WOLF-Prinzip typeset as an equation, a Werdegang typeset as a ledger, and a colophon that is her own wordmark. What the reader never sees is an icon card, a testimonial carousel, a counter, a gradient blob or a glow.
|
||||||
|
|
||||||
|
Her brand lives on two poles that coach websites never reconcile: feminine-premium (Zahntechnik, Premium-Retail, Keynote) and hard-concrete (Kaltakquise, Außendienst, Umsatz). Print has carried that tension for a hundred years — Cormorant at 300 next to tabular DM Mono figures, ivory paper next to a night-black spread. The structure stays "schlicht" (seven sections, three service pages, two legal pages); the WOW comes from precision: typesetting, rhythm, and a choreography in which everything moves like type being laid onto paper — masks, lines that draw, pages that turn.
|
||||||
|
|
||||||
|
Gold is rationed like gold leaf: the wolf's stroke, hairlines, numerals, eyebrows, one gradient word per page. The wolf is drawn once on load — left V, right V, then the head — and its neck runs, without a break, into the first hairline of the page: the logo is the source of the page, not an ornament placed on it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Palette, gold gradient, section rhythm
|
||||||
|
|
||||||
|
### 2.1 Tokens (`@theme` in `globals.css`)
|
||||||
|
|
||||||
|
| Token | Hex | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| `--ink` | `#0F0E0C` | Dark page ground (hero, Prinzip, Kontakt/Footer, service-page heroes, contact bands). Never `#000`. |
|
||||||
|
| `--ink-2` | `#191714` | Raised dark surface: header after scroll on ink sections, overlay-menu frame, hover row on dark ledgers. |
|
||||||
|
| `--line-dark` | `#2A2722` | 1 px hairlines on ink. Decorative only. |
|
||||||
|
| `--ivory` | `#F6F1E8` | Paper. Ground of all reading sections and legal pages. |
|
||||||
|
| `--bone` | `#EEE6D8` | Termine ledger block, quote blocks, Rahmen tables, ledger row hover on ivory. Always with 1 px `--line-light`. |
|
||||||
|
| `--line-light` | `#DCD3C3` | 1 px hairlines on ivory/bone. |
|
||||||
|
| `--grey-warm` | `#A8A196` | Secondary text on dark (7.54:1 on ink). |
|
||||||
|
| `--grey-deep` | `#5E5A52` | Secondary text on light (6.10:1 on ivory). |
|
||||||
|
| `--gold` | `#C9A24D` | Flat gold: text, strokes, hairlines, focus rings on **dark only** (8.05:1). Hover fill of the primary button (ink text, 8.05:1). |
|
||||||
|
| `--gold-deep` | `#7D5F1B` | Gold for text/UI on **light** (5.30:1 on ivory, 4.81:1 on bone). |
|
||||||
|
| `--gold-700` | `#A37A2C` | Large display numerals on ivory (≥ 40 px only, 3.47:1 = AA-large). |
|
||||||
|
| `--gold-100` | `#F4E5B8` | Champagne gradient stop 0 % / sheen colour. |
|
||||||
|
| `--gold-200` | `#E6C97A` | Gradient stop 22 %. |
|
||||||
|
| `--gold-900` | `#6E501A` | Gradient stop 100 %; strokes/surfaces only, never text. |
|
||||||
|
|
||||||
|
Contrast rules that are enforced as lint-able CSS: `--gold` is never used as `color` inside `[data-theme="ivory"]`; `.gold-text` is only permitted on `[data-theme="ink"]` and at `font-size >= 2.5rem`.
|
||||||
|
|
||||||
|
### 2.2 Gold gradient CSS
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* metallic ramp — SVG strokes, monogram, surfaces (never text) */
|
||||||
|
--gold-gradient: linear-gradient(135deg, #F4E5B8 0%, #E6C97A 22%, #C9A24D 48%, #A37A2C 74%, #6E501A 100%);
|
||||||
|
/* text-safe ramp — stops at --gold-700 (4.94:1 on ink); display ≥ 40px on dark only */
|
||||||
|
--gold-gradient-text: linear-gradient(100deg, #F4E5B8 0%, #E6C97A 32%, #C9A24D 62%, #A37A2C 100%);
|
||||||
|
/* 1px hairline with champagne centre, fading ends — dark grounds */
|
||||||
|
--gold-hairline: linear-gradient(90deg, transparent 0%, #C9A24D 18%, #F4E5B8 50%, #C9A24D 82%, transparent 100%);
|
||||||
|
/* 1px hairline for light grounds — solid ends */
|
||||||
|
--gold-hairline-light: linear-gradient(90deg, #A37A2C 0%, #C9A24D 35%, #E6C97A 50%, #C9A24D 65%, #A37A2C 100%);
|
||||||
|
/* hover sweep for the primary CTA: background-size 250%, background-position 0% → 100% in .6s */
|
||||||
|
--gold-sheen: linear-gradient(115deg, #A37A2C 0%, #C9A24D 30%, #F4E5B8 50%, #C9A24D 70%, #A37A2C 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gold-text {
|
||||||
|
color: #C9A24D;
|
||||||
|
background: var(--gold-gradient-text);
|
||||||
|
-webkit-background-clip: text; background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* SVG monogram gradient (inline <defs>) */
|
||||||
|
/* <linearGradient id="wolf-gold" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#F4E5B8"/>
|
||||||
|
<stop offset=".22" stop-color="#E6C97A"/>
|
||||||
|
<stop offset=".48" stop-color="#C9A24D"/>
|
||||||
|
<stop offset=".74" stop-color="#A37A2C"/>
|
||||||
|
<stop offset="1" stop-color="#6E501A"/>
|
||||||
|
</linearGradient> */
|
||||||
|
```
|
||||||
|
|
||||||
|
Gradient directions are fixed (135° strokes, 100° text, 90° hairlines) so every gold element reads as lit from top-left — that consistency is what makes it metal instead of a web gradient.
|
||||||
|
|
||||||
|
### 2.3 Theme mechanics
|
||||||
|
|
||||||
|
- Every `<section>` carries `data-theme="ink"` or `data-theme="ivory"`. All colours of text, numerals, eyebrows, hairlines, links, buttons, focus rings and the header are selected via `[data-theme]` — static, no JS.
|
||||||
|
- `<body>` carries `--bg` and `--fg`. One GSAP ScrollTrigger per boundary (start `top 70%`, end `top 30%`, scrub 0.4) tweens `--bg`/`--fg` between `--ink` and `--ivory`. That is the whole "breathing" effect. Sections themselves have `background: transparent`; only the body paints.
|
||||||
|
- At each boundary a 1 px hairline (`--gold-hairline` entering ink, `--gold-hairline-light` entering ivory) draws `scaleX 0→1` from the left (0.9 s, expo-out) when it enters the viewport — "a sheet being laid on".
|
||||||
|
- Paper grain: static SVG `feTurbulence` (baseFrequency .8, 2 octaves) as a fixed pseudo-layer, opacity .05 on ivory / .07 on ink, `mix-blend-mode: overlay` only on desktop fine-pointer, plain opacity on touch. No canvas.
|
||||||
|
|
||||||
|
### 2.4 Section rhythm
|
||||||
|
|
||||||
|
**Homepage:** Hero (ink) → Positionierung (ivory) → Leistungen (ivory) → Das WOLF-Prinzip (ink) → Über Anelia (ivory) → Für wen + Termine (ivory, Termine block on bone) → Kontakt + Footer (ink).
|
||||||
|
**Service pages:** Hero (ink, 60svh) → all content (ivory, Rahmen/Termine blocks on bone) → Kontakt band + Footer (ink).
|
||||||
|
**Impressum/Datenschutz:** ivory only, ink text; gold appears only in the footer wordmark rule.
|
||||||
|
**404:** ink.
|
||||||
|
|
||||||
|
Gold budget: ≤ ~5 % of any viewport. Seven jobs only — wolf stroke, hairlines, eyebrows, numerals, arrow glyphs (→ ↗, the only "icons" on the site), link underlines/hover, focus rings. No gold fills, no glows, no shadows, no gold on photos, no gold for error/status states.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Typography
|
||||||
|
|
||||||
|
### 3.1 Families (`src/app/fonts.ts`, `next/font/local`, woff2 committed under `src/app/fonts/`)
|
||||||
|
|
||||||
|
| Role | Family | Files | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Display | Cormorant Garamond | 300, 400, 400 Italic | `--font-display`. Only ≥ 24 px; display ≥ 40 px; body never. |
|
||||||
|
| Text, labels, wordmark | Jost (variable 100–900) | 1 file | `--font-sans`. Futura genome = the logo's letterforms. |
|
||||||
|
| Meta | DM Mono | 400 | `--font-mono`. Tabular numerals, `font-variant-numeric: tabular-nums`. |
|
||||||
|
|
||||||
|
Subset: latin + latin-ext (ä ö ü ß „“ – ·). Preload: Cormorant 300 + Jost. `display: 'swap'`, `adjustFontFallback: true`. Verify after build: `out/_next/static/media/*.woff2` present, zero requests to `fonts.googleapis.com` / `gstatic.com`. Remove `Geist`/`Geist_Mono` from `layout.tsx`.
|
||||||
|
|
||||||
|
### 3.2 Scale (tokens in `@theme`, all fluid, no breakpoint jumps)
|
||||||
|
|
||||||
|
| Style | Family / weight | Size | Leading / tracking / notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| H1 hero | Cormorant 300 | `clamp(3rem, 1.4rem + 7.4vw, 9.5rem)` (48→152 px) | lh 0.94, ls −0.015em, max-width 12ch, `text-wrap: balance`, one word in 400 Italic. Colour `--ivory`. |
|
||||||
|
| H1 service page | Cormorant 300 | `clamp(2.75rem, 1.4rem + 5.4vw, 7rem)` | lh 0.96, max 14ch, one italic word. |
|
||||||
|
| H2 section | Cormorant 300 on ink / **400 on ivory** | `clamp(2.25rem, 1.4rem + 3.2vw, 4.75rem)` (36→76 px) | lh 1.02, ls −0.01em, max 16ch. |
|
||||||
|
| H3 ledger / block title | Cormorant 400 | `clamp(1.5rem, 1.3rem + 0.8vw, 2.125rem)` | lh 1.15. |
|
||||||
|
| Section numeral | Cormorant 300 | `clamp(4rem, 2rem + 8vw, 10rem)` | lh 0.9, `lining-nums`. `.gold-text` on ink, flat `--gold-700` on ivory. |
|
||||||
|
| Formula line | Cormorant 300 | `clamp(2.5rem, 1.2rem + 5.4vw, 7.5rem)` | lh 1.0. |
|
||||||
|
| Quote | Cormorant 400 Italic | `clamp(1.5rem, 1.2rem + 1.4vw, 2.5rem)` | lh 1.25, hanging „ via `text-indent: -0.4em`. |
|
||||||
|
| Editorial paragraph (Positionierung) | Cormorant 300 | `clamp(1.625rem, 1.2rem + 2vw, 3rem)` | lh 1.25. |
|
||||||
|
| Lead | Jost 300 | `clamp(1.25rem, 1.1rem + 0.6vw, 1.5rem)` | lh 1.45, max 38ch. |
|
||||||
|
| Body | Jost 400 | `clamp(1.0625rem, 1.02rem + 0.2vw, 1.125rem)` (17→18 px) | lh 1.6, max-width 36rem (~62ch), paragraph gap 1.25em, `hyphens: auto` (lang="de"). |
|
||||||
|
| Eyebrow | Jost 500 | 0.72rem (11.5 px) | uppercase, ls 0.28em, lh 1; preceded by a 24×1 px gold tick (`::before`). `--gold` on ink / `--gold-deep` on ivory. |
|
||||||
|
| Nav | Jost 400 | 0.8125rem | uppercase, ls 0.16em. |
|
||||||
|
| Button | Jost 500 | 0.8125rem | uppercase, ls 0.2em, padding 1.05em 2.1em, 1 px border, radius 0. |
|
||||||
|
| Wordmark name | Jost 300 (350 via axis if the logo looks heavier) | nav 1.0625rem / footer `clamp(1.5rem, 1rem + 2vw, 2.75rem)` | uppercase, ls 0.32em, `padding-left: 0.32em` (optical centring). |
|
||||||
|
| Wordmark sub | Jost 400 | 0.5em of name | uppercase, ls 0.22em; reduce to 0.18em if wider than the name, never shrink the name. |
|
||||||
|
| Meta / mono | DM Mono 400 | 0.75rem (12 px) | ls 0.06em, tabular-nums. Folio, marginalia, ledger keywords, phone, dates, formats. |
|
||||||
|
| Small caps names | Cormorant 500 | inherits | `font-feature-settings: "smcp" 1`, ls 0.08em. |
|
||||||
|
|
||||||
|
Micro-typography: German quotes „…“, en-dashes with spaces, no bold anywhere, `-webkit-font-smoothing: antialiased` on ink, Cormorant never below 24 px, H1 never below 40 px, max one italic word per headline, emphasis is always italic never colour, exactly one `.gold-text` word per page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Motion system
|
||||||
|
|
||||||
|
### 4.1 Principle
|
||||||
|
|
||||||
|
Everything moves like type being laid on paper: clip-path masks, hairlines that draw, lines that rise out of a mask, a sheet that turns. No scale on hover, no bounce, no blur on paragraphs, no autoplay marquee, no cursor replacement.
|
||||||
|
|
||||||
|
### 4.2 Easings & durations
|
||||||
|
|
||||||
|
- Enter: `[0.22, 1, 0.36, 1]` (expo-out). Curtain/wipes: `power3.inOut`. Scrubbed: `none`.
|
||||||
|
- Hairline `scaleX`: 0.9 s. Line reveal: 0.9 s, stagger 0.08 s. Block fade (`opacity 0→1, y 16→0`): 0.6 s, stagger 0.06. Wolf draw: 1.2 s. Wolf sheen: 0.8 s, once. Door mask (photos): 1.1 s. Umblättern: 0.45 s in + 0.6 s enter + 0.45 s out ≤ 900 ms total. Hover states: 0.3–0.35 s. Nothing over 1.2 s except scrubbed.
|
||||||
|
|
||||||
|
### 4.3 Primitives (exactly five, site-wide; nothing else is invented per section)
|
||||||
|
|
||||||
|
1. `<Rule/>` — 1 px hairline, `transform-origin: left`, `scaleX 0→1` at in-view (`top 85%`, once). Accepts `origin="x"` so the hero folio line can draw outward from the wolf handoff point.
|
||||||
|
2. `<Lines/>` — React Bits SplitText (`type: 'lines', mask: 'lines', autoSplit: true`, waits on `document.fonts.ready`), each line `yPercent 100→0`, start `top 82%`, once.
|
||||||
|
3. `<Reveal/>` — motion `whileInView`, `opacity 0→1, y 16→0`, `staggerChildren 0.06`, `viewport={{ once: true, margin: '-10%' }}`. Used for ledger rows, paragraphs, columns.
|
||||||
|
4. `<Draw/>` — `motion.path` `pathLength 0→1` (or GSAP DrawSVG when scrubbed), gradient stroke, `vector-effect: non-scaling-stroke`.
|
||||||
|
5. `<Door/>` — image mask `clip-path: inset(100% 0 0 0) → inset(0)`, 1.1 s, image `scale 1.06→1`; optional 6 % parallax (`yPercent -6→6`, scrub 0.6, desktop only).
|
||||||
|
|
||||||
|
### 4.4 Scroll choreography (Lenis + GSAP)
|
||||||
|
|
||||||
|
- Lenis: `lerp 0.1`, `autoRaf: false`, driven by `gsap.ticker` (`lagSmoothing(0)`), `syncTouch: false`, `smoothWheel: !reducedMotion`, `anchors: { offset: -80 }`. One `ReactLenis root` in `layout.tsx`, never in `template.tsx`. `useLenis(() => ScrollTrigger.update())`.
|
||||||
|
- `ScrollTrigger.config({ ignoreMobileResize: true })`. Refresh after: template enter complete, hero image `onLoad`, overlay menu close.
|
||||||
|
- Hero: portrait `y +8 %` parallax (scrub 0.6); headline lines `−6 / −3 / 0 %` (scrub); after 80 px the header gets the current `--bg` at 92 % + 1 px hairline.
|
||||||
|
- Section numerals: CSS `position: sticky; top: calc(50vh - .45em)` — no JS.
|
||||||
|
- Body ground tween at every boundary (see §2.3).
|
||||||
|
- Positionierung paragraph: React Bits ScrollReveal (word opacity 10 %→100 %, `enableBlur: false`, `baseRotation: 1`), desktop ≥ 1024 only; mobile uses `<Lines/>` once.
|
||||||
|
- Das WOLF-Prinzip: the **only pin** (300vh, desktop ≥ 1024 and no-preference only). Mobile and reduced-motion: stacked flow with `<Lines/>`, wolf drawn once at `top 80%`.
|
||||||
|
- Folio counter: one ScrollTrigger per section (`start: 'top 50%'`, `onEnter/onLeaveBack`) writes `01 / 06`. Magic UI Scroll Progress restyled as a 1 px `--gold` line under the header.
|
||||||
|
- Hover (`pointer: fine` only): ledger row = hairline recolours from the left + title crossfades to italic + arrow `translateX 8px`; button = gold fill 0.35 s + mono suffix slides out of a mask; text links = underline grows from the left (`background-size 0→100%`, 0.4 s). Optional React Bits Magnet (strength 3) on the 3–4 primary CTAs only.
|
||||||
|
|
||||||
|
### 4.5 Page transition ("Umblättern")
|
||||||
|
|
||||||
|
`TransitionLink` (wraps `next/link`, `scroll={false}`, lets Cmd/Ctrl/Shift/middle clicks through, ignores double clicks via a global `isTransitioning` flag):
|
||||||
|
|
||||||
|
1. Close the overlay menu if open (`lenis.start()`).
|
||||||
|
2. A fixed ivory sheet (`#curtain`, `translateY(100%)`, 1 px `--gold-hairline-light` at its top edge, monogram 48 px centred) slides up 0.45 s `power3.inOut`. For 400 ms the sheet shows the destination name in DM Mono 12 px `--gold-deep`: „Trainings & Workshops“.
|
||||||
|
3. `router.push(href)`; Lenis `scrollTo(0, { immediate: true, force: true })` on pathname change.
|
||||||
|
4. `template.tsx`: `motion.main` `opacity 0→1, y 16→0`, 0.6 s; `onAnimationComplete` → `ScrollTrigger.refresh()`.
|
||||||
|
5. Sheet continues upward (`yPercent −200`, 0.45 s) and resets. Hard fallback: if the route hasn't changed after 900 ms, navigate anyway.
|
||||||
|
|
||||||
|
The gold edge of the sheet becomes the first hairline of the new page's masthead — one line through the whole site, achieved without measuring anything.
|
||||||
|
|
||||||
|
### 4.6 Reduced motion & touch
|
||||||
|
|
||||||
|
- `<MotionConfig reducedMotion="user">` at the root; `gsap.matchMedia` with `reduce`, `ok`, `desktop` conditions.
|
||||||
|
- Reduce: all end states immediate, no pin, no curtain (direct `router.push`, 200 ms fade), wolf fully drawn, no sheen, no ScrollReveal, Lenis `smoothWheel: false`, no parallax, no Magnet.
|
||||||
|
- Touch (`pointer: coarse`): native scroll, no hover reveals (all content visible anyway), no Magnet, grain without blend mode.
|
||||||
|
- Keyboard: every ledger row is a single `<a>`; focus ring 2 px `--gold` (ink) / `--gold-deep` (ivory), offset 4 px. Skip link „Zum Inhalt“ first in DOM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Global components
|
||||||
|
|
||||||
|
### 5.1 Header (fixed, 72 px desktop / 60 px mobile)
|
||||||
|
|
||||||
|
Grid 12, page margin `clamp(20px, 5vw, 80px)`, max-width 1680 px.
|
||||||
|
|
||||||
|
- **Left (cols 1–3):** Monogram SVG 28 px (static, gradient stroke; one-time sheen on hover) + HTML wordmark „ANELIA WOLF“ (Jost 300, 1.0625rem, ls 0.32em); sub-line hidden < 1024 px. One `<a href="/">` with `aria-label="Anelia Wolf – Startseite"`.
|
||||||
|
- **Centre (cols 4–9, ≥ 1024 px):** three links „Coaching & Analysen“ · „Trainings & Workshops“ · „Keynotes & Speaking“ (Jost 400 13 px caps). Active page: 1 px gold underline. Hover: underline grows from left.
|
||||||
|
- **Right (cols 10–12):** text link „Kontakt“ (`/#kontakt`, Lenis anchor, offset −80) + outline button „Kennenlerngespräch anfragen“ (≥ 1280 px; below that only „Kontakt“) + „Menü“ trigger (< 1024 px; Jost 500 13 px caps + DM Mono glyph `+` → `×`).
|
||||||
|
- States: transparent over the hero; after 80 px, ground = current section ground at 92 % (`[data-theme]`) + 1 px hairline (`--line-dark` / `--line-light`), no blur except the header's own `backdrop-filter: blur(8px)` (the only blur in the UI). Below the header: 1 px gold scroll-progress line.
|
||||||
|
|
||||||
|
### 5.2 Overlay menu („Menü“)
|
||||||
|
|
||||||
|
Ivory sheet from the top (`translateY −100%→0`, 0.5 s, `power3.inOut`), `data-lenis-prevent`, `lenis.stop()`, focus trap, Escape closes, closes on route change. Content: numbered ledger — `01 Coaching & Analysen`, `02 Trainings & Workshops`, `03 Keynotes & Speaking`, `04 Über Anelia` (`/#ueber`), `05 Kontakt` (`/#kontakt`) in Cormorant 300 `clamp(2rem, 1.4rem + 3vw, 4rem)` `--ink`, numerals DM Mono `--gold-deep`, rows hairline-separated, staggered out of clip masks (0.06). Bottom: phone as `tel:` link in Cormorant 24 px, e-mail, „LinkedIn ↗“, „Impressum · Datenschutz“ in mono. Custom build with `motion`, not React Bits StaggeredMenu.
|
||||||
|
|
||||||
|
### 5.3 Footer (identical on every page incl. 404)
|
||||||
|
|
||||||
|
Ink. Top: `--gold-hairline` full width (the last `<Rule/>` of every page, 1.2 s).
|
||||||
|
Grid 12: cols 1–6 monogram 40 px + wordmark at footer size (name ivory, rule gold, sub-line `--gold`); cols 7–9 „Inhalt“ column (DM Mono 11 px caps `--grey-warm` heading, Jost 400 14 px links: Coaching & Analysen / Trainings & Workshops / Keynotes & Speaking / Über Anelia / Kontakt); cols 10–12 „Kontakt“ column: `info@anelia-wolf.com`, `+49 174 4555115`, „LinkedIn ↗“, „herCAREER Expo 2026 ↗“ (text link, no logo).
|
||||||
|
Bottom row (1 px `--line-dark` above, 48 px): left DM Mono 11 px „© 2026 Anelia Wolf · Sales Mentoring & Potential Development“; centre „Impressum · Datenschutz“ (text links); right „Unlock Your Potential — Next Level“ Jost 500 11 px caps ls 0.28em `--gold`.
|
||||||
|
No social icons, no newsletter, no agency credit (Impressum Bildnachweis only).
|
||||||
|
|
||||||
|
### 5.4 Cursor
|
||||||
|
|
||||||
|
Native cursor, always. No replacement, no spotlight. The only pointer-reactive effects: ledger hover, button fill + suffix, link underline, optional Magnet on primary CTAs. All under `(pointer: fine)` and no-preference.
|
||||||
|
|
||||||
|
### 5.5 Logo treatment
|
||||||
|
|
||||||
|
- Source: `public/brand/logo-mark.svg` as **true stroke paths** (not filled outlines): `path#v-left`, `path#v-right` (stroke `--ivory` on ink / `--ink` on ivory, width 1.5), `path#wolf` (one continuous path, stroke `url(#wolf-gold)`, width 1.25, ends at the neck), plus `path#handoff` (short segment from the neck downward, same stroke; only rendered in the hero). `fill: none; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke`. If only a PNG exists: trace in Figma/Illustrator (~1 h), path order left V → right V → wolf, wolf path direction ending at the neck.
|
||||||
|
- React component `<Monogram size stroke draw sheen theme />`.
|
||||||
|
- Draw sequence (hero, once): left V 0–0.35 s, right V 0.25–0.6 s, wolf 0.5–1.2 s (`power2.inOut`), handoff 1.2–1.4 s, then a one-time sheen: second `path#wolf` layer, stroke `#F4E5B8`, `stroke-dasharray: 12 200`, dashoffset tween across the path in 0.8 s (light over metal). Header monogram plays the sheen once on hover.
|
||||||
|
- Sizes: header 28 px; hero 28vh (with photo) / 56vh (without); Prinzip 28vh scrubbed; curtain 48 px; footer 40 px; 404 40vh (V strokes only, wolf missing); favicon = wolf head alone, gold on ink, 32/180/512 px + SVG.
|
||||||
|
- Rules: never filled, never glowing, never mirrored (faces right, into reading direction), never below 24 px, never a preloader, never longer than 1.2 s.
|
||||||
|
- Wordmark: always HTML (`<a class="wordmark">` with `__name`, `__rule`, `__sub`), rule uses `--gold-hairline` / `--gold-hairline-light`. `logo-full.png` is used only for OpenGraph.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Homepage
|
||||||
|
|
||||||
|
Route `/`. Grid: 12 columns, gutter `clamp(16px, 2vw, 32px)`, page margin `clamp(20px, 5vw, 80px)`, max-width 1680 px. Section numerals sit in cols 1–2 (sticky); content in cols 3–9; marginalia in cols 10–12. On < 1024 px the numeral becomes a mono line above the eyebrow and everything is single column.
|
||||||
|
|
||||||
|
### 6.1 `#hero` — Cover (ink)
|
||||||
|
|
||||||
|
**Purpose.** Answer who / what / for whom in three seconds, and set the "who built this?" moment using her own logo: the drawn wolf whose neck becomes the page's first line.
|
||||||
|
|
||||||
|
**Layout.** `min-height: 100svh`. Rows: `[Mastkopf auto][1fr][H1 auto][Subline + CTA auto][Folio auto]`.
|
||||||
|
- **Mastkopf** (below the header, padding-top 96 px): hairline above and below (`--gold-hairline`), 44 px row, three slots — left Jost 500 11.5 px caps ls 0.28em `--gold` „Keynote Speakerin · Trainerin · Coach“; centre same style `--grey-warm` „Sales Mentoring & Potential Development“; right DM Mono 12 px `--grey-warm` „Deutsch & Englisch“. Mobile: two rows (role / language).
|
||||||
|
- **Portrait — Variant A (≥ 1 photo):** cols 8–12, negative margin-right to the viewport edge, height from Mastkopf to folio hairline, `object-fit: cover`, warm grade (see §9), scrims: top third `linear-gradient(var(--ink) 0%, transparent 45%)` and left edge `linear-gradient(90deg, var(--ink) 0%, rgba(15,14,12,.72) 30%, transparent 60%)`. z-index 0. Caption DM Mono 11 px bottom-right inside the image: „Anelia Wolf · Foto: [Name]“.
|
||||||
|
- **Monogram — Variant A:** 28vh, cols 6–8, bottom-aligned to the folio hairline, overlapping the portrait's left edge by ~80 px, z-index 1 (over the photo, under the headline). Its neck ends at the bottom-left; `path#handoff` runs straight down to the folio hairline; the folio `<Rule origin="x">` draws outward from that x in both directions.
|
||||||
|
- **Monogram — Variant B (0 photos):** 56vh, cols 7–12, right-aligned, same handoff.
|
||||||
|
- **H1:** cols 1–10 (cols 1–8 below 1280 px), z-index 2, `align-self: end`, „wirkst“ in 400 Italic.
|
||||||
|
- **Subline + CTA:** cols 1–5, margin-top 40 px; below it (margin-top 32 px, flex, gap 32 px) the outline button + text link; micro-copy under the button in DM Mono 11 px.
|
||||||
|
- **Folio:** hairline, then a 40 px row: left DM Mono `01 / 06`, right „Positionierung ↓“ as an anchor link to `#positionierung`.
|
||||||
|
- Mobile (< 768 px): Mastkopf two rows; monogram 22vh above the H1 (no handoff); portrait as a full-width 3:4 block under the CTAs (max-height 48svh); H1 48–64 px.
|
||||||
|
|
||||||
|
**Copy.**
|
||||||
|
- Mastkopf: „Keynote Speakerin · Trainerin · Coach“ | „Sales Mentoring & Potential Development“ | „Deutsch & Englisch“
|
||||||
|
- H1: **„Wer du bist, entscheidet, wie du *wirkst*.“**
|
||||||
|
- Subline: „Persönlichkeitsanalysen, 1:1 Coaching, B2B-Vertriebstrainings und Keynotes nach dem WOLF-Prinzip. Für Menschen, die wissen wollen, wer sie sind, wo sie stehen und wo sie hinwollen.“
|
||||||
|
- Primary CTA: „Kennenlerngespräch anfragen“ (`mailto:info@anelia-wolf.com?subject=Kennenlerngespräch`); hover suffix (desktop, slides out of a mask inside the button): „( 30 Min · kostenfrei )“
|
||||||
|
- Micro-copy: „Telefonisch oder online · Deutsch oder Englisch“
|
||||||
|
- Text link: „Zu den Keynotes →“ (`/leistungen/keynotes/`)
|
||||||
|
- Folio: „01 / 06“ · „Positionierung ↓“
|
||||||
|
- Alt text: „Anelia Wolf, Keynote Speakerin, Trainerin und Coach“
|
||||||
|
|
||||||
|
**Motion (load, GSAP timeline, no preloader, starts on `fonts.ready` or 1.5 s).** 0.00 s upper Mastkopf hairline `scaleX` (0.9 s) · 0.15 s Mastkopf slots `opacity/y 8` (0.6 s, stagger 0.08) · 0.30 s H1 lines out of masks (0.9 s, stagger 0.09) · 0.50 s portrait `<Door/>` (1.1 s) · 0.70 s monogram draw: left V, right V, wolf, handoff (1.4 s total) · 1.30 s folio hairline draws outward from the handoff x (0.9 s) + folio text · 1.50 s wolf sheen (0.8 s) · 1.20 s subline + CTA (0.6 s). Scroll: portrait `y +8 %`, H1 lines `−6/−3/0 %`. Reduced motion: all end states.
|
||||||
|
|
||||||
|
**Components.** Custom `Hero` (server component + `'use client'` timeline via `useGSAP`), React Bits SplitText, `<Monogram draw sheen>`, `<Door/>`, `<Rule/>`, Magic UI Scroll Progress (restyled), optional Magnet on the CTA.
|
||||||
|
|
||||||
|
### 6.2 `#positionierung` — 01 (ivory)
|
||||||
|
|
||||||
|
**Purpose.** Her own sentence and her three questions as the method. Gives the whole site its spine (the questions return on the coaching page).
|
||||||
|
|
||||||
|
**Layout.** Boundary tween ink→ivory + `--gold-hairline-light`. `padding-block: clamp(112px, 16vh, 224px)`. Numeral „01“ cols 1–2 sticky, `--gold-700`. Eyebrow + H2 cols 3–9. Editorial paragraph cols 3–9, margin-top 48 px. Three question rows cols 3–9: DM Mono `01/02/03` `--gold-deep` + Cormorant 400 Italic 32 px, hairline-separated, padding-block 20 px. Signature quote cols 4–9, margin-top 64 px, Jost 300 20 px `--grey-deep` with a 24 px gold tick. Marginalia cols 10–12, sticky top 30vh, DM Mono 12 px `--grey-deep`, gold ticks. Mobile: marginalia becomes a mono block under the quote.
|
||||||
|
|
||||||
|
**Copy.**
|
||||||
|
- Eyebrow: „Positionierung“
|
||||||
|
- H2: **„Drei Fragen. Ein Prinzip.“**
|
||||||
|
- Paragraph: „Wer bist du? Wo stehst du? Wo willst du hin? Diese drei Fragen stelle ich jedem Menschen, mit dem ich arbeite – im Coaching, im Training, vor jeder Keynote. Die Antworten sind der Anfang. Danach geht es um Wirkung: souverän auftreten, klar positioniert sein, im Vertrieb mehr erreichen.“
|
||||||
|
- Rows: „01 · Wer bist du? — Persönlichkeits- & Verhaltensanalyse“ / „02 · Wo stehst du? — Positionierung im Coaching“ / „03 · Wo willst du hin? — Umsetzung in Training, Coaching oder Keynote“
|
||||||
|
- Signature: „Als Keynote Speakerin, Trainerin und Coach begleite ich Menschen und Unternehmen dabei, ihr volles Potenzial zu entfalten.“ — Anelia Wolf
|
||||||
|
- Marginalia: „Praxis: B2B-Außendienst“ · „Sprachen: Deutsch, Englisch“ · „Bühne: herCAREER Expo 2026, München“
|
||||||
|
|
||||||
|
**Motion.** Boundary hairline; eyebrow + H2 `<Lines/>`; paragraph ScrollReveal (desktop) / `<Lines/>` (mobile); rows `<Reveal/>` stagger 0.1 with hairline `scaleX`; marginalia fade. Folio → `02 / 06`.
|
||||||
|
|
||||||
|
**Components.** ScrollReveal, SplitText, `<Rule/>`, `<Reveal/>`, `<Numeral/>` (CSS sticky), `<Marginalia/>`.
|
||||||
|
|
||||||
|
### 6.3 `#leistungen` — 02 (ivory)
|
||||||
|
|
||||||
|
**Purpose.** The three services as a table of contents that is also the navigation. Three doors with specific links.
|
||||||
|
|
||||||
|
**Layout.** No boundary; `--line-light` hairline as separator. `padding-block: clamp(96px, 14vh, 192px)`. Numeral „02“ sticky. Eyebrow + H2 cols 3–8. Ledger cols 3–12, margin-top 64 px: three rows, each one `<TransitionLink>` as a 12-subcolumn grid: [numeral DM Mono 12 px `--gold-deep`, sub 1] [title Cormorant 400 `clamp(1.75rem, 1.4rem + 1.6vw, 3rem)` `--ink`, sub 2–5, roman/italic stacked layers] [one-liner Jost 400 17 px `--ink` + keywords DM Mono 12 px `--grey-deep` separated by ` · ` — **always visible**, sub 6–10] [link Jost 500 13 px caps ls 0.2em `--gold-deep` + `→`, sub 11–12, right]. Rows padding-block 40 px, 1 px `--line-light` above/below. Hover: ground `--bone` 0.3 s, lower hairline recolours to `--gold-hairline-light` (`scaleX` from left, 0.6 s), title crossfades to italic, arrow +8 px. Focus: 2 px `--gold-deep` outline, offset 4 px. Mobile: three stacked lines per row (numeral + title / one-liner + keywords / link), padding 28 px.
|
||||||
|
|
||||||
|
**Copy.**
|
||||||
|
- Eyebrow: „Leistungen“
|
||||||
|
- H2: **„Coaching. Training. Bühne.“**
|
||||||
|
- Row 01: „01“ · „Coaching & Analysen“ · „Wer bist du – und wer kannst du sein?“ · „Persönlichkeits- & Verhaltensanalyse · 1:1 Coaching · Positionierung & Neuorientierung“ · „Zum Coaching →“ (`/leistungen/coaching/`)
|
||||||
|
- Row 02: „02“ · „Trainings & Workshops“ · „Vertrieb mit Persönlichkeit – für Teams und Führungskräfte.“ · „B2B-Vertriebstraining · Neukunden & Bestandskunden · Leadership & Teamdynamik“ · „Zu den Trainings →“ (`/leistungen/trainings/`)
|
||||||
|
- Row 03: „03“ · „Keynotes & Speaking“ · „Mut zum Erfolg. Das WOLF-Prinzip auf der Bühne.“ · „Firmen-Events & Kongresse · 20–60 Minuten · Deutsch & Englisch“ · „Zu den Keynotes →“ (`/leistungen/keynotes/`)
|
||||||
|
|
||||||
|
**Motion.** H2 `<Lines/>`; rows `<Reveal/>` stagger 0.12, each with its upper hairline `scaleX`. Click → Umblättern.
|
||||||
|
|
||||||
|
**Components.** Custom `<Ledger variant="toc">`, `TransitionLink`, SplitText. (React Bits FlowingMenu rejected — marquee images are an agency trope.)
|
||||||
|
|
||||||
|
### 6.4 `#prinzip` — 03 (ink, the only pin)
|
||||||
|
|
||||||
|
**Purpose.** The WOLF-Prinzip as a typeset formula: three parts, two sentences each, which service delivers which. The page's one gold moment.
|
||||||
|
|
||||||
|
**Layout.** Boundary tween ivory→ink + `--gold-hairline`. Desktop: section 300vh, inner container `sticky top 0`, `height 100svh`, grid 12. Numeral „03“ cols 1–2 `.gold-text`. Eyebrow cols 3–9 (padding-top 128 px) `--gold`. Formula block cols 3–9, vertically centred: three lines Cormorant 300 formula size, ivory; operators `+` / `=` in DM Mono 0.5em `--gold` before lines 2 and 3; „Erfolg“ is the page's single `.gold-text` word. Right of each line (cols 9–12, aligned to the line): definition Jost 300 18 px `--grey-warm` max 30ch + DM Mono 12 px `--gold` „Liefert: …“. Monogram cols 10–12 bottom-right, 28vh, scrubbed draw. Sign-off bottom-left cols 3–9 (padding-bottom 64 px): Jost 500 11.5 px caps ls 0.28em `--gold`. Mobile/tablet (< 1024) and reduced motion: no pin, natural flow, definition under each line, monogram 40vw at the end, drawn once.
|
||||||
|
|
||||||
|
**Copy.**
|
||||||
|
- Eyebrow: „Das WOLF-Prinzip“
|
||||||
|
- Line 1: „Starke Persönlichkeit“ — „Du weißt, wer du bist und was dich antreibt. Die Analyse macht es sichtbar, das Coaching macht es nutzbar.“ — „Liefert: Coaching & Analysen“
|
||||||
|
- Line 2: „+ Souveräne Wirkung“ — „Du sagst klar, was du kannst und was du willst – im Kundengespräch, im Team, auf der Bühne.“ — „Liefert: Coaching, Trainings & Keynotes“
|
||||||
|
- Line 3: „= Maximaler *Erfolg*“ („Erfolg“ `.gold-text`) — „Das Ergebnis, wenn Persönlichkeit und Wirkung zusammenkommen: Neukunden, die bleiben. Bestandskunden, die wachsen. Entscheidungen, die du selbst triffst.“ — „Liefert: Trainings & Workshops“
|
||||||
|
- Sign-off: „Unlock Your Potential — Next Level“
|
||||||
|
- Text link under the sign-off: „Das WOLF-Prinzip auf der Bühne →“ (`/leistungen/keynotes/`)
|
||||||
|
|
||||||
|
**Motion.** ScrollTrigger pin (desktop, no-preference), scrub 0.4, reversible: 0–15 % eyebrow + numeral; 20 % line 1 `clip-path inset(0 0 100% 0)→0` + definition fade; 45 % line 2 + `+` glyph; 70 % line 3 + `=` glyph + one sheen over „Erfolg“ (`background-position 0→100%`, 1.2 s, once); 70–95 % wolf `pathLength 0→1` scrubbed; 95 % sign-off. No bounce, no scale.
|
||||||
|
|
||||||
|
**Components.** GSAP ScrollTrigger pin + timeline in `useGSAP` with `gsap.matchMedia`, `<Draw scrub>` (DrawSVG), custom `<Formula/>`. Aceternity Sticky Scroll Reveal rejected (nested scroller).
|
||||||
|
|
||||||
|
### 6.5 `#ueber` — 04 (ivory)
|
||||||
|
|
||||||
|
**Purpose.** Prove the practitioner: first person, a portrait that stays in view while reading, a Werdegang as a ledger, marginalia with verifiable facts. No counters, no logos.
|
||||||
|
|
||||||
|
**Layout.** Boundary tween ink→ivory + `--gold-hairline-light`. `padding-block: clamp(112px, 16vh, 224px)`. Numeral „04“ sticky `--gold-700`. Portrait (photo 2, or photo 1 in a tighter crop) cols 3–6, aspect 4:5, `<Door/>`, 1 px `--line-light` frame, caption DM Mono 11 px `--grey-deep` with gold tick, `sticky top 120px`. Text cols 7–12: eyebrow, H2 Cormorant 400 (max 14ch), body Jost 400 18 px `--ink` max 36rem, two paragraphs. Werdegang ledger cols 7–12, margin-top 64 px: five rows [keyword DM Mono 12 px caps `--gold-deep`, 25 %] [description Jost 400 17 px `--ink`, 75 %], `--line-light` separators, padding-block 18 px. Without a photo: text cols 3–8, ledger cols 3–9, monogram 24vh as a quiet vignette in cols 10–12 (`--gold-deep` stroke). Marginalia cols 10–12 only when the photo is present (otherwise under the ledger). Mobile: photo full width 4:5 max-height 60svh, then text, then ledger.
|
||||||
|
|
||||||
|
**Copy.**
|
||||||
|
- Eyebrow: „Über Anelia“
|
||||||
|
- H2: **„Ich stehe selbst im Vertrieb. Jede Woche.“**
|
||||||
|
- P1: „Mein Vater war Händler. Ich habe früh gesehen, wie ein Gespräch eine Entscheidung verändert – und dass Menschen von Menschen kaufen. Später kam die Ausbildung zur Zahntechnikerin, die Abteilungsleitung, dann Verkauf und Store Management im Premium-Segment.“
|
||||||
|
- P2: „Heute arbeite ich im B2B-Außendienst: Kaltakquise, Neukunden, Bestandskunden. Parallel begleite ich Menschen und Teams als Coach, Trainerin und Keynote Speakerin – auf Deutsch und Englisch. Was ich weitergebe, habe ich in der Woche davor selbst gemacht. Und zu Hause trainieren mich zwei sechzehnjährige Zwillinge täglich in Verhandlungsführung.“
|
||||||
|
- Werdegang: „Prägung — Verkaufsgespräche im Betrieb meines Vaters“ / „Präzision — Zahntechnikerin, Abteilungsleiterin“ / „Premium — Verkauf & Store Management im Premium-Segment“ / „B2B — Außendienst: Kaltakquise, Neu- & Bestandskunden“ / „Bühne — Keynote Speakerin, Trainerin & Coach · herCAREER Expo 2026“
|
||||||
|
- Caption: „Anelia Wolf · Foto: [Name der Fotografin]“
|
||||||
|
- Marginalia: „Trainerin für Persönlichkeit, Wirkung & Vertrieb“ · „LinkedIn ↗“ (`linkedin.com/in/anelia-wolf-a26071280`, `rel="noopener"`) · „Deutsch · Englisch“
|
||||||
|
|
||||||
|
*(„Medizinprodukte“ and the Zwillinge line ship only after client approval — §12.)*
|
||||||
|
|
||||||
|
**Motion.** Portrait `<Door/>` at `top 85%`, 6 % parallax; H2 `<Lines/>`; paragraphs `<Reveal/>`; ledger rows `<Reveal/>` stagger 0.08 + hairline `scaleX`; marginalia fade.
|
||||||
|
|
||||||
|
**Components.** `next/image` (unoptimized, two pre-generated widths), `<Ledger variant="werdegang">`, `<Door sticky>`, SplitText, `<Reveal/>`, `<Rule/>`. No TiltedCard / DecayCard — a magazine portrait does not move.
|
||||||
|
|
||||||
|
### 6.6 `#fuer-wen` — 05 (ivory, Termine on bone)
|
||||||
|
|
||||||
|
**Purpose.** Replaces the empty testimonial slot with a sharp audience definition and a verifiable dates ledger. Gets a „Stimmen“ row the day approved quotes exist.
|
||||||
|
|
||||||
|
**Layout.** `--line-light` separator. `padding-block: clamp(96px, 14vh, 192px)`. Numeral „05“ sticky. Eyebrow + H2 cols 3–9. Three columns cols 3–12, margin-top 64 px, `grid-template-columns: repeat(3, 1fr)`, gap 48 px; the middle column is offset 48 px downward (deliberate asymmetry). Per column: DM Mono 12 px `--gold-deep` label, Cormorant 400 28 px title, three Jost 400 17 px lines separated by 1 px `--line-light` (no bullets, no icons), a specific text link. Termine block cols 3–12, margin-top 96 px, ground `--bone`, padding 40 px, 1 px `--line-light` frame: heading DM Mono 12 px caps `--gold-deep` „Termine“ with a static 6 px gold dot (no pulse), two rows [date DM Mono 13 px tabular `--ink`, 20 %] [format Jost 500 13 px caps `--grey-deep`, 20 %] [title Cormorant 400 24 px `--ink`, 40 %] [venue DM Mono 12 px `--grey-deep`, 20 %], each row a link to the herCAREER page. Stimmen (only with approval): under the ledger, cols 4–11, Cormorant 400 Italic quote size, name in small caps, role in mono — max three, stacked, no carousel, no stars, no avatars. Mobile: columns stack, rows two lines.
|
||||||
|
|
||||||
|
**Copy.**
|
||||||
|
- Eyebrow: „Für wen“
|
||||||
|
- H2: **„Für wen ich arbeite.“**
|
||||||
|
- Col 1: „Unternehmen“ — „Vertriebsteams & Führungskräfte“ — „Neukundengewinnung, die planbar wird“ / „Bestandskunden, die wachsen statt abwandern“ / „Führung und Teamdynamik im Vertrieb“ — „Zu den Trainings →“
|
||||||
|
- Col 2: „Einzelpersonen“ — „Fach- & Führungskräfte, Frauen in der Neuorientierung“ — „Souverän auftreten und überzeugen“ / „Wissen, wofür du stehst – und es sagen“ / „Beruflich neu ausrichten, ohne dich zu verbiegen“ — „Zum Coaching →“
|
||||||
|
- Col 3: „Veranstalter“ — „Firmen-Events, Kongresse, Karriere-Formate“ — „Keynote nach dem WOLF-Prinzip, 20–60 Minuten“ / „Deutsch oder Englisch, live oder online“ / „Ein Impuls, der nachwirkt“ — „Zu den Keynotes →“
|
||||||
|
- Termine: „23.10.2026 · 10:30“ | „Keynote“ | „Das WOLF-Prinzip – Mut zum Erfolg!“ | „herCAREER Expo, MOC München, Auditorium 2“ // „22.–23.10.2026“ | „Ausstellerin“ | „Trainerin für Persönlichkeit, Wirkung & Vertrieb“ | „herCAREER Expo, Halle 3, Stand P.10.2“
|
||||||
|
|
||||||
|
**Motion.** H2 `<Lines/>`; columns `<Reveal/>` stagger 0.12 with inner hairlines; Termine frame: top hairline draws, then rows fade. Hover only on text links.
|
||||||
|
|
||||||
|
**Components.** Custom `<Columns/>`, `<Ledger variant="termine">`, SplitText, `<Reveal/>`; `<Quote/>` ready for Stimmen. Aceternity Animated Testimonials explicitly not used.
|
||||||
|
|
||||||
|
### 6.7 `#kontakt` — 06 (ink) + Footer
|
||||||
|
|
||||||
|
**Purpose.** One path, one CTA. The three-step process lowers the hurdle; then phone, mail, LinkedIn — no form, no third parties, minimal Datenschutz. Ends with the wordmark as colophon.
|
||||||
|
|
||||||
|
**Layout.** Boundary tween ivory→ink + `--gold-hairline`. `padding-top: clamp(128px, 18vh, 240px)`. Numeral „06“ sticky `.gold-text`. Eyebrow + H2 cols 3–9, H2 Cormorant 300 `clamp(2.75rem, 1.6rem + 4.4vw, 6rem)` ivory. Ablauf cols 3–8, margin-top 56 px: three rows [DM Mono 12 px `--gold` „01“] [Cormorant 400 26 px ivory title] [Jost 300 17 px `--grey-warm` text], `--line-dark` separators, padding-block 22 px. Contact block cols 9–12 (sticky top 30vh desktop): three large links, each DM Mono 11 px `--grey-warm` label + Cormorant 400 `clamp(1.5rem, 1.2rem + 1.2vw, 2.25rem)` ivory value with growing gold underline on hover; below: outline button + micro-copy. Colophon cols 1–12, margin-top 128 px: sign-off line, then the footer (§5.3). Mobile: Ablauf, contact block, colophon single column.
|
||||||
|
|
||||||
|
**Copy.**
|
||||||
|
- Eyebrow: „Kontakt“
|
||||||
|
- H2: **„Lass uns *sprechen*.“**
|
||||||
|
- Ablauf: „01 Kennenlerngespräch — 30 Minuten, kostenfrei, telefonisch oder online. Du erzählst, ich höre zu und frage nach.“ / „02 Analyse & Konzept — Wir klären Ziel, Format und Rahmen. Bei Teams und Einzelpersonen beginnt das meist mit einer Verhaltensanalyse.“ / „03 Umsetzung — Coaching, Training oder Keynote. Auf Deutsch oder Englisch, vor Ort oder online.“
|
||||||
|
- Contact: „Schreib mir“ → „info@anelia-wolf.com“ (`mailto:`) / „Ruf an“ → „+49 174 4555115“ (`tel:+491744555115`) / „Vernetzen“ → „LinkedIn ↗“ (`target="_blank" rel="noopener"`)
|
||||||
|
- Button: „Kennenlerngespräch anfragen“ · Micro-copy: „Unverbindlich. Auf Deutsch oder Englisch.“
|
||||||
|
- Sign-off above the colophon: „Unlock Your Potential — Next Level“
|
||||||
|
- Colophon wordmark: „ANELIA WOLF“ / „SALES MENTORING & POTENTIAL DEVELOPMENT“
|
||||||
|
|
||||||
|
**Motion.** H2 `<Lines/>`; Ablauf rows `<Reveal/>` stagger 0.1 + hairlines; contact links fade + hover underline; colophon hairline `scaleX` full width (1.2 s) as the page's last stroke; wordmark `letter-spacing 0.24em→0.32em` on reveal (0.9 s) — the only tracking tween on the site. Optional Magnet on the button.
|
||||||
|
|
||||||
|
**Components.** Custom `<Steps/>`, `<ContactLinks/>`, `<Wordmark/>`, `<Rule/>`, Magnet (optional).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Service pages
|
||||||
|
|
||||||
|
Routes: `/leistungen/coaching/`, `/leistungen/trainings/`, `/leistungen/keynotes/` (matches `next.config.ts`). Each page is one "issue" internally; the reader sees the plain page name.
|
||||||
|
|
||||||
|
### 7.1 Shared template (in order)
|
||||||
|
|
||||||
|
1. **Hero** — ink, `min-height: 60svh`, grid 12. Mastkopf as on the homepage with the page name in the middle slot („Coaching & Analysen“). Numeral `01/02/03` (DM Mono in the mastkopf, not a giant roman numeral). Eyebrow cols 3–9 `--gold`. H1 Cormorant 300 service size, ivory, max 14ch, one italic word, cols 3–11. Subline Jost 300 20 px `--grey-warm` cols 3–7 max 40ch. Page-specific primary CTA + micro-copy. **Facts stack** cols 10–12 top-right: 3–4 DM Mono 12 px lines (Format · Sprache · Ort · Dauer) with gold ticks. Header monogram only; no hero draw, no photo (exception: keynote stage photo, §9). Folio: `01 / 03` + page name. Motion: hairlines + `<Lines/>`, 1.4 s total.
|
||||||
|
2. **Ausgangslage** — ivory, `padding-block: clamp(96px, 14vh, 176px)`. H2 cols 3–8. Three rows cols 3–11 as a ledger: [DM Mono numeral `--gold-deep`] [Cormorant 400 Italic `clamp(1.375rem, 1.1rem + 1.2vw, 2rem)` `--ink`, written as the reader's situation]. Hairlines, `<Reveal/>`.
|
||||||
|
3. **Leistungsbausteine** — ivory. H2 cols 3–8. Blocks as a **spec table**, not cards: per block one row cols 3–12 with subgrid [title Cormorant 400 30 px, 30 %] [three labelled paragraphs: DM Mono label („Ziel“, „Inhalte“, „Für wen“) `--gold-deep` + Jost 400 17 px `--ink`, 55 %] [meta DM Mono 12 px `--grey-deep` right-aligned: Dauer / Ort / Sprache, 15 %]. Rows padding-block 48 px, `--line-light`. Paper stays paper — no zebra fills. (Trainings page uses the `<details>` table variant, §7.3.)
|
||||||
|
4. **Rahmen** — bone block cols 3–12, 1 px `--line-light`, padding 40 px: two-column DM Mono facts table (Label / Wert): Setting, Ort, Sprache, Dauer, Gruppengröße, Vorbereitung. Rows whose value is `[PLATZHALTER]` are removed by a build flag (`site.ts → showPlaceholders: false`), never shown empty.
|
||||||
|
5. **Ablauf** — ivory, four numbered rows like `#kontakt` (cols 3–8); marginalia cols 9–12 „Was du bekommst“.
|
||||||
|
6. **FAQ** — only if the client supplies answers; otherwise the section does not render. Ledger rows using native `<details>/<summary>`: question Cormorant 400 1.5rem, answer Jost 400, a DM Mono `+` that rotates to `×` via CSS, no accordion library. The question list always includes a price question („Was kostet …?“ — range or „auf Anfrage“).
|
||||||
|
7. **Kontakt band** — ink, ~70svh: boundary hairline, H2 Cormorant 300 (page-specific), contact links as on the homepage, page-specific CTA, then colophon + footer.
|
||||||
|
|
||||||
|
Cross-page: one boundary ink→ivory after the hero, one back before the contact band; masthead is the orientation (no breadcrumbs); folio shows `01 / 03` etc.; one primary CTA per page, identical in hero and band; secondary links never „Mehr erfahren“.
|
||||||
|
|
||||||
|
### 7.2 `/leistungen/coaching/` — Coaching & Analysen (warmest page)
|
||||||
|
|
||||||
|
- Mastkopf centre: „Coaching & Analysen“. Eyebrow: „1:1 Coaching & Persönlichkeitsanalysen“.
|
||||||
|
- H1: **„Wer bist du – und wer kannst du *sein*?“**
|
||||||
|
- Subline: „Persönlichkeits- und Verhaltensanalysen plus 1:1 Coaching: für souveräne Wirkung, klare Positionierung und berufliche Neuorientierung.“
|
||||||
|
- CTA: „Kennenlerngespräch anfragen“ · Micro: „Kostenfrei · 30 Minuten · telefonisch oder online“
|
||||||
|
- Facts: „1:1“ · „Vor Ort oder online“ · „Deutsch / Englisch“ · „Dauer nach Vereinbarung“
|
||||||
|
- Ausgangslage H2: „Drei Situationen.“ — „Du bist fachlich stark – und wirkst im Raum kleiner, als du bist.“ / „Du willst dich klar positionieren und weißt nicht, wofür du stehst.“ / „Du stehst vor einer beruflichen Neuorientierung und willst sie nicht dem Zufall überlassen.“
|
||||||
|
- **Special section „Die drei Fragen“** (between Ausgangslage and Bausteine): three double rows cols 3–12; left (cols 3–6, sticky top 30vh) the question in Cormorant 300 `clamp(2.5rem, 1.4rem + 3.6vw, 5rem)` `--ink`; right (cols 7–12) the answer. „Wer bin ich?“ → „Die Verhaltensanalyse zeigt dein Profil: Stärken, Muster, Potenziale. Nicht als Etikett, sondern als Arbeitsgrundlage.“ / „Wo stehe ich?“ → „Im Auswertungsgespräch klären wir, was davon heute sichtbar ist – im Auftritt, in der Sprache, in der Position.“ / „Wo will ich hin?“ → „Das Coaching übersetzt die Antworten in Auftritt, Positionierung und Entscheidungen. Konkret, in deinem Alltag.“ One React Bits BlurText permitted here on the question line (desktop only, one line). Photo 2 (if present) cols 10–12, 4:5, 240 px, sticky, `<Door/>`.
|
||||||
|
- Bausteine H2: „Zwei Bausteine.“
|
||||||
|
- „Persönlichkeits- & Verhaltensanalyse“ — Ziel: „Dein Profil verstehen: Stärken, Verhaltensmuster, Potenziale.“ Inhalte: „Online-Fragebogen, schriftliches Profil, Auswertungsgespräch [Dauer] [Tool/Zertifizierung].“ Für wen: „Einzelpersonen, Bewerbungs- und Karrierephasen, als Basis für Team-Trainings.“ Meta: „Online · DE/EN · [Dauer]“
|
||||||
|
- „1:1 Coaching“ — three tracks as sub-rows: „Souveräne Wirkung – überzeugend auftreten“, „Potenzialentfaltung – klare Positionierung“, „Berufsorientierung – Neuorientierung“. Meta: „Vor Ort oder online · DE/EN · Einzelsetting“
|
||||||
|
- Rahmen: Setting Einzelsetting · Ort vor Ort oder online · Sprache Deutsch/Englisch · Dauer [PLATZHALTER] · Paket [PLATZHALTER] · Vorbereitung Kennenlerngespräch, Fragebogen.
|
||||||
|
- Ablauf: „01 Kennenlerngespräch“ → „02 Analyse“ → „03 Coaching-Sessions“ → „04 Transfer in den Alltag“. Marginalia „Was du bekommst“: „Schriftliches Profil“ · „Klare Positionierung“ · „Nächste Schritte, die du selbst gehst“.
|
||||||
|
- FAQ (if answered): Für wen ist das Coaching? / Wie viele Sessions brauche ich? / Geht das online? / Was kostet das Coaching?
|
||||||
|
- Kontakt band H2: **„Fang mit dem Gespräch an.“** CTA „Kennenlerngespräch anfragen“.
|
||||||
|
|
||||||
|
### 7.3 `/leistungen/trainings/` — Trainings & Workshops (the "offer" page, least motion)
|
||||||
|
|
||||||
|
- Mastkopf centre: „Trainings & Workshops“. Eyebrow: „Für Unternehmen & Teams“.
|
||||||
|
- H1: **„Vertrieb mit *Persönlichkeit*.“**
|
||||||
|
- Subline: „B2B-Vertriebstrainings und Leadership-Workshops, die im Alltag funktionieren – nicht nur im Seminarraum. Inhouse, online, auf Deutsch oder Englisch.“
|
||||||
|
- CTA: „Training anfragen“ · Micro: „Unverbindlich“ (+ „Antwort innerhalb von 24 Stunden“ only with her commitment — flag).
|
||||||
|
- Facts: „Inhouse oder online“ · „1–2 Tage oder Reihe“ · „Deutsch / Englisch“ · „Gruppengröße [PLATZHALTER]“
|
||||||
|
- Ausgangslage H2: „Drei Bremsen.“ — „Die Neukundengewinnung stockt – und niemand ruft gern kalt an.“ / „Bestandskunden werden betreut, aber nicht ausgebaut.“ / „Führung und Teamdynamik bremsen den Vertrieb, statt ihn zu tragen.“
|
||||||
|
- **Bausteine as a ledger TABLE** (grafted from Auftritt): header row DM Mono caps `--gold-deep` „Modul | Ziel | Inhalte | Für wen“; three rows, each a native `<details>` whose `<summary>` is the full table row (Modul title Cormorant 400 30 px; Ziel and Für wen in Jost 400; meta DM Mono right), expanding to the „Inhalte“ list (3–5 lines, hairline-separated). DM Mono `+`/`×` at the right edge. H2: „Drei Module.“
|
||||||
|
- „B2B-Vertriebstraining“ — Ziel „Gesprächsführung, die zum Abschluss führt.“ Inhalte „Bedarfsanalyse · Einwände · Preisgespräch · Verhaltenstypen im Kundengespräch“ Für wen „Außendienst, Innendienst, Key Account“ Meta „1–2 Tage inhouse · DE/EN“
|
||||||
|
- „Umsatzwachstum: Neukunden & Bestandskunden“ — Ziel „Kaltakquise, die planbar wird; Bestandskunden, die wachsen.“ Inhalte „Akquise-Routine · Erstkontakt · Nachfassen · Account-Ausbau · Kennzahlen“ Für wen „Vertriebsteams und ihre Führung“ Meta „Trainingsreihe [PLATZHALTER] · DE/EN“
|
||||||
|
- „Leadership & Teamdynamik“ — Ziel „Führung, die Vertriebsteams stärkt.“ Inhalte „Verhaltensanalysen im Team · Rollen · Feedback · Konflikte · Zielvereinbarung“ Für wen „Vertriebsleitung, Teamleitung, Geschäftsführung“ Meta „Workshop 1 Tag · vor Ort · DE/EN“
|
||||||
|
- **Special section „Wie ich arbeite“**: three short paragraphs cols 3–8 with marginalia — „Praxis statt Folien: Ich bin selbst im B2B-Außendienst. Was wir trainieren, habe ich in der Woche davor gemacht.“ / „Analyse als Fundament: Verhaltensprofile zeigen, wie ein Team tickt – und wie es mit Kunden spricht.“ / „Transfer statt Seminar: Jedes Training endet mit konkreten Vereinbarungen für die nächsten 30 Tage.“
|
||||||
|
- „Typische Ziele“ (goals, not promises) as three ledger lines: „Mehr qualifizierte Erstgespräche“ / „Systematischer Bestandskundenausbau“ / „Klarere Führung im Vertriebsteam“.
|
||||||
|
- Rahmen: Format Inhouse-Workshop 1–2 Tage / Trainingsreihe / Online · Sprache DE/EN · Gruppengröße [PLATZHALTER] · Vorbereitung Bedarfsgespräch, optional Verhaltensanalysen.
|
||||||
|
- Ablauf: „01 Bedarfsgespräch“ → „02 Konzept“ → „03 Training“ → „04 Follow-up nach 30 Tagen“.
|
||||||
|
- FAQ (if answered): Inhouse oder extern? / Wie viele Teilnehmende? / Auch für Führungskräfte ohne Vertriebsrolle? / Was kostet ein Trainingstag?
|
||||||
|
- Kontakt band H2: **„Sprechen wir über dein Team.“** CTA „Training anfragen“; phone prominent.
|
||||||
|
- Motion: no pin, no blur, no BlurText — only `<Rule/>`, `<Lines/>`, `<Reveal/>`.
|
||||||
|
|
||||||
|
### 7.4 `/leistungen/keynotes/` — Keynotes & Speaking (the stage page)
|
||||||
|
|
||||||
|
- Mastkopf centre: „Keynotes & Speaking“. Eyebrow: „Keynotes nach dem WOLF-Prinzip · Deutsch & Englisch“.
|
||||||
|
- H1: **„Mut zum *Erfolg*.“** — the italic word may also be `.gold-text` here (this page's one gradient word).
|
||||||
|
- Subline: „Keynotes nach dem WOLF-Prinzip für Firmen-Events, Kick-offs und Kongresse. Ein Impuls, der nachwirkt – auf Deutsch oder Englisch, live oder online.“
|
||||||
|
- CTA: „Keynote anfragen“ · Secondary „Speaker-Profil (PDF) →“ only if the file exists in `/public`.
|
||||||
|
- Facts: „20–60 Minuten“ · „Live, online, hybrid“ · „Deutsch / Englisch“ · „Deutschlandweit, Europa auf Anfrage“
|
||||||
|
- Hero photo: stage photo (if present) cols 8–12, 16:10, `<Door/>`; otherwise no photo.
|
||||||
|
- **Signature Talk** (replaces Ausgangslage): H2 „Der Vortrag.“ Ledger with one main row: „01 · Das WOLF-Prinzip – Mut zum Erfolg!“ — Kernbotschaft „Starke Persönlichkeit, souveräne Wirkung, maximaler Erfolg: wie du dein Potenzial erkennst und selbstbewusst einsetzt – im Leben und im Business.“ — Für wen „Mitarbeitende, Vertriebsteams, Führungskräfte, Karriere-Publikum“ — Meta „20–60 Min. · DE/EN · live & online“. Further talks (`02 Unlock your Sales-DNA`, etc.) only after client approval — flag.
|
||||||
|
- **Typographic interruption** (this page only): 100svh ink section, „Mut zum Erfolg.“ in Cormorant 300 `clamp(4rem, 2rem + 12vw, 14rem)` ivory, cols 2–12, `<Lines/>`; below in DM Mono 12 px `--gold`: „Keynote · herCAREER Expo 2026 · 23.10. · 10:30 · Auditorium 2“.
|
||||||
|
- Anlässe: three columns like `#fuer-wen`: „Firmen-Events & Kick-offs“ / „Vertriebstagungen & Kongresse“ / „Karriere-Events & Frauennetzwerke“.
|
||||||
|
- „Was dein Publikum mitnimmt“: „Klarheit darüber, wer man ist und wofür man steht.“ / „Ein konkretes Vorgehen für souveränes Auftreten – im Kundengespräch wie im Team.“ / „Den Mut, die nächste Entscheidung selbst zu treffen.“
|
||||||
|
- **Speaker-Facts strip** (grafted): one full-width row, equal cells with 1 px `--line-light` vertical separators; each cell DM Mono label `--grey-deep` + Jost 400 value `--ink`: „Sprachen — DE / EN“ · „Dauer — 20–60 Min.“ · „Format — live, online, hybrid“ · „Technik — Headset, Präsentation optional“ · „Reise — deutschlandweit, Europa auf Anfrage“ · „Anfrage — unverbindlich“.
|
||||||
|
- Termine: ledger as on the homepage (herCAREER 2026), gold dot.
|
||||||
|
- Für Veranstalter: row „Speaker-Profil (PDF): Kurzvita, Themen, Fotos, Technik“ from `/public` — only if delivered.
|
||||||
|
- FAQ (if answered): Wie lange im Voraus anfragen? / Auch als Workshop nach der Keynote? / Auf Englisch? / Gibt es Video oder Referenzen?
|
||||||
|
- Kontakt band H2: **„Hol das WOLF-Prinzip auf deine Bühne.“** CTA „Keynote anfragen“ · Micro „Unverbindlich · Antwort auf Deutsch oder Englisch“.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Impressum & Datenschutz
|
||||||
|
|
||||||
|
Routes `/impressum/`, `/datenschutz/`. Ivory, `data-theme="ivory"`, no motion except the template enter fade and the Umblättern sheet. Header and footer identical to all pages. Layout: masthead (centre slot „Impressum“ / „Datenschutz“), content cols 3–9, H1 Cormorant 400 44 px, H2 as DM Mono 12 px caps `--gold-deep` with gold tick, body Jost 400 17 px `--ink` lh 1.65, meta („Stand: …“) DM Mono `--grey-deep`, links `--gold-deep` underlined. Content from the legal research templates: § 5 DDG fields, no HR entry, no OS-platform link, VSBG sentence, § 18 MStV voluntary line, Bildnachweis, „Bezeichnung WOLF-Prinzip“ (not „Wortmarke“ until registered); Datenschutz with hosting/log section, contact section, external links, self-hosted fonts, no cookies/no storage statement, rights, supervisory authority placeholder. No cookie banner. Verify before launch: no third-party requests, no `localStorage`/`sessionStorage` anywhere in the build.
|
||||||
|
|
||||||
|
**404** (`not-found.tsx`): ink, monogram 40vh with only the two V strokes drawn, text Cormorant 300 „Hier ist der Wolf nicht.“, link „Zur Startseite →“, footer with legal links.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Photo strategy
|
||||||
|
|
||||||
|
Treatment (always identical): warm grade, not duotone — `filter: sepia(.12) saturate(.9) contrast(1.02)`; static grain over it; hard rectangles (radius 0); 1 px `--line-light` frame on ivory / no frame on ink; caption DM Mono 11 px with gold tick „Anelia Wolf · Foto: [Name]“. Never gold frames, never scale-on-hover, never tilt, never freistellen. The face stays recognisable — event managers book a face.
|
||||||
|
|
||||||
|
- **0 photos:** monogram is the image (hero 56vh with handoff, Über Anelia 24vh vignette, service heroes without photo). Werdegang ledger + marginalia become the "portrait". Nothing waits for an image. The 800×800 herCAREER picture is too small for the hero; with cleared rights it may run in Über Anelia at ≤ 320 px width only.
|
||||||
|
- **1 photo (portrait ≥ 2000 px):** hero cols 8–12 (variant A); the same file in Über Anelia in a tighter head-and-shoulders 4:5 crop — different crop and grade strength (hero 100 %, portrait 60 %) make it read as two images. Service pages without photo.
|
||||||
|
- **2 photos:** photo 1 hero, photo 2 Über Anelia + coaching page (Die drei Fragen, small, sticky).
|
||||||
|
- **3 photos (ideal: portrait, three-quarter portrait, landscape stage/speaking):** photo 3 on the keynote hero cols 8–12, 16:10. If no stage picture exists, the keynote page carries no photo — the typographic interruption carries it.
|
||||||
|
|
||||||
|
Technical: `next/image` with `unoptimized`, two pre-generated widths per motif (1200 / 2000 px, AVIF + JPEG fallback), `sizes`, `priority` on the hero image, `<Door/>` reveal instead of fade, descriptive alt texts, photographer credit in caption and Impressum.
|
||||||
|
|
||||||
|
Brief to the client/photographer: one 90-minute session, neutral light background (beige/grey), soft side light from **top-left** (matches the gold gradient's light source), three motifs — frontal portrait, three-quarter portrait looking left (into the text), landscape half-shot speaking — ≥ 3000 px long edge, room left of the head for the monogram overlap, written usage rights.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Component & library shopping list
|
||||||
|
|
||||||
|
| Need | Choice | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| Line/mask headline reveals | React Bits SplitText (`lines`, `mask`, `autoSplit`, fonts.ready) | https://reactbits.dev/text-animations/split-text |
|
||||||
|
| Scrubbed editorial paragraph | React Bits ScrollReveal (`enableBlur: false`, desktop only) | https://reactbits.dev/text-animations/scroll-reveal |
|
||||||
|
| One blur line (coaching page questions) | React Bits BlurText | https://reactbits.dev/text-animations/blur-text |
|
||||||
|
| Scroll progress hairline | Magic UI Scroll Progress (restyled 1 px `--gold`) | https://magicui.design/docs/components/scroll-progress |
|
||||||
|
| Magnetic primary CTAs (optional, 3–4 max) | React Bits Magnet (strength 3, pointer: fine) | https://reactbits.dev/animations/magnet |
|
||||||
|
| Wolf draw / scrubbed draw | GSAP DrawSVGPlugin (free since 3.13) or `motion.path pathLength` | https://gsap.com/docs/v3/Plugins/DrawSVGPlugin/ |
|
||||||
|
| Pin (WOLF-Prinzip) | GSAP ScrollTrigger + `useGSAP` + `gsap.matchMedia` | https://gsap.com/docs/v3/Plugins/ScrollTrigger/ |
|
||||||
|
| Smooth scroll | `lenis/react` (`ReactLenis root`, ticker-driven) | https://github.com/darkroomengineering/lenis |
|
||||||
|
| Enter animation / reduced motion | `motion` (`MotionConfig reducedMotion="user"`, `whileInView`) | https://motion.dev |
|
||||||
|
| Custom (in-house) | `Rule`, `Lines`, `Reveal`, `Draw`, `Door`, `Monogram`, `Wordmark`, `Masthead`, `Numeral`, `Marginalia`, `Ledger` (toc / werdegang / termine / module-table), `Formula`, `Columns`, `Steps`, `ContactLinks`, `Facts`, `SpeakerFacts`, `FAQ` (`<details>`), `TransitionLink`, `Curtain`, `OverlayMenu`, `Grain` | — |
|
||||||
|
| Explicitly not used | FlowingMenu, StaggeredMenu, TiltedCard, DecayCard, MetallicPaint, Noise canvas, ScrollStack, Sticky Scroll Reveal, Text Hover Effect, Scroll Based Velocity, any shader/particle background, custom cursors, Animated Testimonials, CountUp, LogoLoop, GradientText, Flip Words | — |
|
||||||
|
|
||||||
|
Repo tasks before section work: (1) replace Geist with `next/font/local` (Cormorant 300/400/400i, Jost variable, DM Mono 400) in `src/app/fonts.ts`; (2) add `src/lib/gsap.ts` registering ScrollTrigger, SplitText, DrawSVG, `useGSAP`; (3) `SmoothScroll.tsx` per the research guide (ticker-driven Lenis, `ScrollTrigger.update` on scroll, scroll-to-top on pathname); (4) `template.tsx` enter + refresh; (5) `public/brand/logo-mark.svg` as stroke paths; (6) `site.ts` flags: `showPlaceholders`, `showFaq`, `showStimmen`, `showSpeakerPdf`, `showSecondTalk`, `show24hPromise`, `showIndustry`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. AI-tells avoided / handcrafted signals used
|
||||||
|
|
||||||
|
**Avoided:** centred hero with pill eyebrow + two buttons; three icon cards; Tailwind default palette or purple/indigo gradients; gradient text on headings/metrics (one word per page, display only); Inter/Geist/Instrument Serif; template rhythm (hero → cards → stats → carousel → FAQ → CTA banner); counters and „0+“ stats; glassmorphism, drop shadows, glow, neon borders; thick coloured left borders; dark-mode-by-reflex with dotted grids; emoji/icon bullets; stock imagery (handshake, laptop, summit); buzzword copy („Empower“, „Next-level Lösungen“, „am Montag noch trägt“, triple adjectives, „Stell dir vor“); over-rounded corners (radius 0 site-wide); testimonial carousel with stars and avatars; uniform fade-up on everything, bounce/elastic, image scale on hover, pulsing dots; auto-scrolling logo marquee; symmetric 50/50 rows; kicker labels that just say „OUR SERVICES“; custom cursor; shader backgrounds; rotating/typewriter headlines; „Mehr erfahren“ buttons; magazine jargon exposed to the reader.
|
||||||
|
|
||||||
|
**Handcrafted signals:** three-slot masthead with a folio; sticky section numerals in the gutter; asymmetric 12-column layouts (content starting at column 3, portraits bleeding past the edge, offset middle column); roman/italic emphasis inside one headline; hairline rules and tracked 11.5 px caps eyebrows with gold ticks (engraved-stationery system); tabular mono numerals for dates, formats, phone; typographic ledgers instead of cards and logo walls; the WOLF-Prinzip typeset as an equation with definitions; a Werdegang ledger without unverified years; marginalia with verifiable facts; the logo drawn as a stroke whose neck hands off into the page's first hairline; one sheen over metal; a page-turn transition that names the destination; deliberate light/dark rhythm with tweened boundaries; portraits in a door mask with a warm grade and a mono caption; first-person copy with her own phrases (WOLF-Prinzip, Mut zum Erfolg, the three questions) and B2B nouns; a 404 with the wolf missing; native `<details>` FAQ and `<details>` module table; zero third-party requests, no cookies, no forms.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Open questions for the client (blocking before launch)
|
||||||
|
|
||||||
|
1. **Logo vector:** original SVG/AI of the W-monogram; confirmation that the wolf may be drawn as a single stroke and shown alone (favicon, curtain). Written usage rights from the designer.
|
||||||
|
2. **Impressum:** ladungsfähige Anschrift, USt-IdNr. or W-IdNr. (or neither), Kleinunternehmerregelung yes/no, Berufshaftpflicht yes/no, Bundesland (supervisory authority), hoster and e-mail provider (for the Datenschutz text and AVV).
|
||||||
|
3. **Nebentätigkeit:** may the site say „B2B-Außendienst“? May it say „Medizinprodukte“? Employer is never named.
|
||||||
|
4. **Biography approval:** Vater als Händler, Zahntechnikerin, Abteilungsleitung, Premium-Store-Management, Zwillinge — all from her herCAREER text; confirm each, and whether years may be added later.
|
||||||
|
5. **Photos:** which originals exist (≥ 2000 px, portrait/landscape/stage), photographer name for credits, rights for the herCAREER 800×800 image.
|
||||||
|
6. **Analysis tool:** which profiling instrument (DISG/INSIGHTS/persolog/…), active licence, may the name/badge appear, duration of the Auswertungsgespräch.
|
||||||
|
7. **Rahmen values:** coaching session length and packages; training group sizes and series format; keynote travel radius; technical requirements.
|
||||||
|
8. **Prices:** range or „auf Anfrage“ for coaching, training day, keynote.
|
||||||
|
9. **FAQ answers** for the three pages (section ships only with answers).
|
||||||
|
10. **Second talk:** may „Unlock your Sales-DNA“ be listed, and with what content? Any further talks?
|
||||||
|
11. **Speaker profile PDF:** exists or to be produced (Kurzvita, Themen, Fotos, Technik).
|
||||||
|
12. **Promises:** „Antwort innerhalb von 24 Stunden“ — yes/no. Free 30-minute Kennenlerngespräch — confirm.
|
||||||
|
13. **Testimonials / references:** any quotes with name, role, company and written consent; any logos with permission; herCAREER logo permission (otherwise text link only).
|
||||||
|
14. **Audience weighting:** B2B first or individuals first? (Copy swap only: row order in Leistungen and the hero subline.)
|
||||||
|
15. **Language:** German only at launch; English route later? „Deutsch & Englisch“ in the masthead refers to her services, not the site.
|
||||||
|
16. **Trademark:** „WOLF-Prinzip“ is used by third parties (Bornemann, Kitzler) — recommend a Markenrecherche; until registered the site says „Bezeichnung“, never „Wortmarke“.
|
||||||
|
17. **After 23.10.2026:** the Termine rows become „Gesprochen bei herCAREER 2026“ or a Stimmen block — who maintains that, and when?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Considered and deliberately not built (v2 candidates)
|
||||||
|
|
||||||
|
- **Site-wide gold thread** (Faden): the strongest idea in the jury, too fragile for a 2-day static build (anchor measurement across font swap, image load, pin spacer, hover row growth, mobile URL bar). Its safe fragments are in: eyebrow ticks and the hero neck-to-hairline handoff.
|
||||||
|
- **W·OLF hero with header migration** (Auftritt): best single hero idea; requires a FLIP into the header slot synced with Lenis and re-measured on every resize. Candidate for a later iteration once the base ships and the logo vector is confirmed.
|
||||||
|
- **Jost-only typography**: coherent and disciplined, but loses the feminine pole and the true italics; Cormorant stays, with the restrictions in §3. Bodoni Moda is the documented fallback if the client wants a harder fashion edge.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Z. Umgesetzte Abweichungen (Stand 2026-09-03, nach zwei QA-Runden und fünf Fachprüfungen)
|
||||||
|
|
||||||
|
Diese Entscheidungen wurden während der Qualitätssicherung getroffen und sind gebaut. Sie gehen der
|
||||||
|
obigen Synthese vor.
|
||||||
|
|
||||||
|
1. **Grund und Themenwechsel ohne Skript.** Der ursprüngliche Ansatz, `--bg/--fg/--accent` am `<body>`
|
||||||
|
zu tweenen, ist verworfen. Ursache: Jeder Abschnitt deklariert dieselben Variablen über
|
||||||
|
`[data-theme]`, und die nähere Regel gewinnt. Am Übergang stand deshalb der auslaufende Abschnitt
|
||||||
|
mit eigener Textfarbe auf bereits gewechseltem Grund. Jetzt malt jeder Abschnitt seinen eigenen
|
||||||
|
deckenden Grund, die goldene Haarlinie markiert die Naht. `ThemeTween.tsx` ist gelöscht,
|
||||||
|
`<body>` hat einen festen Grund. Kein Skript fasst mehr Farben an.
|
||||||
|
2. **Kopfzeile.** Die drei Navigationslinks erscheinen erst ab 1280 Pixel, der Menüknopf bleibt bis
|
||||||
|
dahin. „Kontakt“ ab 1024. Die Mittelspalte der Mastkopfzeile ist unterhalb 1280 ausgeblendet.
|
||||||
|
3. **Einblendungen.** Kein `autoAlpha` und kein `visibility: hidden` bei Scroll-Einblendungen, sonst
|
||||||
|
fallen Elemente aus der Tabulatorreihenfolge. Nur `opacity` und `y`. Ausnahme ist der
|
||||||
|
Seitenwechsel-Vorhang, der bewusst aus der Reihenfolge fällt.
|
||||||
|
4. **Schriftgrößen.** Bodoni Moda wird nirgends unter 28 Pixel gesetzt. Kleinere Titel laufen in Jost.
|
||||||
|
5. **Sprungmarken über Seitengrenzen.** Nach dem Seitenwechsel wird erst `document.fonts.ready`
|
||||||
|
abgewartet, dann zwei Bildwechsel, dann `ScrollTrigger.refresh()`, dann zum Anker gesprungen.
|
||||||
|
6. **Heldenbereich.** Die Textspalte belegt unterhalb 1440 Pixel sechs statt acht Rasterspalten, damit
|
||||||
|
sie nicht in das Monogramm läuft. Der Bereich passt bei jeder geprüften Breite exakt in das erste
|
||||||
|
Bild, die Goldlinie vom Wolfshals endet auf der Folio-Linie.
|
||||||
|
7. **Kennziffern.** Alle sechs Abschnitte der Startseite tragen die große Bodoni-Kennziffer, nicht nur
|
||||||
|
die ersten drei.
|
||||||
|
8. **WOLF-Prinzip.** Die Bühne ist CSS-`sticky` statt GSAP-Pin. Bei reduzierter Bewegung fällt sie auf
|
||||||
|
ihre natürliche Höhe zusammen.
|
||||||
|
9. **Flaggen.** Ein Block, dessen Flagge falsch ist und dessen Datei fehlt, wird gar nicht gerendert.
|
||||||
|
10. **Positionierungs-Absatz** in Jost statt Bodoni, damit Überschrift und Fließtext nicht konkurrieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Z2. Schriftwahl vom 06.09.2026 (ersetzt Abschnitt A.1)
|
||||||
|
|
||||||
|
Nach einem Vergleich unter `/schrift-test/` (27 Paarungen im Hero) und
|
||||||
|
`/schrift-test/seite/` (die vollständige Startseite zum Umschalten) fiel die
|
||||||
|
Entscheidung auf Paarung 01 aus der pagecloud-Liste:
|
||||||
|
|
||||||
|
| Rolle | Schrift | Ersetzt |
|
||||||
|
|---|---|---|
|
||||||
|
| Display | Playfair Display (variable, normal + kursiv) | Bodoni Moda |
|
||||||
|
| Text, Labels, Wortmarke | Source Sans 3 (variable) | Jost |
|
||||||
|
| Ziffern, Meta | DM Mono | unverändert |
|
||||||
|
|
||||||
|
Playfair hat keine optische Achse, deshalb sind alle
|
||||||
|
`[font-variation-settings:'opsz'_N]` entfernt. Die Mindestgröße von 28 px für die
|
||||||
|
Display-Schrift bleibt; darunter läuft Text in Source Sans 3.
|
||||||
|
|
||||||
|
Die CSS-Variablen heißen jetzt neutral `--font-display-face` und `--font-body-face`
|
||||||
|
statt `--font-bodoni` und `--font-jost`, damit sie bei einem weiteren Wechsel nicht
|
||||||
|
in die Irre führen.
|
||||||
|
|
||||||
|
**Betonungswort.** Das eine `<em>` je Headline ist kursiv und auf `data-theme="dark"`
|
||||||
|
zusätzlich im Goldverlauf. Auf hellem Grund bleibt es rein typografisch: Der Verlauf
|
||||||
|
käme auf Elfenbein auf 2,4:1. Damit bleibt Gold weiter auf die dunklen Flächen
|
||||||
|
rationiert. Die Regel steht als eine Selektorgruppe in `globals.css`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Z3. Zweite Sprache vom 06.09.2026 (erweitert Abschnitt 8 und 12)
|
||||||
|
|
||||||
|
Die Spec kannte nur eine Sprache. Auf Kundenwunsch steht die Seite jetzt
|
||||||
|
vollständig auf Deutsch **und** Englisch; der alte Navigationspunkt „Kontakt“
|
||||||
|
ist durch einen Flaggen-Umschalter ersetzt.
|
||||||
|
|
||||||
|
**Adressen.** Deutsch behält seine Pfade, Englisch bekommt eigene Segmente
|
||||||
|
(`/en/`, `/en/services/coaching/`, `/en/imprint/`, `/en/privacy/`). Eine
|
||||||
|
Sprachweiche über `Accept-Language` gibt es bewusst nicht: Der Export hat keinen
|
||||||
|
Server, und eine Weiterleitung, die niemand abschalten kann, ist schlimmer als
|
||||||
|
zwei sichtbare Flaggen. Alle Pfade stehen in `routesByLang` (`src/lib/i18n.ts`).
|
||||||
|
|
||||||
|
**Struktur statt Wortlaut.** Jede deutsche Inhaltsdatei exportiert ihre Form
|
||||||
|
(`Widen<typeof x>`), die englische Datei erfüllt genau diesen Typ. Damit ist
|
||||||
|
„die englische Seite hat den Abschnitt nicht“ ein Typfehler und keine
|
||||||
|
Entdeckung im Browser. Der Seitenaufbau liegt einmal in
|
||||||
|
`src/components/pages/*Body.tsx`, die Routendateien tragen nur noch Metadaten.
|
||||||
|
|
||||||
|
**Zwei Wurzel-Layouts.** `<html lang>` muss die Sprache des Dokuments angeben
|
||||||
|
(WCAG 3.1.1), und im App Router schreibt nur das Wurzel-Layout das
|
||||||
|
`<html>`-Element. Deshalb `app/(de)/layout.tsx` und `app/en/layout.tsx`, beide
|
||||||
|
nur ein Aufruf von `<Shell lang>`. Folge: Ein Sprachwechsel ist ein voller
|
||||||
|
Seitenaufbau (richtig so) — und Next kann keine gemeinsame 404 mehr bauen,
|
||||||
|
weshalb `experimental.globalNotFound` plus `src/app/global-not-found.tsx` die
|
||||||
|
Datei `out/404.html` erzeugt.
|
||||||
|
|
||||||
|
**404 zweisprachig.** Ein statischer Export liefert für jede unbekannte Adresse
|
||||||
|
dieselbe Datei aus, auch unter `/en/`. Die Seite bleibt deutsch, trägt aber
|
||||||
|
unter einer Haarlinie einen englischen Zweizeiler mit eigenem Link, in
|
||||||
|
`lang="en"` ausgezeichnet.
|
||||||
|
|
||||||
|
**Rechtstexte.** Verbindlich bleibt die deutsche Fassung — § 5 DDG verlangt die
|
||||||
|
Angaben ohnehin auf Deutsch. Beide englischen Rechtsseiten sagen das im ersten
|
||||||
|
Abschnitt und verlinken das deutsche Original. Der Build-Guard
|
||||||
|
(`assertNoPlaceholders`) läuft über beide Fassungen.
|
||||||
|
|
||||||
|
**Gold-Rationierung.** Die Flaggen sind die einzige farbige Fläche im Kopf. Sie
|
||||||
|
sind 16 × 11 px, randlos und stehen neben dem Sprachkürzel in DM Mono — eine
|
||||||
|
Flagge bezeichnet ein Land, nicht eine Sprache, deshalb trägt das Kürzel die
|
||||||
|
Aussage und der zugängliche Name nennt die Sprache ausgeschrieben.
|
||||||
141
docs/superpowers/specs/legal-templates.md
Normal file
141
docs/superpowers/specs/legal-templates.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
=== IMPRESSUM ===
|
||||||
|
Impressum
|
||||||
|
|
||||||
|
Angaben gemäß § 5 DDG
|
||||||
|
|
||||||
|
Anelia Wolf
|
||||||
|
[OPTIONAL: Geschäftsbezeichnung, z. B. „Anelia Wolf – Sales Mentoring & Potential Development“ – nur, wenn sie tatsächlich unter dieser Bezeichnung auftritt; der bürgerliche Name muss in jedem Fall stehen]
|
||||||
|
[STRASSE UND HAUSNUMMER]
|
||||||
|
[PLZ] [ORT]
|
||||||
|
Deutschland
|
||||||
|
|
||||||
|
Kontakt
|
||||||
|
Telefon: +49 174 4555115
|
||||||
|
E-Mail: info@anelia-wolf.com
|
||||||
|
|
||||||
|
Umsatzsteuer-Identifikationsnummer / Wirtschafts-Identifikationsnummer
|
||||||
|
[VARIANTE A – USt-IdNr. vorhanden:]
|
||||||
|
Umsatzsteuer-Identifikationsnummer gemäß § 27a Umsatzsteuergesetz: DE[XXXXXXXXX]
|
||||||
|
[VARIANTE B – keine USt-IdNr., aber W-IdNr. bereits vom Bundeszentralamt für Steuern mitgeteilt:]
|
||||||
|
Wirtschafts-Identifikationsnummer gemäß § 139c Abgabenordnung: DE[XXXXXXXXX]-[XXXXX]
|
||||||
|
[VARIANTE C – weder USt-IdNr. noch W-IdNr. zugeteilt: diesen Abschnitt vollständig weglassen. Die Steuernummer des Finanzamts gehört NICHT ins Impressum.]
|
||||||
|
[OPTIONAL, nur falls Kleinunternehmerin nach § 19 UStG: „Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.“ – rechtlich nicht erforderlich, aber üblich]
|
||||||
|
|
||||||
|
[OPTIONAL – nur falls vorhanden, Angabe nach § 2 Abs. 1 Nr. 11 DL-InfoV:]
|
||||||
|
Berufshaftpflichtversicherung: [NAME UND ANSCHRIFT DES VERSICHERERS], räumlicher Geltungsbereich: [z. B. Deutschland / Europa]
|
||||||
|
|
||||||
|
Berufliche Tätigkeit
|
||||||
|
Keynote Speakerin, Trainerin und Coach (Persönlichkeits- und Verhaltensanalysen, 1:1 Coaching, Trainings & Workshops, Keynotes). Es handelt sich nicht um reglementierte Berufe; eine Kammerzugehörigkeit besteht nicht.
|
||||||
|
[Hinweis: Dieser Absatz ist freiwillig – § 5 Abs. 1 Nr. 5 DDG gilt nur für reglementierte Berufe. Er kann entfallen oder als Transparenzhinweis stehen bleiben.]
|
||||||
|
|
||||||
|
Inhaltlich verantwortlich gemäß § 18 Abs. 2 MStV
|
||||||
|
Anelia Wolf, [STRASSE UND HAUSNUMMER], [PLZ] [ORT]
|
||||||
|
[Hinweis: Nur bei journalistisch-redaktionellen Inhalten (Blog, News) Pflicht – freiwillige Angabe wird empfohlen. Kein Postfach.]
|
||||||
|
|
||||||
|
Verbraucherstreitbeilegung
|
||||||
|
Ich bin nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle im Sinne des Verbraucherstreitbeilegungsgesetzes (VSBG) teilzunehmen.
|
||||||
|
[Hinweis: Für Unternehmen mit bis zu 10 Beschäftigten keine Pflicht nach § 36 VSBG; Angabe ist üblich und unschädlich. KEINEN Link zur EU-Plattform zur Online-Streitbeilegung (OS-Plattform) aufnehmen – die Plattform wurde zum 20.07.2025 abgeschaltet, ein solcher Link wäre irreführend.]
|
||||||
|
|
||||||
|
Haftung für Inhalte
|
||||||
|
Die Inhalte dieser Website wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte übernehme ich jedoch keine Gewähr. Als Diensteanbieterin bin ich für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich.
|
||||||
|
[Hinweis: Frühere Textbausteine mit Verweis auf „§§ 7–10 TMG“ nicht mehr verwenden – diese Normen sind entfallen; die Haftungsprivilegien regelt heute der Digital Services Act (VO (EU) 2022/2065). Für eine Seite ohne fremde/nutzergenerierte Inhalte ist der Absatz ohnehin nur ein Hinweis, keine Pflicht.]
|
||||||
|
|
||||||
|
Haftung für Links
|
||||||
|
Diese Website enthält Links zu externen Websites Dritter, auf deren Inhalte ich keinen Einfluss habe. Für diese fremden Inhalte ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft; rechtswidrige Inhalte waren zu diesem Zeitpunkt nicht erkennbar. Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Links umgehend entfernen.
|
||||||
|
|
||||||
|
Urheberrecht
|
||||||
|
Die auf dieser Website veröffentlichten Inhalte, Texte, Fotos, Grafiken sowie das Logo und die Wortmarke „Das WOLF-Prinzip“ unterliegen dem deutschen Urheber- und Markenrecht. Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechts bedürfen der schriftlichen Zustimmung von Anelia Wolf.
|
||||||
|
[Hinweis: „Wortmarke“ nur schreiben, wenn „WOLF-Prinzip“ tatsächlich als Marke eingetragen ist – sonst „Bezeichnung“.]
|
||||||
|
|
||||||
|
Bildnachweis
|
||||||
|
Porträtfotos: [NAME FOTOGRAF:IN, ggf. Website]
|
||||||
|
Logo und Gestaltung: [DESIGNER:IN / AGENTUR]
|
||||||
|
Website: [AGENTUR, optional]
|
||||||
|
|
||||||
|
Stand: [DATUM]
|
||||||
|
=== DATENSCHUTZ ===
|
||||||
|
Datenschutzerklärung
|
||||||
|
|
||||||
|
Stand: [DATUM]
|
||||||
|
[Hinweis zur Anrede: Rechtstexte hier in „Sie“-Form – üblich und auch bei Du-Websites verbreitet. Wenn die Marke konsequent „du“ sein soll, kann der Text 1:1 auf „du“ umgestellt werden; rechtlich ist beides gleichwertig.]
|
||||||
|
|
||||||
|
1. Verantwortliche
|
||||||
|
Verantwortliche im Sinne der Datenschutz-Grundverordnung (DSGVO) und anderer datenschutzrechtlicher Bestimmungen ist:
|
||||||
|
Anelia Wolf
|
||||||
|
[STRASSE UND HAUSNUMMER]
|
||||||
|
[PLZ] [ORT]
|
||||||
|
Telefon: +49 174 4555115
|
||||||
|
E-Mail: info@anelia-wolf.com
|
||||||
|
|
||||||
|
Eine Datenschutzbeauftragte bzw. ein Datenschutzbeauftragter ist nicht benannt, da die gesetzlichen Voraussetzungen für eine Benennungspflicht (Art. 37 DSGVO, § 38 BDSG) nicht vorliegen.
|
||||||
|
|
||||||
|
2. Überblick: Was diese Website tut – und was nicht
|
||||||
|
Diese Website dient ausschließlich der Information über meine Leistungen. Sie
|
||||||
|
– setzt keine Cookies und speichert keine Informationen in Ihrem Endgerät (kein Local Storage, kein Session Storage),
|
||||||
|
– verwendet keine Analyse-, Tracking- oder Marketing-Dienste (kein Google Analytics, keine Pixel, kein Fingerprinting),
|
||||||
|
– bindet keine Inhalte von Drittanbietern ein (keine externen Schriftarten, keine Videos, keine Karten, keine Social-Media-Plugins),
|
||||||
|
– enthält kein Kontaktformular und keinen Newsletter.
|
||||||
|
Alle Schriftarten, Skripte und Bilder werden vom Server des unten genannten Hosting-Anbieters ausgeliefert. Ein Cookie-Banner ist deshalb nicht erforderlich.
|
||||||
|
Personenbezogene Daten werden nur in zwei Fällen verarbeitet: beim technischen Abruf der Seiten (Server-Logfiles, Ziffer 3) und wenn Sie mich per E-Mail oder Telefon kontaktieren (Ziffer 4).
|
||||||
|
[Hinweis für die Entwicklung: Dieser Absatz ist nur wahr, wenn der Build wirklich keine Requests an Dritte auslöst und kein localStorage nutzt. Vor Livegang im Netzwerk-Tab und in den Application-Storage-Tools prüfen. Falls ein Theme-Toggle o. Ä. localStorage nutzt: entfernen oder Ziffer 2 und 6 anpassen.]
|
||||||
|
|
||||||
|
3. Hosting und Server-Logfiles
|
||||||
|
Diese Website wird gehostet bei:
|
||||||
|
[HOSTING-ANBIETER: Firma, Anschrift, Land] („Hoster“).
|
||||||
|
Beim Aufruf dieser Website übermittelt Ihr Browser automatisch Daten an den Server des Hosters, die in sogenannten Server-Logfiles gespeichert werden:
|
||||||
|
– IP-Adresse des anfragenden Geräts,
|
||||||
|
– Datum und Uhrzeit des Zugriffs,
|
||||||
|
– aufgerufene Seite bzw. Datei und übertragene Datenmenge,
|
||||||
|
– HTTP-Statuscode,
|
||||||
|
– Referrer-URL (die zuvor besuchte Seite, sofern Ihr Browser sie übermittelt),
|
||||||
|
– Browsertyp und -version sowie Betriebssystem.
|
||||||
|
Diese Daten werden nicht mit anderen Datenquellen zusammengeführt und nicht ausgewertet, um einzelne Personen zu identifizieren.
|
||||||
|
Zweck: technischer Betrieb, Stabilität und Sicherheit der Website (z. B. Abwehr von Angriffen, Fehleranalyse).
|
||||||
|
Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse an einem sicheren und funktionsfähigen Betrieb der Website.
|
||||||
|
Speicherdauer: Die Logfiles werden nach [ANZAHL TAGE – beim Hoster erfragen; üblich sind 7 bis 14 Tage] Tagen gelöscht, sofern nicht ein sicherheitsrelevanter Vorfall eine längere Aufbewahrung erfordert.
|
||||||
|
Empfänger: Der Hoster verarbeitet diese Daten in meinem Auftrag. Mit ihm besteht ein Vertrag zur Auftragsverarbeitung gemäß Art. 28 DSGVO.
|
||||||
|
[NUR FALLS DER HOSTER SEINEN SITZ AUSSERHALB DER EU/DES EWR HAT, z. B. Vercel Inc., Netlify Inc., Cloudflare Inc. (USA):]
|
||||||
|
Der Hoster hat seinen Sitz in [LAND]. Die Übermittlung personenbezogener Daten dorthin stützt sich auf [den Angemessenheitsbeschluss der EU-Kommission zum EU-US Data Privacy Framework, unter dem der Anbieter zertifiziert ist (Art. 45 DSGVO) / die Standardvertragsklauseln der EU-Kommission (Art. 46 Abs. 2 lit. c DSGVO)]. Eine Kopie der Garantien stelle ich auf Anfrage zur Verfügung.
|
||||||
|
[BEI EU-HOSTER DIESEN ABSATZ STREICHEN.]
|
||||||
|
|
||||||
|
4. Kontaktaufnahme per E-Mail oder Telefon
|
||||||
|
Wenn Sie mich per E-Mail (info@anelia-wolf.com) oder telefonisch (+49 174 4555115) kontaktieren, verarbeite ich die von Ihnen mitgeteilten Daten (z. B. Name, E-Mail-Adresse, Telefonnummer, Unternehmen, Inhalt Ihrer Anfrage), um Ihre Anfrage zu beantworten und – sofern gewünscht – ein Angebot zu erstellen oder einen Termin für ein Kennenlerngespräch zu vereinbaren.
|
||||||
|
Rechtsgrundlage: Art. 6 Abs. 1 lit. b DSGVO, soweit Ihre Anfrage auf den Abschluss oder die Durchführung eines Vertrags (Coaching, Analyse, Training, Keynote) gerichtet ist; im Übrigen Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse an der Beantwortung von Anfragen.
|
||||||
|
Speicherdauer: Ich lösche Ihre Anfrage, sobald sie abschließend bearbeitet ist und keine weitere Kommunikation zu erwarten ist, spätestens jedoch nach [z. B. 12] Monaten. Unterliegen Nachrichten gesetzlichen Aufbewahrungspflichten (Geschäftsbriefe: 6 Jahre, Buchungsbelege und Rechnungen: 8 Jahre nach § 147 AO bzw. § 257 HGB), werden sie erst nach Ablauf dieser Fristen gelöscht.
|
||||||
|
Empfänger: Für E-Mail und Kalender nutze ich [E-MAIL-/OFFICE-ANBIETER, z. B. IONOS SE, Elgendorfer Str. 57, 56410 Montabaur / Microsoft Ireland Operations Ltd. / Google Ireland Ltd. – Firma, Anschrift, Land]. Mit dem Anbieter besteht ein Vertrag zur Auftragsverarbeitung gemäß Art. 28 DSGVO. [Bei Anbietern mit Drittlandbezug: Absatz zur Übermittlung wie in Ziffer 3 ergänzen.]
|
||||||
|
Eine Weitergabe Ihrer Daten an sonstige Dritte erfolgt nicht.
|
||||||
|
|
||||||
|
5. Externe Links
|
||||||
|
Diese Website enthält Links zu externen Websites (z. B. LinkedIn, herCAREER). Beim bloßen Anklicken eines Links werden von dieser Website keine Daten an den Betreiber der Zielseite übertragen; ab dem Aufruf der Zielseite gelten ausschließlich die Datenschutzbestimmungen des jeweiligen Anbieters. Social-Media-Plugins, Share-Buttons oder Tracking-Pixel dieser Anbieter sind auf dieser Website nicht eingebunden.
|
||||||
|
LinkedIn wird betrieben von der LinkedIn Ireland Unlimited Company, Wilton Place, Dublin 2, Irland. Datenschutzerklärung: https://www.linkedin.com/legal/privacy-policy
|
||||||
|
|
||||||
|
6. Schriftarten
|
||||||
|
Die auf dieser Website verwendeten Schriftarten [SCHRIFTNAMEN, z. B. Jost, Inter] sind lokal auf dem Webserver gespeichert und werden ausschließlich von dort ausgeliefert. Es findet keine Verbindung zu Servern von Google (Google Fonts) oder anderen Schriftanbietern statt; Ihre IP-Adresse wird dadurch nicht an Dritte übermittelt.
|
||||||
|
|
||||||
|
7. Verschlüsselung
|
||||||
|
Diese Website nutzt eine TLS-/SSL-Verschlüsselung (erkennbar an „https://“ und dem Schloss-Symbol in der Adresszeile Ihres Browsers). Daten, die zwischen Ihrem Browser und dem Server übertragen werden, können dadurch nicht von Dritten mitgelesen werden.
|
||||||
|
|
||||||
|
8. Ihre Rechte als betroffene Person
|
||||||
|
Sie haben mir gegenüber folgende Rechte hinsichtlich der Sie betreffenden personenbezogenen Daten:
|
||||||
|
– Recht auf Auskunft (Art. 15 DSGVO),
|
||||||
|
– Recht auf Berichtigung (Art. 16 DSGVO),
|
||||||
|
– Recht auf Löschung (Art. 17 DSGVO),
|
||||||
|
– Recht auf Einschränkung der Verarbeitung (Art. 18 DSGVO),
|
||||||
|
– Recht auf Datenübertragbarkeit (Art. 20 DSGVO),
|
||||||
|
– Recht auf Widerruf einer erteilten Einwilligung mit Wirkung für die Zukunft (Art. 7 Abs. 3 DSGVO).
|
||||||
|
|
||||||
|
Widerspruchsrecht (Art. 21 DSGVO)
|
||||||
|
Sie haben das Recht, aus Gründen, die sich aus Ihrer besonderen Situation ergeben, jederzeit gegen die Verarbeitung Sie betreffender personenbezogener Daten, die auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO erfolgt, Widerspruch einzulegen. Ich verarbeite die personenbezogenen Daten dann nicht mehr, es sei denn, ich kann zwingende schutzwürdige Gründe für die Verarbeitung nachweisen, die Ihre Interessen, Rechte und Freiheiten überwiegen, oder die Verarbeitung dient der Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen.
|
||||||
|
|
||||||
|
Zur Ausübung Ihrer Rechte genügt eine formlose E-Mail an info@anelia-wolf.com.
|
||||||
|
|
||||||
|
Beschwerderecht bei einer Aufsichtsbehörde (Art. 77 DSGVO)
|
||||||
|
Unbeschadet anderer Rechtsbehelfe haben Sie das Recht, sich bei einer Datenschutz-Aufsichtsbehörde zu beschweren, insbesondere in dem Mitgliedstaat Ihres Aufenthaltsorts, Ihres Arbeitsplatzes oder des Orts des mutmaßlichen Verstoßes. Die für mich zuständige Aufsichtsbehörde ist:
|
||||||
|
[AUFSICHTSBEHÖRDE DES BUNDESLANDES, in dem Anelia Wolf ihren Sitz hat – z. B. Bayern: Bayerisches Landesamt für Datenschutzaufsicht (BayLDA), Promenade 18, 91522 Ansbach, www.lda.bayern.de · Berlin: Berliner Beauftragte für Datenschutz und Informationsfreiheit, Alt-Moabit 59–61, 10555 Berlin · Nordrhein-Westfalen: Landesbeauftragte für Datenschutz und Informationsfreiheit NRW, Kavalleriestraße 2–4, 40213 Düsseldorf · Hessen: Der Hessische Beauftragte für Datenschutz und Informationsfreiheit, Gustav-Stresemann-Ring 1, 65189 Wiesbaden]
|
||||||
|
Eine Übersicht aller Landesbehörden: https://www.bfdi.bund.de/DE/Service/Anschriften/Laender/Laender-node.html
|
||||||
|
|
||||||
|
9. Pflicht zur Bereitstellung, automatisierte Entscheidungen
|
||||||
|
Die Bereitstellung Ihrer personenbezogenen Daten ist weder gesetzlich noch vertraglich vorgeschrieben. Ohne Ihre Kontaktdaten kann ich eine Anfrage jedoch nicht beantworten. Eine automatisierte Entscheidungsfindung einschließlich Profiling (Art. 22 DSGVO) findet nicht statt.
|
||||||
|
|
||||||
|
10. Änderungen dieser Datenschutzerklärung
|
||||||
|
Ich passe diese Datenschutzerklärung an, sobald sich die Website oder die Rechtslage ändert – etwa bei Einführung eines Kontaktformulars, eines Newsletters, einer Online-Terminbuchung oder von Video-Einbettungen. Es gilt die jeweils auf dieser Seite veröffentlichte Fassung.
|
||||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
22
next.config.ts
Normal file
22
next.config.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
// Static HTML export → deployable on any web host (Hetzner, Netlify, Vercel, plain FTP)
|
||||||
|
output: "export",
|
||||||
|
// /leistungen/coaching/ → out/leistungen/coaching/index.html (works on dumb static hosts)
|
||||||
|
trailingSlash: true,
|
||||||
|
images: {
|
||||||
|
// No image optimisation server in a static export
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
|
reactStrictMode: true,
|
||||||
|
experimental: {
|
||||||
|
/* Ohne dieses Flag hat der Export bei ZWEI Wurzel-Layouts (Deutsch und Englisch)
|
||||||
|
keine gestaltete 404 mehr: Next findet kein gemeinsames Layout und schreibt seine
|
||||||
|
eigene, ungestaltete Seite nach out/404.html. src/app/global-not-found.tsx ersetzt
|
||||||
|
sie und rendert das Dokument selbst. */
|
||||||
|
globalNotFound: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
6855
package-lock.json
generated
Normal file
6855
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "anelia-wolf-website",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"gsap": "^3.15.0",
|
||||||
|
"lenis": "^1.3.26",
|
||||||
|
"next": "16.3.4",
|
||||||
|
"react": "19.2.8",
|
||||||
|
"react-dom": "19.2.8",
|
||||||
|
"tailwind-merge": "^3.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.3.4",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
44
public/brand/README.md
Normal file
44
public/brand/README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Brand-Assets
|
||||||
|
|
||||||
|
## Was hier liegt
|
||||||
|
|
||||||
|
| Datei | Inhalt | Verwendung |
|
||||||
|
|---|---|---|
|
||||||
|
| `logo-mark.svg` | Das W-Monogramm mit Wolfskopf, aus dem Original-Logo der Kundin abgeleitet | Header, Hero, WOLF-Prinzip, Footer, Vorhang beim Seitenwechsel, 404 |
|
||||||
|
| `logo-full.png` | Komplettes Lockup (Monogramm + Wortmarke) auf Tinte, 2400 × 1260 px | Social-Preview (OpenGraph), z. B. beim Teilen auf LinkedIn |
|
||||||
|
|
||||||
|
## Wie `logo-mark.svg` entstanden ist
|
||||||
|
|
||||||
|
Quelle: `v3-anelia-wolf-gold.svg` der Kundin. Daraus wurde für die Website aufbereitet:
|
||||||
|
|
||||||
|
- C2PA-Metadaten entfernt. Der Block war rund 16 KB groß und wird auf **jeder** Seite
|
||||||
|
mit eingebettet, weil das Monogramm inline im HTML steht.
|
||||||
|
- Weißes Hintergrundrechteck entfernt, damit die Marke auf Tinte und auf Elfenbein sitzt.
|
||||||
|
- Wortmarke entfernt. „ANELIA WOLF“ steht auf der Website als echter Text (Jost, gesperrt),
|
||||||
|
nicht als Bild: das bleibt bei jeder Fenstergröße scharf, ist vorlesbar und lässt sich
|
||||||
|
markieren. Das Original-Lockup steckt in `logo-full.png`.
|
||||||
|
- `viewBox` auf die Marke beschnitten, damit sie in ihrem Feld keine Luft verschenkt.
|
||||||
|
- Die schwarzen Flächen auf `currentColor` gestellt. Dadurch ist das W auf dunklen Flächen
|
||||||
|
elfenbein und auf hellen tinte, ohne zweite Datei. Der Wolf behält seinen Goldverlauf.
|
||||||
|
- Feste IDs vergeben: `v-left`, `v-right`, `wolf`. `BrandMark.tsx` hängt daran die Klassen
|
||||||
|
für die Animation.
|
||||||
|
|
||||||
|
**Die Original-Koordinaten wurden nicht verändert**, nur der Ausschnitt. Der Goldverlauf
|
||||||
|
(`userSpaceOnUse`) trifft deshalb exakt dieselben Stellen wie im Original.
|
||||||
|
|
||||||
|
## Wenn ein neues Logo kommt
|
||||||
|
|
||||||
|
Die Datei einfach ersetzen. Voraussetzungen:
|
||||||
|
|
||||||
|
- **SVG, kein `<script>`.** Ein Skript bricht den Build absichtlich ab, mit Fehlermeldung.
|
||||||
|
Die Datei wird inline eingebettet, auf einer Seite, die „keine externen Skripte“ zusagt.
|
||||||
|
- Kein Hintergrundrechteck, transparent.
|
||||||
|
- Die schwarzen Teile auf `fill="currentColor"`, der Wolf auf seinen Verlauf.
|
||||||
|
- IDs `v-left`, `v-right`, `wolf` vergeben, sonst wird in DOM-Reihenfolge animiert.
|
||||||
|
|
||||||
|
Fehlt die Datei ganz, zeichnet die Website automatisch die eigene Näherung
|
||||||
|
(`src/components/brand/WolfMark.tsx`). Nichts bricht.
|
||||||
|
|
||||||
|
## Portraits
|
||||||
|
|
||||||
|
Fotos von Anelia gehören nach `public/photos/`, siehe die dortige README.
|
||||||
BIN
public/brand/logo-full.png
Normal file
BIN
public/brand/logo-full.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
10
public/brand/logo-mark.svg
Normal file
10
public/brand/logo-mark.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="336.95 179.47 606.6 467.63" fill="none">
|
||||||
|
<title>Anelia Wolf – Monogramm</title>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="wolf-gold" gradientUnits="userSpaceOnUse" x1="470" y1="150" x2="700" y2="830"><stop offset="0" stop-color="#7a5518"></stop><stop offset="0.1" stop-color="#c69544"></stop><stop offset="0.22" stop-color="#f7e6b6"></stop><stop offset="0.33" stop-color="#dcb96f"></stop><stop offset="0.46" stop-color="#a87a2a"></stop><stop offset="0.58" stop-color="#ecd69d"></stop><stop offset="0.72" stop-color="#c18f3c"></stop><stop offset="0.86" stop-color="#8a6220"></stop><stop offset="1" stop-color="#5c3e10"></stop></linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path id="v-left" fill="currentColor" fill-rule="evenodd" d="M342.95 252.83L437.95 252.76L492.27 382.54L496.27 442.81Q496.6 447.8 494.67 443.19L423.12 271.95L372.83 271.87L518.07 620.85L546.52 621.13L604.42 488.33L608.92 499.73L616.45 511.75L559.5 641.01L505.22 641.1L342.95 252.83Z"/>
|
||||||
|
<path id="v-right" fill="currentColor" fill-rule="evenodd" d="M745.4 463.5C749.15 468.61 750.09 474.4 753.33 480.23C758.39 473.22 759.96 464.57 765 457.42C771.39 457.07 779.8 456.6 785.58 458.18C783.62 466.04 779.77 473.76 776.69 481.41C768.74 501.18 760.7 520.88 752.71 540.64C752.64 540.45 729.39 482.59 730.34 476.82C731.53 469.62 739.41 465.83 745.4 463.5Z M842.48 252.76L937.55 252.86L888.13 363.11L870.54 351.72L905.13 271.88L856.63 271.88L831.16 334.16L812.13 325.77L842.48 252.76Z"/>
|
||||||
|
<path id="wolf" fill="url(#wolf-gold)" fill-rule="evenodd" d="M687 359.6C682 363.5 656 376 647.55 385.14C601.07 434.39 633.25 498.47 685.03 526.75C685.62 512.43 682.57 495.89 687.36 481.35C694 462 722 432 753.71 426.64C763.02 424.94 771.33 426.55 780.51 427.53C798.79 429.48 817.6 426.08 832.72 415.93C839.61 411.31 845.74 405.45 848.35 397.26C850.68 389.96 848.54 383.49 843.11 378.32C841 376.31 838.62 376.01 836.24 374.49C816.41 361.86 781.23 353.42 767.56 335.52C762.95 329.49 759.75 322.18 755.35 315.96C744.69 300.87 727.3 288.87 710.47 281.69C705.29 279.48 699.01 280.11 694.14 277.5C674.42 266.92 684.83 235.89 673.46 220.38C669.38 221.97 667.85 224.66 664.45 227.55C642.95 245.82 628.54 270.04 619.1 296.6C616.5 303.91 616.22 311.48 611.92 318.1L611.92 318.1C607.99 312.37 609.1 304.7 605.64 299.16C553.34 310.34 485.02 429.02 538.37 471.48C564.6 447.08 569.77 404.5 601.05 377.94C583.83 423.29 565.1 466.85 548.33 512.39C544.52 522.73 541.02 533.22 537.24 543.63C534.16 532.78 531.01 522.34 527.59 511.73C501.78 431.56 493.32 351.18 570.95 295.53C584.92 285.51 604.49 279.71 612.01 263.96C625.62 235.47 644.79 212.66 671.62 195.53C677.38 191.86 682.46 187.37 688.94 185.47C695.28 195.04 697.23 249.67 703.64 257.15C705.86 259.73 709.77 259.23 712.78 260.45C723.59 264.84 735.12 270.75 744.18 277.96C748.68 281.54 758.42 290.33 761.6 294.79C768.13 303.94 776.33 323.62 786.09 329.27C804.11 339.71 867.4 359.72 877.71 372.79C882.97 379.45 877.44 389.1 874.62 395.45C855 439.7 823.99 442.2 780.52 445.24C763 445.4 739 449 720.58 461.83C707.45 470.98 705.5 489 703 500C703 515 722 590 735 625.2L695.4 569.4C690.6 562.2 685.1 555.4 680.21 549.55C672.45 541.98 662.5 537.14 653.76 530.87C634.81 517.29 621.02 498.92 611.58 477.69C602.38 456.99 602.45 427.05 612.43 406.55C624.19 382.42 642.63 364.74 669.64 358.96C676.49 357.5 682 356.8 687 359.6Z M754.75 342.41C742 347.13 713.67 332.6 707.02 321.37C723.93 304.79 750.58 324.07 754.75 342.41Z"/>
|
||||||
|
<path class="wolfmark-wolf" fill="currentColor" fill-rule="evenodd" d="M857 439.88L858.81 439.56L858.78 441.31L776.1 641.09L721.98 641.07L696 570L735 624.6L761.9 624.36L832.04 452.74L856.25 440.39L857 439.88Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.5 KiB |
10
public/photos/README.md
Normal file
10
public/photos/README.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Portraits – hier die Fotos ablegen
|
||||||
|
|
||||||
|
| Datei | Format | Verwendung |
|
||||||
|
|---|---|---|
|
||||||
|
| `anelia-hero.jpg` | Hochkant 4:5, min. 1600 px breit, ruhiger Hintergrund | Startseite Hero |
|
||||||
|
| `anelia-portrait.jpg` | Hochkant 3:4, min. 1400 px breit | "Über mich"-Sektion |
|
||||||
|
| `anelia-stage.jpg` | Querformat 3:2, Bühnen-/Vortragsfoto | Keynotes-Seite |
|
||||||
|
|
||||||
|
Fehlt eine Datei, blendet die Seite den jeweiligen Bildplatz sauber aus bzw. nutzt
|
||||||
|
eine typografische Alternative – nichts bricht.
|
||||||
15
src/app/(de)/datenschutz/page.tsx
Normal file
15
src/app/(de)/datenschutz/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { LegalPage } from "@/components/legal/LegalPage";
|
||||||
|
import { datenschutz } from "@/content/legal";
|
||||||
|
import { alternatesFor } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: datenschutz.meta.title,
|
||||||
|
description: datenschutz.meta.description,
|
||||||
|
alternates: alternatesFor("de", "datenschutz"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** /datenschutz/ – Spec §8. Copy aus src/content/legal.ts, Platzhalter sichtbar in --gold-deep. */
|
||||||
|
export default function DatenschutzPage() {
|
||||||
|
return <LegalPage id="datenschutz" content={datenschutz} />;
|
||||||
|
}
|
||||||
15
src/app/(de)/impressum/page.tsx
Normal file
15
src/app/(de)/impressum/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { LegalPage } from "@/components/legal/LegalPage";
|
||||||
|
import { impressum } from "@/content/legal";
|
||||||
|
import { alternatesFor } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: impressum.meta.title,
|
||||||
|
description: impressum.meta.description,
|
||||||
|
alternates: alternatesFor("de", "impressum"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** /impressum/ – Spec §8. Copy aus src/content/legal.ts, Platzhalter sichtbar in --gold-deep. */
|
||||||
|
export default function ImpressumPage() {
|
||||||
|
return <LegalPage id="impressum" content={impressum} />;
|
||||||
|
}
|
||||||
25
src/app/(de)/layout.tsx
Normal file
25
src/app/(de)/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import "../globals.css";
|
||||||
|
import { Shell } from "@/components/layout/Shell";
|
||||||
|
import { rootMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wurzel-Layout der deutschen Fassung (`<html lang="de">`).
|
||||||
|
*
|
||||||
|
* Die Gruppe `(de)` taucht in keiner URL auf – die deutschen Adressen bleiben
|
||||||
|
* unverändert `/`, `/leistungen/…`, `/impressum/`. Sie existiert nur, damit die
|
||||||
|
* englische Fassung unter `app/en/` ein eigenes Wurzel-Layout mit `lang="en"`
|
||||||
|
* bekommen kann; das `<html>`-Element schreibt im App Router ausschließlich das
|
||||||
|
* Wurzel-Layout.
|
||||||
|
*/
|
||||||
|
export const metadata: Metadata = rootMetadata("de");
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#0f0e0c",
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DeLayout({ children }: LayoutProps<"/">) {
|
||||||
|
return <Shell lang="de">{children}</Shell>;
|
||||||
|
}
|
||||||
22
src/app/(de)/leistungen/coaching/page.tsx
Normal file
22
src/app/(de)/leistungen/coaching/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { CoachingBody } from "@/components/pages/CoachingBody";
|
||||||
|
import { coaching, specLabels } from "@/content/coaching";
|
||||||
|
import { alternatesFor, OG_LOCALE } from "@/lib/i18n";
|
||||||
|
import { site } from "@/lib/site";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: coaching.meta.title,
|
||||||
|
description: coaching.meta.description,
|
||||||
|
alternates: alternatesFor("de", "coaching"),
|
||||||
|
openGraph: {
|
||||||
|
title: `${coaching.meta.title} – ${site.name}`,
|
||||||
|
description: coaching.meta.description,
|
||||||
|
url: alternatesFor("de", "coaching").canonical,
|
||||||
|
locale: OG_LOCALE.de,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** /leistungen/coaching/ – Aufbau in <CoachingBody/>, Texte in src/content/coaching.ts. */
|
||||||
|
export default function CoachingPage() {
|
||||||
|
return <CoachingBody content={coaching} specLabels={specLabels} />;
|
||||||
|
}
|
||||||
22
src/app/(de)/leistungen/keynotes/page.tsx
Normal file
22
src/app/(de)/leistungen/keynotes/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { KeynotesBody } from "@/components/pages/KeynotesBody";
|
||||||
|
import { keynotes, talkLabels } from "@/content/keynotes";
|
||||||
|
import { alternatesFor, OG_LOCALE } from "@/lib/i18n";
|
||||||
|
import { site } from "@/lib/site";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: keynotes.meta.title,
|
||||||
|
description: keynotes.meta.description,
|
||||||
|
alternates: alternatesFor("de", "keynotes"),
|
||||||
|
openGraph: {
|
||||||
|
title: `${keynotes.meta.title} – ${site.name}`,
|
||||||
|
description: keynotes.meta.description,
|
||||||
|
url: alternatesFor("de", "keynotes").canonical,
|
||||||
|
locale: OG_LOCALE.de,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** /leistungen/keynotes/ – Aufbau in <KeynotesBody/>, Texte in src/content/keynotes.ts. */
|
||||||
|
export default function KeynotesPage() {
|
||||||
|
return <KeynotesBody content={keynotes} talkLabels={talkLabels} />;
|
||||||
|
}
|
||||||
23
src/app/(de)/leistungen/trainings/page.tsx
Normal file
23
src/app/(de)/leistungen/trainings/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { TrainingsBody } from "@/components/pages/TrainingsBody";
|
||||||
|
import { about } from "@/content/home";
|
||||||
|
import { trainings } from "@/content/trainings";
|
||||||
|
import { alternatesFor, OG_LOCALE } from "@/lib/i18n";
|
||||||
|
import { site } from "@/lib/site";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: trainings.meta.title,
|
||||||
|
description: trainings.meta.description,
|
||||||
|
alternates: alternatesFor("de", "trainings"),
|
||||||
|
openGraph: {
|
||||||
|
title: `${trainings.meta.title} – ${site.name}`,
|
||||||
|
description: trainings.meta.description,
|
||||||
|
url: alternatesFor("de", "trainings").canonical,
|
||||||
|
locale: OG_LOCALE.de,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** /leistungen/trainings/ – Aufbau in <TrainingsBody/>, Texte in src/content/trainings.ts. */
|
||||||
|
export default function TrainingsPage() {
|
||||||
|
return <TrainingsBody content={trainings} marginalia={about.marginalia} />;
|
||||||
|
}
|
||||||
20
src/app/(de)/not-found.tsx
Normal file
20
src/app/(de)/not-found.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { NotFoundBody } from "@/components/pages/NotFoundBody";
|
||||||
|
import { notFound } from "@/content/not-found";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: notFound.meta.title,
|
||||||
|
description: notFound.meta.description,
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 404 innerhalb der deutschen Route-Gruppe – greift, wenn eine Seite `notFound()` ruft.
|
||||||
|
* Die Datei, die der Hoster bei einer unbekannten Adresse ausliefert (`out/404.html`),
|
||||||
|
* entsteht dagegen aus src/app/global-not-found.tsx: Bei zwei Wurzel-Layouts kann Next
|
||||||
|
* keine gemeinsame Fehlerseite mehr zusammensetzen und fiele sonst auf seine eigene,
|
||||||
|
* ungestaltete Seite zurück. Beide rendern denselben <NotFoundBody/>.
|
||||||
|
*/
|
||||||
|
export default function NotFound() {
|
||||||
|
return <NotFoundBody content={notFound} />;
|
||||||
|
}
|
||||||
16
src/app/(de)/page.tsx
Normal file
16
src/app/(de)/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { HomeBody } from "@/components/pages/HomeBody";
|
||||||
|
import { home } from "@/content/home";
|
||||||
|
import { alternatesFor } from "@/lib/i18n";
|
||||||
|
import { rootCopy } from "@/lib/metadata";
|
||||||
|
|
||||||
|
/* Titel fällt auf den Standard des Layouts zurück („Anelia Wolf – Keynote Speakerin …“). */
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
description: rootCopy.de.description,
|
||||||
|
alternates: alternatesFor("de", "home"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Startseite. Aufbau und JSON-LD liegen in <HomeBody/>, damit /en/ denselben Baum rendert. */
|
||||||
|
export default function Home() {
|
||||||
|
return <HomeBody lang="de" content={home} />;
|
||||||
|
}
|
||||||
69
src/app/(de)/schrift-test/TypeNav.tsx
Normal file
69
src/app/(de)/schrift-test/TypeNav.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Pairing } from "./fonts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index der Testseite: rechte Randleiste mit allen Nummern, die gerade sichtbare ist
|
||||||
|
* hervorgehoben. Springt über Lenis, damit der weiche Scroll der Seite nicht dagegen
|
||||||
|
* arbeitet. Kein Overlay über dem Entwurf – der Name steht in der Kopfzeile jeder
|
||||||
|
* Variante. TEMPORÄR – siehe fonts.ts.
|
||||||
|
*/
|
||||||
|
export function TypeNav({ pairings }: { pairings: readonly Pairing[] }) {
|
||||||
|
const [active, setActive] = useState(pairings[0]?.id ?? "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sections = pairings
|
||||||
|
.map((p) => document.getElementById(p.id))
|
||||||
|
.filter((el): el is HTMLElement => Boolean(el));
|
||||||
|
if (!sections.length) return;
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const visible = entries
|
||||||
|
.filter((e) => e.isIntersecting)
|
||||||
|
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
|
||||||
|
if (visible) setActive(visible.target.id);
|
||||||
|
},
|
||||||
|
{ rootMargin: "-40% 0px -40% 0px", threshold: [0, 0.25, 0.5, 1] },
|
||||||
|
);
|
||||||
|
sections.forEach((el) => io.observe(el));
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [pairings]);
|
||||||
|
|
||||||
|
const go = (id: string) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
const lenis = (window as unknown as { lenis?: { scrollTo: (t: HTMLElement, o?: object) => void } }).lenis;
|
||||||
|
if (lenis) lenis.scrollTo(el, { offset: -8 });
|
||||||
|
else el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<nav
|
||||||
|
aria-label="Schriftpaarungen"
|
||||||
|
className="fixed right-3 top-1/2 z-40 hidden -translate-y-1/2 flex-col gap-1 lg:flex"
|
||||||
|
>
|
||||||
|
{pairings.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => go(p.id)}
|
||||||
|
aria-current={p.id === active ? "true" : undefined}
|
||||||
|
title={p.name}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-[3px] font-mono text-[10px] tracking-[0.08em] tabular-nums transition-colors",
|
||||||
|
p.id === active ? "bg-gold text-ink" : "text-grey-warm hover:text-gold",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.no}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TypeNav;
|
||||||
274
src/app/(de)/schrift-test/fonts.ts
Normal file
274
src/app/(de)/schrift-test/fonts.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* Schriften für die Test-Seite `/schrift-test/`.
|
||||||
|
*
|
||||||
|
* TEMPORÄR. Diese Seite dient nur dazu, den Hero mit verschiedenen Schriftpaarungen
|
||||||
|
* nebeneinander zu sehen. Sobald die Entscheidung gefallen ist: den ganzen Ordner
|
||||||
|
* `src/app/schrift-test/` löschen. Damit verschwinden auch alle hier geladenen
|
||||||
|
* Schriftdateien wieder aus dem Build – die Live-Seite lädt nur Jost, DM Mono und
|
||||||
|
* die gewählte Display-Schrift.
|
||||||
|
*
|
||||||
|
* Alle Schriften kommen über `next/font/google`, werden also beim Bauen
|
||||||
|
* heruntergeladen und mit ausgeliefert. Auch die Testseite stellt damit KEINE
|
||||||
|
* externen Anfragen im Browser.
|
||||||
|
*
|
||||||
|
* Quelle der Paarungen 01–19: pagecloud.com/blog/best-google-fonts-pairings
|
||||||
|
* (dort umbenannt: „Source Sans Pro“ → Source Sans 3, „Source Serif Pro“ →
|
||||||
|
* Source Serif 4, „Muli“ → Mulish). 20–25 sind eigene Vorschläge im Sinne der
|
||||||
|
* Marke. 00 ist der aktuelle Stand als Vergleichsanker.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
Abril_Fatface,
|
||||||
|
Arvo,
|
||||||
|
Bodoni_Moda,
|
||||||
|
Cardo,
|
||||||
|
Cinzel,
|
||||||
|
Cormorant_Garamond,
|
||||||
|
Crimson_Text,
|
||||||
|
EB_Garamond,
|
||||||
|
Fauna_One,
|
||||||
|
Fjalla_One,
|
||||||
|
Fraunces,
|
||||||
|
Gloock,
|
||||||
|
Instrument_Serif,
|
||||||
|
Italiana,
|
||||||
|
Josefin_Sans,
|
||||||
|
Jost,
|
||||||
|
Karla,
|
||||||
|
Lato,
|
||||||
|
Ledger,
|
||||||
|
Libre_Baskerville,
|
||||||
|
Libre_Caslon_Display,
|
||||||
|
Lora,
|
||||||
|
Lustria,
|
||||||
|
Marcellus,
|
||||||
|
Merriweather,
|
||||||
|
Montserrat,
|
||||||
|
Mulish,
|
||||||
|
Newsreader,
|
||||||
|
Nixie_One,
|
||||||
|
Oswald,
|
||||||
|
Philosopher,
|
||||||
|
Pontano_Sans,
|
||||||
|
Poppins,
|
||||||
|
Proza_Libre,
|
||||||
|
Playfair_Display,
|
||||||
|
Source_Sans_3,
|
||||||
|
Source_Serif_4,
|
||||||
|
Spectral,
|
||||||
|
Stint_Ultra_Expanded,
|
||||||
|
Unica_One,
|
||||||
|
Yeseva_One,
|
||||||
|
} from "next/font/google";
|
||||||
|
|
||||||
|
/* --- Display-Schriften ---------------------------------------------------- */
|
||||||
|
const bodoni = Bodoni_Moda({ subsets: ["latin"], display: "swap", style: ["normal", "italic"], axes: ["opsz"] });
|
||||||
|
const playfair = Playfair_Display({ subsets: ["latin"], display: "swap", style: ["normal", "italic"] });
|
||||||
|
const cormorant = Cormorant_Garamond({ subsets: ["latin"], display: "swap", weight: ["300", "400"], style: ["normal", "italic"] });
|
||||||
|
const libreBaskerville = Libre_Baskerville({ subsets: ["latin"], display: "swap", weight: ["400", "700"], style: ["normal", "italic"] });
|
||||||
|
const yeseva = Yeseva_One({ subsets: ["latin"], display: "swap", weight: "400" });
|
||||||
|
const cardo = Cardo({ subsets: ["latin"], display: "swap", weight: ["400", "700"], style: ["normal", "italic"] });
|
||||||
|
const spectral = Spectral({ subsets: ["latin"], display: "swap", weight: ["300", "400"], style: ["normal", "italic"] });
|
||||||
|
const lora = Lora({ subsets: ["latin"], display: "swap", style: ["normal", "italic"] });
|
||||||
|
const cinzel = Cinzel({ subsets: ["latin"], display: "swap" });
|
||||||
|
const lustria = Lustria({ subsets: ["latin"], display: "swap", weight: "400" });
|
||||||
|
const oswald = Oswald({ subsets: ["latin"], display: "swap" });
|
||||||
|
const fjalla = Fjalla_One({ subsets: ["latin"], display: "swap", weight: "400" });
|
||||||
|
const abril = Abril_Fatface({ subsets: ["latin"], display: "swap", weight: "400" });
|
||||||
|
const philosopher = Philosopher({ subsets: ["latin"], display: "swap", weight: ["400", "700"], style: ["normal", "italic"] });
|
||||||
|
const unica = Unica_One({ subsets: ["latin"], display: "swap", weight: "400" });
|
||||||
|
const nixie = Nixie_One({ subsets: ["latin"], display: "swap", weight: "400" });
|
||||||
|
const sourceSerif = Source_Serif_4({ subsets: ["latin"], display: "swap", style: ["normal", "italic"] });
|
||||||
|
const arvo = Arvo({ subsets: ["latin"], display: "swap", weight: ["400", "700"], style: ["normal", "italic"] });
|
||||||
|
const stint = Stint_Ultra_Expanded({ subsets: ["latin"], display: "swap", weight: "400" });
|
||||||
|
const instrument = Instrument_Serif({ subsets: ["latin"], display: "swap", weight: "400", style: ["normal", "italic"] });
|
||||||
|
const fraunces = Fraunces({ subsets: ["latin"], display: "swap", style: ["normal", "italic"] });
|
||||||
|
const newsreader = Newsreader({ subsets: ["latin"], display: "swap", style: ["normal", "italic"] });
|
||||||
|
const marcellus = Marcellus({ subsets: ["latin"], display: "swap", weight: "400" });
|
||||||
|
const italiana = Italiana({ subsets: ["latin"], display: "swap", weight: "400" });
|
||||||
|
const gloock = Gloock({ subsets: ["latin"], display: "swap", weight: "400" });
|
||||||
|
const libreCaslon = Libre_Caslon_Display({ subsets: ["latin"], display: "swap", weight: "400" });
|
||||||
|
|
||||||
|
/* --- Brot- und Labelschriften --------------------------------------------- */
|
||||||
|
const jost = Jost({ subsets: ["latin"], display: "swap" });
|
||||||
|
const sourceSans = Source_Sans_3({ subsets: ["latin"], display: "swap" });
|
||||||
|
const prozaLibre = Proza_Libre({ subsets: ["latin"], display: "swap", weight: ["400", "500"] });
|
||||||
|
const montserrat = Montserrat({ subsets: ["latin"], display: "swap" });
|
||||||
|
const josefin = Josefin_Sans({ subsets: ["latin"], display: "swap" });
|
||||||
|
const karla = Karla({ subsets: ["latin"], display: "swap" });
|
||||||
|
const merriweather = Merriweather({ subsets: ["latin"], display: "swap" });
|
||||||
|
const faunaOne = Fauna_One({ subsets: ["latin"], display: "swap", weight: "400" });
|
||||||
|
const lato = Lato({ subsets: ["latin"], display: "swap", weight: ["300", "400"] });
|
||||||
|
const ebGaramond = EB_Garamond({ subsets: ["latin"], display: "swap" });
|
||||||
|
const poppins = Poppins({ subsets: ["latin"], display: "swap", weight: ["300", "400"] });
|
||||||
|
const mulish = Mulish({ subsets: ["latin"], display: "swap" });
|
||||||
|
const crimson = Crimson_Text({ subsets: ["latin"], display: "swap", weight: ["400", "600"] });
|
||||||
|
const ledger = Ledger({ subsets: ["latin"], display: "swap", weight: "400" });
|
||||||
|
const pontano = Pontano_Sans({ subsets: ["latin"], display: "swap" });
|
||||||
|
|
||||||
|
export type Emphasis = "italic" | "gold" | "italic-gold";
|
||||||
|
export type Source = "aktuell" | "pagecloud" | "eigen";
|
||||||
|
|
||||||
|
export interface Pairing {
|
||||||
|
/** Laufende Nummer, zweistellig. */
|
||||||
|
no: string;
|
||||||
|
/** Anker-Id der Sektion. */
|
||||||
|
id: string;
|
||||||
|
/** „Bodoni Moda + Jost“ */
|
||||||
|
name: string;
|
||||||
|
/** Ein Satz: was diese Paarung mit der Marke macht. */
|
||||||
|
note: string;
|
||||||
|
source: Source;
|
||||||
|
displayFamily: string;
|
||||||
|
bodyFamily: string;
|
||||||
|
/** Gewicht der Headline. */
|
||||||
|
displayWeight: number;
|
||||||
|
/** Optische Korrektur der Headline-Größe (1 = wie aktuell). */
|
||||||
|
scale: number;
|
||||||
|
/** Laufweite der Headline. */
|
||||||
|
tracking: string;
|
||||||
|
/** Betonungswort: echte Kursive, Gold, oder beides. Schriften ohne Kursive bekommen Gold. */
|
||||||
|
emphasis: Emphasis;
|
||||||
|
}
|
||||||
|
|
||||||
|
const f = (font: { style: { fontFamily: string } }) => font.style.fontFamily;
|
||||||
|
|
||||||
|
export const PAIRINGS: readonly Pairing[] = [
|
||||||
|
{
|
||||||
|
no: "00", id: "p00", name: "Bodoni Moda + Jost", source: "aktuell",
|
||||||
|
note: "Aktueller Stand. Didone-Haarlinien gegen das Futura-Genom der Wortmarke.",
|
||||||
|
displayFamily: f(bodoni), bodyFamily: f(jost), displayWeight: 400, scale: 1, tracking: "-0.015em", emphasis: "italic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "01", id: "p01", name: "Playfair Display + Source Sans 3", source: "pagecloud",
|
||||||
|
note: "Der Klassiker der Liste. Wärmer und runder als Bodoni, dafür deutlich häufiger gesehen.",
|
||||||
|
displayFamily: f(playfair), bodyFamily: f(sourceSans), displayWeight: 400, scale: 0.95, tracking: "-0.02em", emphasis: "italic-gold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "02", id: "p02", name: "Cormorant Garamond + Proza Libre", source: "pagecloud",
|
||||||
|
note: "Sehr feine Renaissance-Antiqua. Elegant, aber die 2025er-Standardwahl für Coaching-Seiten.",
|
||||||
|
displayFamily: f(cormorant), bodyFamily: f(prozaLibre), displayWeight: 300, scale: 1.1, tracking: "-0.01em", emphasis: "italic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "03", id: "p03", name: "Libre Baskerville + Montserrat", source: "pagecloud",
|
||||||
|
note: "Robuste Buchantiqua mit großer x-Höhe. Wirkt seriös und ruhig, weniger Mode.",
|
||||||
|
displayFamily: f(libreBaskerville), bodyFamily: f(montserrat), displayWeight: 400, scale: 0.82, tracking: "-0.02em", emphasis: "italic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "04", id: "p04", name: "Yeseva One + Josefin Sans", source: "pagecloud",
|
||||||
|
note: "Dekorative Display-Antiqua. Viel Charakter, aber schnell nach Boutique statt nach B2B.",
|
||||||
|
displayFamily: f(yeseva), bodyFamily: f(josefin), displayWeight: 400, scale: 0.9, tracking: "-0.01em", emphasis: "gold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "05", id: "p05", name: "Cardo + Josefin Sans", source: "pagecloud",
|
||||||
|
note: "Wissenschaftliche Antiqua mit geometrischer Sans. Nüchtern und wach.",
|
||||||
|
displayFamily: f(cardo), bodyFamily: f(josefin), displayWeight: 400, scale: 0.95, tracking: "-0.015em", emphasis: "italic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "06", id: "p06", name: "Spectral + Karla", source: "pagecloud",
|
||||||
|
note: "Bildschirm-Antiqua mit eigenwilliger Grotesk. Modern, technisch, wenig Pathos.",
|
||||||
|
displayFamily: f(spectral), bodyFamily: f(karla), displayWeight: 300, scale: 0.92, tracking: "-0.02em", emphasis: "italic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "07", id: "p07", name: "Lora + Merriweather", source: "pagecloud",
|
||||||
|
note: "Zwei Antiquas. Sehr lesbar, aber ohne Kontrast zwischen Überschrift und Text.",
|
||||||
|
displayFamily: f(lora), bodyFamily: f(merriweather), displayWeight: 400, scale: 0.88, tracking: "-0.02em", emphasis: "italic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "08", id: "p08", name: "Cinzel + Fauna One", source: "pagecloud",
|
||||||
|
note: "Römische Kapitalis, nur Versalien. Monumental, aber lange Wörter werden schwer.",
|
||||||
|
displayFamily: f(cinzel), bodyFamily: f(faunaOne), displayWeight: 400, scale: 0.66, tracking: "0.01em", emphasis: "gold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "09", id: "p09", name: "Lustria + Lato", source: "pagecloud",
|
||||||
|
note: "Weiche Antiqua mit neutraler Sans. Freundlich, etwas beliebig.",
|
||||||
|
displayFamily: f(lustria), bodyFamily: f(lato), displayWeight: 400, scale: 0.92, tracking: "-0.015em", emphasis: "gold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "10", id: "p10", name: "Oswald + EB Garamond", source: "pagecloud",
|
||||||
|
note: "Schmale Grotesk als Headline, Antiqua im Text. Laut, plakativ, Richtung Sport.",
|
||||||
|
displayFamily: f(oswald), bodyFamily: f(ebGaramond), displayWeight: 300, scale: 1.0, tracking: "-0.01em", emphasis: "gold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "11", id: "p11", name: "Fjalla One + Libre Baskerville", source: "pagecloud",
|
||||||
|
note: "Kräftige Schmalschrift. Sehr präsent, nimmt der Marke die Ruhe.",
|
||||||
|
displayFamily: f(fjalla), bodyFamily: f(libreBaskerville), displayWeight: 400, scale: 0.95, tracking: "-0.005em", emphasis: "gold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "12", id: "p12", name: "Abril Fatface + Poppins", source: "pagecloud",
|
||||||
|
note: "Fette Didone. Ein Statement, aber schwer und nah an Modemagazin-Klischee.",
|
||||||
|
displayFamily: f(abril), bodyFamily: f(poppins), displayWeight: 400, scale: 0.88, tracking: "-0.02em", emphasis: "gold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "13", id: "p13", name: "Montserrat + Cardo", source: "pagecloud",
|
||||||
|
note: "Grotesk als Headline, Antiqua im Text. Umgekehrte Rollenverteilung, sachlich.",
|
||||||
|
displayFamily: f(montserrat), bodyFamily: f(cardo), displayWeight: 300, scale: 0.82, tracking: "-0.02em", emphasis: "italic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "14", id: "p14", name: "Philosopher + Mulish", source: "pagecloud",
|
||||||
|
note: "Humanistische Sans mit Antiqua-Anklängen. Eigenwillig, etwas nostalgisch.",
|
||||||
|
displayFamily: f(philosopher), bodyFamily: f(mulish), displayWeight: 400, scale: 0.95, tracking: "-0.015em", emphasis: "italic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "15", id: "p15", name: "Unica One + Crimson Text", source: "pagecloud",
|
||||||
|
note: "Schmale Display-Grotesk. Technisch und kantig, passt eher zu Architektur.",
|
||||||
|
displayFamily: f(unica), bodyFamily: f(crimson), displayWeight: 400, scale: 0.95, tracking: "0em", emphasis: "gold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "16", id: "p16", name: "Nixie One + Ledger", source: "pagecloud",
|
||||||
|
note: "Schreibmaschinen-Anmutung. Interessant, aber zu spielerisch für Vertriebstrainings.",
|
||||||
|
displayFamily: f(nixie), bodyFamily: f(ledger), displayWeight: 400, scale: 0.95, tracking: "0em", emphasis: "gold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "17", id: "p17", name: "Source Serif 4 + Source Sans 3", source: "pagecloud",
|
||||||
|
note: "Eine Familie in zwei Ausprägungen. Perfekt abgestimmt, dafür ohne Spannung.",
|
||||||
|
displayFamily: f(sourceSerif), bodyFamily: f(sourceSans), displayWeight: 300, scale: 0.9, tracking: "-0.02em", emphasis: "italic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "18", id: "p18", name: "Arvo + Lato", source: "pagecloud",
|
||||||
|
note: "Slab-Serif. Bodenständig und handwerklich, wenig Premium.",
|
||||||
|
displayFamily: f(arvo), bodyFamily: f(lato), displayWeight: 400, scale: 0.8, tracking: "-0.02em", emphasis: "italic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "19", id: "p19", name: "Stint Ultra Expanded + Pontano Sans", source: "pagecloud",
|
||||||
|
note: "Extrem breite Antiqua. Sehr ungewöhnlich, frisst aber die Zeilenlänge.",
|
||||||
|
displayFamily: f(stint), bodyFamily: f(pontano), displayWeight: 400, scale: 0.6, tracking: "-0.01em", emphasis: "gold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "20", id: "p20", name: "Instrument Serif + Jost", source: "eigen",
|
||||||
|
note: "Enge Display-Antiqua mit hohem Kontrast. Redaktionell, sehr aktuell.",
|
||||||
|
displayFamily: f(instrument), bodyFamily: f(jost), displayWeight: 400, scale: 1.05, tracking: "-0.02em", emphasis: "italic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "21", id: "p21", name: "Fraunces + Jost", source: "eigen",
|
||||||
|
note: "Antiqua mit weichen, fast handgeschnittenen Formen. Warm und eigenständig.",
|
||||||
|
displayFamily: f(fraunces), bodyFamily: f(jost), displayWeight: 300, scale: 0.92, tracking: "-0.02em", emphasis: "italic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "22", id: "p22", name: "Newsreader + Jost", source: "eigen",
|
||||||
|
note: "Zeitungsantiqua für den Bildschirm. Ruhig, erwachsen, gut für lange Zeilen.",
|
||||||
|
displayFamily: f(newsreader), bodyFamily: f(jost), displayWeight: 300, scale: 0.95, tracking: "-0.02em", emphasis: "italic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "23", id: "p23", name: "Marcellus + Jost", source: "eigen",
|
||||||
|
note: "Antiqua mit Kapitalis-Anmutung. Klassisch und still, nah am Logo.",
|
||||||
|
displayFamily: f(marcellus), bodyFamily: f(jost), displayWeight: 400, scale: 0.95, tracking: "-0.005em", emphasis: "gold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "24", id: "p24", name: "Italiana + Jost", source: "eigen",
|
||||||
|
note: "Sehr feine, schmale Display-Antiqua. Couture-Nähe, funktioniert nur groß.",
|
||||||
|
displayFamily: f(italiana), bodyFamily: f(jost), displayWeight: 400, scale: 1.12, tracking: "0.005em", emphasis: "gold",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "25", id: "p25", name: "Libre Caslon Display + Jost", source: "eigen",
|
||||||
|
note: "Caslon fürs Display. Britisch-verlegerisch, warm ohne weich zu sein.",
|
||||||
|
displayFamily: f(libreCaslon), bodyFamily: f(jost), displayWeight: 400, scale: 0.9, tracking: "-0.02em", emphasis: "italic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
no: "26", id: "p26", name: "Gloock + Jost", source: "eigen",
|
||||||
|
note: "Junge Display-Antiqua mit sehr hohem Strichkontrast. Modisch, kraftvoll, noch selten gesehen.",
|
||||||
|
displayFamily: f(gloock), bodyFamily: f(jost), displayWeight: 400, scale: 0.95, tracking: "-0.02em", emphasis: "gold",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Für die Kopfzeile der Testseite. */
|
||||||
|
export const MONO_FAMILY = "var(--font-mono-face)";
|
||||||
140
src/app/(de)/schrift-test/page.tsx
Normal file
140
src/app/(de)/schrift-test/page.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { hero } from "@/content/home";
|
||||||
|
import { chrome } from "@/content/chrome";
|
||||||
|
import { BrandMark } from "@/components/brand/BrandMark";
|
||||||
|
import { Container } from "@/components/layout/Container";
|
||||||
|
import { PAIRINGS, type Pairing } from "./fonts";
|
||||||
|
import { TypeNav } from "./TypeNav";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/schrift-test/` – TEMPORÄRE Vergleichsseite.
|
||||||
|
*
|
||||||
|
* Zeigt den Hero der Startseite in {@link PAIRINGS} Schriftpaarungen untereinander,
|
||||||
|
* mit identischem Text, identischem Raster und identischen Farben. Nur die Schriften
|
||||||
|
* wechseln, damit man den Unterschied auch wirklich der Schrift zuschreiben kann.
|
||||||
|
* Die Größen sind pro Paarung optisch nachjustiert (`scale`), weil zwei Schriften bei
|
||||||
|
* gleicher Punktgröße unterschiedlich groß wirken.
|
||||||
|
*
|
||||||
|
* Nicht Teil der Website: nicht in der Sitemap, `noindex`, nicht verlinkt.
|
||||||
|
* Nach der Entscheidung `src/app/schrift-test/` löschen.
|
||||||
|
*/
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Schrift-Test",
|
||||||
|
description: "Interne Vergleichsseite für Schriftpaarungen im Hero.",
|
||||||
|
robots: { index: false, follow: false, nocache: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const MONO = "font-mono text-[11px] tracking-[0.08em]";
|
||||||
|
|
||||||
|
function TypeHero({ p, mark }: { p: Pairing; mark: ReactNode }) {
|
||||||
|
const display: CSSProperties = {
|
||||||
|
fontFamily: p.displayFamily,
|
||||||
|
fontWeight: p.displayWeight,
|
||||||
|
letterSpacing: p.tracking,
|
||||||
|
fontSize: `calc(clamp(2.75rem, 1.4rem + 6.4vw, 8.5rem) * ${p.scale})`,
|
||||||
|
};
|
||||||
|
const body: CSSProperties = { fontFamily: p.bodyFamily };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id={p.id}
|
||||||
|
data-theme="dark"
|
||||||
|
className="relative min-h-svh scroll-mt-0 border-t border-line-dark bg-ink text-fg"
|
||||||
|
>
|
||||||
|
<Container className="relative flex min-h-svh flex-col pt-[calc(var(--header-h,4.5rem)+1.25rem)] pb-8">
|
||||||
|
{/* Kopfzeile der Variante – gehört zur Testseite, nicht zum Entwurf */}
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 border-b border-line-dark pb-3">
|
||||||
|
<h2 className={cn(MONO, "uppercase text-gold")}>
|
||||||
|
{p.no} · {p.name}
|
||||||
|
</h2>
|
||||||
|
<p className={cn(MONO, "uppercase text-grey-deep")}>{p.source}</p>
|
||||||
|
<p className={cn(MONO, "text-grey-warm")}>{p.note}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid flex-1 grid-cols-12 items-center gap-x-[clamp(16px,2vw,32px)] pt-8">
|
||||||
|
<div className="col-span-12 lg:col-span-7">
|
||||||
|
{/* Mastkopf */}
|
||||||
|
<p
|
||||||
|
className="text-[0.72rem] font-medium uppercase leading-none tracking-[0.28em] text-gold"
|
||||||
|
style={body}
|
||||||
|
>
|
||||||
|
{hero.masthead.role}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Headline – auf der Testseite ein <p>, damit die Seite genau eine H1 behält */}
|
||||||
|
<p className="mt-8 max-w-[12ch] leading-[0.96]" style={display}>
|
||||||
|
{hero.h1.before}
|
||||||
|
{p.emphasis === "italic" && <em className="italic">{hero.h1.em}</em>}
|
||||||
|
{p.emphasis === "italic-gold" && <em className="gold-text italic">{hero.h1.em}</em>}
|
||||||
|
{p.emphasis === "gold" && <span className="gold-text">{hero.h1.em}</span>}
|
||||||
|
{hero.h1.after}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
className="mt-8 max-w-[42ch] text-[clamp(1.0625rem,1rem+0.4vw,1.25rem)] font-light leading-[1.5] text-grey-warm"
|
||||||
|
style={body}
|
||||||
|
>
|
||||||
|
{hero.subline}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 flex flex-wrap items-center gap-x-8 gap-y-4">
|
||||||
|
<span
|
||||||
|
className="border border-gold px-[1.6em] py-[1em] text-[0.75rem] font-medium uppercase leading-none tracking-[0.2em] text-gold"
|
||||||
|
style={body}
|
||||||
|
>
|
||||||
|
{hero.cta.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Marke: statisch, ohne Zeichenanimation – sonst liefe sie hier 26 Mal */}
|
||||||
|
<div className="col-span-12 mt-10 lg:col-span-5 lg:mt-0 lg:justify-self-end">
|
||||||
|
<div className="h-[22vh] text-ivory lg:h-[34vh] [&_svg]:h-full [&_svg]:w-auto">{mark}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Wortmarke in der Brotschrift – der zweite Ort, an dem die Paarung zählt */}
|
||||||
|
<div className="mt-6 flex items-end justify-between border-t border-line-dark pt-4">
|
||||||
|
<span
|
||||||
|
className="text-[1.0625rem] font-light uppercase leading-none tracking-[0.32em] text-ivory"
|
||||||
|
style={body}
|
||||||
|
>
|
||||||
|
{chrome.wordmark.name}
|
||||||
|
</span>
|
||||||
|
<span className={cn(MONO, "text-grey-warm")}>{hero.masthead.lang}</span>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SchriftTestPage() {
|
||||||
|
return (
|
||||||
|
<div data-theme="dark" className="bg-ink text-fg">
|
||||||
|
<Container className="pt-[calc(var(--header-h,4.5rem)+3rem)] pb-10">
|
||||||
|
<h1 className="font-display text-[clamp(2rem,1.4rem+2vw,3rem)] font-normal text-ivory">Schrift-Test</h1>
|
||||||
|
<p className="mt-4 max-w-[60ch] font-sans text-base font-light leading-relaxed text-grey-warm">
|
||||||
|
Der Hero der Startseite in {PAIRINGS.length} Schriftpaarungen. Gleicher Text, gleiches Raster,
|
||||||
|
gleiche Farben – nur die Schriften wechseln. Die Größen sind pro Paarung optisch nachjustiert,
|
||||||
|
weil zwei Schriften bei gleicher Punktgröße unterschiedlich groß wirken.
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 max-w-[60ch] font-sans text-sm font-light leading-relaxed text-grey-warm">
|
||||||
|
Nummer 00 ist der aktuelle Stand. 01 bis 19 stammen aus der Liste von pagecloud, 20 bis 26 sind
|
||||||
|
eigene Vorschläge. Über jeder Variante stehen Nummer, Namen und eine kurze Einschätzung.
|
||||||
|
Rechts am Rand liegt der Index zum Springen.
|
||||||
|
</p>
|
||||||
|
<p className={cn(MONO, "mt-6 uppercase text-gold")}>
|
||||||
|
Interne Seite · nicht verlinkt · nicht im Index
|
||||||
|
</p>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{PAIRINGS.map((p) => (
|
||||||
|
<TypeHero key={p.id} p={p} mark={<BrandMark idPrefix={p.id} title={`Monogramm – ${p.name}`} />} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<TypeNav pairings={PAIRINGS} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/app/(de)/schrift-test/seite/FontCompare.tsx
Normal file
117
src/app/(de)/schrift-test/seite/FontCompare.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { CompareOption } from "./fonts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Umschalter für die vollständige Startseite. TEMPORÄR – siehe ./fonts.ts.
|
||||||
|
*
|
||||||
|
* Setzt `--font-display-face` und `--font-body-face` auf <html>, damit auch Kopf- und Fußzeile
|
||||||
|
* mitwechseln (die stehen im Root-Layout, nicht in dieser Seite).
|
||||||
|
*
|
||||||
|
* Zwei Feinheiten, die beim Umschalten wichtig sind:
|
||||||
|
*
|
||||||
|
* 1. Die Variablen werden im Klick gesetzt, BEVOR der Zustand wechselt. Die Sektionen
|
||||||
|
* hängen an GSAP SplitText, das die Zeilenumbrüche misst; würde die Schrift erst im
|
||||||
|
* Effekt danach wechseln, wäre die Messung mit der alten Schrift gemacht.
|
||||||
|
* 2. Der Inhalt bekommt den Namen der Variante als `key`. Dadurch wird er neu gemountet,
|
||||||
|
* SplitText teilt neu auf und die Reveals laufen einmal in der neuen Schrift durch.
|
||||||
|
* Ohne das blieben die alten Zeilenkästen stehen und der Text würde abgeschnitten.
|
||||||
|
*/
|
||||||
|
export function FontCompare({ options, children }: { options: readonly CompareOption[]; children: ReactNode }) {
|
||||||
|
const [active, setActive] = useState(options[0]);
|
||||||
|
const [gold, setGold] = useState(true);
|
||||||
|
const applied = useRef(false);
|
||||||
|
|
||||||
|
const apply = useCallback((o: CompareOption) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.style.setProperty("--font-display-face", o.displayFamily);
|
||||||
|
root.style.setProperty("--font-body-face", o.bodyFamily);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* Erststand setzen und beim Verlassen der Seite wieder aufräumen. */
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!applied.current) {
|
||||||
|
apply(options[0]);
|
||||||
|
applied.current = true;
|
||||||
|
}
|
||||||
|
const root = document.documentElement;
|
||||||
|
return () => {
|
||||||
|
root.style.removeProperty("--font-display-face");
|
||||||
|
root.style.removeProperty("--font-body-face");
|
||||||
|
};
|
||||||
|
}, [apply, options]);
|
||||||
|
|
||||||
|
const choose = (o: CompareOption) => {
|
||||||
|
if (o.id === active.id) return;
|
||||||
|
apply(o);
|
||||||
|
setActive(o);
|
||||||
|
};
|
||||||
|
|
||||||
|
const btn =
|
||||||
|
"px-3 py-2 font-mono text-[11px] uppercase tracking-[0.1em] transition-colors whitespace-nowrap";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div key={active.id} data-typo-emphasis={gold ? "gold" : "normal"}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-theme="dark"
|
||||||
|
className="fixed inset-x-0 bottom-0 z-[60] border-t border-gold/40 bg-ink/95 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex max-w-[105rem] flex-wrap items-center gap-x-6 gap-y-2 px-[clamp(16px,4vw,40px)] py-3">
|
||||||
|
<span className="font-mono text-[11px] uppercase tracking-[0.18em] text-gold">Schrift</span>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => choose(o)}
|
||||||
|
aria-pressed={o.id === active.id}
|
||||||
|
title={o.name}
|
||||||
|
className={cn(btn, o.id === active.id ? "bg-gold text-ink" : "text-grey-warm hover:text-gold")}
|
||||||
|
>
|
||||||
|
{o.no} · {o.short}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="hidden h-4 w-px bg-line-dark sm:block" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="font-mono text-[11px] uppercase tracking-[0.18em] text-grey-deep">Betonung</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGold(false)}
|
||||||
|
aria-pressed={!gold}
|
||||||
|
className={cn(btn, !gold ? "bg-gold text-ink" : "text-grey-warm hover:text-gold")}
|
||||||
|
>
|
||||||
|
{active.hasItalic ? "Kursiv" : "Schlicht"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGold(true)}
|
||||||
|
aria-pressed={gold}
|
||||||
|
className={cn(btn, gold ? "bg-gold text-ink" : "text-grey-warm hover:text-gold")}
|
||||||
|
>
|
||||||
|
Gold
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="ml-auto hidden max-w-[42ch] font-mono text-[11px] leading-snug text-grey-warm lg:block">
|
||||||
|
{active.note}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platz, damit die Leiste den Footer nicht verdeckt */}
|
||||||
|
<div aria-hidden="true" className="h-16 bg-ink" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FontCompare;
|
||||||
53
src/app/(de)/schrift-test/seite/fonts.ts
Normal file
53
src/app/(de)/schrift-test/seite/fonts.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Nur die Schriften, die auf der Vergleichsseite `/schrift-test/seite/` gebraucht werden.
|
||||||
|
*
|
||||||
|
* Bewusst eine eigene Datei und nicht `../fonts.ts`: Die große Testseite lädt rund
|
||||||
|
* 40 Familien. Diese Seite soll nur die drei Kandidaten laden, sonst dauert der
|
||||||
|
* Aufbau unnötig lange.
|
||||||
|
*
|
||||||
|
* TEMPORÄR – zusammen mit `src/app/schrift-test/` löschen, sobald entschieden ist.
|
||||||
|
*/
|
||||||
|
import { Bodoni_Moda, Jost, Lato, Lustria, Playfair_Display, Source_Sans_3 } from "next/font/google";
|
||||||
|
|
||||||
|
const bodoni = Bodoni_Moda({ subsets: ["latin"], display: "swap", style: ["normal", "italic"], axes: ["opsz"] });
|
||||||
|
const jost = Jost({ subsets: ["latin"], display: "swap" });
|
||||||
|
const playfair = Playfair_Display({ subsets: ["latin"], display: "swap", style: ["normal", "italic"] });
|
||||||
|
const sourceSans = Source_Sans_3({ subsets: ["latin"], display: "swap" });
|
||||||
|
const lustria = Lustria({ subsets: ["latin"], display: "swap", weight: "400" });
|
||||||
|
const lato = Lato({ subsets: ["latin"], display: "swap", weight: ["300", "400"] });
|
||||||
|
|
||||||
|
export interface CompareOption {
|
||||||
|
id: string;
|
||||||
|
no: string;
|
||||||
|
/** Kurzform für den Umschalter. */
|
||||||
|
short: string;
|
||||||
|
/** Vollständige Bezeichnung. */
|
||||||
|
name: string;
|
||||||
|
note: string;
|
||||||
|
/** Wird auf `--font-display-face` gesetzt (Display: Überschriften, Ziffern, Formel, Zitate). */
|
||||||
|
displayFamily: string;
|
||||||
|
/** Wird auf `--font-body-face` gesetzt (Fließtext, Labels, Buttons, Wortmarke). */
|
||||||
|
bodyFamily: string;
|
||||||
|
/** Hat die Display-Schrift eine echte Kursive? Sonst trägt nur Gold die Betonung. */
|
||||||
|
hasItalic: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const f = (font: { style: { fontFamily: string } }) => font.style.fontFamily;
|
||||||
|
|
||||||
|
export const COMPARE: readonly CompareOption[] = [
|
||||||
|
{
|
||||||
|
id: "bodoni", no: "00", short: "Bodoni", name: "Bodoni Moda + Jost",
|
||||||
|
note: "Der frühere Stand. Didone mit hohem Strichkontrast, Futura-Genom im Text.",
|
||||||
|
displayFamily: f(bodoni), bodyFamily: f(jost), hasItalic: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "playfair", no: "01", short: "Playfair", name: "Playfair Display + Source Sans 3",
|
||||||
|
note: "Der gebaute Stand. Wärmer und runder als Bodoni, kräftigere Serifen, ruhigerer Fließtext.",
|
||||||
|
displayFamily: f(playfair), bodyFamily: f(sourceSans), hasItalic: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lustria", no: "09", short: "Lustria", name: "Lustria + Lato",
|
||||||
|
note: "Weiche Antiqua ohne Kursive, dafür sehr eigener Rhythmus. Freundlicher Ton.",
|
||||||
|
displayFamily: f(lustria), bodyFamily: f(lato), hasItalic: false,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
67
src/app/(de)/schrift-test/seite/page.tsx
Normal file
67
src/app/(de)/schrift-test/seite/page.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getPhotos } from "@/lib/assets";
|
||||||
|
import { BrandMark } from "@/components/brand/BrandMark";
|
||||||
|
import { Hero } from "@/components/home/Hero";
|
||||||
|
import { Positioning } from "@/components/home/Positioning";
|
||||||
|
import { ServicesIndex } from "@/components/home/ServicesIndex";
|
||||||
|
import { WolfPrinciple } from "@/components/home/WolfPrinciple";
|
||||||
|
import { About } from "@/components/home/About";
|
||||||
|
import { Audience } from "@/components/home/Audience";
|
||||||
|
import { ProcessContact } from "@/components/home/ProcessContact";
|
||||||
|
import { FontCompare } from "./FontCompare";
|
||||||
|
import { COMPARE } from "./fonts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/schrift-test/seite/` – die vollständige Startseite zum Umschalten. TEMPORÄR.
|
||||||
|
*
|
||||||
|
* Dieselben Sektionen wie `/`, in denselben Komponenten. Die Schrift wird nicht in den
|
||||||
|
* Komponenten geändert, sondern über die zwei Variablen `--font-display-face` (Display)
|
||||||
|
* und `--font-body-face` (Text) auf <html>. Deshalb wechselt wirklich alles mit: Überschriften,
|
||||||
|
* Ziffern, Formel, Ledger, Buttons, Wortmarke, Kopf- und Fußzeile.
|
||||||
|
*
|
||||||
|
* Kein JSON-LD und kein Canonical wie auf der echten Startseite – die Seite soll
|
||||||
|
* ausdrücklich nicht als zweite Startseite gelten.
|
||||||
|
*/
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Schrift-Vergleich · Startseite",
|
||||||
|
description: "Interne Vergleichsseite: die vollständige Startseite in drei Schriftpaarungen.",
|
||||||
|
robots: { index: false, follow: false, nocache: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Betonungswörter (`<em>`) auf Gold umstellen, wenn der Umschalter auf „Gold“ steht.
|
||||||
|
* Auf dunklem Grund der Verlauf, auf hellem `--gold-deep` – Gold-Verlauf auf Elfenbein
|
||||||
|
* käme auf 2,4:1 und wäre nicht lesbar.
|
||||||
|
*/
|
||||||
|
const EMPHASIS_CSS = `
|
||||||
|
[data-typo-emphasis="gold"] [data-theme="dark"] em {
|
||||||
|
color: var(--gold);
|
||||||
|
background: var(--gold-gradient-text);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
[data-typo-emphasis="gold"] [data-theme="light"] em {
|
||||||
|
color: var(--gold-deep);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function SchriftVergleichPage() {
|
||||||
|
const photos = getPhotos();
|
||||||
|
const aboutPhoto = photos.portrait ?? photos.hero;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style dangerouslySetInnerHTML={{ __html: EMPHASIS_CSS }} />
|
||||||
|
<FontCompare options={COMPARE}>
|
||||||
|
<Hero mark={<BrandMark idPrefix="hero" handoff />} photo={photos.hero} />
|
||||||
|
<Positioning />
|
||||||
|
<ServicesIndex />
|
||||||
|
<WolfPrinciple mark={<BrandMark idPrefix="prinzip" />} />
|
||||||
|
<About photo={aboutPhoto} />
|
||||||
|
<Audience />
|
||||||
|
<ProcessContact />
|
||||||
|
</FontCompare>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/app/(de)/template.tsx
Normal file
6
src/app/(de)/template.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Enter-Choreografie jeder Seite. Der Rumpf liegt in einer eigenen Komponente,
|
||||||
|
* weil es seit der englischen Fassung zwei Wurzel-Layouts gibt und `template.tsx`
|
||||||
|
* ein Dateikonvention ist, die pro Wurzel einmal existieren muss.
|
||||||
|
*/
|
||||||
|
export { PageTemplate as default } from "@/components/motion/PageTemplate";
|
||||||
BIN
src/app/apple-icon.png
Normal file
BIN
src/app/apple-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
15
src/app/en/imprint/page.tsx
Normal file
15
src/app/en/imprint/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { LegalPage } from "@/components/legal/LegalPage";
|
||||||
|
import { impressumEn } from "@/content/en/legal";
|
||||||
|
import { alternatesFor } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: impressumEn.meta.title,
|
||||||
|
description: impressumEn.meta.description,
|
||||||
|
alternates: alternatesFor("en", "impressum"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** /en/imprint/ – Übersetzung; verbindlich bleibt die deutsche Fassung (siehe erster Abschnitt). */
|
||||||
|
export default function EnImprintPage() {
|
||||||
|
return <LegalPage id="imprint" content={impressumEn} lang="en" />;
|
||||||
|
}
|
||||||
24
src/app/en/layout.tsx
Normal file
24
src/app/en/layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import "../globals.css";
|
||||||
|
import { Shell } from "@/components/layout/Shell";
|
||||||
|
import { rootMetadata } from "@/lib/metadata";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wurzel-Layout der englischen Fassung (`<html lang="en">`).
|
||||||
|
*
|
||||||
|
* `app/en/` liegt neben der Gruppe `(de)` und hat kein Layout über sich – damit ist
|
||||||
|
* dieses hier ein zweites Wurzel-Layout. Der Wechsel zwischen den Sprachen ist deshalb
|
||||||
|
* ein vollständiger Seitenaufbau statt einer Client-Navigation (Next.js-Verhalten bei
|
||||||
|
* mehreren Wurzel-Layouts) – bei einem Sprachwechsel ist das richtig.
|
||||||
|
*/
|
||||||
|
export const metadata: Metadata = rootMetadata("en");
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#0f0e0c",
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EnLayout({ children }: LayoutProps<"/en">) {
|
||||||
|
return <Shell lang="en">{children}</Shell>;
|
||||||
|
}
|
||||||
16
src/app/en/page.tsx
Normal file
16
src/app/en/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { HomeBody } from "@/components/pages/HomeBody";
|
||||||
|
import { homeEn } from "@/content/en/home";
|
||||||
|
import { alternatesFor } from "@/lib/i18n";
|
||||||
|
import { rootCopy } from "@/lib/metadata";
|
||||||
|
|
||||||
|
/* Title falls back to the layout default („Anelia Wolf – Keynote Speaker, Trainer & Coach“). */
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
description: rootCopy.en.description,
|
||||||
|
alternates: alternatesFor("en", "home"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** /en/ – same tree as the German start page, English copy. */
|
||||||
|
export default function EnHome() {
|
||||||
|
return <HomeBody lang="en" content={homeEn} />;
|
||||||
|
}
|
||||||
15
src/app/en/privacy/page.tsx
Normal file
15
src/app/en/privacy/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { LegalPage } from "@/components/legal/LegalPage";
|
||||||
|
import { datenschutzEn } from "@/content/en/legal";
|
||||||
|
import { alternatesFor } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: datenschutzEn.meta.title,
|
||||||
|
description: datenschutzEn.meta.description,
|
||||||
|
alternates: alternatesFor("en", "datenschutz"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** /en/privacy/ – Übersetzung; verbindlich bleibt die deutsche Fassung (siehe erster Abschnitt). */
|
||||||
|
export default function EnPrivacyPage() {
|
||||||
|
return <LegalPage id="privacy" content={datenschutzEn} lang="en" />;
|
||||||
|
}
|
||||||
22
src/app/en/services/coaching/page.tsx
Normal file
22
src/app/en/services/coaching/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { CoachingBody } from "@/components/pages/CoachingBody";
|
||||||
|
import { coachingEn, specLabelsEn } from "@/content/en/coaching";
|
||||||
|
import { alternatesFor, OG_LOCALE } from "@/lib/i18n";
|
||||||
|
import { site } from "@/lib/site";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: coachingEn.meta.title,
|
||||||
|
description: coachingEn.meta.description,
|
||||||
|
alternates: alternatesFor("en", "coaching"),
|
||||||
|
openGraph: {
|
||||||
|
title: `${coachingEn.meta.title} – ${site.name}`,
|
||||||
|
description: coachingEn.meta.description,
|
||||||
|
url: alternatesFor("en", "coaching").canonical,
|
||||||
|
locale: OG_LOCALE.en,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** /en/services/coaching/ – layout in <CoachingBody/>, copy in src/content/en/coaching.ts. */
|
||||||
|
export default function EnCoachingPage() {
|
||||||
|
return <CoachingBody content={coachingEn} specLabels={specLabelsEn} />;
|
||||||
|
}
|
||||||
22
src/app/en/services/keynotes/page.tsx
Normal file
22
src/app/en/services/keynotes/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { KeynotesBody } from "@/components/pages/KeynotesBody";
|
||||||
|
import { keynotesEn, talkLabelsEn } from "@/content/en/keynotes";
|
||||||
|
import { alternatesFor, OG_LOCALE } from "@/lib/i18n";
|
||||||
|
import { site } from "@/lib/site";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: keynotesEn.meta.title,
|
||||||
|
description: keynotesEn.meta.description,
|
||||||
|
alternates: alternatesFor("en", "keynotes"),
|
||||||
|
openGraph: {
|
||||||
|
title: `${keynotesEn.meta.title} – ${site.name}`,
|
||||||
|
description: keynotesEn.meta.description,
|
||||||
|
url: alternatesFor("en", "keynotes").canonical,
|
||||||
|
locale: OG_LOCALE.en,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** /en/services/keynotes/ – layout in <KeynotesBody/>, copy in src/content/en/keynotes.ts. */
|
||||||
|
export default function EnKeynotesPage() {
|
||||||
|
return <KeynotesBody content={keynotesEn} talkLabels={talkLabelsEn} />;
|
||||||
|
}
|
||||||
23
src/app/en/services/trainings/page.tsx
Normal file
23
src/app/en/services/trainings/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { TrainingsBody } from "@/components/pages/TrainingsBody";
|
||||||
|
import { homeEn } from "@/content/en/home";
|
||||||
|
import { trainingsEn } from "@/content/en/trainings";
|
||||||
|
import { alternatesFor, OG_LOCALE } from "@/lib/i18n";
|
||||||
|
import { site } from "@/lib/site";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: trainingsEn.meta.title,
|
||||||
|
description: trainingsEn.meta.description,
|
||||||
|
alternates: alternatesFor("en", "trainings"),
|
||||||
|
openGraph: {
|
||||||
|
title: `${trainingsEn.meta.title} – ${site.name}`,
|
||||||
|
description: trainingsEn.meta.description,
|
||||||
|
url: alternatesFor("en", "trainings").canonical,
|
||||||
|
locale: OG_LOCALE.en,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** /en/services/trainings/ – layout in <TrainingsBody/>, copy in src/content/en/trainings.ts. */
|
||||||
|
export default function EnTrainingsPage() {
|
||||||
|
return <TrainingsBody content={trainingsEn} marginalia={homeEn.about.marginalia} />;
|
||||||
|
}
|
||||||
6
src/app/en/template.tsx
Normal file
6
src/app/en/template.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Enter-Choreografie jeder Seite. Der Rumpf liegt in einer eigenen Komponente,
|
||||||
|
* weil es seit der englischen Fassung zwei Wurzel-Layouts gibt und `template.tsx`
|
||||||
|
* ein Dateikonvention ist, die pro Wurzel einmal existieren muss.
|
||||||
|
*/
|
||||||
|
export { PageTemplate as default } from "@/components/motion/PageTemplate";
|
||||||
35
src/app/global-not-found.tsx
Normal file
35
src/app/global-not-found.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
import { Shell } from "@/components/layout/Shell";
|
||||||
|
import { NotFoundBody } from "@/components/pages/NotFoundBody";
|
||||||
|
import { notFound } from "@/content/not-found";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Fehlerseite für unbekannte Adressen – im statischen Export ist das `out/404.html`,
|
||||||
|
* die Datei, die der Hoster bei jedem Treffer ins Leere ausliefert.
|
||||||
|
*
|
||||||
|
* Sie muss das GANZE Dokument rendern (Next umgeht hier jedes Layout), deshalb steht hier
|
||||||
|
* `globals.css` und <Shell/>. Nötig wurde sie mit dem zweiten Wurzel-Layout für /en/:
|
||||||
|
* ohne sie hat Next kein Layout, aus dem es eine gemeinsame 404 bauen könnte, und liefert
|
||||||
|
* seine eigene weiße „404: This page could not be found.“ aus.
|
||||||
|
*
|
||||||
|
* Sprache: Deutsch – die Fassung, die der Rest der Adressen spricht. <NotFoundBody/> hängt
|
||||||
|
* einen englischen Zweizeiler an, weil es im statischen Export nur diese eine Datei gibt.
|
||||||
|
*/
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: `${notFound.meta.title} – Anelia Wolf`,
|
||||||
|
description: notFound.meta.description,
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function GlobalNotFound() {
|
||||||
|
return (
|
||||||
|
<Shell lang="de">
|
||||||
|
{/* Kein template.tsx über dieser Seite – das `main#inhalt`, auf das der Sprunglink
|
||||||
|
zeigt, muss sie deshalb selbst setzen. */}
|
||||||
|
<main id="inhalt" className="flex-1">
|
||||||
|
<NotFoundBody content={notFound} />
|
||||||
|
</main>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
272
src/app/globals.css
Normal file
272
src/app/globals.css
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "lenis/dist/lenis.css";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------
|
||||||
|
Anelia Wolf – Design Tokens
|
||||||
|
Palette derived from the logo: warm near-black, metallic gold ramp,
|
||||||
|
ivory paper. Gold is rationed: hairlines, numerals, labels, strokes.
|
||||||
|
------------------------------------------------------------------ */
|
||||||
|
:root {
|
||||||
|
/* Dark ground */
|
||||||
|
--ink: #0f0e0c;
|
||||||
|
--ink-2: #191714;
|
||||||
|
--line-dark: #2a2722;
|
||||||
|
|
||||||
|
/* Light ground */
|
||||||
|
--ivory: #f6f1e8;
|
||||||
|
--bone: #eee6d8;
|
||||||
|
--line-light: #dcd3c3;
|
||||||
|
|
||||||
|
/* Neutrals */
|
||||||
|
--grey-warm: #a8a196; /* muted text on dark (7.5:1 on ink) */
|
||||||
|
--grey-deep: #5e5a52; /* muted text on light (6.1:1 on ivory) */
|
||||||
|
|
||||||
|
/* Gold */
|
||||||
|
--gold: #c9a24d; /* text/UI gold on DARK only (8.05:1 on ink) */
|
||||||
|
--gold-deep: #7d5f1b; /* text/UI gold on LIGHT only (5.3:1 on ivory) */
|
||||||
|
--gold-100: #f4e5b8;
|
||||||
|
--gold-200: #e6c97a;
|
||||||
|
--gold-500: #c9a24d;
|
||||||
|
--gold-700: #a37a2c;
|
||||||
|
--gold-900: #6e501a;
|
||||||
|
|
||||||
|
/* Gradients (fixed light direction: top-left) */
|
||||||
|
--gold-gradient: linear-gradient(135deg, #f4e5b8 0%, #e6c97a 22%, #c9a24d 48%, #a37a2c 74%, #6e501a 100%);
|
||||||
|
--gold-gradient-text: linear-gradient(100deg, #f4e5b8 0%, #e6c97a 32%, #c9a24d 62%, #a37a2c 100%);
|
||||||
|
--gold-hairline: linear-gradient(90deg, transparent 0%, #c9a24d 18%, #f4e5b8 50%, #c9a24d 82%, transparent 100%);
|
||||||
|
--gold-hairline-light: linear-gradient(90deg, #a37a2c 0%, #c9a24d 35%, #e6c97a 50%, #c9a24d 65%, #a37a2c 100%);
|
||||||
|
--gold-sheen: linear-gradient(115deg, #a37a2c 0%, #c9a24d 30%, #f4e5b8 50%, #c9a24d 70%, #a37a2c 100%);
|
||||||
|
|
||||||
|
/* Live theme pair – set per themed block by the `[data-theme]` rules below */
|
||||||
|
--bg: var(--ink);
|
||||||
|
--fg: var(--ivory);
|
||||||
|
--fg-muted: var(--grey-warm);
|
||||||
|
--accent: var(--gold);
|
||||||
|
--line: var(--line-dark);
|
||||||
|
--surface: var(--ink-2);
|
||||||
|
|
||||||
|
/* Motion */
|
||||||
|
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--ease-out-quart: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
|
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light-ground sections flip the live pair */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: var(--ivory);
|
||||||
|
--fg: var(--ink);
|
||||||
|
--fg-muted: var(--grey-deep);
|
||||||
|
--accent: var(--gold-deep);
|
||||||
|
--line: var(--line-light);
|
||||||
|
--surface: var(--bone);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg: var(--ink);
|
||||||
|
--fg: var(--ivory);
|
||||||
|
--fg-muted: var(--grey-warm);
|
||||||
|
--accent: var(--gold);
|
||||||
|
--line: var(--line-dark);
|
||||||
|
--surface: var(--ink-2);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 1440 px: the width where the header row fits nav + „Kontakt“ + the full CTA
|
||||||
|
label and the masthead centre slot gets its full tracking back. A NAMED
|
||||||
|
breakpoint, not `min-[1440px]:` – arbitrary min-width variants are emitted
|
||||||
|
before the named ones, so `min-[1440px]:inline-block` lost against
|
||||||
|
`lg:inline-block` at 1440. */
|
||||||
|
@theme {
|
||||||
|
--breakpoint-wide: 90rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-ink: var(--ink);
|
||||||
|
--color-ink-2: var(--ink-2);
|
||||||
|
--color-ivory: var(--ivory);
|
||||||
|
--color-bone: var(--bone);
|
||||||
|
--color-gold: var(--gold);
|
||||||
|
--color-gold-deep: var(--gold-deep);
|
||||||
|
--color-gold-100: var(--gold-100);
|
||||||
|
--color-gold-200: var(--gold-200);
|
||||||
|
--color-gold-700: var(--gold-700);
|
||||||
|
--color-gold-900: var(--gold-900);
|
||||||
|
--color-grey-warm: var(--grey-warm);
|
||||||
|
--color-grey-deep: var(--grey-deep);
|
||||||
|
--color-line-dark: var(--line-dark);
|
||||||
|
--color-line-light: var(--line-light);
|
||||||
|
|
||||||
|
--color-bg: var(--bg);
|
||||||
|
--color-fg: var(--fg);
|
||||||
|
--color-fg-muted: var(--fg-muted);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-line: var(--line);
|
||||||
|
--color-surface: var(--surface);
|
||||||
|
|
||||||
|
--font-sans: var(--font-body-face), "Source Sans 3", "Segoe UI", system-ui, Arial, sans-serif;
|
||||||
|
--font-display: var(--font-display-face), "Playfair Display", "Georgia", "Times New Roman", serif;
|
||||||
|
--font-mono: var(--font-mono-face), "SF Mono", "Menlo", "Consolas", monospace;
|
||||||
|
|
||||||
|
--ease-out-expo: var(--ease-out-expo);
|
||||||
|
--ease-out-quart: var(--ease-out-quart);
|
||||||
|
--ease-in-out: var(--ease-in-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------
|
||||||
|
Ground & boundary (no JS).
|
||||||
|
Every top-level themed block paints its OWN opaque ground, so a
|
||||||
|
section's text can never sit on the neighbouring palette. The seam
|
||||||
|
itself is the spec's 1 px gold hairline (§2.3) – no gradient, no fade,
|
||||||
|
nothing soft-focus on a page built from hairlines: blocks that draw
|
||||||
|
their own <Rule variant="gold"/> as a direct child carry the seam
|
||||||
|
themselves, every other block gets the identical hairline from here.
|
||||||
|
|
||||||
|
The rule is depth-independent on purpose: `main > [data-theme]` silently
|
||||||
|
left a block on the neighbouring palette as soon as it sat one wrapper
|
||||||
|
deeper – the exact failure the own-ground decision exists to prevent.
|
||||||
|
`:not([data-theme] [data-theme])` gives the ground to the OUTERMOST themed
|
||||||
|
element on any branch, so nesting (e.g. <ServiceLayout/> around its light
|
||||||
|
<Section/>s, or <WolfPrinciple/> around <Formula/>) still paints once.
|
||||||
|
------------------------------------------------------------------ */
|
||||||
|
main [data-theme]:not([data-theme] [data-theme]),
|
||||||
|
footer[data-theme] {
|
||||||
|
background-color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports selector(:has(*)) {
|
||||||
|
main > [data-theme="light"] + [data-theme="dark"]:not(:has(> .rule-gold)),
|
||||||
|
main:has(> [data-theme="light"]:last-child) + footer[data-theme]:not(:has(> .rule-gold)) {
|
||||||
|
background-image: var(--gold-hairline);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 100% 1px;
|
||||||
|
}
|
||||||
|
main > [data-theme="dark"] + [data-theme="light"]:not(:has(> .rule-gold)),
|
||||||
|
main:has(> [data-theme="dark"]:last-child) + footer[data-theme]:not(:has(> .rule-gold)) {
|
||||||
|
background-image: var(--gold-hairline-light);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 100% 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
html {
|
||||||
|
background: var(--ink);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
/* Fixed ink base – no JS ever touches colours. Every themed block paints its own ground. */
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: clamp(1.0625rem, 1.02rem + 0.2vw, 1.125rem);
|
||||||
|
line-height: 1.6;
|
||||||
|
letter-spacing: 0.005em;
|
||||||
|
min-height: 100svh;
|
||||||
|
overflow-x: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The header is ink chrome, but while it is still transparent it sits ON the
|
||||||
|
page's first block – on an ivory-first page (Impressum, Datenschutz) its type
|
||||||
|
has to be ink until the 80 px ground fades in. */
|
||||||
|
body:has(main > [data-theme="light"]:first-child) header:not([data-scrolled]) {
|
||||||
|
--fg: var(--ink);
|
||||||
|
--fg-muted: var(--grey-deep);
|
||||||
|
--accent: var(--gold-deep);
|
||||||
|
--line: var(--line-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Legal pages (Spec A/D6): „Datenschutzerklärung“ is one 20-character word and
|
||||||
|
at 44 px it is wider than the 375 px column – it widened the whole document.
|
||||||
|
Scoped here because the rule belongs to src/components/legal/LegalPage.tsx,
|
||||||
|
which is outside this pass' file scope; move it there when that file is touched. */
|
||||||
|
#impressum h1,
|
||||||
|
#datenschutz h1 {
|
||||||
|
font-size: clamp(2rem, 1.2rem + 4vw, 2.75rem);
|
||||||
|
hyphens: auto;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An SVG sized by height (404 monogram, hero mark) must never widen the page. */
|
||||||
|
svg {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: var(--gold);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient-clipped display text – ONE word per headline, ≥ 40px, dark ground only */
|
||||||
|
.gold-text {
|
||||||
|
color: var(--gold);
|
||||||
|
background: var(--gold-gradient-text);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Betonungswort in Display-Headlines: kursiv und, auf dunklem Grund, zusätzlich in Gold.
|
||||||
|
Entscheidung vom 06.09.2026 zusammen mit der Schriftwahl (Playfair Display + Source Sans 3).
|
||||||
|
Auf hellem Grund bleibt die Betonung rein typografisch: Der Gold-Verlauf käme auf Elfenbein
|
||||||
|
auf 2,4:1 und wäre nicht lesbar. Gold bleibt damit weiter auf die dunklen Flächen rationiert. */
|
||||||
|
[data-theme="dark"] .font-display em {
|
||||||
|
color: var(--gold);
|
||||||
|
background: var(--gold-gradient-text);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hairlines */
|
||||||
|
.rule-gold { height: 1px; background: var(--gold-hairline); }
|
||||||
|
[data-theme="light"] .rule-gold { background: var(--gold-hairline-light); }
|
||||||
|
|
||||||
|
/* Static film grain – zero JS, sits above everything, never intercepts input.
|
||||||
|
Exactly one viewport: the layer is `fixed`, so it never moves relative to the
|
||||||
|
viewport and needs no bleed – `inset: -50%` at 200 % painted four times the
|
||||||
|
visible area (≈ 20 MB of GPU texture at 1440×900) for a 4.5 % noise overlay. */
|
||||||
|
.grain::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 60;
|
||||||
|
opacity: 0.045;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
background-size: 300px 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Anchor targets clear the fixed header. ONE number for every landing: the JS path
|
||||||
|
(anchorOffset() in TransitionLink) computes −(--header-h + 16px), so a native
|
||||||
|
`:target` jump and a Lenis jump land on the same pixel. */
|
||||||
|
main [id] {
|
||||||
|
scroll-margin-top: calc(var(--header-h, 72px) + 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.001ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.001ms !important;
|
||||||
|
}
|
||||||
|
/* Reveal / Lines / Formula end state in CSS as well – not only via gsap.matchMedia.
|
||||||
|
`clip-path` is in the list because Formula's three lines are gated by one. */
|
||||||
|
.js [data-reveal] {
|
||||||
|
opacity: 1 !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
transform: none !important;
|
||||||
|
clip-path: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/app/icon.svg
Normal file
10
src/app/icon.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||||
|
<!-- Anelia Wolf favicon: the wolf head alone, gold on ink (Spec 5.5).
|
||||||
|
Path geometry = WOLF_MARK_PATHS.wolf / .eye from src/components/brand/WolfMark.tsx
|
||||||
|
(viewBox 0 0 250 200), cropped and scaled to a 64 x 64 frame. -->
|
||||||
|
<rect width="64" height="64" fill="#0F0E0C"/>
|
||||||
|
<g transform="translate(-34.78 3.44) scale(0.42)" fill="none" stroke="#C9A24D" stroke-width="9" stroke-linecap="round" stroke-linejoin="miter" stroke-miterlimit="4">
|
||||||
|
<path d="M111,70 C117,56 117,42 126,32 L140,12 L155,34 C159,39 164,44 168,47 C180,52 193,59 204,66 C207,68 207,73 204,76 C196,78 188,79 180,80 C172,84 162,92 154,102 C149,108 157.3,116.6 160.2,124"/>
|
||||||
|
<path d="M160,53 Q165,49.5 170,53 Q165,56.5 160,53 Z" fill="#C9A24D" stroke-width="3" stroke-linejoin="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 870 B |
13
src/app/robots.ts
Normal file
13
src/app/robots.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
import { site } from "@/lib/site";
|
||||||
|
|
||||||
|
/** Static export: metadata routes are prerendered; mark explicitly (Next static-exports guide). */
|
||||||
|
export const dynamic = "force-static";
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
// /schrift-test/ ist die interne Schrift-Vergleichsseite (temporär, siehe src/app/schrift-test/).
|
||||||
|
rules: { userAgent: "*", allow: "/", disallow: ["/schrift-test/"] },
|
||||||
|
sitemap: `${site.url}/sitemap.xml`,
|
||||||
|
};
|
||||||
|
}
|
||||||
42
src/app/sitemap.ts
Normal file
42
src/app/sitemap.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
import { LANGS, routesByLang, type Lang, type PageKey } from "@/lib/i18n";
|
||||||
|
import { site } from "@/lib/site";
|
||||||
|
|
||||||
|
/** Static export: metadata routes are prerendered; mark explicitly (Next static-exports guide). */
|
||||||
|
export const dynamic = "force-static";
|
||||||
|
|
||||||
|
type ChangeFrequency = NonNullable<MetadataRoute.Sitemap[number]["changeFrequency"]>;
|
||||||
|
|
||||||
|
const pages: ReadonlyArray<{ page: PageKey; changeFrequency: ChangeFrequency; priority: number }> = [
|
||||||
|
{ page: "home", changeFrequency: "monthly", priority: 1 },
|
||||||
|
{ page: "coaching", changeFrequency: "monthly", priority: 0.8 },
|
||||||
|
{ page: "trainings", changeFrequency: "monthly", priority: 0.8 },
|
||||||
|
{ page: "keynotes", changeFrequency: "monthly", priority: 0.8 },
|
||||||
|
{ page: "impressum", changeFrequency: "yearly", priority: 0.2 },
|
||||||
|
{ page: "datenschutz", changeFrequency: "yearly", priority: 0.2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* Die englische Fassung ist eine Übersetzung, keine eigenständige Seite: gleiche
|
||||||
|
Änderungsfrequenz, aber niedrigere Priorität, damit Deutsch die Originalfassung bleibt.
|
||||||
|
Jeder Eintrag nennt über `alternates.languages` beide Fassungen – dasselbe hreflang,
|
||||||
|
das die Seiten im <head> tragen. */
|
||||||
|
const PRIORITY_FACTOR: Record<Lang, number> = { de: 1, en: 0.9 };
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
// Evaluated at build time (no request time in a static export): "last modified" = last build.
|
||||||
|
const lastModified = new Date();
|
||||||
|
return LANGS.flatMap((lang) =>
|
||||||
|
pages.map(({ page, changeFrequency, priority }) => ({
|
||||||
|
url: `${site.url}${routesByLang[lang][page]}`,
|
||||||
|
lastModified,
|
||||||
|
changeFrequency,
|
||||||
|
priority: Number((priority * PRIORITY_FACTOR[lang]).toFixed(2)),
|
||||||
|
alternates: {
|
||||||
|
languages: {
|
||||||
|
de: `${site.url}${routesByLang.de[page]}`,
|
||||||
|
en: `${site.url}${routesByLang.en[page]}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
130
src/components/brand/BrandMark.tsx
Normal file
130
src/components/brand/BrandMark.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getLogoSvg } from "@/lib/assets";
|
||||||
|
import { WolfMark } from "./WolfMark";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server Component. Renders the client's `public/brand/logo-mark.svg` inline
|
||||||
|
* (checked with fs at build time via `getLogoSvg()`) or falls back to the
|
||||||
|
* in-house <WolfMark/>.
|
||||||
|
*
|
||||||
|
* Sanitising: a `<script>` in the delivered file aborts the build; `<foreignObject>`,
|
||||||
|
* `on*=` handlers and every non-fragment `href` are stripped before injection.
|
||||||
|
*
|
||||||
|
* Inline SVG preparation: every <path> gets `data-draw`; ids are mapped to
|
||||||
|
* the WolfMark classes (`#v-left`/`#v-right` → `.wolfmark-v`, `#wolf` →
|
||||||
|
* `.wolfmark-wolf`, `#handoff` → `.wolfmark-wolf.wolfmark-handoff`), ids are
|
||||||
|
* prefixed with `idPrefix`, `width`/`height` are stripped so CSS sizes the mark.
|
||||||
|
*/
|
||||||
|
export interface BrandMarkProps {
|
||||||
|
className?: string;
|
||||||
|
/** Prefix for gradient/path ids when the mark appears more than once per page. */
|
||||||
|
idPrefix?: string;
|
||||||
|
/** `v-only` = the two V strokes without the wolf (404 page). */
|
||||||
|
variant?: "full" | "v-only";
|
||||||
|
/** Keep `path#handoff` of the client SVG (hero only). */
|
||||||
|
handoff?: boolean;
|
||||||
|
/** Accessible name. */
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ID_CLASS: Record<string, string> = {
|
||||||
|
"v-left": "wolfmark-v",
|
||||||
|
"v-right": "wolfmark-v",
|
||||||
|
wolf: "wolfmark-wolf",
|
||||||
|
handoff: "wolfmark-wolf wolfmark-handoff",
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeAttr(s: string): string {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareSvg(
|
||||||
|
raw: string,
|
||||||
|
{ className, idPrefix, variant, handoff, title }: Required<Pick<BrandMarkProps, "variant" | "handoff" | "title">> &
|
||||||
|
Pick<BrandMarkProps, "className" | "idPrefix">,
|
||||||
|
): string {
|
||||||
|
/* The file comes from a non-technical client via public/brand/ and is injected with
|
||||||
|
`dangerouslySetInnerHTML` into the header, footer, curtain and hero of EVERY page –
|
||||||
|
on a site whose whole privacy story is „no external scripts“. A designer export can
|
||||||
|
legitimately contain a <script> or an event handler, so: a script aborts the build
|
||||||
|
(loud, not silently patched away), everything else executable is removed. */
|
||||||
|
if (/<script[\s>]/i.test(raw)) {
|
||||||
|
throw new Error(
|
||||||
|
"public/brand/logo-mark.svg enthält ein <script>-Element. Die Datei wird auf jeder Seite inline eingebettet – " +
|
||||||
|
"bitte eine Export-Variante ohne Skript liefern (in Illustrator/Figma: „SVG ohne Interaktivität“).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let svg = raw
|
||||||
|
.replace(/<\?xml[^>]*\?>/g, "")
|
||||||
|
.replace(/<!DOCTYPE[^>]*>/gi, "")
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, "")
|
||||||
|
/* Executable payloads: inline handlers, foreign HTML, javascript: targets. */
|
||||||
|
.replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, "")
|
||||||
|
.replace(/\son[a-z]+\s*=\s*"[^"]*"/gi, "")
|
||||||
|
.replace(/\son[a-z]+\s*=\s*'[^']*'/gi, "")
|
||||||
|
.replace(/\s(?:xlink:)?href\s*=\s*"(?!#)[^"]*"/gi, "")
|
||||||
|
.replace(/\s(?:xlink:)?href\s*=\s*'(?!#)[^']*'/gi, "")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const prefix = idPrefix ? `${idPrefix}-` : "";
|
||||||
|
if (prefix) {
|
||||||
|
svg = svg
|
||||||
|
.replace(/\bid="([^"]+)"/g, (_, id: string) => `id="${prefix}${id}"`)
|
||||||
|
.replace(/url\(#([^)]+)\)/g, (_, id: string) => `url(#${prefix}${id})`)
|
||||||
|
.replace(/\b(xlink:)?href="#([^"]+)"/g, (_, x: string | undefined, id: string) => `${x ?? ""}href="#${prefix}${id}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg = svg.replace(/<path\b([^>]*?)(\/?)>/g, (_, attrs: string, selfClose: string) => {
|
||||||
|
let a = attrs;
|
||||||
|
const idMatch = /\bid="([^"]+)"/.exec(a);
|
||||||
|
const rawId = idMatch ? idMatch[1].slice(prefix.length) : null;
|
||||||
|
const cls = rawId ? ID_CLASS[rawId] : undefined;
|
||||||
|
const isWolf = rawId === "wolf" || rawId === "handoff" || /wolfmark-wolf/.test(a);
|
||||||
|
if (rawId === "handoff" && !handoff) return "";
|
||||||
|
if (variant === "v-only" && isWolf) return "";
|
||||||
|
if (!/\bdata-draw\b/.test(a)) a += ' data-draw=""';
|
||||||
|
if (cls) {
|
||||||
|
a = /\bclass="/.test(a) ? a.replace(/\bclass="([^"]*)"/, (__, c: string) => `class="${c} ${cls}"`) : `${a} class="${cls}"`;
|
||||||
|
}
|
||||||
|
return `<path${a}${selfClose}>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
svg = svg.replace(/<svg\b([^>]*)>/, (_, attrs: string) => {
|
||||||
|
let a = attrs.replace(/\s(width|height)="[^"]*"/g, "");
|
||||||
|
if (/\bclass="/.test(a)) {
|
||||||
|
a = a.replace(/\bclass="([^"]*)"/, (__, c: string) => `class="${cn(c, className)}"`);
|
||||||
|
} else if (className) {
|
||||||
|
a += ` class="${className}"`;
|
||||||
|
}
|
||||||
|
if (!/\brole=/.test(a)) a += ' role="img"';
|
||||||
|
if (!/\baria-label=/.test(a)) a += ` aria-label="${escapeAttr(title)}"`;
|
||||||
|
return `<svg${a}>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrandMark({
|
||||||
|
className,
|
||||||
|
idPrefix,
|
||||||
|
variant = "full",
|
||||||
|
handoff = false,
|
||||||
|
title = "Anelia Wolf – Monogramm",
|
||||||
|
}: BrandMarkProps) {
|
||||||
|
const raw = getLogoSvg();
|
||||||
|
if (raw) {
|
||||||
|
const html = prepareSvg(raw, { className, idPrefix, variant, handoff, title });
|
||||||
|
return <span className="contents" dangerouslySetInnerHTML={{ __html: html }} />;
|
||||||
|
}
|
||||||
|
if (variant === "v-only") {
|
||||||
|
return (
|
||||||
|
<span className="contents [&_.wolfmark-wolf]:hidden">
|
||||||
|
<WolfMark className={className} idPrefix={idPrefix} title={title} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <WolfMark className={className} idPrefix={idPrefix} title={title} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BrandMark;
|
||||||
220
src/components/brand/DrawMark.tsx
Normal file
220
src/components/brand/DrawMark.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLayoutEffect, useRef, type ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { gsap, DUR, MM } from "@/lib/gsap";
|
||||||
|
import { STROKE_IN, WIPE_IN, playSheen, primeMark } from "@/components/motion/Draw";
|
||||||
|
|
||||||
|
export interface DrawMarkProps {
|
||||||
|
/** `load` = on mount (hero), `view` = at 80 % in view (once), `scrub` = scroll-tied (Prinzip). */
|
||||||
|
play?: "load" | "view" | "scrub";
|
||||||
|
/** One-time champagne sweep over the wolf stroke after drawing. */
|
||||||
|
sheen?: boolean;
|
||||||
|
/** Continue the neck stroke downward into the first hairline (hero only). */
|
||||||
|
handoff?: boolean;
|
||||||
|
/** Length of the handoff hairline in px when the SVG has no `#handoff` path (24–48). */
|
||||||
|
handoffLength?: number;
|
||||||
|
delay?: number;
|
||||||
|
className?: string;
|
||||||
|
onComplete?: () => void;
|
||||||
|
/** Receives the handoff x in px from the wrapper's left edge (for `<Rule origin={x}/>`). */
|
||||||
|
onHandoff?: (x: number) => void;
|
||||||
|
/** `<BrandMark/>` (Server Component, passed as children). */
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Timing of the draw sequence in seconds (spec §5.5). */
|
||||||
|
const T = { vLeft: [0, 0.35], vRight: [0.25, 0.35], wolf: [0.5, 0.7], detail: [1.0, 0.3], handoff: [1.2, 0.2], sheen: 1.5 } as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client shell around <BrandMark/>: V-left → V-right → wolf → (handoff) → sheen.
|
||||||
|
* Paths are ordered by the classes `.wolfmark-v` / `.wolfmark-wolf`; an inline
|
||||||
|
* client SVG without those classes is drawn in DOM order.
|
||||||
|
*/
|
||||||
|
export function DrawMark({
|
||||||
|
play = "load",
|
||||||
|
sheen = false,
|
||||||
|
handoff = false,
|
||||||
|
handoffLength = 40,
|
||||||
|
delay = 0,
|
||||||
|
className,
|
||||||
|
onComplete,
|
||||||
|
onHandoff,
|
||||||
|
children,
|
||||||
|
}: DrawMarkProps) {
|
||||||
|
const wrap = useRef<HTMLSpanElement>(null);
|
||||||
|
const line = useRef<HTMLSpanElement>(null);
|
||||||
|
const onCompleteRef = useRef(onComplete);
|
||||||
|
const onHandoffRef = useRef(onHandoff);
|
||||||
|
/* Read imperatively, never a dependency: the hero measures the gap and pushes a new
|
||||||
|
length on every resize, and re-running the effect would `mm.revert()` the whole
|
||||||
|
matchMedia scope and replay the monogram draw mid-interaction. */
|
||||||
|
const handoffLengthRef = useRef(handoffLength);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
onCompleteRef.current = onComplete;
|
||||||
|
onHandoffRef.current = onHandoff;
|
||||||
|
});
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
handoffLengthRef.current = handoffLength;
|
||||||
|
const el = line.current;
|
||||||
|
if (el) el.style.height = `${handoffLength}px`;
|
||||||
|
}, [handoffLength]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = wrap.current;
|
||||||
|
const svg = el?.querySelector("svg");
|
||||||
|
if (!el || !svg) return;
|
||||||
|
|
||||||
|
const all = Array.from(svg.querySelectorAll<SVGPathElement>("path"));
|
||||||
|
const handoffPath = all.find((p) => p.classList.contains("wolfmark-handoff")) ?? null;
|
||||||
|
let vs = all.filter((p) => p.classList.contains("wolfmark-v"));
|
||||||
|
let wolves = all.filter((p) => p.classList.contains("wolfmark-wolf") && p !== handoffPath);
|
||||||
|
if (!vs.length && !wolves.length) {
|
||||||
|
// Unknown SVG: everything in DOM order – first two strokes as "V", the rest as "wolf".
|
||||||
|
const drawable = all.filter((p) => p.hasAttribute("data-draw"));
|
||||||
|
const seq = drawable.length ? drawable : all;
|
||||||
|
vs = seq.slice(0, 2);
|
||||||
|
wolves = seq.slice(2);
|
||||||
|
}
|
||||||
|
const mainWolf = wolves[0] ?? null;
|
||||||
|
const details = wolves.slice(1);
|
||||||
|
const sequence = [...vs, ...wolves, ...(handoffPath ? [handoffPath] : [])];
|
||||||
|
const lineEl = line.current;
|
||||||
|
const useLine = handoff && !handoffPath && !!lineEl;
|
||||||
|
|
||||||
|
/* The path geometry is fixed in SVG user space – only the CTM changes on resize.
|
||||||
|
Sample once (2 × 121 `getPointAtLength` calls), then re-project the cached point. */
|
||||||
|
let anchor: { x: number; y: number } | null | undefined;
|
||||||
|
const sampleAnchor = (): { x: number; y: number } | null => {
|
||||||
|
if (!mainWolf) return null;
|
||||||
|
const end = mainWolf.getPointAtLength(mainWolf.getTotalLength());
|
||||||
|
const pts: { x: number; y: number }[] = [];
|
||||||
|
let maxY = -Infinity;
|
||||||
|
vs.forEach((v) => {
|
||||||
|
const len = v.getTotalLength();
|
||||||
|
for (let i = 0; i <= 120; i++) {
|
||||||
|
const p = v.getPointAtLength((len * i) / 120);
|
||||||
|
pts.push({ x: p.x, y: p.y });
|
||||||
|
if (p.y > maxY) maxY = p.y;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const pool = pts.filter((p) => p.y >= maxY - 2);
|
||||||
|
return (pool.length ? pool : [{ x: end.x, y: end.y }]).reduce((a, b) =>
|
||||||
|
Math.abs(b.x - end.x) < Math.abs(a.x - end.x) ? b : a,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Position of the handoff hairline: the lowest V point nearest the wolf's end. */
|
||||||
|
const placeLine = (): number | null => {
|
||||||
|
if (!mainWolf || !lineEl) return null;
|
||||||
|
try {
|
||||||
|
const ctm = svg.getScreenCTM();
|
||||||
|
if (!ctm) return null;
|
||||||
|
if (anchor === undefined) anchor = sampleAnchor();
|
||||||
|
if (!anchor) return null;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const sp = new DOMPoint(anchor.x, anchor.y).matrixTransform(ctm);
|
||||||
|
const x = sp.x - rect.left;
|
||||||
|
const y = sp.y - rect.top;
|
||||||
|
lineEl.style.left = `${x}px`;
|
||||||
|
lineEl.style.top = `${y}px`;
|
||||||
|
lineEl.style.height = `${handoffLengthRef.current}px`;
|
||||||
|
el.style.setProperty("--handoff-x", `${x}px`);
|
||||||
|
return x;
|
||||||
|
} catch {
|
||||||
|
/* A measurement failure must never strand the mark – the reveal below runs anyway. */
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mm = gsap.matchMedia();
|
||||||
|
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
|
||||||
|
if (ctx.conditions?.reduce) {
|
||||||
|
gsap.set(el, { opacity: 1 });
|
||||||
|
if (useLine) {
|
||||||
|
const x = placeLine();
|
||||||
|
gsap.set(lineEl, { scaleY: 1, opacity: 1 });
|
||||||
|
if (x !== null) onHandoffRef.current?.(x);
|
||||||
|
}
|
||||||
|
onCompleteRef.current?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = primeMark(svg, sequence);
|
||||||
|
let hx: number | null = null;
|
||||||
|
if (useLine) {
|
||||||
|
hx = placeLine();
|
||||||
|
gsap.set(lineEl, { scaleY: 0, transformOrigin: "50% 0%", opacity: 1 });
|
||||||
|
}
|
||||||
|
gsap.set(el, { opacity: 1 });
|
||||||
|
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
paused: play !== "load",
|
||||||
|
delay: play === "load" ? delay : 0,
|
||||||
|
onComplete: () => {
|
||||||
|
if (hx !== null) onHandoffRef.current?.(hx);
|
||||||
|
if (sheen && play !== "scrub" && mainWolf) playSheen(mainWolf, DUR.sheen);
|
||||||
|
onCompleteRef.current?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ease = "power2.inOut";
|
||||||
|
if (mode === "wipe") {
|
||||||
|
/* Interlocked filled artwork: one slice down the whole mark (see Draw.tsx). */
|
||||||
|
tl.to(svg, { ...WIPE_IN, duration: T.wolf[0] + T.wolf[1], ease }, T.vLeft[0]);
|
||||||
|
} else {
|
||||||
|
if (vs[0]) tl.to(vs[0], { ...STROKE_IN, duration: T.vLeft[1], ease }, T.vLeft[0]);
|
||||||
|
if (vs[1]) tl.to(vs[1], { ...STROKE_IN, duration: T.vRight[1], ease }, T.vRight[0]);
|
||||||
|
vs.slice(2).forEach((v, i) => tl.to(v, { ...STROKE_IN, duration: 0.3, ease }, T.vRight[0] + 0.1 * (i + 1)));
|
||||||
|
if (mainWolf) tl.to(mainWolf, { ...STROKE_IN, duration: T.wolf[1], ease }, T.wolf[0]);
|
||||||
|
if (details.length) tl.to(details, { ...STROKE_IN, duration: T.detail[1], ease, stagger: 0.08 }, T.detail[0]);
|
||||||
|
if (handoffPath) tl.to(handoffPath, { ...STROKE_IN, duration: T.handoff[1], ease: "power2.out" }, T.handoff[0]);
|
||||||
|
}
|
||||||
|
/* The hairline lives outside the svg, so it follows either reveal. */
|
||||||
|
if (useLine) tl.to(lineEl, { scaleY: 1, duration: T.handoff[1] + 0.1, ease: "power2.out" }, T.handoff[0]);
|
||||||
|
|
||||||
|
if (play === "view") {
|
||||||
|
gsap.timeline({ scrollTrigger: { trigger: el, start: "top 80%", once: true, onEnter: () => tl.play() } });
|
||||||
|
} else if (play === "scrub") {
|
||||||
|
tl.eventCallback("onComplete", null);
|
||||||
|
gsap.to(tl, {
|
||||||
|
progress: 1,
|
||||||
|
ease: "none",
|
||||||
|
scrollTrigger: { trigger: el, start: "top 80%", end: "bottom 40%", scrub: 0.6 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep the hairline attached to the mark when the viewport changes – one rAF per
|
||||||
|
frame, not one full re-projection per resize event. */
|
||||||
|
let raf = 0;
|
||||||
|
const onResize = () => {
|
||||||
|
if (!useLine || raf) return;
|
||||||
|
raf = requestAnimationFrame(() => {
|
||||||
|
raf = 0;
|
||||||
|
placeLine();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
if (raf) cancelAnimationFrame(raf);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return () => mm.revert();
|
||||||
|
}, [play, sheen, handoff, delay]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={wrap} className={cn("relative inline-block [.js_&]:opacity-0", className)}>
|
||||||
|
{children}
|
||||||
|
{handoff && (
|
||||||
|
<span
|
||||||
|
ref={line}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute w-px bg-gold opacity-0"
|
||||||
|
style={{ height: handoffLength, left: 0, top: "100%" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DrawMark;
|
||||||
125
src/components/brand/WolfMark.tsx
Normal file
125
src/components/brand/WolfMark.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
/** viewBox of the Anelia Wolf monogram. Proportion ~1.25 : 1. */
|
||||||
|
export const WOLF_MARK_VIEWBOX = "0 0 250 200";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path geometry of the mark (viewBox 0 0 250 200).
|
||||||
|
* The two V's are hollow outline bands: the left V is one closed path
|
||||||
|
* (outer + inner contour); the right V is an open contour whose left arm is
|
||||||
|
* taken over by the wolf head. The wolf is ONE continuous open path (nape ->
|
||||||
|
* ear -> brow -> muzzle -> jaw -> throat, merging into the W's middle
|
||||||
|
* down-stroke) plus one tiny detail stroke (the eye).
|
||||||
|
*/
|
||||||
|
export const WOLF_MARK_PATHS = {
|
||||||
|
vLeft: "M111,70 L64,190 L10,52 L34,52 L64,130 L92.6,56",
|
||||||
|
vRight: "M160.2,124 L186,190 L240,52 L216,52 L186,130 L172.9,96",
|
||||||
|
wolf: "M111,70 C117,56 117,42 126,32 L140,12 L155,34 C159,39 164,44 168,47 C180,52 193,59 204,66 C207,68 207,73 204,76 C196,78 188,79 180,80 C172,84 162,92 154,102 C149,108 157.3,116.6 160.2,124",
|
||||||
|
eye: "M160,53 Q165,49.5 170,53 Q165,56.5 160,53 Z",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const GOLD_STOPS: ReadonlyArray<readonly [number, string]> = [
|
||||||
|
[0, "#F4E5B8"],
|
||||||
|
[0.22, "#E6C97A"],
|
||||||
|
[0.48, "#C9A24D"],
|
||||||
|
[0.74, "#A37A2C"],
|
||||||
|
[1, "#6E501A"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface WolfMarkProps {
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
/** Accessible name of the mark. */
|
||||||
|
title?: string;
|
||||||
|
/** Prefix for the gradient id – set it when the mark appears more than once on a page. */
|
||||||
|
idPrefix?: string;
|
||||||
|
/** Stroke width in viewBox units (default 7). */
|
||||||
|
strokeWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anelia Wolf monogram: a "W" built from two hollow V outlines whose middle peak
|
||||||
|
* is a wolf head in profile (facing right) drawn in metallic gold.
|
||||||
|
* Server-component friendly – no hooks, no client code.
|
||||||
|
*
|
||||||
|
* - V outlines use `currentColor` (set `color` on a parent: black on light, ivory on dark).
|
||||||
|
* - Every gold path carries `data-draw` and `pathLength="1"`, so a draw-on animation
|
||||||
|
* is simply `stroke-dasharray: 1; stroke-dashoffset: 1 -> 0`.
|
||||||
|
*/
|
||||||
|
export function WolfMark({
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
title = "Anelia Wolf – Monogramm",
|
||||||
|
idPrefix,
|
||||||
|
strokeWidth = 7,
|
||||||
|
}: WolfMarkProps) {
|
||||||
|
const gradientId = idPrefix ? `${idPrefix}-wolf-gold` : "wolf-gold";
|
||||||
|
const gold = `url(#${gradientId})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox={WOLF_MARK_VIEWBOX}
|
||||||
|
role="img"
|
||||||
|
aria-label={title}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<title>{title}</title>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1" gradientUnits="objectBoundingBox">
|
||||||
|
{GOLD_STOPS.map(([offset, color]) => (
|
||||||
|
<stop key={offset} offset={offset} stopColor={color} />
|
||||||
|
))}
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* The two V's – hollow outline bands */}
|
||||||
|
<path
|
||||||
|
className="wolfmark-v"
|
||||||
|
d={WOLF_MARK_PATHS.vLeft}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
strokeLinecap="butt"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="wolfmark-v"
|
||||||
|
d={WOLF_MARK_PATHS.vRight}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
strokeLinecap="butt"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Wolf head – one continuous gold line + the eye */}
|
||||||
|
<path
|
||||||
|
className="wolfmark-wolf"
|
||||||
|
data-draw=""
|
||||||
|
pathLength={1}
|
||||||
|
d={WOLF_MARK_PATHS.wolf}
|
||||||
|
fill="none"
|
||||||
|
stroke={gold}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
strokeMiterlimit={4}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="wolfmark-wolf wolfmark-wolf-detail"
|
||||||
|
data-draw=""
|
||||||
|
pathLength={1}
|
||||||
|
d={WOLF_MARK_PATHS.eye}
|
||||||
|
fill={gold}
|
||||||
|
stroke={gold}
|
||||||
|
strokeWidth={strokeWidth * 0.35}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WolfMark;
|
||||||
73
src/components/brand/Wordmark.tsx
Normal file
73
src/components/brand/Wordmark.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
|
||||||
|
import { TransitionLink } from "@/components/motion/TransitionLink";
|
||||||
|
|
||||||
|
export interface WordmarkProps {
|
||||||
|
size?: "nav" | "footer";
|
||||||
|
href?: string;
|
||||||
|
className?: string;
|
||||||
|
/** Optional monogram rendered before the name (header 28 px, footer 40 px). */
|
||||||
|
mark?: ReactNode;
|
||||||
|
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
|
||||||
|
chrome?: Chrome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML wordmark: ANELIA WOLF (Jost 300, ls .32em) over a gold hairline and the
|
||||||
|
* sub-line. One `<a>` with aria-label „Anelia Wolf – Startseite“.
|
||||||
|
* Nav: 1.0625rem, name only – rule and sub-line are the footer's job (the
|
||||||
|
* header row has no width to spare between 1024 and 1440). Footer: fluid
|
||||||
|
* 20–44 px name, sub-line gold at 0.45em / 0.18em tracking (spec §3.2: the
|
||||||
|
* sub never out-tracks the name).
|
||||||
|
*/
|
||||||
|
export function Wordmark({ size = "nav", href = "/", className, mark, chrome = chromeDe }: WordmarkProps) {
|
||||||
|
const footer = size === "footer";
|
||||||
|
return (
|
||||||
|
<TransitionLink
|
||||||
|
href={href}
|
||||||
|
aria-label={chrome.wordmark.ariaLabel}
|
||||||
|
className={cn("wordmark group inline-flex max-w-full items-center", footer ? "gap-4 sm:gap-6" : "gap-4", className)}
|
||||||
|
>
|
||||||
|
{mark && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("wordmark__mark shrink-0 [&_svg]:h-full [&_svg]:w-auto", footer ? "h-10" : "h-7")}
|
||||||
|
>
|
||||||
|
{mark}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"wordmark__text flex flex-col",
|
||||||
|
footer ? "min-w-0 text-[clamp(1.25rem,0.75rem+1.8vw,2.75rem)]" : "text-[1.0625rem]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"wordmark__name whitespace-nowrap pl-[0.32em] font-sans text-[1em] font-light uppercase leading-none tracking-[0.32em]",
|
||||||
|
"text-fg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{chrome.wordmark.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("wordmark__rule rule-gold", footer ? "my-[0.3em]" : "hidden")}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"wordmark__sub pl-[0.22em] font-sans font-normal uppercase",
|
||||||
|
footer
|
||||||
|
? "max-sm:whitespace-normal max-sm:leading-[1.5] sm:whitespace-nowrap sm:leading-none text-[0.55em] tracking-[0.18em] text-gold sm:text-[0.45em]"
|
||||||
|
: "hidden",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{chrome.wordmark.sub}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</TransitionLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Wordmark;
|
||||||
105
src/components/home/About.tsx
Normal file
105
src/components/home/About.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { flags } from "@/lib/site";
|
||||||
|
import { about as aboutDe, type HomeContent } from "@/content/home";
|
||||||
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { Lines } from "@/components/motion/Lines";
|
||||||
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
|
import { Rule } from "@/components/motion/Rule";
|
||||||
|
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||||
|
import { Ledger } from "@/components/ui/Ledger";
|
||||||
|
import { Marginalia } from "@/components/ui/Marginalia";
|
||||||
|
import { Portrait } from "@/components/ui/Portrait";
|
||||||
|
|
||||||
|
export interface AboutProps {
|
||||||
|
/** `getPhotos().portrait` – `null` renders the documented variant B (no photo). */
|
||||||
|
photo: string | null;
|
||||||
|
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
|
||||||
|
content?: HomeContent["about"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H2 on ivory: Playfair 400, max 14ch (spec §3.2 / §6.5). */
|
||||||
|
const H2 =
|
||||||
|
"font-display text-[clamp(2.25rem,1.4rem+3.2vw,4.75rem)] font-normal leading-[1.02] tracking-[-0.01em] text-fg [text-wrap:balance] max-w-[14ch]";
|
||||||
|
|
||||||
|
/* Body: Jost 400, 18 px, max 36rem, paragraph gap 1.25em, German hyphenation. */
|
||||||
|
const BODY = "max-w-[36rem] font-sans text-[1.125rem] leading-[1.6] text-fg [hyphens:auto]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 04 · Über Anelia (ivory – spec §6.5). First person, a portrait that stays
|
||||||
|
* in view while reading, the Werdegang as a ledger, marginalia with
|
||||||
|
* verifiable facts. No counters, no logos.
|
||||||
|
*
|
||||||
|
* With photo: Portrait cols 3–6 (sticky) · text + ledger cols 7–12 · marginalia cols 10–12.
|
||||||
|
* Without: text cols 3–8 · ledger cols 3–9 · marginalia under the ledger
|
||||||
|
* (no monogram vignette – see below).
|
||||||
|
*/
|
||||||
|
export function About({ photo, content: about = aboutDe }: AboutProps) {
|
||||||
|
const paragraphs = about.paragraphs.map((p, i) =>
|
||||||
|
i === about.paragraphs.length - 1 && flags.showTwins ? `${p} ${about.twins}` : p,
|
||||||
|
);
|
||||||
|
const hasPhoto = Boolean(photo);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-theme="light" className="relative bg-transparent text-fg">
|
||||||
|
<Rule variant="gold" />
|
||||||
|
<Section
|
||||||
|
id={about.id}
|
||||||
|
theme="light"
|
||||||
|
numeral={about.numeral}
|
||||||
|
label={about.eyebrow}
|
||||||
|
className="py-[clamp(4.5rem,10vh,8rem)] md:py-[clamp(7rem,16vh,14rem)]"
|
||||||
|
>
|
||||||
|
{hasPhoto && (
|
||||||
|
<div className="col-span-12 mb-14 lg:col-span-4 lg:col-start-3 lg:mb-0">
|
||||||
|
<Portrait
|
||||||
|
src={photo}
|
||||||
|
alt={about.alt}
|
||||||
|
aspect="4/5"
|
||||||
|
caption={about.caption}
|
||||||
|
sticky
|
||||||
|
parallax
|
||||||
|
sizes="(min-width: 1024px) 30vw, 100vw"
|
||||||
|
className="w-full max-w-[48svh] lg:max-w-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cn("col-span-12 lg:row-start-1", hasPhoto ? "lg:col-span-6 lg:col-start-7" : "lg:col-span-6 lg:col-start-3")}>
|
||||||
|
<Eyebrow className="mb-8">{about.eyebrow}</Eyebrow>
|
||||||
|
<Lines as="h2" className={H2}>
|
||||||
|
{about.h2.before}
|
||||||
|
{about.h2.em && <em className="italic">{about.h2.em}</em>}
|
||||||
|
{about.h2.after}
|
||||||
|
</Lines>
|
||||||
|
<div className="mt-10 flex flex-col gap-[1.25em]">
|
||||||
|
{paragraphs.map((text, i) => (
|
||||||
|
<Reveal key={i} as="p" delay={i * 0.08} className={BODY}>
|
||||||
|
{text}
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Werdegang ledger: cols 7–12 beside the photo, cols 3–9 without. */}
|
||||||
|
<div className={cn("col-span-12 mt-12 lg:row-start-2 lg:mt-16", hasPhoto ? "lg:col-span-6 lg:col-start-7" : "lg:col-span-7 lg:col-start-3")}>
|
||||||
|
<Ledger variant="werdegang" rows={about.werdegang} stagger={0.08} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* No monogram vignette in variant B: the WOLF-Prinzip closer directly
|
||||||
|
above already ends on a full-height mark, and section 04 now carries
|
||||||
|
the sticky Bodoni numeral it was missing – a third W in the same
|
||||||
|
viewport turned the signature into wallpaper. */}
|
||||||
|
<Reveal
|
||||||
|
className={cn(
|
||||||
|
"col-span-12 mt-12",
|
||||||
|
hasPhoto ? "lg:col-span-3 lg:col-start-10 lg:row-start-2" : "lg:col-span-7 lg:col-start-3 lg:row-start-3",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Marginalia items={about.marginalia} />
|
||||||
|
</Reveal>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default About;
|
||||||
57
src/components/home/Audience.tsx
Normal file
57
src/components/home/Audience.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { audience as audienceDe, type HomeContent } from "@/content/home";
|
||||||
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { Lines } from "@/components/motion/Lines";
|
||||||
|
import { Rule } from "@/components/motion/Rule";
|
||||||
|
import { Columns } from "@/components/ui/Columns";
|
||||||
|
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||||
|
import { Ledger } from "@/components/ui/Ledger";
|
||||||
|
|
||||||
|
/* H2 on ivory: Bodoni 400, max 16ch (spec §3.2). */
|
||||||
|
const H2 =
|
||||||
|
"font-display text-[clamp(2.25rem,1.4rem+3.2vw,4.75rem)] font-normal leading-[1.02] tracking-[-0.01em] text-fg [text-wrap:balance] max-w-[16ch]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 05 · Für wen + Termine (ivory, Termine block on bone – spec §6.6).
|
||||||
|
* Three editorial columns with the middle one offset 48 px downward, then the
|
||||||
|
* verifiable dates ledger (static gold dot, no pulse). „Stimmen“ ships only
|
||||||
|
* with approved quotes (`flags.showStimmen`, currently off – nothing to render).
|
||||||
|
*/
|
||||||
|
export interface AudienceProps {
|
||||||
|
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
|
||||||
|
content?: HomeContent["audience"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Audience({ content: audience = audienceDe }: AudienceProps = {}) {
|
||||||
|
const termine = audience.termine;
|
||||||
|
return (
|
||||||
|
<div data-theme="light" className="relative bg-transparent text-fg">
|
||||||
|
<Rule variant="line" />
|
||||||
|
<Section
|
||||||
|
id={audience.id}
|
||||||
|
theme="light"
|
||||||
|
numeral={audience.numeral}
|
||||||
|
label={audience.eyebrow}
|
||||||
|
className="py-[clamp(4rem,9vh,7rem)] md:py-[clamp(6rem,14vh,12rem)]"
|
||||||
|
>
|
||||||
|
<div className="col-span-12 lg:col-span-7 lg:col-start-3 lg:row-start-1">
|
||||||
|
<Eyebrow className="mb-8">{audience.eyebrow}</Eyebrow>
|
||||||
|
<Lines as="h2" className={H2}>
|
||||||
|
{audience.h2.before}
|
||||||
|
{audience.h2.em && <em className="italic">{audience.h2.em}</em>}
|
||||||
|
{audience.h2.after}
|
||||||
|
</Lines>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-12 mt-12 lg:col-span-10 lg:col-start-3 lg:row-start-2 lg:mt-16">
|
||||||
|
<Columns items={audience.columns} offsetMiddle />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id={termine.id} className="col-span-12 mt-16 scroll-mt-24 lg:col-span-10 lg:col-start-3 lg:mt-24">
|
||||||
|
<Ledger variant="termine" rows={termine.rows} heading={termine.heading} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Audience;
|
||||||
320
src/components/home/Hero.tsx
Normal file
320
src/components/home/Hero.tsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { flags } from "@/lib/site";
|
||||||
|
import { gsap, MM } from "@/lib/gsap";
|
||||||
|
import { splitPlaceholders } from "@/lib/legal";
|
||||||
|
import { hero as heroDe, type HomeContent } from "@/content/home";
|
||||||
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { Folio } from "@/components/layout/Folio";
|
||||||
|
import { Lines } from "@/components/motion/Lines";
|
||||||
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
|
import { Door } from "@/components/motion/Door";
|
||||||
|
import { Magnet } from "@/components/motion/Magnet";
|
||||||
|
import { DrawMark } from "@/components/brand/DrawMark";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { PlaceholderText } from "@/components/ui/Facts";
|
||||||
|
|
||||||
|
export interface HeroProps {
|
||||||
|
/** `<BrandMark idPrefix="hero" handoff/>` – Server Component, rendered by page.tsx. */
|
||||||
|
mark: ReactNode;
|
||||||
|
/** `getPhotos().hero` – `null` renders the documented variant B (monogram is the image). */
|
||||||
|
photo: string | null;
|
||||||
|
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
|
||||||
|
content?: HomeContent["hero"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Load choreography (spec §6.1), seconds from mount. Masthead hairlines 0 / 0.15 are
|
||||||
|
Masthead's own; the folio hairline waits for the neck stroke (draw 0.7 + handoff 1.2–1.4). */
|
||||||
|
const T = { lines: 0.3, door: 0.5, draw: 0.7, cta: 1.2, folio: 2.0 } as const;
|
||||||
|
|
||||||
|
/** Fallback length of the handoff hairline until it is measured (px). */
|
||||||
|
const HANDOFF = 40;
|
||||||
|
|
||||||
|
const MONO_11 = "font-mono text-[11px] tracking-[0.06em]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handoff x for `<Rule origin={x}/>`, measured from the folio's left edge.
|
||||||
|
* DrawMark writes `--handoff-x` on its wrapper (fallback WolfMark); a client
|
||||||
|
* SVG with `path#handoff` is measured at the path's end point.
|
||||||
|
*/
|
||||||
|
function measureHandoffX(markWrap: HTMLElement, folioEl: HTMLElement): number | null {
|
||||||
|
const folioLeft = folioEl.getBoundingClientRect().left;
|
||||||
|
const drawWrap = markWrap.querySelector<HTMLElement>("[style*='--handoff-x']");
|
||||||
|
if (drawWrap) {
|
||||||
|
const v = parseFloat(drawWrap.style.getPropertyValue("--handoff-x"));
|
||||||
|
if (!Number.isNaN(v)) return drawWrap.getBoundingClientRect().left + v - folioLeft;
|
||||||
|
}
|
||||||
|
const path = markWrap.querySelector<SVGPathElement>("path.wolfmark-handoff");
|
||||||
|
const ctm = path?.ownerSVGElement?.getScreenCTM();
|
||||||
|
if (path && ctm) {
|
||||||
|
const end = path.getPointAtLength(path.getTotalLength());
|
||||||
|
const sp = new DOMPoint(end.x, end.y).matrixTransform(ctm);
|
||||||
|
return sp.x - folioLeft;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Samples the mark's outline in SVG user space – once. The geometry never
|
||||||
|
* changes, only the CTM does, so a resize re-projects these points instead of
|
||||||
|
* walking `getPointAtLength` again (~500 forced geometry calls per event).
|
||||||
|
*/
|
||||||
|
function sampleMarkPoints(markWrap: HTMLElement): { x: number; y: number }[] {
|
||||||
|
const svg = markWrap.querySelector("svg");
|
||||||
|
if (!svg) return [];
|
||||||
|
const pts: { x: number; y: number }[] = [];
|
||||||
|
svg.querySelectorAll<SVGPathElement>("path").forEach((path) => {
|
||||||
|
let len = 0;
|
||||||
|
try {
|
||||||
|
len = path.getTotalLength();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!len) return;
|
||||||
|
for (let i = 0; i <= 60; i++) {
|
||||||
|
const p = path.getPointAtLength((len * i) / 60);
|
||||||
|
pts.push({ x: p.x, y: p.y });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen y of the mark's lowest painted point – the V vertex the handoff
|
||||||
|
* hairline hangs from. The SVG box bottom is ~5 % below it (viewBox padding),
|
||||||
|
* so a fixed length always stopped short of the folio rule.
|
||||||
|
*/
|
||||||
|
function markBottomY(markWrap: HTMLElement, pts: { x: number; y: number }[]): number | null {
|
||||||
|
const svg = markWrap.querySelector("svg");
|
||||||
|
const ctm = svg?.getScreenCTM();
|
||||||
|
if (!svg || !ctm || !pts.length) return null;
|
||||||
|
let maxY = -Infinity;
|
||||||
|
for (const p of pts) {
|
||||||
|
const y = new DOMPoint(p.x, p.y).matrixTransform(ctm).y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
return Number.isFinite(maxY) ? maxY : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cover (ink, ≥ 100svh). Rows: (leer) · 1fr · H1 · Subline + CTA · Folio.
|
||||||
|
* Die Mastkopf-Zeile wurde am 06.09.2026 auf Kundenwunsch gestrichen; die erste
|
||||||
|
* Rasterzeile bleibt leer, damit die übrigen Zeilenzuweisungen unverändert gelten.
|
||||||
|
* Variant A (photo): portrait cols 8–12 bleeding to the page edge with scrims,
|
||||||
|
* monogram 28vh in cols 6–8 overlapping its left edge, neck handoff into the
|
||||||
|
* folio hairline, which draws outward from that x. Variant B (no photo):
|
||||||
|
* monogram 56vh cols 7–12, right-aligned, same handoff. Mobile: masthead,
|
||||||
|
* monogram 14svh, H1, CTAs, portrait 3:4 (≤ 48svh) – no folio row (it costs
|
||||||
|
* 69 px the phone cover does not have; Positionierung carries „01 / 06“).
|
||||||
|
*/
|
||||||
|
export function Hero({ mark, photo, content: hero = heroDe }: HeroProps) {
|
||||||
|
const root = useRef<HTMLDivElement>(null);
|
||||||
|
const h1 = useRef<HTMLDivElement>(null);
|
||||||
|
const markWrap = useRef<HTMLDivElement>(null);
|
||||||
|
const folio = useRef<HTMLDivElement>(null);
|
||||||
|
const [origin, setOrigin] = useState<number | null>(null);
|
||||||
|
const [handoffLength, setHandoffLength] = useState(HANDOFF);
|
||||||
|
const hasPhoto = Boolean(photo);
|
||||||
|
|
||||||
|
/* Handoff x → folio rule origin, and the exact gap from the mark's lowest
|
||||||
|
point down to that rule, so the neck stroke actually reaches the hairline
|
||||||
|
instead of ending as a stub ~25 px above it (desktop only; on mobile the
|
||||||
|
mark sits above the H1). */
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
let pts: { x: number; y: number }[] | null = null;
|
||||||
|
const measure = () => {
|
||||||
|
const m = markWrap.current;
|
||||||
|
const f = folio.current;
|
||||||
|
if (!m || !f || !window.matchMedia("(min-width: 1024px)").matches) return;
|
||||||
|
const x = measureHandoffX(m, f);
|
||||||
|
if (x !== null && x > 0) setOrigin(Math.round(x));
|
||||||
|
if (!pts) pts = sampleMarkPoints(m);
|
||||||
|
const bottom = markBottomY(m, pts);
|
||||||
|
if (bottom !== null) {
|
||||||
|
const gap = f.getBoundingClientRect().top - bottom;
|
||||||
|
setHandoffLength(Math.round(Math.min(72, Math.max(16, gap))));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
/* One measurement per frame: a window drag fires `resize` dozens of times a second
|
||||||
|
and each pass forces layout for the whole monogram. */
|
||||||
|
let raf = 0;
|
||||||
|
const onResize = () => {
|
||||||
|
if (raf) return;
|
||||||
|
raf = requestAnimationFrame(() => {
|
||||||
|
raf = 0;
|
||||||
|
measure();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
if (raf) cancelAnimationFrame(raf);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* Scroll: headline drifts up 6 % over the hero (desktop, no reduced motion). */
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = root.current;
|
||||||
|
const head = h1.current;
|
||||||
|
if (!el || !head) return;
|
||||||
|
const ctx = gsap.context(() => {
|
||||||
|
const mm = gsap.matchMedia();
|
||||||
|
mm.add({ desktop: MM.desktop, ok: MM.ok }, (c) => {
|
||||||
|
if (!c.conditions?.desktop || !c.conditions?.ok) return;
|
||||||
|
gsap.to(head, {
|
||||||
|
yPercent: -6,
|
||||||
|
ease: "none",
|
||||||
|
scrollTrigger: { trigger: el, start: "top top", end: "bottom top", scrub: 0.6 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, el);
|
||||||
|
return () => ctx.revert();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const captionHasPlaceholder = splitPlaceholders(hero.caption).some((s) => s.placeholder);
|
||||||
|
const showCaption = !captionHasPlaceholder || flags.showPlaceholders;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
id={hero.id}
|
||||||
|
theme="dark"
|
||||||
|
className="grid min-h-svh pt-[calc(var(--header-h,7.5rem)+0.75rem)] pb-0 lg:pt-[calc(var(--header-h,7.5rem)+0.5rem)] [&>div]:grid"
|
||||||
|
>
|
||||||
|
{/* From lg the FOURTH row (subline + CTA) is the flexible one, not the second:
|
||||||
|
the slack has to sit between the CTA block and the folio, otherwise a
|
||||||
|
shorter CTA row pushes the headline down into the monogram. */}
|
||||||
|
<div
|
||||||
|
ref={root}
|
||||||
|
className="col-span-12 grid grid-cols-12 grid-rows-[auto_auto_auto_auto_auto_auto] gap-x-[clamp(16px,2vw,32px)] lg:grid-rows-[auto_auto_auto_1fr_auto]"
|
||||||
|
>
|
||||||
|
|
||||||
|
{/* Portrait – variant A. Desktop: cols 8–12, rows 2–4, bleeds to the page edge. */}
|
||||||
|
{photo && (
|
||||||
|
<div className="relative z-0 col-span-12 row-start-5 mt-10 lg:col-span-5 lg:col-start-8 lg:row-start-2 lg:row-end-5 lg:mt-0 lg:-mr-[clamp(20px,5vw,80px)]">
|
||||||
|
<div className="relative h-full">
|
||||||
|
<Door
|
||||||
|
src={photo}
|
||||||
|
alt={hero.alt}
|
||||||
|
width={1600}
|
||||||
|
height={2000}
|
||||||
|
sizes="(min-width: 1024px) 45vw, 100vw"
|
||||||
|
priority
|
||||||
|
parallax
|
||||||
|
play="load"
|
||||||
|
delay={T.door}
|
||||||
|
className="max-h-[48svh] w-full lg:h-full lg:max-h-none"
|
||||||
|
imgClassName="[filter:sepia(.12)_saturate(.9)_contrast(1.02)]"
|
||||||
|
/>
|
||||||
|
{/* Scrims: top third + left edge (headline legibility). */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 bg-[linear-gradient(var(--ink)_0%,transparent_45%)]"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 hidden bg-[linear-gradient(90deg,var(--ink)_0%,rgba(15,14,12,0.72)_30%,transparent_60%)] lg:block"
|
||||||
|
/>
|
||||||
|
{showCaption && (
|
||||||
|
<p className={cn(MONO_11, "absolute right-4 bottom-4 text-ivory/80")}>
|
||||||
|
<PlaceholderText text={hero.caption} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Monogram: bottom-aligned to the folio hairline; the neck hands off into it. */}
|
||||||
|
<div
|
||||||
|
ref={markWrap}
|
||||||
|
className={cn(
|
||||||
|
"relative z-[1] col-span-12 row-start-2 mt-6 h-[14svh] text-ivory lg:row-start-2 lg:row-end-5 lg:mt-0 lg:mb-4 lg:self-end",
|
||||||
|
"max-lg:[&_span[aria-hidden=true]]:hidden",
|
||||||
|
/* WolfMark's 7-unit stroke is ~18 px at hero size – far from the
|
||||||
|
spec's hairline mark. Overridden here, not in WolfMark.tsx. */
|
||||||
|
"[&_.wolfmark-v]:[stroke-width:3] [&_.wolfmark-wolf]:[stroke-width:2.6]",
|
||||||
|
hasPhoto
|
||||||
|
? "lg:col-span-3 lg:col-start-6 lg:h-[28vh] lg:justify-self-start"
|
||||||
|
: "lg:col-span-6 lg:col-start-7 lg:h-[min(45vh,24vw)] lg:justify-self-end",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DrawMark
|
||||||
|
play="load"
|
||||||
|
sheen
|
||||||
|
handoff
|
||||||
|
handoffLength={handoffLength}
|
||||||
|
delay={T.draw}
|
||||||
|
className="block h-full [&_svg]:h-full [&_svg]:w-auto"
|
||||||
|
>
|
||||||
|
{mark}
|
||||||
|
</DrawMark>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* H1 */}
|
||||||
|
<div
|
||||||
|
ref={h1}
|
||||||
|
className="relative z-[2] col-span-12 row-start-3 mt-6 lg:col-span-8 lg:col-start-1 lg:row-start-3 lg:mt-0 lg:self-end"
|
||||||
|
>
|
||||||
|
{/* The H1 is capped by viewport HEIGHT as well as width: `26vh - 108px`
|
||||||
|
is neutral at 1440×900 (the cover's reference frame) and pulls the
|
||||||
|
three Bodoni lines back on 800/768 px laptops, where the vw term
|
||||||
|
alone grows faster than the fold shrinks. */}
|
||||||
|
<Lines
|
||||||
|
as="h1"
|
||||||
|
play="load"
|
||||||
|
delay={T.lines}
|
||||||
|
stagger={0.09}
|
||||||
|
className="max-w-[12ch] font-display text-[clamp(3rem,min(1.4rem_+_7.4vw,26vh_-_108px),8rem)] font-normal leading-[0.94] tracking-[-0.015em] text-ivory [text-wrap:balance]"
|
||||||
|
>
|
||||||
|
{hero.h1.before}
|
||||||
|
<em className="italic">{hero.h1.em}</em>
|
||||||
|
{hero.h1.after}
|
||||||
|
</Lines>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subline + CTA. Vertical rhythm from lg is viewport-relative so the cover
|
||||||
|
keeps fitting `min-h-svh` at 768–900 px viewport heights; the row runs
|
||||||
|
cols 1–8 below 1440 so button + text link stay on one line there too. At
|
||||||
|
1440×900 – the cover's reference frame – the original 24/32 px rhythm is
|
||||||
|
restored, which also keeps the headline clear of the monogram. */}
|
||||||
|
<div className="relative z-[2] col-span-12 row-start-4 mt-6 pb-2 lg:col-span-6 lg:col-start-1 lg:row-start-4 lg:mt-[2.2vh] lg:pb-[3vh] wide:mt-6 wide:pb-8">
|
||||||
|
<Reveal play="load" delay={T.cta} as="p" className="max-w-[38ch] font-sans text-[clamp(1.125rem,min(1.1rem_+_0.6vw,3.1vh),1.5rem)] font-light leading-[1.45] text-fg-muted wide:max-w-[46ch]">
|
||||||
|
{hero.subline}
|
||||||
|
</Reveal>
|
||||||
|
<Reveal play="load" delay={T.cta + 0.1} className="mt-6 flex flex-wrap items-start gap-x-8 gap-y-4 lg:mt-[2.2vh] wide:mt-6">
|
||||||
|
<Magnet>
|
||||||
|
{/* The hover suffix („( 30 Min · kostenfrei )“) must NOT reserve its
|
||||||
|
width in flow – it added ~190 px of empty outline and pushed the
|
||||||
|
text link onto its own line. It grows out of the masked cell on
|
||||||
|
hover/focus instead (spec §6.1: button + text link, one row). */}
|
||||||
|
<Button
|
||||||
|
href={hero.cta.href}
|
||||||
|
label={hero.cta.label}
|
||||||
|
suffix={hero.cta.suffix}
|
||||||
|
className="[&>span[aria-hidden]]:max-w-0 [&>span[aria-hidden]]:transition-[max-width] [&>span[aria-hidden]]:duration-[350ms] [&>span[aria-hidden]]:ease-[var(--ease-out-quart)] hover:[&>span[aria-hidden]]:max-w-[16rem] focus-visible:[&>span[aria-hidden]]:max-w-[16rem] motion-reduce:[&>span[aria-hidden]]:transition-none"
|
||||||
|
/>
|
||||||
|
</Magnet>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Folio: hairline draws outward from the handoff x, „01 / 06“ · „Positionierung ↓“.
|
||||||
|
Hidden below lg – the folio row costs 69 px that the phone cover does not have,
|
||||||
|
and Positionierung directly below carries its own „01 / 06“ mobile counter. */}
|
||||||
|
<div ref={folio} className="col-span-12 row-start-6 mt-6 hidden lg:block lg:row-start-5 lg:mt-0">
|
||||||
|
<Folio
|
||||||
|
current={hero.folio.current}
|
||||||
|
total={hero.folio.total}
|
||||||
|
next={{ label: hero.folio.next.label, href: `/${hero.folio.next.href}` }}
|
||||||
|
origin={origin ?? "left"}
|
||||||
|
play="load"
|
||||||
|
delay={T.folio}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Hero;
|
||||||
81
src/components/home/Positioning.tsx
Normal file
81
src/components/home/Positioning.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { positioning as positioningDe, type HomeContent } from "@/content/home";
|
||||||
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { Lines } from "@/components/motion/Lines";
|
||||||
|
import { Reveal, RevealGroup, RevealItem } from "@/components/motion/Reveal";
|
||||||
|
import { Rule } from "@/components/motion/Rule";
|
||||||
|
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||||
|
|
||||||
|
/** H2 on ivory: Playfair 400, 36 → 76 px, one italic word max. */
|
||||||
|
export const H2 =
|
||||||
|
"max-w-[16ch] font-display text-[clamp(2.25rem,1.4rem+3.2vw,4.75rem)] font-normal leading-[1.02] tracking-[-0.01em] text-fg [text-wrap:balance]";
|
||||||
|
|
||||||
|
const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 01 · Positionierung (ivory, spec §6.2). Numeral cols 1–2 (sticky), eyebrow +
|
||||||
|
* H2 + editorial paragraph + the three question rows cols 3–9
|
||||||
|
* cols 4–9. Below 1024 px everything stacks. Die Marginalien-Spalte wurde am
|
||||||
|
* 06.09.2026 auf Kundenwunsch gestrichen; die Außenspalte bleibt bewusst leer.
|
||||||
|
*/
|
||||||
|
export interface PositioningProps {
|
||||||
|
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
|
||||||
|
content?: HomeContent["positioning"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Positioning({ content: positioning = positioningDe }: PositioningProps = {}) {
|
||||||
|
const { numeral, eyebrow, h2, paragraph, rows } = positioning;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
id={positioning.id}
|
||||||
|
theme="light"
|
||||||
|
numeral={numeral}
|
||||||
|
label={eyebrow}
|
||||||
|
className="py-[clamp(72px,10vh,140px)] md:py-[clamp(112px,16vh,224px)]"
|
||||||
|
>
|
||||||
|
<div className="col-span-12 lg:col-span-7 lg:col-start-3 lg:row-start-1">
|
||||||
|
<Reveal>
|
||||||
|
<Eyebrow>{eyebrow}</Eyebrow>
|
||||||
|
</Reveal>
|
||||||
|
<Lines as="h2" className={cn("mt-8", H2)}>
|
||||||
|
{h2.before}
|
||||||
|
{h2.em && <em className="italic">{h2.em}</em>}
|
||||||
|
{h2.after}
|
||||||
|
</Lines>
|
||||||
|
|
||||||
|
{/* The three questions – hairline-separated rows. */}
|
||||||
|
<div className="mt-10 lg:mt-12">
|
||||||
|
<Rule />
|
||||||
|
<RevealGroup as="ol" stagger={0.1}>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<RevealItem as="li" key={row.numeral}>
|
||||||
|
<div className="grid grid-cols-[3rem_1fr] items-baseline gap-x-6 gap-y-2 py-5 sm:grid-cols-[3rem_1fr_auto] sm:gap-x-8">
|
||||||
|
<span className={cn(MONO, "text-accent")}>{row.numeral}</span>
|
||||||
|
<span className="font-display text-[2rem] font-normal italic leading-[1.2] text-fg">
|
||||||
|
{row.question}
|
||||||
|
</span>
|
||||||
|
<span className="col-start-2 font-sans text-[1.0625rem] leading-snug text-fg-muted sm:col-start-3 sm:text-right">
|
||||||
|
{row.answer}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Rule />
|
||||||
|
</RevealItem>
|
||||||
|
))}
|
||||||
|
</RevealGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editorial paragraph – Jost 300 (spec A/D10: display type is for
|
||||||
|
headlines, numerals, formula and quotes only). */}
|
||||||
|
<Lines
|
||||||
|
as="p"
|
||||||
|
className="mt-12 max-w-[60ch] font-sans text-[clamp(1.375rem,1.1rem+1vw,1.75rem)] font-light leading-[1.5] text-fg"
|
||||||
|
>
|
||||||
|
{paragraph}
|
||||||
|
</Lines>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Positioning;
|
||||||
75
src/components/home/ProcessContact.tsx
Normal file
75
src/components/home/ProcessContact.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { contact as contactDe, type HomeContent } from "@/content/home";
|
||||||
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { Lines } from "@/components/motion/Lines";
|
||||||
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
|
import { Rule } from "@/components/motion/Rule";
|
||||||
|
import { ContactLinks } from "@/components/ui/ContactLinks";
|
||||||
|
import { Eyebrow, EYEBROW_TEXT } from "@/components/ui/Eyebrow";
|
||||||
|
import { ContactForm } from "@/components/ui/ContactForm";
|
||||||
|
|
||||||
|
/* H2 on ink: Bodoni, `clamp(2.75rem, 1.6rem + 4.4vw, 6rem)`, ivory (spec §6.7). */
|
||||||
|
const H2 =
|
||||||
|
"font-display text-[clamp(2.75rem,1.6rem+4.4vw,6rem)] font-normal leading-[1.0] tracking-[-0.01em] text-fg [text-wrap:balance] max-w-[14ch]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 06 · Kontakt (ink). Ein Weg: links das Formular, daneben Mail, Telefon und
|
||||||
|
* LinkedIn als große Links, darunter der Abbinder. Der Footer (Wortmarke,
|
||||||
|
* letzte goldene Haarlinie) hängt global in layout.tsx.
|
||||||
|
*
|
||||||
|
* Der frühere Dreischritt (Kennenlerngespräch / Analyse / Umsetzung) und der
|
||||||
|
* separate CTA-Knopf wurden am 06.09.2026 auf Kundenwunsch durch das Formular
|
||||||
|
* ersetzt; der Haupt-CTA der Seite springt jetzt hierher.
|
||||||
|
*/
|
||||||
|
export interface ProcessContactProps {
|
||||||
|
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
|
||||||
|
content?: HomeContent["contact"];
|
||||||
|
/** Ziel des Datenschutz-Links im Formular. */
|
||||||
|
privacyHref?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProcessContact({ content: contact = contactDe, privacyHref }: ProcessContactProps = {}) {
|
||||||
|
return (
|
||||||
|
<div data-theme="dark" className="relative bg-transparent text-fg">
|
||||||
|
<Rule variant="gold" />
|
||||||
|
<Section
|
||||||
|
id={contact.id}
|
||||||
|
theme="dark"
|
||||||
|
numeral={contact.numeral}
|
||||||
|
label={contact.eyebrow}
|
||||||
|
className="pt-[clamp(5rem,11vh,9rem)] pb-[clamp(3.5rem,7vh,6rem)] md:pt-[clamp(8rem,18vh,15rem)] md:pb-[clamp(5rem,10vh,8rem)]"
|
||||||
|
>
|
||||||
|
{/* 06 resolves to `.gold-text` – the section ground is dark. */}
|
||||||
|
<div className="col-span-12 lg:col-span-9 lg:col-start-3 xl:col-span-7">
|
||||||
|
<Eyebrow className="mb-8">{contact.eyebrow}</Eyebrow>
|
||||||
|
<Lines as="h2" className={H2}>
|
||||||
|
{contact.h2.before}
|
||||||
|
{contact.h2.em && <em className="italic">{contact.h2.em}</em>}
|
||||||
|
{contact.h2.after}
|
||||||
|
</Lines>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formular: cols 3–12 bis 1280, cols 3–8 neben dem Kontaktblock ab xl.
|
||||||
|
Es verschickt nichts selbst, sondern übergibt eine fertige E-Mail an das
|
||||||
|
Mailprogramm – siehe den Kopf von ContactForm.tsx. */}
|
||||||
|
<Reveal className="col-span-12 mt-12 lg:col-span-10 lg:col-start-3 lg:mt-14 xl:col-span-6">
|
||||||
|
<ContactForm content={contact.form} privacyHref={privacyHref} />
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
{/* Contact block: under the Ablauf up to 1280 (the CTA button is 354 px
|
||||||
|
wide and overflowed the 4-column cell at 1024), cols 9–12 sticky from xl. */}
|
||||||
|
<div className="col-span-12 mt-12 lg:col-span-10 lg:col-start-3 lg:mt-14 xl:col-span-4 xl:col-start-9 xl:mt-14">
|
||||||
|
<div className="flex flex-col gap-12 xl:sticky xl:top-[30vh]">
|
||||||
|
<ContactLinks content={contact.links} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sign-off above the colophon (the Footer follows globally). */}
|
||||||
|
<Reveal as="p" className={`col-span-12 mt-20 lg:col-span-10 lg:col-start-3 lg:mt-32 ${EYEBROW_TEXT} text-gold`}>
|
||||||
|
{contact.signoff}
|
||||||
|
</Reveal>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProcessContact;
|
||||||
60
src/components/home/ServicesIndex.tsx
Normal file
60
src/components/home/ServicesIndex.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { services as servicesDe, type HomeContent } from "@/content/home";
|
||||||
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { Lines } from "@/components/motion/Lines";
|
||||||
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
|
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||||
|
import { Ledger } from "@/components/ui/Ledger";
|
||||||
|
import { H2 } from "./Positioning";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 02 · Leistungen (ivory, spec §6.3). No theme boundary – a `--line` hairline
|
||||||
|
* separates it from Positionierung. Numeral cols 1–2, eyebrow + H2 cols 3–8,
|
||||||
|
* the three services as a table-of-contents ledger cols 3–12 (each row is one
|
||||||
|
* <TransitionLink> → Umblättern).
|
||||||
|
*/
|
||||||
|
export interface ServicesIndexProps {
|
||||||
|
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
|
||||||
|
content?: HomeContent["services"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServicesIndex({ content: services = servicesDe }: ServicesIndexProps = {}) {
|
||||||
|
const { numeral, eyebrow, h2, rows } = services;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
id={services.id}
|
||||||
|
theme="light"
|
||||||
|
numeral={numeral}
|
||||||
|
label={eyebrow}
|
||||||
|
className="border-t border-line py-[clamp(64px,9vh,120px)] md:py-[clamp(96px,14vh,192px)]"
|
||||||
|
>
|
||||||
|
<div className="col-span-12 lg:col-span-6 lg:col-start-3 lg:row-start-1">
|
||||||
|
<Reveal>
|
||||||
|
<Eyebrow>{eyebrow}</Eyebrow>
|
||||||
|
</Reveal>
|
||||||
|
<Lines as="h2" className={cn("mt-8", H2)}>
|
||||||
|
{h2.before}
|
||||||
|
{h2.em && <em className="italic">{h2.em}</em>}
|
||||||
|
{h2.after}
|
||||||
|
</Lines>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-12 mt-12 lg:col-span-10 lg:col-start-3 lg:row-start-2 lg:mt-16">
|
||||||
|
{/* The nowrap „ZU DEN KEYNOTES →“ needs ~182 px, but Ledger's default link
|
||||||
|
cell is two of twelve sub-columns: 171 px at 1280 (the label overprinted
|
||||||
|
the one-liner) and 189 px even at 1440 (2–4 px of air). Re-balanced here
|
||||||
|
to one-liner 4 / link 3 at every width from lg – ~116 px of clear gutter
|
||||||
|
at 1440. The same default belongs in Ledger.tsx' toc row. */}
|
||||||
|
<Ledger
|
||||||
|
variant="toc"
|
||||||
|
rows={rows}
|
||||||
|
stagger={0.12}
|
||||||
|
className="lg:[&_a>span:nth-of-type(2)]:col-span-4 lg:[&_a>span:nth-of-type(3)]:col-span-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ServicesIndex;
|
||||||
52
src/components/home/WolfPrinciple.tsx
Normal file
52
src/components/home/WolfPrinciple.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Rule } from "@/components/motion/Rule";
|
||||||
|
import { Formula } from "@/components/ui/Formula";
|
||||||
|
import { principle as principleDe, type HomeContent } from "@/content/home";
|
||||||
|
|
||||||
|
export interface WolfPrincipleProps {
|
||||||
|
/** `<BrandMark idPrefix="prinzip"/>` (Server Component) – drawn scrubbed 70–95 % on desktop. */
|
||||||
|
mark: ReactNode;
|
||||||
|
/** Inhalt der Sektion. Ohne Angabe die deutsche Fassung – die englische reicht sie durch. */
|
||||||
|
content?: HomeContent["principle"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 03 · Das WOLF-Prinzip (ink, the page's only pin – spec §6.4).
|
||||||
|
*
|
||||||
|
* `<Formula/>` from the UI contract already IS the section: it renders
|
||||||
|
* `<section id data-theme="dark">` with Container, 12-column grid, the
|
||||||
|
* `.gold-text` numeral in cols 1–2, eyebrow, the three formula lines with
|
||||||
|
* definitions, the sign-off, the text link and the monogram. Wrapping it in a
|
||||||
|
* second `<Section/>` would nest two sections/containers (double page
|
||||||
|
* margin, duplicate id), so this component owns the ink boundary instead:
|
||||||
|
* the entering `--gold-hairline` (spec §2.3) plus the themed wrapper that
|
||||||
|
* <ThemeTween/> reads the palette from (Formula's own section is a nested
|
||||||
|
* island and is ignored by the tween – same pattern as <ServiceLayout/>).
|
||||||
|
*/
|
||||||
|
export function WolfPrinciple({ mark, content: principle = principleDe }: WolfPrincipleProps) {
|
||||||
|
return (
|
||||||
|
<div data-theme="dark" className="relative bg-transparent text-fg">
|
||||||
|
<Rule variant="gold" />
|
||||||
|
{/* Hanging operators: „Starke Persönlichkeit“ has no +/=, so it used to sit
|
||||||
|
22 px left of the two terms below it. Every line now gets a 0.62 em
|
||||||
|
operator cell (= 1.24 em of the 0.5 em operator) and the same padding,
|
||||||
|
so all three terms share one left edge and the +/= hang in the gutter.
|
||||||
|
From lg the line box is pulled back by that amount, so the terms stay
|
||||||
|
flush with the eyebrow and the sign-off in column 3. The same rule
|
||||||
|
belongs in Formula.tsx; here it comes in via the section className. */}
|
||||||
|
<Formula
|
||||||
|
className="[&_[data-f=line]]:pl-[0.62em] lg:[&_[data-f=line]]:-ml-[0.62em] [&_[data-f=op]]:-ml-[1.24em] [&_[data-f=op]]:mr-0 [&_[data-f=op]]:w-[1.24em]"
|
||||||
|
id={principle.id}
|
||||||
|
numeral={principle.numeral}
|
||||||
|
eyebrow={principle.eyebrow}
|
||||||
|
lines={principle.lines}
|
||||||
|
opLabels={principle.opLabels}
|
||||||
|
meta={`${principle.numeral} / 06`}
|
||||||
|
link={principle.link}
|
||||||
|
mark={mark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WolfPrinciple;
|
||||||
14
src/components/layout/Container.tsx
Normal file
14
src/components/layout/Container.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface ContainerProps {
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page frame: max-width 1680 px, page margin clamp(20px, 5vw, 80px). */
|
||||||
|
export function Container({ className, children }: ContainerProps) {
|
||||||
|
return <div className={cn("mx-auto w-full max-w-[105rem] px-[clamp(20px,5vw,80px)]", className)}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Container;
|
||||||
44
src/components/layout/Folio.tsx
Normal file
44
src/components/layout/Folio.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Rule, type RuleProps } from "@/components/motion/Rule";
|
||||||
|
import { TransitionLink } from "@/components/motion/TransitionLink";
|
||||||
|
|
||||||
|
export interface FolioProps {
|
||||||
|
/**
|
||||||
|
* „01“ – only where the folio IS the page's counter (the home hero counts the
|
||||||
|
* six sections). Service pages omit it: their sections carry their own
|
||||||
|
* „01 / 06“ and a second, differently-denominated fraction 150 px away reads
|
||||||
|
* as a bug, not as a folio.
|
||||||
|
*/
|
||||||
|
current?: string;
|
||||||
|
/** „06“ */
|
||||||
|
total?: string;
|
||||||
|
/** „Positionierung“ → `#positionierung` */
|
||||||
|
next?: { label: string; href: string };
|
||||||
|
className?: string;
|
||||||
|
/** Passed to the hairline – the hero draws it outward from the handoff x. */
|
||||||
|
origin?: RuleProps["origin"];
|
||||||
|
play?: RuleProps["play"];
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gold hairline + 44 px row: „01 / 06“ left, „Positionierung ↓“ right. */
|
||||||
|
export function Folio({ current, total, next, className, origin = "left", play = "view", delay }: FolioProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("w-full", className)}>
|
||||||
|
<Rule variant="gold" origin={origin} play={play} delay={delay} />
|
||||||
|
<div className="flex min-h-11 flex-wrap items-center justify-between gap-x-6 gap-y-1 py-1 font-mono text-xs tracking-[0.06em] tabular-nums text-fg-muted">
|
||||||
|
<span>{current && total ? `${current} / ${total}` : null}</span>
|
||||||
|
{next && (
|
||||||
|
<TransitionLink
|
||||||
|
href={next.href}
|
||||||
|
className="-my-3.5 inline-block py-3.5 text-fg transition-colors duration-300 hover:text-accent focus-visible:text-accent"
|
||||||
|
>
|
||||||
|
{next.label} <span aria-hidden="true">↓</span>
|
||||||
|
</TransitionLink>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Folio;
|
||||||
111
src/components/layout/Footer.tsx
Normal file
111
src/components/layout/Footer.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { legal as legalDe, site } from "@/lib/site";
|
||||||
|
import { routesByLang, type Lang } from "@/lib/i18n";
|
||||||
|
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
|
||||||
|
import { BrandMark } from "@/components/brand/BrandMark";
|
||||||
|
import { Wordmark } from "@/components/brand/Wordmark";
|
||||||
|
import { Rule } from "@/components/motion/Rule";
|
||||||
|
import { TransitionLink } from "@/components/motion/TransitionLink";
|
||||||
|
import { Container } from "./Container";
|
||||||
|
import { GRID } from "./Section";
|
||||||
|
|
||||||
|
const heading = "mb-5 font-mono text-[11px] uppercase tracking-[0.12em] text-grey-warm";
|
||||||
|
/* -my-2.5/py-2.5 keeps the visual rhythm and lifts the tap target to 40 px. */
|
||||||
|
const link =
|
||||||
|
"-my-2.5 inline-block py-2.5 font-sans text-sm text-fg transition-colors duration-300 hover:text-gold focus-visible:text-gold";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server Component, identical on every page. Paints its own ink ground
|
||||||
|
* (`data-theme="dark"`, globals.css), gold hairline on top, wordmark +
|
||||||
|
* two columns, bottom row with copyright · legal · claim (desktop).
|
||||||
|
*/
|
||||||
|
export interface FooterProps {
|
||||||
|
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
|
||||||
|
chrome?: Chrome;
|
||||||
|
/** Impressum/Datenschutz der jeweiligen Sprache. */
|
||||||
|
legal?: readonly { href: string; label: string }[];
|
||||||
|
/** Aktuelle Sprache – bestimmt das Ziel der Wortmarke. */
|
||||||
|
lang?: Lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Footer({ chrome = chromeDe, legal = legalDe, lang = "de" }: FooterProps = {}) {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
return (
|
||||||
|
/* The legal links are the last thing on a phone – keep them clear of the home indicator
|
||||||
|
and the browser's bottom bar (the row's own padding is eaten by the link's -my-3). */
|
||||||
|
<footer data-theme="dark" className="relative pb-[max(2.5rem,env(safe-area-inset-bottom))] text-fg lg:pb-0">
|
||||||
|
<Rule variant="gold" />
|
||||||
|
<Container>
|
||||||
|
<div className={`${GRID} gap-y-12 py-16 lg:py-24`}>
|
||||||
|
<div className="col-span-12 lg:col-span-7 lg:pr-8">
|
||||||
|
<Wordmark size="footer" href={routesByLang[lang].home} mark={<BrandMark className="h-10 w-auto" idPrefix="ftr" />} chrome={chrome} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav aria-label={chrome.footer.inhaltHeading} className="col-span-6 lg:col-span-3">
|
||||||
|
<h2 className={heading}>{chrome.footer.inhaltHeading}</h2>
|
||||||
|
<ul className="flex flex-col gap-5">
|
||||||
|
{chrome.footer.inhalt.map((item) => (
|
||||||
|
<li key={item.href}>
|
||||||
|
<TransitionLink href={item.href} label={item.label} className={link}>
|
||||||
|
{item.label}
|
||||||
|
</TransitionLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="col-span-6 lg:col-span-2">
|
||||||
|
<h2 className={heading}>{chrome.footer.kontaktHeading}</h2>
|
||||||
|
<ul className="flex flex-col gap-5">
|
||||||
|
<li>
|
||||||
|
<a href={`mailto:${site.email}`} className={link}>
|
||||||
|
{site.email}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href={site.phoneHref} className={`${link} font-mono tabular-nums`}>
|
||||||
|
{site.phoneDisplay}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href={site.linkedin} target="_blank" rel="noopener noreferrer" className={link}>
|
||||||
|
{chrome.footer.linkedin} <span aria-hidden="true">↗</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href={chrome.footer.hercareer.href} target="_blank" rel="noopener noreferrer" className={link}>
|
||||||
|
{chrome.footer.hercareer.label} <span aria-hidden="true">↗</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-12 flex-wrap items-center justify-between gap-x-8 gap-y-3 border-t border-line-dark py-3 font-mono text-[11px] tracking-[0.04em] text-grey-warm">
|
||||||
|
<span>
|
||||||
|
© {year} {site.name} · {chrome.footer.copyrightSuffix}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{legal.map((l, i) => (
|
||||||
|
<span key={l.href}>
|
||||||
|
{i > 0 && <span aria-hidden="true"> · </span>}
|
||||||
|
<TransitionLink
|
||||||
|
href={l.href}
|
||||||
|
label={l.label}
|
||||||
|
className="-my-3 inline-block py-3 transition-colors duration-300 hover:text-gold"
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</TransitionLink>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
{/* The Kontakt sign-off already closes the page on phones – no second claim there. */}
|
||||||
|
<span className="hidden font-sans text-[11px] font-medium uppercase tracking-[0.28em] text-gold lg:inline">
|
||||||
|
{chrome.footer.claim}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
169
src/components/layout/Header.tsx
Normal file
169
src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"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. */}
|
||||||
|
{flags.showLangSwitch && <LangSwitch lang={lang} className="hidden lg:flex" />}
|
||||||
|
<a
|
||||||
|
data-menu-inert=""
|
||||||
|
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} lang={lang} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header;
|
||||||
70
src/components/layout/LangSwitch.tsx
Normal file
70
src/components/layout/LangSwitch.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { counterpart, langSwitch, otherLang, type Lang } from "@/lib/i18n";
|
||||||
|
import { TransitionLink } from "@/components/motion/TransitionLink";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sprachumschalter im Kopf. Steht an der Stelle, an der vorher „Kontakt“ stand.
|
||||||
|
*
|
||||||
|
* Zeigt beide Sprachen: die aktive als stiller Zustand, die andere als Link auf das
|
||||||
|
* Gegenstück derselben Seite (`counterpart()`), nicht pauschal auf die Startseite.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* ausgeschrieben. Wer die Flaggen weglassen will, entfernt hier zwei Zeilen.
|
||||||
|
*/
|
||||||
|
export function LangSwitch({ lang, className }: { lang: Lang; className?: string }) {
|
||||||
|
const pathname = usePathname() ?? "/";
|
||||||
|
const other = otherLang(lang);
|
||||||
|
const href = counterpart(pathname, other);
|
||||||
|
|
||||||
|
const base =
|
||||||
|
"inline-flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.14em] transition-colors duration-300";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center gap-2", className)}>
|
||||||
|
<span className={cn(base, "text-accent")} aria-current="true" title={langSwitch[lang].title}>
|
||||||
|
<Flag lang={lang} />
|
||||||
|
{langSwitch[lang].short}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden="true" className="h-3 w-px bg-line" />
|
||||||
|
<TransitionLink
|
||||||
|
href={href}
|
||||||
|
label={langSwitch[other].label}
|
||||||
|
hrefLang={other}
|
||||||
|
aria-label={langSwitch[other].title}
|
||||||
|
className={cn(base, "-my-3 py-3 text-fg-muted hover:text-accent focus-visible:text-accent")}
|
||||||
|
>
|
||||||
|
<Flag lang={other} />
|
||||||
|
{langSwitch[other].short}
|
||||||
|
</TransitionLink>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 16 × 11 px, randlos, damit die Fahne neben 11-px-Versalien nicht dominiert. */
|
||||||
|
function Flag({ lang }: { lang: Lang }) {
|
||||||
|
const common = { width: 16, height: 11, viewBox: "0 0 16 11", "aria-hidden": true as const, className: "shrink-0" };
|
||||||
|
if (lang === "de") {
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<rect width="16" height="11" fill="#0C0C0C" />
|
||||||
|
<rect y="3.667" width="16" height="3.667" fill="#DD0000" />
|
||||||
|
<rect y="7.334" width="16" height="3.666" fill="#FFCE00" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<rect width="16" height="11" fill="#012169" />
|
||||||
|
<path d="M0 0 16 11M16 0 0 11" stroke="#FFF" strokeWidth="2.2" />
|
||||||
|
<path d="M0 0 16 11M16 0 0 11" stroke="#C8102E" strokeWidth="1.1" />
|
||||||
|
<path d="M8 0v11M0 5.5h16" stroke="#FFF" strokeWidth="3.7" />
|
||||||
|
<path d="M8 0v11M0 5.5h16" stroke="#C8102E" strokeWidth="2.2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LangSwitch;
|
||||||
56
src/components/layout/Masthead.tsx
Normal file
56
src/components/layout/Masthead.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
|
||||||
|
import { Rule } from "@/components/motion/Rule";
|
||||||
|
|
||||||
|
export interface MastheadProps {
|
||||||
|
/** Centre slot, e.g. „Sales Mentoring & Potential Development“. */
|
||||||
|
center: string;
|
||||||
|
className?: string;
|
||||||
|
/** Draw the hairlines on mount (hero load choreography) instead of static. */
|
||||||
|
draw?: boolean;
|
||||||
|
delay?: number;
|
||||||
|
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
|
||||||
|
chrome?: Chrome;
|
||||||
|
}
|
||||||
|
|
||||||
|
const caps = "font-sans text-[0.72rem] font-medium uppercase leading-none tracking-[0.28em] max-sm:text-[0.66rem] max-sm:tracking-[0.2em]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Engraved stationery: three slots in ONE register (Jost 500 caps) between two
|
||||||
|
* gold hairlines – role (accent, always the dominant line) | page name /
|
||||||
|
* centre (muted) | „Deutsch & Englisch“ (muted). The language slot used to be
|
||||||
|
* 12 px DM Mono and therefore optically louder than the 10.5 px tracked role.
|
||||||
|
*
|
||||||
|
* ≥ 1280 all three sit in one row; the centre shrinks to 0.62 rem so the
|
||||||
|
* 37-character home line („Sales Mentoring & Potential Development“) never
|
||||||
|
* wraps inside the 44 px row. 640–1279 the centre drops to its own second
|
||||||
|
* row; below 640 the language slot goes (it is the least load-bearing of the
|
||||||
|
* three) so the page name keeps the orientation the masthead is there for.
|
||||||
|
*/
|
||||||
|
export function Masthead({ center, className, draw = false, delay = 0, chrome = chromeDe }: MastheadProps) {
|
||||||
|
const rule = (extra: number) =>
|
||||||
|
draw ? <Rule variant="gold" play="load" delay={delay + extra} /> : <div aria-hidden="true" className="rule-gold" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("w-full", className)}>
|
||||||
|
{rule(0)}
|
||||||
|
<div className="grid min-h-11 grid-cols-1 items-center gap-x-4 gap-y-2 py-3 sm:grid-cols-[1fr_auto] xl:grid-cols-3 xl:gap-y-0 xl:py-0">
|
||||||
|
<span className={cn(caps, "text-accent xl:order-1")}>{chrome.masthead.role}</span>
|
||||||
|
<span className={cn(caps, "hidden text-fg-muted sm:block sm:justify-self-end xl:order-3")}>
|
||||||
|
{chrome.masthead.languages}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
caps,
|
||||||
|
"leading-[1.5] text-fg-muted sm:col-span-2 xl:order-2 xl:col-span-1 xl:whitespace-nowrap xl:text-center xl:leading-none xl:text-[0.62rem] xl:tracking-[0.18em] wide:text-[0.72rem] wide:tracking-[0.28em]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{center}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{rule(0.15)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Masthead;
|
||||||
235
src/components/layout/OverlayMenu.tsx
Normal file
235
src/components/layout/OverlayMenu.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { flags, legal as legalDe, site } from "@/lib/site";
|
||||||
|
import type { Lang } from "@/lib/i18n";
|
||||||
|
import { LangSwitch } from "./LangSwitch";
|
||||||
|
import { chrome as chromeDe, type Chrome } from "@/content/chrome";
|
||||||
|
import { gsap, EASE, MM, getLenis, refreshSoon } from "@/lib/gsap";
|
||||||
|
import { TransitionLink } from "@/components/motion/TransitionLink";
|
||||||
|
import { Container } from "./Container";
|
||||||
|
|
||||||
|
export interface OverlayMenuProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
|
||||||
|
chrome?: Chrome;
|
||||||
|
/** Impressum/Datenschutz der jeweiligen Sprache. */
|
||||||
|
legal?: readonly { href: string; label: string }[];
|
||||||
|
/** Aktuelle Sprache – für den Umschalter im Fuß des Menüs. */
|
||||||
|
lang?: Lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The header's „Menü“ button – part of the dialog's operable set (see the trap below). */
|
||||||
|
export const MENU_TOGGLE_ID = "menue-toggle";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fullscreen ivory sheet from the top (0.5 s power3.inOut): numbered ledger
|
||||||
|
* 01–05, contact row at the bottom. `data-lenis-prevent`, `lenis.stop()`,
|
||||||
|
* focus trap, Escape closes, closes on route change, rest of the page `inert`.
|
||||||
|
*
|
||||||
|
* The trap spans the panel AND the header's toggle: the toggle stays visible on
|
||||||
|
* top of the sheet (header z-50 > panel z-40), so it has to stay reachable by Tab
|
||||||
|
* as well as by mouse. Every other header control is `inert` while the sheet is
|
||||||
|
* open, so the mouse-operable and the keyboard-operable set are the same one.
|
||||||
|
*/
|
||||||
|
export function OverlayMenu({ open, onClose, chrome = chromeDe, legal = legalDe, lang = "de" }: OverlayMenuProps) {
|
||||||
|
const panel = useRef<HTMLDivElement>(null);
|
||||||
|
const pathname = usePathname();
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
/* The panel has to outlive `open` for the length of its exit tween. State adjusted
|
||||||
|
during render (the sanctioned pattern), not in an effect – an effect would cost a
|
||||||
|
cascading render on every open. */
|
||||||
|
const [exiting, setExiting] = useState(false);
|
||||||
|
const [prevOpen, setPrevOpen] = useState(open);
|
||||||
|
if (prevOpen !== open) {
|
||||||
|
setPrevOpen(open);
|
||||||
|
setExiting(!open);
|
||||||
|
}
|
||||||
|
const mounted = open || exiting;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on route change.
|
||||||
|
const lastPath = useRef(pathname);
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastPath.current !== pathname) {
|
||||||
|
lastPath.current = pathname;
|
||||||
|
onCloseRef.current();
|
||||||
|
}
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
/* Enter / exit on GSAP (the site's only animation runtime). The panel is a single
|
||||||
|
element, so an <AnimatePresence> is not load-bearing – `exiting` keeps it in the
|
||||||
|
DOM until the exit tween has run. */
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = panel.current;
|
||||||
|
if (!mounted || !el) return;
|
||||||
|
const rows = gsap.utils.toArray<HTMLElement>("[data-menu-row]", el);
|
||||||
|
const foot = el.querySelector<HTMLElement>("[data-menu-foot]");
|
||||||
|
const mm = gsap.matchMedia();
|
||||||
|
|
||||||
|
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
|
||||||
|
const reduce = Boolean(ctx.conditions?.reduce);
|
||||||
|
if (open) {
|
||||||
|
gsap.set(el, { yPercent: reduce ? 0 : -100 });
|
||||||
|
gsap.set(rows, { yPercent: reduce ? 0 : 100 });
|
||||||
|
gsap.set(foot, { opacity: reduce ? 1 : 0 });
|
||||||
|
if (reduce) return;
|
||||||
|
const tl = gsap.timeline();
|
||||||
|
tl.to(el, { yPercent: 0, duration: 0.5, ease: EASE.inOut }, 0)
|
||||||
|
.to(rows, { yPercent: 0, duration: 0.6, ease: EASE.out, stagger: 0.06 }, 0.25)
|
||||||
|
.to(foot, { opacity: 1, duration: 0.6 }, 0.6);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* Reduced motion still goes through the tween, at zero duration: `onComplete`
|
||||||
|
must fire off the current render, never synchronously inside this effect. */
|
||||||
|
gsap.to(el, {
|
||||||
|
yPercent: -100,
|
||||||
|
duration: reduce ? 0 : 0.4,
|
||||||
|
ease: EASE.inOut,
|
||||||
|
onComplete: () => setExiting(false),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => mm.revert();
|
||||||
|
}, [open, mounted]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const lenis = getLenis();
|
||||||
|
lenis?.stop();
|
||||||
|
const previous = document.activeElement as HTMLElement | null;
|
||||||
|
const toggle = document.getElementById(MENU_TOGGLE_ID) as HTMLElement | null;
|
||||||
|
/* Everything the reader can still SEE but must no longer reach: the rest of the
|
||||||
|
page plus the header's own links, which float above the ivory sheet. */
|
||||||
|
const others = Array.from(
|
||||||
|
document.querySelectorAll<HTMLElement>("main, footer, header [data-menu-inert]"),
|
||||||
|
);
|
||||||
|
others.forEach((el) => {
|
||||||
|
el.inert = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const inPanel = () =>
|
||||||
|
Array.from(panel.current?.querySelectorAll<HTMLElement>("a[href], button:not([disabled])") ?? []);
|
||||||
|
/* The toggle first: it sits above the sheet, so Shift+Tab from the first ledger row
|
||||||
|
lands on it and Tab from the last row cycles back to it. */
|
||||||
|
const focusables = () => (toggle ? [toggle, ...inPanel()] : inPanel());
|
||||||
|
const t = window.setTimeout(() => inPanel()[0]?.focus(), 50);
|
||||||
|
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
onCloseRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== "Tab") return;
|
||||||
|
const items = focusables();
|
||||||
|
if (!items.length) return;
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
const active = document.activeElement as HTMLElement | null;
|
||||||
|
const inside = active ? items.includes(active) : false;
|
||||||
|
if (e.shiftKey && (active === first || !inside)) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!e.shiftKey && (active === last || !inside)) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(t);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
others.forEach((el) => {
|
||||||
|
el.inert = false;
|
||||||
|
});
|
||||||
|
lenis?.start();
|
||||||
|
previous?.focus?.();
|
||||||
|
refreshSoon();
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={panel}
|
||||||
|
id="menue"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={chrome.header.menu.ariaLabel}
|
||||||
|
data-theme="light"
|
||||||
|
data-lenis-prevent=""
|
||||||
|
className="fixed inset-0 z-40 flex flex-col overflow-y-auto overscroll-contain bg-ivory text-ink"
|
||||||
|
>
|
||||||
|
<Container className="flex min-h-full flex-1 flex-col pb-10 pt-[calc(var(--header-h,60px)+24px)]">
|
||||||
|
<ol className="border-t border-line-light">
|
||||||
|
{chrome.menu.items.map((item) => (
|
||||||
|
<li key={item.href} className="overflow-hidden border-b border-line-light">
|
||||||
|
<div data-menu-row="">
|
||||||
|
<TransitionLink
|
||||||
|
href={item.href}
|
||||||
|
label={item.label}
|
||||||
|
onClick={() => onCloseRef.current()}
|
||||||
|
className="group flex items-baseline gap-5 py-5 lg:gap-8 lg:py-7"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-xs tracking-[0.06em] tabular-nums text-gold-deep">{item.numeral}</span>
|
||||||
|
<span className="font-display text-[clamp(2rem,1.4rem+3vw,4rem)] leading-none text-ink transition-[font-style] group-hover:italic">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="ml-auto text-gold-deep transition-transform duration-300 group-hover:translate-x-2"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</TransitionLink>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-menu-foot=""
|
||||||
|
className="mt-auto flex flex-col gap-4 pt-12 lg:flex-row lg:items-end lg:justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<a href={site.phoneHref} className="font-display text-[1.75rem] leading-none text-ink">
|
||||||
|
{site.phoneDisplay}
|
||||||
|
</a>
|
||||||
|
<a href={`mailto:${site.email}`} className="font-sans text-sm text-grey-deep">
|
||||||
|
{site.email}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 font-mono text-xs tracking-[0.06em] text-grey-deep">
|
||||||
|
{/* Unter 1024 px versteckt der Kopf den Umschalter (dort ist kein Platz neben
|
||||||
|
der Wortmarke). Ohne diese Zeile gäbe es auf dem Telefon gar keinen Weg in
|
||||||
|
die andere Sprache – er steht deshalb genau dort, wo er im Kopf fehlt. */}
|
||||||
|
{flags.showLangSwitch && <LangSwitch lang={lang} className="lg:hidden" />}
|
||||||
|
<a href={site.linkedin} target="_blank" rel="noopener noreferrer" className="hover:text-gold-deep">
|
||||||
|
{chrome.menu.linkedin} <span aria-hidden="true">↗</span>
|
||||||
|
</a>
|
||||||
|
<span>
|
||||||
|
{legal.map((l, i) => (
|
||||||
|
<span key={l.href}>
|
||||||
|
{i > 0 && <span aria-hidden="true"> · </span>}
|
||||||
|
<TransitionLink href={l.href} label={l.label} onClick={() => onCloseRef.current()} className="hover:text-gold-deep">
|
||||||
|
{l.label}
|
||||||
|
</TransitionLink>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OverlayMenu;
|
||||||
80
src/components/layout/Section.tsx
Normal file
80
src/components/layout/Section.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Container } from "./Container";
|
||||||
|
import { SectionGutter } from "./SectionGutter";
|
||||||
|
|
||||||
|
export interface SectionProps {
|
||||||
|
id: string;
|
||||||
|
theme: "dark" | "light";
|
||||||
|
/** „01“ */
|
||||||
|
numeral?: string;
|
||||||
|
/** „06“ */
|
||||||
|
total?: string;
|
||||||
|
/** „Positionierung“ – the running head under the numeral. */
|
||||||
|
label?: string;
|
||||||
|
/** The `< lg` mono counter „0X / 06“ above the content (off where a folio already carries it). */
|
||||||
|
mobileMeta?: boolean;
|
||||||
|
/**
|
||||||
|
* Vertical rhythm. `chapter` (default) opens a new chapter with its own
|
||||||
|
* eyebrow + Bodoni H2; `block` is a sub-block of the chapter above
|
||||||
|
* (Rahmen → Ablauf → FAQ) and halves the gap that precedes it, so the
|
||||||
|
* page reads as a spread instead of a component list with one fixed margin.
|
||||||
|
*/
|
||||||
|
space?: "chapter" | "block";
|
||||||
|
className?: string;
|
||||||
|
as?: "section" | "div" | "article";
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gutter of the 12-column grid – reuse in children that nest their own grid. */
|
||||||
|
export const GRID = "grid grid-cols-12 gap-x-[clamp(16px,2vw,32px)]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<section data-theme id>` with Container + 12-column grid. Children place
|
||||||
|
* themselves with `col-span-*` / `col-start-*` (content cols 3–9, marginalia
|
||||||
|
* 10–12 on ≥ 1024). With a `numeral` the section opens with <SectionGutter/>:
|
||||||
|
* the sticky Bodoni numeral + running head in cols 1–2 from 1024, the mono
|
||||||
|
* counter „0X / 06“ above the content below it – identical on every route.
|
||||||
|
*
|
||||||
|
* Top-level sections paint their own opaque ground (globals.css:
|
||||||
|
* `main > [data-theme]`); the seam between two grounds is the 1 px gold
|
||||||
|
* hairline alone (spec §2.3). Nested sections stay transparent and inherit
|
||||||
|
* the ground of their themed wrapper.
|
||||||
|
*/
|
||||||
|
export function Section({
|
||||||
|
id,
|
||||||
|
theme,
|
||||||
|
numeral,
|
||||||
|
total,
|
||||||
|
label,
|
||||||
|
mobileMeta = true,
|
||||||
|
space = "chapter",
|
||||||
|
className,
|
||||||
|
as = "section",
|
||||||
|
children,
|
||||||
|
}: SectionProps) {
|
||||||
|
const Tag = as;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
id={id}
|
||||||
|
data-theme={theme}
|
||||||
|
className={cn(
|
||||||
|
"relative bg-transparent pb-[clamp(4rem,10vw,9rem)] text-fg",
|
||||||
|
space === "block" ? "pt-[clamp(2rem,5vw,4.5rem)]" : "pt-[clamp(4rem,10vw,9rem)]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Container className="relative">
|
||||||
|
<div className={GRID}>
|
||||||
|
{numeral && (
|
||||||
|
<SectionGutter numeral={numeral} total={total} label={label ?? ""} mobileMeta={mobileMeta} />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Section;
|
||||||
54
src/components/layout/SectionGutter.tsx
Normal file
54
src/components/layout/SectionGutter.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Numeral } from "@/components/ui/Numeral";
|
||||||
|
|
||||||
|
export interface SectionGutterProps {
|
||||||
|
/** „01“ … „06“ */
|
||||||
|
numeral: string;
|
||||||
|
/** „06“ */
|
||||||
|
total?: string;
|
||||||
|
/** Running head under the numeral, e.g. „Positionierung“. */
|
||||||
|
label: string;
|
||||||
|
/**
|
||||||
|
* The `< lg` mono counter above the content. Section 01 suppresses it – the
|
||||||
|
* hero folio directly above already carries „01 / 06 · Positionierung ↓“.
|
||||||
|
*/
|
||||||
|
mobileMeta?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums text-fg-muted";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gutter of a numbered home section (cols 1–2 from 1024): the sticky Bodoni
|
||||||
|
* <Numeral/> and the small mono running head in ONE sticky wrapper, so the pair
|
||||||
|
* always travels together (they used to have two different sticky anchors and
|
||||||
|
* drifted ~350 px apart). Anchor is the spec's `calc(50vh − .45em)`.
|
||||||
|
*
|
||||||
|
* The counter („04 / 06“) stays on the mobile line and in the hero folio – next
|
||||||
|
* to a 147 px Bodoni „04“ it would only repeat the numeral, so the desktop line
|
||||||
|
* carries the label alone.
|
||||||
|
*
|
||||||
|
* Rendered by <Section/> itself whenever a `numeral` is passed – there is exactly
|
||||||
|
* ONE chapter gutter on the site now. It used to coexist with an inline copy inside
|
||||||
|
* <Section/> (a 48 px Bodoni numeral on mobile, a mono running head sticky under the
|
||||||
|
* header on desktop), so the home page and the three service pages showed the same
|
||||||
|
* furniture in two different treatments with two different sticky anchors.
|
||||||
|
*/
|
||||||
|
export function SectionGutter({ numeral, total = "06", label, mobileMeta = true, className }: SectionGutterProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{mobileMeta && <p className={cn("col-span-12 mb-8 lg:hidden", MONO)}>{`${numeral} / ${total}`}</p>}
|
||||||
|
<div className={cn("hidden lg:col-span-2 lg:col-start-1 lg:row-span-2 lg:row-start-1 lg:block", className)}>
|
||||||
|
<div className="lg:sticky lg:top-[calc(50vh_-_0.45em)]">
|
||||||
|
{/* Decorative – the accessible section number is the mono line above. */}
|
||||||
|
<Numeral value={numeral} sticky={false} />
|
||||||
|
<span aria-hidden="true" className={cn("mt-5 block whitespace-nowrap", MONO)}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SectionGutter;
|
||||||
69
src/components/layout/Shell.tsx
Normal file
69
src/components/layout/Shell.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { SmoothScroll } from "@/components/providers/SmoothScroll";
|
||||||
|
import { BrandMark } from "@/components/brand/BrandMark";
|
||||||
|
import { Header } from "@/components/layout/Header";
|
||||||
|
import { Footer } from "@/components/layout/Footer";
|
||||||
|
import { Curtain } from "@/components/motion/Curtain";
|
||||||
|
import { ScrollProgress } from "@/components/motion/ScrollProgress";
|
||||||
|
import { chromeByLang } from "@/content/chrome-by-lang";
|
||||||
|
import { fontVariables } from "@/lib/fonts";
|
||||||
|
import { HTML_LANG, type Lang } from "@/lib/i18n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das ganze Dokument – von `<html>` bis `</body>` – für eine Sprache.
|
||||||
|
*
|
||||||
|
* Beide Wurzel-Layouts (`app/(de)/layout.tsx`, `app/en/layout.tsx`) bestehen nur noch
|
||||||
|
* aus einem Aufruf dieser Komponente. Zwei Wurzel-Layouts sind nötig, weil `<html lang>`
|
||||||
|
* die Sprache des Dokuments angibt (WCAG 3.1.1) und im App Router nur das Wurzel-Layout
|
||||||
|
* das `<html>`-Element schreibt. Ein Wechsel zwischen den Sprachen ist deshalb ein
|
||||||
|
* vollständiger Seitenaufbau statt einer Client-Navigation – für einen Sprachwechsel
|
||||||
|
* genau richtig, denn Schrift, Metadaten und Hülle wechseln ohnehin komplett.
|
||||||
|
*/
|
||||||
|
export function Shell({ lang, children }: { lang: Lang; children: ReactNode }) {
|
||||||
|
const { chrome, nav, legal } = chromeByLang[lang];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={HTML_LANG[lang]} className={`${fontVariables} h-full`} suppressHydrationWarning>
|
||||||
|
<body className="grain min-h-full flex flex-col">
|
||||||
|
{/* Marks the document as JS-capable before first paint: initial motion states live
|
||||||
|
under `html.js` only. The failsafe matters as much as the class: everything the
|
||||||
|
class hides is only ever un-hidden by the module bundle, and a static export
|
||||||
|
re-hashes its chunks on every deploy – a browser on cached HTML that 404s the
|
||||||
|
bundle would sit on a blank page forever. If nothing has booted after 2.5 s the
|
||||||
|
class drops and the page renders as plain HTML. <SmoothScroll/> clears the timer. */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html:
|
||||||
|
"document.documentElement.classList.add('js');window.__unhide=setTimeout(function(){document.documentElement.classList.remove('js')},2500)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href="#inhalt"
|
||||||
|
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:bg-ink focus:text-gold focus:px-4 focus:py-2 focus:font-mono focus:text-sm"
|
||||||
|
>
|
||||||
|
{chrome.skipLink}
|
||||||
|
</a>
|
||||||
|
{/* No <MotionConfig reducedMotion="user"> any more: every animation on the site is
|
||||||
|
built on gsap.matchMedia and gates itself on `MM.reduce`, so the provider bought
|
||||||
|
nothing – and it was one of four reasons a second animation runtime shipped. */}
|
||||||
|
<SmoothScroll>
|
||||||
|
<Header
|
||||||
|
mark={<BrandMark className="h-7 w-auto" idPrefix="hdr" />}
|
||||||
|
chrome={chrome}
|
||||||
|
nav={nav}
|
||||||
|
legal={legal}
|
||||||
|
lang={lang}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
<Footer chrome={chrome} legal={legal} lang={lang} />
|
||||||
|
<Curtain chrome={chrome}>
|
||||||
|
<BrandMark className="h-12 w-auto" idPrefix="curtain" />
|
||||||
|
</Curtain>
|
||||||
|
<ScrollProgress />
|
||||||
|
</SmoothScroll>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Shell;
|
||||||
209
src/components/legal/LegalPage.tsx
Normal file
209
src/components/legal/LegalPage.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { Masthead } from "@/components/layout/Masthead";
|
||||||
|
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||||
|
import type { LegalPage as LegalPageContent, LegalParagraph, LegalSection } from "@/content/legal";
|
||||||
|
import { hasPlaceholder, splitPlaceholders } from "@/lib/legal";
|
||||||
|
import { flags, site } from "@/lib/site";
|
||||||
|
import { chromeByLang } from "@/content/chrome-by-lang";
|
||||||
|
import { DEFAULT_LANG, type Lang } from "@/lib/i18n";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Text rendering: „[…]“-Platzhalter in --gold-deep, URLs / E-Mail / Telefon als Links. */
|
||||||
|
|
||||||
|
type Token =
|
||||||
|
| { kind: "text"; text: string }
|
||||||
|
| { kind: "placeholder"; text: string }
|
||||||
|
| { kind: "link"; text: string; href: string; external: boolean };
|
||||||
|
|
||||||
|
function escapeRegExp(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINK_RE = new RegExp(
|
||||||
|
`(https?:\\/\\/[^\\s„“"<>]+|[\\w.+-]+@[\\w-]+(?:\\.[\\w-]+)+|${escapeRegExp(site.phoneDisplay)})`,
|
||||||
|
"g",
|
||||||
|
);
|
||||||
|
|
||||||
|
function linkify(text: string): Token[] {
|
||||||
|
const out: Token[] = [];
|
||||||
|
let last = 0;
|
||||||
|
for (const m of text.matchAll(LINK_RE)) {
|
||||||
|
const index = m.index ?? 0;
|
||||||
|
let hit = m[0];
|
||||||
|
let trail = "";
|
||||||
|
if (hit.startsWith("http")) {
|
||||||
|
const t = /[.,;:)]+$/.exec(hit);
|
||||||
|
if (t) {
|
||||||
|
trail = t[0];
|
||||||
|
hit = hit.slice(0, -trail.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (index > last) out.push({ kind: "text", text: text.slice(last, index) });
|
||||||
|
if (hit === site.phoneDisplay) {
|
||||||
|
out.push({ kind: "link", text: hit, href: site.phoneHref, external: false });
|
||||||
|
} else if (hit.startsWith("http")) {
|
||||||
|
out.push({ kind: "link", text: hit, href: hit, external: true });
|
||||||
|
} else {
|
||||||
|
out.push({ kind: "link", text: hit, href: `mailto:${hit}`, external: false });
|
||||||
|
}
|
||||||
|
if (trail) out.push({ kind: "text", text: trail });
|
||||||
|
last = index + m[0].length;
|
||||||
|
}
|
||||||
|
if (last < text.length) out.push({ kind: "text", text: text.slice(last) });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenize(text: string): Token[] {
|
||||||
|
return splitPlaceholders(text).flatMap<Token>((seg) =>
|
||||||
|
seg.placeholder ? [{ kind: "placeholder", text: seg.text }] : linkify(seg.text),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINK =
|
||||||
|
"text-gold-deep underline decoration-[1px] underline-offset-[0.2em] [overflow-wrap:anywhere] transition-opacity duration-300 hover:opacity-70";
|
||||||
|
|
||||||
|
function renderText(text: string): ReactNode {
|
||||||
|
return tokenize(text).map((tok, i) => {
|
||||||
|
if (tok.kind === "placeholder") {
|
||||||
|
return (
|
||||||
|
<span key={i} className="text-gold-deep">
|
||||||
|
{tok.text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tok.kind === "link") {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={tok.href}
|
||||||
|
className={LINK}
|
||||||
|
target={tok.external ? "_blank" : undefined}
|
||||||
|
rel={tok.external ? "noopener noreferrer" : undefined}
|
||||||
|
>
|
||||||
|
{tok.text}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return tok.text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Blocks: aufeinanderfolgende „– …“-Absätze werden zu einer Liste. */
|
||||||
|
|
||||||
|
type Block = { type: "p"; text: string } | { type: "list"; items: string[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Absatz überlebt den Launch-Flip ganz oder gar nicht. Platzhalter aus dem
|
||||||
|
* Satz zu schneiden hinterließ Reste wie „Anelia Wolf,,“, ein Impressum ohne
|
||||||
|
* ladungsfähige Anschrift (§ 5 Abs. 1 Nr. 1 DDG) und ein Label ohne USt-IdNr.
|
||||||
|
* Pflichtabsätze mit offenem Platzhalter kommen hier gar nicht erst an – der
|
||||||
|
* Build-Guard in src/content/legal.ts bricht vorher ab.
|
||||||
|
*/
|
||||||
|
function prepare(raw: LegalParagraph): string | null {
|
||||||
|
const text = typeof raw === "string" ? raw : raw.text;
|
||||||
|
if (!flags.showPlaceholders && hasPlaceholder(text)) return null;
|
||||||
|
return text.trim().length > 0 ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBlocks(paragraphs: readonly LegalParagraph[]): Block[] {
|
||||||
|
const blocks: Block[] = [];
|
||||||
|
for (const raw of paragraphs) {
|
||||||
|
const text = prepare(raw);
|
||||||
|
if (!text) continue;
|
||||||
|
if (/^–\s/.test(text)) {
|
||||||
|
const prev = blocks[blocks.length - 1];
|
||||||
|
if (prev && prev.type === "list") prev.items.push(text);
|
||||||
|
else blocks.push({ type: "list", items: [text] });
|
||||||
|
} else {
|
||||||
|
blocks.push({ type: "p", text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slug(text: string, index: number): string {
|
||||||
|
const s = text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ä/g, "ae")
|
||||||
|
.replace(/ö/g, "oe")
|
||||||
|
.replace(/ü/g, "ue")
|
||||||
|
.replace(/ß/g, "ss")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
return s.length > 0 ? s : `abschnitt-${index + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BODY = "max-w-[36rem] font-sans text-[1.0625rem] font-normal leading-[1.65] text-fg [hyphens:auto]";
|
||||||
|
|
||||||
|
function LegalBlock({ section, index }: { section: LegalSection; index: number }) {
|
||||||
|
const id = slug(section.h2, index);
|
||||||
|
const blocks = toBlocks(section.paragraphs);
|
||||||
|
if (blocks.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section aria-labelledby={id} className="mt-14 lg:mt-16">
|
||||||
|
<Eyebrow as="h2" className="font-mono text-xs font-normal tracking-[0.12em]">
|
||||||
|
<span id={id}>{section.h2}</span>
|
||||||
|
</Eyebrow>
|
||||||
|
<div className="mt-5 space-y-[1.25em]">
|
||||||
|
{blocks.map((block, i) =>
|
||||||
|
block.type === "p" ? (
|
||||||
|
<p key={i} className={BODY}>
|
||||||
|
{renderText(block.text)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul key={i} className={cn(BODY, "list-none space-y-[0.35em] pl-[1.1em] -indent-[1.1em]")}>
|
||||||
|
{block.items.map((item, j) => (
|
||||||
|
<li key={j}>{renderText(item)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export interface LegalPageProps {
|
||||||
|
/** Section id (`impressum` / `datenschutz`). */
|
||||||
|
id: string;
|
||||||
|
content: LegalPageContent;
|
||||||
|
/** Sprache der Seite – die Kopfzeile (Rolle, Sprachen) kommt aus der Hülle. */
|
||||||
|
lang?: Lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server Component. Ivory page (Spec §8): Masthead with the page name in the
|
||||||
|
* centre slot, content in cols 3–9, H1 Bodoni 44 px, H2 as mono caps eyebrows
|
||||||
|
* with gold tick, body Jost 17 px / 1.65, meta line „Stand: …“ in DM Mono.
|
||||||
|
* No motion beyond the template enter fade. Placeholders „[…]“ stay visible in
|
||||||
|
* `--gold-deep` while `flags.showPlaceholders` is true.
|
||||||
|
*/
|
||||||
|
export function LegalPage({ id, content, lang = DEFAULT_LANG }: LegalPageProps) {
|
||||||
|
return (
|
||||||
|
<Section id={id} theme="light" className="pt-[calc(var(--header-h,7.5rem)+1.5rem)]">
|
||||||
|
<div className="col-span-12">
|
||||||
|
{/* Rolle und Sprachzeile stehen in der Hülle, nicht im Rechtstext – ohne das
|
||||||
|
stand über dem englischen Impressum „Keynote Speakerin · Trainerin · Coach“. */}
|
||||||
|
<Masthead center={content.masthead.center} chrome={chromeByLang[lang].chrome} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-12 pt-14 lg:col-span-7 lg:col-start-3 lg:pt-20">
|
||||||
|
<h1 className="font-display text-[2.75rem] font-normal leading-[1.05] tracking-[-0.01em] text-fg [text-wrap:balance]">
|
||||||
|
{content.h1}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-5 font-mono text-xs tracking-[0.06em] tabular-nums text-fg-muted">{renderText(content.stand)}</p>
|
||||||
|
|
||||||
|
{content.sections.map((section, i) => (
|
||||||
|
<LegalBlock key={section.h2} section={section} index={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LegalPage;
|
||||||
107
src/components/motion/Curtain.tsx
Normal file
107
src/components/motion/Curtain.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
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 { endTransition, setTransitionPhase, subscribe } from "./transition-store";
|
||||||
|
|
||||||
|
export interface CurtainProps {
|
||||||
|
/** The monogram (48 px). layout.tsx passes `<BrandMark/>`; falls back to `<WolfMark/>`. */
|
||||||
|
children?: ReactNode;
|
||||||
|
/** Texte der Hülle. Ohne Angabe die deutsche Fassung. */
|
||||||
|
chrome?: Chrome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page-turn sheet: fixed ivory, 1 px gold hairline on its top edge,
|
||||||
|
* monogram + destination label centred. Note the start state lives ONLY in
|
||||||
|
* GSAP (`yPercent`) – a CSS `translateY(100%)` would be read as the tween's
|
||||||
|
* base `y` and added on top, which pushed the whole cover tween one viewport
|
||||||
|
* below the fold (the reader saw a hard cut, then the sheet wiping up over the
|
||||||
|
* new page). Slides up 0.45 s (power3.inOut),
|
||||||
|
* pushes the route, keeps rising once the pathname changed, then resets.
|
||||||
|
* Hard fallback: full navigation when the route has not changed after 900 ms.
|
||||||
|
*/
|
||||||
|
export function Curtain({ children, chrome = chromeDe }: CurtainProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const sheet = useRef<HTMLDivElement>(null);
|
||||||
|
const phase = useRef<"idle" | "cover" | "pushed" | "reveal">("idle");
|
||||||
|
const timer = useRef<number>(0);
|
||||||
|
const [label, setLabel] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribe((s) => {
|
||||||
|
if (s.phase !== "cover" || !s.href || phase.current !== "idle") return;
|
||||||
|
const el = sheet.current;
|
||||||
|
if (!el) return;
|
||||||
|
const href = s.href;
|
||||||
|
phase.current = "cover";
|
||||||
|
setLabel(s.label ?? chrome.curtain.fallbackLabel);
|
||||||
|
gsap.killTweensOf(el);
|
||||||
|
gsap.set(el, { y: 0, yPercent: 100, autoAlpha: 1, pointerEvents: "auto" });
|
||||||
|
gsap.to(el, {
|
||||||
|
yPercent: 0,
|
||||||
|
duration: 0.45,
|
||||||
|
ease: EASE.inOut,
|
||||||
|
onComplete: () => {
|
||||||
|
phase.current = "pushed";
|
||||||
|
setTransitionPhase("pushed");
|
||||||
|
router.push(href, { scroll: false });
|
||||||
|
window.clearTimeout(timer.current);
|
||||||
|
timer.current = window.setTimeout(() => {
|
||||||
|
if (phase.current === "pushed") window.location.assign(href);
|
||||||
|
}, 900);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [router, chrome.curtain.fallbackLabel]);
|
||||||
|
|
||||||
|
// Route changed → the sheet continues upward and resets.
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase.current !== "pushed") return;
|
||||||
|
const el = sheet.current;
|
||||||
|
if (!el) return;
|
||||||
|
window.clearTimeout(timer.current);
|
||||||
|
phase.current = "reveal";
|
||||||
|
setTransitionPhase("reveal");
|
||||||
|
gsap.to(el, {
|
||||||
|
yPercent: -100,
|
||||||
|
duration: 0.45,
|
||||||
|
ease: EASE.inOut,
|
||||||
|
delay: 0.05,
|
||||||
|
onComplete: () => {
|
||||||
|
gsap.set(el, { y: 0, yPercent: 100, autoAlpha: 0, pointerEvents: "none" });
|
||||||
|
phase.current = "idle";
|
||||||
|
setLabel(null);
|
||||||
|
endTransition();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => () => window.clearTimeout(timer.current), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={sheet}
|
||||||
|
id="curtain"
|
||||||
|
aria-hidden="true"
|
||||||
|
data-theme="light"
|
||||||
|
className="pointer-events-none invisible fixed inset-0 z-[70] flex flex-col items-center justify-center gap-6 bg-ivory text-ink"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-x-0 top-0 h-px"
|
||||||
|
style={{ background: "var(--gold-hairline-light)" }}
|
||||||
|
/>
|
||||||
|
<span className="block h-12 text-ink [&_svg]:h-12 [&_svg]:w-auto">
|
||||||
|
{children ?? <WolfMark idPrefix="curtain" className="h-12 w-auto" />}
|
||||||
|
</span>
|
||||||
|
<span className="min-h-[1em] font-mono text-xs tracking-[0.06em] text-gold-deep">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Curtain;
|
||||||
111
src/components/motion/Door.tsx
Normal file
111
src/components/motion/Door.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLayoutEffect, useRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { gsap, DUR, EASE, MM, refreshSoon } from "@/lib/gsap";
|
||||||
|
|
||||||
|
export interface DoorProps {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
sizes?: string;
|
||||||
|
/** 6 % parallax (`yPercent −6 → 6`, scrub 0.6), desktop + fine pointer only. */
|
||||||
|
parallax?: boolean;
|
||||||
|
/** Eager loading for above-the-fold images. */
|
||||||
|
priority?: boolean;
|
||||||
|
/** Play immediately (hero timeline) instead of at 85 % in view. */
|
||||||
|
play?: "view" | "load";
|
||||||
|
delay?: number;
|
||||||
|
className?: string;
|
||||||
|
imgClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Photo behind a "door": `clip-path inset(100% 0 0 0) → inset(0)` (1.1 s,
|
||||||
|
* expo-out) while the image settles `scale 1.06 → 1`. Plain `<img>` – the
|
||||||
|
* static export has no image optimiser. Reduced motion = end state.
|
||||||
|
*/
|
||||||
|
export function Door({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
sizes,
|
||||||
|
parallax = false,
|
||||||
|
priority = false,
|
||||||
|
play = "view",
|
||||||
|
delay = 0,
|
||||||
|
className,
|
||||||
|
imgClassName,
|
||||||
|
}: DoorProps) {
|
||||||
|
const box = useRef<HTMLDivElement>(null);
|
||||||
|
const img = useRef<HTMLImageElement>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const boxEl = box.current;
|
||||||
|
const imgEl = img.current;
|
||||||
|
if (!boxEl || !imgEl) return;
|
||||||
|
const mm = gsap.matchMedia();
|
||||||
|
mm.add({ reduce: MM.reduce, ok: MM.ok, desktop: MM.desktop }, (ctx) => {
|
||||||
|
const { reduce, desktop } = ctx.conditions ?? {};
|
||||||
|
if (reduce) {
|
||||||
|
gsap.set(boxEl, { clipPath: "inset(0% 0% 0% 0%)" });
|
||||||
|
gsap.set(imgEl, { scale: 1, yPercent: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tl = gsap.timeline({
|
||||||
|
delay,
|
||||||
|
scrollTrigger: play === "view" ? { trigger: boxEl, start: "top 85%", once: true } : undefined,
|
||||||
|
});
|
||||||
|
tl.fromTo(
|
||||||
|
boxEl,
|
||||||
|
{ clipPath: "inset(100% 0% 0% 0%)" },
|
||||||
|
{ clipPath: "inset(0% 0% 0% 0%)", duration: DUR.door, ease: EASE.out },
|
||||||
|
0,
|
||||||
|
).fromTo(imgEl, { scale: 1.06 }, { scale: 1, duration: DUR.door, ease: EASE.out }, 0);
|
||||||
|
|
||||||
|
if (parallax && desktop) {
|
||||||
|
gsap.fromTo(
|
||||||
|
imgEl,
|
||||||
|
{ yPercent: -6 },
|
||||||
|
{
|
||||||
|
yPercent: 6,
|
||||||
|
ease: EASE.none,
|
||||||
|
scrollTrigger: { trigger: boxEl, start: "top bottom", end: "bottom top", scrub: 0.6 },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => mm.revert();
|
||||||
|
}, [parallax, play, delay]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={box}
|
||||||
|
className={cn("relative overflow-hidden [.js_&]:[clip-path:inset(100%_0_0_0)]", className)}
|
||||||
|
style={{ aspectRatio: `${width} / ${height}` }}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element -- static export, no optimiser */}
|
||||||
|
<img
|
||||||
|
ref={img}
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
sizes={sizes}
|
||||||
|
loading={priority ? "eager" : "lazy"}
|
||||||
|
fetchPriority={priority ? "high" : "auto"}
|
||||||
|
decoding="async"
|
||||||
|
onLoad={refreshSoon}
|
||||||
|
className={cn(
|
||||||
|
"absolute left-0 h-full w-full object-cover will-change-transform [.js_&]:[transform:scale(1.06)]",
|
||||||
|
parallax ? "-top-[6%] h-[112%]" : "top-0",
|
||||||
|
imgClassName,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Door;
|
||||||
106
src/components/motion/Draw.tsx
Normal file
106
src/components/motion/Draw.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { gsap, DUR } from "@/lib/gsap";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveal helpers shared by <DrawMark/> (hero, footer, 404) and <Formula/>.
|
||||||
|
*
|
||||||
|
* The generic `<Draw/>` wrapper this file used to export was never mounted –
|
||||||
|
* every draw on the site goes through <DrawMark/>, which owns the sequence.
|
||||||
|
* It shipped a `use client` component into every bundle for nothing, so the
|
||||||
|
* helpers are all that is left here.
|
||||||
|
*
|
||||||
|
* Two kinds of artwork have to reveal, and they need different gestures:
|
||||||
|
*
|
||||||
|
* - **stroke art** (the in-house <WolfMark/> fallback): each path is drawn on
|
||||||
|
* its own with a dash offset, V-left → V-right → wolf. The parts are
|
||||||
|
* separate strokes, so a half-finished mark still reads as a mark.
|
||||||
|
*
|
||||||
|
* - **filled art** (the client's `logo-mark.svg`): a dash offset is invisible
|
||||||
|
* on a filled shape, and the parts are *interlocked* – the wolf's chest is
|
||||||
|
* the middle stroke of the W, and the right V exists only as two fragments
|
||||||
|
* around the head. Revealing them one after another shows broken shards.
|
||||||
|
* So the whole SVG is wiped in as ONE gesture, top → bottom. Clipping the
|
||||||
|
* root (not each path) keeps the percentages in one coordinate space, so
|
||||||
|
* every intermediate frame is a clean horizontal slice of the finished mark.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A path that carries a fill and no visible stroke cannot be dash-drawn. */
|
||||||
|
function isFilledPath(path: SVGPathElement): boolean {
|
||||||
|
const cs = getComputedStyle(path);
|
||||||
|
const fill = cs.fill;
|
||||||
|
const hasFill = Boolean(fill) && fill !== "none" && fill !== "rgba(0, 0, 0, 0)" && fill !== "transparent";
|
||||||
|
const hasStroke =
|
||||||
|
Boolean(cs.stroke) && cs.stroke !== "none" && cs.stroke !== "rgba(0, 0, 0, 0)" && parseFloat(cs.strokeWidth || "0") > 0;
|
||||||
|
return hasFill && !hasStroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a mark reveals: one wipe over the whole SVG, or a dash draw per path. */
|
||||||
|
export type RevealMode = "wipe" | "stroke";
|
||||||
|
|
||||||
|
/** Tween vars that finish either reveal. Pair with the target `primeMark` reported. */
|
||||||
|
export const WIPE_IN: gsap.TweenVars = { clipPath: "inset(0% 0% 0% 0%)" };
|
||||||
|
export const STROKE_IN: gsap.TweenVars = { strokeDashoffset: 0 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hides a mark before its reveal and reports how it has to be animated.
|
||||||
|
* `wipe` → tween `svg` with {@link WIPE_IN}; `stroke` → tween each path with
|
||||||
|
* {@link STROKE_IN}, in whatever order the caller wants.
|
||||||
|
*/
|
||||||
|
export function primeMark(svg: SVGSVGElement | null, paths: SVGPathElement[]): RevealMode {
|
||||||
|
const drawable = paths.filter(Boolean);
|
||||||
|
if (svg && drawable.length && drawable.every(isFilledPath)) {
|
||||||
|
gsap.set(svg, { clipPath: "inset(0% 0% 100% 0%)" });
|
||||||
|
return "wipe";
|
||||||
|
}
|
||||||
|
drawable.forEach((path) => {
|
||||||
|
path.setAttribute("pathLength", "1");
|
||||||
|
gsap.set(path, { strokeDasharray: 1, strokeDashoffset: 1 });
|
||||||
|
});
|
||||||
|
return "stroke";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time light sweep over the mark – "light over metal".
|
||||||
|
*
|
||||||
|
* Stroke art gets a second champagne stroke running along the path. Filled art
|
||||||
|
* gets a champagne copy of the shape wiped across it left → right, which is the
|
||||||
|
* same gesture for a solid mark. The copy removes itself afterwards.
|
||||||
|
*/
|
||||||
|
export function playSheen(path: SVGPathElement, duration = DUR.sheen): gsap.core.Tween {
|
||||||
|
const filled = isFilledPath(path);
|
||||||
|
const clone = path.cloneNode(false) as SVGPathElement;
|
||||||
|
clone.removeAttribute("id");
|
||||||
|
clone.removeAttribute("data-draw");
|
||||||
|
clone.removeAttribute("pathLength");
|
||||||
|
clone.removeAttribute("class");
|
||||||
|
clone.setAttribute("aria-hidden", "true");
|
||||||
|
path.parentNode?.insertBefore(clone, path.nextSibling);
|
||||||
|
const done = () => clone.remove();
|
||||||
|
|
||||||
|
if (filled) {
|
||||||
|
clone.style.fill = "#F4E5B8";
|
||||||
|
clone.style.opacity = "0.85";
|
||||||
|
gsap.set(clone, { clipPath: "inset(0% 100% 0% 0%)" });
|
||||||
|
return gsap.to(clone, {
|
||||||
|
keyframes: [
|
||||||
|
{ clipPath: "inset(0% 0% 0% 0%)", duration: duration * 0.55, ease: "power2.out" },
|
||||||
|
{ clipPath: "inset(0% 0% 0% 100%)", duration: duration * 0.45, ease: "power2.in" },
|
||||||
|
],
|
||||||
|
onComplete: done,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clone.style.stroke = "#F4E5B8";
|
||||||
|
clone.style.strokeDasharray = "";
|
||||||
|
clone.style.strokeDashoffset = "";
|
||||||
|
clone.style.opacity = "0.9";
|
||||||
|
const length = clone.getTotalLength() || 200;
|
||||||
|
gsap.set(clone, { strokeDasharray: `12 ${length + 12}`, strokeDashoffset: 12 });
|
||||||
|
return gsap.to(clone, {
|
||||||
|
strokeDashoffset: -length,
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
onComplete: done,
|
||||||
|
});
|
||||||
|
}
|
||||||
79
src/components/motion/Lines.tsx
Normal file
79
src/components/motion/Lines.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLayoutEffect, useRef, type ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { gsap, SplitText, DUR, EASE, MM, fontsReady, hasJs } from "@/lib/gsap";
|
||||||
|
|
||||||
|
export interface LinesProps {
|
||||||
|
as?: "h1" | "h2" | "h3" | "p" | "div";
|
||||||
|
play?: "view" | "load";
|
||||||
|
delay?: number;
|
||||||
|
stagger?: number;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Line reveal: SplitText (`type: "lines"`, `mask: "lines"`, `autoSplit`), each
|
||||||
|
* line rises `yPercent 100 → 0` (0.9 s, stagger 0.08, expo-out), once.
|
||||||
|
* Waits for `document.fonts.ready` (1.5 s timeout). Reduced motion or missing
|
||||||
|
* `html.js` → static text. SplitText (`aria: "auto"`) sets the aria-label.
|
||||||
|
* Hidden via opacity only (never `visibility`), so nothing drops out of the
|
||||||
|
* tab sequence.
|
||||||
|
*/
|
||||||
|
export function Lines({ as = "p", play = "view", delay = 0, stagger = 0.08, className, children }: LinesProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const Tag = as as "div";
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (!hasJs()) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let split: SplitText | null = null;
|
||||||
|
const mm = gsap.matchMedia();
|
||||||
|
|
||||||
|
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
|
||||||
|
if (ctx.conditions?.reduce) {
|
||||||
|
gsap.set(el, { opacity: 1 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fontsReady().then(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
split = SplitText.create(el, {
|
||||||
|
type: "lines",
|
||||||
|
mask: "lines",
|
||||||
|
autoSplit: true,
|
||||||
|
aria: "auto",
|
||||||
|
linesClass: "line",
|
||||||
|
onSplit: (self) => {
|
||||||
|
gsap.set(el, { opacity: 1 });
|
||||||
|
return gsap.from(self.lines, {
|
||||||
|
yPercent: 100,
|
||||||
|
duration: DUR.lines,
|
||||||
|
ease: EASE.out,
|
||||||
|
stagger,
|
||||||
|
delay,
|
||||||
|
scrollTrigger: play === "view" ? { trigger: el, start: "top 82%", once: true } : undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
split?.revert();
|
||||||
|
mm.revert();
|
||||||
|
};
|
||||||
|
}, [play, delay, stagger]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag ref={ref} data-reveal="" className={cn("[.js_&]:opacity-0", className)}>
|
||||||
|
{children}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Lines;
|
||||||
84
src/components/motion/Magnet.tsx
Normal file
84
src/components/motion/Magnet.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { gsap, MM } from "@/lib/gsap";
|
||||||
|
|
||||||
|
export interface MagnetProps {
|
||||||
|
/** Fraction of the pointer offset the child follows (default 0.3). */
|
||||||
|
strength?: number;
|
||||||
|
/** Activation radius around the element in px (default 80). */
|
||||||
|
radius?: number;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls its child toward the cursor within `radius` px and springs it back.
|
||||||
|
* Desktop + fine pointer + no reduced-motion only; one rAF per pointer move.
|
||||||
|
*/
|
||||||
|
export function Magnet({ strength = 0.3, radius = 80, className, children }: MagnetProps) {
|
||||||
|
const wrap = useRef<HTMLSpanElement>(null);
|
||||||
|
const inner = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = wrap.current;
|
||||||
|
const target = inner.current;
|
||||||
|
if (!el || !target) return;
|
||||||
|
|
||||||
|
const mm = gsap.matchMedia();
|
||||||
|
mm.add({ desktop: MM.desktop, reduce: MM.reduce }, (ctx) => {
|
||||||
|
const { desktop, reduce } = ctx.conditions ?? {};
|
||||||
|
if (!desktop || reduce) return;
|
||||||
|
|
||||||
|
const xTo = gsap.quickTo(target, "x", { duration: 0.4, ease: "power3.out" });
|
||||||
|
const yTo = gsap.quickTo(target, "y", { duration: 0.4, ease: "power3.out" });
|
||||||
|
let raf = 0;
|
||||||
|
let active = false;
|
||||||
|
let px = 0;
|
||||||
|
let py = 0;
|
||||||
|
|
||||||
|
const release = () => {
|
||||||
|
active = false;
|
||||||
|
gsap.to(target, { x: 0, y: 0, duration: 0.8, ease: "elastic.out(1, 0.45)", overwrite: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
raf = 0;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
const inside =
|
||||||
|
px > r.left - radius && px < r.right + radius && py > r.top - radius && py < r.bottom + radius;
|
||||||
|
if (inside) {
|
||||||
|
active = true;
|
||||||
|
xTo((px - (r.left + r.width / 2)) * strength);
|
||||||
|
yTo((py - (r.top + r.height / 2)) * strength);
|
||||||
|
} else if (active) {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMove = (e: PointerEvent) => {
|
||||||
|
px = e.clientX;
|
||||||
|
py = e.clientY;
|
||||||
|
if (!raf) raf = requestAnimationFrame(update);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", onMove, { passive: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointermove", onMove);
|
||||||
|
if (raf) cancelAnimationFrame(raf);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return () => mm.revert();
|
||||||
|
}, [strength, radius]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={wrap} className={cn("inline-block", className)}>
|
||||||
|
<span ref={inner} className="inline-block will-change-transform">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Magnet;
|
||||||
147
src/components/motion/PageTemplate.tsx
Normal file
147
src/components/motion/PageTemplate.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { gsap, EASE, MM, ScrollTrigger, fontsReady, getLenis, refreshSoon } from "@/lib/gsap";
|
||||||
|
import { anchorOffset } from "@/components/motion/TransitionLink";
|
||||||
|
|
||||||
|
/* False during SSR + first hydration: the first page load renders visible
|
||||||
|
without an enter animation (the hero has its own choreography). Every
|
||||||
|
client navigation afterwards remounts the template and animates. */
|
||||||
|
let navigated = false;
|
||||||
|
|
||||||
|
/** translateY of <main> while the enter animation runs (rects include it, layout does not). */
|
||||||
|
function mainShift(): number {
|
||||||
|
const main = document.getElementById("inhalt");
|
||||||
|
if (!main) return 0;
|
||||||
|
return main.getBoundingClientRect().top + window.scrollY - main.offsetTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Document y for `#hash` with the header offset, corrected for the enter transform. */
|
||||||
|
function hashTop(hash: string): number | null {
|
||||||
|
const el = document.getElementById(hash.replace(/^#/, ""));
|
||||||
|
if (!el) return null;
|
||||||
|
return Math.round(el.getBoundingClientRect().top + window.scrollY - mainShift() + anchorOffset());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fonts ready → two frames → re-measure. The one refresh that always has to happen. */
|
||||||
|
function refreshAfterFonts(): void {
|
||||||
|
fontsReady().then(() => {
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => ScrollTrigger.refresh()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-page anchor (`/leistungen/… → /#kontakt`): the new page has not laid
|
||||||
|
* out when the pathname changes – Lenis' `limit` and the native scrollHeight
|
||||||
|
* still belong to the previous page, so an immediate scroll gets clamped.
|
||||||
|
* Wait for fonts + two frames, re-measure (ScrollTrigger + Lenis), jump to
|
||||||
|
* the computed y; then correct once more when <main> resizes or the enter
|
||||||
|
* animation ends and the target moved – unless the reader scrolled meanwhile.
|
||||||
|
*/
|
||||||
|
function scheduleHashScroll(hash: string): () => void {
|
||||||
|
let cancelled = false;
|
||||||
|
let ro: ResizeObserver | null = null;
|
||||||
|
let expected = -1;
|
||||||
|
const timers: number[] = [];
|
||||||
|
|
||||||
|
const jump = () => {
|
||||||
|
ScrollTrigger.refresh();
|
||||||
|
getLenis()?.resize();
|
||||||
|
const top = hashTop(hash);
|
||||||
|
if (top === null) return;
|
||||||
|
const lenis = getLenis();
|
||||||
|
if (lenis) lenis.scrollTo(top, { immediate: true, force: true });
|
||||||
|
else window.scrollTo(0, top);
|
||||||
|
expected = Math.round(window.scrollY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const correct = () => {
|
||||||
|
if (cancelled || expected < 0) return;
|
||||||
|
if (Math.abs(window.scrollY - expected) > 2) return; // the reader took over
|
||||||
|
const top = hashTop(hash);
|
||||||
|
if (top !== null && Math.abs(top - expected) > 2) jump();
|
||||||
|
};
|
||||||
|
|
||||||
|
fontsReady().then(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
jump();
|
||||||
|
const main = document.getElementById("inhalt");
|
||||||
|
if (main && typeof ResizeObserver !== "undefined") {
|
||||||
|
ro = new ResizeObserver(correct);
|
||||||
|
ro.observe(main);
|
||||||
|
}
|
||||||
|
// After the 0.6 s enter animation and once more for late layout.
|
||||||
|
timers.push(window.setTimeout(correct, 700), window.setTimeout(() => ro?.disconnect(), 1500));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
ro?.disconnect();
|
||||||
|
timers.forEach((t) => window.clearTimeout(t));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageTemplate({ children }: { children: ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [enter] = useState(() => navigated);
|
||||||
|
const main = useRef<HTMLElement>(null);
|
||||||
|
|
||||||
|
/* Enter fade on GSAP – the site already ships GSAP for every reveal, so a second
|
||||||
|
animation runtime (framer-motion) for one 0.6 s opacity/y tween is not worth
|
||||||
|
~39 KB gzip on every route. Reduced motion is handled by matchMedia, as everywhere. */
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = main.current;
|
||||||
|
if (!el || !enter) return;
|
||||||
|
const mm = gsap.matchMedia();
|
||||||
|
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
|
||||||
|
if (ctx.conditions?.reduce) {
|
||||||
|
gsap.set(el, { opacity: 1, y: 0 });
|
||||||
|
refreshSoon();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gsap.fromTo(
|
||||||
|
el,
|
||||||
|
{ opacity: 0, y: 16 },
|
||||||
|
{ opacity: 1, y: 0, duration: 0.6, ease: EASE.out, onComplete: refreshSoon },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return () => mm.revert();
|
||||||
|
}, [enter, pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const first = !navigated;
|
||||||
|
navigated = true;
|
||||||
|
if (first) {
|
||||||
|
/* Also on the FIRST load: every Reveal / Rule / Door / Formula builds its
|
||||||
|
ScrollTrigger in useLayoutEffect against fallback-font metrics, and Bodoni +
|
||||||
|
Jost are `display: swap` – when they land, the whole document reflows and every
|
||||||
|
start/end is stale (spec A/D5, applied to the entry path too). */
|
||||||
|
refreshAfterFonts();
|
||||||
|
return; // the browser handles initial scroll position / hash
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = window.location.hash;
|
||||||
|
const lenis = getLenis();
|
||||||
|
if (lenis) lenis.scrollTo(0, { immediate: true, force: true });
|
||||||
|
else window.scrollTo(0, 0);
|
||||||
|
/* No eager refresh here: at this moment the new page has not laid out (that is
|
||||||
|
exactly what scheduleHashScroll documents), so it would measure a layout about
|
||||||
|
to be replaced. The enter animation's onComplete and the scheduled jump refresh. */
|
||||||
|
if (hash) return scheduleHashScroll(hash);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main ref={main} id="inhalt" className="flex-1">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageTemplate;
|
||||||
150
src/components/motion/Reveal.tsx
Normal file
150
src/components/motion/Reveal.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLayoutEffect, useRef, type ElementType, type ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { gsap, DUR, EASE, MM } from "@/lib/gsap";
|
||||||
|
|
||||||
|
/* Initial state only under html.js – without JS everything is visible. Opacity only
|
||||||
|
(never `visibility: hidden`), so links inside stay in the tab sequence. */
|
||||||
|
const HIDDEN = "[.js_&]:opacity-0 [.js_&]:[transform:translateY(16px)]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard reachability: a block that receives focus before its ScrollTrigger
|
||||||
|
* fired jumps to its end state (the browser scrolls the focused link into
|
||||||
|
* view; the reveal must not lag behind or hide the focus ring).
|
||||||
|
*/
|
||||||
|
function revealOnFocus(el: HTMLElement, tween: gsap.core.Tween): () => void {
|
||||||
|
const onFocus = () => {
|
||||||
|
tween.scrollTrigger?.kill();
|
||||||
|
tween.progress(1);
|
||||||
|
};
|
||||||
|
el.addEventListener("focusin", onFocus, { once: true });
|
||||||
|
return () => el.removeEventListener("focusin", onFocus);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevealProps {
|
||||||
|
as?: ElementType;
|
||||||
|
play?: "view" | "load";
|
||||||
|
delay?: number;
|
||||||
|
/** Start offset in px (default 16). */
|
||||||
|
y?: number;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block fade: `opacity 0 → 1, y 16 → 0`, 0.6 s expo-out, once
|
||||||
|
* (`top 90%` ≈ viewport margin −10 %). Reduced motion = end state.
|
||||||
|
*/
|
||||||
|
export function Reveal({ as = "div", play = "view", delay = 0, y = 16, className, children }: RevealProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const Tag = as as "div";
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const mm = gsap.matchMedia();
|
||||||
|
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
|
||||||
|
if (ctx.conditions?.reduce) {
|
||||||
|
gsap.set(el, { opacity: 1, y: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tween = gsap.fromTo(
|
||||||
|
el,
|
||||||
|
{ opacity: 0, y },
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: DUR.reveal,
|
||||||
|
ease: EASE.out,
|
||||||
|
delay,
|
||||||
|
scrollTrigger: play === "view" ? { trigger: el, start: "top 90%", once: true } : undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return revealOnFocus(el, tween);
|
||||||
|
});
|
||||||
|
return () => mm.revert();
|
||||||
|
}, [play, delay, y]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag ref={ref} data-reveal="" className={cn(HIDDEN, className)}>
|
||||||
|
{children}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevealGroupProps {
|
||||||
|
as?: ElementType;
|
||||||
|
play?: "view" | "load";
|
||||||
|
stagger?: number;
|
||||||
|
delay?: number;
|
||||||
|
y?: number;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Staggers all nested `<RevealItem/>`s (0.06 s) from one trigger. */
|
||||||
|
export function RevealGroup({
|
||||||
|
as = "div",
|
||||||
|
play = "view",
|
||||||
|
stagger = 0.06,
|
||||||
|
delay = 0,
|
||||||
|
y = 16,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: RevealGroupProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const Tag = as as "div";
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const items = Array.from(el.querySelectorAll<HTMLElement>("[data-reveal-item]"));
|
||||||
|
if (!items.length) return;
|
||||||
|
const mm = gsap.matchMedia();
|
||||||
|
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
|
||||||
|
if (ctx.conditions?.reduce) {
|
||||||
|
gsap.set(items, { opacity: 1, y: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tween = gsap.fromTo(
|
||||||
|
items,
|
||||||
|
{ opacity: 0, y },
|
||||||
|
{
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: DUR.reveal,
|
||||||
|
ease: EASE.out,
|
||||||
|
stagger,
|
||||||
|
delay,
|
||||||
|
scrollTrigger: play === "view" ? { trigger: el, start: "top 90%", once: true } : undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return revealOnFocus(el, tween);
|
||||||
|
});
|
||||||
|
return () => mm.revert();
|
||||||
|
}, [play, stagger, delay, y]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag ref={ref} className={className}>
|
||||||
|
{children}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevealItemProps {
|
||||||
|
as?: ElementType;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RevealItem({ as = "div", className, children }: RevealItemProps) {
|
||||||
|
const Tag = as as "div";
|
||||||
|
return (
|
||||||
|
<Tag data-reveal-item="" data-reveal="" className={cn(HIDDEN, className)}>
|
||||||
|
{children}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Reveal;
|
||||||
61
src/components/motion/Rule.tsx
Normal file
61
src/components/motion/Rule.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLayoutEffect, useRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { gsap, DUR, EASE, MM } from "@/lib/gsap";
|
||||||
|
|
||||||
|
export interface RuleProps {
|
||||||
|
/** `gold` = metallic hairline (`.rule-gold`), `line` = neutral `--line`. */
|
||||||
|
variant?: "gold" | "line";
|
||||||
|
/** Growth origin; a number is px from the left edge (hero folio: the handoff x). */
|
||||||
|
origin?: "left" | "center" | "right" | number;
|
||||||
|
/** `view` = when 85 % in view (once), `load` = immediately on mount. */
|
||||||
|
play?: "view" | "load";
|
||||||
|
delay?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1 px hairline that draws `scaleX 0 → 1` (0.9 s, expo-out), once.
|
||||||
|
* Initial hidden state only under `html.js`; reduced motion = end state.
|
||||||
|
*/
|
||||||
|
export function Rule({ variant = "line", origin = "left", play = "view", delay = 0, className }: RuleProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const transformOrigin = typeof origin === "number" ? `${origin}px 50%` : `${origin} 50%`;
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const mm = gsap.matchMedia();
|
||||||
|
mm.add({ reduce: MM.reduce, ok: MM.ok }, (ctx) => {
|
||||||
|
if (ctx.conditions?.reduce) {
|
||||||
|
gsap.set(el, { scaleX: 1 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gsap.set(el, { scaleX: 0, transformOrigin });
|
||||||
|
gsap.to(el, {
|
||||||
|
scaleX: 1,
|
||||||
|
duration: DUR.rule,
|
||||||
|
ease: EASE.out,
|
||||||
|
delay,
|
||||||
|
scrollTrigger: play === "view" ? { trigger: el, start: "top 85%", once: true } : undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => mm.revert();
|
||||||
|
}, [play, delay, transformOrigin]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ transformOrigin }}
|
||||||
|
className={cn(
|
||||||
|
"h-px w-full [.js_&]:[transform:scaleX(0)]",
|
||||||
|
variant === "gold" ? "rule-gold" : "bg-line",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Rule;
|
||||||
43
src/components/motion/ScrollProgress.tsx
Normal file
43
src/components/motion/ScrollProgress.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLayoutEffect, useRef } from "react";
|
||||||
|
import { gsap, EASE, MM } from "@/lib/gsap";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1 px `--accent` line directly under the header (the header writes
|
||||||
|
* `--header-h` on <html>). Grows with the page scroll; purely decorative.
|
||||||
|
*
|
||||||
|
* Scrubbed by ScrollTrigger, not by a second animation runtime: this was the
|
||||||
|
* only reason `useScroll` (framer-motion) shipped on every route.
|
||||||
|
*/
|
||||||
|
export function ScrollProgress() {
|
||||||
|
const bar = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = bar.current;
|
||||||
|
if (!el) return;
|
||||||
|
const mm = gsap.matchMedia();
|
||||||
|
mm.add({ reduce: MM.reduce, ok: MM.ok }, () => {
|
||||||
|
/* Reduced motion too: the bar reports position, it does not animate for effect –
|
||||||
|
the scrub follows the reader's own scrolling either way. */
|
||||||
|
gsap.set(el, { scaleX: 0, transformOrigin: "0 50%" });
|
||||||
|
gsap.to(el, {
|
||||||
|
scaleX: 1,
|
||||||
|
ease: EASE.none,
|
||||||
|
scrollTrigger: { trigger: document.documentElement, start: "top top", end: "bottom bottom", scrub: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => mm.revert();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={bar}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none fixed inset-x-0 z-50 h-px origin-left scale-x-0 bg-accent transition-opacity duration-300 [[data-menu-open]_&]:opacity-0"
|
||||||
|
style={{ top: "var(--header-h, 60px)" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScrollProgress;
|
||||||
91
src/components/motion/TransitionLink.tsx
Normal file
91
src/components/motion/TransitionLink.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import type { ComponentProps, MouseEvent } from "react";
|
||||||
|
import { getLenis, hasJs, prefersReducedMotion } from "@/lib/gsap";
|
||||||
|
import { isTransitioning, startTransition } from "./transition-store";
|
||||||
|
|
||||||
|
type NextLinkProps = ComponentProps<typeof Link>;
|
||||||
|
|
||||||
|
export interface TransitionLinkProps extends Omit<NextLinkProps, "href"> {
|
||||||
|
href: string;
|
||||||
|
/** Destination name shown on the curtain, e.g. „Trainings & Workshops“. */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header height when `--header-h` has not been written yet (60 px < lg, 72 px from lg). */
|
||||||
|
const HEADER_FALLBACK = 72;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The site's ONE anchor offset: −(measured header height + 16 px). The header
|
||||||
|
* publishes its height as `--header-h` on <html>, and globals.css derives the
|
||||||
|
* native `:target` landing (`scroll-margin-top`) from the same expression – so a
|
||||||
|
* JS jump and a native jump end on the same pixel (spec A/D5).
|
||||||
|
*/
|
||||||
|
export function anchorOffset(): number {
|
||||||
|
if (typeof document === "undefined") return -(HEADER_FALLBACK + 16);
|
||||||
|
const raw = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--header-h"));
|
||||||
|
return -((Number.isFinite(raw) && raw > 0 ? raw : HEADER_FALLBACK) + 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(p: string): string {
|
||||||
|
const s = p.split("#")[0].split("?")[0];
|
||||||
|
return s.endsWith("/") ? s : `${s}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Smooth-scrolls to `#id` (Lenis when available, else native), respecting the header offset. */
|
||||||
|
export function scrollToHash(hash: string, immediate = false) {
|
||||||
|
const id = hash.replace(/^#/, "");
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
const lenis = getLenis();
|
||||||
|
const offset = anchorOffset();
|
||||||
|
if (lenis) {
|
||||||
|
lenis.scrollTo(el, { offset, immediate, force: true });
|
||||||
|
} else {
|
||||||
|
const top = el.getBoundingClientRect().top + window.scrollY + offset;
|
||||||
|
window.scrollTo({ top, behavior: immediate || prefersReducedMotion() ? "auto" : "smooth" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* next/link with the „Umblättern“ curtain. Falls back to a normal link for
|
||||||
|
* 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) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
onClick?.(e);
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
|
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||||
|
if (rest.target && rest.target !== "_self") return;
|
||||||
|
if (!href.startsWith("/")) return;
|
||||||
|
|
||||||
|
const [path, hash] = href.split("#");
|
||||||
|
if (normalizePath(path || pathname) === normalizePath(pathname)) {
|
||||||
|
// Same page: anchor → smooth scroll; plain → to top.
|
||||||
|
e.preventDefault();
|
||||||
|
if (hash) {
|
||||||
|
scrollToHash(hash);
|
||||||
|
window.history.pushState(null, "", `#${hash}`);
|
||||||
|
} else {
|
||||||
|
const lenis = getLenis();
|
||||||
|
if (lenis) lenis.scrollTo(0, { force: true });
|
||||||
|
else window.scrollTo({ top: 0, behavior: prefersReducedMotion() ? "auto" : "smooth" });
|
||||||
|
}
|
||||||
|
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} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TransitionLink;
|
||||||
55
src/components/motion/transition-store.ts
Normal file
55
src/components/motion/transition-store.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Tiny framework-free store for the page transition („Umblättern“).
|
||||||
|
* TransitionLink calls `startTransition`; Curtain subscribes, animates,
|
||||||
|
* pushes the route and finally calls `endTransition`.
|
||||||
|
*/
|
||||||
|
export type TransitionPhase = "idle" | "cover" | "pushed" | "reveal";
|
||||||
|
|
||||||
|
export interface TransitionState {
|
||||||
|
phase: TransitionPhase;
|
||||||
|
href: string | null;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Listener = (state: TransitionState) => void;
|
||||||
|
|
||||||
|
let state: TransitionState = { phase: "idle", href: null };
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
listeners.forEach((cb) => cb(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Begins a transition. Returns false when one is already running (double clicks). */
|
||||||
|
export function startTransition(href: string, label?: string): boolean {
|
||||||
|
if (state.phase !== "idle") return false;
|
||||||
|
state = { phase: "cover", href, label };
|
||||||
|
emit();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal: Curtain advances the phase. */
|
||||||
|
export function setTransitionPhase(phase: TransitionPhase) {
|
||||||
|
state = { ...state, phase };
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endTransition() {
|
||||||
|
state = { phase: "idle", href: null };
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTransitioning(): boolean {
|
||||||
|
return state.phase !== "idle";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTransitionState(): TransitionState {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe(cb: Listener): () => void {
|
||||||
|
listeners.add(cb);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(cb);
|
||||||
|
};
|
||||||
|
}
|
||||||
168
src/components/pages/CoachingBody.tsx
Normal file
168
src/components/pages/CoachingBody.tsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { ServiceHero } from "@/components/services/ServiceHero";
|
||||||
|
import { ServiceLayout } from "@/components/services/ServiceLayout";
|
||||||
|
import { SectionHeading } from "@/components/services/SectionHeading";
|
||||||
|
import { ThreeQuestions } from "@/components/services/coaching/ThreeQuestions";
|
||||||
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
|
import { Ledger } from "@/components/ui/Ledger";
|
||||||
|
import { SpecTable, type SpecLabels } from "@/components/ui/SpecTable";
|
||||||
|
import { Facts } from "@/components/ui/Facts";
|
||||||
|
import { Steps } from "@/components/ui/Steps";
|
||||||
|
import { Marginalia } from "@/components/ui/Marginalia";
|
||||||
|
import { Faq } from "@/components/ui/Faq";
|
||||||
|
import { CtaBand } from "@/components/ui/CtaBand";
|
||||||
|
import { specLabels as specLabelsDe, type CoachingContent } from "@/content/coaching";
|
||||||
|
import { getPhotos } from "@/lib/assets";
|
||||||
|
import { flags } from "@/lib/site";
|
||||||
|
|
||||||
|
/* Placement of every section head on this page (cols 3–8). The shared
|
||||||
|
<SectionHeading/> carries no default placement – the caller owns the grid. */
|
||||||
|
const HEAD = "col-span-12 lg:col-span-6 lg:col-start-3";
|
||||||
|
|
||||||
|
export interface CoachingBodyProps {
|
||||||
|
content: CoachingContent;
|
||||||
|
/** Zeilenbeschriftungen der Bausteine-Tabelle in der Sprache der Seite. */
|
||||||
|
specLabels?: SpecLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Rumpf von /leistungen/coaching/ bzw. /en/services/coaching/ (Spec §7.1 + §7.2,
|
||||||
|
* die wärmste Seite). Reihenfolge: Hero (ink) → Ausgangslage → Die drei Fragen →
|
||||||
|
* Bausteine → Rahmen → Ablauf → FAQ (Flag) → Kontakt-Band (ink).
|
||||||
|
* Alle Texte kommen als Prop; kein <main> – template.tsx liefert `main#inhalt`.
|
||||||
|
*/
|
||||||
|
export function CoachingBody({ content: coaching, specLabels = specLabelsDe }: CoachingBodyProps) {
|
||||||
|
const photos = getPhotos();
|
||||||
|
const showFaq = flags.showFaq && coaching.faq.items.length > 0;
|
||||||
|
|
||||||
|
/* Section numbering within the page („01 / 06 – Ausgangslage“ …). */
|
||||||
|
const sections: string[] = [
|
||||||
|
coaching.situations.id,
|
||||||
|
coaching.questions.id,
|
||||||
|
coaching.blocks.id,
|
||||||
|
coaching.rahmen.id,
|
||||||
|
coaching.ablauf.id,
|
||||||
|
...(showFaq ? [coaching.faq.id] : []),
|
||||||
|
];
|
||||||
|
const total = String(sections.length).padStart(2, "0");
|
||||||
|
const numeral = (id: string) => String(sections.indexOf(id) + 1).padStart(2, "0");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* text-pretty instead of the shared text-balance: balance orphans the en dash at the
|
||||||
|
start of line 2 ("Wer bist du / – und wer"). GSAP SplitText normalises the no-break
|
||||||
|
space in the copy away, so the wrap mode is what has to keep the dash with "du".
|
||||||
|
|
||||||
|
The cover has to close: with a three-line H1 the shared hero grows to its natural
|
||||||
|
928 px and the folio row („01 / 03“, „Drei Situationen ↓“) is sliced by the bottom
|
||||||
|
edge of a 900 px viewport. `lg:min-h-svh` (the same handle /leistungen/keynotes/
|
||||||
|
already uses) pins the folio to the fold, and trimming the content row from
|
||||||
|
`lg:py-20` to 3 rem keeps the natural height below the fold so the floor, not the
|
||||||
|
copy, decides where the hero ends. `.grid-cols-12` is the hero's content row – the
|
||||||
|
only 12-column grid inside the section. */}
|
||||||
|
<ServiceHero content={coaching} className="lg:min-h-svh [&_h1]:text-pretty lg:[&_.grid-cols-12]:py-12" />
|
||||||
|
|
||||||
|
<ServiceLayout>
|
||||||
|
{/* Ausgangslage – situations as an italic ledger */}
|
||||||
|
<Section
|
||||||
|
id={coaching.situations.id}
|
||||||
|
theme="light"
|
||||||
|
numeral={numeral(coaching.situations.id)}
|
||||||
|
total={total}
|
||||||
|
label={coaching.situations.eyebrow}
|
||||||
|
>
|
||||||
|
<SectionHeading eyebrow={coaching.situations.eyebrow} h2={coaching.situations.h2} className={HEAD} />
|
||||||
|
<div className="col-span-12 mt-12 lg:col-span-9 lg:col-start-3 lg:mt-16">
|
||||||
|
<Ledger variant="situations" rows={coaching.situations.rows} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Special section: Die drei Fragen */}
|
||||||
|
<Section
|
||||||
|
id={coaching.questions.id}
|
||||||
|
theme="light"
|
||||||
|
numeral={numeral(coaching.questions.id)}
|
||||||
|
total={total}
|
||||||
|
label={coaching.questions.eyebrow}
|
||||||
|
>
|
||||||
|
<ThreeQuestions
|
||||||
|
eyebrow={coaching.questions.eyebrow}
|
||||||
|
rows={coaching.questions.rows}
|
||||||
|
photo={photos.portrait}
|
||||||
|
alt={coaching.questions.alt}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Leistungsbausteine – spec table, not cards */}
|
||||||
|
<Section
|
||||||
|
id={coaching.blocks.id}
|
||||||
|
theme="light"
|
||||||
|
numeral={numeral(coaching.blocks.id)}
|
||||||
|
total={total}
|
||||||
|
label={coaching.blocks.eyebrow}
|
||||||
|
>
|
||||||
|
<SectionHeading eyebrow={coaching.blocks.eyebrow} h2={coaching.blocks.h2} className={HEAD} />
|
||||||
|
<div className="col-span-12 mt-12 lg:col-span-10 lg:col-start-3 lg:mt-16">
|
||||||
|
<SpecTable blocks={coaching.blocks.items} labels={specLabels} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Rahmen – facts table on bone; placeholder rows follow flags.showPlaceholders */}
|
||||||
|
<Section
|
||||||
|
id={coaching.rahmen.id}
|
||||||
|
theme="light"
|
||||||
|
numeral={numeral(coaching.rahmen.id)}
|
||||||
|
total={total}
|
||||||
|
label={coaching.rahmen.eyebrow}
|
||||||
|
>
|
||||||
|
{/* The chapter needs a real heading: <Facts title> is a plain <p>, so section 04
|
||||||
|
was unreachable by heading navigation (identical fix in TrainingsRahmen). */}
|
||||||
|
<SectionHeading eyebrow={coaching.rahmen.eyebrow} className={HEAD} />
|
||||||
|
<Reveal className="col-span-12 mt-10 lg:col-span-10 lg:col-start-3">
|
||||||
|
<Facts variant="table" items={coaching.rahmen.rows} />
|
||||||
|
</Reveal>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Ablauf – four numbered rows + marginalia „Was du bekommst“ */}
|
||||||
|
<Section
|
||||||
|
id={coaching.ablauf.id}
|
||||||
|
theme="light"
|
||||||
|
numeral={numeral(coaching.ablauf.id)}
|
||||||
|
total={total}
|
||||||
|
label={coaching.ablauf.eyebrow}
|
||||||
|
>
|
||||||
|
<SectionHeading eyebrow={coaching.ablauf.eyebrow} className={HEAD} />
|
||||||
|
<div className="col-span-12 mt-12 lg:col-span-6 lg:col-start-3 lg:mt-16">
|
||||||
|
<Steps items={coaching.ablauf.steps} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-12 mt-12 lg:col-span-3 lg:col-start-10 lg:mt-16">
|
||||||
|
<Reveal delay={0.2}>
|
||||||
|
<Marginalia title={coaching.ablauf.marginalia.title} items={coaching.ablauf.marginalia.items} sticky />
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* FAQ – only with delivered answers (flags.showFaq) */}
|
||||||
|
{showFaq && (
|
||||||
|
<Section
|
||||||
|
id={coaching.faq.id}
|
||||||
|
theme="light"
|
||||||
|
numeral={numeral(coaching.faq.id)}
|
||||||
|
total={total}
|
||||||
|
label={coaching.faq.eyebrow}
|
||||||
|
>
|
||||||
|
<SectionHeading eyebrow={coaching.faq.eyebrow} className={HEAD} />
|
||||||
|
<Reveal className="col-span-12 mt-12 lg:col-span-8 lg:col-start-3 lg:mt-16">
|
||||||
|
<Faq items={coaching.faq.items} />
|
||||||
|
</Reveal>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</ServiceLayout>
|
||||||
|
|
||||||
|
{/* Kontakt band (ink) – same primary CTA as the hero */}
|
||||||
|
<CtaBand h2={coaching.band.h2} cta={coaching.band.cta} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CoachingBody;
|
||||||
89
src/components/pages/HomeBody.tsx
Normal file
89
src/components/pages/HomeBody.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { BrandMark } from "@/components/brand/BrandMark";
|
||||||
|
import { Hero } from "@/components/home/Hero";
|
||||||
|
import { Positioning } from "@/components/home/Positioning";
|
||||||
|
import { ServicesIndex } from "@/components/home/ServicesIndex";
|
||||||
|
import { WolfPrinciple } from "@/components/home/WolfPrinciple";
|
||||||
|
import { About } from "@/components/home/About";
|
||||||
|
import { Audience } from "@/components/home/Audience";
|
||||||
|
import { ProcessContact } from "@/components/home/ProcessContact";
|
||||||
|
import type { HomeContent } from "@/content/home";
|
||||||
|
import { getPhotos } from "@/lib/assets";
|
||||||
|
import { routesByLang, type Lang } from "@/lib/i18n";
|
||||||
|
import { rootCopy } from "@/lib/metadata";
|
||||||
|
import { site } from "@/lib/site";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Rumpf der Startseite – beide Sprachfassungen rendern durch diese Komponente,
|
||||||
|
* die Route reicht nur ihre Inhaltsdatei herein. Ohne das stünde derselbe Baum
|
||||||
|
* zweimal im Projekt und liefe garantiert auseinander.
|
||||||
|
*
|
||||||
|
* Sieben Abschnitte im Hell-Dunkel-Rhythmus der Spec:
|
||||||
|
* Hero (ink) → Positionierung → Leistungen (ivory) → WOLF-Prinzip (ink) →
|
||||||
|
* Über Anelia → Für wen + Termine (ivory) → Kontakt (ink). Kein <main> –
|
||||||
|
* template.tsx rendert `main#inhalt`.
|
||||||
|
*/
|
||||||
|
export function HomeBody({ lang, content }: { lang: Lang; content: HomeContent }) {
|
||||||
|
const photos = getPhotos();
|
||||||
|
/* Nur ein Foto vorhanden → die Hero-Datei trägt auch „Über Anelia“ (engerer Schnitt, Spec §9). */
|
||||||
|
const aboutPhoto = photos.portrait ?? photos.hero;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd(lang, content)).replace(/</g, "\\u003c") }}
|
||||||
|
/>
|
||||||
|
<Hero mark={<BrandMark idPrefix="hero" handoff />} photo={photos.hero} content={content.hero} />
|
||||||
|
<Positioning content={content.positioning} />
|
||||||
|
<ServicesIndex content={content.services} />
|
||||||
|
<WolfPrinciple mark={<BrandMark idPrefix="prinzip" />} content={content.principle} />
|
||||||
|
<About photo={aboutPhoto} content={content.about} />
|
||||||
|
<Audience content={content.audience} />
|
||||||
|
<ProcessContact content={content.contact} privacyHref={routesByLang[lang].datenschutz} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Person + die drei Leistungen (schema.org).
|
||||||
|
*
|
||||||
|
* `@id` bleibt in beiden Sprachen dieselbe Person – die deutsche Startseite ist der
|
||||||
|
* kanonische Ort, sonst stünden zwei Personen mit identischen Kontaktdaten im Graph.
|
||||||
|
* Die Leistungen bekommen die URL ihrer eigenen Sprachfassung, damit `url` und die
|
||||||
|
* ausgelieferte Seite zusammenpassen.
|
||||||
|
*/
|
||||||
|
function jsonLd(lang: Lang, content: HomeContent) {
|
||||||
|
const PERSON_ID = `${site.url}/#person`;
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"@type": "Person",
|
||||||
|
"@id": PERSON_ID,
|
||||||
|
name: site.name,
|
||||||
|
jobTitle: content.hero.masthead.role,
|
||||||
|
description: rootCopy[lang].description,
|
||||||
|
url: `${site.url}${routesByLang[lang].home}`,
|
||||||
|
email: site.email,
|
||||||
|
telephone: site.phoneDisplay,
|
||||||
|
sameAs: [site.linkedin],
|
||||||
|
knowsLanguage: ["de", "en"],
|
||||||
|
knowsAbout: content.services.rows.flatMap((row) => [...row.keywords]),
|
||||||
|
mainEntityOfPage: `${site.url}${routesByLang.de.home}`,
|
||||||
|
},
|
||||||
|
...content.services.rows.map((row) => ({
|
||||||
|
"@type": "Service",
|
||||||
|
"@id": `${site.url}${row.link.href}#service`,
|
||||||
|
name: row.title,
|
||||||
|
description: row.oneLiner,
|
||||||
|
serviceType: [...row.keywords],
|
||||||
|
url: `${site.url}${row.link.href}`,
|
||||||
|
provider: { "@id": PERSON_ID },
|
||||||
|
availableLanguage: ["de", "en"],
|
||||||
|
areaServed: { "@type": "Country", name: "DE" },
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HomeBody;
|
||||||
124
src/components/pages/KeynotesBody.tsx
Normal file
124
src/components/pages/KeynotesBody.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { ServiceHero } from "@/components/services/ServiceHero";
|
||||||
|
import { ServiceLayout } from "@/components/services/ServiceLayout";
|
||||||
|
import { Occasions } from "@/components/services/keynotes/Occasions";
|
||||||
|
import { SectionHeading } from "@/components/services/SectionHeading";
|
||||||
|
import { SignatureTalk, type TalkLabels } from "@/components/services/keynotes/SignatureTalk";
|
||||||
|
import { SpeakerProfile } from "@/components/services/keynotes/SpeakerProfile";
|
||||||
|
import { CtaBand } from "@/components/ui/CtaBand";
|
||||||
|
import { FactsStrip } from "@/components/ui/FactsStrip";
|
||||||
|
import { Faq } from "@/components/ui/Faq";
|
||||||
|
import { Interruption } from "@/components/ui/Interruption";
|
||||||
|
import { Ledger } from "@/components/ui/Ledger";
|
||||||
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
|
import { talkLabels as talkLabelsDe, type KeynotesContent } from "@/content/keynotes";
|
||||||
|
import { getPhotos, getSpeakerPdf } from "@/lib/assets";
|
||||||
|
import { flags } from "@/lib/site";
|
||||||
|
|
||||||
|
/** Content column of every light section: cols 3–12 on desktop. */
|
||||||
|
const BODY = "col-span-12 lg:col-span-10 lg:col-start-3";
|
||||||
|
/** Heading column: cols 3–8. */
|
||||||
|
const HEAD = "col-span-12 lg:col-span-6 lg:col-start-3";
|
||||||
|
/** FAQ ledger: cols 3–10 – identical to /coaching/ and /trainings/ so the „+“ sits at one x. */
|
||||||
|
const FAQ_COL = "col-span-12 lg:col-span-8 lg:col-start-3";
|
||||||
|
|
||||||
|
export interface KeynotesBodyProps {
|
||||||
|
content: KeynotesContent;
|
||||||
|
/** Zeilenbeschriftungen im Vortragsblock – in der Sprache der Seite. */
|
||||||
|
talkLabels?: TalkLabels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Rumpf von /leistungen/keynotes/ bzw. /en/services/keynotes/ (Spec §7.1 + §7.4,
|
||||||
|
* „die Bühnenseite“). Server Component: Foto und PDF werden zur Bauzeit über lib/assets
|
||||||
|
* geprüft. Kein <main> – template.tsx rendert `main#inhalt`.
|
||||||
|
*
|
||||||
|
* Reihenfolge: Hero (ink, optionales Bühnenfoto) → 01 Der Vortrag → Interruption (ink, auf
|
||||||
|
* oberster Ebene, damit sie ihren eigenen Grund malt) → 02 Anlässe → 03 Publikum →
|
||||||
|
* 04 Für Veranstalter (Speaker-Facts, Termine, PDF-Zeile nur bei gelieferter Datei) →
|
||||||
|
* 05 FAQ (Flag) → Kontakt-Band (ink).
|
||||||
|
*/
|
||||||
|
export function KeynotesBody({ content: keynotes, talkLabels = talkLabelsDe }: KeynotesBodyProps) {
|
||||||
|
const { stage } = getPhotos();
|
||||||
|
const speakerPdf = flags.showSpeakerPdf ? getSpeakerPdf() : null;
|
||||||
|
const showFaq = flags.showFaq && keynotes.faq.items.length > 0;
|
||||||
|
const total = showFaq ? "05" : "04";
|
||||||
|
|
||||||
|
const { talk, interruption, occasions, takeaways, speakerFacts, termine, veranstalter, faq, band } = keynotes;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* One-line H1: without the extra height the folio floats mid-viewport and the
|
||||||
|
next section's sticky marginal peeks in at scrollY 0. */}
|
||||||
|
<ServiceHero content={keynotes} photo={stage} className="lg:min-h-svh" />
|
||||||
|
|
||||||
|
<ServiceLayout>
|
||||||
|
<Section id={talk.id} theme="light" numeral="01" total={total} label={talk.eyebrow}>
|
||||||
|
<SectionHeading eyebrow={talk.eyebrow} h2={talk.h2} className={HEAD} />
|
||||||
|
<div className={`${BODY} mt-16`}>
|
||||||
|
<SignatureTalk rows={talk.rows} labels={talkLabels} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</ServiceLayout>
|
||||||
|
|
||||||
|
{/* 100 svh is a stage on desktop; on a phone it would be ~70 % empty. */}
|
||||||
|
<Interruption
|
||||||
|
id={interruption.id}
|
||||||
|
text={interruption.text}
|
||||||
|
mono={interruption.mono}
|
||||||
|
className="min-h-[70svh] lg:min-h-svh"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ServiceLayout>
|
||||||
|
<Section id={occasions.id} theme="light" numeral="02" total={total} label={occasions.eyebrow}>
|
||||||
|
<SectionHeading eyebrow={occasions.eyebrow} h2={occasions.h2} className={HEAD} />
|
||||||
|
<div className={`${BODY} mt-16`}>
|
||||||
|
<Occasions items={occasions.columns} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section id={takeaways.id} theme="light" numeral="03" total={total} label={takeaways.eyebrow}>
|
||||||
|
<SectionHeading eyebrow={takeaways.eyebrow} h2={takeaways.h2} className={HEAD} />
|
||||||
|
<div className={`${BODY} mt-16`}>
|
||||||
|
<Ledger variant="goals" rows={takeaways.rows} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section id={veranstalter.id} theme="light" numeral="04" total={total} label={veranstalter.eyebrow}>
|
||||||
|
<SectionHeading eyebrow={veranstalter.eyebrow} h2={veranstalter.h2} className={HEAD} />
|
||||||
|
<Reveal className={`${BODY} mt-16`}>
|
||||||
|
<div id={speakerFacts.id}>
|
||||||
|
<FactsStrip items={speakerFacts.items} />
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
<div id={termine.id} className={`${BODY} mt-16`}>
|
||||||
|
<Ledger variant="termine" rows={termine.rows} heading={termine.heading} />
|
||||||
|
</div>
|
||||||
|
{/* Spec §7.4: die Zeile erscheint nur, wenn die PDF-Datei geliefert wurde. */}
|
||||||
|
{speakerPdf && (
|
||||||
|
<div className={`${BODY} mt-16`}>
|
||||||
|
<SpeakerProfile
|
||||||
|
label={veranstalter.speakerPdf.label}
|
||||||
|
text={veranstalter.speakerPdf.text}
|
||||||
|
href={speakerPdf}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{showFaq && (
|
||||||
|
<Section id={faq.id} theme="light" numeral="05" total={total} label={faq.eyebrow}>
|
||||||
|
<SectionHeading eyebrow={faq.eyebrow} className={HEAD} />
|
||||||
|
<Reveal className={`${FAQ_COL} mt-12`}>
|
||||||
|
<Faq items={faq.items} />
|
||||||
|
</Reveal>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</ServiceLayout>
|
||||||
|
|
||||||
|
<CtaBand h2={band.h2} cta={band.cta} micro={band.micro} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default KeynotesBody;
|
||||||
57
src/components/pages/NotFoundBody.tsx
Normal file
57
src/components/pages/NotFoundBody.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { BrandMark } from "@/components/brand/BrandMark";
|
||||||
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { TextLink } from "@/components/ui/TextLink";
|
||||||
|
import { notFound as notFoundDe, type NotFoundContent } from "@/content/not-found";
|
||||||
|
import { notFoundEn } from "@/content/en/not-found";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 404 (Spec §8 / §5.5): ink, Monogramm 40vh, eine Display-Zeile mit genau einem kursiven
|
||||||
|
* Wort, kurzer Text, Textlink zur Startseite. Das Monogramm steht vollständig – warum die
|
||||||
|
* „wolflose“ Variante der Spec mit dem echten Logo nicht funktioniert, steht in
|
||||||
|
* src/content/not-found.ts.
|
||||||
|
*
|
||||||
|
* Zweisprachig, und zwar aus einem technischen Grund: Ein statischer Export kennt genau
|
||||||
|
* EINE Fehlerseite (`out/404.html`). Wer sich auf einer englischen Adresse vertippt,
|
||||||
|
* bekommt also dieselbe Datei. Deshalb steht unter dem deutschen Text ein englischer
|
||||||
|
* Zweizeiler – in `lang="en"` ausgezeichnet, damit ein Screenreader die Sprache wechselt.
|
||||||
|
*/
|
||||||
|
export function NotFoundBody({ content = notFoundDe }: { content?: NotFoundContent } = {}) {
|
||||||
|
const { h1 } = content;
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
id="nicht-gefunden"
|
||||||
|
theme="dark"
|
||||||
|
className="flex min-h-svh flex-col justify-center pt-[calc(var(--header-h,7.5rem)+1.5rem)]"
|
||||||
|
>
|
||||||
|
<div className="col-span-12 lg:col-span-8 lg:col-start-3">
|
||||||
|
{/* Spec A/D6: on phones the mark is sized by WIDTH (40vh would be ~422 px wide in a
|
||||||
|
390 px viewport and widen the document); from `sm` it goes back to 40vh height. */}
|
||||||
|
<BrandMark
|
||||||
|
idPrefix="nf"
|
||||||
|
title={content.markTitle}
|
||||||
|
className="h-auto w-full max-w-[min(100%,52vh)] text-fg sm:h-[40vh] sm:w-auto sm:max-w-full"
|
||||||
|
/>
|
||||||
|
<h1 className="mt-12 max-w-[14ch] font-display text-[clamp(2.75rem,1.4rem+5.4vw,7rem)] font-normal leading-[0.96] tracking-[-0.015em] text-fg [text-wrap:balance]">
|
||||||
|
{h1.before}
|
||||||
|
<em className="italic">{h1.em}</em>
|
||||||
|
{h1.after}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-8 max-w-[40ch] font-sans text-xl font-light leading-[1.45] text-fg-muted">{content.text}</p>
|
||||||
|
<div className="mt-10">
|
||||||
|
<TextLink href={content.link.href} label={content.link.label} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Der englische Hinweis – kleiner gesetzt, damit er als Fußnote liest, nicht als
|
||||||
|
zweite Hälfte der Seite. Die goldene Haarlinie trennt die Sprachen. */}
|
||||||
|
<div lang="en" className="mt-14 border-t border-line pt-8">
|
||||||
|
<p className="max-w-[46ch] font-sans text-base font-light leading-[1.5] text-grey-warm">{notFoundEn.text}</p>
|
||||||
|
<div className="mt-6">
|
||||||
|
<TextLink href={notFoundEn.link.href} label={notFoundEn.link.label} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotFoundBody;
|
||||||
64
src/components/pages/TrainingsBody.tsx
Normal file
64
src/components/pages/TrainingsBody.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Section } from "@/components/layout/Section";
|
||||||
|
import { ServiceHero } from "@/components/services/ServiceHero";
|
||||||
|
import { ServiceLayout } from "@/components/services/ServiceLayout";
|
||||||
|
import { TrainingsSituations } from "@/components/services/trainings/TrainingsSituations";
|
||||||
|
import { TrainingsModules } from "@/components/services/trainings/TrainingsModules";
|
||||||
|
import { TrainingsMethod } from "@/components/services/trainings/TrainingsMethod";
|
||||||
|
import { TrainingsGoals } from "@/components/services/trainings/TrainingsGoals";
|
||||||
|
import { TrainingsRahmen } from "@/components/services/trainings/TrainingsRahmen";
|
||||||
|
import { TrainingsAblauf } from "@/components/services/trainings/TrainingsAblauf";
|
||||||
|
import { TrainingsFaq } from "@/components/services/trainings/TrainingsFaq";
|
||||||
|
import { TrainingsBand } from "@/components/services/trainings/TrainingsBand";
|
||||||
|
import type { TrainingsContent } from "@/content/trainings";
|
||||||
|
import type { HomeContent } from "@/content/home";
|
||||||
|
import { flags } from "@/lib/site";
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
|
export interface TrainingsBodyProps {
|
||||||
|
content: TrainingsContent;
|
||||||
|
/** „Über Anelia“-Randspalte im Abschnitt „Wie ich arbeite“ – in der Sprache der Seite. */
|
||||||
|
marginalia: HomeContent["about"]["marginalia"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Rumpf von /leistungen/trainings/ bzw. /en/services/trainings/ (Spec §7.1 + §7.3).
|
||||||
|
* Die „Angebots“-Seite, am wenigsten Bewegung: nur <Rule/>, <Lines/>, <Reveal/> – kein Pin,
|
||||||
|
* kein Draw, kein Blur, kein Foto (das Bühnenfoto gehört zu den Keynotes, §7.1).
|
||||||
|
* Kein <main>: template.tsx liefert `main#inhalt`.
|
||||||
|
*/
|
||||||
|
export function TrainingsBody({ content: trainings, marginalia }: TrainingsBodyProps) {
|
||||||
|
/* Helle Abschnitte in Lesereihenfolge; FAQ nur mit `flags.showFaq` (Spec §7.1.6). */
|
||||||
|
const sections = [
|
||||||
|
{ id: trainings.situations.id, label: trainings.situations.eyebrow, body: <TrainingsSituations content={trainings} /> },
|
||||||
|
{ id: trainings.modules.id, label: trainings.modules.eyebrow, body: <TrainingsModules content={trainings} /> },
|
||||||
|
{
|
||||||
|
id: trainings.how.id,
|
||||||
|
label: trainings.how.eyebrow,
|
||||||
|
body: <TrainingsMethod content={trainings} marginalia={marginalia} />,
|
||||||
|
},
|
||||||
|
{ id: trainings.goals.id, label: trainings.goals.eyebrow, body: <TrainingsGoals content={trainings} /> },
|
||||||
|
{ id: trainings.rahmen.id, label: trainings.rahmen.eyebrow, body: <TrainingsRahmen content={trainings} /> },
|
||||||
|
{ id: trainings.ablauf.id, label: trainings.ablauf.eyebrow, body: <TrainingsAblauf content={trainings} /> },
|
||||||
|
...(flags.showFaq && trainings.faq.items.length > 0
|
||||||
|
? [{ id: trainings.faq.id, label: trainings.faq.eyebrow, body: <TrainingsFaq content={trainings} /> }]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
const total = pad(sections.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ServiceHero content={trainings} />
|
||||||
|
<ServiceLayout>
|
||||||
|
{sections.map((s, i) => (
|
||||||
|
<Section key={s.id} id={s.id} theme="light" numeral={pad(i + 1)} total={total} label={s.label}>
|
||||||
|
{s.body}
|
||||||
|
</Section>
|
||||||
|
))}
|
||||||
|
</ServiceLayout>
|
||||||
|
<TrainingsBand content={trainings} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrainingsBody;
|
||||||
52
src/components/providers/SmoothScroll.tsx
Normal file
52
src/components/providers/SmoothScroll.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import Lenis from "lenis";
|
||||||
|
import gsap from "gsap";
|
||||||
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lenis smooth scrolling, driven by GSAP's ticker so ScrollTrigger and Lenis
|
||||||
|
* share one clock (no jitter, no double rAF). Disabled for users who prefer
|
||||||
|
* reduced motion and on coarse pointers (touch), where native scrolling feels best.
|
||||||
|
*/
|
||||||
|
export function SmoothScroll({ children }: { children: React.ReactNode }) {
|
||||||
|
useEffect(() => {
|
||||||
|
/* The bundle booted, so the reveal states will be cleared by GSAP: cancel the
|
||||||
|
2.5 s un-hide failsafe the inline script in layout.tsx armed. */
|
||||||
|
const w = window as unknown as { __unhide?: number };
|
||||||
|
if (w.__unhide) {
|
||||||
|
clearTimeout(w.__unhide);
|
||||||
|
delete w.__unhide;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
const touch = window.matchMedia("(pointer: coarse)").matches;
|
||||||
|
if (reduce || touch) return;
|
||||||
|
|
||||||
|
const lenis = new Lenis({
|
||||||
|
autoRaf: false,
|
||||||
|
lerp: 0.09,
|
||||||
|
wheelMultiplier: 0.9,
|
||||||
|
smoothWheel: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
lenis.on("scroll", ScrollTrigger.update);
|
||||||
|
const tick = (time: number) => lenis.raf(time * 1000);
|
||||||
|
gsap.ticker.add(tick);
|
||||||
|
gsap.ticker.lagSmoothing(0);
|
||||||
|
|
||||||
|
// Expose for anchor links / programmatic scroll
|
||||||
|
(window as unknown as { lenis?: Lenis }).lenis = lenis;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
gsap.ticker.remove(tick);
|
||||||
|
lenis.destroy();
|
||||||
|
delete (window as unknown as { lenis?: Lenis }).lenis;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
50
src/components/services/SectionHeading.tsx
Normal file
50
src/components/services/SectionHeading.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Lines } from "@/components/motion/Lines";
|
||||||
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
|
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||||
|
import { HeadlineText } from "@/components/ui/CtaBand";
|
||||||
|
import type { Headline } from "@/content/home";
|
||||||
|
|
||||||
|
/** H2 on ivory: Bodoni 400, 36 → 76 px, lh 1.02, max 16 ch (Spec §3.2). */
|
||||||
|
export const H2_LIGHT =
|
||||||
|
"max-w-[16ch] font-display text-[clamp(2.25rem,1.4rem+3.2vw,4.75rem)] font-normal leading-[1.02] tracking-[-0.01em] text-fg [text-wrap:balance]";
|
||||||
|
|
||||||
|
export interface SectionHeadingProps {
|
||||||
|
eyebrow: string;
|
||||||
|
/** With an H2 the eyebrow is a `<p>`; without, the eyebrow itself is the section's `<h2>`. */
|
||||||
|
h2?: Headline;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The head of a light service section – ONE component for all three service
|
||||||
|
* pages (PLAN §2.10 reserves src/components/services/ for exactly this).
|
||||||
|
* The three per-service copies had drifted into two different H2 clamps, two
|
||||||
|
* different reveal behaviours and, worse, two of them dropped the heading
|
||||||
|
* entirely when no H2 was supplied: 5 of 7 trainings chapters and 3 of 6
|
||||||
|
* coaching chapters shipped with no `<h2>` at all, so heading navigation
|
||||||
|
* skipped them.
|
||||||
|
*
|
||||||
|
* Placement (`col-span-*` / `col-start-*`) comes from the caller.
|
||||||
|
*/
|
||||||
|
export function SectionHeading({ eyebrow, h2, className }: SectionHeadingProps) {
|
||||||
|
if (!h2) {
|
||||||
|
return (
|
||||||
|
<Reveal className={className}>
|
||||||
|
<Eyebrow as="h2">{eyebrow}</Eyebrow>
|
||||||
|
</Reveal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col items-start gap-8", className)}>
|
||||||
|
<Reveal>
|
||||||
|
<Eyebrow>{eyebrow}</Eyebrow>
|
||||||
|
</Reveal>
|
||||||
|
<Lines as="h2" className={H2_LIGHT}>
|
||||||
|
<HeadlineText {...h2} />
|
||||||
|
</Lines>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SectionHeading;
|
||||||
117
src/components/services/ServiceHero.tsx
Normal file
117
src/components/services/ServiceHero.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Container } from "@/components/layout/Container";
|
||||||
|
import { Folio } from "@/components/layout/Folio";
|
||||||
|
import { GRID } from "@/components/layout/Section";
|
||||||
|
import { Lines } from "@/components/motion/Lines";
|
||||||
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
|
import type { Cta, FactItem, Headline } from "@/content/home";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Facts } from "@/components/ui/Facts";
|
||||||
|
import { Portrait } from "@/components/ui/Portrait";
|
||||||
|
|
||||||
|
/** The slice of `coaching` / `trainings` / `keynotes` the hero needs (structural). */
|
||||||
|
export interface ServiceHeroContent {
|
||||||
|
/** „01“ / „02“ / „03“ – mono, cols 1–2. */
|
||||||
|
numeral: string;
|
||||||
|
masthead: { center: string };
|
||||||
|
eyebrow: string;
|
||||||
|
h1: Headline;
|
||||||
|
/** The italic word may also carry `.gold-text` (keynotes – the page's one gradient word). */
|
||||||
|
h1GoldEm?: boolean;
|
||||||
|
subline: string;
|
||||||
|
cta: Cta;
|
||||||
|
facts: readonly FactItem[];
|
||||||
|
/** No fraction here – the page's own section counter is the only counter (see <Folio/>). */
|
||||||
|
folio: { next?: { label: string; href: string } };
|
||||||
|
/** Photo alt / caption (keynote stage photo). */
|
||||||
|
alt?: string;
|
||||||
|
caption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceHeroProps {
|
||||||
|
content: ServiceHeroContent;
|
||||||
|
/** Stage photo (keynotes) – `null`/undefined = no photo. */
|
||||||
|
photo?: string | null;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service-page hero (ink, ≥ 60svh): mono numeral, H1 with one italic word,
|
||||||
|
* subline, the page's primary CTA, facts stack top-right, folio with the next
|
||||||
|
* section. Header monogram only – no hero draw.
|
||||||
|
*/
|
||||||
|
export function ServiceHero({ content, photo, 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. */
|
||||||
|
const { numeral, masthead, h1, h1GoldEm, subline, cta, facts, folio, alt, caption } = content;
|
||||||
|
const hasPhoto = Boolean(photo);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
data-theme="dark"
|
||||||
|
className={cn(
|
||||||
|
"relative flex min-h-[60svh] flex-col bg-transparent pt-[calc(var(--header-h,7.5rem)+1.5rem)] text-fg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Container className="flex flex-1 flex-col">
|
||||||
|
<div className={cn(GRID, "flex-1 gap-y-12 py-14 lg:py-20")}>
|
||||||
|
<Reveal
|
||||||
|
play="load"
|
||||||
|
delay={0.3}
|
||||||
|
as="p"
|
||||||
|
className="hidden font-mono text-xs tracking-[0.06em] tabular-nums text-fg-muted lg:col-span-2 lg:block lg:pt-1"
|
||||||
|
>
|
||||||
|
{numeral}
|
||||||
|
</Reveal>
|
||||||
|
|
||||||
|
<div className={cn("col-span-12 flex flex-col lg:col-start-3", hasPhoto ? "lg:col-span-5" : "lg:col-span-7")}>
|
||||||
|
<Lines
|
||||||
|
as="h1"
|
||||||
|
play="load"
|
||||||
|
delay={0.35}
|
||||||
|
className="max-w-[14ch] font-display text-[clamp(2.75rem,1.4rem+5.4vw,7rem)] font-normal leading-[0.96] tracking-[-0.015em] text-ivory [text-wrap:balance]"
|
||||||
|
>
|
||||||
|
{h1.before}
|
||||||
|
{h1.em && <em className={cn("italic", h1GoldEm && "gold-text")}>{h1.em}</em>}
|
||||||
|
{h1.after}
|
||||||
|
</Lines>
|
||||||
|
<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">
|
||||||
|
{subline}
|
||||||
|
</Reveal>
|
||||||
|
<Reveal play="load" delay={1.2} className="mt-8">
|
||||||
|
<Button {...cta} />
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"col-span-12 flex flex-col gap-10",
|
||||||
|
hasPhoto ? "lg:col-span-5 lg:col-start-8" : "lg:col-span-3 lg:col-start-10",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Reveal play="load" delay={0.6} className={cn(hasPhoto && "lg:self-end")}>
|
||||||
|
<Facts items={facts} />
|
||||||
|
</Reveal>
|
||||||
|
{photo && (
|
||||||
|
<Portrait
|
||||||
|
src={photo}
|
||||||
|
alt={alt ?? masthead.center}
|
||||||
|
aspect="16/10"
|
||||||
|
caption={caption}
|
||||||
|
priority
|
||||||
|
frame={false}
|
||||||
|
sizes="(min-width: 1024px) 40vw, 100vw"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Folio next={folio.next} play="load" delay={1.3} />
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ServiceHero;
|
||||||
26
src/components/services/ServiceLayout.tsx
Normal file
26
src/components/services/ServiceLayout.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Rule } from "@/components/motion/Rule";
|
||||||
|
|
||||||
|
export interface ServiceLayoutProps {
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Light body of a service page: the ink→ivory boundary hairline, then the
|
||||||
|
* content sections (`<Section theme="light">` each: Ausgangslage, Bausteine,
|
||||||
|
* Rahmen, Ablauf, FAQ …). The dark contact band (`<CtaBand/>`) sits outside,
|
||||||
|
* after this wrapper. Paints its own ivory ground; the seam to the ink hero
|
||||||
|
* above is the gold hairline alone (spec §2.3) – no fade, no gradient.
|
||||||
|
*/
|
||||||
|
export function ServiceLayout({ className, children }: ServiceLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div data-theme="light" className={cn("relative text-fg", className)}>
|
||||||
|
<Rule variant="gold" className="relative z-[1]" />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ServiceLayout;
|
||||||
100
src/components/services/coaching/ThreeQuestions.tsx
Normal file
100
src/components/services/coaching/ThreeQuestions.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Lines } from "@/components/motion/Lines";
|
||||||
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
|
import { Eyebrow } from "@/components/ui/Eyebrow";
|
||||||
|
import { Portrait } from "@/components/ui/Portrait";
|
||||||
|
|
||||||
|
export interface QuestionRow {
|
||||||
|
q: string;
|
||||||
|
a: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThreeQuestionsProps {
|
||||||
|
eyebrow: string;
|
||||||
|
rows: readonly QuestionRow[];
|
||||||
|
/** Portrait (4:5, 240 px, sticky) – `null` = documented variant B without photo. */
|
||||||
|
photo?: string | null;
|
||||||
|
alt: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gutter identical to the page grid so the inner rows align with cols 3–12. */
|
||||||
|
const GUTTER = "gap-x-[clamp(16px,2vw,32px)]";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Die drei Fragen“ (Spec §7.2): three double rows. Left the question in
|
||||||
|
* Bodoni ≥ 40 px, right the answer as a Jost 300 lead – both start on the
|
||||||
|
* same optical line so the pair reads as one unit. Variant A (photo
|
||||||
|
* delivered) keeps the tall row and the sticky question next to the sticky
|
||||||
|
* portrait in cols 10–12; variant B (no photo) is content-tall and static.
|
||||||
|
* Renders inside a `<Section/>` grid – every child places itself.
|
||||||
|
*/
|
||||||
|
export function ThreeQuestions({ eyebrow, rows, photo, alt, className }: ThreeQuestionsProps) {
|
||||||
|
const hasPhoto = Boolean(photo);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* The eyebrow IS the section's heading: the three questions below are `<h3>`, and
|
||||||
|
without an `<h2>` of their own they were reported as sub-topics of the previous
|
||||||
|
chapter („Drei Situationen.“) – a heading-level skip across a section boundary. */}
|
||||||
|
<div className={cn("col-span-12 lg:col-span-8 lg:col-start-3", className)}>
|
||||||
|
<Reveal>
|
||||||
|
<Eyebrow as="h2">{eyebrow}</Eyebrow>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn("col-span-12 mt-10 lg:col-start-3 lg:mt-14", hasPhoto ? "lg:col-span-7" : "lg:col-span-8")}>
|
||||||
|
<ol className="border-t border-line">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<li
|
||||||
|
key={row.q}
|
||||||
|
className={cn(
|
||||||
|
"grid grid-cols-1 gap-y-6 border-b border-line",
|
||||||
|
GUTTER,
|
||||||
|
/* With the photo the row stays generous (the portrait needs the height); without it
|
||||||
|
the row is only as tall as its content, so question and answer stay side by side. */
|
||||||
|
hasPhoto
|
||||||
|
? "py-[clamp(2.5rem,7vh,5.5rem)] lg:min-h-[50vh] lg:grid-cols-[4fr_3fr]"
|
||||||
|
: "py-[clamp(2rem,4.5vh,3.5rem)] lg:grid-cols-[5fr_5fr]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Sticky only in variant A: the portrait keeps that row tall. Without the photo the
|
||||||
|
row is only as tall as its content, so a sticky question could just drift away
|
||||||
|
from the answer it belongs to. */}
|
||||||
|
<div className={cn(hasPhoto && "lg:sticky lg:top-[calc(var(--header-h,7.5rem)+2rem)] lg:self-start")}>
|
||||||
|
<Lines
|
||||||
|
as="h3"
|
||||||
|
className="font-display text-[clamp(2.5rem,1.4rem+3.6vw,5rem)] font-normal leading-[1.02] tracking-[-0.01em] text-fg [text-wrap:balance]"
|
||||||
|
>
|
||||||
|
{row.q}
|
||||||
|
</Lines>
|
||||||
|
</div>
|
||||||
|
<Reveal
|
||||||
|
as="p"
|
||||||
|
delay={0.08 * (i % 3)}
|
||||||
|
className="max-w-[38ch] font-sans text-[clamp(1.25rem,1.1rem+0.6vw,1.5rem)] font-light leading-[1.45] text-fg lg:pt-[0.55em]"
|
||||||
|
>
|
||||||
|
{row.a}
|
||||||
|
</Reveal>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{photo && (
|
||||||
|
<div className="col-span-12 mt-12 lg:col-span-3 lg:col-start-10 lg:mt-14">
|
||||||
|
<Portrait
|
||||||
|
src={photo}
|
||||||
|
alt={alt}
|
||||||
|
aspect="4/5"
|
||||||
|
sticky
|
||||||
|
sizes="(min-width: 1024px) 240px, 60vw"
|
||||||
|
className="max-w-[240px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThreeQuestions;
|
||||||
45
src/components/services/keynotes/Occasions.tsx
Normal file
45
src/components/services/keynotes/Occasions.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { RevealGroup, RevealItem } from "@/components/motion/Reveal";
|
||||||
|
import type { ColumnItem } from "@/content/home";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Anlässe“ (Keynotes §7.4): drei gleichwertige Bodoni-Titel, jeder über einer
|
||||||
|
* Haarlinie, darunter je drei Beispielzeilen in Jost 17 px zwischen Haarlinien –
|
||||||
|
* ohne diesen Körper stand die Spalte als Kopfzeile über leerem Elfenbein (QA R2).
|
||||||
|
*
|
||||||
|
* Keine Versatz-Asymmetrie wie in `<Columns/>`: die drei Spalten sind hier
|
||||||
|
* gleich lang, ein 48-px-Versatz läse als Fehler.
|
||||||
|
*/
|
||||||
|
export interface OccasionsProps {
|
||||||
|
items: readonly ColumnItem[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Occasions({ items, className }: OccasionsProps) {
|
||||||
|
return (
|
||||||
|
<RevealGroup stagger={0.12} className={cn("grid grid-cols-1 gap-x-12 gap-y-12 lg:grid-cols-3", className)}>
|
||||||
|
{items.map((col) => (
|
||||||
|
<RevealItem key={col.title} className="flex flex-col border-t border-line pt-6">
|
||||||
|
{col.label && <p className="mb-4 font-mono text-xs tracking-[0.06em] text-accent">{col.label}</p>}
|
||||||
|
<h3 className="max-w-[16ch] font-display text-[1.75rem] font-normal leading-[1.15] text-fg [text-wrap:balance]">
|
||||||
|
{col.title}
|
||||||
|
</h3>
|
||||||
|
{col.lines && col.lines.length > 0 && (
|
||||||
|
<ul className="mt-8">
|
||||||
|
{col.lines.map((line) => (
|
||||||
|
<li
|
||||||
|
key={line}
|
||||||
|
className="border-b border-line py-3 font-sans text-[1.0625rem] leading-snug text-fg-muted"
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</RevealItem>
|
||||||
|
))}
|
||||||
|
</RevealGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Occasions;
|
||||||
56
src/components/services/keynotes/SignatureTalk.tsx
Normal file
56
src/components/services/keynotes/SignatureTalk.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { RevealGroup, RevealItem } from "@/components/motion/Reveal";
|
||||||
|
import { MetaLines } from "@/components/ui/MetaLines";
|
||||||
|
import { talkLabels as talkLabelsDe, type TalkRow } from "@/content/keynotes";
|
||||||
|
|
||||||
|
const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums";
|
||||||
|
|
||||||
|
/** Die beiden Zeilenbeschriftungen – in der Sprache der Seite. */
|
||||||
|
export type TalkLabels = { message: string; audience: string };
|
||||||
|
|
||||||
|
export interface SignatureTalkProps {
|
||||||
|
labels?: TalkLabels;
|
||||||
|
rows: readonly TalkRow[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signature-Talk ledger (replaces Ausgangslage on the keynote page): per talk
|
||||||
|
* one hairline row cols [numeral 1] [title Bodoni 4] [Kernbotschaft / Für wen 5]
|
||||||
|
* [meta mono 2]. One row today; further talks are flag-gated in content.
|
||||||
|
*/
|
||||||
|
export function SignatureTalk({ rows, className, labels = talkLabelsDe }: SignatureTalkProps) {
|
||||||
|
return (
|
||||||
|
<RevealGroup as="ol" stagger={0.1} className={cn("border-t border-line", className)}>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<RevealItem
|
||||||
|
key={row.numeral}
|
||||||
|
as="li"
|
||||||
|
className="grid grid-cols-1 gap-y-6 border-b border-line py-8 lg:grid-cols-12 lg:gap-x-[clamp(16px,2vw,32px)] lg:py-12"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline gap-4 lg:contents">
|
||||||
|
<span className={cn(MONO, "text-accent lg:col-span-1 lg:pt-[0.6em]")}>{row.numeral}</span>
|
||||||
|
<h3 className="font-display text-[clamp(1.75rem,1.4rem+1.2vw,2.5rem)] font-normal leading-[1.1] text-fg lg:col-span-5 lg:pr-6">
|
||||||
|
{row.title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<dl className="flex flex-col gap-5 lg:col-span-4">
|
||||||
|
<div>
|
||||||
|
<dt className={cn(MONO, "mb-2 text-accent")}>{labels.message}</dt>
|
||||||
|
<dd className="max-w-[36rem] font-sans text-[1.0625rem] leading-[1.6] text-fg">{row.message}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className={cn(MONO, "mb-2 text-accent")}>{labels.audience}</dt>
|
||||||
|
<dd className="max-w-[36rem] font-sans text-[1.0625rem] leading-[1.6] text-fg">{row.audience}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<p className={cn(MONO, "text-fg-muted lg:col-span-2 lg:pt-[0.6em] lg:text-right")}>
|
||||||
|
<MetaLines text={row.meta} />
|
||||||
|
</p>
|
||||||
|
</RevealItem>
|
||||||
|
))}
|
||||||
|
</RevealGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SignatureTalk;
|
||||||
40
src/components/services/keynotes/SpeakerProfile.tsx
Normal file
40
src/components/services/keynotes/SpeakerProfile.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
|
import { TextLink } from "@/components/ui/TextLink";
|
||||||
|
|
||||||
|
const MONO = "font-mono text-xs tracking-[0.06em] tabular-nums";
|
||||||
|
|
||||||
|
export interface SpeakerProfileProps {
|
||||||
|
/** „Speaker-Profil (PDF)“ */
|
||||||
|
label: string;
|
||||||
|
/** „Kurzvita, Themen, Fotos, Technik“ */
|
||||||
|
text: string;
|
||||||
|
/** Public URL of the PDF – `null` = not delivered → plain text row, no link. */
|
||||||
|
href: string | null;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Für Veranstalter“ row: one hairline row [mono label] [Jost text]. With the
|
||||||
|
* PDF delivered (`flags.showSpeakerPdf` + file in /public) the label becomes
|
||||||
|
* an external text link (↗); otherwise the sentence stands as plain text.
|
||||||
|
*/
|
||||||
|
export function SpeakerProfile({ label, text, href, className }: SpeakerProfileProps) {
|
||||||
|
return (
|
||||||
|
<Reveal
|
||||||
|
className={cn(
|
||||||
|
"grid grid-cols-1 gap-y-2 border-y border-line py-6 sm:grid-cols-4 sm:items-baseline sm:gap-x-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{href ? (
|
||||||
|
<TextLink href={href} label={label} external className="sm:col-span-1" />
|
||||||
|
) : (
|
||||||
|
<p className={cn(MONO, "text-accent sm:col-span-1")}>{label}</p>
|
||||||
|
)}
|
||||||
|
<p className="font-sans text-[1.0625rem] leading-snug text-fg sm:col-span-3">{text}</p>
|
||||||
|
</Reveal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SpeakerProfile;
|
||||||
29
src/components/services/trainings/TrainingsAblauf.tsx
Normal file
29
src/components/services/trainings/TrainingsAblauf.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
|
import { Marginalia } from "@/components/ui/Marginalia";
|
||||||
|
import { Steps } from "@/components/ui/Steps";
|
||||||
|
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
|
||||||
|
import { SectionHeading } from "@/components/services/SectionHeading";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ablauf – four numbered rows (Bedarfsgespräch → Follow-up nach 30 Tagen),
|
||||||
|
* cols 3–8, plus the „Was du bekommst“ marginalia in cols 10–12 (sticky on
|
||||||
|
* desktop) so the spread reads like coaching’s Ablauf.
|
||||||
|
*/
|
||||||
|
export function TrainingsAblauf({ content = trainingsDe }: { content?: TrainingsContent }) {
|
||||||
|
const { eyebrow, steps, marginalia } = content.ablauf;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeading eyebrow={eyebrow} className="col-span-12 lg:col-span-6 lg:col-start-3" />
|
||||||
|
<div className="col-span-12 mt-10 lg:col-span-6 lg:col-start-3">
|
||||||
|
<Steps items={steps} />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-12 mt-12 lg:col-span-3 lg:col-start-10 lg:mt-10">
|
||||||
|
<Reveal delay={0.2}>
|
||||||
|
<Marginalia title={marginalia.title} items={marginalia.items} sticky />
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrainingsAblauf;
|
||||||
15
src/components/services/trainings/TrainingsBand.tsx
Normal file
15
src/components/services/trainings/TrainingsBand.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { CtaBand } from "@/components/ui/CtaBand";
|
||||||
|
import { site } from "@/lib/site";
|
||||||
|
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kontakt band „Sprechen wir über dein Team.“ – same primary CTA as the hero.
|
||||||
|
* Phone prominent: the number sits in the CTA micro line, in addition to the
|
||||||
|
* large „Ruf an“ tel link that <ContactLinks/> renders on the right.
|
||||||
|
*/
|
||||||
|
export function TrainingsBand({ content = trainingsDe }: { content?: TrainingsContent }) {
|
||||||
|
const { h2, cta, micro } = content.band;
|
||||||
|
return <CtaBand h2={h2} cta={cta} micro={`${micro} · ${site.phoneDisplay}`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrainingsBand;
|
||||||
19
src/components/services/trainings/TrainingsFaq.tsx
Normal file
19
src/components/services/trainings/TrainingsFaq.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
|
import { Faq } from "@/components/ui/Faq";
|
||||||
|
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
|
||||||
|
import { SectionHeading } from "@/components/services/SectionHeading";
|
||||||
|
|
||||||
|
/** FAQ – native `<details>` ledger incl. the price question („auf Anfrage“), cols 3–10. */
|
||||||
|
export function TrainingsFaq({ content = trainingsDe }: { content?: TrainingsContent }) {
|
||||||
|
const { eyebrow, items } = content.faq;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeading eyebrow={eyebrow} className="col-span-12 lg:col-span-6 lg:col-start-3" />
|
||||||
|
<Reveal className="col-span-12 mt-10 lg:col-span-8 lg:col-start-3">
|
||||||
|
<Faq items={items} />
|
||||||
|
</Reveal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrainingsFaq;
|
||||||
18
src/components/services/trainings/TrainingsGoals.tsx
Normal file
18
src/components/services/trainings/TrainingsGoals.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Ledger } from "@/components/ui/Ledger";
|
||||||
|
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
|
||||||
|
import { SectionHeading } from "@/components/services/SectionHeading";
|
||||||
|
|
||||||
|
/** „Typische Ziele“ – goals (not promises) as three hairline ledger lines, cols 3–8. */
|
||||||
|
export function TrainingsGoals({ content = trainingsDe }: { content?: TrainingsContent }) {
|
||||||
|
const { eyebrow, rows } = content.goals;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeading eyebrow={eyebrow} className="col-span-12 lg:col-span-6 lg:col-start-3" />
|
||||||
|
<div className="col-span-12 mt-10 lg:col-span-6 lg:col-start-3">
|
||||||
|
<Ledger variant="goals" rows={rows} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrainingsGoals;
|
||||||
62
src/components/services/trainings/TrainingsMethod.tsx
Normal file
62
src/components/services/trainings/TrainingsMethod.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Reveal, RevealGroup, RevealItem } from "@/components/motion/Reveal";
|
||||||
|
import { Marginalia } from "@/components/ui/Marginalia";
|
||||||
|
import { about as aboutDe, type HomeContent } from "@/content/home";
|
||||||
|
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
|
||||||
|
import { SectionHeading } from "@/components/services/SectionHeading";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits „Lead-in: rest“ so the run-in can be set in italics – emphasis is
|
||||||
|
* always italic, never a heavier weight (Spec §3.2: no bold anywhere).
|
||||||
|
* The colon stays roman.
|
||||||
|
*/
|
||||||
|
function splitLead(text: string): { lead: string | null; rest: string } {
|
||||||
|
const i = text.indexOf(": ");
|
||||||
|
if (i < 0) return { lead: null, rest: text };
|
||||||
|
return { lead: text.slice(0, i), rest: text.slice(i + 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Wie ich arbeite“ – three short paragraphs cols 3–8 (Jost 300, 20 px),
|
||||||
|
* marginalia cols 9–12: verifiable trainer facts from content/home
|
||||||
|
* (role, LinkedIn, languages), sticky on desktop.
|
||||||
|
*/
|
||||||
|
export interface TrainingsMethodProps {
|
||||||
|
content?: TrainingsContent;
|
||||||
|
/** Die überprüfbaren Trainerinnen-Angaben aus „Über Anelia“ – in der Sprache der Seite. */
|
||||||
|
marginalia?: HomeContent["about"]["marginalia"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrainingsMethod({ content = trainingsDe, marginalia = aboutDe.marginalia }: TrainingsMethodProps = {}) {
|
||||||
|
const { eyebrow, paragraphs } = content.how;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeading eyebrow={eyebrow} className="col-span-12 lg:col-span-6 lg:col-start-3" />
|
||||||
|
<RevealGroup
|
||||||
|
as="div"
|
||||||
|
stagger={0.1}
|
||||||
|
className="col-span-12 mt-10 flex flex-col gap-8 lg:col-span-6 lg:col-start-3 lg:mt-12"
|
||||||
|
>
|
||||||
|
{paragraphs.map((text) => {
|
||||||
|
const { lead, rest } = splitLead(text);
|
||||||
|
return (
|
||||||
|
<RevealItem key={text} as="p" className="max-w-[40ch] font-sans text-xl font-light leading-[1.5] text-fg">
|
||||||
|
{lead && (
|
||||||
|
<>
|
||||||
|
<em className="italic">{lead}</em>
|
||||||
|
{": "}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{rest}
|
||||||
|
</RevealItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</RevealGroup>
|
||||||
|
{/* Marginalia rail: cols 10–12 on every service page, never 9–12. */}
|
||||||
|
<Reveal delay={0.2} className="col-span-12 mt-12 lg:col-span-3 lg:col-start-10 lg:mt-12 lg:pt-1">
|
||||||
|
<Marginalia items={marginalia} sticky />
|
||||||
|
</Reveal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrainingsMethod;
|
||||||
45
src/components/services/trainings/TrainingsModules.tsx
Normal file
45
src/components/services/trainings/TrainingsModules.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
|
import { ModuleTable } from "@/components/ui/ModuleTable";
|
||||||
|
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
|
||||||
|
import { SectionHeading } from "@/components/services/SectionHeading";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page-level corrections to <ModuleTable/> (shared component – see report).
|
||||||
|
* Scoped to xl and above, where the ledger has room; between lg and xl the shared
|
||||||
|
* table has a separate, pre-existing problem (the Bodoni titles overrun the
|
||||||
|
* „Ziel“ column) that needs the component itself to move its five-column
|
||||||
|
* layout from `lg:` to `xl:`.
|
||||||
|
*
|
||||||
|
* 1. The „+“ carries `self-start`, so it floated above the right-aligned mono
|
||||||
|
* meta. The row is already `items-baseline`, so the glyph goes back on
|
||||||
|
* that baseline and lines up with the meta’s first line.
|
||||||
|
* 2. From 1360 px – the first width at which the mono meta has real slack –
|
||||||
|
* the trailing glyph column drops from 2 rem to 1 rem (the free space goes
|
||||||
|
* to the fr columns), so the „+“ no longer sits ~55 px clear of the meta
|
||||||
|
* and the right edge of the table resolves.
|
||||||
|
* Written out in full – Tailwind only ever sees literal class strings.
|
||||||
|
*/
|
||||||
|
const MODULE_TABLE_FIX = [
|
||||||
|
"xl:[&_.self-start]:self-baseline",
|
||||||
|
"min-[1360px]:[&>div]:grid-cols-[minmax(0,3.5fr)_minmax(0,3fr)_minmax(0,2fr)_minmax(0,2.2fr)_1rem]",
|
||||||
|
"min-[1360px]:[&_summary]:grid-cols-[minmax(0,3.5fr)_minmax(0,3fr)_minmax(0,2fr)_minmax(0,2.2fr)_1rem]",
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leistungsbausteine „Drei Module.“ – ledger table cols 3–12: header row
|
||||||
|
* „Modul | Ziel | Für wen“, one native `<details>` per module (+ → ×),
|
||||||
|
* opening to the hairline-separated „Inhalte“ list.
|
||||||
|
*/
|
||||||
|
export function TrainingsModules({ content = trainingsDe }: { content?: TrainingsContent }) {
|
||||||
|
const { eyebrow, h2, items } = content.modules;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeading eyebrow={eyebrow} h2={h2} className="col-span-12 lg:col-span-6 lg:col-start-3" />
|
||||||
|
<Reveal className="col-span-12 mt-12 lg:col-span-8 lg:col-start-3 lg:mt-16">
|
||||||
|
<ModuleTable modules={items} className={MODULE_TABLE_FIX} />
|
||||||
|
</Reveal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrainingsModules;
|
||||||
29
src/components/services/trainings/TrainingsRahmen.tsx
Normal file
29
src/components/services/trainings/TrainingsRahmen.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Reveal } from "@/components/motion/Reveal";
|
||||||
|
import { SectionHeading } from "@/components/services/SectionHeading";
|
||||||
|
import { Facts } from "@/components/ui/Facts";
|
||||||
|
import { trainings as trainingsDe, type TrainingsContent } from "@/content/trainings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rahmen – bone block cols 3–10 (1 px `--line`, padded) with the two-column
|
||||||
|
* mono facts table (Format · Sprache · Gruppengröße · Vorbereitung).
|
||||||
|
* Placeholder rows follow `flags.showPlaceholders` inside <Facts/>.
|
||||||
|
* No page-level grid override – the shared table stacks label above value
|
||||||
|
* below `sm` on every page, so the Rahmen block is the same object here and
|
||||||
|
* on /leistungen/coaching/.
|
||||||
|
*
|
||||||
|
* The chapter carries a real `<h2>` like every other one: <Facts title> is a
|
||||||
|
* plain `<p>`, so this section used to be unreachable by heading navigation.
|
||||||
|
*/
|
||||||
|
export function TrainingsRahmen({ content = trainingsDe }: { content?: TrainingsContent }) {
|
||||||
|
const { eyebrow, rows } = content.rahmen;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeading eyebrow={eyebrow} className="col-span-12 lg:col-span-6 lg:col-start-3" />
|
||||||
|
<Reveal className="col-span-12 mt-10 lg:col-span-8 lg:col-start-3">
|
||||||
|
<Facts variant="table" items={rows} />
|
||||||
|
</Reveal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrainingsRahmen;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user