Compare commits

16 Commits
Author SHA1 Message Date
knsoandClaude Opus 5.5 4139f31925 Terminplaner: Marke „Bald verfügbar“ (noch nicht für Kunden buchbar)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 20:09:28 +00:00
rootandClaude Opus 5.5 c1d775e090 Bühne: Live-Vorschau mit echter Bäckerei-Website, Chat als Live-Demo mit Zoom, weiches Zuklappen
- Live-Vorschau: Im MacBook steht die Beispiel-Website der Bäckerei Sonnenschein selbst
  (Startbereich, Sortiment, Backstube, Torten, Öffnungszeiten, Fuß; Instrument Serif,
  CC0-Fotos von Wikimedia Commons, nur Backwaren). Beim Scrollen durch die Station läuft
  sie einmal durch (--kp-v, nur transform). Portal-Aufnahmen (Projekte, Als App) neu mit
  derselben Website im Vorschaukasten.
- Chat: Portal-Chat als Live-Demo. Die Kamera zoomt aufs Gespräch, Foto anhängen, tippen,
  Sprachnachricht, Antworten von Kenso ploppen auf (spielerische Texte, Sie-Form). Das iPhone
  gleitet in dieser Station weg. Hinweis auf die E-Mail bei Team-Antworten (K17).
- „Funktionen“: Unterpunkte falten sich beim Verlassen ein, danach gleitet „Als App“ per FLIP
  nach oben (auch beim Aufklappen nach unten), Pfeil zeigt den Zustand.
- Schrittlängen je Station (normal, Demo, Vorschau, Chat), bei reduzierter Bewegung kurz.
- Tests: Bühne angepasst, neue Tests für Chat-Ablauf und Bäckerei-Website (287 grün).

Spezifikation: docs/superpowers/specs/2026-10-04-vorschau-chat.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:16:15 +00:00
rootandClaude Opus 5.5 58ac7745e6 Heatmap-Demo: ein Klick = ein Punkt an der Zeigerstelle, weich eingeblendet
Wunsch des Inhabers: ein simulierter Klick soll nicht mehrere Heatpunkte auf einmal
bringen. Die Heatmap der letzten 30 Tage ist jetzt von Anfang an ganz zu sehen; jeder
Demo-Klick legt genau einen Punkt an die Stelle des Zeigers, der in gut 0,4 s weich
hereinwächst. Beim Neustart blenden nur die Demo-Klicks aus, nicht die ganze Heatmap.

heat.ts: heatEbene() zeichnet den Grundbestand einmal und färbt je Bild nur den kleinen
Ausschnitt um die sich ändernden Punkte neu ein (gemessen ca. 2.000 Pixel je Klick),
nach der Überblendung läuft nichts mehr. Ohne Bewegung: alles sofort im Endzustand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:58:50 +00:00
rootandClaude Opus 5.5 adb1587a7b Heatmap-Demo: sofort sichtbar, je Klick nur wenige neue Punkte, etwas weniger Streuung
- Die Heatmap beginnt mit 70 % ihrer Punkte (Grundbestand) statt leer; jeder simulierte
  Klick gibt nur einen kleinen Teil des Rests seiner Zone frei (gemessen 1–4 Punkte je
  Klick statt rund 18), keine Streuung mehr in fremde Zonen.
- Dichte der übrigen Zonen etwas zurückgenommen (Exponent 1,8, Mindestens 1 Punkt je
  18 Klicks); „Tageskarte kaufen“ bleibt klar am heißesten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:48:47 +00:00
rootandClaude Opus 5.5 2f4ef5309b Wisch-Reihe: Funktions-Karten nur mit Titel, Stichpunkte entfernt
Wunsch des Inhabers: auch auf dem Handy zeigen Terminplaner, Website-Analyse und
News-Pinnwand nur ihren Titel. Stichpunkte samt Daten und Styles entfernt; der Hinweis
„Erfundenes Beispiel“ und der Tipp-Hinweis der Heatmap bleiben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:26:11 +00:00
rootandClaude Opus 5.5 9914002e74 Bühne: „Funktionen“ als aufklappender Überpunkt, Heatmap mit Klicks auch außerhalb der Tageskarte
- Liste: Überpunkt „Funktionen“ (Bausteine für Ihre Website). Er klappt auf, sobald
  Terminplaner, Website-Analyse oder News-Pinnwand erreicht ist, und danach wieder zu.
  Die drei Unterpunkte zeigen nur ihren Titel (keine Stichpunkte); die Liste steht oben
  in der Bühne, damit beim Aufklappen nichts darüber springt.
- Heatmap der Boulderhalle: jede Zone bekommt eine Mindestzahl Klickpunkte (Telefon,
  Menü, Routenbild, Karten sind sichtbar), die Tageskarte bleibt klar am heißesten.
  Telefonnummer 96 statt 61 Klicks (getauscht mit Öffnungszeiten, Summe bleibt 1.284).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:15:47 +00:00
rootandClaude Opus 5.5 f0bced164b Kundenportal-Bühne: Gruppe „Weitere Funktionen“, Unterpunkte als Stichpunkte
Wunsch des Inhabers: Die drei Funktionen heißen als Gruppe „Weitere Funktionen“ und
beschreiben sich in drei Stichpunkten statt in einem Satz (Liste der Bühne und Karten
der Wisch-Reihe). Der Bedienhinweis der Heatmap („Zeigen Sie mit der Maus …“ bzw.
„Tippen Sie …“) steht jetzt im Hinweis unter dem MacBook und in der Karte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:02:04 +00:00
rootandClaude Opus 5.5 72275db9fa Kundenportal-Bühne: Terminplaner, Heatmap und Pinnwand als Live-Demos statt „Funktionen“
Die Station „Funktionen“ ist durch drei Stationen ersetzt (Gruppe „Funktionen zum
Dazubuchen“). Auf dem MacBook läuft je eine erfundene Kunden-Website als Live-Komponente,
auf dem iPhone die Portal-Ansicht der Funktion (echter Portal-Code, Fake-Daten):

- Terminplaner (Salon Schnittstelle): Buchung im Widget, Bestätigung per Link,
  Eintrag im Kalender-Abo, Mitteilung „Neue Buchung …“ auf dem Handy
- Website-Analyse (Boulderhalle Überhang): simulierte Klicks bauen die Heatmap auf
  (Algorithmus wie analytics/client/h.js); Maus über der Seite zeigt Zone + Klickzahl,
  darunter die Diagnose mit Rangliste und Satz (Beispieldaten)
- News-Pinnwand (Ortsgemeinde Musterbach): „Aufhängen“ im Portal, Zettel fällt auf die Pinnwand

Demos laden als eigene Chunks, laufen nur bei aktiver, sichtbarer Station und ohne
reduzierte Bewegung. Unter 900 px zeigen die Karten die Website in der Handy-Fassung.
Spezifikation: docs/superpowers/specs/2026-10-04-funktionen-demos.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:24:59 +00:00
rootandClaude Opus 5.5 c9ad9ba2b7 Portal-Screenshots im Geräteformat neu aufgenommen
iPhone 16 Pro 402 x 874 (Safe Area 59/34) und MacBook-Safari-Fläche 1512 x 893,
je @2x, weiter nur mit den Demodaten der Bäckerei Sonnenschein. Kein Versatz
mehr im iPhone; im letzten Schritt bleibt die Statusleiste deckend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:54:23 +00:00
rootandClaude Opus 5.5 e0e81b65a8 Kundenportal: realistische MacBook- und iPhone-Rahmen aus KNSONWS/homepage (device-mockups)
DeviceFrames.tsx und device-frames.css unverändert übernommen (Commit ae123f0).
DesktopFenster/HandyRahmen nutzen sie jetzt; Anordnung wie in der Vorlage
(iPhone über der rechten unteren Ecke), Screenshots füllen die Bildschirmfläche.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 18:30:10 +00:00
rootandClaude Opus 5.5 6e8ba2cddb Relaunch Phase 1: Feinschliff nach Browser- und Code-Prüfung
- Kopfleiste voll deckend, nichts scheint beim Scrollen durch
- Leistungen: Überschrift bleibt links stehen, während die Liste vorbeizieht
- langer Handy-Screenshot unten beschnitten (kein schwarzer Streifen am Ende)
- Titel, Beschreibung, og/twitter nach Spezifikation 6.5; canonical je Route
- Über uns ohne alte Versprechen, nur belegte Aussagen
- Schrittzeile neutral benannt, Tippflächen 44 px, Navigation als eigenes Modul

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:33:27 +00:00
rootandClaude Opus 5.5 1c8cfa5c1c Relaunch Phase 1: Hero mit Deutschland-Karte, Kundenportal-Bühne, Leistungen, Preise, Unterseiten im neuen Layout
- Hero: wechselnde Überschrift (ein Durchlauf, Pause-Knopf), Karte zeichnet
  sich, roter Punkt auf Falkenstein
- Kundenportal: Bühne mit Browserfenster und Handy-Rahmen (Desktop),
  Wisch-Reihe (Handy), Schrittzeile statt „So arbeiten wir“
- Leistungen als nummerierte Zeilen, Preise aus src/data/abos.ts
- Unterseiten ohne eigenen Kopfbereich, Wortlaut der Rechtstexte unverändert
- Tests für Hero, Kundenportal, Leistungen/Preise und Layout

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:28:38 +00:00
rootandClaude Opus 5.5 69d006334f Wisch-Reihe für Handys: Scroll-Snap mit angeschnittener Folgekarte und Zähler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:20:43 +00:00
rootandClaude Opus 5.5 822481f2e4 Relaunch Phase 1: Gestaltungs-Grundlage, Kopfleiste, Footer, neue Startseiten-Struktur
Token für Hell und Dunkel, eine Schriftfamilie (Instrument Sans selbst gehostet,
?schrift=a für die Systemschrift), gemeinsames Layout für alle Seiten,
Abschnitte Über uns und Kontakt, Platzhalter für Hero, Kundenportal,
Leistungen und Preise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:20:18 +00:00
rootandClaude Opus 5.5 11fd118aa7 README: kurze Projektbeschreibung statt Lovable-Vorlage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:09:17 +00:00
rootandClaude Opus 5.5 6eac6ef8fb Relaunch: Spezifikation, Material (Deutschlandkarte, Portal-Screenshots) und CLAUDE.md
Spec docs/superpowers/specs/2026-10-01-relaunch-design.md (wartet auf Freigabe),
Karte aus Natural Earth (gemeinfrei) mit Punkt Falkenstein, 19 Portal-Aufnahmen
mit erfundenen Demodaten, Regeln für Cloud-Sitzungen in CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:09:02 +00:00
146 changed files with 17079 additions and 1279 deletions

No files matched your search

+3
View File
@@ -23,3 +23,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Rohfotos der Beispiel-Bäckerei (werden von scripts/relaunch-baeckerei-fotos.py von Wikimedia Commons geladen)
docs/relaunch/assets/baeckerei/roh/
+62
View File
@@ -0,0 +1,62 @@
# webklar.com
Website of WEBklar GbR (a small web agency in Winnweiler, Rhineland-Palatinate). Vite + React 18 + TypeScript + Tailwind + shadcn/ui, client-rendered SPA with React Router.
**Current project: the relaunch.** Read `docs/superpowers/specs/2026-10-01-relaunch-design.md` before any change. The material for it (Germany map, customer portal screenshots) is in `docs/relaunch/`.
Status: the spec is waiting for the owner's approval. Once it is approved, write an implementation plan with the `superpowers:writing-plans` skill and save it to `docs/superpowers/plans/`. Implement only after that.
## Commands
```bash
npm install
npm run dev # local dev server
npm test # Vitest + Testing Library (must stay green)
npm run build # production build into dist/
npm run lint # ESLint (main already has old errors; add no new ones)
```
## How this repository relates to the live site
- This GitHub repository is the **workspace**. Its `main` is **not** live.
- webklar.com is built from a separate internal repository on the owner's server. Only the owner, or a session on that server, transfers an approved state there. Never try to deploy from here.
## Rules
- **Language:** everything user-facing is German, in the polite form **"Sie"**. Brand spelling is always **"WEBklar"**. Write commit messages, PR texts and notes for the owner in German.
- **The wording of the legal pages stays unchanged without the owner's explicit OK:** `src/pages/AGB.tsx`, `Impressum.tsx`, `WartungsAbo.tsx`, `HostingAbo.tsx`, and the privacy policy (the `/datenschutz` redirect to the customer portal).
- For the new layout you may change their markup and CSS classes: remove their own header block, replace uppercase and old font/button classes.
- Keep their wording, order and structure.
- The tests in `src/test/abos.test.tsx`, `agb-haftung.test.tsx` and `agb-verguetung.test.tsx` protect them; never weaken or delete these three files.
- `src/test/sofort-paket.test.tsx` may be adapted where it pins old copy or removed components. Keep its source-code scans against banned statements.
- **No invented content:**
- no numbers without a source (no "99,9 %", no project counters);
- no references without the customer's consent (AGB § 13);
- no stock or AI images of people;
- no promises of success (AGB § 2: no result is owed);
- SEO only as a separately ordered service;
- support after launch only through the subscriptions.
- **Fixed facts:**
- Phone: only 0176 23726355 (`tel:+4917623726355`). E-mail: support@webklar.com.
- Reply time: only "spätestens am nächsten Werktag".
- Servers: Hetzner in Falkenstein/Vogtland, Saxony. This applies **only to the website and the customer portal**. Never write "all data in Germany".
- Prices are final prices, small-business rule § 19 UStG, no VAT. Hosting-Abo 20 € per month, Wartungs-Abo 50 € per month, 12 months minimum term each. Extra work 100 € per hour in 15-minute steps, only after approval.
- **Customer portal screenshots** show only the invented demo customer "Bäckerei Sonnenschein". Never use real customer data.
- **Privacy:** no requests to third-party servers. Host fonts, images and scripts yourself; no Google Fonts, no CDNs, no tracking.
- **Secrets:** never commit them. `.env` stays local; `.env.example` holds only public client IDs.
- **This repository may be public.** Do not check in internal data: price calculations, customer names without consent, server details, logins.
- **Before every push:** run `npm test`, `npm run build`, and `npm run lint` (no new errors). For visual changes, put screenshots in the pull request: 1440 × 900 and 390 × 844, light and dark.
- **Work on branches and merge through pull requests.** The relaunch branch is `relaunch`.
- **Code style:**
- Match the existing code.
- German comments where the code already uses them.
- Only `transform`/`opacity` animations, always behind `prefers-reduced-motion`. The spec section 8.4 lists the only exceptions: map outline drawing, a 3× pulse around the map dot, the rotating hero headline (one pass, with a pause button), and the scroll-linked word highlight.
- Hover effects only for `(hover: hover) and (pointer: fine)`.
## Structure (short)
- `src/routes.tsx`: all routes. Subpages share one layout with the footer (`SubpageLayout`).
- `src/App.tsx`: providers, `ScrollToTop`. It jumps to anchors such as `/#leistungen` once the fonts have loaded.
- `src/hooks/use-page-meta.ts`: title and description per route.
- `src/pages/`: pages. `src/components/`: sections and parts. `src/components/ui/`: shadcn.
- `src/test/`: tests. `sofort-paket.test.tsx` holds source-code scans against banned statements: the old phone number, Unsplash, "24 Stunden", "99,9", success promises. Keep these scans when sections are removed.
+11 -68
View File
@@ -1,73 +1,16 @@
# Welcome to your Lovable project
# webklar.com
## Project info
Website der WEBklar GbR (Vite, React, TypeScript, Tailwind).
**URL**: https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID
## How can I edit this code?
There are several ways of editing your application.
**Use Lovable**
Simply visit the [Lovable Project](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and start prompting.
Changes made via Lovable will be committed automatically to this repo.
**Use your preferred IDE**
If you want to work locally using your own IDE, you can clone this repo and push changes. Pushed changes will also be reflected in Lovable.
The only requirement is having Node.js & npm installed - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating)
Follow these steps:
```sh
# Step 1: Clone the repository using the project's Git URL.
git clone <YOUR_GIT_URL>
# Step 2: Navigate to the project directory.
cd <YOUR_PROJECT_NAME>
# Step 3: Install the necessary dependencies.
npm i
# Step 4: Start the development server with auto-reloading and an instant preview.
npm run dev
```bash
npm install
npm run dev # Entwicklung
npm test # Tests
npm run build # Build nach dist/
```
**Edit a file directly in GitHub**
- Regeln für die Arbeit an diesem Repository: [`CLAUDE.md`](CLAUDE.md)
- Aktuelles Vorhaben, der Relaunch: [`docs/superpowers/specs/2026-10-01-relaunch-design.md`](docs/superpowers/specs/2026-10-01-relaunch-design.md)
- Material zum Relaunch: [`docs/relaunch/`](docs/relaunch/)
- Navigate to the desired file(s).
- Click the "Edit" button (pencil icon) at the top right of the file view.
- Make your changes and commit the changes.
**Use GitHub Codespaces**
- Navigate to the main page of your repository.
- Click on the "Code" button (green button) near the top right.
- Select the "Codespaces" tab.
- Click on "New codespace" to launch a new Codespace environment.
- Edit files directly within the Codespace and commit and push your changes once you're done.
## What technologies are used for this project?
This project is built with:
- Vite
- TypeScript
- React
- shadcn-ui
- Tailwind CSS
## How can I deploy this project?
Simply open [Lovable](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and click on Share -> Publish.
## Can I connect a custom domain to my Lovable project?
Yes, you can!
To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
Read more here: [Setting up a custom domain](https://docs.lovable.dev/features/custom-domain#custom-domain)
Die Kartendaten stammen von Natural Earth (gemeinfrei).
+60
View File
@@ -0,0 +1,60 @@
# Material für den Relaunch
Dieses Verzeichnis gehört zur Spezifikation `docs/superpowers/specs/2026-10-01-relaunch-design.md`.
## Deutschland-Karte
- `assets/deutschland-umriss.svg` – Umriss von Deutschland mit rotem Punkt auf dem Serverstandort Falkenstein/Vogtland (Sachsen).
- Ein `<path>` (nur Linie, `stroke="currentColor"`, `vector-effect="non-scaling-stroke"`) plus ein `<circle>` (`#e5251c`), viewBox `0 0 663 892`, ca. 7 KB.
- Quelle: **Natural Earth**, „Admin 0 – Countries“, Maßstab 1:10 m. Gemeinfrei (Public Domain): „All versions of Natural Earth raster + vector map data … are in the public domain.“ Ein Quellenhinweis ist nicht nötig („Made with Natural Earth.“ ist freiwillig).
- Behalten: Festland, Rügen, Usedom, Fehmarn, Sylt, Föhr/Amrum. Vereinfacht (Douglas-Peucker, ca. 0,9 km Toleranz).
- Projektion: Lambert-Azimutal-flächentreu (wie ETRS89-LAEA), Mitte 10° O / 52° N, Kugelradius 6371 km. 1 Einheit ≈ 1 km. Geprüft: Berlin–München 504 km auf der Karte wie in echt.
- Punkt Falkenstein (50,477 N, 12,371 O): `cx = 469.6`, `cy = 520.5` (70,8 % von links, 58,4 % von oben; liegt im Festland, 13,9 km von der tschechischen Grenze).
**Weitere Punkte setzen** (z. B. Büro Winnweiler, ca. 49,567 N, 7,850 O – Koordinaten vor Einbau prüfen):
```js
const R = 6371, lon0 = 10 * Math.PI / 180, lat0 = 52 * Math.PI / 180;
// Grad -> SVG-Koordinaten in der viewBox 0 0 663 892
function project(lon, lat) {
const l = lon * Math.PI / 180, p = lat * Math.PI / 180;
const k = Math.sqrt(2 / (1 + Math.sin(lat0) * Math.sin(p) + Math.cos(lat0) * Math.cos(p) * Math.cos(l - lon0)));
const x = R * k * Math.cos(p) * Math.sin(l - lon0);
const y = R * k * (Math.cos(lat0) * Math.sin(p) - Math.sin(lat0) * Math.cos(p) * Math.cos(l - lon0));
return [x + 289.794 + 12, 341.920 - y + 12];
}
project(12.371, 50.477); // -> [469.6, 520.5] Falkenstein
```
Für das Zeichnen des Umrisses per `stroke-dasharray` das Attribut `vector-effect="non-scaling-stroke"` entfernen und `pathLength="1"` setzen (sonst stimmen die Strichlängen nicht).
## Kundenportal-Screenshots
`assets/portal/` – Aufnahmen des echten Kundenportals (project.webklar.com) mit **erfundenen Demodaten** („Bäckerei Sonnenschein“, Rechnungsbeträge, Chatverlauf). Es sind keine echten Kundendaten enthalten. Das Brotfoto ist eine selbst gezeichnete Grafik, die Demo-Website der Bäckerei eine selbst erstellte Seite; beides sind keine Stock- oder KI-Bilder. Aufgenommen am 2026-10-01 mit dem damaligen Stand des Portals.
Vor dem Livegang neu aufnehmen (das Aufnahme-Werkzeug liegt intern beim Inhaber): wenn sich das Portal sichtbar ändert, und damit Auftrags-, Rechnungs- und Kundennummern offensichtlich erfunden aussehen (z. B. Auftrag 1001, Rechnung 2026-0001). Die heutigen Nummern sind zwar erfunden, wirken aber wie echte Zählerstände.
| Datei | Größe (px) | Inhalt |
|---|---|---|
| `00-uebersicht.png` | 2360 × 1229 | Übersicht aller Aufnahmen (nur zur Orientierung, nicht ausliefern) |
| `desktop-1-projekte.webp` | 2880 × 1800 | Projektübersicht mit Live-Vorschau der Website |
| `desktop-2-fortschritt.webp` | 2880 × 1800 | „Was wir zuletzt erledigt haben“ – Fortschritt in Klartext |
| `desktop-3-chat.webp` | 2880 × 1800 | Chat mit Sprachnachricht und Datei |
| `desktop-3b-chat-foto.webp` | 2880 × 1800 | Chat mit Foto |
| `desktop-4-rechnungen.webp` | 2880 × 1800 | Rechnungen |
| `desktop-5-stundenkonto.webp` | 2880 × 1800 | Stundenkonto des Wartungs-Abos (nur nach Freigabe zeigen) |
| `desktop-6-funktionen.webp` | 2880 × 1800 | Funktionen (Terminplaner/Analyse dort sind Platzhalter – nicht bewerben) |
| `desktop-7-news-pinnwand.webp` | 2880 × 1800 | News-Pinnwand mit Vorschau |
| `phone-0-login.webp` | 780 × 1688 | Anmeldung (Handy) |
| `phone-1-projekte.webp` | 780 × 1688 | Projekte mit Vorschau (Handy-App) |
| `phone-1b-tabbar.webp` | 780 × 192 | untere Tab-Leiste der Handy-App |
| `phone-2-projekte-lang.webp` | 780 × 5428 | lange Projektseite zum Durchscrollen im Handy-Rahmen |
| `phone-3-projektdetails.webp` | 780 × 1688 | Projektdetails mit letzten Änderungen |
| `phone-4-chat.webp` | 780 × 1688 | Chat |
| `phone-4b-chat-foto.webp` | 780 × 1688 | Chat mit Foto |
| `phone-5-rechnungen.webp` | 780 × 1688 | Rechnungen |
| `phone-6-vertraege.webp` | 780 × 1688 | Verträge (nur nach Freigabe zeigen) |
| `phone-7-news.webp` | 780 × 1688 | News-Pinnwand bearbeiten |
| `phone-8-news-vorschau.webp` | 780 × 1688 | News-Pinnwand, Vorschau „So sehen es alle“ |
Die Handy-Aufnahmen haben oben 47 px und unten 34 px Rand für Uhrzeit und Home-Leiste (Safe Area). Die Dateien sind Rohexporte in doppelter Auflösung; für die Website verkleinern (Budgets in der Spec, Abschnitt 9.5).
+17
View File
@@ -0,0 +1,17 @@
# Fotos der Beispiel-Website „Bäckerei Sonnenschein“
Die Bäckerei ist erfunden (Demo-Kundin des Kundenportals). Die Fotos zeigen nur Backwaren, keine Menschen.
Alle stammen von Wikimedia Commons und stehen unter **CC0 1.0** (gemeinfrei, keine Namensnennung nötig).
Verkleinert und zugeschnitten mit `scripts/relaunch-baeckerei-fotos.py` nach `public/relaunch/demos/baeckerei/`.
| Datei in public | Commons-Datei |
| --- | --- |
| roggenbrot-600.webp | [Rye bread Roggenbengel 2025-02-14 13.jpg](https://commons.wikimedia.org/wiki/File:Rye_bread_Roggenbengel_2025-02-14_13.jpg) |
| roggenbrot-anschnitt-400.webp | [Cut rye bread Kartoffelkruste 2025-02-20 02.jpg](https://commons.wikimedia.org/wiki/File:Cut_rye_bread_Kartoffelkruste_2025-02-20_02.jpg) |
| broetchen-400.webp | [Bread-rolls.jpg](https://commons.wikimedia.org/wiki/File:Bread-rolls.jpg) |
| croissants-400.webp | [Freshippo croissant bread.jpg](https://commons.wikimedia.org/wiki/File:Freshippo_croissant_bread.jpg) |
| zimtschnecken-400.webp | [IKEA cinnamon bun.jpg](https://commons.wikimedia.org/wiki/File:IKEA_cinnamon_bun.jpg) (Ausschnitt ohne Logo oder Schrift) |
| baguettes-700.webp | [Baguette-246424.jpg](https://commons.wikimedia.org/wiki/File:Baguette-246424.jpg) |
| zwetschgenkuchen-520.webp | [Plum cake, Ehrenbach.jpg](https://commons.wikimedia.org/wiki/File:Plum_cake,_Ehrenbach.jpg) |
Lizenz von jeder Datei am 2026-10-04 über die Commons-API geprüft (`LicenseShortName` = CC0).
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 663 892" role="img" aria-labelledby="t d">
<title id="t">Umriss von Deutschland mit Serverstandort Falkenstein</title>
<desc id="d">Roter Punkt: Hetzner-Rechenzentrum Falkenstein/Vogtland, Sachsen (50,477 N, 12,371 O). Umriss: Natural Earth 1:10m (Public Domain), Projektion ETRS89-LAEA, vereinfacht.</desc>
<path d="M581.4 706.2l-2 5.8 2.5 2-1 2.4 0.4 7-1.8 4.8-2.9 1.2-1.1 4.3-7-4.5-7.7-1.8-4.8 1.5-1 1.4 1.6 4.9-3.3 15.7-9.3 8.2-10.1 2.2-7.6 3.4-7.1 6.3-5.2 1.6-8.8 9.6 1.8 5.4 5.4 5.2 2.6 5.8 5.3 4 4.8 8.5-6.9 14 5.5 1.8 4.1-1 4.2 5.8 0.1 4.1-2.3 4.3-0.6 4.8 0.9 5.3-2.5 3.2-5.4-0.7-11.8-8.6 0.4-5.3 2-1.1-4.8-4 0.7-1.9-8.2-0.7-8.4 5.2-3.1 0.4-4-2.9-1.7-3.9-13.8 1.8-1.2-1.8 1.3-4.1-4.8 3.1 2.4 6.6-2.4 4.6-17.8-0.1-6.3 1.4-2.3 2.7-15-1.2-5 8.5-10.4 1.1-3.5 4.1 1.8 0.8-0.4 1.9-5.8 1.2-5.1 4.7-1.8-0.2 0.1-3-1.6-0.7-8.3 4.5-7.8 0-1.9-2.1 0.3-2.5-3.8-4-3.9-1.8-0.5-0.8 3.1-2.5-2.6-1.7-7.3 2-1.6-1.7-11.8-3.6-3.5 3.6-4.1-0.3-2.1-1.5 0.2-3.6-2.1 0.2-1.1 0.5 1.3 3.4-0.9 5.5 2.2 2.2 0.3 3.8-1.7 4.7-4.3 3.2-4.9 7.3-5 2.7-6 0.8 2.3-3 1.4-8.3-1.4-0.7-8.2 2.2-2.2-5.1 1.7-1.3-0.3-2.5-3.7-5.4-3 0.8-2.8-6.7-8.4 0.7-1.5-0.8-0.4-3.8-2-1.4-2.2 0.7-3.6 5.6-6.9 1.1-4.7-1.4-20.7-13.1-5.7-0.7-1-1.6-12.5-1.1-5.4 2.7-4.7-0.3-3.3-2.8 1.4-1.1-4.3-3.3-2-0.1-0.1 2.8-4 0-1.2-3.2 1-3.8-2.3-0.2-2.8-3.6-2 3.7-1.1-4.2-1.4-0.6-3.1 0.5 0.7 1.8-1.1 0.5-5.5 1-2 4.5-3.6 3.4 0 3 4.9 2.9 8.3-2.4 2.9 0.8-0.5 2.6-1.5-0.7-0.1 4.3-1.5 1.2-2.8-3.7-4.6 1.7-1 2.4-7.1 0.2-3.6-1.3-1.3-2.7-4.1-0.8-4.1 0.6-7 5.2-3.4 0.7-9.8-0.3-0.9-3-5.9-1-4.1 4.2-6.3 1.1-5.4-2.4 1.9 0 1.9-3.4-5.6 1.1 0.4-2.6-5.1-7.6-0.4-3.6 2.1-2.7-0.7-5.7 2.9-6.1-0.1-4.9 2.1-6.3 2.8-3.4 0-3.4-3.1-5.8 0.5-6.8 2.8-7.1 5.7-8 1.3-6.4 3.7-3.3-0.7-7.2 3.1-10.1 2.8-2.3 0.1-10.6 3.2-4.8 8-6.7 1-4 3.4-0.4 1.7-3.2 3.8-1.8 8.5-16.5-8-2.5-11.4-6.5-5.5 0.1-6-1.9-3.4 1.2-6.7-0.4-1.9-2.8-6.3-2.6-3.4-5.1 0.4-2.5-4.4-0.9-0.3-1.2-4.3 0.9-1.4 2.7-2.7 0.6-1.7 2.5-8.7-1.4-5.3-3.2-0.6 2.3-2.3 1.4-1.5-1.4-0.6-7-6.8-3-5.8-0.6 0.2 5.2-1.4 1.6-6.9-1.1-1.7-2 0.3-4.6-1.6 0.6-2.7-7.2-5.7-6.4 0-2.3 1.7-0.9-4.5-7.8-7.6-5-4.2 1 1-12.4 5.2-7.8-1-1.9 7.3-5.9-0.9-2.5 1.5-6.8-7.5-1.8-4.3-3.7-2.3 0.5-1-3-4.4-3.1-3.2-7.9-0.9 1.5-4-12 2.8-9.4-0.4-3.1 3.2-2.1-1.1-3.6 3.2-3 6-2.5 1.2-4 6.4-1-2.4-5.9 1.4-7.3-1.8-3.7 1-1.6-5.7-1.8-3.4 0.8-1-3.3-1.4-0.4-0.6-2.9 5.4-4.5 1.3-3-6.3-1.2-0.8-1.7 1-0.5-5.2-8.2-4.9 0.3 0.1-3.2-2.6-6.6 2.1-0.5 1-4.4 3.4-1.2 0.8-6-4.3-2.7 0.5-4.9-5.3-0.8-3.7 1.2-1.1-8.7 2-0.9 3.9 2.6 3.4-4.7 11.9-8-1.4-1.5 2.3-1.5-1.6-0.6-3.6 2.2-1.6-1 0-4.6 6.9-11.4 3.3-2.8 1.3-4.7-1-1.3 0.7-12.3-6.9-10 1.2-5.4-6-4.4 0.4-3.8-4.9-2.1 2-4.8-2-4.5 10.7-3.6 4.9 1.5-4.1-5 2.4-1.2 6.5 4.2 2.7-0.3 1.5 2.3 3.7 1.3 0.1 1.7 2.6-0.6-0.1-3.6 5.7 1.6 4.7-2.9 14.9-2.9 4.8-5.6 0-2.1-8.9-6.6 0.5-2.8 2.2-0.6 2.8-3.9 5.6-0.9 3.9-5.1 3-1.1 7.1-6.8-0.9-5.5 2.8-8.8-4.6-9.8-5.1 1.9-10.6-2.9-3.1-2.6-1.2-7.4 5-1.8-2.6-2.7 0-4 2.5-1.6 8.7-0.4 10.2 2.2 1.3-2.3 2.5-19.6 7.2-11.8 2.4-7.2-0.8-15.6 2-11.8 2.7-3.1-0.2-2.6 1.8-2.5 2.4-0.4 4.9 2.6-4.1-3.5-15-1.1-2.4-1.4-0.6-11.3 2.2-6.8 0.6-1 3.9 0.7 1.6-2.2-3.4-4.2 0.9-2.7 8.6-7.3 5.3-1.8 10.1-0.8 2.9 1.9 29.6-3.9 5.2 1.7-0.5 4 1.7 4 4.8 5.2-0.6 2.1 3.2 2.2-0.9 3-6.5 2.5 1.7 5.1 4.3 0.9 3.4 4.4 3.6-0.1 3.9-6.9 0.4-3.8-1.8-2.3-4 0.4 2.8-9.7 3.9-0.5 3.7 4.7 10.8 3.9-4.2 6.3-0.5 9.5 1.1 4.8-0.1-13 4-4.7 0.6-3.2-3.6-8.8-1.3-8.4 6.1-17.4 5.2-3.7 8.5 5.7 5.1 1.3 10.2-0.9 6.5-2.6 10.5 0.1 8.3 13.7 5.4 5.9 5.3 10.1 8.6 4.8 8.9 1.5-3.3-2.9-5.9-0.8-7.2-4.1-2.1-2.4-1.2-8.5-6.3-4.3-2.6-9.1-9-6.2-10.7-0.9-4.2-1.7-1.4 1.6-1.5-0.5-4.5-4.5-5.2-11.1 3.8-1.2 5.5 2.7 1.4-0.7 1.5-7.5-2.8-3.4 0.2-1.9-1.6-0.5-6.1 2.6-3-6.2 2.3-5.8-0.4-2.5 5.4-2 2.7-4.8-5.3 3 0.4-1.5-9.7 1-3.9 2.6-3.6-2.4-1.4-5.3 2.7-1.9 3.6-0.3-2.9-2.2-1.1 1.4 1-4.6 15.4-1 7.8-5.2 0.5-4.5-8.4-11.2-3.5 0.8 1.8-2.3-2.9-7.6-7.4-5.5 0.4-2.7-3.1-9.4 1.2-5.8 4.6 0.9 5.9-1.8 5.1 1 18.5 5.5 3.1 5.5 5.7-0.2 3.4-1.7 4.2 1 8.3-6.1 0.2 4 9.7 2.6 4.3 2.6 1.4 3 4.6-1.4-0.3-2.3 3.6 1.2 4.6 8.7-3.1 0-2.6 3 4.3-0.5 2.7 1.3-0.8 11.9-3.9 6-8.1 3.4 2.8 1.5 16.8-3.3 3.9 3.4-1.3 1.3 0.5 5.8-3.2 2.7 0 5.4 2.5-1.9 2.8-8.1 6.1-3.3 2.7 0.2 15.6 8.4 5.9 5.4 2.6 0.6 3-0.6 10.2-7.2 5.4-0.8-2.8 0.8 3.8 1.2 4.5-2.9 2 0.8-3 3.8-1.4-0.7 2.1 14.8-1.8 4.6-11.2 9.2-5.7 0.9-3.4 5 3.2 5.5 4.9 0.5 0.8 3.5 2.4 0.3 8.4-5.4 8-1.2 1.9 3.2 2.7 1.1-0.1 3.7 3.4 0.5 2.6-2.4 8.2 6 0.8-7 1.6-1.5-0.4-2.2 2.2-2.3-0.5-1.6 3.5-0.3 3.3-5.1-0.1-2.9 4.1-5.1 8 0.4 17.8-5.4 0.5 8.9 1.6 1.7-1.5-5.2 3-4-2.6 0.8 0.4-1 4.9-6.9 9.9-7 9.7-19.2 2-1.7 1 2.9 2.8 1 18.7-0.4 2.7 1.6-3.2 2-11.4-1.4-1.5 1.5-3-0.2-1 1.9-1.7 0.1 0.9 2.2-2.7-0.4-1.6 2.1-2.4-1.9-3.4 0.4-0.8 4.8-1.8-0.7-0.2 3.4-1.8 1.2 0.5 4.6 2.9 1.9 3-0.6-3.2-1.4 3.9-4.7 0.3-2.4 5.2-3.2-1.3-1.4 2.1-0.9 1.3 1.1 5.3-2.8 0.2-2.1 2.3-0.4-2 4.7 1.3-3.1 2 2.3 3.3-1.6 2.2 3.7 2.3-0.1 4.6-6.5 5-2.8 1.7 0.4-0.2 3.6 4.5 3.1-1.2 2.2 2.9 7.9 3.6 0.3-1.7 1.6 9.6 2.7 4.2 5.8-1.7 1.6 5.3-1-1.3 2.4 2.3-0.2 3.4 6 2.7 0.7-2-3.7 8-2 6.9-4.2-0.8 2.3 7.5 5.1-2 6.6-1.7 1.1 6.4 5.3 0.8 3.4 4 2.2-5.5 6.5 0.1 2.8 15 10 9.9 1.2 3-2.6 2 1-0.1 2.6-3 2.4 3.4 0.7 4 21.1 4.1 7 4 18.1 2.7 2.9-3.6 7.1 0.9 6.4-2.5 9.4-12.7 10.6 1.8 7.1-2.4 5.2 18 11.4 3.7 5.6 15.1 11.2-2 6.7 1.7 2.3-2.4 5.4-3.3 3.3 0.7 4.4 3.5 7.9-0.3 2 8 3.7 1.1 1.9-0.9 12.8 5.4 4.6-3.6 9.7-0.3 9-5.7 8.1-0.4 4.3 3.5 3.7 1.4 5.6 6.2 6.2 1.1 5.7-1.8 4.5 0 4.1 14.4 5.9 3.3 3.5 1.1 11.1 4.6 6.8 0.4 3.8-5.9 27.7-3 8.6-3.2 4.7-1.3 6.4-3.3 1.Line truncated
<circle cx="469.6" cy="520.5" r="5" fill="#e5251c"/>
</svg>

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1,633 @@
# Relaunch webklar.com – Design-Spezifikation
- **Stand:** 2026-10-01
- **Status:** Entwurf, wartet auf Freigabe durch den Inhaber. Nach der Freigabe wird daraus ein Umsetzungsplan geschrieben (Skill `superpowers:writing-plans`, Ablage `docs/superpowers/plans/`).
- **Grundlage:** Prüfung der Live-Seite vom 2026-10-01 (Desktop, Handy, Texte, Code, Kundenportal, Design-Recherche) und die Wünsche des Inhabers. Die Prüfberichte selbst liegen intern; alles, was für die Umsetzung nötig ist, steht in diesem Dokument.
- **Material:** Deutschland-Karte und Kundenportal-Screenshots in `docs/relaunch/assets/` (Übersicht: `docs/relaunch/README.md`).
---
## 1. Ziel
Wer eine Website oder eine digitale Dienstleistung sucht und auf webklar.com landet, versteht in den ersten Sekunden, wer WEBklar ist und was er bekommt. Das passiert über Bilder, Bewegung und kurze Sätze statt über Textblöcke. Die Seite wirkt modern und ruhig, mit Abständen und Typografie wie bei Apple-Produktseiten. Sie darf nicht nach KI-Vorlage aussehen, ist übersichtlich, sagt nichts doppelt und ist auf dem Handy kein Endlos-Scroll.
### Erfolgskriterien
| Nr. | Kriterium | Wer prüft, wie |
|---|---|---|
| E1 | Fünf-Sekunden-Test: Eine Testperson kann danach sagen „Die bauen Websites, Shops und Apps für kleine Betriebe, und man sieht alles in einem Kundenportal.“ | **Inhaber**, mit 2–3 Personen, die die Seite nicht kennen |
| E2 | Startseite auf dem Handy (390 × 844) höchstens 8 Bildschirmhöhen lang (heute 14) | Umsetzung: `document.documentElement.scrollHeight / innerHeight` |
| E3 | Jede Aussage steht genau einmal auf der Startseite (Regeln in 6.3) | Umsetzung: Textprüfung |
| E4 | Heller und dunkler Modus sind beide gestaltet, alle Texte erreichen mindestens 4,5 : 1 (ab 24 px mindestens 3 : 1) | Umsetzung: Kontrastmessung im Browser und Token-Test |
| E5 | Startseite lädt höchstens 130 KB JavaScript (gzip), kein WebGL; LCP auf dem Handy unter 2,5 s, CLS unter 0,1 | Umsetzung: `vite build`, Lighthouse Mobile |
| E6 | Alle Routen und Rechtsseiten funktionieren, die Texte der Rechtsseiten sind unverändert, die Rechtstext-Tests bleiben grün | Umsetzung: `npm test` |
| E7 | Austausch-Test: Mit einem anderen Agenturlogo passt die Seite nicht mehr, weil Texte, Karte und Portal nur zu WEBklar passen | **Inhaber**, Sichtprüfung |
---
## 2. Ausgangslage
- **Technik:** Vite, React 18, TypeScript, Tailwind, shadcn/ui, React Router. Reine Client-SPA. Das Kontaktformular schreibt per Appwrite-Client in eine Datenbank.
- **Live-Betrieb:** webklar.com wird aus einem internen Repository gebaut. **Dieses Repository ist der Arbeitsort für den Relaunch;** sein `main` ist nicht live. Was hier fertig ist, wird nach Freigabe des Inhabers übertragen (Abschnitt 12).
- **Befunde der Prüfung (Kurzfassung):**
- Die Startseite erzählt dieselbe Botschaft viermal (Problem, Lösung, Nutzen-Karten, Leistungen). „Leistungen“ und „Ablauf“ sind dieselben vier Schritte, „Potenzialanalyse“ steht fünfmal da.
- Positionierung passt nicht: Die Seite verkauft „Digitalisierung für KMU“ mit CRM, Pipeline und Kennzahlen. Tatsächlich baut WEBklar Websites, Online-Shops, Web-Apps und Automatisierungen für kleine Betriebe.
- Vorlagen-Look: drei Schriftfamilien, vier WebGL-/Effekt-Komponenten auf fast jedem Abschnitt, cyan-blaue Akzentfarbe, Karten mit Kreis-Symbolen, hochzählende Kennzahlen, Technik-Laufband.
- Handy: 14 Bildschirmhöhen bei rund 630 Wörtern, keine horizontale Interaktion, Hover-Effekte ohne Funktion auf Touch, Menü ohne Scrollsperre.
- Der helle Modus war nie gestaltet (dunkle Kopfleiste auf Weiß, Pixelpunkte über Text, unsichtbare Leuchtschrift).
- Die Startseite lädt 476 KB JavaScript (gzip), davon 232 KB nur für den 3D-Hintergrund im Hero. `prefers-reduced-motion` wird nirgends beachtet.
- **Bereits live behoben (Sofort-Paket, 2026-10-01):**
- Kontrast im hellen Modus, Theme-Schalter klickbar, Leuchtschrift-Fehler, Anker unter der Kopfleiste.
- Ehrliche Fakten statt „52+ Projekte“ und „99,9 %“; erfundene Referenzen und Unsplash-Bilder entfernt.
- Eine Telefonnummer; „spätestens am nächsten Werktag“ statt „24 Stunden“; Erfolgsversprechen entschärft.
- Footer und Rechtslinks auf allen Unterseiten (gemeinsames Layout in `src/routes.tsx`), Datenschutzhinweis am Kontaktformular, eigene Seitentitel, deutsche 404-Seite.
- Tests dazu: `src/test/sofort-paket.test.tsx`.
- **Übergangslösung:** Bis zum Relaunch kann die Live-Seite fest auf „dunkel“ stehen (nicht Teil dieses Repos). Der Relaunch gestaltet beide Modi neu.
---
## 3. Wünsche des Inhabers
1. Die leuchtenden Überschriften („Leistungen“, „Ablauf“, „Projekte“) reagieren erst, wenn die Maus schon im Wort ist. Das muss besser werden.
2. Auf dem Handy gibt es viele Fehler, und die Seite ist nur ein langer Scroll. Beispiel: die Schritte als Karten, die man von links nach rechts wischt.
3. „So arbeiten wir“ soll raus. Stattdessen wird das Kundenportal vorgestellt, mit Screenshots aus dem Portal, durch die man scrollen bzw. wischen kann.
4. Nichts soll doppelt vorkommen.
5. Das Programmiersprachen-Laufband: Hat das heute noch Sinn?
6. „Standort Deutschland“ ist sehr wichtig, aber nicht als Etikett in der unteren Leiste. Wunsch: rechts eine Umriss-Karte von Deutschland (SVG) mit einem roten Punkt dort, wo die Server stehen.
7. Mehr mit den Texten spielen, statt sie nur hinzustellen. Modern, aber nicht nach KI aussehend. Übersichtlicher. Eine echte „Experience“. Abstände und Texte wie auf der Apple-Website.
8. Ein Interessent soll sofort und spielerisch verstehen, was WEBklar ist und macht, am besten bildlich.
Wo jeder Wunsch umgesetzt wird: 1 → D10 und 8.4 · 2 → 7.2, 7.3, 9.4, 9.6 · 3 → 7.2 · 4 → 6.3 · 5 → D8 · 6 → 7.1 · 7 → 7.1, 7.3, Abschnitt 8 · 8 → 7.1 (wechselnde Überschrift), 7.2 direkt danach (Portal-Bilder als zweiter Abschnitt).
---
## 4. Bereits getroffene Entscheidungen
| Nr. | Entscheidung |
|---|---|
| D1 | **Positionierung:** „Websites, Shops und Apps für kleine Betriebe“. Das Kundenportal ist der sichtbare Unterschied zu anderen Agenturen. |
| D2 | **Anrede:** durchgehend „Sie“ (wie Verträge und Rechtstexte). |
| D3 | **Telefon:** nur 0176 23726355 (`tel:+4917623726355`). E-Mail: support@webklar.com. |
| D4 | **Keine erfundenen Zahlen oder Referenzen:** keine Verfügbarkeitsprozente, keine Projektzähler, keine Stockfotos und keine KI-Bilder von Personen. Referenzen nur mit Zustimmung des Kunden (AGB § 13); bis dahin gibt es keinen Projekte-Abschnitt. |
| D5 | **Antwortzeit:** nur „spätestens am nächsten Werktag“ (entspricht den Abo-Bedingungen). |
| D6 | **Keine Erfolgsversprechen** (AGB § 2: kein Erfolg geschuldet). SEO nur als gesondert beauftragte Leistung. Betreuung nach dem Start gibt es nur im Abo. |
| D7 | **Serverstandort:** Rechenzentrum von Hetzner in Falkenstein/Vogtland, Sachsen (50,477 N, 12,371 O). Die Aussage gilt nur für **Website und Kundenportal**. E-Mail-Postfächer und Zahlungsabwicklung laufen anders (siehe Datenschutzerklärung). Deshalb nie „alle Daten in Deutschland“ und nie „Ihre Daten aus Sachsen“ schreiben. |
| D8 | **Das Programmiersprachen-Laufband entfällt.** Es beantwortet keine Kundenfrage, und die Inhalte sind nicht belegt. |
| D9 | **„So arbeiten wir“ entfällt.** Der Ablauf wird eine kompakte Schrittzeile im Kundenportal-Abschnitt. |
| D10 | **Der Leuchtschrift-Effekt entfällt.** Abschnitte bekommen echte `h2`-Überschriften, die beim Scrollen sanft erscheinen. Das funktioniert auch auf dem Handy und für Screenreader. |
| D11 | **Heller und dunkler Modus** werden beide gestaltet. Standard folgt dem System, der Umschalter sitzt in der Kopfleiste (Handy: im Menü). |
| D12 | **Vorgehen:** Zuerst entsteht ein anklickbarer Entwurf der neuen Startseite (echte Seite auf einem Zweig), der Inhaber gibt Rückmeldung, danach folgt die Fertigstellung (Abschnitt 5). |
| D13 | **Die Texte der Rechtsseiten bleiben unverändert** (AGB, Impressum, Wartungs-Abo, Hosting-Abo, Datenschutz-Weiterleitung). Markup und CSS-Klassen dieser Seiten dürfen für das neue Layout angepasst werden: Der eigene Kopfbereich (`glass-nav`-Header) entfällt, weil das gemeinsame Layout ihn liefert; Versalien, alte Schriftklassen und alte Button-Klassen werden ersetzt. Wortlaut, Reihenfolge und Gliederung der Texte bleiben gleich. Die Rechtstext-Tests müssen ohne Änderung grün bleiben. |
---
## 5. Vorgehen in Phasen
### Phase 1 – Entwurf der Startseite
- Die neue Startseite wird **als echte Seite** im Code gebaut (kein Wegwerf-Mockup), auf dem Zweig `relaunch`. Sie ersetzt `src/pages/Index.tsx`.
- Dazu gehören die neue Kopfleiste (6.1), alle Abschnitte aus 6.2 außer „Projekte“, der neue Footer (7.8), beide Modi, Desktop und Handy, echte Texte, echte Portal-Screenshots und die Deutschland-Karte.
- Zum Vergleich gibt es **zwei Schriftvarianten** (8.1), umschaltbar über `?schrift=a` bzw. `?schrift=b`. Der Umschalter ist nur im Entwurf vorhanden.
- Unterseiten bekommen in Phase 1 nur so viel Anpassung, dass sie mit der neuen Kopfleiste und dem neuen Footer nicht kaputt aussehen.
- Die alten Startseiten-Abschnitte bleiben noch als Dateien liegen; Phase 2 räumt sie auf.
- Tests, die alte Texte oder alte Navigationspunkte festschreiben, werden in Phase 1 angepasst (9.8). Die Prüfungen gegen verbotene Aussagen bleiben.
- Für den Inhaber gibt es eine Vorschau (O10) und Screenshots jedes Abschnitts in 1440 × 900 und 390 × 844, hell und dunkel, **je Schriftvariante**, im Pull Request.
### Phase 2 – Fertigstellung
- Rückmeldungen des Inhabers einarbeiten und die gewählte Schrift fest einbauen; der Umschalter `?schrift` wird entfernt.
- `/kontakt`, `/ueber-uns` und die 404-Seite bekommen den neuen Stil. Die Rechtsseiten bekommen nur das neue Layout (D13).
- Aufräumen (9.2), Barrierefreiheit (9.6), Leistungsbudget (E5), Browserprüfung (9.8).
### Phase 3 – Livegang
- Nur mit ausdrücklichem OK des Inhabers. Übertragung ins Live-Repository (Abschnitt 12), danach Prüfung der Live-Seite.
---
## 6. Informationsarchitektur
### 6.1 Kopfleiste und Navigation
| Eintrag | Ziel | Hinweis |
|---|---|---|
| Logo „WEBklar“ | `/` | |
| Kundenportal | `/#kundenportal` | |
| Was wir bauen | `/#leistungen` | |
| Preise | `/#preise` | |
| Über uns | `/#ueber-uns` | Abschnitt auf der Startseite. Die ausführliche Seite `/ueber-uns` ist von dort und vom Footer verlinkt. |
| Projekte | `/#projekte` | erscheint nur, wenn es freigegebene Referenzen gibt |
| Button „Projekt besprechen“ | `/kontakt` | |
| Hell/Dunkel-Schalter | – | |
- **Desktop:** Alles in einer Leiste.
- **Handy (unter 900 px):** In der Leiste stehen nur Logo und Menü-Knopf. Der Menü-Knopf ist ein echter `button` mit `aria-expanded` und mindestens 44 × 44 px groß.
- **Menü-Panel auf dem Handy:**
- enthält die Navigation, den Button „Projekt besprechen“ und den Hell/Dunkel-Schalter;
- dunkelt den Hintergrund ab und sperrt das Scrollen der Seite;
- schließt per Escape, per Tipp daneben und bei Auswahl eines Links.
- Alle Anker-Ziele sind `<section id="…">`, damit `scroll-margin-top` greift (bereits global gesetzt für `section[id]`). Von Unterseiten aus springt `ScrollToTop` nach dem Laden der Schriften zum Anker (vorhanden in `src/App.tsx`).
### 6.2 Startseite (Reihenfolge)
| # | Abschnitt | Anker | Typ | Kernaussage |
|---|---|---|---|---|
| 1 | Hero mit Deutschland-Karte | – | Bühne | Wir bauen Ihre Website, Ihren Shop, Ihre Web-App oder Ihre Automatisierung. Website und Kundenportal laufen auf dem Server in Falkenstein. |
| 2 | Kundenportal | `#kundenportal` | Bühne | Sie sehen Ihr Projekt live: Vorschau, Fortschritt, Chat, Rechnungen. Darunter die Schrittzeile. |
| 3 | Was wir bauen | `#leistungen` | Beleg | Vier Produkte, dazu der Umzug bestehender Websites |
| 4 | Projekte | `#projekte` | Beleg | **nur mit freigegebenen Referenzen**, sonst entfällt der Abschnitt komplett |
| 5 | Preise | `#preise` | Beleg | Angebot vorher, Hosting-Abo 20 €, Wartungs-Abo 50 €, weitere Arbeit nur nach Freigabe |
| 6 | Über uns | `#ueber-uns` | Beleg | Wer dahinter steckt, wo |
| 7 | Kontakt | `#kontakt` | Bühne | Ein Satz, ein Button, E-Mail und Telefon |
| – | Footer | – | – | Navigation, Rechtliches, Anschrift |
- **Bühne** = etwa eine Bildschirmhöhe auf dem Desktop (`min-height: 100svh`), ein Satz plus ein Bild oder Objekt.
- **Beleg** = Höhe nach Inhalt, Text in einer Spalte von höchstens 46 rem.
- Das Kundenportal steht an zweiter Stelle, damit gleich nach dem Hero ein Bild zeigt, was WEBklar anders macht (Wunsch 8).
### 6.3 Jede Aussage hat genau einen Ort
| Aussage | Einziger Ort auf der Startseite |
|---|---|
| Positionierung „Websites, Shops und Apps für kleine Betriebe“ | kleine Zeile im Hero |
| Serverstandort Falkenstein | Kartenpunkt im Hero |
| Kundenportal und Live-Vorschau | Abschnitt Kundenportal (im Hero nur ein Satz im Untertext, ohne Funktionsliste) |
| Ablauf | Schrittzeile im Kundenportal-Abschnitt |
| Kennenlernen (15 Minuten, kostenlos) | Kontakt-Abschnitt; die Schrittzeile sagt nur „Kennenlernen“ |
| Preise | Abschnitt Preise |
| Winnweiler / Pfalz | Abschnitt Über uns (die Anschrift im Footer ist Pflichtangabe und zählt nicht) |
| E-Mail und Telefon | Kontakt-Abschnitt (Footer ohne eigene Kontaktspalte) |
- Den Erstkontakt nennt der Button überall „Projekt besprechen“. Das Wort „Potenzialanalyse“ entfällt.
- Die heutige Faktenzeile im Hero („Falkenstein / Live-Vorschau / ab 20 €“) entfällt.
### 6.4 Unterseiten
| Route | Inhalt im Relaunch |
|---|---|
| `/kontakt` | Formular mit der bestehenden Logik (Appwrite), neue Gestaltung, der Datenschutzhinweis bleibt |
| `/ueber-uns` | ausführlichere Fassung des Team-Abschnitts; der Block „Der Unterschied“ entfällt |
| `/wartungs-abo`, `/hosting-abo`, `/agb`, `/impressum` | Texte unverändert, neues Layout (D13) |
| `/datenschutz` | bleibt Weiterleitung auf die Datenschutzerklärung im Kundenportal |
| `*` | 404 im neuen Layout |
### 6.5 Seitentitel, Beschreibung, Vorschaubild
- **Startseite:**
- Titel: „WEBklar – Websites, Shops und Apps für kleine Betriebe“
- Beschreibung: „WEBklar baut Websites, Online-Shops und Web-Apps. Im Kundenportal sehen Sie jeden Schritt, mit Live-Vorschau Ihrer Website.“
- **Wo ändern:** `index.html` (`title`, `description`, `og:*`, `twitter:*`), `DEFAULT_TITLE` und `DEFAULT_DESCRIPTION` in `src/hooks/use-page-meta.ts` und `src/pages/Index.tsx`.
- **`/kontakt`:**
- Titel: „Projekt besprechen“
- Beschreibung: „Beschreiben Sie Ihr Vorhaben in zwei Sätzen. WEBklar meldet sich spätestens am nächsten Werktag.“
- **`public/og-image.png`** zeigt noch die alte Positionierung. Ein neues Bild (1200 × 630, höchstens 150 KB) im neuen Stil entsteht in Phase 2: Logo, Positionierungssatz und Karte, ohne Personen.
---
## 7. Abschnitte im Detail
Texte in **[eckigen Klammern]** brauchen die Freigabe des Inhabers (Abschnitt 11). Bis dahin gilt der jeweils genannte Ersatztext. Alle anderen Texte sind Entwürfe, die so eingebaut werden.
### 7.1 Hero mit Deutschland-Karte
**Text (links):**
- **Kleine Zeile darüber:** „Websites, Shops und Apps für kleine Betriebe“
- **Überschrift `h1` mit wechselndem Teil:** „Wir bauen **Ihre Website.**“ → „Wir bauen **Ihren Online-Shop.**“ → „Wir bauen **Ihre Web-App.**“ → „Wir bauen **Ihre Automatisierung.**“ → zurück zu „Wir bauen **Ihre Website.**“
- Es wechselt der ganze Teil nach „Wir bauen“ (wegen Ihre/Ihren).
- **Ablauf:** Wechsel alle 2,4 s mit 400 ms Überblendung. Nach **einem** Durchlauf bleibt „Ihre Website.“ stehen.
- **Anhalten:** Während des Durchlaufs gibt es einen unauffälligen Pause-Knopf (`aria-label` „Wechselnde Überschrift anhalten“, Tippfläche mindestens 44 × 44 px; WCAG 2.2.2). Hover oder Fokus auf der Überschrift halten den Wechsel ebenfalls an.
- **Ohne Bewegung:** Bei `prefers-reduced-motion: reduce` steht fest „Wir bauen Ihre Website.“, ohne Knopf. Ist der Tab nicht sichtbar, pausiert der Wechsel.
- **Screenreader:** Im `h1` steht ein fester, visuell versteckter Satz: „Wir bauen Websites, Online-Shops, Web-Apps und Automatisierungen.“ Der wechselnde Teil ist `aria-hidden`.
- **Layout:** Der Wechsel darf nichts springen lassen. Die Varianten liegen übereinander, die Breite richtet sich nach der längsten.
- **Untertext:** „In unserem Kundenportal sehen Sie jederzeit, wie weit Ihr Projekt ist – mit Live-Vorschau Ihrer Website.“
- **Buttons:** „Projekt besprechen“ (→ `/kontakt`, Hauptbutton) und „Kundenportal ansehen“ (→ `#kundenportal`, zweitrangig gestaltet).
**Karte (rechts):**
- **Datei:** `docs/relaunch/assets/deutschland-umriss.svg` (ein Pfad, nur Linie, `stroke="currentColor"`, viewBox `0 0 663 892`). Als Inline-SVG-Komponente einbauen.
- **Roter Punkt Falkenstein:** `cx = 469.6`, `cy = 520.5`. Ist schon in der Datei und geprüft: liegt im Südwesten Sachsens, 14 km von der tschechischen Grenze.
- **Beschriftung am Punkt:** „Falkenstein, Sachsen“, darunter klein „Hier laufen Ihre Website und das Kundenportal.“ Mindestgröße 14 px.
- **[Optional, O8]** zweiter, hohler Punkt für das Büro in Winnweiler (ca. 49,567 N, 7,850 O → `cx ≈ 146.7`, `cy ≈ 622.2`, Formel in der README). Falls ja, steht „Hier sitzen wir“ dort, und der Ort wird im Abschnitt Über uns nicht noch einmal genannt.
- **Farben:** Linie `var(--line-strong)`, Punkt `var(--accent)`.
- **Bewegung beim ersten Erscheinen:**
- Der Umriss zeichnet sich in 1,6 s (`stroke-dashoffset`; dafür `pathLength="1"` setzen und `vector-effect="non-scaling-stroke"` entfernen, die Strichstärke dann in der viewBox angeben).
- Danach erscheint der Punkt, dann pulsiert ein Ring um ihn dreimal (Skalierung 1 → 2,6, je 2,4 s, Deckkraft abnehmend) und bleibt dann stehen.
- Bei reduzierter Bewegung ist alles sofort sichtbar, ohne Ring.
- **Barrierefreiheit:** Die Karte ist Dekoration (`aria-hidden="true"`). Die Aussage steht als Text daneben.
**Layout:**
- **Desktop:** Zweispaltig, Text links (etwa 55 %), Karte rechts mit etwa 80 svh Höhe. Bei 1280 × 800 ist der ganze Hero ohne Scrollen sichtbar.
- **Handy:** Text oben, Karte darunter (40–46 svh hoch, mittig). Die Beschriftung am Punkt bleibt lesbar.
- **Hintergrund:** ruhige Fläche (`--bg`), kein WebGL-, Video- oder Effekthintergrund.
### 7.2 Kundenportal (`#kundenportal`)
**Text:**
- **Überschrift:** „Ihr Projekt. Live im Kundenportal.“
- **Einleitung:** „Sie sehen, was wir zuletzt erledigt haben, öffnen die Live-Vorschau Ihrer Website und schreiben uns im Chat – mit Fotos, Dateien oder Sprachnachricht. Ihre Rechnungen liegen ebenfalls dort, und aufs Handy installieren Sie das Portal wie eine App.“
- Die Installierbarkeit ist geprüft: Das Portal hat ein Web-App-Manifest und einen Service Worker und ist auf dem Handy als App installierbar.
**Funktionen in dieser Reihenfolge** (Bilder in `docs/relaunch/assets/portal/`, Dateiendung `.webp`):
| # | Titel | Bildunterschrift | Desktop-Bild | Handy-Bild |
|---|---|---|---|---|
| 1 | Live-Vorschau | „Ihre Website, noch während wir daran bauen.“ | `desktop-1-projekte` | `phone-1-projekte` |
| 2 | Fortschritt | „Was wir zuletzt erledigt haben – in einfachen Sätzen.“ | `desktop-2-fortschritt` | `phone-3-projektdetails` |
| 3 | Chat | „Frage, Foto oder Sprachnachricht direkt ans Team.“ | `desktop-3b-chat-foto` | `phone-4b-chat-foto` |
| 4 | Rechnungen | „Ansehen, bezahlen, herunterladen.“ | `desktop-4-rechnungen` | `phone-5-rechnungen` |
| 5 | Funktionen | „Bausteine zum Dazubuchen, zum Beispiel eine News-Pinnwand für Ihre Website.“ | `desktop-7-news-pinnwand` | `phone-8-news-vorschau` |
| 6 | Als App | „Aufs Handy installieren, fertig.“ | – (siehe unten) | `phone-2-projekte-lang` |
- **[Nur wenn O9 = ja]:** Zeile 4 heißt dann „Rechnungen und Verträge“ und bekommt zusätzlich `phone-6-vertraege`. Eine weitere Zeile „Stundenkonto“ mit `desktop-5-stundenkonto` kommt hinzu, weil diese Ansichten im Portal noch nicht für alle Kunden freigeschaltet sind.
- **Nicht bewerben:** „Terminplaner“ und „Analyse“. Sie sind im Portal nur Platzhalter („kommt bald“).
**Desktop (ab 900 px):**
- Eine festgepinnte Bühne (`position: sticky`, Scrollstrecke etwa 3–4 Bildschirmhöhen).
- **Links** steht die Liste der Funktionen. Die aktive Funktion ist hervorgehoben, ein roter Punkt dient als Marker.
- **Rechts:**
- Ein Desktop-Fenster: 1 px Linie, 12–14 px Rundung, oben eine schlichte Leiste mit der Adresse **project.webklar.com**, keine Ampelpunkte.
- Unten rechts überlappt ein Handy-Rahmen das Fenster, etwa 30 % der Breite.
- Die Bilder wechseln per Überblendung, sobald die nächste Funktion zur Hälfte im Blick ist (`IntersectionObserver`).
- **Bei Schritt 6** blendet das Desktop-Fenster aus. Der Handy-Rahmen rückt in die Mitte und wird größer. Darin schiebt sich der lange Screenshot `phone-2-projekte-lang` mit dem Seiten-Scroll nach oben. Das ist `transform: translateY`, gekoppelt an den Scrollfortschritt, kein eigener Scrollbereich.
**Handy (unter 900 px):**
- Keine festgepinnte Bühne.
- Stattdessen eine **Wisch-Reihe** (9.4) mit je einem Handy-Screenshot im Rahmen und der Bildunterschrift darunter.
- Die letzte Karte „Als App“ zeigt `phone-2-projekte-lang`. Es schiebt sich langsam nach oben, solange die Karte sichtbar ist.
- Bei O9 = ja höchstens 7 Karten.
**Schrittzeile unter dem Portal** (ersetzt „So arbeiten wir“):
- Text: „**1** Kennenlernen · **2** Angebot mit Preis · **3** Bauen mit Vorschau und Ihrer Freigabe · **4** Online – Hosting und Pflege auf Wunsch im Abo“
- **Desktop:** eine Zeile mit vier Punkten.
- **Handy:** vier kleine Karten in einer Wisch-Reihe (Wunsch 2).
**Rahmen und Bildinhalt:**
- Es gibt **einen** Rahmenstil für alle Bilder. Er muss auf hellem und dunklem Grund funktionieren. Das Portal selbst ist dunkel und violett; das ist Bildinhalt, nicht Seitenfarbe.
- Die Screenshots zeigen nur die erfundene „Bäckerei Sonnenschein“. Nie echte Kundendaten verwenden, auch nicht verpixelt.
### 7.3 Was wir bauen (`#leistungen`)
**Text:**
- Überschrift: „Was wir für Sie bauen.“
- Einleitung:
- **Standard:** „Die meisten Betriebe starten mit einer Website. Wenn mehr nötig ist, bauen wir mehr.“
- **[Bei O2 = ja]:** „Die meisten Betriebe starten mit einer Website: Startseite und bis zu drei Unterseiten, mit Texten, Design und Anpassung für Handy und Computer. Wenn mehr nötig ist, bauen wir mehr.“
**Vier nummerierte Einträge**, als **Textzeilen statt Karten mit Symbolen**:
1. **Website**: „Startseite, Unterseiten, Impressum und Datenschutz – gestaltet, getextet und online.“
2. **Online-Shop**: „Warenkorb, Kasse, Zahlung und Versand. Auf Wunsch angebunden an Ihre Warenwirtschaft.“
3. **Web-App**: „Eigene Software für Ihren Betrieb mit Login, Verwaltung und Auswertungen, im Browser und auf dem Handy.“
4. **Automatisierung**: „Was sich ständig wiederholt, erledigt der Rechner: Bestätigungen, Rechnungen, Dokumente auslesen, ein KI-Assistent mit Ihrem Firmenwissen.“
**Schlusszeile:** „Sie haben schon eine Website? Wir ziehen sie um – mit Domain, Inhalten und Postfächern.“
**Mit Texten spielen:**
- Die Einleitung hellt sich beim Scrollen Wort für Wort auf (gedimmt 0,18 → voll), höchstens 30 Wörter.
- Nur wenn `animation-timeline` unterstützt wird und keine reduzierte Bewegung gewünscht ist. Sonst steht der Text normal da.
**Layout:**
- **Desktop:** große Überschrift links, nummerierte Liste rechts, viel Weißraum.
- **Handy:** die vier Einträge als **Wisch-Reihe** (9.4). Jede Karte ist 80 vw breit, die nächste ist angeschnitten sichtbar, dazu ein Zähler „1 / 4“.
### 7.4 Projekte (`#projekte`)
- **Solange keine Referenz freigegeben ist, entfällt der Abschnitt komplett.** Ist das Daten-Array leer, werden Abschnitt und Menüpunkt nicht gerendert.
- **Sobald Freigaben vorliegen:**
- Überschrift: „Echte Websites. Echte Betriebe.“
- Untertext: „Nur Projekte, die online sind und die wir zeigen dürfen.“
- Je Projekt ein **eigener** Screenshot (kein Stockfoto), ein echter Link und eine Zeile Beschreibung.
- Desktop mit großen Bildern, Handy als Wisch-Reihe, höchstens 5 Projekte.
### 7.5 Preise (`#preise`)
**Text:**
- Überschrift: „Was es kostet. Vorher.“
- Einleitung:
- **Standard:** „Für Ihr Projekt bekommen Sie vorher ein Angebot mit Preis. Für den Betrieb danach gibt es zwei Abos. Alle Preise sind Endpreise; als Kleinunternehmer (§ 19 UStG) berechnen wir keine Umsatzsteuer.“
- **[Bei O1 = ja]:** Der erste Satz wird ersetzt durch den freigegebenen Preisanker, z. B. „Eine Website gibt es ab … €.“
**Zwei Blöcke** (Desktop nebeneinander, Handy untereinander). Wortlaut aus den Abo-Seiten:
- **Hosting-Abo, 20 € im Monat:** „Für statische Websites: Hosting mit SSL-Zertifikat, täglicher Datensicherung und Überwachung der Erreichbarkeit, eine Domain und ein E-Mail-Postfach mit 5 GB. 12 Monate Mindestlaufzeit.“ → Link „Details zum Hosting-Abo“ (`/hosting-abo`)
- **Wartungs-Abo, 50 € im Monat:** „Für alle Websites und Web-Anwendungen, auch mit Login, Shop oder Datenbank: zusätzlich Sicherheitsupdates für die Software Ihrer Website, beliebig viele Postfächer mit zusammen 5 GB und eine Stunde Arbeitszeit pro Monat. 12 Monate Mindestlaufzeit.“ → Link „Details zum Wartungs-Abo“ (`/wartungs-abo`)
**Zeile darunter:** „Weitere Arbeit nur mit Ihrer Freigabe: 100 € pro Stunde, abgerechnet in 15-Minuten-Schritten.“ Das gilt für beide Abos, so steht es auf beiden Abo-Seiten.
**Eine Quelle für alle Angaben:**
- Preise, Laufzeiten und Leistungen müssen mit den Seiten `/hosting-abo` und `/wartungs-abo` übereinstimmen.
- Die Werte liegen in **einem** Datenmodul `src/data/abos.ts`, das die Startseite nutzt.
- Die Abo-Seiten selbst werden **nicht** auf das Modul umgebaut (D13). Stattdessen prüft ein Test, dass Preis, Laufzeit und Speicherangaben auf Startseite und Abo-Seiten gleich sind.
**Darstellung:** Preise zählen nicht hoch. Zahlen mit `tabular-nums`.
### 7.6 Über uns (`#ueber-uns`)
**Text:**
- Überschrift: „Wer hinter WEBklar steckt.“
- Text:
- **Standard:** „Wir sind ein kleines Team aus Winnweiler in der Pfalz. Sie sprechen bei uns direkt mit den Leuten, die Ihre Website bauen.“
- **[Bei O4 = ja]:** Namen und Rollen kommen dazu, z. B. „Justin kümmert sich um Websites, Angebote und Betreuung, Kenso um Shops, Software und Automatisierung.“
- **[Bei Freigabe]:** „Erreichbar Montag bis Freitag von 9 bis 17 Uhr.“
**Fotos:**
- Nur echte Fotos des Teams, die der Inhaber liefert.
- **Bis dahin keine Fotos**: keine Platzhalter-Gesichter, keine KI-Bilder, keine Initialen-Kreise.
**Link:** „Mehr über uns“ → `/ueber-uns`.
### 7.7 Kontakt (`#kontakt`)
- **Überschrift:** „Erzählen Sie uns, was Sie vorhaben.“
- **Text:** „Zwei Sätze genügen. Wir melden uns spätestens am nächsten Werktag. Im ersten Gespräch klären wir in 15 Minuten, ob und wie wir helfen können – kostenlos und unverbindlich.“
- **Button:** „Projekt besprechen“ (→ `/kontakt`), darunter E-Mail und Telefon.
- **Gestaltung:** Bühne mit viel Ruhe, kein Effektrahmen.
### 7.8 Footer
- **Links im Footer:** Logo, „WEBklar GbR“ und die Anschrift (Pflichtangabe, aus dem Impressum).
- **Spalte Seiten:**
- Startseite → `/`
- Kundenportal → `/#kundenportal`
- Was wir bauen → `/#leistungen`
- Preise → `/#preise`
- Über uns → `/ueber-uns`
- Kontakt → `/kontakt`
- Wartungs-Abo → `/wartungs-abo`
- Hosting-Abo → `/hosting-abo`
- **Spalte Rechtliches:**
- AGB, Impressum, Datenschutz
- „Verträge hier kündigen“ und „Vertrag widerrufen“: Beide bleiben hervorgehoben wie heute, weil sie rechtlich vorgeschrieben sind. Die Ziele bleiben wie im heutigen `Footer.tsx`.
- **Gestaltung:** Passt sich hellem und dunklem Modus an, also kein harter schwarzer Block im hellen Modus. Kein animiertes Raster.
---
## 8. Gestaltung
### 8.1 Schrift
- **Eine** Schriftfamilie, **zwei** Gewichte (400 und 600). Inter, Space Grotesk und Poppins entfallen.
- Im Entwurf gibt es zwei Varianten zum Vergleich:
- **A – Systemschrift:** `-apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif`. Auf Apple-Geräten ist das echtes SF Pro, ohne Lizenz- oder Ladefrage.
- **B – selbst gehostete Variable-Schrift unter SIL Open Font License**, z. B. „Instrument Sans“, als `woff2` in `public/fonts/`. Nichts von Google Fonts oder anderen fremden Servern laden (Datenschutz).
- Vor der Entscheidung an echten deutschen Überschriften mit Umlauten und ß prüfen.
- **Keine Versalien-Überschriften** mehr, sondern normale Satzschreibung. Überschriften mit `text-wrap: balance`, Absätze mit `text-wrap: pretty`, dazu `hyphens: auto` mit `lang="de"`.
**Größen (Token):**
```css
:root {
--fs-hero: clamp(2.5rem, 1.569rem + 3.81vw, 5rem); /* 40 → 80 px */
--fs-h2: clamp(2rem, 1.444rem + 2.29vw, 3.5rem); /* 32 → 56 px */
--fs-lead: clamp(1.1875rem, 1.05rem + 0.6vw, 1.625rem); /* 19 → 26 px */
--fs-body: 1.0625rem; /* 17 px, Zeilenhöhe 1.47 */
--fs-small: 0.875rem; /* 14 px, kleinste Textgröße überhaupt */
--measure-text: 46rem;
--container: 1100px;
}
h1, h2 { font-weight: 600; line-height: 1.05; letter-spacing: -0.015em; }
```
- Abstand von der Überschrift zum ersten Absatz: 1,2 em.
- Fließtext und Formularfelder mindestens 16 px, sonst zoomt iOS.
### 8.2 Farben
**Grundsatz:**
- Neutral, also nahezu Schwarz und Weiß mit wenigen Grautönen, plus **eine** Akzentfarbe: **Rot** wie der Kartenpunkt.
- Das cyan-blaue Primär und das Türkis im dunklen Modus entfallen.
- Rot nur für den Kartenpunkt, aktive Zustände und Marker. Der Hauptbutton ist neutral: dunkel auf hell, hell auf dunkel.
**Startwerte** (geprüfte Kontraste, Feinabstimmung im Entwurf, O7):
| Token | Hell | Dunkel | Zweck |
|---|---|---|---|
| `--bg` | `#ffffff` | `#0b0b0c` | Seitenfläche |
| `--bg-alt` | `#f5f5f7` | `#161618` | jeder zweite Abschnitt |
| `--fg` | `#1d1d1f` | `#f5f5f7` | Text (≥ 15 : 1) |
| `--fg-muted` | `#6e6e73` | `#a1a1a6` | Nebentext (hell ≥ 4,66 : 1 auch auf `--bg-alt`, dunkel ≥ 7 : 1) |
| `--line` | `#d2d2d7` | `#2c2c2e` | Trennlinien, Rahmen |
| `--line-strong` | `#86868b` | `#636366` | Kartenumriss |
| `--accent` | `#e5251c` | `#e5251c` | Punkt und Marker (Nicht-Text, ≥ 3 : 1 auf allen Flächen) |
| `--accent-text` | `#c81e16` | `#ff5a4f` | Rot als Text oder Link (hell ≥ 5,28 : 1, dunkel ≥ 5,88 : 1, auch auf `--bg-alt`) |
| `--focus` | `#c81e16` | `#ff5a4f` | Fokusrahmen |
- Alle Kombinationen prüft ein Test auf Kontrast. Heute geschieht das schon so in `src/test/sofort-paket.test.tsx`, über die Token in `index.css`.
- Das Logo bleibt (`src/components/Logo.tsx`), Schreibweise „WEBklar“.
### 8.3 Abstände und Flächen
- **Abstände:** zwischen Abschnitten 120–160 px auf dem Desktop und 72–96 px auf dem Handy. Container 1100 px breit, Textspalte 46 rem.
- **Flächen:** Flächenwechsel statt Rahmen. Abschnitte stehen abwechselnd auf `--bg` und `--bg-alt`, der Text steht frei auf der Fläche.
- **Formen:** Rundungen und Rahmen nur um Bilder und Bildschirme.
### 8.4 Bewegung
**Grundregeln:**
- Eine Kurve: `cubic-bezier(.2, .8, .2, 1)`. Zwei Dauern: 200 ms für Zustände, 600 ms für das Erscheinen. Versatz höchstens 60 ms. Kein Federn.
- **Erscheinen beim Scrollen** per CSS `animation-timeline: view()` innerhalb von `@supports`. Der Grundzustand ist **sichtbar**: Ohne Unterstützung steht alles einfach da.
- **Reduzierte Bewegung:** Alles läuft hinter `@media (prefers-reduced-motion: no-preference)`. Bei `reduce` gibt es keine Bewegung außer Farbwechseln.
- **Animiert werden** nur `transform` und `opacity`.
- **Nicht erlaubt:** Scroll-Hijacking und `scroll-snap` auf der ganzen Seite.
- **Sticky:** Vorfahren von `position: sticky` nutzen `overflow: clip` statt `hidden`.
- **Hover:** Hover-Effekte nur in `@media (hover: hover) and (pointer: fine)`.
**Ausdrücklich erlaubte Ausnahmen**, auch nur ohne reduzierte Bewegung:
| Element | Abweichung |
|---|---|
| Kartenumriss (7.1) | `stroke-dashoffset` über 1,6 s |
| Ring um den Punkt (7.1) | 3 × 2,4 s, dann Stillstand |
| Wechselnde Überschrift (7.1) | 400 ms Überblendung alle 2,4 s, ein Durchlauf |
| Wort-für-Wort-Aufhellen (7.3) | an den Scrollfortschritt gekoppelt |
### 8.5 Regeln gegen den Vorlagen-Look
1. **Schrift:** eine Familie, zwei Gewichte.
2. **Farbe:** eine Akzentfarbe (Rot), und zwar mit Bedeutung.
3. **Hintergründe:** keine Effekte, also kein WebGL, keine Glüh-Ränder, kein Pixelraster, kein Glas.
4. **Layout:** kein Raster aus drei Karten mit Kreis-Symbolen. Stattdessen nummerierte Textzeilen, eine große Aussage plus Liste oder asymmetrische Layouts.
5. **Texte, die nur WEBklar sagen kann:** Falkenstein, Kundenportal, Winnweiler. Verboten sind „maßgeschneidert“, „ganzheitlich“, „innovativ“, „nahtlos“, „auf das nächste Level“, „Hebel“, „Tool-Inseln“, „Pipeline“ und „Potenzialanalyse“.
6. **Zahlen:** keine Kennzahlen ohne Beleg, keine hochzählenden Zahlen.
7. **Bewegung:** nur mit Aufgabe (8.4).
8. **Flächen:** wenige Kästen, viel Fläche.
9. **Bilder:** nur echtes Bildmaterial, also Portal-Screenshots mit Demodaten, echte Teamfotos und echte Projekte.
10. **Austausch-Test:** siehe E7.
---
## 9. Technik
### 9.1 Bleibt
- **Grundgerüst:** Vite, React, TypeScript, Tailwind und React Router mit allen bestehenden Routen.
- **Layout:** `src/routes.tsx` mit dem gemeinsamen Unterseiten-Layout `SubpageLayout`. Es wird zum neuen Layout mit Kopfleiste und Footer.
- **Seiten-Helfer:** `usePageMeta` setzt Titel und Beschreibung je Route. `ScrollToTop` springt zu Ankern.
- **Kontaktformular:** Die Logik in `src/pages/Contact.tsx` und `src/lib/appwrite.ts` bleibt, mit dem Datenschutzhinweis.
- **Rechtsseiten:** Ihre Texte und Tests bleiben: `src/test/abos.test.tsx`, `agb-haftung.test.tsx`, `agb-verguetung.test.tsx`.
- **Hell/Dunkel:** `next-themes` mit der Klassen-Strategie und dem Standard „system“, dazu das Skript in `index.html` gegen Aufblitzen.
### 9.2 Fällt weg (Phase 2)
Vor jedem Löschen prüfen, dass nichts mehr die Datei importiert.
- **Effekt-Komponenten und ihre Pakete:**
- Komponenten: `Silk`, `PixelBlast`, `LightRays`, `BorderGlow`, `FlickeringGrid`, `TextHoverEffect`, `CountUp`.
- Pakete: `three`, `@react-three/fiber`, `postprocessing`, `ogl`.
- **Alte Startseiten-Abschnitte:**
- `Partners` (das Laufband) mit `public/svg/*`.
- `ProblemSection`, `SolutionSection`, `Values`, `Services`, `Process`.
- Die Hintergrundbilder `problem*.jpg`, `loesung.jpg`, `backgroud_effect*`.
- **Alte Schriften:** `src/fonts.css` und `public/fonts/inter-*`, `space-grotesk-*`. Sie werden durch die gewählte Schrift ersetzt.
- **Alte Buttons:** das CSS für die Buchstaben-Animation.
- **Analytics:** `AnalyticsProvider`, `src/lib/analytics.ts`, die `data-analytics-*`-Attribute und `gtag`. Heute ist das inaktiv; es würde aber Google laden, und der Relaunch nutzt kein Tracking (Abschnitt 13).
- **Ungenutzte shadcn-Komponenten und Pakete:** laut Prüfung rund 45 tote Dateien und 29 Pakete, die nur von toten Dateien genutzt werden.
- **Tests:**
- Tests, die nur gelöschte Komponenten prüfen, werden entfernt oder auf die neuen Abschnitte umgeschrieben.
- Die Quelltext-Prüfungen gegen verbotene Aussagen bleiben in jedem Fall: alte Telefonnummer, Unsplash, „24 Stunden“, „99,9“, Erfolgsversprechen.
### 9.3 Neu
- **Startseite:** `src/pages/Index.tsx` mit den Abschnitten aus 7, je Abschnitt eine Komponente in `src/components/home/`:
- `HeroKarte.tsx`
- `Kundenportal.tsx`
- `Leistungen.tsx`
- `Preise.tsx`
- `UeberUns.tsx`
- `KontaktBuehne.tsx`
- **`src/components/DeutschlandKarte.tsx`:** Inline-SVG aus dem Asset. Die Punkte kommen als Props, dazu die Formel aus der README.
- **`src/components/WischReihe.tsx`:** die wiederverwendbare horizontale Reihe (9.4).
- **`src/components/GeraeteRahmen.tsx`:** Desktop-Fenster mit Adressleiste und Handy-Rahmen.
- **`src/data/abos.ts`:** Preise und Leistungen für die Startseite (7.5).
- **Design-Token:** neu in `src/index.css` und `tailwind.config.ts` (8.1, 8.2).
### 9.4 Wisch-Reihe (unter 900 px)
- **Technik:** natives CSS ohne Bibliothek.
- Reihe: `display: flex; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: 16px; overscroll-behavior-x: contain;`
- Karten: `flex: 0 0 80vw; max-width: 380px; scroll-snap-align: start`.
- **Wischen erkennbar machen:**
- Die nächste Karte ist angeschnitten sichtbar. Das ist der wichtigste Hinweis.
- Dazu ein Zähler „2 / 4“. Die aktive Karte bestimmt ein `IntersectionObserver` (mindestens 60 % sichtbar).
- **Barrierefreiheit:**
- Liste mit `role="list"`.
- Bereich mit `tabindex="0"` und `aria-label`, z. B. „Vier Leistungen, seitlich wischen“.
- Die Fokusreihenfolge entspricht der Anzeige.
- Kein `aria-live`, keine automatische Bewegung.
- **Ab 900 px** zeigt jeder Abschnitt seine Desktop-Darstellung (Liste, Raster oder Bühne). Auf dem Desktop bleibt keine Wisch-Reihe übrig.
- **Grenze:** Höchstens 7 Karten je Reihe.
### 9.5 Bilder
- **Format und Auslieferung:**
- WebP mit Qualität 80–85.
- Über `srcset` ausliefern: Desktop 800, 1200 und 1600 w, Handy 390 und 780 w.
- `loading="lazy"` (außer Bildern im ersten Bildschirm), `decoding="async"`, feste `width`/`height`.
- **Budgets:**
- Ein einzelnes Desktop-Bild höchstens 120 KB, ein einzelnes Handy-Bild höchstens 60 KB.
- Was beim ersten Erreichen des Portal-Abschnitts sichtbar wird, höchstens 300 KB.
- Alle Bilder des Abschnitts für eine Gerätegröße zusammen höchstens 900 KB.
- **Rohdateien:** Die Dateien in `docs/relaunch/assets/portal/` sind Rohexporte in doppelter Auflösung und müssen verkleinert werden. Das geht einmalig vorab oder per Build-Skript mit `sharp`. In `public/` landen nur die verkleinerten Fassungen.
- **Alt-Texte:** jedes Bild bekommt einen, z. B. „Kundenportal: Projektübersicht mit Live-Vorschau der Website“.
### 9.6 Barrierefreiheit und Handy
- **Struktur:** genau ein `h1` je Seite, jeder Abschnitt ein `h2`, dazu die Landmarken `header`, `nav`, `main`, `footer`.
- **Bedienung:** sichtbarer Fokus (`--focus`), die Tastatur erreicht alles, Tippflächen mindestens 44 × 44 px.
- **Formulare:** mit `autocomplete` (name, email, organization) und `aria-live` für Fehler und Erfolg.
- **Handy-Checkliste** (aus der Prüfung):
- kein seitlicher Überlauf bei 360 und 390 px;
- `svh`/`dvh` statt `vh`;
- Safe-Area-Ränder (`env(safe-area-inset-*)`) bei festen Elementen;
- Menü mit Scrollsperre (6.1);
- Anker landen unter der Kopfleiste;
- Text mindestens 14 px, Fließtext 16–17 px;
- nichts ist nur per Hover erreichbar;
- Querformat 844 × 390 hat Inhalt und Buttons ohne erzwungene Höhe.
### 9.7 Leistung
- **Budgets:** E5, dazu CSS höchstens 15 KB gzip.
- **Routen per `React.lazy`:** Die Rechtsseiten müssen nicht im Startpaket sein. Die Render-Tests in `src/test/` fragen dann asynchron ab (`await screen.findBy…`).
- **Appwrite-SDK:** erst beim Absenden des Formulars laden (`import()`).
- **Keine fremden Server:** Schriften, Bilder und Skripte selbst hosten.
### 9.8 Tests und Prüfung
- **`npm test`** (Vitest und Testing Library) prüft:
- Abschnitte rendern mit `h2`, und die Anker passen zur Navigation.
- Wisch-Reihen haben `tabindex` und `aria-label`.
- Preise auf der Startseite stimmen mit den Abo-Seiten überein.
- Verbotene Aussagen und Wörter kommen nicht vor (8.5 Punkt 5).
- Die Rechtsseiten-Tests laufen unverändert grün.
- Die Kontrast-Token beider Modi erreichen mindestens 4,5 : 1.
- **Anpassung bestehender Tests in Phase 1:**
- In `src/test/sofort-paket.test.tsx` dürfen die Stellen geändert werden, die alte Texte oder Strukturen festschreiben: Meta-Beschreibung mit „Potenzialanalyse“, Navigationspunkte „Leistungen“ und „Ablauf“, Texte auf `/kontakt` und `/ueber-uns` sowie Tests gelöschter Komponenten.
- Nicht entfernt werden die Quelltext-Prüfungen gegen verbotene Aussagen.
- Die drei Rechtstext-Testdateien bleiben unverändert.
- **Browserprüfung** (Playwright) bei 390 × 844 und 1440 × 900, hell und dunkel:
- kein seitlicher Überlauf, keine Konsolenfehler;
- Seitenlänge auf dem Handy höchstens 8 Bildschirme;
- Karte und Punkt sind sichtbar, Wisch-Reihen lassen sich wischen;
- bei `prefers-reduced-motion: reduce` gibt es keine Bewegung;
- Screenshots für den Inhaber.
- **Vor jedem Push:** `npm test`, `npm run build` und `npm run lint` (keine **neuen** Fehler gegenüber `main`).
---
## 10. Rechtliche Leitplanken
- **Rechtstexte:** Die Texte von AGB, Impressum, Abo-Seiten und Datenschutzerklärung nicht ohne ausdrückliche Freigabe des Inhabers ändern (D13). Die Tests sichern das ab.
- **Inhalte:**
- keine Erfolgsversprechen und keine unbelegten Zahlen;
- keine Referenzen ohne Zustimmung;
- keine Stockfotos und keine KI-Bilder von Personen.
- **Preise:** als Endpreise, mit Hinweis auf § 19 UStG.
- **Server:** Die Aussage zum Serverstandort gilt nur für Website und Kundenportal (D7).
- **Kontaktformular:** Der Datenschutzhinweis mit Link bleibt direkt am Formular.
- **Keine Drittanbieter-Anfragen,** damit die Datenschutzerklärung stimmt.
- **Portal-Screenshots:** nur mit Demodaten.
- **Footer:** „Verträge hier kündigen“ und „Vertrag widerrufen“ bleiben auf jeder Seite erreichbar.
---
## 11. Offene Entscheidungen (Inhaber)
| Nr. | Frage | Bis zur Entscheidung gilt |
|---|---|---|
| O1 | Preisanker für Websites veröffentlichen? Welcher Betrag? | Standardtext in 7.5 (Angebot mit Preis, ohne Betrag) |
| O2 | Leistungsumfang „Startseite und bis zu drei Unterseiten“ als Einstieg nennen? | Standardtext in 7.3 |
| O3 | Zeitrahmen veröffentlichen (z. B. erste Vorschau nach wenigen Tagen)? | nicht nennen |
| O4 | Team: Wer wird mit Namen, Rolle und Foto gezeigt? | Standardtext in 7.6, keine Fotos |
| O5 | Welche Kunden stimmen einer Referenz zu? | kein Projekte-Abschnitt |
| O6 | Werden Eingänge über das Kontaktformular zuverlässig gemeldet? Nur dann ist „spätestens am nächsten Werktag“ haltbar | Formulierung bleibt; vor dem Livegang klären |
| O7 | Schrift A oder B, genauer Rotton | im Entwurf beide Schriften zeigen, Startwerte aus 8.2 |
| O8 | Zweiter Kartenpunkt für Winnweiler („Hier sitzen wir“)? | nur Falkenstein |
| O9 | Stundenkonto und Verträge im Portal-Abschnitt zeigen? Hängt an der Freischaltung im Portal | nicht zeigen |
| O10 | Wie sieht der Inhaber den Entwurf? | **GitHub Pages** über einen Actions-Workflow für den Zweig `relaunch` (siehe unten). Geht das nicht (z. B. privates Repo ohne Pages), Screenshots im Pull Request |
| O11 | Repository öffentlich oder privat? | nichts Internes einchecken: keine Preiskalkulation, keine Kundennamen, keine Serverdetails |
| O12 | Erreichbarkeit „Montag bis Freitag 9 bis 17 Uhr“ auf der Startseite nennen? | nicht nennen |
**Vorschau über GitHub Pages (O10):**
- Der Build bekommt den Unterpfad über `VITE_BASE` (`vite.config.ts`: `base`) und `BrowserRouter basename={import.meta.env.BASE_URL}`.
- Damit Unterseiten direkt aufrufbar sind, wird `dist/index.html` im Workflow nach `dist/404.html` kopiert.
- Das Kontaktformular sendet in der Vorschau nichts (ohne Appwrite-Variablen zeigt es eine Fehlermeldung, das ist in Ordnung).
- Schriftvarianten sind über `?schrift=a` bzw. `?schrift=b` erreichbar.
---
## 12. Arbeitsweise und Livegang
- **Zweige:** Gearbeitet wird auf Zweigen in diesem Repository, zusammengeführt per Pull Request nach `main` dieses Repos. **`main` hier ist nicht live.**
- **Pull Requests** enthalten Screenshots der geänderten Abschnitte: Desktop und Handy, hell und dunkel.
- **Geheimnisse:** Keine einchecken. `.env` bleibt lokal; `.env.example` enthält nur öffentliche Client-IDs.
- **Livegang:**
- Nach Freigabe des Inhabers wird der Stand ins interne Live-Repository übertragen, danach werden alle Routen live geprüft.
- Die Übertragung macht eine Sitzung auf dem Server oder der Inhaber; sie gehört nicht zur Cloud-Arbeit.
- Bei der Übertragung dürfen Dateien, die es nur im Live-Repository gibt (Server-Doku, Vorschau-Konfiguration), nicht gelöscht werden.
---
## 13. Nicht im Umfang
- Blog, CMS, Mehrsprachigkeit.
- Analytics, Tracking und Cookie-Banner.
- Vorab-Rendering (SSR/SSG), `sitemap.xml`, strukturierte Daten und echte 404-Statuscodes. Das ist ein eigenes Folgeprojekt; der Code soll dafür aber nichts verbauen.
- Änderungen am Kundenportal selbst, z. B. eine einheitliche Anrede oder der Mehrwertsteuer-Hinweis bei Funktionen.
- Änderungen an Server, Deploy und Kontakt-Backend.
---
## 14. Abnahme
**Phase 1 (Entwurf) ist fertig, wenn:**
- auf dem Zweig `relaunch` laufen:
- die neue Kopfleiste,
- alle Abschnitte aus 6.2 außer „Projekte“,
- der neue Footer;
- der Hero mit wechselnder Überschrift, gezeichneter Karte und Pause-Knopf funktioniert, ebenso die Wisch-Reihen auf dem Handy und die Portal-Bühne auf dem Desktop;
- beide Schriftvarianten umschaltbar sind;
- die Vorschau (O10) erreichbar ist und Screenshots vorliegen: 1440 × 900 und 390 × 844, hell und dunkel, je Schriftvariante;
- `npm test` und `npm run build` grün sind.
**Phase 2 ist fertig, wenn:**
- die Erfolgskriterien E2–E6 gemessen erfüllt sind;
- der Inhaber E1 und E7 bestätigt hat;
- alle Unterseiten das neue Layout haben und die Texte der Rechtsseiten unverändert sind;
- der Schriftumschalter entfernt ist und die gewählte Schrift fest eingebaut ist;
- Effekt-Komponenten, Analytics und tote Abhängigkeiten gelöscht sind;
- die Rückmeldungen des Inhabers eingearbeitet sind und er den Stand freigibt.
@@ -0,0 +1,140 @@
# Funktions-Demos im Abschnitt Kundenportal (2026-10-04)
Auftrag des Inhabers (2026-10-04, sinngemäß): In der Handy-Sektion (Kundenportal-Bühne) die eine Station
„Funktionen“ herausnehmen und stattdessen die einzelnen Funktionen zeigen, die WEBklar anbietet:
**Terminplaner**, **Website-Analyse (Heatmap)** und **News-Pinnwand**. Je Funktion eine erfundene, interessante
Kunden-Website, bei der die Funktion Sinn ergibt. Auf dem **Laptop die richtige Website groß**, auf dem **Handy
das Kundenportal** (die Einrichtung/Verwaltung). Die Heatmap soll „fancy“ wirken: simulierte Klicks, die Heatmap
baut sich auf; **beim Hovern** sieht man, wo viel geklickt wurde, **darunter eine kleine Diagnose**. Beim
Terminplaner soll ein **Kalender (Google Kalender)** mit den Einträgen im Bild sein. Bei der Pinnwand eine
Website wie eine Gemeinde/Stadtverwaltung – „irgendwas Interessantes“.
Dieses Dokument ergänzt `2026-10-01-relaunch-design.md` (Abschnitt 7.2 Kundenportal). Wo es abweicht, gilt es.
## 1. Stationen der Bühne (Reihenfolge)
1. Live-Vorschau · 2. Fortschritt · 3. Chat · 4. Rechnungen (unverändert)
5. **Terminplaner** · 6. **Website-Analyse** · 7. **News-Pinnwand** – Gruppe „Weitere Funktionen“ (Inhaber 2026-10-04: so nennen, Unterpunkte nur in Stichpunkten)
8. Als App (unverändert, bleibt die letzte Station mit dem langen Screenshot)
Texte (Sie-Form, keine Erfolgsversprechen, keine Zahlen ohne Quelle):
| Station | Titel | Stichpunkte |
|---|---|---|
| 5 | Terminplaner | Gäste buchen direkt auf Ihrer Website · Termine und Anfragen im Portal · Kalender-Abo, z. B. Google Kalender |
| 6 | Website-Analyse | Heatmap: wohin Besucher klicken · Aufrufe, Herkunft und Geräte · Im Wartungs-Abo enthalten |
| 7 | News-Pinnwand | Text und Foto, vom Handy aus · Hängt direkt auf Ihrer Website · Laufzeit je Zettel wählbar |
Bedienhinweis der Heatmap (nicht in den Stichpunkten, sondern im Hinweis unter dem MacBook bzw. in der Karte):
„Zeigen Sie mit der Maus auf die Seite.“ (Maus) / „Tippen Sie auf eine Stelle der Seite.“ (Touch).
Wahrheit der Aussagen (geprüft im Portal-Code und in den Spezifikationen):
- Terminplaner: Buchung im Widget, Gast bestätigt per Link in der E-Mail, Betrieb bekommt eine Mail
„Neue Buchung: {Leistung} am {Datum} um {Zeit}“ (K20), Termine stehen im Portal (Reiter Termine). Kalender:
**Kalender-Abo** je Person/Platz (geheimer .ics-Link), den man in Google Kalender, Apple Kalender oder Outlook
einfügt; Google aktualisiert nur alle paar Stunden. Es gibt **keinen** Zwei-Wege-Sync. Kalendereinträge für den
Betrieb heißen „{Leistung}: {Gastname}“.
- Website-Analyse: Heatmap der Klicks (Farbskala blau → grün → gelb → rot, wie `analytics/client/h.js`),
Seitenaufrufe, Besucher, Herkunft, Geräte, Kontakt-Klicks; nur mit Einwilligung; im Wartungs-Abo enthalten.
- News-Pinnwand: kurze Neuigkeiten mit Text und/oder Foto, „hängen wie Zettel an einer Pinnwand auf deiner Website“.
## 2. Erfundene Beispiel-Kunden
Alle drei sind erfunden und werden als Beispiel gekennzeichnet (kleiner Hinweis „Erfundenes Beispiel“ an der
Bühne). Adressen in der Safari-Leiste mit der reservierten Endung `.example` (RFC 2606), damit kein echter
Betrieb gemeint sein kann. Keine Fotos von Menschen, keine Stockfotos, keine fremden Logos: Bilder sind
selbst gezeichnete SVG/CSS-Grafiken. Keine Anfragen an fremde Server.
| Funktion | Beispiel | Adresse | Warum |
|---|---|---|---|
| Terminplaner | **Salon Schnittstelle** – Friseur, drei Personen (Mia, Can, Lea) | `salon-schnittstelle.example` | Klassischer Fall: Leistung, Person, Zeit |
| Website-Analyse | **Boulderhalle Überhang** – Kletterhalle | `boulderhalle-ueberhang.example` | Viele Klickziele (Tageskarte, Live-Auslastung, Kurse, Preise, Öffnungszeiten, Routenfoto) |
| News-Pinnwand | **Ortsgemeinde Musterbach** – Gemeinde-Website | `musterbach.example` | Ständig Neues: Kerwe, Straßensperrung, Bücherzelle, Sprechstunde |
## 3. Aufbau je Station
Ab 900 px (Bühne): **MacBook** = die Website als Live-Komponente (keine Bilddatei), gesetzt in 1512 x 893,
der Rahmen verkleinert sie (bei 1440 px Fensterbreite ca. Faktor 0,41). Darum große Schrift (Fließtext
mindestens 22 Designpunkte, Überschriften 64+), große Flächen, wenig Kleinkram. **iPhone** = Kundenportal-Ansicht
der Funktion als Screenshot aus dem echten Portal-Code mit erfundenen Daten (Fixture-Server), Format wie die
übrigen Handy-Bilder (804 x 1748 @2x, ausgeliefert 390/780 px webp). Darüber dürfen kleine Live-Ebenen liegen
(Mitteilung, Tipp-Welle), die mit dem Ablauf der Website synchron laufen.
Unter 900 px (Wisch-Reihe): je Funktion eine Karte; im iPhone-Rahmen läuft die **Website** in ihrer
Handy-Fassung (402 x 874, oben 59 / unten 34 frei für Statusleiste und Home-Balken). Die Heatmap-Karte
reagiert auf Tippen (Zone antippen ⇒ Diagnose unter der Karte).
### 3.1 Terminplaner (Salon Schnittstelle)
- Website: Kopf mit Name/Navigation, große Überschrift, rechts das **Buchungs-Widget** im Stil des echten
Widgets (`kundenbereich…/build/public/embed/termine.js`, `termine-demo.html`): Leistung wählen → Person
(„Egal“ möglich) → Tag und freie Zeit → Daten → „Termin buchen“ → Hinweis „Bitte bestätigen Sie den Termin
über den Link in Ihrer E-Mail.“ (der Gast bestätigt per Link).
- Kalender-Fenster über der Website (rechts oben/unten, im Stil einer Kalender-App, **ohne Google-Logo und ohne
Google-Farben/-Schrift als Marke**): Wochenansicht mit 6–9 Einträgen „{Leistung}: {Gastname}“, Kopfzeile
„Kalender-Abo · Salon Schnittstelle – Mia“, kleiner Hinweis „z. B. in Google Kalender“. Nach der Buchung
erscheint der neue Eintrag (weich eingeblendet).
- Ablauf (ca. 9–11 s, Schleife): Zeiger wählt Leistung → Person → Zeit → klickt „Termin buchen“ → Bestätigung
→ Eintrag erscheint im Kalender; auf dem iPhone gleitet eine Mitteilung herein „Neue Buchung: Damenhaarschnitt
am Do., 8. Okt. um 14:30“ (Absender WEBklar Termine). Ohne Bewegung: Endzustand (gebucht, Eintrag da).
- iPhone: Portal-Ansicht Terminplaner, Reiter „Termine“ (Liste der Woche mit Status, ein Eintrag „angefragt“).
### 3.2 Website-Analyse (Boulderhalle Überhang)
- Website: Navigation (Routen, Kurse, Preise, Öffnungszeiten), Held „Heute 14 neue Boulder“ mit großem Knopf
„Tageskarte kaufen“, Kachel **Live-Auslastung** (z. B. 42 %), Karten Schnupperkurs / Preise / Öffnungszeiten,
ein großes **Routenbild** (SVG-Wand mit bunten Griffen) – das ist kein Link, wird aber oft angeklickt
(„toter Klick“), Telefonnummer im Kopf. 8–10 Zonen mit `data-zone`.
- Heatmap: Leinwand über der Website, Zeichnung mit `heat.ts` (gleicher Algorithmus wie das echte Overlay).
Klickpunkte sind Beispieldaten aus einer festen Saat (`zufall`), je Zone ein Gewicht; Summe z. B. 1.284 Klicks.
- Ablauf (Schleife): Zeiger fliegt Zonen an, Klickwelle, die Heatmap wird mit jedem Klick dichter (je Klick ein
Schub Punkte), nach ca. 10 s ist sie voll, bleibt ca. 6 s stehen, blendet aus, neu. Ohne Bewegung: volle Heatmap.
- **Hover (nur Maus)**: Sobald die Maus über der Website ist, stoppt der simulierte Zeiger, die Heatmap ist voll
sichtbar; die Zone unter der Maus bekommt einen Rahmen und ein Schildchen „Tageskarte kaufen · 402 Klicks“.
**Diagnose darunter** (unter dem MacBook, links neben dem iPhone, normaler Seitenmaßstab, Seitenfarben hell/dunkel):
Kopf „Diagnose · letzte 30 Tage · Beispieldaten“, Rangliste der 4 meistgeklickten Zonen mit Balken und Prozent,
dazu ein Satz zur gewählten Zone (ohne Hover: zur meistgeklickten). Beispiel-Sätze: „Am meisten geklickt:
‚Tageskarte kaufen‘ – 31 % aller Klicks.“ / „Auffällig: 12 % klicken auf das Routenbild. Es ist kein Link –
hier lohnt sich ein Link zur Routenliste.“ Keine Erfolgsversprechen („mehr Umsatz“ o. Ä. verboten).
- Tastatur/Screenreader: die Ranglisten-Zeilen sind Knöpfe; Fokus/Klick wählt die Zone (Rahmen auf der Website).
Die Website selbst ist `aria-hidden` und enthält keine fokussierbaren Elemente.
- Handy-Karte: die Website in Handy-Fassung mit Heatmap; Tippen auf eine Zone zeigt die Diagnose unter der Karte.
- iPhone (Bühne): Portal-Ansicht Analyse (Kennzahlen + Diagramm).
### 3.3 News-Pinnwand (Ortsgemeinde Musterbach)
- Website: Kopf mit erfundenem, schlichtem Wappen (SVG), Navigation (Rathaus, Leben in Musterbach,
Veranstaltungen, Pinnwand), Bereich „Neues aus Musterbach“ als **Pinnwand** (Kork o. ä. per CSS) mit 4–5
leicht gedrehten Zetteln im Stil der echten News-Pinnwand (`…/public/embed/news.js`, `news-demo.html`):
„Kerwe 2026: Freitag bis Montag auf dem Dorfplatz“, „Hauptstraße ab 13.10. halbseitig gesperrt (Kanalbau)“,
„Neue Bücherzelle am Spielplatz“, „Sprechstunde des Ortsbürgermeisters: donnerstags 17–19 Uhr“ usw.
Mindestens ein Zettel mit „Foto“ (gezeichnete Grafik, z. B. Kerwe-Karussell oder Bücherzelle).
- Ablauf (Schleife): auf dem iPhone (Portal-Screenshot „News-Pinnwand“ mit dem neuen Zettel) tippt eine
Tipp-Welle auf „Veröffentlichen“ (Koordinaten aus der Aufnahme), kurz danach fällt der neue Zettel mit Nadel
auf die Pinnwand der Website (transform/opacity), Plakette „Neu“. Ohne Bewegung: Zettel hängt schon.
- iPhone: Portal-Ansicht News-Pinnwand (Musterbach-Daten).
## 4. Technik
- Ordner `src/components/home/demos/`: `gemeinsam.tsx/.css` (Taktgeber `useAblauf`, `useDemoLauf`,
`useBewegungErlaubt`, `useFeinerZeiger`, `useImBild`, `DemoZeiger`, `zufall`, `zahl`), `heat.ts`
(Heatmap-Zeichnung wie h.js). Je Demo ein Unterordner `termin/`, `heatmap/`, `pinnwand/`.
- Schnittstelle je Demo (eine Quelle der Wahrheit für Website und Handy-Ebene):
- `use<Name>Ablauf(laeuft: boolean, statisch: boolean)` liefert den Zustand (Phase usw.).
- `<Name>Seite` (`{ zustand, variante: "desktop" | "handy" }`) = die Website auf der Fläche.
- optional `<Name>Handy` (`{ zustand }`) = Live-Ebene über dem Portal-Screenshot im iPhone.
- Heatmap zusätzlich `HeatmapDiagnose` (`{ zustand }`) und Auswahl/Hover im Zustand.
- `laeuft` = Station aktiv **und** Bühne/Karte im Bild **und** Tab sichtbar **und** Bewegung erlaubt.
Nichts läuft im Hintergrund (Lehre aus dem Leistungs-Audit 2026-09-25).
- Bewegung nur `transform`/`opacity` hinter `prefers-reduced-motion: no-preference`; Ausnahme (Auftrag des
Inhabers, ergänzt 8.4): das Neuzeichnen der Heatmap-Leinwand (nur bei Klicks, nicht je Frame).
- Hover-Effekte nur bei `(hover: hover) and (pointer: fine)`.
- Die Demos werden erst geladen, wenn die Bühne die Station fast erreicht (wie die Bilder, `eingehaengt`),
möglichst als eigener Chunk (`React.lazy`), damit die Startseite nicht schwerer wird.
- Inaktive Ebenen der Bühne bekommen `pointer-events: none`, damit nur die sichtbare Website auf die Maus reagiert.
- Tests: Vitest je Demo (Rendern Desktop/Handy, statischer Endzustand ohne Bewegung, Diagnose-Logik,
keine fokussierbaren Elemente in der Website); `relaunch-kundenportal.test.tsx` auf 8 Stationen angepasst.
`sofort-paket.test.tsx` (Scans gegen verbotene Aussagen) bleibt grün.
## 5. Offen für den Inhaber
- Terminplaner ist erst im Pilotbetrieb (TP2 „Verkauf“ noch nicht live). Vor dem Livegang des Relaunchs:
entweder TP2 live oder die Station kennzeichnen („bald buchbar“).
- Namen der Beispiel-Kunden und ihre Texte sind Vorschläge.
@@ -0,0 +1,63 @@
# Kundenportal-Bühne: Live-Vorschau, Chat-Demo und weiches Zuklappen
Stand 2026-10-04. Wunsch des Inhabers (diktiert): Das Durchscrollen der Bühne von „Live-Vorschau“ bis „Als App“ soll schöner werden.
1. Die Testseite in der Live-Vorschau wirkt primitiv. Sie soll wie eine richtige Website aussehen, auch übergreifend im Chat.
2. Beim Herausscrollen aus „Funktionen“ soll das Zuklappen der Unterpunkte schöner sein.
3. Der Chat bekommt eine erklärende Animation: Nachrichten ploppen auf, es wird hineingezoomt, damit man sie lesen kann.
Die Texte sollen spielerisch sein, nicht kalt.
## 1. Live-Vorschau
- Im MacBook steht jetzt die Beispiel-Website selbst (`demos/baeckerei/BaeckereiSeite.tsx`), nicht mehr die Portal-Aufnahme.
Die Adresse in der Safari-Leiste ist `baeckerei-sonnenschein.project.webklar.com`, also dieselbe wie im Portal.
- Die Seite ist eine ganze Website: Leiste, Kopf, Startbereich („Frisch gebacken. Jeden Morgen.“), Band, Sortiment mit vier
Karten, Backstube, Torten, Öffnungszeiten mit Stadtplan und Fuß. Die Schrift ist Instrument Serif (selbst gehostet, OFL)
zusammen mit Instrument Sans.
- Die Fotos stammen von Wikimedia Commons und stehen unter CC0. Sie zeigen nur Backwaren, keine Menschen.
Quellen stehen in `docs/relaunch/assets/baeckerei/QUELLEN.md`, das Skript ist `scripts/relaunch-baeckerei-fotos.py`.
- Scroll-Erlebnis: Die Station ist länger (`--kp-l-vorschau: 110svh`). Beim Scrollen durch die Station läuft die Website im
MacBook einmal von oben nach unten durch. Die Bühne schreibt dafür `--kp-v` (0 bis 1); bewegt wird nur `transform`.
Bei reduzierter Bewegung steht die Website oben, und die Station ist kurz.
- Das iPhone zeigt die Projektübersicht des Portals. Die Aufnahme ist neu und zeigt dieselbe Website im Vorschaukasten.
Das gilt auch für die lange Aufnahme in „Als App“. Aufgenommen wurde mit der Fixture des Portals; `demo-site.html` wird mit
`gen-baeckerei.mjs` aus der Komponente erzeugt.
- Hinweiskarte: „Geschützt: Ihre Vorschau öffnet sich nur nach der Anmeldung im Kundenportal – Fremde sehen nichts.“
Das stimmt: `/api/auth/preview-access` lässt nur Kundensitzungen des Projekts und das Team durch.
## 2. Chat-Demo
- Im MacBook ist der Portal-Chat zu sehen. Darunter liegt die Aufnahme des leeren Chats (`desktop-3c-chat-leer`, auch in
2400 px, damit sie beim Zoomen scharf bleibt). Darüber liegt eine Live-Ebene (`demos/chat/`) mit den Blasen, dem
Eingabefeld, dem Anhang, der Sprachaufnahme, dem Senden-Knopf und einem Zeiger. Formen und Farben sind wie im Portal.
- Ablauf (etwa 28 s, Schleife): Zuerst die Übersicht, dann zoomt die Kamera auf das Gespräch (1,8-fach, nur `transform`).
Marlene hängt ein Foto an und tippt Zeichen für Zeichen. Danach wird gesendet, Kenso antwortet, und Marlene schickt eine
Sprachnachricht. Es folgen noch eine Antwort, eine Nachricht von Marlene und die letzte Antwort. Dann zoomt die Kamera
heraus, und die Nachrichten blenden aus.
Neue Blasen ploppen aus ihrer Ecke auf und federn leicht nach. Die älteren Blasen rutschen weich nach oben (FLIP).
- Texte (Sie-Form vom Team, warm und spielerisch):
- Marlene: „Guten Morgen! ☀️ Unser neues Roggenbrot ist gerade aus dem Ofen. Darf das auf die Startseite?“ (mit Foto)
- Kenso: „Und wie das darf! 😍 Das Foto ist so knusprig, ich rieche es bis hierher.“
- Marlene: Sprachnachricht 0:12
- Kenso: „Alles notiert: Brot ganz nach oben, Preis dazu, Öffnungszeiten etwas größer. Schauen Sie mal in die Vorschau! 🥖“
- Marlene: „Sieht super aus! Morgen gibt’s Croissants fürs ganze Team. 🥐“
- Kenso: „Dann stellen wir schon mal den Kaffee an. ☕“
- Das Foto im Chat ist dasselbe Roggenbrot wie auf der Website, denn die Geschichte geht über beide Stationen.
- Die Uhrzeiten (07:52 bis 10:31) versprechen keine Sofort-Antwort. Ein Tippen-Hinweis des Teams wird nicht gezeigt, weil
das Portal keinen hat.
- In der Station Chat gleitet das iPhone nach rechts unten weg. Sonst würde es die eigenen Nachrichten und den
Senden-Knopf verdecken.
- Hinweiskarte: „Antwortet das Team, kommt zusätzlich eine E-Mail – so verpassen Sie keine Nachricht.“
Das stimmt: Bei jeder Team-Nachricht geht die Mail K17 hinaus, gedrosselt.
- In der Wisch-Reihe auf dem Handy läuft dasselbe Gespräch über der Handy-Aufnahme des leeren Chats, dort ohne Zoom.
- Bei reduzierter Bewegung steht sofort der Endzustand da: alle Nachrichten, hineingezoomt.
## 3. Weiches Auf- und Zuklappen von „Funktionen“
- Zustand `zu`, `offen` oder `schliesst`. Beim Verlassen der drei Funktionen falten sich die Unterpunkte von unten nach
oben ein, und die Linie links zieht sich nach oben zurück. Das dauert etwa 260 ms. Danach verschwindet die Unterliste.
- „Als App“ springt dabei nicht, sondern gleitet per FLIP von der alten an die neue Stelle (Web Animations API, nur
`transform`). Beim Aufklappen gleitet es genauso nach unten, und die Unterpunkte gleiten nacheinander herein.
- Ein kleiner Pfeil hinter „Funktionen“ zeigt den Zustand: nach rechts heißt zu, nach unten heißt offen.
- Für Screenreader ist die Gruppe sofort zu (`aria-expanded="false"`, die Unterliste ist während des Einfaltens
`aria-hidden`). Bei reduzierter Bewegung klappt die Gruppe sofort zu.
+6 -6
View File
@@ -25,18 +25,18 @@
</script>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WEBklar – Webagentur für KMU | Digitalisierung &amp; Automatisierung</title>
<title>WEBklar – Websites, Shops und Apps für kleine Betriebe</title>
<meta
name="description"
content="WEBklar digitalisiert KMU: Website, Prozesse und Systeme aus einer Hand. Kostenlose Potenzialanalyse – Antwort spätestens am nächsten Werktag."
content="WEBklar baut Websites, Online-Shops und Web-Apps. Im Kundenportal sehen Sie jeden Schritt, mit Live-Vorschau Ihrer Website."
/>
<meta name="author" content="WEBklar" />
<link rel="canonical" href="https://webklar.com/" />
<meta property="og:title" content="WEBklar – Webagentur für KMU" />
<meta property="og:title" content="WEBklar – Websites, Shops und Apps für kleine Betriebe" />
<meta
property="og:description"
content="Website, Automatisierung und vernetzte Prozesse für wachsende Unternehmen – ohne Mehraufwand im Alltag."
content="WEBklar baut Websites, Online-Shops und Web-Apps. Im Kundenportal sehen Sie jeden Schritt, mit Live-Vorschau Ihrer Website."
/>
<meta property="og:type" content="website" />
<meta property="og:url" content="https://webklar.com/" />
@@ -45,10 +45,10 @@
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@WEBklar" />
<meta name="twitter:title" content="WEBklar – Webagentur für KMU" />
<meta name="twitter:title" content="WEBklar – Websites, Shops und Apps für kleine Betriebe" />
<meta
name="twitter:description"
content="Digitalisierung für KMU: Website, Prozesse und Systeme. Kostenlose Potenzialanalyse."
content="WEBklar baut Websites, Online-Shops und Web-Apps. Im Kundenportal sehen Sie jeden Schritt, mit Live-Vorschau Ihrer Website."
/>
<meta name="twitter:image" content="https://webklar.com/og-image.png" />
</head>
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) InstrumentSans-Italic[wdth,wght].ttf: Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Instrument Serif Project Authors (https://github.com/Instrument/instrument-serif)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
Relaunch: Fotos der erfundenen Beispiel-Website „Bäckerei Sonnenschein“ (Station Live-Vorschau und Chat-Demo).
Alle Fotos stammen von Wikimedia Commons und stehen unter CC0 (gemeinfrei, keine Namensnennung nötig).
Es sind nur Backwaren zu sehen, keine Menschen. Quellen: docs/relaunch/assets/baeckerei/QUELLEN.md.
Wikimedia Commons (einmalig, Zwischenspeicher docs/relaunch/assets/baeckerei/roh/, nicht im Repository)
-> public/relaunch/demos/baeckerei/<name>-<breite>.webp
Aufruf (aus dem Projektverzeichnis): python3 scripts/relaunch-baeckerei-fotos.py
Braucht Python 3 und Pillow mit WebP-Unterstützung.
"""
from pathlib import Path
import sys
import urllib.parse
import urllib.request
from PIL import Image
WURZEL = Path(__file__).resolve().parent.parent
ROH = WURZEL / "docs" / "relaunch" / "assets" / "baeckerei" / "roh"
ZIEL = WURZEL / "public" / "relaunch" / "demos" / "baeckerei"
KENNUNG = {"User-Agent": "WEBklar-relaunch/1.0 (support@webklar.com)"}
KB = 1024
QUALITAET = 78
MIN_QUALITAET = 50
# Name -> (Datei auf Commons, Ausschnitt als Anteile (links, oben, rechts, unten), Zielbreiten, Seitenverhältnis B/H, Budget je Datei)
FOTOS = {
# Startseite und Chat-Foto: dasselbe Roggenbrot (Marlene schickt es im Chat, danach steht es auf der Website)
"roggenbrot": ("Rye bread Roggenbengel 2025-02-14 13.jpg", (0.0, 0.0, 1.0, 1.0), (600,), 1.08, 58 * KB),
"roggenbrot-anschnitt": ("Cut rye bread Kartoffelkruste 2025-02-20 02.jpg", (0.12, 0.12, 0.92, 0.95), (400,), 0.8, 30 * KB),
"broetchen": ("Bread-rolls.jpg", (0.0, 0.0, 1.0, 1.0), (400,), 0.8, 30 * KB),
"croissants": ("Freshippo croissant bread.jpg", (0.05, 0.0, 0.95, 1.0), (400,), 0.8, 34 * KB),
"zimtschnecken": ("IKEA cinnamon bun.jpg", (0.05, 0.0, 0.95, 1.0), (400,), 0.8, 30 * KB),
"baguettes": ("Baguette-246424.jpg", (0.0, 0.0, 1.0, 1.0), (700,), 1.2, 40 * KB),
"zwetschgenkuchen": ("Plum cake, Ehrenbach.jpg", (0.18, 0.08, 1.0, 1.0), (520,), 1.0, 50 * KB),
}
def lade(datei: str) -> Path:
"""Lädt das Foto einmal in 2000 px Breite von Commons (Special:FilePath) und hält es im Zwischenspeicher."""
ROH.mkdir(parents=True, exist_ok=True)
ziel = ROH / datei.replace(" ", "_")
if not ziel.exists():
url = "https://commons.wikimedia.org/w/index.php?title=Special:FilePath/" + urllib.parse.quote(datei) + "&width=2000"
with urllib.request.urlopen(urllib.request.Request(url, headers=KENNUNG), timeout=60) as antwort:
ziel.write_bytes(antwort.read())
return ziel
def zuschneiden(bild: Image.Image, anteile: tuple, verhaeltnis: float) -> Image.Image:
"""Schneidet erst den Ausschnitt aus, dann mittig auf das Seitenverhältnis."""
b, h = bild.size
l, o, r, u = anteile
bild = bild.crop((round(l * b), round(o * h), round(r * b), round(u * h)))
b, h = bild.size
if b / h > verhaeltnis:
neu = round(h * verhaeltnis)
return bild.crop(((b - neu) // 2, 0, (b - neu) // 2 + neu, h))
neu = round(b / verhaeltnis)
return bild.crop((0, (h - neu) // 2, b, (h - neu) // 2 + neu))
def speichern(bild: Image.Image, ziel: Path, budget: int) -> tuple[int, int]:
qualitaet = QUALITAET
while True:
bild.save(ziel, "WEBP", quality=qualitaet, method=6)
groesse = ziel.stat().st_size
if groesse <= budget or qualitaet - 4 < MIN_QUALITAET:
return groesse, qualitaet
qualitaet -= 4
def main() -> int:
ZIEL.mkdir(parents=True, exist_ok=True)
for alt in ZIEL.glob("*.webp"):
alt.unlink()
summe = 0
for name, (datei, anteile, breiten, verhaeltnis, budget) in FOTOS.items():
roh = zuschneiden(Image.open(lade(datei)).convert("RGB"), anteile, verhaeltnis)
for breite in breiten:
klein = roh.resize((breite, round(breite / verhaeltnis)), Image.LANCZOS)
groesse, q = speichern(klein, ZIEL / f"{name}-{breite}.webp", budget)
summe += groesse
print(f"{name}-{breite}.webp {klein.size[0]}x{klein.size[1]} {groesse / KB:.1f} KB q{q}")
print(f"Summe: {summe / KB:.0f} KB")
return 0
if __name__ == "__main__":
sys.exit(main())
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
Relaunch: verkleinert die Rohexporte der Kundenportal-Screenshots für die Website.
docs/relaunch/assets/portal/*.webp (Rohdateien, doppelte Auflösung)
-> public/relaunch/portal/<name>-<breite>.webp
Aufruf (aus dem Projektverzeichnis): python3 scripts/relaunch-bilder.py [NAME ...]
Ohne Argument werden alle Bilder neu geschrieben (alte Ausgaben vorher entfernt). Mit einem oder mehreren NAME-Filtern
(Dateiname ohne .webp, auch als Anfang, z. B. "phone-9" oder "phone-10-analyse") entstehen nur die passenden Bilder;
alle anderen Dateien in public/relaunch/portal bleiben unangetastet.
Nur die Bilder, die der Abschnitt "Kundenportal" (Spezifikation 7.2) wirklich zeigt, landen in public/.
Format und Budgets laut Spezifikation 9.5:
- WebP, Qualität etwa 80, Desktop-Bilder in 800 / 1200 / 1600 px Breite, Handy-Bilder in 390 / 780 px
- ein einzelnes Desktop-Bild höchstens 120 KB, ein einzelnes Handy-Bild höchstens 60 KB
(der lange Screenshot hat ein eigenes, größeres Budget, weil er mehr als dreimal so hoch ist)
- wird ein Budget überschritten, sinkt die Qualität pro Datei schrittweise (nicht unter MIN_QUALITAET)
Braucht Python 3 und Pillow mit WebP-Unterstützung.
"""
from pathlib import Path
import sys
from PIL import Image
WURZEL = Path(__file__).resolve().parent.parent
QUELLE = WURZEL / "docs" / "relaunch" / "assets" / "portal"
ZIEL = WURZEL / "public" / "relaunch" / "portal"
QUALITAET = 80
MIN_QUALITAET = 46
SCHRITT = 4
KB = 1024
BUDGET_DESKTOP = 120 * KB
BUDGET_HANDY = 60 * KB
# Der lange Screenshot ist gut dreimal so hoch wie ein Handy-Bild (5131 statt 1748 px). Sein Budget
# wächst mit der Höhe (je Bildschirmhöhe weiterhin unter 60 KB), sonst wäre der Text nur noch Matsch.
BUDGET_LANG = {390: 80 * KB, 780: 150 * KB}
# Unter der letzten Inhaltszeile (Zeile 5091) ist der Rohexport nur noch schwarz. Abgeschnitten wird
# 40 px darunter, sonst endet die Verschiebung im Handy-Rahmen auf einem leeren Streifen.
LANG_HOEHE = 5131
# Dateiname (ohne .webp) -> (Art, Zielbreiten)
BILDER = {
# Desktop-Fenster (Roh 3024 x 1786 = MacBook-Safari-Fläche 1512 x 893 @2x); die Live-Vorschau zeigt im MacBook die
# Beispiel-Website selbst (BaeckereiSeite), kein Bild
"desktop-2-fortschritt": ("desktop", (800, 1200, 1600)),
# Chat-Demo (Station Chat): leerer Chat als Kulisse, die Nachrichten sind live; 2400 px, weil die Kamera hineinzoomt
"desktop-3c-chat-leer": ("desktop", (1600, 2400)),
"desktop-4-rechnungen": ("desktop", (800, 1200, 1600)),
# Handy (Roh 804 x 1748 = iPhone 16 Pro @2x, Safe Area 59/34; das lange Bild 804 x 5340, beschnitten auf 5131)
"phone-1-projekte": ("handy", (390, 780)),
"phone-3-projektdetails": ("handy", (390, 780)),
"phone-4c-chat-leer": ("handy", (390, 780)),
"phone-5-rechnungen": ("handy", (390, 780)),
# Funktions-Stationen (Spezifikation 2026-10-04-funktionen-demos.md): Handy-Ansicht der Funktion im Kundenportal
"phone-9-termine": ("handy", (390, 780)),
"phone-10-analyse": ("handy", (390, 780)),
"phone-11-news-musterbach": ("handy", (390, 780)),
# langer Screenshot: behält sein hohes Seitenverhältnis (eigenes Budget, siehe BUDGET_LANG)
"phone-2-projekte-lang": ("handy", (390, 780)),
}
def speichern(bild: Image.Image, ziel: Path, budget: int) -> tuple[int, int]:
"""Speichert als WebP; senkt die Qualität, bis das Budget passt. Gibt (Bytes, Qualität) zurück."""
qualitaet = QUALITAET
while True:
bild.save(ziel, "WEBP", quality=qualitaet, method=6)
groesse = ziel.stat().st_size
if groesse <= budget or qualitaet - SCHRITT < MIN_QUALITAET:
return groesse, qualitaet
qualitaet -= SCHRITT
def waehle(filter_: list[str]) -> dict:
"""Alle Bilder ohne Filter, sonst die, deren Name genau so heißt oder mit einem Filter beginnt (z. B. "phone-9")."""
if not filter_:
return BILDER
# "phone-1" soll nicht auch "phone-10-analyse" treffen: Anfang nur bis zum nächsten Bindestrich-Segment
treffer = {
name: wert for name, wert in BILDER.items()
if any(name == f or name.startswith(f + "-") for f in filter_)
}
unbekannt = [f for f in filter_ if not any(n == f or n.startswith(f + "-") for n in BILDER)]
if unbekannt:
raise SystemExit(f"Unbekannter Bildname: {', '.join(unbekannt)}")
return treffer
def main(argv: list[str]) -> int:
ZIEL.mkdir(parents=True, exist_ok=True)
bilder = waehle(argv)
if not argv:
# alte Ausgaben entfernen, damit nichts Verwaistes liegen bleibt (nur bei einem Lauf über alle Bilder)
for alt in ZIEL.glob("*.webp"):
alt.unlink()
fehler = 0
summe = 0
print(f"{'Datei':44} {'Pixel':>11} {'KB':>7} {'Q':>3} Budget")
for name, (art, breiten) in bilder.items():
quelle = QUELLE / f"{name}.webp"
if not quelle.exists():
print(f"FEHLT: {quelle}", file=sys.stderr)
fehler += 1
continue
roh = Image.open(quelle).convert("RGB")
if name.endswith("-lang"):
roh = roh.crop((0, 0, roh.width, LANG_HOEHE))
for breite in breiten:
if name.endswith("-lang"):
budget = BUDGET_LANG[breite]
else:
budget = BUDGET_DESKTOP if art == "desktop" else BUDGET_HANDY
if breite > roh.width:
print(f"übersprungen: {name} {breite} (Rohbild nur {roh.width} px breit)")
continue
hoehe = round(roh.height * breite / roh.width)
klein = roh if breite == roh.width else roh.resize((breite, hoehe), Image.LANCZOS)
datei = ZIEL / f"{name}-{breite}.webp"
groesse, qualitaet = speichern(klein, datei, budget)
summe += groesse
ok = "ok" if groesse <= budget else f"ÜBER {budget // KB} KB"
print(f"{datei.name:44} {breite:>5}x{hoehe:<5} {groesse / KB:7.1f} {qualitaet:>3} {ok}")
print(f"Summe aller Dateien: {summe / KB:.0f} KB")
return 1 if fehler else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+130
View File
@@ -0,0 +1,130 @@
import { useLayoutEffect, useRef, useState } from "react";
import "./deutschland-karte.css";
/**
* Umriss von Deutschland als Inline-SVG (Spezifikation 7.1).
* Quelle der Pfaddaten: docs/relaunch/assets/deutschland-umriss.svg (Natural Earth 1:10m, Public Domain,
* Lambert-Azimutal-flächentreu, viewBox 0 0 663 892, 1 Einheit ≈ 1 km).
*
* Der Umriss besteht aus sechs geschlossenen Teilpfaden (Festland, Rügen, Usedom, Fehmarn, Sylt, Föhr/Amrum).
* Jeder ist ein eigener <path> mit pathLength="1", damit alle beim Zeichnen gleichzeitig fertig werden
* (bei einem einzigen Pfad wären die kleinen Inseln schon nach den ersten Millisekunden da).
* Die Pfade beginnen mit absolutem M; die Befehle danach sind unverändert aus der Datei übernommen.
*/
const UMRISS = [
"M581.4 706.2l-2 5.8 2.5 2-1 2.4 0.4 7-1.8 4.8-2.9 1.2-1.1 4.3-7-4.5-7.7-1.8-4.8 1.5-1 1.4 1.6 4.9-3.3 15.7-9.3 8.2-10.1 2.2-7.6 3.4-7.1 6.3-5.2 1.6-8.8 9.6 1.8 5.4 5.4 5.2 2.6 5.8 5.3 4 4.8 8.5-6.9 14 5.5 1.8 4.1-1 4.2 5.8 0.1 4.1-2.3 4.3-0.6 4.8 0.9 5.3-2.5 3.2-5.4-0.7-11.8-8.6 0.4-5.3 2-1.1-4.8-4 0.7-1.9-8.2-0.7-8.4 5.2-3.1 0.4-4-2.9-1.7-3.9-13.8 1.8-1.2-1.8 1.3-4.1-4.8 3.1 2.4 6.6-2.4 4.6-17.8-0.1-6.3 1.4-2.3 2.7-15-1.2-5 8.5-10.4 1.1-3.5 4.1 1.8 0.8-0.4 1.9-5.8 1.2-5.1 4.7-1.8-0.2 0.1-3-1.6-0.7-8.3 4.5-7.8 0-1.9-2.1 0.3-2.5-3.8-4-3.9-1.8-0.5-0.8 3.1-2.5-2.6-1.7-7.3 2-1.6-1.7-11.8-3.6-3.5 3.6-4.1-0.3-2.1-1.5 0.2-3.6-2.1 0.2-1.1 0.5 1.3 3.4-0.9 5.5 2.2 2.2 0.3 3.8-1.7 4.7-4.3 3.2-4.9 7.3-5 2.7-6 0.8 2.3-3 1.4-8.3-1.4-0.7-8.2 2.2-2.2-5.1 1.7-1.3-0.3-2.5-3.7-5.4-3 0.8-2.8-6.7-8.4 0.7-1.5-0.8-0.4-3.8-2-1.4-2.2 0.7-3.6 5.6-6.9 1.1-4.7-1.4-20.7-13.1-5.7-0.7-1-1.6-12.5-1.1-5.4 2.7-4.7-0.3-3.3-2.8 1.4-1.1-4.3-3.3-2-0.1-0.1 2.8-4 0-1.2-3.2 1-3.8-2.3-0.2-2.8-3.6-2 3.7-1.1-4.2-1.4-0.6-3.1 0.5 0.7 1.8-1.1 0.5-5.5 1-2 4.5-3.6 3.4 0 3 4.9 2.9 8.3-2.4 2.9 0.8-0.5 2.6-1.5-0.7-0.1 4.3-1.5 1.2-2.8-3.7-4.6 1.7-1 2.4-7.1 0.2-3.6-1.3-1.3-2.7-4.1-0.8-4.1 0.6-7 5.2-3.4 0.7-9.8-0.3-0.9-3-5.9-1-4.1 4.2-6.3 1.1-5.4-2.4 1.9 0 1.9-3.4-5.6 1.1 0.4-2.6-5.1-7.6-0.4-3.6 2.1-2.7-0.7-5.7 2.9-6.1-0.1-4.9 2.1-6.3 2.8-3.4 0-3.4-3.1-5.8 0.5-6.8 2.8-7.1 5.7-8 1.3-6.4 3.7-3.3-0.7-7.2 3.1-10.1 2.8-2.3 0.1-10.6 3.2-4.8 8-6.7 1-4 3.4-0.4 1.7-3.2 3.8-1.8 8.5-16.5-8-2.5-11.4-6.5-5.5 0.1-6-1.9-3.4 1.2-6.7-0.4-1.9-2.8-6.3-2.6-3.4-5.1 0.4-2.5-4.4-0.9-0.3-1.2-4.3 0.9-1.4 2.7-2.7 0.6-1.7 2.5-8.7-1.4-5.3-3.2-0.6 2.3-2.3 1.4-1.5-1.4-0.6-7-6.8-3-5.8-0.6 0.2 5.2-1.4 1.6-6.9-1.1-1.7-2 0.3-4.6-1.6 0.6-2.7-7.2-5.7-6.4 0-2.3 1.7-0.9-4.5-7.8-7.6-5-4.2 1 1-12.4 5.2-7.8-1-1.9 7.3-5.9-0.9-2.5 1.5-6.8-7.5-1.8-4.3-3.7-2.3 0.5-1-3-4.4-3.1-3.2-7.9-0.9 1.5-4-12 2.8-9.4-0.4-3.1 3.2-2.1-1.1-3.6 3.2-3 6-2.5 1.2-4 6.4-1-2.4-5.9 1.4-7.3-1.8-3.7 1-1.6-5.7-1.8-3.4 0.8-1-3.3-1.4-0.4-0.6-2.9 5.4-4.5 1.3-3-6.3-1.2-0.8-1.7 1-0.5-5.2-8.2-4.9 0.3 0.1-3.2-2.6-6.6 2.1-0.5 1-4.4 3.4-1.2 0.8-6-4.3-2.7 0.5-4.9-5.3-0.8-3.7 1.2-1.1-8.7 2-0.9 3.9 2.6 3.4-4.7 11.9-8-1.4-1.5 2.3-1.5-1.6-0.6-3.6 2.2-1.6-1 0-4.6 6.9-11.4 3.3-2.8 1.3-4.7-1-1.3 0.7-12.3-6.9-10 1.2-5.4-6-4.4 0.4-3.8-4.9-2.1 2-4.8-2-4.5 10.7-3.6 4.9 1.5-4.1-5 2.4-1.2 6.5 4.2 2.7-0.3 1.5 2.3 3.7 1.3 0.1 1.7 2.6-0.6-0.1-3.6 5.7 1.6 4.7-2.9 14.9-2.9 4.8-5.6 0-2.1-8.9-6.6 0.5-2.8 2.2-0.6 2.8-3.9 5.6-0.9 3.9-5.1 3-1.1 7.1-6.8-0.9-5.5 2.8-8.8-4.6-9.8-5.1 1.9-10.6-2.9-3.1-2.6-1.2-7.4 5-1.8-2.6-2.7 0-4 2.5-1.6 8.7-0.4 10.2 2.2 1.3-2.3 2.5-19.6 7.2-11.8 2.4-7.2-0.8-15.6 2-11.8 2.7-3.1-0.2-2.6 1.8-2.5 2.4-0.4 4.9 2.6-4.1-3.5-15-1.1-2.4-1.4-0.6-11.3 2.2-6.8 0.6-1 3.9 0.7 1.6-2.2-3.4-4.2 0.9-2.7 8.6-7.3 5.3-1.8 10.1-0.8 2.9 1.9 29.6-3.9 5.2 1.7-0.5 4 1.7 4 4.8 5.2-0.6 2.1 3.2 2.2-0.9 3-6.5 2.5 1.7 5.1 4.3 0.9 3.4 4.4 3.6-0.1 3.9-6.9 0.4-3.8-1.8-2.3-4 0.4 2.8-9.7 3.9-0.5 3.7 4.7 10.8 3.9-4.2 6.3-0.5 9.5 1.1 4.8-0.1-13 4-4.7 0.6-3.2-3.6-8.8-1.3-8.4 6.1-17.4 5.2-3.7 8.5 5.7 5.1 1.3 10.2-0.9 6.5-2.6 10.5 0.1 8.3 13.7 5.4 5.9 5.3 10.1 8.6 4.8 8.9 1.5-3.3-2.9-5.9-0.8-7.2-4.1-2.1-2.4-1.2-8.5-6.3-4.3-2.6-9.1-9-6.2-10.7-0.9-4.2-1.7-1.4 1.6-1.5-0.5-4.5-4.5-5.2-11.1 3.8-1.2 5.5 2.7 1.4-0.7 1.5-7.5-2.8-3.4 0.2-1.9-1.6-0.5-6.1 2.6-3-6.2 2.3-5.8-0.4-2.5 5.4-2 2.7-4.8-5.3 3 0.4-1.5-9.7 1-3.9 2.6-3.6-2.4-1.4-5.3 2.7-1.9 3.6-0.3-2.9-2.2-1.1 1.4 1-4.6 15.4-1 7.8-5.2 0.5-4.5-8.4-11.2-3.5 0.8 1.8-2.3-2.9-7.6-7.4-5.5 0.4-2.7-3.1-9.4 1.2-5.8 4.6 0.9 5.9-1.8 5.1 1 18.5 5.5 3.1 5.5 5.7-0.2 3.4-1.7 4.2 1 8.3-6.1 0.2 4 9.7 2.6 4.3 2.6 1.4 3 4.6-1.4-0.3-2.3 3.6 1.2 4.6 8.7-3.1 0-2.6 3 4.3-0.5 2.7 1.3-0.8 11.9-3.9 6-8.1 3.4 2.8 1.5 16.8-3.3 3.9 3.4-1.3 1.3 0.5 5.8-3.2 2.7 0 5.4 2.5-1.9 2.8-8.1 6.1-3.3 2.7 0.2 15.6 8.4 5.9 5.4 2.6 0.6 3-0.6 10.2-7.2 5.4-0.8-2.8 0.8 3.8 1.2 4.5-2.9 2 0.8-3 3.8-1.4-0.7 2.1 14.8-1.8 4.6-11.2 9.2-5.7 0.9-3.4 5 3.2 5.5 4.9 0.5 0.8 3.5 2.4 0.3 8.4-5.4 8-1.2 1.9 3.2 2.7 1.1-0.1 3.7 3.4 0.5 2.6-2.4 8.2 6 0.8-7 1.6-1.5-0.4-2.2 2.2-2.3-0.5-1.6 3.5-0.3 3.3-5.1-0.1-2.9 4.1-5.1 8 0.4 17.8-5.4 0.5 8.9 1.6 1.7-1.5-5.2 3-4-2.6 0.8 0.4-1 4.9-6.9 9.9-7 9.7-19.2 2-1.7 1 2.9 2.8 1 18.7-0.4 2.7 1.6-3.2 2-11.4-1.4-1.5 1.5-3-0.2-1 1.9-1.7 0.1 0.9 2.2-2.7-0.4-1.6 2.1-2.4-1.9-3.4 0.4-0.8 4.8-1.8-0.7-0.2 3.4-1.8 1.2 0.5 4.6 2.9 1.9 3-0.6-3.2-1.4 3.9-4.7 0.3-2.4 5.2-3.2-1.3-1.4 2.1-0.9 1.3 1.1 5.3-2.8 0.2-2.1 2.3-0.4-2 4.7 1.3-3.1 2 2.3 3.3-1.6 2.2 3.7 2.3-0.1 4.6-6.5 5-2.8 1.7 0.4-0.2 3.6 4.5 3.1-1.2 2.2 2.9 7.9 3.6 0.3-1.7 1.6 9.6 2.7 4.2 5.8-1.7 1.6 5.3-1-1.3 2.4 2.3-0.2 3.4 6 2.7 0.7-2-3.7 8-2 6.9-4.2-0.8 2.3 7.5 5.1-2 6.6-1.7 1.1 6.4 5.3 0.8 3.4 4 2.2-5.5 6.5 0.1 2.8 15 10 9.9 1.2 3-2.6 2 1-0.1 2.6-3 2.4 3.4 0.7 4 21.1 4.1 7 4 18.1 2.7 2.9-3.6 7.1 0.9 6.4-2.5 9.4-12.7 10.6 1.8 7.1-2.4 5.2 18 11.4 3.7 5.6 15.1 11.2-2 6.7 1.7 2.3-2.4 5.4-3.3 3.3 0.7 4.4 3.5 7.9-0.3 2 8 3.7 1.1 1.9-0.9 12.8 5.4 4.6-3.6 9.7-0.3 9-5.7 8.1-0.4 4.3 3.5 3.7 1.4 5.6 6.2 6.2 1.1 5.7-1.8 4.5 0 4.1 14.4 5.9 3.3 3.5 1.1 11.1 4.6 6.8 0.4 3.8-5.9 27.7-3 8.6-3.2 4.7-1.3 6.4-3.3 1.3-8.9-3.Line truncated
"M577.2 130.4l-2 3.6 1.8 3.1-7.4 1.3-2.6-0.9-3.2 3.1-3.7 1.2-6.5-0.3-1.3-1.6 7-4.2 0.3-3.2-2.7-4.5 0-2.8 4.5 0.4-0.6 5.4 1.7-2.4 2.7-0.1-0.8 1.6 2.2 0.6-0.2-7.1-1-1.8-1.5 0.7-2.3-4.4-2.4-1-1.9 1.2 0.7 2.2-2.1 3.1-2.2 1 1.1-3.9-0.9-1.5-4.3 3.8-1.4-0.4 2.2-8.9-4.3-5.5 0.5-1.3 2.2-1.7 1.2 0.5 4.8 7.6 9 4.3z",
"M382.9 77.6l3.9 7.2-8.1-1.3-1.2 0.7 0.6 0.7-4.3-0.1-1-4-4.2 0.5-0.5-1.2-1.1 0.4 1.5-6 2.5-2.5 3.1-0.6 5.6 2.3z",
"M541.2 81.8l4.6 5-2.4 7.6-2.1-0.5 1.3-2.3-3.9 0.9 3.7-4-6.1 1.8 4.7-3.2-1.8-0.9-11.7 2.3-7.6 7.3-0.1 3 3.9-2.1-0.5 4.2-6.3-2.2 1.3-3.7-4.2 3.2-3.8-2-0.5-2.3-3.5 0.9 1.7-2.3-4.2-2.9 2-4.5 7.6-1.3-3.8-4.8-3.5 0.6 7.2-6.5-2.6-1.1-0.2-2.5-3 1-2.9-3.7 7.5-1.6 2.6 3.7 0-3.4 4.3-4.1-1 6.9 2.2-1-0.6-3.6 3.8 7.8 5 0.2 0.7-3.1-1.6-3.7 1.3-2.4-5 1.8-5-5.9-7.2 5.6 2.2-8.7-4.4 1 1-2.8 11.2-3.7 1.8 1.5-3.8 4.4 2.9 5.5 13.9-0.7 2.6 4.9-5.6 6.3-0.2 3.5 2.2 4.5z",
"M205.4 46.4l3.5 0.3 2.6 3-1.9 4.5-5.2-0.2-5.7-3.1 2.7-3.7z",
"M197.1 24.9l-0.5 4 2.1 2.1 3.1 0.8 13.9-0.4-1.6 1.2-8-0.6-3.8 2.2-4.4-0.1-2.1-1.5-2.4 1.5-1.7 13.6 1.3-18.9 5.1-13.5 2.8-3.3 3 1.7-4.4 0.7 1.7 0.2 0.5 1.3-4.4 4.8z",
];
export const KARTE_BREITE = 663;
export const KARTE_HOEHE = 892;
/** Grad -> SVG-Koordinaten in der viewBox 0 0 663 892 (Formel aus docs/relaunch/README.md). */
const R = 6371;
const LON0 = (10 * Math.PI) / 180;
const LAT0 = (52 * Math.PI) / 180;
// eslint-disable-next-line react-refresh/only-export-components -- Hilfsfunktion und Daten gehören zur Karte (Spezifikation 9.3)
export function project(lon: number, lat: number): [number, number] {
const l = (lon * Math.PI) / 180;
const p = (lat * Math.PI) / 180;
const k = Math.sqrt(2 / (1 + Math.sin(LAT0) * Math.sin(p) + Math.cos(LAT0) * Math.cos(p) * Math.cos(l - LON0)));
const x = R * k * Math.cos(p) * Math.sin(l - LON0);
const y = R * k * (Math.cos(LAT0) * Math.sin(p) - Math.sin(LAT0) * Math.cos(p) * Math.cos(l - LON0));
return [x + 289.794 + 12, 341.92 - y + 12];
}
export type KartenPunkt = {
/** Position in der viewBox (siehe project) */
cx: number;
cy: number;
/** server = roter Punkt mit Ring, buero = hohler Punkt */
art: "server" | "buero";
label: string;
sublabel?: string;
};
/** Serverstandort Hetzner Falkenstein/Vogtland (50,477 N, 12,371 O), nur Website und Kundenportal. */
// eslint-disable-next-line react-refresh/only-export-components -- siehe project()
export const FALKENSTEIN: KartenPunkt = {
cx: 469.6,
cy: 520.5,
art: "server",
label: "Falkenstein, Sachsen",
sublabel: "Hier laufen Ihre Website und das Kundenportal.",
};
type DeutschlandKarteProps = {
punkte?: KartenPunkt[];
className?: string;
};
/**
* Ablauf der Bewegung: aus = alles sofort sichtbar (reduzierte Bewegung, Grundzustand),
* wartet = versteckt, bis die Karte im Bild ist, laeuft = Umriss zeichnet sich, dann Punkt, Ring, Beschriftung.
* Die Animationen selbst stehen in deutschland-karte.css hinter prefers-reduced-motion: no-preference.
*/
type Lauf = "aus" | "wartet" | "laeuft";
const DeutschlandKarte = ({ punkte = [FALKENSTEIN], className }: DeutschlandKarteProps) => {
const wurzel = useRef<HTMLDivElement>(null);
const [lauf, setLauf] = useState<Lauf>("aus");
// Vor dem ersten Zeichnen entscheiden, damit die fertige Karte nicht kurz aufblitzt
useLayoutEffect(() => {
if (typeof window === "undefined") return;
const reduziert = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
if (reduziert) return;
const el = wurzel.current;
if (!el || typeof IntersectionObserver === "undefined") {
setLauf("laeuft");
return;
}
setLauf("wartet");
const beobachter = new IntersectionObserver(
([eintrag]) => {
if (eintrag?.isIntersecting) {
setLauf("laeuft");
beobachter.disconnect();
}
},
{ threshold: 0.3 },
);
beobachter.observe(el);
return () => beobachter.disconnect();
}, []);
return (
<div ref={wurzel} className={`wk-karte${className ? ` ${className}` : ""}`} data-lauf={lauf}>
{/* Dekoration: die Aussage steht als Text daneben */}
<svg className="wk-karte__svg" viewBox={`0 0 ${KARTE_BREITE} ${KARTE_HOEHE}`} aria-hidden="true" focusable="false">
<g className="wk-karte__umriss">
{UMRISS.map((d, i) => (
<path key={i} d={d} pathLength={1} />
))}
</g>
{punkte.map((p) => (
<g key={`${p.cx}-${p.cy}`} className={`wk-karte__punkt wk-karte__punkt--${p.art}`}>
{p.art === "server" && <circle className="wk-karte__ring" cx={p.cx} cy={p.cy} r={7} />}
<circle className="wk-karte__kern" cx={p.cx} cy={p.cy} r={7} />
</g>
))}
</svg>
{punkte.map((p) => (
<div
key={`${p.cx}-${p.cy}`}
className="wk-karte__label"
style={{ left: `${(p.cx / KARTE_BREITE) * 100}%`, top: `${(p.cy / KARTE_HOEHE) * 100}%` }}
>
<p className="wk-karte__titel">{p.label}</p>
{p.sublabel && <p className="wk-karte__zusatz">{p.sublabel}</p>}
</div>
))}
</div>
);
};
export default DeutschlandKarte;
+77
View File
@@ -0,0 +1,77 @@
import type { ReactNode } from "react";
import { MacBookFrame, IPhoneFrame } from "./device-frames/DeviceFrames";
import "./geraete-rahmen.css";
/** Ein Bild samt Größenangaben. Alle Felder landen 1:1 im <img> (Spezifikation 9.5). */
export type RahmenBildDaten = {
src: string;
srcSet: string;
sizes: string;
/** Breite und Höhe des größten Rohbilds: sie legen nur das Seitenverhältnis fest, damit nichts springt. */
width: number;
height: number;
alt: string;
/** Standard: lazy. Nur Bilder im ersten Bildschirm bekommen "eager". */
loading?: "lazy" | "eager";
};
type BildProps = {
bild: RahmenBildDaten;
className?: string;
};
/** Das <img> eines Rahmens: immer mit Alt-Text, fester Größe, srcset und asynchroner Dekodierung. */
export const RahmenBild = ({ bild, className }: BildProps) => (
<img
className={className}
src={bild.src}
srcSet={bild.srcSet}
sizes={bild.sizes}
width={bild.width}
height={bild.height}
alt={bild.alt}
loading={bild.loading ?? "lazy"}
decoding="async"
/>
);
type RahmenProps = {
/** Ein einzelnes Bild. Für überblendete Bilder stattdessen children (mehrere Ebenen) übergeben. */
bild?: RahmenBildDaten;
children?: ReactNode;
className?: string;
};
type FensterProps = RahmenProps & {
/** Schlichter Adresstext in der Leiste, keine Ampelpunkte. */
adresse?: string;
};
/**
* Desktop: MacBook Pro 14" mit Safari und der Adresse project.webklar.com (Rahmen aus
* components/device-frames, übernommen aus KNSONWS/homepage „device-mockups“). Der Inhalt wird
* in 1512 x 893 gesetzt und verkleinert; der Screenshot füllt die Fläche von oben.
*/
export const DesktopFenster = ({ bild, children, className, adresse = "project.webklar.com" }: FensterProps) => (
<MacBookFrame url={adresse} className={className}>
<div className="wk-geraet-inhalt">{bild ? <RahmenBild bild={bild} /> : children}</div>
</MacBookFrame>
);
/**
* Handy: iPhone 16 Pro (Inhalt in 402 x 874). Die Portal-Aufnahmen sind genau in diesem Format
* mit Safe Area 59 / 34 aufgenommen, Statusleiste und Home-Balken liegen also passend darüber.
*/
export const HandyRahmen = ({ bild, children, className }: RahmenProps) => (
<IPhoneFrame className={className}>
<div className="wk-geraet-inhalt wk-geraet-inhalt--handy">{bild ? <RahmenBild bild={bild} /> : children}</div>
</IPhoneFrame>
);
type GeraeteRahmenProps = (FensterProps & { art: "desktop" }) | (RahmenProps & { art: "handy" });
/** Ein Einstieg für beide Rahmen: <GeraeteRahmen art="desktop" .../> oder art="handy". */
const GeraeteRahmen = (props: GeraeteRahmenProps) =>
props.art === "handy" ? <HandyRahmen {...props} /> : <DesktopFenster {...props} />;
export default GeraeteRahmen;
+56
View File
@@ -0,0 +1,56 @@
import { Children, useEffect, useRef, useState, type ReactNode } from "react";
import "./wisch-reihe.css";
type WischReiheProps = {
/** Beschreibung für Screenreader, z. B. „Vier Leistungen, seitlich wischen“ */
ariaLabel: string;
children: ReactNode;
className?: string;
/** Zähler „2 / 4“ unter der Reihe anzeigen (Standard: ja) */
zaehler?: boolean;
};
/**
* Horizontale Wisch-Reihe für Handys (Spezifikation 9.4): natives Scroll-Snap ohne Bibliothek,
* die nächste Karte ist angeschnitten sichtbar, darunter ein Zähler. Keine automatische Bewegung.
* Ab 900 px entscheidet der Abschnitt selbst, ob er die Reihe überhaupt zeigt.
*/
const WischReihe = ({ ariaLabel, children, className, zaehler = true }: WischReiheProps) => {
const karten = Children.toArray(children);
const reiheRef = useRef<HTMLUListElement>(null);
const [aktiv, setAktiv] = useState(0);
useEffect(() => {
const reihe = reiheRef.current;
if (!reihe || typeof IntersectionObserver === "undefined") return;
const beobachter = new IntersectionObserver(
(eintraege) => {
for (const e of eintraege) {
if (e.isIntersecting) setAktiv(Number((e.target as HTMLElement).dataset.index));
}
},
{ root: reihe, threshold: 0.6 },
);
reihe.querySelectorAll<HTMLElement>(":scope > li").forEach((li) => beobachter.observe(li));
return () => beobachter.disconnect();
}, [karten.length]);
return (
<div className={`wk-wisch${className ? ` ${className}` : ""}`}>
<ul ref={reiheRef} className="wk-wisch__reihe" role="list" tabIndex={0} aria-label={ariaLabel}>
{karten.map((karte, i) => (
<li key={i} className="wk-wisch__karte" data-index={i}>
{karte}
</li>
))}
</ul>
{zaehler && karten.length > 1 && (
<p className="wk-wisch__zaehler wk-num" aria-hidden>
{aktiv + 1} / {karten.length}
</p>
)}
</div>
);
};
export default WischReihe;
+169
View File
@@ -0,0 +1,169 @@
/* Deutschland-Karte (Spezifikation 7.1): ruhiger Umriss, roter Punkt auf Falkenstein */
.wk-karte {
position: relative;
width: 100%;
aspect-ratio: 663 / 892;
/* Strichstärke in viewBox-Einheiten, damit die Linie bei jeder Kartengröße etwa 1,25 px stark ist
(Desktop: Karte ca. 0,75 px je Einheit, Handy ca. 0,43 px je Einheit) */
--karte-strich: 1.7;
--karte-punkt: 7px;
/* Abstand zwischen Beschriftung und Punkt: Die tschechische Grenze (Ascher Zipfel) liegt knapp links unter dem Punkt */
--karte-abstand: 22px;
}
@media (max-width: 899.98px) {
.wk-karte {
--karte-strich: 2.9;
--karte-punkt: 10px;
--karte-abstand: 16px;
}
}
.wk-karte__svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
}
.wk-karte__umriss path {
fill: none;
stroke: var(--line-strong);
stroke-width: var(--karte-strich);
stroke-linejoin: round;
stroke-linecap: round;
}
.wk-karte__kern {
fill: var(--accent);
r: var(--karte-punkt);
transform-box: fill-box;
transform-origin: center;
}
/* Büro (optional, O8): hohler Punkt */
.wk-karte__punkt--buero .wk-karte__kern {
fill: var(--bg);
stroke: var(--accent);
stroke-width: 2.4;
}
/* Der Ring ist im Grundzustand unsichtbar: ohne Bewegung gibt es ihn nicht */
.wk-karte__ring {
fill: none;
stroke: var(--accent);
stroke-width: 1.2;
opacity: 0;
r: var(--karte-punkt);
transform-box: fill-box;
transform-origin: center;
}
/* Beschriftung als echter Text, links neben dem Punkt: dort liegt die leere Fläche innerhalb von
Deutschland. Rechts und darunter verläuft die tschechische Grenze dicht am Punkt. */
.wk-karte__label {
position: absolute;
width: max-content;
max-width: min(58%, 18rem);
text-align: right;
/* erste Zeile mittig auf Höhe des Punktes, rechter Rand links vom Punkt */
transform: translate(calc(-100% - var(--karte-abstand)), -0.72em);
pointer-events: none;
}
.wk-karte__titel {
font-size: 1rem;
line-height: 1.25;
font-weight: 600;
color: var(--fg);
letter-spacing: -0.005em;
text-wrap: balance;
}
.wk-karte__zusatz {
margin-top: 0.3rem;
font-size: var(--fs-small);
line-height: 1.4;
color: var(--fg-muted);
text-wrap: balance;
}
/* Handy: Die Karte ist klein, links vom Punkt wäre bis zur Westgrenze zu wenig Platz. Dort steht die
Beschriftung oberhalb des Punktes, über der leeren Fläche von Thüringen und Sachsen. */
@media (max-width: 899.98px) {
.wk-karte__label {
transform: translate(calc(-100% + 6px), calc(-100% - 14px));
}
}
@media (min-width: 900px) {
.wk-karte__titel {
font-size: 1.1875rem;
}
.wk-karte__zusatz {
font-size: 0.9375rem;
max-width: 24ch;
margin-left: auto;
}
}
/* ── Bewegung (nur ohne reduzierte Bewegung) ────────────────────────────────
Ablauf: Umriss zeichnet sich in 1,6 s, danach erscheinen Punkt und Beschriftung, dann pulsiert
ein Ring dreimal (2,4 s je Durchlauf) und bleibt dann weg. Ausnahmen laut Spezifikation 8.4.
Der Umriss wird über stroke-dashoffset gezeichnet (pathLength="1"). Das Muster „1 2“ mit Startversatz
1,06 lässt auch bei runden Linienenden nichts übrig, das vor dem Start als Pünktchen sichtbar wäre. */
@media (prefers-reduced-motion: no-preference) {
.wk-karte[data-lauf="wartet"] .wk-karte__umriss path,
.wk-karte[data-lauf="laeuft"] .wk-karte__umriss path {
stroke-dasharray: 1 2;
stroke-dashoffset: 1.06;
}
.wk-karte[data-lauf="wartet"] .wk-karte__kern,
.wk-karte[data-lauf="wartet"] .wk-karte__label {
opacity: 0;
}
.wk-karte[data-lauf="laeuft"] .wk-karte__umriss path {
animation: wk-karte-zeichnen 1600ms var(--ease) 150ms forwards;
}
.wk-karte[data-lauf="laeuft"] .wk-karte__kern {
animation: wk-karte-punkt 500ms var(--ease) 1750ms backwards;
}
.wk-karte[data-lauf="laeuft"] .wk-karte__label {
animation: wk-karte-label 600ms var(--ease) 1950ms backwards;
}
.wk-karte[data-lauf="laeuft"] .wk-karte__ring {
animation: wk-karte-ring 2400ms var(--ease) 2250ms 3 forwards;
}
}
@keyframes wk-karte-zeichnen {
from {
stroke-dashoffset: 1.06;
}
to {
stroke-dashoffset: 0;
}
}
@keyframes wk-karte-punkt {
from {
opacity: 0;
transform: scale(0.3);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes wk-karte-label {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes wk-karte-ring {
from {
opacity: 0.55;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(2.6);
}
}
@@ -0,0 +1,372 @@
/*
* DeviceFrames.tsx – React-Komponenten für die Geräterahmen
* (MacBook Pro 14" und iPhone 16 Pro, Styles in device-frames.css)
*
* import { MacBookFrame, IPhoneFrame } from './DeviceFrames';
*
* <MacBookFrame url="project.webklar.com" className="w-[760px]">
* <PortalDesktop /> // gesetzt in 1512 x 893 (Safari-Viewport)
* </MacBookFrame>
* <IPhoneFrame className="w-[220px]">
* <PortalMobile /> // gesetzt in 402 x 874, oben 59 / unten 34 frei lassen
* </IPhoneFrame>
*
* Die Breite des Wurzelelements bestimmt die Größe, alles andere skaliert mit
* (beim MacBook ist das die Breite des Unterteils, der Deckel ist 87,7 % davon).
* device-frames.js wird in React nicht gebraucht: der Hook unten skaliert.
* Weitere Attribute (id, aria-hidden, data-*, Events …) gehen an das Wurzelelement.
*/
import { useEffect, useLayoutEffect, useRef, version } from 'react';
import type { CSSProperties, HTMLAttributes, ReactNode, RefObject } from 'react';
import './device-frames.css';
export type MacBookColor = 'silver' | 'space-black';
export type IPhoneColor = 'black' | 'natural' | 'white' | 'desert';
// useLayoutEffect warnt beim Server-Rendering, dort reicht useEffect (läuft nicht)
const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
/** Passt eine Canvas in Designgröße per transform: scale() an ihren Viewport an. */
function useCanvasScale(ref: RefObject<HTMLDivElement | null>, width: number, height: number): void {
useIsoLayoutEffect(() => {
const canvas = ref.current;
const host = canvas ? canvas.parentElement : null;
if (!canvas || !host) return undefined;
const apply = (w: number) => {
if (!w) return; // noch nicht sichtbar
const k = Math.round((w / width) * 1e6) / 1e6;
canvas.style.setProperty('--dm-dw', String(width));
canvas.style.setProperty('--dm-dh', String(height));
canvas.style.setProperty('--dm-k', String(k));
};
// Bruchteil-genaue Breite (clientWidth rundet)
apply(parseFloat(getComputedStyle(host).width) || host.clientWidth);
if (typeof ResizeObserver !== 'function') return undefined;
const ro = new ResizeObserver((entries) => {
const last = entries[entries.length - 1];
if (last) apply(last.contentRect.width);
});
ro.observe(host);
return () => ro.disconnect();
}, [ref, width, height]);
}
// inert: React 19 kennt es als boolesches Attribut (dort hieße '' "aus"),
// React 18 reicht es als unbekanntes Attribut nur als String durch ('' = gesetzt).
const INERT = (parseInt(version, 10) >= 19 ? { inert: true } : { inert: '' }) as unknown as { inert?: boolean };
/** decorative: Gerät ist ein Bild (role="img"), Inhalt nicht fokussier- oder klickbar */
function DeviceCanvas({ width, height, decorative, children }: {
width: number;
height: number;
decorative?: boolean;
children?: ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
useCanvasScale(ref, width, height);
return (
<div ref={ref} className="dm-canvas" {...(decorative ? INERT : null)} data-design-width={width} data-design-height={height}>
{children}
</div>
);
}
/** Attribute, die an das Wurzelelement weitergereicht werden (id, aria-hidden, data-*, onClick …) */
type RootAttributes = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'role' | 'color' | 'className' | 'style'>;
const cx = (...parts: Array<string | undefined | false>) => parts.filter(Boolean).join(' ');
/* ------------------------------------------------------------ Symbole
Alle dekorativ (aria-hidden), gezeichnet in Design-Punkten. */
const G = {
// Ampel als eine Grafik: drei exakt gleiche Kreise bei jeder Größe
lights: (
<svg viewBox="0 0 52 12" aria-hidden="true" focusable="false">
<circle cx="6" cy="6" r="5.7" fill="#ff5f57" stroke="#000" strokeOpacity=".22" strokeWidth=".6" />
<circle cx="26" cy="6" r="5.7" fill="#febc2e" stroke="#000" strokeOpacity=".22" strokeWidth=".6" />
<circle cx="46" cy="6" r="5.7" fill="#28c840" stroke="#000" strokeOpacity=".22" strokeWidth=".6" />
</svg>
),
apple: (
<svg className="dm-macbook__apple" viewBox="0 0 14 17" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M11.62 9.03c-.02-1.9 1.55-2.82 1.62-2.86-.88-1.29-2.26-1.47-2.75-1.49-1.17-.12-2.28.69-2.88.69-.59 0-1.5-.67-2.47-.65A3.67 3.67 0 0 0 2.05 6.6C.73 8.88 1.71 12.27 3 14.13c.63.91 1.38 1.93 2.36 1.9.95-.04 1.31-.61 2.45-.61 1.15 0 1.47.61 2.47.59 1.02-.02 1.66-.93 2.29-1.84.72-1.06 1.02-2.08 1.03-2.13-.02-.01-1.97-.76-1.98-3.01ZM9.75 3.46c.52-.63.87-1.51.78-2.38-.75.03-1.66.5-2.2 1.13-.48.56-.9 1.45-.79 2.31.84.07 1.69-.42 2.21-1.06Z" />
</svg>
),
macBattery: (
<svg viewBox="0 0 27 13" aria-hidden="true" focusable="false">
<rect x=".6" y=".6" width="23.2" height="11.8" rx="3.4" fill="none" stroke="currentColor" strokeOpacity=".45" strokeWidth="1.2" />
<rect x="2.3" y="2.3" width="16.4" height="8.4" rx="1.9" fill="currentColor" />
<path d="M25 4.4c.9.2 1.5.9 1.5 2.1s-.6 1.9-1.5 2.1z" fill="currentColor" fillOpacity=".45" />
</svg>
),
wifi: (
<svg viewBox="0 0 17 12.6" aria-hidden="true" focusable="false">
<g fill="none" stroke="currentColor" strokeWidth="2.1">
<path d="M1.43 4.93a10 10 0 0 1 14.14 0" />
<path d="M4.05 7.55a6.3 6.3 0 0 1 8.9 0" />
</g>
<path fill="currentColor" d="M8.5 12 5.95 9.45a3.6 3.6 0 0 1 5.1 0Z" />
</svg>
),
search: (
<svg viewBox="0 0 15 15" aria-hidden="true" focusable="false">
<circle cx="6.2" cy="6.2" r="4.9" fill="none" stroke="currentColor" strokeWidth="1.6" />
<path d="m9.8 9.8 4.2 4.2" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
),
control: (
<svg viewBox="0 0 17 14" aria-hidden="true" focusable="false">
<rect x=".8" y=".8" width="15.4" height="5.2" rx="2.6" fill="none" stroke="currentColor" strokeWidth="1.3" />
<circle cx="4.1" cy="3.4" r="1.7" fill="currentColor" />
<rect x=".8" y="8" width="15.4" height="5.2" rx="2.6" fill="none" stroke="currentColor" strokeWidth="1.3" />
<circle cx="12.9" cy="10.6" r="1.7" fill="currentColor" />
</svg>
),
sidebar: (
<svg viewBox="0 0 22 17" aria-hidden="true" focusable="false">
<rect x=".8" y=".8" width="20.4" height="15.4" rx="3.4" fill="none" stroke="currentColor" strokeWidth="1.5" />
<path d="M8 1v15" stroke="currentColor" strokeWidth="1.5" />
<path d="M3.3 4.6h2.4M3.3 7.1h2.4M3.3 9.6h2.4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
),
chevDown: (
<svg viewBox="0 0 9 6" aria-hidden="true" focusable="false">
<path d="m1 1 3.5 3.5L8 1" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
back: (
<svg viewBox="0 0 10 17" aria-hidden="true" focusable="false">
<path d="M8.3 1.4 1.7 8.5l6.6 7.1" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
fwd: (
<svg viewBox="0 0 10 17" aria-hidden="true" focusable="false">
<path d="m1.7 1.4 6.6 7.1-6.6 7.1" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
pageMenu: (
<svg viewBox="0 0 18 14" aria-hidden="true" focusable="false">
<rect x=".8" y=".8" width="16.4" height="12.4" rx="2.6" fill="none" stroke="currentColor" strokeWidth="1.3" />
<path d="M4 4.6h10M4 7h10M4 9.4h6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
),
lock: (
<svg viewBox="0 0 10 13" aria-hidden="true" focusable="false">
<path d="M2.6 5.6V4a2.4 2.4 0 0 1 4.8 0v1.6" fill="none" stroke="currentColor" strokeWidth="1.35" />
<rect x=".6" y="5.4" width="8.8" height="7" rx="1.6" fill="currentColor" />
</svg>
),
reload: (
<svg viewBox="0 0 14 15" aria-hidden="true" focusable="false">
<path d="M12 8.4A5 5 0 1 1 9.7 3.6" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M8.4 1 11.2 3.5 8.2 5.9" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
share: (
<svg viewBox="0 0 16 20" aria-hidden="true" focusable="false">
<path d="M5.2 7.2H3.4A1.9 1.9 0 0 0 1.5 9.1v7.5c0 1.05.85 1.9 1.9 1.9h9.2c1.05 0 1.9-.85 1.9-1.9V9.1a1.9 1.9 0 0 0-1.9-1.9h-1.8" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M8 12.2V1.4M4.8 4.4 8 1.2l3.2 3.2" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
),
plus: (
<svg viewBox="0 0 15 15" aria-hidden="true" focusable="false">
<path d="M7.5 1v13M1 7.5h13" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
),
tabs: (
<svg viewBox="0 0 18 18" aria-hidden="true" focusable="false">
<rect x="4.8" y=".9" width="12.3" height="12.3" rx="2.6" fill="none" stroke="currentColor" strokeWidth="1.4" />
<path d="M12.9 15.3a2.4 2.4 0 0 1-2.2 1.8H3.4A2.5 2.5 0 0 1 .9 14.6V7.3a2.4 2.4 0 0 1 1.8-2.3" fill="none" stroke="currentColor" strokeWidth="1.4" />
</svg>
),
cellular: (
<svg viewBox="0 0 18 12" aria-hidden="true" focusable="false">
<rect x="0" y="7.4" width="3.1" height="4.6" rx=".9" fill="currentColor" />
<rect x="4.9" y="5" width="3.1" height="7" rx=".9" fill="currentColor" />
<rect x="9.8" y="2.5" width="3.1" height="9.5" rx=".9" fill="currentColor" />
<rect x="14.7" y="0" width="3.1" height="12" rx=".9" fill="currentColor" />
</svg>
),
iosBattery: (
<svg viewBox="0 0 27.5 13" aria-hidden="true" focusable="false">
<rect x=".55" y=".55" width="23.9" height="11.9" rx="3.7" fill="none" stroke="currentColor" strokeOpacity=".38" strokeWidth="1.1" />
<rect x="2.1" y="2.1" width="18.6" height="8.8" rx="2.2" fill="currentColor" />
<path d="M25.6 4.3c.95.25 1.5 1.05 1.5 2.2s-.55 1.95-1.5 2.2z" fill="currentColor" fillOpacity=".4" />
</svg>
),
};
const MENUS = ['Ablage', 'Bearbeiten', 'Darstellung', 'Verlauf', 'Lesezeichen', 'Fenster', 'Hilfe'];
/* ------------------------------------------------------------ MacBook */
export interface MacBookFrameProps extends RootAttributes {
/** Adresse in der Safari-Adressleiste */
url?: string;
color?: MacBookColor;
/** false = Vollbild ohne macOS-Menüleiste (Streifen neben der Notch schwarz, keine Fenstertasten) */
menuBar?: boolean;
/** Datum und Uhrzeit rechts in der Menüleiste */
clock?: string;
className?: string;
style?: CSSProperties;
/** Setzt role="img" + aria-label; der Inhalt ist dann ein Bild (inert, nicht bedienbar) */
label?: string;
/** Inhalt in 1512 x 893 Design-Pixeln */
children?: ReactNode;
}
export function MacBookFrame({
url = 'project.webklar.com',
color = 'silver',
menuBar = true,
clock = 'Do. 1. Okt. 09:41',
className,
style,
label,
children,
...rest
}: MacBookFrameProps) {
return (
<div
{...rest}
className={cx('dm-macbook', className)}
data-color={color}
data-menubar={menuBar ? undefined : 'off'}
{...(label ? { role: 'img', 'aria-label': label } : null)}
style={style}
>
<div className="dm-macbook__lid">
<div className="dm-macbook__glass">
<div className="dm-macbook__screen">
{menuBar && (
<div className="dm-macbook__menubar" aria-hidden="true">
<div className="dm-macbook__menus">
{G.apple}
<b>Safari</b>
{MENUS.map((m) => (
<span key={m}>{m}</span>
))}
</div>
<div className="dm-macbook__extras">
{G.macBattery}
{G.wifi}
{G.search}
{G.control}
<span>{clock}</span>
</div>
</div>
)}
<div className="dm-macbook__window">
<div className="dm-macbook__toolbar" aria-hidden="true">
<span className="dm-macbook__lights">{G.lights}</span>
<span className="dm-macbook__tool dm-macbook__tool--sidebar">
{G.sidebar}
{G.chevDown}
</span>
<span className="dm-macbook__tool dm-macbook__tool--nav">
{G.back}
{G.fwd}
</span>
<span className="dm-macbook__address">
{G.pageMenu}
<span className="dm-macbook__url">
{G.lock}
<span>{url}</span>
</span>
{G.reload}
</span>
<span className="dm-macbook__tool dm-macbook__tool--right">
{G.share}
{G.plus}
{G.tabs}
</span>
</div>
<div className="dm-macbook__viewport">
<DeviceCanvas width={1512} height={893} decorative={!!label}>
{children}
</DeviceCanvas>
</div>
</div>
<div className="dm-macbook__notch" aria-hidden="true">
<i className="dm-macbook__camera" />
</div>
</div>
</div>
</div>
<div className="dm-macbook__hinge" aria-hidden="true" />
<div className="dm-macbook__base" aria-hidden="true">
<i className="dm-macbook__lip" />
</div>
<div className="dm-macbook__shadow" aria-hidden="true" />
</div>
);
}
/* ------------------------------------------------------------ iPhone */
export interface IPhoneFrameProps extends RootAttributes {
color?: IPhoneColor;
/** Uhrzeit in der Statusleiste */
time?: string;
/** 'dark' für helle App-Inhalte (schwarze Statusleiste und Home-Balken) */
statusBar?: 'light' | 'dark';
className?: string;
style?: CSSProperties;
/** Setzt role="img" + aria-label; der Inhalt ist dann ein Bild (inert, nicht bedienbar) */
label?: string;
/** Inhalt in 402 x 874 Design-Pixeln (oben 59, unten 34 Safe Area) */
children?: ReactNode;
}
export function IPhoneFrame({
color = 'natural',
time = '9:41',
statusBar = 'light',
className,
style,
label,
children,
...rest
}: IPhoneFrameProps) {
return (
<div
{...rest}
className={cx('dm-iphone', className)}
data-color={color}
data-statusbar={statusBar === 'dark' ? 'dark' : undefined}
{...(label ? { role: 'img', 'aria-label': label } : null)}
style={style}
>
<span className="dm-iphone__buttons" aria-hidden="true">
<i className="dm-iphone__btn--action" />
<i className="dm-iphone__btn--vol-up" />
<i className="dm-iphone__btn--vol-down" />
<i className="dm-iphone__btn--side" />
<i className="dm-iphone__btn--camera" />
</span>
<div className="dm-iphone__body">
<div className="dm-iphone__glass">
<div className="dm-iphone__screen">
<div className="dm-iphone__viewport">
<DeviceCanvas width={402} height={874} decorative={!!label}>
{children}
</DeviceCanvas>
</div>
<div className="dm-iphone__statusbar" aria-hidden="true">
<span className="dm-iphone__time">{time}</span>
<span className="dm-iphone__indicators">
{G.cellular}
{G.wifi}
{G.iosBattery}
</span>
</div>
<div className="dm-iphone__island" aria-hidden="true">
<i />
</div>
<div className="dm-iphone__home" aria-hidden="true" />
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,786 @@
/* ==========================================================================
device-frames.css – realistische, fluide Geräterahmen
MacBook Pro 14" (ab 2021, mit Notch) · iPhone 16 Pro / 17 Pro
Eine Breite skaliert alles:
.dm-macbook { width: 760px } .dm-iphone { width: 220px }
Die Rahmengeometrie steht in cqw des Wurzelelements (container-type).
Die Bildschirm-Chrome (Menüleiste, Safari, Statusleiste) ist in
Design-Punkten gezeichnet: --dm-u = 1 pt des echten Bildschirms.
Seiteninhalt gehört in .dm-canvas (feste Designgröße, wird per
device-frames.js bzw. React-Hook mit transform: scale() eingepasst):
MacBook-Webviewport 1512 x 893, iPhone-Bildschirm 402 x 874.
Maße (echte Geräte):
MacBook Unterteil = Wurzelbreite = 114 % des Deckels (L = 87,72cqw);
Alurand 0,35 % L, schwarzes Glas 1,4 % L (Seiten/oben),
Kinn 3,1 % L, Deckelecke 2,0 % L, Panelecke 0,7 % S,
Display 1512 x 982 pt, Notch 188 x 37 pt.
iPhone Gehäuse 71,5 x 149,6 mm, Display 402 x 874 pt,
Titanband 1,35 % + schwarzer Rand 2,05 % der Gehäusebreite,
konzentrische Radien, Dynamic Island 126 x 37 pt @ 11 pt.
========================================================================== */
/* Wurzeln mit Spezifität 0: Klassen der Seite (Tailwind usw.) gewinnen */
:where(.dm-macbook, .dm-iphone) {
position: relative;
display: block;
box-sizing: border-box;
width: 100%;
container-type: inline-size;
isolation: isolate;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-user-select: none;
user-select: none;
}
:where(.dm-macbook) {
display: flex;
flex-direction: column;
align-items: center;
}
/* box-sizing nur für die eigenen dm-Elemente, nicht für den Inhalt */
:where(.dm-macbook, .dm-iphone) :where([class^="dm-"], [class*=" dm-"]),
:where(.dm-macbook, .dm-iphone) :where([class^="dm-"], [class*=" dm-"])::before,
:where(.dm-macbook, .dm-iphone) :where([class^="dm-"], [class*=" dm-"])::after {
box-sizing: border-box;
}
/* Systemschrift nur für die Chrome-Ebenen */
.dm-macbook__menubar,
.dm-macbook__toolbar,
.dm-iphone__statusbar {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-style: normal;
line-height: 1;
text-align: left;
text-transform: none;
text-rendering: geometricPrecision; /* kein Hinting-Zittern bei kleinen Größen */
}
:where(.dm-macbook__menubar, .dm-macbook__toolbar, .dm-iphone__statusbar) svg {
display: block;
flex: none;
overflow: visible;
}
/* --------------------------------------------------------------------------
Inhalts-Canvas (gemeinsam)
-------------------------------------------------------------------------- */
.dm-canvas {
position: absolute;
top: 0;
left: 0;
width: calc(var(--dm-dw) * 1px);
height: calc(var(--dm-dh) * 1px);
transform-origin: 0 0;
/* JS setzt --dm-k; ohne JS rechnet CSS Breite / Designbreite */
transform: scale(var(--dm-k, tan(atan2(100cqw, calc(var(--dm-dw) * 1px)))));
overflow: hidden;
container-type: inline-size; /* cqw im Inhalt = Design-Pixel */
-webkit-user-select: text;
user-select: text;
}
/* Gerät als Bild (role="img"): Inhalt ist Dekoration, nicht bedienbar
(zusätzlich zum inert-Attribut der Canvas) */
:where(.dm-macbook, .dm-iphone)[role="img"] .dm-canvas {
pointer-events: none;
-webkit-user-select: none;
user-select: none;
}
/* ==========================================================================
MacBook Pro 14"
========================================================================== */
.dm-macbook {
/* Silber (Standard) */
--dm-al-hi: #f7f8f9;
--dm-al-1: #e0e1e4;
--dm-al-2: #cbcdd1;
--dm-al-3: #b1b4b9;
--dm-al-4: #8f9298;
--dm-al-5: #6d7076;
--dm-al-6: #4b4d52;
--dm-al-spec: rgba(255, 255, 255, .85);
--dm-al-sheen: rgba(255, 255, 255, .5);
--dm-al-edge: rgba(40, 42, 46, .5);
--dm-hinge-1: #08080a;
--dm-hinge-2: #2b2c2f;
--dm-hinge-3: #46484c;
--dm-foot: #2a2b2e;
--dm-bezel: #060607;
}
.dm-macbook[data-color="space-black"] {
--dm-al-hi: #8a8c91;
--dm-al-1: #56585c;
--dm-al-2: #424448;
--dm-al-3: #333539;
--dm-al-4: #26272a;
--dm-al-5: #1b1c1e;
--dm-al-6: #111213;
--dm-al-spec: rgba(255, 255, 255, .3);
--dm-al-sheen: rgba(255, 255, 255, .16);
--dm-al-edge: rgba(0, 0, 0, .6);
--dm-hinge-1: #050506;
--dm-hinge-2: #1e1f21;
--dm-hinge-3: #34363a;
--dm-foot: #0b0b0c;
}
/* ---- Deckel: Aluschale, nur als haarfeiner Rand sichtbar ---- */
.dm-macbook__lid {
position: relative;
z-index: 2;
width: 87.72cqw;
padding: .307cqw; /* Rand 0,35 % L */
border-radius: 1.754cqw 1.754cqw .26cqw .26cqw; /* 2,0 % L oben, unten fast eckig */
background:
/* Glanzstreifen und Kanten, die vom Licht wegdrehen */
linear-gradient(90deg,
rgba(0,0,0,.16) 0%, rgba(0,0,0,0) 9%,
var(--dm-al-sheen) 30%, rgba(255,255,255,0) 46%,
rgba(255,255,255,0) 64%, rgba(255,255,255,.22) 78%,
rgba(0,0,0,0) 90%, rgba(0,0,0,.2) 100%),
linear-gradient(180deg,
var(--dm-al-hi) 0%, var(--dm-al-1) 3%, var(--dm-al-2) 18%, var(--dm-al-1) 34%,
var(--dm-al-3) 55%, var(--dm-al-2) 72%, var(--dm-al-3) 90%, var(--dm-al-4) 100%);
box-shadow:
inset 0 max(.5px, .06cqw) 0 var(--dm-al-spec), /* Glanzkante oben */
inset .19cqw 0 .16cqw rgba(0, 0, 0, .12), /* Seiten dunkler */
inset -.19cqw 0 .16cqw rgba(0, 0, 0, .2),
inset 0 -.26cqw .18cqw rgba(0, 0, 0, .28), /* Unterkante im Schatten */
0 0 0 max(.5px, .04cqw) var(--dm-al-edge); /* Außenkante */
}
/* Glanzlichter, die um den Rand wandern (ringförmig maskiert) */
.dm-macbook__lid::before {
content: "";
position: absolute;
inset: 0;
padding: .307cqw;
border-radius: inherit;
pointer-events: none;
background: conic-gradient(from 0deg at 50% 50%,
rgba(255,255,255,.35) 0deg, rgba(255,255,255,0) 16deg,
rgba(255,255,255,.4) 40deg, rgba(255,255,255,0) 52deg,
rgba(0,0,0,.1) 70deg, rgba(255,255,255,.18) 92deg, rgba(0,0,0,.12) 118deg,
rgba(0,0,0,.2) 160deg, rgba(0,0,0,.2) 200deg,
rgba(0,0,0,.06) 236deg, rgba(255,255,255,.28) 262deg, rgba(255,255,255,0) 286deg,
rgba(255,255,255,.55) 318deg, rgba(255,255,255,.08) 336deg,
rgba(255,255,255,.35) 360deg);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
}
/* ---- schwarzes Glas mit Rändern ---- */
.dm-macbook__glass {
position: relative;
padding: 1.228cqw 1.228cqw 2.719cqw; /* 1,4 % L Seiten/oben, 3,1 % L Kinn */
border-radius: 1.447cqw 1.447cqw .1cqw .1cqw;
background: var(--dm-bezel);
box-shadow:
inset 0 0 0 max(.5px, .05cqw) rgba(0, 0, 0, .9),
inset 0 0 0 max(1px, .1cqw) rgba(255, 255, 255, .06); /* Glaskante */
}
/* sehr schwache Spiegelung über dem ganzen Glas (stört nie die Lesbarkeit) */
.dm-macbook__glass::after {
content: "";
position: absolute;
inset: 0;
z-index: 5;
border-radius: inherit;
pointer-events: none;
background:
linear-gradient(122deg,
rgba(255,255,255,.055) 0%,
rgba(255,255,255,.028) 24%,
rgba(255,255,255,.008) 46%,
rgba(255,255,255,0) 60%);
}
/* ---- Display 1512 x 982 pt ---- */
.dm-macbook__screen {
--dm-u: calc(100cqw / 1512); /* 1 macOS-Punkt */
position: relative;
width: 100%;
aspect-ratio: 1512 / 982;
overflow: hidden;
border-radius: .593cqw .593cqw 0 0; /* 0,7 % S, nur oben */
container-type: inline-size;
background: /* Schreibtisch-Hintergrundbild */
radial-gradient(70% 140% at 12% 0%, #4a2f86 0%, rgba(74,47,134,0) 60%),
radial-gradient(60% 120% at 88% 0%, #1f3b6e 0%, rgba(31,59,110,0) 65%),
linear-gradient(180deg, #1b1430, #0b0a12);
}
/* Vollbild ohne Menüleiste: Streifen neben der Notch ist schwarz wie der Rand,
die Notch verschwindet darin */
.dm-macbook[data-menubar="off"] .dm-macbook__screen { background: var(--dm-bezel); }
/* macOS-Menüleiste (durchscheinend über dem Hintergrund, von der Notch geteilt) */
.dm-macbook__menubar {
position: absolute;
inset: 0 0 auto 0;
height: calc(37 * var(--dm-u));
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 calc(14 * var(--dm-u)) 0 calc(21 * var(--dm-u));
font-size: calc(13 * var(--dm-u));
font-weight: 500;
letter-spacing: .005em;
color: rgba(255, 255, 255, .94);
background: linear-gradient(180deg, rgba(0,0,0,.28), rgba(0,0,0,.18));
text-shadow: 0 0 calc(2 * var(--dm-u)) rgba(0, 0, 0, .25);
white-space: nowrap;
}
/* Text darf nie in die Notch laufen (auch nicht mit Mindestschriftgröße):
was nicht passt, bricht in eine unsichtbare zweite Zeile um */
.dm-macbook__menus,
.dm-macbook__extras {
display: flex;
flex-wrap: wrap;
align-items: center;
height: 100%;
overflow: hidden;
}
.dm-macbook__menus {
max-width: calc(633 * var(--dm-u));
column-gap: calc(19 * var(--dm-u));
}
.dm-macbook__extras {
max-width: calc(640 * var(--dm-u));
justify-content: flex-end;
column-gap: calc(17 * var(--dm-u));
}
.dm-macbook__menus > span,
.dm-macbook__menus > b,
.dm-macbook__extras > span { line-height: calc(37 * var(--dm-u)); }
.dm-macbook__menus b { font-weight: 700; margin-left: calc(4 * var(--dm-u)); }
.dm-macbook__apple { width: calc(14 * var(--dm-u)); height: calc(17 * var(--dm-u)); margin-top: calc(-2 * var(--dm-u)); }
.dm-macbook__extras svg { height: calc(13 * var(--dm-u)); width: auto; }
.dm-macbook__extras svg:nth-child(3) { height: calc(14 * var(--dm-u)); }
.dm-macbook__extras span { margin-left: calc(2 * var(--dm-u)); white-space: pre; }
/* Notch in Rahmenschwarz, mit kleinen Hohlkehlen und Kamera */
.dm-macbook__notch {
position: absolute;
top: 0;
left: 50%;
z-index: 3;
width: calc(188 * var(--dm-u));
height: calc(37 * var(--dm-u));
transform: translateX(-50%);
background: var(--dm-bezel);
border-radius: 0 0 calc(13 * var(--dm-u)) calc(13 * var(--dm-u));
}
.dm-macbook__notch::before,
.dm-macbook__notch::after {
content: "";
position: absolute;
top: 0;
width: calc(6 * var(--dm-u));
height: calc(6 * var(--dm-u));
}
.dm-macbook__notch::before {
right: 100%;
background: radial-gradient(circle at 0 100%, transparent calc(6 * var(--dm-u) - .5px), var(--dm-bezel) calc(6 * var(--dm-u)));
}
.dm-macbook__notch::after {
left: 100%;
background: radial-gradient(circle at 100% 100%, transparent calc(6 * var(--dm-u) - .5px), var(--dm-bezel) calc(6 * var(--dm-u)));
}
.dm-macbook__camera {
position: absolute;
top: 50%;
left: 50%;
width: calc(8 * var(--dm-u));
height: calc(8 * var(--dm-u));
transform: translate(-50%, -50%);
border-radius: 50%;
background:
radial-gradient(circle at 36% 34%, rgba(150,140,255,.55) 0, rgba(80,70,170,.25) 22%, rgba(0,0,0,0) 46%),
radial-gradient(circle at 50% 50%, #0d0f1c 0, #07080f 55%, #15161c 70%, #050506 100%);
box-shadow: 0 0 0 calc(1.2 * var(--dm-u)) rgba(255, 255, 255, .035);
}
/* Safari-Fenster (maximiert), Dunkelmodus */
.dm-macbook__window {
position: absolute;
top: calc(37 * var(--dm-u));
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: calc(12 * var(--dm-u)) calc(12 * var(--dm-u)) calc(6 * var(--dm-u)) calc(6 * var(--dm-u));
background: #1c1c1e;
}
.dm-macbook[data-menubar="off"] .dm-macbook__window { border-radius: 0; }
.dm-macbook__window::after { /* Fensterkontur im Dunkelmodus */
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
box-shadow: inset 0 0 0 max(.5px, calc(1 * var(--dm-u))) rgba(255, 255, 255, .13);
z-index: 2;
}
.dm-macbook__toolbar {
position: relative;
flex: none;
display: flex;
align-items: center;
height: calc(52 * var(--dm-u));
padding: 0 calc(20 * var(--dm-u));
background: linear-gradient(180deg, #333336, #2c2c2f);
box-shadow: inset 0 calc(-1 * var(--dm-u)) 0 rgba(0, 0, 0, .75);
color: rgba(255, 255, 255, .62);
z-index: 1;
}
/* Ampel als eine Vektorgrafik: drei gleich große Kreise bei jeder Größe */
.dm-macbook__lights { display: flex; margin-right: calc(28 * var(--dm-u)); }
.dm-macbook__lights svg { width: calc(52 * var(--dm-u)); height: calc(12 * var(--dm-u)); }
/* Vollbild: macOS blendet die Fenstertasten aus */
.dm-macbook[data-menubar="off"] .dm-macbook__lights { visibility: hidden; }
.dm-macbook__tool { display: flex; align-items: center; }
.dm-macbook__tool--sidebar { gap: calc(5 * var(--dm-u)); margin-right: calc(30 * var(--dm-u)); }
.dm-macbook__tool--sidebar svg:first-child { width: calc(22 * var(--dm-u)); }
.dm-macbook__tool--sidebar svg:last-child { width: calc(9 * var(--dm-u)); opacity: .8; }
.dm-macbook__tool--nav { gap: calc(26 * var(--dm-u)); }
.dm-macbook__tool--nav svg { width: calc(10 * var(--dm-u)); }
.dm-macbook__tool--nav svg:last-child { opacity: .35; }
.dm-macbook__tool--right { margin-left: auto; gap: calc(28 * var(--dm-u)); }
.dm-macbook__tool--right svg:nth-child(1) { width: calc(16 * var(--dm-u)); margin-top: calc(-3 * var(--dm-u)); }
.dm-macbook__tool--right svg:nth-child(2) { width: calc(15 * var(--dm-u)); }
.dm-macbook__tool--right svg:nth-child(3) { width: calc(18 * var(--dm-u)); }
.dm-macbook__address {
position: absolute;
left: 50%;
top: 50%;
display: flex;
align-items: center;
justify-content: space-between;
gap: calc(10 * var(--dm-u));
width: calc(640 * var(--dm-u));
height: calc(32 * var(--dm-u));
padding: 0 calc(11 * var(--dm-u));
transform: translate(-50%, -50%);
border-radius: calc(9 * var(--dm-u));
background: rgba(255, 255, 255, .085);
box-shadow: inset 0 0 0 max(.4px, calc(.5 * var(--dm-u))) rgba(255, 255, 255, .04);
}
.dm-macbook__address > svg:first-child { width: calc(18 * var(--dm-u)); opacity: .75; }
.dm-macbook__address > svg:last-child { width: calc(14 * var(--dm-u)); opacity: .75; }
.dm-macbook__url {
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
gap: calc(6 * var(--dm-u));
font-size: calc(13.5 * var(--dm-u));
font-weight: 500;
color: rgba(255, 255, 255, .9);
letter-spacing: .005em;
white-space: nowrap;
}
.dm-macbook__url span { overflow: hidden; text-overflow: ellipsis; }
.dm-macbook__url svg { width: calc(9.5 * var(--dm-u)); color: rgba(255, 255, 255, .6); margin-top: calc(-1 * var(--dm-u)); }
/* Web-Viewport: 1512 x 893 pt */
.dm-macbook__viewport {
--dm-dw: 1512;
--dm-dh: 893;
position: relative;
flex: 1 1 auto;
overflow: hidden;
background: #000;
container-type: inline-size;
}
/* ---- Scharnier (dunkel, endet unter dem Alurand des Deckels) ---- */
.dm-macbook__hinge {
position: relative;
z-index: 1;
width: 87.2cqw; /* 99,4 % L */
height: .78cqw; /* 0,9 % L */
margin-top: -.05cqw;
border-radius: 0 0 .5cqw .5cqw / 0 0 .5cqw .5cqw;
background:
linear-gradient(90deg, rgba(0,0,0,.55), rgba(0,0,0,0) 4%, rgba(0,0,0,0) 96%, rgba(0,0,0,.55)),
linear-gradient(180deg, var(--dm-hinge-1) 0%, var(--dm-hinge-1) 22%, var(--dm-hinge-2) 50%, var(--dm-hinge-3) 66%, var(--dm-hinge-2) 80%, var(--dm-hinge-1) 100%);
}
/* ---- Unterteil: Aluplatte, fast genau von vorn gesehen ---- */
.dm-macbook__base {
position: relative;
z-index: 2;
width: 100cqw;
height: 2.72cqw; /* 3,1 % L */
margin-top: -.15cqw;
margin-bottom: .36cqw; /* Platz für die Füße */
border-radius: 1.2cqw 1.2cqw 2.2cqw 2.2cqw / .7cqw .7cqw 1.6cqw 1.6cqw;
background:
/* von oben gerundete Enden: auch die helle Oberkante dreht dort weg */
radial-gradient(4.2cqw 1.5cqw at 0 0, rgba(0,0,0,.24), rgba(0,0,0,.08) 55%, rgba(0,0,0,0)),
radial-gradient(4.2cqw 1.5cqw at 100% 0, rgba(0,0,0,.24), rgba(0,0,0,.08) 55%, rgba(0,0,0,0)),
/* Enden, die vom Licht wegdrehen, und breite Glanzstreifen */
linear-gradient(90deg,
rgba(0,0,0,.32) 0%, rgba(0,0,0,.08) 2.5%, rgba(0,0,0,0) 6%,
rgba(255,255,255,.16) 24%, rgba(255,255,255,0) 40%,
rgba(255,255,255,0) 62%, rgba(255,255,255,.1) 76%,
rgba(0,0,0,0) 94%, rgba(0,0,0,.08) 97.5%, rgba(0,0,0,.32) 100%),
/* Oberseite als schmaler Streifen -> Kante -> Front, die in den Schatten fällt */
linear-gradient(180deg,
var(--dm-al-hi) 0%, var(--dm-al-1) 4%, var(--dm-al-2) 10%, var(--dm-al-3) 14%,
var(--dm-al-5) 16%, var(--dm-al-2) 18.5%, var(--dm-al-hi) 21.5%, var(--dm-al-1) 28%,
var(--dm-al-2) 42%, var(--dm-al-3) 58%, var(--dm-al-4) 72%, var(--dm-al-5) 84%,
var(--dm-al-6) 93%, var(--dm-al-5) 100%);
box-shadow:
inset 0 calc(-1 * max(.5px, .044cqw)) 0 rgba(0, 0, 0, .35),
0 0 0 max(.5px, .03cqw) rgba(0, 0, 0, .2);
}
/* Daumenmulde in der Vorderkante */
.dm-macbook__lip {
position: absolute;
top: 0;
left: 50%;
width: 12.28cqw; /* 14 % L */
height: 1.07cqw; /* ca. 40 % der Höhe */
transform: translateX(-50%);
border-radius: 0 0 3.5cqw 3.5cqw / 0 0 1.07cqw 1.07cqw;
background:
linear-gradient(90deg, rgba(0,0,0,.22) 0%, rgba(0,0,0,0) 22%, rgba(0,0,0,0) 78%, rgba(0,0,0,.22) 100%),
linear-gradient(180deg,
var(--dm-al-5) 0%, var(--dm-al-4) 30%, var(--dm-al-3) 60%, var(--dm-al-2) 86%, var(--dm-al-1) 100%);
box-shadow:
inset 0 .1cqw .18cqw rgba(0, 0, 0, .28),
0 max(.5px, .044cqw) 0 rgba(255, 255, 255, .5); /* beleuchtete Unterlippe */
}
/* Gummifüße */
.dm-macbook__base::before,
.dm-macbook__base::after {
content: "";
position: absolute;
top: calc(100% - .08cqw);
width: 4.2cqw;
height: .26cqw;
border-radius: 50%;
background: radial-gradient(closest-side, var(--dm-foot) 30%, rgba(0, 0, 0, 0));
opacity: .7; /* von vorn kaum sichtbar */
}
.dm-macbook__base::before { left: 8.5cqw; }
.dm-macbook__base::after { right: 8.5cqw; }
/* Kontakt- und Umgebungsschatten auf dem "Tisch" */
.dm-macbook__shadow {
position: absolute;
z-index: 0;
left: 0;
right: 0;
bottom: -2.3cqw;
height: 3.2cqw;
pointer-events: none;
}
.dm-macbook__shadow::before,
.dm-macbook__shadow::after {
content: "";
position: absolute;
border-radius: 50%;
}
.dm-macbook__shadow::before { /* Kontakt */
left: 3%;
right: 3%;
top: .6cqw;
height: .85cqw;
background: rgba(0, 0, 0, .8);
filter: blur(.27cqw);
}
.dm-macbook__shadow::after { /* Umgebung */
left: 1%;
right: 1%;
top: .45cqw;
height: 2.7cqw;
background: radial-gradient(closest-side, rgba(0, 0, 0, .5), rgba(0, 0, 0, .22) 60%, rgba(0, 0, 0, 0));
filter: blur(.44cqw);
}
/* sehr klein: Haarlinien weglassen, sie würden nur rauschen */
@container (max-width: 320px) {
.dm-macbook__base::before,
.dm-macbook__base::after { display: none; }
}
/* ==========================================================================
iPhone 16 Pro
========================================================================== */
.dm-iphone {
/* Titan Natur (Standard): warmes, gebürstetes Mittelgrau */
--dm-t-hi: #e2ddd5;
--dm-t-1: #cac4ba;
--dm-t-2: #b3aca1;
--dm-t-3: #999287;
--dm-t-4: #7f786e;
--dm-t-5: #5c564e;
--dm-t-ant: rgba(112, 104, 92, .55);
--dm-t-glint: rgba(255, 255, 255, .38);
--dm-t-band-hl: rgba(255, 255, 255, .10);
--dm-status: #fff;
}
.dm-iphone[data-color="black"] {
--dm-t-hi: #6c6c70;
--dm-t-1: #46464a;
--dm-t-2: #363639;
--dm-t-3: #2a2a2d;
--dm-t-4: #1e1e21;
--dm-t-5: #111113;
--dm-t-top-light: rgba(255, 255, 255, .09);
--dm-t-ant: rgba(4, 4, 6, .6);
--dm-t-glint: rgba(255, 255, 255, .26);
--dm-t-band-hl: rgba(255, 255, 255, .06);
--dm-t-glint-opacity: .55; /* dunkles Titan spiegelt matter */
}
.dm-iphone[data-color="white"] {
--dm-t-hi: #ffffff;
--dm-t-1: #f1f1ef;
--dm-t-2: #e1e1de;
--dm-t-3: #cbcbc8;
--dm-t-4: #aeaeab;
--dm-t-5: #8a8a87;
--dm-t-ant: rgba(150, 150, 146, .55);
--dm-t-glint: rgba(255, 255, 255, .6);
}
.dm-iphone[data-color="desert"] {
--dm-t-hi: #f0e2d2;
--dm-t-1: #dcc4ab;
--dm-t-2: #c4a98f;
--dm-t-3: #ad9178;
--dm-t-4: #8f7560;
--dm-t-5: #6b5645;
--dm-t-ant: rgba(130, 104, 82, .55);
--dm-t-glint: rgba(255, 246, 236, .55);
}
/* heller App-Inhalt: dunkle Statusleiste und Home-Balken */
.dm-iphone[data-statusbar="dark"] { --dm-status: #000; }
/* ---- Titanband (von vorn sichtbar) ---- */
.dm-iphone__body {
position: relative;
z-index: 1;
padding: 1.35cqw; /* Titanband */
border-radius: 17.66cqw; /* 14,26 + 2,05 + 1,35 */
background:
/* Antennenlinien (nur auf dem Band sichtbar, Glas deckt den Rest) */
linear-gradient(180deg, transparent 10.9%, var(--dm-t-ant) 10.9%, var(--dm-t-ant) 11.15%, transparent 11.15%,
transparent 88.85%, var(--dm-t-ant) 88.85%, var(--dm-t-ant) 89.1%, transparent 89.1%),
linear-gradient(90deg, transparent 23.6%, var(--dm-t-ant) 23.6%, var(--dm-t-ant) 24.1%, transparent 24.1%,
transparent 75.9%, var(--dm-t-ant) 75.9%, var(--dm-t-ant) 76.4%, transparent 76.4%),
/* Licht von oben */
linear-gradient(180deg, var(--dm-t-top-light, rgba(255,255,255,.16)), rgba(255,255,255,0) 22%, rgba(0,0,0,0) 74%, rgba(0,0,0,.14)),
/* Umgebungsspiegelungen rundherum (0deg = oben) */
conic-gradient(from 0deg at 50% 50%,
var(--dm-t-1) 0deg, var(--dm-t-hi) 13deg, var(--dm-t-2) 26deg, var(--dm-t-3) 40deg,
var(--dm-t-hi) 58deg, var(--dm-t-2) 72deg, var(--dm-t-3) 100deg, var(--dm-t-4) 126deg,
var(--dm-t-2) 148deg, var(--dm-t-3) 166deg, var(--dm-t-4) 182deg, var(--dm-t-3) 196deg,
var(--dm-t-1) 211deg, var(--dm-t-3) 236deg, var(--dm-t-4) 258deg, var(--dm-t-2) 282deg,
var(--dm-t-hi) 302deg, var(--dm-t-2) 318deg, var(--dm-t-1) 336deg, var(--dm-t-hi) 347deg, var(--dm-t-1) 360deg);
box-shadow:
inset 0 0 0 max(.5px, .16cqw) rgba(0, 0, 0, .30), /* Außenkante rollt weg */
inset 0 0 0 .42cqw rgba(0, 0, 0, .10),
inset 0 0 0 .82cqw var(--dm-t-band-hl, rgba(255, 255, 255, .16)), /* gerundetes Band fängt Licht */
0 0 0 max(.5px, .12cqw) rgba(0, 0, 0, .28), /* Silhouette auf hellem Grund */
0 .8cqw 1.6cqw rgba(0, 0, 0, .18),
0 5cqw 12cqw -1cqw rgba(0, 0, 0, .42);
}
/* Glanzpunkte auf dem Band (ringförmig maskiert, das Glas bleibt frei) */
.dm-iphone__body::before {
content: "";
position: absolute;
inset: .16cqw;
z-index: 1;
padding: 1.1cqw;
border-radius: 17.5cqw;
pointer-events: none;
background: conic-gradient(from 0deg at 50% 50%,
rgba(255,255,255,.1) 0deg, rgba(255,255,255,0) 14deg,
var(--dm-t-glint) 26deg, rgba(255,255,255,0) 38deg,
rgba(255,255,255,0) 80deg, rgba(255,255,255,.14) 100deg, rgba(255,255,255,0) 116deg,
rgba(255,255,255,0) 146deg, rgba(255,255,255,.24) 156deg, rgba(255,255,255,0) 166deg,
rgba(0,0,0,.1) 190deg, rgba(255,255,255,0) 214deg,
rgba(255,255,255,.04) 250deg, rgba(255,255,255,.2) 284deg, rgba(255,255,255,.04) 306deg,
var(--dm-t-glint) 326deg, rgba(255,255,255,.7) 333deg, var(--dm-t-glint) 340deg,
rgba(255,255,255,.06) 350deg, rgba(255,255,255,.1) 360deg);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
opacity: var(--dm-t-glint-opacity, .8);
}
/* ---- schwarzes Glas / Rand ---- */
.dm-iphone__glass {
position: relative;
z-index: 2;
padding: 2.05cqw;
border-radius: 16.31cqw;
background: #020203;
box-shadow:
0 0 0 max(.5px, .12cqw) var(--dm-t-1), /* polierte Fase */
0 0 0 max(.6px, .3cqw) rgba(0, 0, 0, .18),
inset 0 0 0 max(.5px, .14cqw) rgba(255, 255, 255, .07); /* Glaskante */
}
.dm-iphone__glass::after { /* Spiegelung */
content: "";
position: absolute;
inset: 0;
z-index: 6;
border-radius: inherit;
pointer-events: none;
background:
linear-gradient(118deg,
rgba(255,255,255,.07) 0%,
rgba(255,255,255,.03) 22%,
rgba(255,255,255,.01) 40%,
rgba(255,255,255,0) 52%);
}
/* ---- Display 402 x 874 pt ---- */
.dm-iphone__screen {
--dm-u: calc(100cqw / 402); /* 1 iOS-Punkt */
position: relative;
width: 100%;
aspect-ratio: 402 / 874;
overflow: hidden;
border-radius: 14.26cqw; /* 15,3 % der Displaybreite */
background: #000;
container-type: inline-size;
}
.dm-iphone__viewport {
--dm-dw: 402;
--dm-dh: 874;
position: absolute;
inset: 0;
overflow: hidden;
container-type: inline-size;
}
/* Statusleiste: Uhrzeit mittig im linken "Ohr", Symbole im rechten */
.dm-iphone__statusbar {
position: absolute;
inset: 0 0 auto 0;
z-index: 3;
height: calc(59 * var(--dm-u));
color: var(--dm-status);
pointer-events: none;
}
.dm-iphone__time,
.dm-iphone__indicators {
position: absolute;
top: calc(30 * var(--dm-u));
display: flex;
align-items: center;
justify-content: center;
transform: translateY(-50%);
}
.dm-iphone__time {
left: calc(16 * var(--dm-u));
width: calc(110 * var(--dm-u));
font-size: calc(17 * var(--dm-u));
font-weight: 600;
letter-spacing: -.01em;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.dm-iphone__indicators {
right: calc(16 * var(--dm-u));
width: calc(110 * var(--dm-u));
gap: calc(5.5 * var(--dm-u));
}
.dm-iphone__indicators svg:nth-child(1) { width: calc(18 * var(--dm-u)); }
.dm-iphone__indicators svg:nth-child(2) { width: calc(16.5 * var(--dm-u)); }
.dm-iphone__indicators svg:nth-child(3) { width: calc(27.5 * var(--dm-u)); }
/* Dynamic Island */
.dm-iphone__island {
position: absolute;
z-index: 4;
top: calc(11 * var(--dm-u));
left: 50%;
width: calc(126 * var(--dm-u));
height: calc(37 * var(--dm-u));
transform: translateX(-50%);
border-radius: calc(18.5 * var(--dm-u));
background: #000;
}
.dm-iphone__island i { /* Frontkamera unter dem Glas */
position: absolute;
top: 50%;
right: calc(12.5 * var(--dm-u));
width: calc(12 * var(--dm-u));
height: calc(12 * var(--dm-u));
transform: translateY(-50%);
border-radius: 50%;
background:
radial-gradient(circle at 38% 34%, rgba(130,140,255,.42) 0, rgba(60,60,140,.18) 24%, rgba(0,0,0,0) 48%),
radial-gradient(circle, #0b0d18 0, #06070c 58%, #101116 74%, #000 100%);
}
/* Home-Balken 140 x 5 pt, 8 pt vom unteren Rand */
.dm-iphone__home {
position: absolute;
z-index: 3;
left: 50%;
bottom: calc(8 * var(--dm-u));
width: calc(140 * var(--dm-u));
height: calc(5 * var(--dm-u));
transform: translateX(-50%);
border-radius: calc(3 * var(--dm-u));
background: var(--dm-status);
opacity: .92;
}
/* ---- Seitentasten (stehen ca. 0,75 % der Gehäusebreite über) ---- */
.dm-iphone__buttons i {
position: absolute;
z-index: 0;
width: 2.2cqw;
border-radius: .9cqw / 1.6cqw;
}
.dm-iphone__btn--action,
.dm-iphone__btn--vol-up,
.dm-iphone__btn--vol-down {
left: -.78cqw;
background:
linear-gradient(180deg, rgba(255,255,255,.35), rgba(255,255,255,0) 10%, rgba(255,255,255,0) 90%, rgba(0,0,0,.25)),
linear-gradient(90deg, var(--dm-t-4) 0%, var(--dm-t-hi) 22%, var(--dm-t-2) 48%, var(--dm-t-3) 100%);
box-shadow: inset 0 0 0 max(.4px, .1cqw) rgba(0, 0, 0, .22);
}
.dm-iphone__btn--side,
.dm-iphone__btn--camera {
right: -.78cqw;
background:
linear-gradient(180deg, rgba(255,255,255,.35), rgba(255,255,255,0) 10%, rgba(255,255,255,0) 90%, rgba(0,0,0,.25)),
linear-gradient(270deg, var(--dm-t-4) 0%, var(--dm-t-hi) 22%, var(--dm-t-2) 48%, var(--dm-t-3) 100%);
box-shadow: inset 0 0 0 max(.4px, .1cqw) rgba(0, 0, 0, .22);
}
.dm-iphone__btn--action { top: 18.2%; height: 4.3%; }
.dm-iphone__btn--vol-up { top: 28.6%; height: 7.3%; }
.dm-iphone__btn--vol-down { top: 38.6%; height: 7.3%; }
.dm-iphone__btn--side { top: 30%; height: 12%; }
/* Kamerasteuerung: fast bündig, Saphirglas in Titaneinfassung */
.dm-iphone__buttons .dm-iphone__btn--camera {
top: 60%;
height: 8%;
right: -.42cqw;
border-radius: .8cqw / 1.2cqw;
background: /* Saphirglanz über farblich passendem Teil */
linear-gradient(180deg, rgba(255,255,255,.28), rgba(255,255,255,0) 22%, rgba(255,255,255,0) 78%, rgba(255,255,255,.14)),
linear-gradient(270deg, var(--dm-t-5) 0%, var(--dm-t-2) 28%, var(--dm-t-4) 58%, var(--dm-t-3) 100%);
box-shadow:
0 0 0 max(.5px, .14cqw) var(--dm-t-3),
inset 0 0 0 max(.4px, .08cqw) rgba(255, 255, 255, .12);
}
/* sehr klein: Antennenlinien weglassen */
@container (max-width: 140px) {
.dm-iphone__body { --dm-t-ant: transparent; }
}
+14
View File
@@ -0,0 +1,14 @@
/* Inhalt der Geräterahmen (MacBook/iPhone aus device-frames): füllt die Bildschirmfläche */
.wk-geraet-inhalt {
position: absolute;
inset: 0;
overflow: clip;
background: #0b0712;
}
.wk-geraet-inhalt > img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
}
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useRef, useState, useSyncExternalStore, type MouseEvent } from "react";
import { Link } from "react-router-dom";
import { Pause } from "lucide-react";
import DeutschlandKarte from "@/components/DeutschlandKarte";
import "./hero.css";
/** Wechselnder Teil der Überschrift (Spezifikation 7.1). Es wechselt der ganze Teil nach „Wir bauen“ (Ihre/Ihren). */
const VARIANTEN = ["Ihre Website.", "Ihren Online-Shop.", "Ihre Web-App.", "Ihre Automatisierung."];
/** Ein Durchlauf: Website → Online-Shop → Web-App → Automatisierung → zurück zu Website, dann Stillstand. */
const ABLAUF = [0, 1, 2, 3, 0];
/** Alle 2,4 s ein Wechsel (die Überblendung selbst dauert 400 ms, siehe hero.css) */
const TAKT_MS = 2400;
const FESTER_SATZ = "Wir bauen Websites, Online-Shops, Web-Apps und Automatisierungen.";
const REDUZIERT = "(prefers-reduced-motion: reduce)";
/** Reagiert auch auf eine Änderung der Systemeinstellung, solange die Seite offen ist. */
const useReduzierteBewegung = () =>
useSyncExternalStore(
(melde) => {
const abfrage = window.matchMedia?.(REDUZIERT);
abfrage?.addEventListener?.("change", melde);
return () => abfrage?.removeEventListener?.("change", melde);
},
() => window.matchMedia?.(REDUZIERT).matches ?? false,
() => false,
);
/** Ein zweiter Klick auf denselben Anker ändert die URL nicht; dann selbst hinscrollen (wie in der Kopfleiste). */
const zumKundenportal = (e: MouseEvent<HTMLAnchorElement>) => {
if (window.location.pathname === "/" && window.location.hash === "#kundenportal") {
e.preventDefault();
document.getElementById("kundenportal")?.scrollIntoView();
}
};
const HeroKarte = () => {
const reduziert = useReduzierteBewegung();
const [schritt, setSchritt] = useState(0);
const [knopfGedrueckt, setKnopfGedrueckt] = useState(false);
const [hover, setHover] = useState(false);
const [fokus, setFokus] = useState(false);
const [verborgen, setVerborgen] = useState(
() => typeof document !== "undefined" && document.visibilityState === "hidden",
);
const ueberschrift = useRef<HTMLHeadingElement>(null);
// Solange der Durchlauf nicht zu Ende ist und niemand angehalten hat, ist die Überschrift „in Bewegung“
const inBewegung = !reduziert && !knopfGedrueckt && schritt < ABLAUF.length - 1;
// Hover oder Fokus auf der Überschrift und ein ausgeblendeter Tab halten den Wechsel nur an
const pausiert = hover || fokus || verborgen;
const aktiv = reduziert ? 0 : ABLAUF[schritt];
useEffect(() => {
const aendern = () => setVerborgen(document.visibilityState === "hidden");
document.addEventListener("visibilitychange", aendern);
return () => document.removeEventListener("visibilitychange", aendern);
}, []);
useEffect(() => {
if (!inBewegung || pausiert) return;
const timer = window.setTimeout(() => setSchritt((s) => s + 1), TAKT_MS);
return () => window.clearTimeout(timer);
}, [inBewegung, pausiert, schritt]);
const anhalten = () => {
setKnopfGedrueckt(true);
// Der Knopf verschwindet: Fokus auf die Überschrift, damit er nicht ins Leere fällt
ueberschrift.current?.focus({ preventScroll: true });
};
// Ohne Bewegung steht nur die erste Variante da; sonst liegen alle übereinander (die breiteste bestimmt die Breite)
const sichtbareVarianten = reduziert ? VARIANTEN.slice(0, 1) : VARIANTEN;
return (
<section className="wk-hero">
<div className="wk-container wk-hero__raster">
<div className="wk-hero__text">
<p className="wk-eyebrow wk-hero__zeile">Websites, Shops und Apps für kleine Betriebe</p>
<div
className="wk-hero__kopf"
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
onFocus={() => setFokus(true)}
onBlur={() => setFokus(false)}
>
<h1 ref={ueberschrift} className="wk-h1 wk-hero__h1" tabIndex={-1}>
<span className="wk-visually-hidden">{FESTER_SATZ}</span>
<span className="wk-hero__sichtbar" aria-hidden="true">
<span className="wk-hero__fest">Wir bauen</span>
<span className="wk-hero__wechsel">
{sichtbareVarianten.map((text, i) => (
<span key={text} className="wk-hero__variante" data-aktiv={i === aktiv}>
{text}
</span>
))}
</span>
</span>
</h1>
{inBewegung && (
<button
type="button"
className="wk-hero__pause"
aria-label="Wechselnde Überschrift anhalten"
onClick={anhalten}
>
<span className="wk-hero__pause-symbol" aria-hidden="true">
<Pause size={14} strokeWidth={2.4} />
</span>
</button>
)}
</div>
<p className="wk-lead wk-hero__lead">
In unserem Kundenportal sehen Sie jederzeit, wie weit Ihr Projekt ist – mit Live-Vorschau Ihrer Website.
</p>
<div className="wk-hero__aktionen">
<Link to="/kontakt" className="wk-btn wk-btn--primary">
Projekt besprechen
</Link>
<Link to="/#kundenportal" className="wk-btn wk-btn--secondary" onClick={zumKundenportal}>
Kundenportal ansehen
</Link>
</div>
</div>
<div className="wk-hero__karte">
<DeutschlandKarte />
</div>
</div>
</section>
);
};
export default HeroKarte;
+32
View File
@@ -0,0 +1,32 @@
import { Link } from "react-router-dom";
/** Spezifikation 7.7: ein Satz, ein Button, E-Mail und Telefon. */
const KontaktBuehne = () => (
<section id="kontakt" className="wk-section wk-section--alt wk-kontakt" aria-labelledby="kontakt-titel">
<div className="wk-container wk-kontakt__inhalt">
<h2 id="kontakt-titel" className="wk-h2 wk-reveal">
Erzählen Sie uns, was Sie vorhaben.
</h2>
<p className="wk-lead wk-reveal">
Zwei Sätze genügen. Wir melden uns spätestens am nächsten Werktag. Im ersten Gespräch klären wir in
15&nbsp;Minuten, ob und wie wir helfen können – kostenlos und unverbindlich.
</p>
<div className="wk-kontakt__aktion wk-reveal">
<Link to="/kontakt" className="wk-btn wk-btn--primary">
Projekt besprechen
</Link>
</div>
<p className="wk-kontakt__wege">
<a href="mailto:support@webklar.com" className="wk-link">
support@webklar.com
</a>
<span aria-hidden>·</span>
<a href="tel:+4917623726355" className="wk-link wk-num">
0176 23726355
</a>
</p>
</div>
</section>
);
export default KontaktBuehne;
+895
View File
@@ -0,0 +1,895 @@
import {
lazy,
Suspense,
useEffect,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
version,
type ComponentType,
type ReactNode,
type RefObject,
} from "react";
import WischReihe from "@/components/WischReihe";
import { DesktopFenster, HandyRahmen, RahmenBild, type RahmenBildDaten } from "@/components/GeraeteRahmen";
import { useBewegungErlaubt, useDemoLauf, useFeinerZeiger, useImBild } from "./demos/gemeinsam";
import type { TerminZustand } from "./demos/termin/ablauf";
import type { HeatmapZustand } from "./demos/heatmap/ablauf";
import type { PinnwandZustand } from "./demos/pinnwand/ablauf";
import type { ChatZustand } from "./demos/chat/ablauf";
import "./kundenportal.css";
/**
* Abschnitt Kundenportal (Spezifikation 7.2).
* Ab 900 px: festgepinnte Bühne mit Funktionsliste links und Desktop-Fenster plus Handy-Rahmen rechts.
* Darunter (unter 900 px): keine Bühne, sondern eine Wisch-Reihe mit je einem Handy-Screenshot.
* Danach die Schrittzeile (ersetzt "So arbeiten wir").
* Die Stationen 5 bis 7 (Terminplaner, Website-Analyse, News-Pinnwand) zeigen statt eines Bildes eine
* Live-Demo: auf dem MacBook die erfundene Kunden-Website, auf dem iPhone das Portal (Spezifikation
* 2026-10-04-funktionen-demos). Die Demos werden per React.lazy erst geladen, wenn die Bühne ihre Station fast erreicht.
* Live sind außerdem (Wunsch des Inhabers 2026-10-04, Spezifikation 2026-10-04-vorschau-chat):
* - Live-Vorschau: im MacBook die Beispiel-Website der Bäckerei Sonnenschein, die beim Scrollen durch die Station mitläuft;
* - Chat: im MacBook der Portal-Chat, die Kamera zoomt hinein, Nachrichten ploppen auf (im iPhone im Gleichtakt).
* Der Überpunkt „Funktionen“ klappt weich auf und zu (Unterpunkte falten sich ein, „Als App“ gleitet nach, FLIP).
*/
const EINLEITUNG =
"Sie sehen, was wir zuletzt erledigt haben, öffnen die Live-Vorschau Ihrer Website und schreiben uns im Chat – mit Fotos, Dateien oder Sprachnachricht. Ihre Rechnungen liegen ebenfalls dort, und aufs Handy installieren Sie das Portal wie eine App.";
// ── Bilder (erzeugt von scripts/relaunch-bilder.py nach public/relaunch/portal/) ──────────────────────────
const BILDER = "/relaunch/portal";
const DESKTOP_BREITEN = [800, 1200, 1600];
const HANDY_BREITEN = [390, 780];
const srcSet = (name: string, breiten: number[]) =>
breiten.map((b) => `${BILDER}/${name}-${b}.webp ${b}w`).join(", ");
/** Desktop-Bild: Rohformat 3024 x 1786 (MacBook-Safari-Fläche 1512 x 893 @2x), ausgeliefert in 800 / 1200 / 1600 px. */
const desktopBild = (name: string, alt: string): RahmenBildDaten => ({
src: `${BILDER}/${name}-1200.webp`,
srcSet: srcSet(name, DESKTOP_BREITEN),
sizes: "(min-width: 1200px) 692px, 50vw",
width: 1600,
height: 945,
alt,
});
/** Handy-Bild: Rohformat 804 x 1748 = iPhone 16 Pro @2x (das lange Bild 804 x 5131), ausgeliefert in 390 / 780 px;
* width/height geben das Seitenverhältnis bei 780 px an. */
const handyBild = (name: string, alt: string, sizes: string, hoehe = 1696): RahmenBildDaten => ({
src: `${BILDER}/${name}-780.webp`,
srcSet: srcSet(name, HANDY_BREITEN),
sizes,
width: 780,
height: hoehe,
alt,
});
type DemoArt = "termin" | "heatmap" | "pinnwand";
/** Live-Stationen außerhalb der Gruppe „Funktionen“ */
type LiveArt = "vorschau" | "chat";
type LadeArt = DemoArt | LiveArt;
type Funktion = {
id: string;
titel: string;
/** Ein Satz unter dem Titel; die drei Funktionen zeigen nur ihren Titel (Wunsch des Inhabers 2026-10-04). */
unterschrift?: string;
/** Bedienhinweis zur Live-Demo, je nachdem, ob es eine Maus gibt (nur Website-Analyse). */
bedienhinweis?: { maus: string; tippen: string };
/** Desktop-Bild; Demo-Stationen zeigen stattdessen die Website, "Als App" hat keines (dort blendet das Fenster aus). */
desktop?: { name: string; alt: string };
handy: { name: string; alt: string };
/** Der lange Screenshot, der sich im Handy-Rahmen nach oben schiebt. */
lang?: boolean;
/** Live-Demo statt Desktop-Bild: welche erfundene Kunden-Website im MacBook läuft (gehört zur Gruppe „Funktionen“). */
demo?: DemoArt;
/** Live-Station außerhalb der Gruppe: Beispiel-Website (Live-Vorschau) oder Portal-Chat mit Zoom (Chat). */
live?: LiveArt;
/** Text der Zeile „Erfundenes Beispiel · …“ unter dem MacBook, wenn nicht die Adresse dort stehen soll. */
beispiel?: string;
/** Adresse in der Safari-Leiste (reservierte Endung .example, es ist kein echter Betrieb gemeint). */
adresse?: string;
/** Beschreibung der Beispiel-Website für Screenreader (die Website selbst ist aria-hidden). */
beschreibung?: string;
/** Hinweis unter dem MacBook (Live-Vorschau, Chat, Terminplaner, Pinnwand; bei der Website-Analyse steht dort die Diagnose). */
hinweis?: string;
/** Noch nicht für Kunden buchbar: Marke „Bald verfügbar“ am Titel (Terminplaner läuft bisher nur im Pilot, Stand 04.10.2026). */
bald?: boolean;
};
const BaldMarke = ({ f }: { f: Funktion }) => (f.bald ? <span className="kp-bald">Bald verfügbar</span> : null);
/** Mitte des Knopfes "Aufhängen" im Portal-Screenshot der News-Pinnwand (Designpunkte der iPhone-Fläche 402 x 874). */
const AUFHAENGEN_PUNKT = { x: 201, y: 613 };
// Reihenfolge und Texte laut Spezifikation 7.2. Platzhalter des Portals ("kommt bald") werden nicht beworben,
// weitere Ansichten nur nach Freigabe des Inhabers (O9 = nein).
const FUNKTIONEN: Funktion[] = [
{
id: "live-vorschau",
titel: "Live-Vorschau",
unterschrift: "Ihre Website, noch während wir daran bauen.",
live: "vorschau",
adresse: "baeckerei-sonnenschein.project.webklar.com",
beispiel: "Bäckerei Sonnenschein",
beschreibung:
"Beispiel-Website einer Bäckerei in der Live-Vorschau: Startseite mit frischem Roggenbrot, Sortiment, Backstube, Torten und Öffnungszeiten",
hinweis: "Geschützt: Ihre Vorschau öffnet sich nur nach der Anmeldung im Kundenportal – Fremde sehen nichts.",
handy: { name: "phone-1-projekte", alt: "Kundenportal auf dem Handy: Projektübersicht mit Live-Vorschau der Website" },
},
{
id: "fortschritt",
titel: "Fortschritt",
unterschrift: "Was wir zuletzt erledigt haben – in einfachen Sätzen.",
desktop: {
name: "desktop-2-fortschritt",
alt: "Kundenportal: zuletzt erledigte Arbeiten am Auftrag, in einfachen Sätzen beschrieben",
},
handy: {
name: "phone-3-projektdetails",
alt: "Kundenportal auf dem Handy: Projektdetails mit den letzten Änderungen",
},
},
{
id: "chat",
titel: "Chat",
unterschrift: "Frage, Foto oder Sprachnachricht direkt ans Team.",
live: "chat",
beispiel: "Bäckerei Sonnenschein im Kundenportal",
beschreibung:
"Chat im Kundenportal: Die Bäckerei schickt ein Foto vom neuen Roggenbrot und eine Sprachnachricht, Kenso vom WEBklar-Team antwortet",
hinweis: "Antwortet das Team, kommt zusätzlich eine E-Mail – so verpassen Sie keine Nachricht.",
handy: { name: "phone-4c-chat-leer", alt: "Kundenportal auf dem Handy: Chat mit dem WEBklar-Team" },
},
{
id: "rechnungen",
titel: "Rechnungen",
unterschrift: "Ansehen, bezahlen, herunterladen.",
desktop: { name: "desktop-4-rechnungen", alt: "Kundenportal: Rechnungen mit Betrag und Status" },
handy: { name: "phone-5-rechnungen", alt: "Kundenportal auf dem Handy: Rechnungen zum Ansehen, Bezahlen und Herunterladen" },
},
{
id: "terminplaner",
titel: "Terminplaner",
demo: "termin",
bald: true,
adresse: "salon-schnittstelle.example",
beschreibung:
"Beispiel-Website eines Friseursalons mit Buchungsformular und einem Kalender, in dem der neue Termin erscheint",
hinweis:
"Kalender-Abo: Den Link einmal in Google Kalender, Apple Kalender oder Outlook einfügen. Neue Termine erscheinen dort mit etwas Verzögerung.",
handy: {
name: "phone-9-termine",
alt: "Kundenportal von WEBklar auf dem Handy: Der Terminplaner zeigt die Termine des Donnerstags, darunter eine bestätigte Buchung, eine neue Anfrage, die Sie annehmen, ablehnen oder verschieben können, und einen weiteren bestätigten Termin.",
},
},
{
id: "website-analyse",
titel: "Website-Analyse",
bedienhinweis: { maus: "Zeigen Sie mit der Maus auf die Seite.", tippen: "Tippen Sie auf eine Stelle der Seite." },
demo: "heatmap",
adresse: "boulderhalle-ueberhang.example",
beschreibung:
"Beispiel-Website einer Boulderhalle, darüber eine Heatmap: Je häufiger eine Stelle angeklickt wird, desto wärmer ihre Farbe",
handy: {
name: "phone-10-analyse",
alt: "Kundenportal von WEBklar auf dem Handy: Die Website-Analyse zeigt für die letzten 30 Tage Seitenaufrufe, Besucher, Kontakt-Klicks und die Zustimmungsquote, darunter ein Diagramm mit den Seitenaufrufen pro Tag.",
},
},
{
id: "news-pinnwand",
titel: "News-Pinnwand",
demo: "pinnwand",
adresse: "musterbach.example",
beschreibung: "Beispiel-Website einer Ortsgemeinde mit einer Pinnwand, an der ein neuer Zettel aufgehängt wird",
hinweis: "Neuer Zettel: Er erscheint nach dem Aufhängen auf der Website.",
handy: {
name: "phone-11-news-musterbach",
alt: "Kundenportal von WEBklar auf dem Handy: Ein neuer Zettel für die News-Pinnwand mit Überschrift und Text ist ausgefüllt, darunter der Knopf zum Aufhängen auf der Website.",
},
},
{
id: "als-app",
titel: "Als App",
unterschrift: "Aufs Handy installieren, fertig.",
handy: {
name: "phone-2-projekte-lang",
alt: "Kundenportal als App auf dem Handy: lange Projektseite mit Vorschau, Status und letzten Änderungen",
},
lang: true,
},
];
const LETZTER = FUNKTIONEN.length - 1;
/** Art der Station: bestimmt, was nachgeladen wird und welche Hinweise unter dem MacBook stehen. */
const ladeArt = (f: Funktion): LadeArt | undefined => f.demo ?? f.live;
/**
* Länge jedes Schritts auf der Bühne (CSS-Variable aus kundenportal.css): normale Stationen kurz, die Demos länger
* (der Ablauf soll zu sehen sein), die Live-Vorschau am längsten, weil die Website beim Scrollen durchläuft.
* Die Summe ergibt mit der Bühnenhöhe und dem letzten Schritt die Scrollstrecke der Hülle.
*/
const strecke = (f: Funktion) => (f.live ? `var(--kp-l-${f.live})` : f.demo ? "var(--kp-l-demo)" : "var(--kp-l-normal)");
const HUELLE_HOEHE = `calc(var(--kp-buehne-h) + ${FUNKTIONEN.slice(0, LETZTER).map(strecke).join(" + ")} + var(--kp-ende))`;
/** Überpunkt der drei Funktionen in der Liste der Bühne (Wunsch des Inhabers 2026-10-04): klappt auf, sobald eine
* der drei erreicht ist; die Unterpunkte zeigen nur ihren Titel. */
const GRUPPE = { titel: "Funktionen", text: "Bausteine für Ihre Website." };
const DEMO_INDIZES = FUNKTIONEN.flatMap((f, i) => (f.demo ? [i] : []));
// ── Demos (eigener Chunk, erst geladen, wenn die Bühne die Station fast erreicht) ──────────────────────
/**
* React.lazy mit `vorladen`: Der Chunk wird beim Einhängen der Station angestoßen, nicht erst, wenn die Komponente
* zum ersten Mal gerendert wird. So laden Website, Handy-Ebene und Taktgeber gleichzeitig (kein Wasserfall).
*/
const lazyMitVorladen = <P,>(laden: () => Promise<{ default: ComponentType<P> }>) => {
let versprechen: ReturnType<typeof laden> | undefined;
const lade = () => (versprechen ??= laden());
const Komponente = lazy(lade);
return Object.assign(Komponente, { vorladen: () => void lade().catch(() => {}) });
};
const BaeckereiSeite = lazyMitVorladen(() => import("./demos/baeckerei/BaeckereiSeite"));
const ChatSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.ChatSteuerung })));
const ChatSeite = lazyMitVorladen(() => import("./demos/chat/ChatSeite"));
const ChatHandy = lazyMitVorladen(() => import("./demos/chat/ChatHandy"));
const TerminSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.TerminSteuerung })));
const HeatmapSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.HeatmapSteuerung })));
const PinnwandSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.PinnwandSteuerung })));
const TerminSeite = lazyMitVorladen(() => import("./demos/termin/TerminSeite"));
const TerminHandy = lazyMitVorladen(() => import("./demos/termin/TerminHandy"));
const HeatmapSeite = lazyMitVorladen(() => import("./demos/heatmap/HeatmapSeite"));
const HeatmapDiagnose = lazyMitVorladen(() => import("./demos/heatmap/HeatmapDiagnose"));
const PinnwandSeite = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandSeite"));
const PinnwandHandy = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandHandy"));
/** Chunks je Demo; die Website-Chunks (Seite, Diagnose) und der Taktgeber laden gleichzeitig. */
const VORLADEN: Record<LadeArt, (() => void)[]> = {
vorschau: [BaeckereiSeite.vorladen],
// ChatHandy braucht nur die Wisch-Reihe (auf der Bühne gleitet das iPhone in der Station Chat weg), ist aber winzig
chat: [ChatSteuerung.vorladen, ChatSeite.vorladen, ChatHandy.vorladen],
termin: [TerminSteuerung.vorladen, TerminSeite.vorladen, TerminHandy.vorladen],
heatmap: [HeatmapSteuerung.vorladen, HeatmapSeite.vorladen, HeatmapDiagnose.vorladen],
pinnwand: [PinnwandSteuerung.vorladen, PinnwandSeite.vorladen, PinnwandHandy.vorladen],
};
/** Hintergrund der Beispiel-Websites, solange ihr Chunk lädt (statt einer schwarzen Fläche im MacBook). */
const PLATZHALTER: Record<LadeArt, string> = {
vorschau: "#f7f0e4",
chat: "#0b0712",
termin: "#f6f1ea",
heatmap: "#141414",
pinnwand: "#f7f2e6",
};
const Platzhalter = ({ art }: { art: LadeArt }) => (
<div className="kp-platzhalter" style={{ background: PLATZHALTER[art] }} aria-hidden="true" />
);
// inert: React 19 kennt es als boolesches Attribut, React 18 reicht nur den String durch ('' = gesetzt)
const INERT = (parseInt(version, 10) >= 19 ? { inert: true } : { inert: "" }) as unknown as { inert?: boolean };
const beispielText = (f: Funktion) => `Erfundenes Beispiel · ${f.beispiel ?? f.adresse}`;
/** Satz unter dem Titel einer Karte der Wisch-Reihe; die drei Funktionen haben keinen (nur der Titel). */
const KartenUnterschrift = ({ f }: { f: Funktion }) =>
f.unterschrift ? <p className="wk-muted kp-karte__text">{f.unterschrift}</p> : null;
const SCHRITTE = [
{ nr: 1, text: "Kennenlernen" },
{ nr: 2, text: "Angebot mit Preis" },
{ nr: 3, text: "Bauen mit Vorschau und Ihrer Freigabe" },
{ nr: 4, text: "Online – Hosting und Pflege auf Wunsch im Abo" },
];
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
/** Medienabfrage als Wert. Ohne matchMedia (SSR) gilt `standard`. */
const useMedia = (abfrage: string, standard = false) =>
useSyncExternalStore(
(melden) => {
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const mq = window.matchMedia(abfrage);
mq.addEventListener?.("change", melden);
return () => mq.removeEventListener?.("change", melden);
},
() => (typeof window !== "undefined" && window.matchMedia ? window.matchMedia(abfrage).matches : standard),
() => standard,
);
const begrenze = (wert: number, min: number, max: number) => Math.min(max, Math.max(min, wert));
// ── Desktop: festgepinnte Bühne ─────────────────────────────────────────────
/**
* Die Hülle ist hoch (Scrollstrecke), darin klebt die Bühne (position: sticky).
* Hinter der Bühne liegt eine unsichtbare Spur aus Markern, eine Marke je Funktion. Der IntersectionObserver
* meldet, welche Marke gerade die Bildschirmmitte kreuzt: das ist die aktive Funktion, das Bild blendet über.
* Beim letzten Schritt wird der Scrollfortschritt als CSS-Variable --kp-q (0 bis 1) an die Bühne geschrieben,
* und das CSS schiebt den langen Screenshot per translateY nach oben (nur transform).
*/
const Buehne = () => {
const [aktiv, setAktiv] = useState(0);
// Bilder werden erst eingehängt, wenn ihr Schritt (oder der davor) erreicht ist: spart Daten beim ersten Erreichen
const [hoechster, setHoechster] = useState(0);
const huelle = useRef<HTMLDivElement>(null);
const buehne = useRef<HTMLDivElement>(null);
const marker = useRef<(HTMLDivElement | null)[]>([]);
// Demos: laeuft = Station aktiv, Bühne im Bild, Tab sichtbar, Bewegung erlaubt (Spezifikation 4).
// Je Demo gibt es einen Taktgeber (eigener Chunk); sein Zustand geht an Website, Handy-Ebene und Diagnose.
const imBild = useImBild(buehne, 0.35);
const lauf = useDemoLauf(true, imBild);
const fein = useFeinerZeiger();
const aktivDemo = FUNKTIONEN[aktiv].demo;
const aktivLive = FUNKTIONEN[aktiv].live;
const aktivArt = ladeArt(FUNKTIONEN[aktiv]);
const bewegung = useBewegungErlaubt();
const [chat, setChat] = useState<ChatZustand | null>(null);
const [termin, setTermin] = useState<TerminZustand | null>(null);
const [heat, setHeat] = useState<HeatmapZustand | null>(null);
const [pinnwand, setPinnwand] = useState<PinnwandZustand | null>(null);
useEffect(() => {
if (typeof IntersectionObserver === "undefined") return;
const beobachter = new IntersectionObserver(
(eintraege) => {
for (const e of eintraege) {
if (!e.isIntersecting) continue;
const i = Number((e.target as HTMLElement).dataset.index);
setAktiv(i);
setHoechster((h) => Math.max(h, i));
}
},
// Messlinie in der Bildschirmmitte: die Marke, die sie kreuzt, ist die aktive
{ rootMargin: "-50% 0px -50% 0px", threshold: 0 },
);
marker.current.forEach((m) => m && beobachter.observe(m));
return () => beobachter.disconnect();
}, []);
// Scrollfortschritt des letzten Schritts (0 bis 1) für den langen Screenshot
useEffect(() => {
const el = buehne.current;
const huellenEl = huelle.current;
const letzterMarker = marker.current[LETZTER];
if (!el || !huellenEl || !letzterMarker || !window.matchMedia) return;
const ruhig = window.matchMedia("(prefers-reduced-motion: no-preference)");
let raf = 0;
const berechne = () => {
raf = 0;
if (!ruhig.matches) {
el.style.setProperty("--kp-q", "0");
el.style.setProperty("--kp-v", "0");
return;
}
const vh = window.innerHeight;
const huelleBox = huellenEl.getBoundingClientRect();
if (huelleBox.bottom < -vh || huelleBox.top > 2 * vh) return;
const oben = parseFloat(getComputedStyle(el).top) || 0;
const gescrollt = oben - huelleBox.top; // Strecke seit dem Anheften
const strecke = huelleBox.height - el.offsetHeight; // gesamte Pin-Strecke
const seitSchritt6 = vh / 2 - letzterMarker.getBoundingClientRect().top;
const laenge = strecke - (gescrollt - seitSchritt6); // Länge des letzten Schritts
const vorlauf = 0.12 * vh; // erst bewegt sich der Rahmen, dann der Inhalt
const halten = 0.2 * vh; // am Ende bleibt der lange Screenshot kurz stehen
const q = begrenze((seitSchritt6 - vorlauf) / Math.max(1, laenge - vorlauf - halten), 0, 1);
el.style.setProperty("--kp-q", q.toFixed(4));
// Live-Vorschau (erster Schritt): die Beispiel-Website läuft vom Anheften der Bühne bis zum nächsten Schritt
// einmal von oben nach unten durch; am Anfang und am Ende bleibt sie kurz stehen
const ersterMarker = marker.current[0];
if (ersterMarker) {
const laenge1 = ersterMarker.offsetHeight - (vh / 2 - oben);
const v = begrenze((gescrollt - 0.06 * vh) / Math.max(1, laenge1 - 0.2 * vh), 0, 1);
el.style.setProperty("--kp-v", v.toFixed(4));
}
};
const planen = () => {
if (!raf) raf = requestAnimationFrame(berechne);
};
window.addEventListener("scroll", planen, { passive: true });
window.addEventListener("resize", planen);
ruhig.addEventListener?.("change", planen);
berechne();
return () => {
window.removeEventListener("scroll", planen);
window.removeEventListener("resize", planen);
ruhig.removeEventListener?.("change", planen);
if (raf) cancelAnimationFrame(raf);
};
}, []);
/** Klick in der Liste: zur Marke dieser Funktion scrollen (weich oder sofort entscheidet das CSS der Seite). */
const springe = (i: number) => {
const huellenEl = huelle.current;
const buehnenEl = buehne.current;
const m = marker.current[i];
if (!huellenEl || !buehnenEl || !m) return;
const oben = parseFloat(getComputedStyle(buehnenEl).top) || 0;
const huelleOben = huellenEl.getBoundingClientRect().top;
// Marke knapp über die Messlinie legen, mindestens aber so weit scrollen, dass die Bühne angeheftet ist
const zurMarke = huelleOben + m.offsetTop - (window.innerHeight / 2 - 1);
const zurBuehne = huelleOben - oben;
window.scrollTo({ top: window.scrollY + Math.max(zurMarke, zurBuehne) });
};
const app = aktiv === LETZTER;
const eingehaengt = (i: number) => i <= hoechster + 1;
const demoEingehaengt = (art: LadeArt) => FUNKTIONEN.some((f, i) => ladeArt(f) === art && eingehaengt(i));
// Sobald die Station eingehängt ist, laden alle Chunks der Demo gleichzeitig (Website, Handy-Ebene, Taktgeber)
useEffect(() => {
for (const art of Object.keys(VORLADEN) as LadeArt[]) {
if (FUNKTIONEN.some((f, i) => ladeArt(f) === art && i <= hoechster + 1)) VORLADEN[art].forEach((vorladen) => vorladen());
}
}, [hoechster]);
// Überpunkt „Funktionen“: offen, solange eine der drei Funktionen aktiv ist. Beim Verlassen falten sich die Unterpunkte
// erst ein („schliesst“), dann verschwindet die Unterliste und „Als App“ gleitet nach oben (ohne Bewegung: sofort zu).
const [gruppe, setGruppe] = useState<"zu" | "offen" | "schliesst">("zu");
const gruppeGewuenscht = aktivDemo !== undefined;
useEffect(() => {
if (gruppeGewuenscht) setGruppe("offen");
else setGruppe((g) => (g === "offen" && bewegung ? "schliesst" : "zu"));
}, [gruppeGewuenscht, bewegung]);
useEffect(() => {
if (gruppe !== "schliesst") return;
const t = window.setTimeout(() => setGruppe("zu"), 260);
return () => window.clearTimeout(t);
}, [gruppe]);
// FLIP: Ändert sich die Höhe der Gruppe, gleiten die Einträge darunter von ihrer alten Lage an die neue (nur transform).
// Die Lagen werden bei jedem Stationswechsel gemerkt, bewegt wird nur, wenn die Gruppe auf- oder zugeht.
const liste = useRef<HTMLOListElement>(null);
const lagen = useRef(new Map<Element, number>());
const letzteGruppe = useRef(gruppe);
useLayoutEffect(() => {
const ol = liste.current;
if (!ol) return;
const neu = new Map<Element, number>();
for (const li of Array.from(ol.children)) neu.set(li, (li as HTMLElement).offsetTop);
if (bewegung && letzteGruppe.current !== gruppe) {
for (const [li, oben] of neu) {
const alt = lagen.current.get(li);
if (alt === undefined || alt === oben || typeof li.animate !== "function") continue;
li.animate([{ transform: `translateY(${alt - oben}px)` }, { transform: "translateY(0)" }], {
duration: 480,
easing: "cubic-bezier(0.2, 0.8, 0.2, 1)",
});
}
}
letzteGruppe.current = gruppe;
lagen.current = neu;
}, [gruppe, aktiv, bewegung]);
return (
<div className="kp-huelle" ref={huelle} style={{ height: HUELLE_HOEHE }}>
{/* Taktgeber der Demos (rendern nichts): erst geladen, wenn die Bühne ihre Station fast erreicht */}
<Suspense fallback={null}>
{demoEingehaengt("chat") ? (
<ChatSteuerung laeuft={lauf.laeuft && aktivLive === "chat"} statisch={lauf.statisch} onZustand={setChat} />
) : null}
{demoEingehaengt("termin") ? (
<TerminSteuerung laeuft={lauf.laeuft && aktivDemo === "termin"} statisch={lauf.statisch} onZustand={setTermin} />
) : null}
{demoEingehaengt("heatmap") ? (
<HeatmapSteuerung
laeuft={lauf.laeuft && aktivDemo === "heatmap"}
statisch={lauf.statisch}
aktiv={aktivDemo === "heatmap"}
onZustand={setHeat}
/>
) : null}
{demoEingehaengt("pinnwand") ? (
<PinnwandSteuerung laeuft={lauf.laeuft && aktivDemo === "pinnwand"} statisch={lauf.statisch} onZustand={setPinnwand} />
) : null}
</Suspense>
<div className="kp-spur" aria-hidden="true">
{FUNKTIONEN.map((f, i) => (
<div
key={f.id}
className="kp-marker"
data-index={i}
style={{ ["--kp-l" as string]: strecke(f) }}
ref={(el) => (marker.current[i] = el)}
/>
))}
</div>
<div className="kp-buehne" ref={buehne} data-app={app ? "ja" : "nein"} data-station={FUNKTIONEN[aktiv].id}>
<div className="wk-container kp-buehne__innen">
<ol className="kp-liste" aria-label="Funktionen des Kundenportals" ref={liste}>
{FUNKTIONEN.map((f, i) => {
if (!f.demo) {
return (
<li key={f.id}>
<button
type="button"
className="kp-eintrag"
aria-current={i === aktiv ? "step" : undefined}
onClick={() => springe(i)}
>
<span className="kp-eintrag__punkt" aria-hidden="true" />
<span className="kp-eintrag__titel">{f.titel}</span>
<span className="kp-eintrag__text">{f.unterschrift}</span>
</button>
</li>
);
}
// die drei Funktionen: ein Überpunkt, der aufklappt, sobald eine von ihnen erreicht ist
if (i !== DEMO_INDIZES[0]) return null;
const offen = gruppe === "offen";
return (
<li key="funktionen" className="kp-gruppe" data-offen={offen ? "ja" : "nein"} data-zustand={gruppe}>
<button
type="button"
className="kp-eintrag kp-eintrag--gruppe"
aria-expanded={offen}
aria-controls="kp-unterliste"
onClick={() => springe(DEMO_INDIZES[0])}
>
<span className="kp-eintrag__titel">
{GRUPPE.titel}
<svg className="kp-eintrag__pfeil" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="m4 6 4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<span className="kp-eintrag__text">{GRUPPE.text}</span>
</button>
<ol
id="kp-unterliste"
className="kp-unterliste"
aria-label={GRUPPE.titel}
hidden={gruppe === "zu"}
aria-hidden={gruppe === "schliesst" || undefined}
>
{DEMO_INDIZES.map((d) => (
<li key={FUNKTIONEN[d].id}>
<button
type="button"
className="kp-untereintrag"
aria-current={d === aktiv ? "step" : undefined}
onClick={() => springe(d)}
>
<span className="kp-eintrag__punkt" aria-hidden="true" />
<span className="kp-untereintrag__titel">
{FUNKTIONEN[d].titel}
<BaldMarke f={FUNKTIONEN[d]} />
</span>
</button>
</li>
))}
</ol>
</li>
);
})}
</ol>
<div className="kp-geraete">
<DesktopFenster className="kp-fenster" adresse={FUNKTIONEN[aktiv].adresse}>
<div className="kp-ebenen" aria-hidden={app || undefined}>
{FUNKTIONEN.map((f, i) => {
const art = ladeArt(f);
if (!eingehaengt(i) || !(f.desktop || art)) return null;
return (
<div
key={f.id}
className={`kp-lage${i === aktiv ? " is-aktiv" : ""}`}
aria-hidden={i !== aktiv || undefined}
// die Website selbst ist aria-hidden; die aktive Ebene trägt eine kurze Beschreibung
role={f.beschreibung && i === aktiv ? "img" : undefined}
aria-label={f.beschreibung && i === aktiv ? f.beschreibung : undefined}
>
{f.desktop ? (
<RahmenBild bild={desktopBild(f.desktop.name, f.desktop.alt)} />
) : art ? (
// ohne gemeldeten Zustand steht die Website im Anfangszustand da (Chunk lädt parallel zum Taktgeber)
<Suspense fallback={<Platzhalter art={art} />}>
{f.live === "vorschau" ? <BaeckereiSeite /> : null}
{f.live === "chat" ? <ChatSeite zustand={chat ?? undefined} /> : null}
{f.demo === "termin" ? <TerminSeite zustand={termin ?? undefined} variante="desktop" /> : null}
{f.demo === "heatmap" ? <HeatmapSeite zustand={heat ?? undefined} variante="desktop" /> : null}
{f.demo === "pinnwand" ? <PinnwandSeite zustand={pinnwand ?? undefined} variante="desktop" /> : null}
</Suspense>
) : null}
</div>
);
})}
</div>
</DesktopFenster>
<HandyRahmen className="kp-handy">
{FUNKTIONEN.map((f, i) =>
eingehaengt(i) ? (
<div
key={f.id}
className={`kp-lage${i === aktiv ? " is-aktiv" : ""}`}
aria-hidden={i !== aktiv || undefined}
>
{f.lang ? (
<div className="kp-lang">
<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "(min-width: 1200px) 340px, 30vw", 4978)} />
</div>
) : (
<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "(min-width: 1200px) 230px, 20vw")} />
)}
{f.demo ? (
// Live-Ebene über dem Portal-Bild: Mitteilung bzw. Tipp-Finger, synchron zur Website
// (in der Station Chat gleitet das iPhone weg, dort gibt es keine Ebene)
<Suspense fallback={null}>
{f.demo === "termin" && termin ? <TerminHandy zustand={termin} /> : null}
{f.demo === "pinnwand" && pinnwand ? <PinnwandHandy zustand={pinnwand} punkt={AUFHAENGEN_PUNKT} /> : null}
</Suspense>
) : null}
</div>
) : null,
)}
</HandyRahmen>
{/* unter dem MacBook, links neben dem iPhone: Hinweis auf das erfundene Beispiel und je Live-Station eine kleine Karte
(Website-Analyse: die Diagnose, sonst ein Hinweis zur Funktion) */}
<div className="kp-unter">
<div className="kp-beispiele">
{FUNKTIONEN.filter((f) => ladeArt(f)).map((f) => (
<p
key={f.id}
className="kp-beispiel"
data-an={ladeArt(f) === aktivArt ? "ja" : "nein"}
aria-hidden={ladeArt(f) !== aktivArt || undefined}
>
{/* Website-Analyse: statt der Adresse (steht in der Safari-Leiste) der Bedienhinweis, damit es eine Zeile bleibt;
ohne Maus (Tablet quer) heißt es „Tippen Sie“ statt „Zeigen Sie mit der Maus“ */}
{f.bedienhinweis
? `Erfundenes Beispiel · ${fein ? f.bedienhinweis.maus : f.bedienhinweis.tippen}`
: beispielText(f)}
</p>
))}
</div>
<div className="kp-karten">
<div
className="kp-diagnose"
data-an={aktivDemo === "heatmap" ? "ja" : "nein"}
aria-hidden={aktivDemo !== "heatmap" || undefined}
{...(aktivDemo !== "heatmap" ? INERT : null)}
>
{demoEingehaengt("heatmap") ? (
<Suspense fallback={null}>
<HeatmapDiagnose zustand={heat ?? undefined} />
</Suspense>
) : null}
</div>
{FUNKTIONEN.filter((f) => ladeArt(f) && f.hinweis).map((f) => (
<p
key={f.id}
className="kp-hinweis"
data-an={ladeArt(f) === aktivArt ? "ja" : "nein"}
aria-hidden={ladeArt(f) !== aktivArt || undefined}
>
{f.hinweis}
</p>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
// ── Handy: Wisch-Reihe ───────────────────────────────────────────────────────
/** Karte "Als App": der lange Screenshot schiebt sich langsam nach oben, solange die Karte sichtbar ist. */
const LangeKarte = ({ f }: { f: Funktion }) => {
const ref = useRef<HTMLDivElement>(null);
const [sichtbar, setSichtbar] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const beobachter = new IntersectionObserver(([e]) => setSichtbar(e.isIntersecting), { threshold: 0.6 });
beobachter.observe(el);
return () => beobachter.disconnect();
}, []);
return (
<div className="kp-karte" ref={ref} data-sichtbar={sichtbar ? "ja" : "nein"}>
<HandyRahmen className="kp-karte__handy">
<div className="kp-lang">
<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "250px", 4978)} />
</div>
</HandyRahmen>
<h3 className="wk-h3 kp-karte__titel">{f.titel}</h3>
<KartenUnterschrift f={f} />
</div>
);
};
/** Kartenrahmen einer Demo-Karte: Handy mit der Website, Titel, Unterschrift und der Hinweis auf das erfundene Beispiel. */
const DemoKarteRahmen = ({
f,
geraetRef,
nachher,
kulisse,
children,
}: {
f: Funktion;
/** Steht sofort im Handy (vor dem Nachladen der Demo), z. B. die Portal-Aufnahme unter der Chat-Ebene */
kulisse?: ReactNode;
/** Das Handy der Karte: nach seiner Sichtbarkeit richtet sich der Ablauf (die Karte selbst kann höher sein als der Bildschirm) */
geraetRef: RefObject<HTMLDivElement>;
/** Unter dem Text (Heatmap: die Diagnose) */
nachher?: ReactNode;
children: ReactNode;
}) => (
<div className="kp-karte kp-karte--demo" data-demo={f.demo}>
<div className="kp-karte__geraet" ref={geraetRef}>
<HandyRahmen className="kp-karte__handy">
{kulisse}
<Suspense fallback={f.demo ? <Platzhalter art={f.demo} /> : null}>{children}</Suspense>
</HandyRahmen>
</div>
<h3 className="wk-h3 kp-karte__titel">{f.titel}</h3>
<BaldMarke f={f} />
<KartenUnterschrift f={f} />
<p className="kp-karte__beispiel">{beispielText(f)}</p>
{f.bedienhinweis ? <p className="kp-karte__bedienen">{f.bedienhinweis.tippen}</p> : null}
{nachher}
</div>
);
/**
* Eigener Ablauf je Karte: Die Website wird geladen, sobald das Handy der Karte (angeschnitten) in Sicht kommt,
* und läuft, solange es zu mindestens 60 % im Bild ist. Maßgeblich ist das Handy, nicht die ganze Karte:
* Die Heatmap-Karte ist mit der Diagnose höher als kleine Bildschirme, 60 % der Karte würden dort nie erreicht.
*/
const useKarte = (art: LadeArt) => {
const ref = useRef<HTMLDivElement>(null);
const nah = useImBild(ref, 0);
const imBild = useImBild(ref, 0.6);
const [gesehen, setGesehen] = useState(false);
useEffect(() => {
if (nah) setGesehen(true);
}, [nah]);
// alle Chunks der Demo gleichzeitig laden, sobald die Karte angeschnitten in Sicht kommt
useEffect(() => {
if (gesehen) VORLADEN[art].forEach((vorladen) => vorladen());
}, [gesehen, art]);
const lauf = useDemoLauf(true, imBild);
return { ref, gesehen, ...lauf };
};
const TerminKarte = ({ f }: { f: Funktion }) => {
const k = useKarte("termin");
const [z, setZ] = useState<TerminZustand | null>(null);
return (
<DemoKarteRahmen f={f} geraetRef={k.ref}>
{k.gesehen ? <TerminSteuerung laeuft={k.laeuft} statisch={k.statisch} onZustand={setZ} /> : null}
{k.gesehen ? <TerminSeite zustand={z ?? undefined} variante="handy" /> : null}
</DemoKarteRahmen>
);
};
/** Chat-Karte: die Portal-Aufnahme des leeren Chats, darüber das Gespräch, sobald die Karte in Sicht kommt. */
const ChatKarte = ({ f }: { f: Funktion }) => {
const k = useKarte("chat");
const [z, setZ] = useState<ChatZustand | null>(null);
return (
<DemoKarteRahmen
f={f}
geraetRef={k.ref}
kulisse={<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "250px")} />}
>
{k.gesehen ? <ChatSteuerung laeuft={k.laeuft} statisch={k.statisch} onZustand={setZ} /> : null}
{k.gesehen && z ? <ChatHandy zustand={z} /> : null}
</DemoKarteRahmen>
);
};
const PinnwandKarte = ({ f }: { f: Funktion }) => {
const k = useKarte("pinnwand");
const [z, setZ] = useState<PinnwandZustand | null>(null);
return (
<DemoKarteRahmen f={f} geraetRef={k.ref}>
{k.gesehen ? <PinnwandSteuerung laeuft={k.laeuft} statisch={k.statisch} onZustand={setZ} /> : null}
{k.gesehen ? <PinnwandSeite zustand={z ?? undefined} variante="handy" /> : null}
</DemoKarteRahmen>
);
};
/** Heatmap-Karte: Tippen auf eine Zone wählt sie, darunter erscheint die kompakte Diagnose. */
const HeatmapKarte = ({ f }: { f: Funktion }) => {
const k = useKarte("heatmap");
const [z, setZ] = useState<HeatmapZustand | null>(null);
return (
<DemoKarteRahmen
f={f}
geraetRef={k.ref}
nachher={
<div className="kp-karte__diagnose">
<Suspense fallback={null}>{k.gesehen ? <HeatmapDiagnose zustand={z ?? undefined} kompakt /> : null}</Suspense>
</div>
}
>
{k.gesehen ? <HeatmapSteuerung laeuft={k.laeuft} statisch={k.statisch} aktiv onZustand={setZ} /> : null}
{k.gesehen ? <HeatmapSeite zustand={z ?? undefined} variante="handy" /> : null}
</DemoKarteRahmen>
);
};
const HandyReihe = () => (
<div className="kp-reihe">
<WischReihe ariaLabel="Acht Funktionen des Kundenportals, seitlich wischen">
{FUNKTIONEN.map((f) => {
if (f.lang) return <LangeKarte key={f.id} f={f} />;
if (f.live === "chat") return <ChatKarte key={f.id} f={f} />;
if (f.demo === "termin") return <TerminKarte key={f.id} f={f} />;
if (f.demo === "heatmap") return <HeatmapKarte key={f.id} f={f} />;
if (f.demo === "pinnwand") return <PinnwandKarte key={f.id} f={f} />;
return (
<div key={f.id} className="kp-karte">
<HandyRahmen className="kp-karte__handy" bild={handyBild(f.handy.name, f.handy.alt, "250px")} />
<h3 className="wk-h3 kp-karte__titel">{f.titel}</h3>
<KartenUnterschrift f={f} />
</div>
);
})}
</WischReihe>
</div>
);
// ── Schrittzeile ──────────────────────────────────────────────────────────────
const Schrittzeile = ({ desktop }: { desktop: boolean }) =>
desktop ? (
<div className="wk-container">
<ol className="kp-schritte wk-reveal" aria-label="Ablauf in vier Schritten">
{SCHRITTE.map((s) => (
<li key={s.nr} className="kp-schritt">
<span className="kp-schritt__nr wk-num">{s.nr}</span>
<span className="kp-schritt__text">{s.text}</span>
</li>
))}
</ol>
</div>
) : (
<div className="kp-schritte-handy">
<WischReihe ariaLabel="Ablauf in vier Schritten, seitlich wischen" className="kp-schritte-reihe">
{SCHRITTE.map((s) => (
<div key={s.nr} className="kp-schritt-karte">
<span className="kp-schritt__nr wk-num">{s.nr}</span>
<span className="kp-schritt__text">{s.text}</span>
</div>
))}
</WischReihe>
</div>
);
// ── Abschnitt ─────────────────────────────────────────────────────────────────
const Kundenportal = () => {
const desktop = useMedia("(min-width: 900px)");
return (
<section id="kundenportal" className="wk-section wk-section--alt kp" aria-labelledby="kundenportal-titel">
<div className="wk-container kp__kopf wk-reveal">
<h2 id="kundenportal-titel" className="wk-h2">
{/* zwei Sätze, zwei Zeilen: der Umbruch soll nach "Projekt." fallen, nicht mitten im zweiten Satz */}
<span className="kp-satz">Ihr Projekt.</span> <span className="kp-satz">Live im Kundenportal.</span>
</h2>
<p className="wk-lead">{EINLEITUNG}</p>
</div>
{desktop ? <Buehne /> : <HandyReihe />}
<Schrittzeile desktop={desktop} />
</section>
);
};
export default Kundenportal;
+125
View File
@@ -0,0 +1,125 @@
import { Fragment, useSyncExternalStore, type CSSProperties } from "react";
import WischReihe from "@/components/WischReihe";
import "./leistungen.css";
/** Spezifikation 7.3 (Standardtext; die Variante für O2 = ja ist noch nicht freigegeben). */
const EINLEITUNG = "Die meisten Betriebe starten mit einer Website. Wenn mehr nötig ist, bauen wir mehr.";
const LEISTUNGEN = [
{
titel: "Website",
text: "Startseite, Unterseiten, Impressum und Datenschutz – gestaltet, getextet und online.",
},
{
titel: "Online-Shop",
text: "Warenkorb, Kasse, Zahlung und Versand. Auf Wunsch angebunden an Ihre Warenwirtschaft.",
},
{
titel: "Web-App",
text: "Eigene Software für Ihren Betrieb mit Login, Verwaltung und Auswertungen, im Browser und auf dem Handy.",
},
{
titel: "Automatisierung",
text: "Was sich ständig wiederholt, erledigt der Rechner: Bestätigungen, Rechnungen, Dokumente auslesen, ein KI-Assistent mit Ihrem Firmenwissen.",
},
] as const;
/** Breakpoint für die Desktop-Darstellung (Spezifikation 9.4). */
const DESKTOP = "(min-width: 900px)";
/** Nur eine Darstellung im DOM: Liste ab 900 px, Wisch-Reihe darunter. Ohne matchMedia gilt die Handy-Ansicht. */
const useDesktop = (): boolean =>
useSyncExternalStore(
(melden) => {
if (typeof window.matchMedia !== "function") return () => {};
const abfrage = window.matchMedia(DESKTOP);
abfrage.addEventListener("change", melden);
return () => abfrage.removeEventListener("change", melden);
},
() => typeof window.matchMedia === "function" && window.matchMedia(DESKTOP).matches,
() => false,
);
/** „01“, „02“ … in Tabellenziffern. */
const nummer = (index: number) => String(index + 1).padStart(2, "0");
/**
* Einleitung, die sich beim Scrollen Wort für Wort aufhellt (Spezifikation 7.3, 8.4).
* Für Screenreader steht der ganze Satz einmal unsichtbar im Text, die Wort-Spans sind aria-hidden.
* Ohne `animation-timeline` oder mit reduzierter Bewegung (siehe leistungen.css) steht der Satz einfach da.
*/
const Einleitung = () => {
const woerter = EINLEITUNG.split(" ");
return (
<p className="wk-leistungen__einleitung">
<span className="wk-visually-hidden">{EINLEITUNG}</span>
<span aria-hidden="true">
{woerter.map((wort, i) => (
<Fragment key={i}>
<span className="wk-leistungen__wort" style={{ "--i": i, "--n": woerter.length } as CSSProperties}>
{wort}
</span>
{i < woerter.length - 1 ? " " : null}
</Fragment>
))}
</span>
</p>
);
};
const Eintrag = ({ index }: { index: number }) => {
const { titel, text } = LEISTUNGEN[index];
return (
<div className="wk-leistung">
<span className="wk-leistung__nr wk-num" aria-hidden="true">
{nummer(index)}
</span>
<div className="wk-leistung__inhalt">
<h3 className="wk-h3 wk-leistung__titel">{titel}</h3>
<p className="wk-leistung__text">{text}</p>
</div>
</div>
);
};
/** Spezifikation 7.3: vier nummerierte Textzeilen statt Karten mit Symbolen. */
const Leistungen = () => {
const desktop = useDesktop();
return (
<section id="leistungen" className="wk-section wk-leistungen" aria-labelledby="leistungen-titel">
<div className="wk-container wk-leistungen__raster">
<div className="wk-leistungen__kopf">
<h2 id="leistungen-titel" className="wk-h2 wk-reveal">
Was wir für Sie bauen.
</h2>
<Einleitung />
</div>
{desktop ? (
<ol className="wk-leistungen__liste" role="list">
{LEISTUNGEN.map((l, i) => (
<li key={l.titel} className="wk-leistungen__zeile wk-reveal">
<Eintrag index={i} />
</li>
))}
</ol>
) : (
<div className="wk-leistungen__wisch">
<WischReihe ariaLabel="Vier Leistungen, seitlich wischen">
{LEISTUNGEN.map((l, i) => (
<Eintrag key={l.titel} index={i} />
))}
</WischReihe>
</div>
)}
<p className="wk-leistungen__umzug wk-reveal">
<strong>Sie haben schon eine Website?</strong> Wir ziehen sie um – mit Domain, Inhalten und Postfächern.
</p>
</div>
</section>
);
};
export default Leistungen;
+52
View File
@@ -0,0 +1,52 @@
import { Link } from "react-router-dom";
import { ABOS, preisText, weitereArbeitTeile } from "@/data/abos";
import "./preise.css";
/** Spezifikation 7.5 (Standardtext; der Preisanker für O1 ist noch nicht freigegeben). */
const EINLEITUNG =
"Für Ihr Projekt bekommen Sie vorher ein Angebot mit Preis. Für den Betrieb danach gibt es zwei Abos. " +
"Alle Preise sind Endpreise; als Kleinunternehmer (§\u00a019\u00a0UStG) berechnen wir keine Umsatzsteuer.";
/** Spezifikation 7.5: zwei Abos nebeneinander (Handy untereinander). Zahlen kommen aus src/data/abos.ts. */
const Preise = () => {
const [vorher, schritte, nachher] = weitereArbeitTeile();
return (
<section id="preise" className="wk-section wk-section--alt wk-preise" aria-labelledby="preise-titel">
<div className="wk-container">
<h2 id="preise-titel" className="wk-h2 wk-reveal">
Was es kostet. Vorher.
</h2>
<p className="wk-lead wk-reveal">{EINLEITUNG}</p>
<div className="wk-preise__raster">
{ABOS.map((abo) => (
<article key={abo.id} className="wk-abo wk-reveal" aria-labelledby={`abo-${abo.id}`}>
<h3 id={`abo-${abo.id}`} className="wk-h3 wk-abo__name">
{abo.name}
</h3>
<p className="wk-abo__preis">
<span className="wk-abo__betrag wk-num">{preisText(abo.preis)}</span>{" "}
<span className="wk-abo__einheit">im Monat</span>
</p>
<p className="wk-abo__text">{abo.beschreibung}</p>
<p className="wk-abo__aktion">
<Link to={abo.link.to} className="wk-link wk-abo__link">
{abo.link.text}
</Link>
</p>
</article>
))}
</div>
<p className="wk-preise__weitere wk-reveal">
{vorher}
<span className="wk-preise__nicht-trennen">{schritte}</span>
{nachher}
</p>
</div>
</section>
);
};
export default Preise;
+25
View File
@@ -0,0 +1,25 @@
import { Link } from "react-router-dom";
/** Spezifikation 7.6: wer dahinter steckt. Keine Fotos, bis echte Teamfotos vorliegen. */
const UeberUns = () => (
<section id="ueber-uns" className="wk-section" aria-labelledby="ueber-uns-titel">
<div className="wk-container wk-ueber-uns">
<h2 id="ueber-uns-titel" className="wk-h2 wk-reveal">
Wer hinter WEBklar steckt.
</h2>
<div className="wk-reveal">
<p className="wk-lead">
Wir sind ein kleines Team aus Winnweiler in der Pfalz. Sie sprechen bei uns direkt mit den Leuten, die Ihre
Website bauen.
</p>
<p className="wk-ueber-uns__link">
<Link to="/ueber-uns" className="wk-link">
Mehr über uns
</Link>
</p>
</div>
</div>
</section>
);
export default UeberUns;
@@ -0,0 +1,271 @@
import { memo, useId, useLayoutEffect, useRef } from "react";
import { FLAECHE } from "../gemeinsam";
import "./baeckerei.css";
/**
* Die Beispiel-Website der Station „Live-Vorschau“: die erfundene „Bäckerei Sonnenschein“, Demo-Kundin des Kundenportals
* (gleiche Adresse wie in den Portal-Aufnahmen: baeckerei-sonnenschein.project.webklar.com).
* Desktop = 1512 px breit (MacBook-Fläche), die Seite ist länger als der Bildschirm: Beim Scrollen durch die Station
* läuft sie im MacBook nach oben (Fortschritt 0 bis 1 kommt als CSS-Variable --kp-v von der Bühne, nur transform).
* Fotos: Wikimedia Commons, CC0, nur Backwaren (docs/relaunch/assets/baeckerei/QUELLEN.md).
* Die Website ist reine Kulisse: aria-hidden, keine fokussierbaren Elemente (div/span statt a/button).
*/
const FOTOS = "/relaunch/demos/baeckerei";
const foto = (name: string) => `${FOTOS}/${name}.webp`;
/** Logo: kleine Sonne über einem Brotlaib, in der Farbe der Kruste. */
const Logo = () => (
<svg className="bk-logo__zeichen" viewBox="0 0 48 48" aria-hidden="true" focusable="false">
<circle cx="24" cy="24" r="23" fill="#2a1a10" />
<g stroke="#f2b544" strokeWidth="2.6" strokeLinecap="round">
<path d="M24 8v4M13 12.5l2.6 2.8M35 12.5l-2.6 2.8M8.5 22h3.6M39.5 22h-3.6" />
</g>
<path d="M16.5 26a7.5 7.5 0 0 1 15 0" fill="#f2b544" />
<path d="M10.5 33.5c0-4.2 6-6.4 13.5-6.4s13.5 2.2 13.5 6.4c0 1.7-1.4 2.6-3.2 2.6H13.7c-1.8 0-3.2-.9-3.2-2.6z" fill="#e8d3b2" />
<path d="M17 30.5l2.4 2.6M23 29.8l2.4 2.6M29 30.5l2.4 2.6" stroke="#2a1a10" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
/** Runder Stempel „Handwerk · seit 1987 · Altstadt ·“ (Schrift auf einer Kreisbahn, auf den Umfang gestreckt). */
const Stempel = () => {
const id = `${useId().replace(/:/g, "")}-kreis`;
return (
<svg className="bk-stempel" viewBox="0 0 120 120" aria-hidden="true" focusable="false">
<defs>
<path id={id} d="M60 60m-44 0a44 44 0 1 1 88 0a44 44 0 1 1-88 0" />
</defs>
<circle cx="60" cy="60" r="58" fill="#f2b544" />
<text fontSize="10.5" fontWeight="600" fill="#2a1a10">
<textPath href={`#${id}`} textLength="272" lengthAdjust="spacing">
HANDWERK · SEIT 1987 · ALTSTADT ·
</textPath>
</text>
<path d="M44 66c0-6 7.2-9.2 16-9.2s16 3.2 16 9.2c0 2.2-1.8 3.4-4 3.4H48c-2.2 0-4-1.2-4-3.4z" fill="#2a1a10" />
<path d="M52 62l2.6 2.8M59 61l2.6 2.8M66 62l2.6 2.8" stroke="#f2b544" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
};
const Pfeil = () => (
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M3 8h9.5M8.5 4l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
const SORTIMENT = [
{ name: "Roggenbrot", text: "36 Stunden Sauerteig, kräftig und lange saftig.", preis: "4,90 €", bild: "roggenbrot-anschnitt-400", neu: true },
{ name: "Kaiserbrötchen", text: "Außen knusprig, innen fluffig – ab 6 Uhr.", preis: "0,55 €", bild: "broetchen-400" },
{ name: "Buttercroissant", text: "Mit echter Butter, Schicht für Schicht gefaltet.", preis: "1,80 €", bild: "croissants-400" },
{ name: "Zimtschnecke", text: "Mit Hagelzucker. Samstags oft noch warm.", preis: "2,40 €", bild: "zimtschnecken-400" },
];
const ZEITEN = [
{ tage: "Montag – Freitag", zeit: "6:00 – 18:00 Uhr", heute: true },
{ tage: "Samstag", zeit: "6:00 – 13:00 Uhr" },
{ tage: "Sonntag", zeit: "7:00 – 11:00 Uhr" },
];
const BANDWOERTER = ["Roggenbrot", "Dinkelvollkorn", "Buttercroissant", "Laugenbrezel", "Zimtschnecke", "Zwetschgenkuchen", "Kaiserbrötchen"];
const BaeckereiSeite = () => {
const seite = useRef<HTMLDivElement>(null);
const inhalt = useRef<HTMLDivElement>(null);
// Wie weit die Seite im MacBook nach oben laufen kann (Designpunkte, unabhängig vom Maßstab des Rahmens)
useLayoutEffect(() => {
const s = seite.current;
const i = inhalt.current;
if (!s || !i) return;
const messen = () => s.style.setProperty("--bk-weg", `${Math.max(0, i.offsetHeight - FLAECHE.desktop.hoehe)}px`);
messen();
if (typeof ResizeObserver === "undefined") return;
const beobachter = new ResizeObserver(messen);
beobachter.observe(i);
return () => beobachter.disconnect();
}, []);
return (
<div className="bk-seite" ref={seite} aria-hidden="true">
<div className="bk-inhalt" ref={inhalt}>
<div className="bk-leiste">
<span>
<b>Heute frisch aus dem Ofen:</b> Roggenbrot mit 36 Stunden Sauerteig
</span>
<span className="bk-leiste__rechts">Für morgen vorbestellen bis 18 Uhr</span>
</div>
<div className="bk-kopf">
<div className="bk-logo">
<Logo />
<span className="bk-logo__text">
<span className="bk-logo__name">Sonnenschein</span>
<span className="bk-logo__zusatz">Bäckerei · seit 1987</span>
</span>
</div>
<div className="bk-menue">
<span>Sortiment</span>
<span>Backstube</span>
<span>Café</span>
<span>Torten</span>
<span>Kontakt</span>
</div>
<span className="bk-knopf bk-knopf--dunkel bk-knopf--klein">Vorbestellen</span>
</div>
<div className="bk-held">
<div className="bk-held__text">
<span className="bk-offen">
<i />
Heute geöffnet · 6 – 18 Uhr
</span>
<h1 className="bk-held__titel">
Frisch gebacken.
<br />
<em>Jeden Morgen.</em>
</h1>
<p className="bk-held__lead">
Brot, Brötchen und Kuchen aus unserer Backstube in der Altstadt – mit Sauerteig, viel Zeit und Mehl aus der
Region.
</p>
<div className="bk-held__knoepfe">
<span className="bk-knopf bk-knopf--dunkel">
Sortiment ansehen <Pfeil />
</span>
<span className="bk-knopf bk-knopf--rand">Torte bestellen</span>
</div>
<div className="bk-fakten">
<span>
<b>36 Std.</b>Teigruhe
</span>
<span>
<b>4:00</b>Ofen an
</span>
<span>
<b>1987</b>in der Altstadt
</span>
</div>
</div>
<div className="bk-held__bild">
<img src={foto("roggenbrot-600")} width={600} height={556} alt="" decoding="async" />
<div className="bk-neu">
<span className="bk-neu__marke">Neu im Regal</span>
<span className="bk-neu__name">Roggenbrot</span>
<span className="bk-neu__zeile">
36 Std. Sauerteig · 750 g <b>4,90 €</b>
</span>
</div>
<Stempel />
</div>
</div>
<div className="bk-band">
{BANDWOERTER.map((w) => (
<span key={w}>{w}</span>
))}
</div>
<div className="bk-abschnitt">
<div className="bk-abschnitt__kopf">
<div>
<span className="bk-dach">Unser Sortiment</span>
<h2 className="bk-h2">Aus dem Ofen, direkt in die Tüte.</h2>
</div>
<span className="bk-link">
Ganzes Sortiment <Pfeil />
</span>
</div>
<div className="bk-karten">
{SORTIMENT.map((s) => (
<div key={s.name} className="bk-karte">
<div className="bk-karte__bild">
<img src={foto(s.bild)} width={400} height={500} alt="" loading="lazy" decoding="async" />
{s.neu ? <span className="bk-karte__neu">Neu</span> : null}
</div>
<div className="bk-karte__zeile">
<span className="bk-karte__name">{s.name}</span>
<span className="bk-karte__preis">{s.preis}</span>
</div>
<p className="bk-karte__text">{s.text}</p>
</div>
))}
</div>
</div>
<div className="bk-backstube">
<div className="bk-backstube__bild">
<img src={foto("baguettes-700")} width={700} height={583} alt="" loading="lazy" decoding="async" />
</div>
<div className="bk-backstube__text">
<span className="bk-dach">Unsere Backstube</span>
<h2 className="bk-h2">Bei uns hat der Teig Zeit.</h2>
<p>
Um vier Uhr geht der Ofen an, aber angefangen hat alles schon am Vortag: Unser Sauerteig ruht bis zu 36 Stunden,
bevor er zu Brot wird. Das schmeckt man – und das Brot bleibt tagelang frisch.
</p>
<p className="bk-unterschrift">Marlene Sonnenschein, Bäckermeisterin</p>
</div>
</div>
<div className="bk-torten">
<div className="bk-torten__text">
<span className="bk-dach bk-dach--hell">Kuchen &amp; Torten</span>
<h2 className="bk-h2 bk-h2--hell">Zum Geburtstag, zur Hochzeit oder einfach so.</h2>
<p>Zwei Tage vorher bestellen, frisch bei uns abholen.</p>
<span className="bk-knopf bk-knopf--sonne">
Torte anfragen <Pfeil />
</span>
</div>
<img src={foto("zwetschgenkuchen-520")} width={520} height={520} alt="" loading="lazy" decoding="async" />
</div>
<div className="bk-besuch">
<div className="bk-zeiten">
<span className="bk-dach">Öffnungszeiten</span>
{ZEITEN.map((z) => (
<div key={z.tage} className={`bk-zeiten__zeile${z.heute ? " is-heute" : ""}`}>
<span>{z.tage}</span>
<span>{z.zeit}</span>
</div>
))}
</div>
<div className="bk-karte-ort">
<div className="bk-stadtplan">
<span className="bk-strasse bk-strasse--a" />
<span className="bk-strasse bk-strasse--b" />
<span className="bk-strasse bk-strasse--c" />
<span className="bk-park" />
<span className="bk-nadel">
<Logo />
</span>
</div>
<div className="bk-adresse">
<span className="bk-dach">So finden Sie uns</span>
<b>Marktgasse 3, Altstadt</b>
<span>Telefon 01234 / 56 78 90</span>
</div>
</div>
</div>
<div className="bk-fuss">
<div className="bk-logo bk-logo--hell">
<Logo />
<span className="bk-logo__text">
<span className="bk-logo__name">Sonnenschein</span>
<span className="bk-logo__zusatz">Bäckerei · seit 1987</span>
</span>
</div>
<span className="bk-fuss__links">
<span>Sortiment</span>
<span>Öffnungszeiten</span>
<span>Impressum</span>
<span>Datenschutz</span>
</span>
</div>
</div>
</div>
);
};
// memo: die Bühne rendert während der Chat-Demo oft neu, die Website hat keine Props und bleibt stehen
export default memo(BaeckereiSeite);
@@ -0,0 +1,594 @@
/*
* Beispiel-Website „Bäckerei Sonnenschein“ (Station Live-Vorschau). Alle Klassen mit Präfix bk-.
* Desktop 1512 px breit, die Seite ist länger als die MacBook-Fläche (893 px) und läuft beim Scrollen durch die Station
* nach oben: --kp-v (0 bis 1) setzt die Bühne, --bk-weg die Seite selbst. Nur transform, nur ohne reduzierte Bewegung.
*/
/* Instrument Serif, selbst gehostet (SIL Open Font License, public/fonts/instrument-serif/LICENSE) */
@font-face {
font-family: "Instrument Serif";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/instrument-serif/instrument-serif-latin-ext-400-normal.woff2) format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF,
U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Instrument Serif";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/instrument-serif/instrument-serif-latin-400-normal.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
.bk-seite {
--bk-tinte: #2a1a10;
--bk-tinte-leise: #6f5a48;
--bk-creme: #f7f0e4;
--bk-papier: #fffaf2;
--bk-sand: #efe3cf;
--bk-kruste: #a8561e;
--bk-sonne: #f2b544;
--bk-linie: #e6d7bf;
--bk-serif: "Instrument Serif", Georgia, "Times New Roman", serif;
position: absolute;
inset: 0;
overflow: hidden;
background: var(--bk-creme);
color: var(--bk-tinte);
font-family: "Instrument Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-weight: 400;
line-height: 1.45;
text-align: left;
letter-spacing: normal;
-webkit-font-smoothing: antialiased;
user-select: none;
}
.bk-seite *,
.bk-seite *::before,
.bk-seite *::after {
box-sizing: border-box;
margin: 0;
}
.bk-seite img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Die ganze Seite läuft mit dem Scrollfortschritt der Station nach oben */
.bk-inhalt {
position: absolute;
left: 0;
top: 0;
width: 1512px;
}
@media (prefers-reduced-motion: no-preference) {
.bk-inhalt {
transform: translateY(calc(var(--kp-v, 0) * -1 * var(--bk-weg, 0px)));
}
}
/* ── Leiste und Kopf ─────────────────────────────────────────────────── */
.bk-leiste {
height: 40px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background: var(--bk-tinte);
color: #f3e6d0;
font-size: 14px;
}
.bk-leiste b {
font-weight: 600;
color: var(--bk-sonne);
}
.bk-leiste__rechts {
position: absolute;
right: 88px;
opacity: 0.75;
}
.bk-kopf {
height: 96px;
display: flex;
align-items: center;
padding: 0 88px;
border-bottom: 1px solid var(--bk-linie);
}
.bk-logo {
display: flex;
align-items: center;
gap: 14px;
}
.bk-logo__zeichen {
width: 48px;
height: 48px;
flex: none;
}
.bk-logo__text {
display: flex;
flex-direction: column;
line-height: 1;
}
.bk-logo__name {
font-family: var(--bk-serif);
font-size: 34px;
letter-spacing: -0.01em;
}
.bk-logo__zusatz {
margin-top: 4px;
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--bk-kruste);
}
.bk-menue {
display: flex;
gap: 40px;
margin: 0 auto;
font-size: 16.5px;
font-weight: 500;
color: #4a3626;
}
.bk-knopf {
display: inline-flex;
align-items: center;
gap: 10px;
height: 58px;
padding: 0 30px;
border-radius: 999px;
font-size: 17px;
font-weight: 600;
white-space: nowrap;
}
.bk-knopf svg {
width: 17px;
height: 17px;
}
.bk-knopf--klein {
height: 46px;
padding: 0 24px;
font-size: 15.5px;
}
.bk-knopf--dunkel {
background: var(--bk-tinte);
color: var(--bk-papier);
}
.bk-knopf--rand {
border: 1.5px solid #cdb595;
color: var(--bk-tinte);
}
.bk-knopf--sonne {
background: var(--bk-sonne);
color: var(--bk-tinte);
}
/* ── Startbereich ────────────────────────────────────────────────────── */
.bk-held {
display: grid;
grid-template-columns: 1fr 620px;
gap: 72px;
align-items: center;
height: 640px;
padding: 0 88px;
}
.bk-offen {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 8px 16px 8px 13px;
border-radius: 999px;
background: var(--bk-papier);
border: 1px solid var(--bk-linie);
font-size: 14.5px;
font-weight: 600;
color: #3f5e2c;
}
.bk-offen i {
width: 9px;
height: 9px;
border-radius: 50%;
background: #4c9a3c;
box-shadow: 0 0 0 4px #dcebd3;
}
.bk-held__titel {
margin: 26px 0 22px;
font-family: var(--bk-serif);
font-size: 112px;
font-weight: 400;
line-height: 0.94;
letter-spacing: -0.025em;
}
.bk-held__titel em {
font-style: normal;
color: var(--bk-kruste);
}
.bk-held__lead {
max-width: 540px;
font-size: 20px;
line-height: 1.55;
color: var(--bk-tinte-leise);
}
.bk-held__knoepfe {
display: flex;
gap: 14px;
margin-top: 34px;
}
.bk-fakten {
display: flex;
gap: 40px;
margin-top: 40px;
padding-top: 26px;
border-top: 1px solid var(--bk-linie);
font-size: 14.5px;
color: var(--bk-tinte-leise);
}
.bk-fakten span {
display: flex;
flex-direction: column;
}
.bk-fakten b {
font-family: var(--bk-serif);
font-size: 34px;
font-weight: 400;
line-height: 1.05;
color: var(--bk-tinte);
}
.bk-held__bild {
position: relative;
height: 560px;
}
.bk-held__bild > img {
border-radius: 32px;
box-shadow: 0 40px 70px -40px rgb(70 35 10 / 0.55);
}
.bk-neu {
position: absolute;
left: -44px;
bottom: 40px;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 270px;
padding: 18px 22px 20px;
border-radius: 22px;
background: var(--bk-papier);
box-shadow: 0 24px 50px -24px rgb(60 30 5 / 0.45);
}
.bk-neu__marke {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--bk-kruste);
}
.bk-neu__name {
font-family: var(--bk-serif);
font-size: 32px;
line-height: 1.1;
}
.bk-neu__zeile {
display: flex;
justify-content: space-between;
gap: 20px;
font-size: 14.5px;
color: var(--bk-tinte-leise);
}
.bk-neu__zeile b {
color: var(--bk-tinte);
}
.bk-stempel {
position: absolute;
right: -34px;
top: -34px;
width: 132px;
height: 132px;
transform: rotate(-12deg);
}
/* ── Band ────────────────────────────────────────────────────────────── */
.bk-band {
display: flex;
align-items: center;
justify-content: center;
gap: 34px;
height: 66px;
margin-top: 24px;
background: var(--bk-kruste);
color: #fbeedc;
font-family: var(--bk-serif);
font-size: 27px;
white-space: nowrap;
overflow: hidden;
}
.bk-band span + span::before {
content: "✺";
margin-right: 34px;
font-family: "Instrument Sans", system-ui, sans-serif;
font-size: 16px;
color: var(--bk-sonne);
vertical-align: 4px;
}
/* ── Sortiment ───────────────────────────────────────────────────────── */
.bk-abschnitt {
padding: 88px 88px 96px;
}
.bk-abschnitt__kopf {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 44px;
}
.bk-dach {
display: block;
margin-bottom: 14px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--bk-kruste);
}
.bk-dach--hell {
color: var(--bk-sonne);
}
.bk-h2 {
max-width: 760px;
font-family: var(--bk-serif);
font-size: 66px;
font-weight: 400;
line-height: 1;
letter-spacing: -0.02em;
}
.bk-h2--hell {
color: #fbeedc;
}
.bk-link {
display: inline-flex;
align-items: center;
gap: 8px;
padding-bottom: 4px;
border-bottom: 1.5px solid currentColor;
font-size: 16.5px;
font-weight: 600;
}
.bk-link svg {
width: 16px;
height: 16px;
}
.bk-karten {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 28px;
}
.bk-karte__bild {
position: relative;
aspect-ratio: 4 / 5;
border-radius: 22px;
overflow: hidden;
}
.bk-karte__neu {
position: absolute;
left: 16px;
top: 16px;
padding: 6px 13px;
border-radius: 999px;
background: var(--bk-sonne);
font-size: 13px;
font-weight: 700;
}
.bk-karte__zeile {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-top: 18px;
}
.bk-karte__name {
font-family: var(--bk-serif);
font-size: 30px;
line-height: 1.1;
}
.bk-karte__preis {
font-size: 17px;
font-weight: 600;
}
.bk-karte__text {
margin-top: 6px;
font-size: 15.5px;
color: var(--bk-tinte-leise);
}
/* ── Backstube ───────────────────────────────────────────────────────── */
.bk-backstube {
display: grid;
grid-template-columns: 640px 1fr;
gap: 88px;
align-items: center;
padding: 80px 88px;
background: var(--bk-sand);
}
.bk-backstube__bild {
height: 440px;
border-radius: 28px;
overflow: hidden;
}
.bk-backstube__text p {
max-width: 560px;
margin-top: 26px;
font-size: 19px;
line-height: 1.6;
color: var(--bk-tinte-leise);
}
.bk-backstube__text .bk-unterschrift {
margin-top: 30px;
font-family: var(--bk-serif);
font-size: 26px;
color: var(--bk-tinte);
}
/* ── Torten ──────────────────────────────────────────────────────────── */
.bk-torten {
display: grid;
grid-template-columns: 1fr 380px;
align-items: center;
gap: 64px;
margin: 88px 88px 0;
padding: 0 0 0 72px;
height: 360px;
border-radius: 32px;
background: var(--bk-tinte);
color: #e7d6bd;
overflow: hidden;
}
.bk-torten img {
height: 360px;
}
.bk-torten p {
margin: 20px 0 30px;
font-size: 19px;
}
/* ── Besuch ──────────────────────────────────────────────────────────── */
.bk-besuch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 28px;
padding: 88px;
}
.bk-zeiten,
.bk-karte-ort {
border-radius: 28px;
background: var(--bk-papier);
border: 1px solid var(--bk-linie);
}
.bk-zeiten {
padding: 36px 44px 28px;
}
.bk-zeiten__zeile {
display: flex;
justify-content: space-between;
padding: 18px 0;
border-top: 1px solid var(--bk-linie);
font-size: 19px;
}
.bk-zeiten__zeile.is-heute {
font-weight: 600;
}
.bk-zeiten__zeile.is-heute span:first-child::after {
content: "Heute";
margin-left: 12px;
padding: 3px 10px;
border-radius: 999px;
background: #dcebd3;
color: #3f5e2c;
font-size: 13px;
vertical-align: 2px;
}
.bk-karte-ort {
display: grid;
grid-template-rows: 1fr auto;
overflow: hidden;
}
.bk-stadtplan {
position: relative;
height: 180px;
background: #ece2d0;
overflow: hidden;
}
.bk-strasse {
position: absolute;
background: var(--bk-papier);
}
.bk-strasse--a {
left: -20px;
right: -20px;
top: 92px;
height: 22px;
transform: rotate(-6deg);
}
.bk-strasse--b {
top: -20px;
bottom: -20px;
left: 300px;
width: 18px;
transform: rotate(14deg);
}
.bk-strasse--c {
top: -20px;
bottom: -20px;
left: 520px;
width: 12px;
transform: rotate(-24deg);
}
.bk-park {
position: absolute;
left: 60px;
top: 128px;
width: 170px;
height: 120px;
border-radius: 40px;
background: #cfdcb8;
}
.bk-nadel {
position: absolute;
left: 290px;
top: 52px;
width: 54px;
height: 54px;
border-radius: 50%;
border: 4px solid var(--bk-papier);
box-shadow: 0 10px 20px -8px rgb(60 30 5 / 0.5);
}
.bk-nadel .bk-logo__zeichen {
width: 100%;
height: 100%;
}
.bk-adresse {
display: flex;
flex-direction: column;
gap: 4px;
padding: 28px 40px 32px;
font-size: 17px;
color: var(--bk-tinte-leise);
}
.bk-adresse .bk-dach {
margin-bottom: 8px;
}
.bk-adresse b {
font-family: var(--bk-serif);
font-size: 30px;
font-weight: 400;
color: var(--bk-tinte);
}
/* ── Fuß ─────────────────────────────────────────────────────────────── */
.bk-fuss {
display: flex;
align-items: center;
justify-content: space-between;
height: 150px;
padding: 0 88px;
background: var(--bk-tinte);
color: #f3e6d0;
}
.bk-fuss__links {
display: flex;
gap: 32px;
font-size: 15.5px;
opacity: 0.8;
}
@@ -0,0 +1,197 @@
import { useLayoutEffect, useRef } from "react";
import { DemoZeiger, type DemoVariante } from "../gemeinsam";
import { ABSENDER, NACHRICHTEN, type ChatZiel, type ChatZustand } from "./ablauf";
import "./chat.css";
/**
* Live-Ebene über der Aufnahme des leeren Portal-Chats (desktop-3c-chat-leer bzw. phone-4c-chat-leer, Fixture-Daten):
* die Nachrichten, das Eingabefeld mit getipptem Text, Anhang, Sprachaufnahme, der Senden-Knopf und ein Zeiger.
* Alle Maße in Designpunkten der Fläche, gemessen in der Aufnahme (vorschau-chat/aufnahmen/chat-masse.json).
* Die Blasen bauen den Portal-Chat nach (kundenbereich.css / chat-composer.css: Lila für eigene, Grau fürs Team).
*/
type Kasten = { x: number; y: number; b: number; h: number };
const MASSE: Record<
DemoVariante,
{
liste: Kasten;
eingabe: Kasten & { radius: number; innen: number };
anhang: Kasten;
mikro: Kasten;
senden: Kasten;
}
> = {
desktop: {
liste: { x: 485, y: 248.5, b: 814, h: 410 },
eingabe: { x: 535, y: 676.5, b: 606.5, h: 50, radius: 14, innen: 14 },
anhang: { x: 481, y: 680.5, b: 46, h: 46 },
mikro: { x: 1149.5, y: 680.5, b: 46, h: 46 },
senden: { x: 1203.5, y: 680.5, b: 99.5, h: 46 },
},
handy: {
liste: { x: 29, y: 236, b: 344, h: 455 },
eingabe: { x: 75, y: 707, b: 204, h: 46, radius: 23, innen: 16 },
anhang: { x: 27, y: 711, b: 42, h: 42 },
mikro: { x: 285, y: 711, b: 42, h: 42 },
senden: { x: 333, y: 711, b: 42, h: 42 },
},
};
const FOTO = "/relaunch/demos/baeckerei/roggenbrot-600.webp";
const px = (k: Kasten) => ({ left: k.x, top: k.y, width: k.b, height: k.h });
const mitte = (k: Kasten) => ({ x: k.x + k.b / 2, y: k.y + k.h / 2 });
/** Pegel der laufenden Aufnahme (feste Höhen, sieht bei jedem Laden gleich aus) */
const WELLE = [0.35, 0.6, 0.9, 0.55, 0.75, 1, 0.65, 0.4, 0.8, 0.95, 0.5, 0.7, 0.45, 0.85, 0.6, 0.3, 0.55, 0.75, 0.4, 0.25];
const Blase = ({ i }: { i: number }) => {
const n = NACHRICHTEN[i];
return (
<div className={`ch-blase ch-blase--${n.von}${n.foto ? " ch-blase--foto" : ""}`}>
{n.foto ? <img className="ch-foto" src={FOTO} width={600} height={556} alt="" decoding="async" /> : null}
{n.sprache ? (
// Player wie im Portal (chat-composer.js): Abspielknopf, „Sprachnachricht“, Fortschrittsleiste, Dauer
<span className="ch-audio">
<span className="ch-audio__play">
<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M5 3.2v9.6L12.6 8z" fill="currentColor" />
</svg>
</span>
<span className="ch-audio__mitte">
<span className="ch-audio__wort">Sprachnachricht</span>
<span className="ch-audio__leiste" />
</span>
<span className="ch-audio__zeit">{n.sprache}</span>
</span>
) : null}
{n.text ? <span className="ch-text">{n.text}</span> : null}
<span className="ch-meta">
{ABSENDER[n.von]} · {n.zeit}
</span>
</div>
);
};
/**
* Die sichtbaren Nachrichten, unten verankert wie in einem Messenger. Kommt eine neue dazu, rutschen die älteren
* weich nach oben (FLIP mit der Web Animations API, nur transform, nur mit Bewegung).
*/
const Verlauf = ({ zustand }: { zustand: ChatZustand }) => {
const liste = useRef<HTMLDivElement>(null);
const vorher = useRef(zustand.sichtbar);
useLayoutEffect(() => {
const el = liste.current;
const neu = zustand.sichtbar > vorher.current;
vorher.current = zustand.sichtbar;
if (!el || !neu || zustand.statisch || typeof el.animate !== "function") return;
const letzte = el.lastElementChild as HTMLElement | null;
if (!letzte) return;
const abstand = parseFloat(getComputedStyle(el).rowGap) || 0;
const weg = letzte.offsetHeight + abstand;
el.animate([{ transform: `translateY(${weg}px)` }, { transform: "translateY(0)" }], {
duration: 480,
easing: "cubic-bezier(0.2, 0.8, 0.2, 1)",
});
}, [zustand.sichtbar, zustand.statisch]);
return (
<div className="ch-liste" ref={liste} data-aus={zustand.aus ? "ja" : "nein"}>
{NACHRICHTEN.slice(0, zustand.sichtbar).map((_, i) => (
<Blase key={`${zustand.runde}-${i}`} i={i} />
))}
</div>
);
};
const Eingabe = ({ zustand, variante }: { zustand: ChatZustand; variante: DemoVariante }) => {
const m = MASSE[variante];
const tippt = zustand.eingabe.length > 0;
return (
<>
{zustand.anhang ? (
<span className="ch-anhang" style={{ left: m.eingabe.x, top: m.eingabe.y - 74 }}>
<img src={FOTO} width={600} height={556} alt="" decoding="async" />
</span>
) : null}
{tippt ? (
<span
className="ch-feld"
style={{ ...px(m.eingabe), borderRadius: m.eingabe.radius, paddingInline: m.eingabe.innen }}
>
<span className="ch-feld__text">
{zustand.eingabe}
<span className="ch-feld__strich" />
</span>
</span>
) : null}
{/* Senden: aktiv, sobald Text im Feld steht; beim Klick drückt er sich kurz ein */}
{tippt || zustand.zeiger === "senden" ? (
<span key={`s-${zustand.klick}`} className="ch-senden" data-druck={zustand.zeiger === "senden" && zustand.klick ? "ja" : "nein"} style={px(m.senden)}>
{variante === "desktop" ? (
"Senden"
) : (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M21 3 10 14M21 3l-7 18-4-7-7-4z" fill="none" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
</svg>
)}
</span>
) : null}
{/* Sprachaufnahme: ersetzt wie im Portal Anhang, Textfeld und Mikrofon (rote Leiste mit Zeit und Pegel) */}
{zustand.aufnahme !== null ? (
<span
className="ch-aufnahme"
// so hoch wie das Eingabefeld (die Knöpfe sind etwas niedriger), damit von der Aufnahme darunter nichts durchscheint
style={{ left: m.anhang.x, top: m.eingabe.y, width: m.mikro.x + m.mikro.b - m.anhang.x, height: m.eingabe.h }}
>
<span className="ch-aufnahme__punkt" />
{variante === "desktop" ? <span className="ch-aufnahme__wort">Aufnahme</span> : null}
<span className="ch-aufnahme__zeit">0:{String(zustand.aufnahme).padStart(2, "0")}</span>
<span className="ch-aufnahme__pegel">
{WELLE.map((h, k) => (
<i key={k} style={{ ["--ch-h" as string]: h, animationDelay: `${(k % 5) * -0.13}s` }} />
))}
</span>
<span className="ch-aufnahme__stopp" />
</span>
) : null}
</>
);
};
/** Wo der Zeiger hinzeigt; in Ruhe wartet er unsichtbar unten rechts neben dem Eingabefeld. */
const zeigerZiel = (ziel: ChatZiel, variante: DemoVariante) => {
const m = MASSE[variante];
if (ziel === "anhang") return mitte(m.anhang);
if (ziel === "mikro") return mitte(m.mikro);
if (ziel === "senden") return mitte(m.senden);
return { x: m.senden.x + m.senden.b / 2, y: m.senden.y + 90 };
};
const ChatEbene = ({ zustand, variante }: { zustand: ChatZustand; variante: DemoVariante }) => {
const ziel = zeigerZiel(zustand.zeiger, variante);
const m = MASSE[variante];
return (
<div className="ch-ebene" data-variante={variante}>
<div className="ch-ausschnitt" style={px(m.liste)}>
<Verlauf zustand={zustand} />
</div>
<Eingabe zustand={zustand} variante={variante} />
<DemoZeiger
x={ziel.x}
y={ziel.y}
art={variante === "desktop" ? "maus" : "finger"}
klick={zustand.klick}
sichtbar={zustand.zeiger !== "ruhe"}
groesse={variante === "desktop" ? 1.1 : 1.2}
/>
</div>
);
};
export default ChatEbene;
@@ -0,0 +1,14 @@
import type { ChatZustand } from "./ablauf";
import ChatEbene from "./ChatEbene";
/**
* Chat-Demo im iPhone (402 x 874): Live-Ebene über der Aufnahme des leeren Portal-Chats auf dem Handy, im Gleichtakt
* mit dem MacBook (derselbe Zustand). Transparent, ohne Zeigerereignisse.
*/
const ChatHandy = ({ zustand }: { zustand: ChatZustand }) => (
<div className="ch-handy" aria-hidden="true">
<ChatEbene zustand={zustand} variante="handy" />
</div>
);
export default ChatHandy;
@@ -0,0 +1,32 @@
import type { ChatZustand } from "./ablauf";
import { CHAT_START } from "./ablauf";
import ChatEbene from "./ChatEbene";
/**
* Chat-Demo im MacBook der Bühne (1512 x 893): die Aufnahme des leeren Portal-Chats (Fixture-Daten) mit der Live-Ebene darüber.
* Die „Kamera“ zoomt auf das Gespräch (nur transform), damit man die Nachrichten lesen kann, und am Ende wieder heraus.
* Das Bild gibt es in 2400 px Breite, damit es auch hineingezoomt scharf bleibt.
*/
const BILD = "/relaunch/portal/desktop-3c-chat-leer";
const ChatSeite = ({ zustand = CHAT_START }: { zustand?: ChatZustand }) => (
<div className="ch-seite" aria-hidden="true">
<div className="ch-kamera" data-zoom={zustand.zoom ? "ja" : "nein"}>
<img
className="ch-kulisse"
src={`${BILD}-1600.webp`}
srcSet={`${BILD}-1600.webp 1600w, ${BILD}-2400.webp 2400w`}
// hineingezoomt ist das Bild etwa 1,7-mal so breit wie das MacBook
sizes="(min-width: 1200px) 1300px, 110vw"
width={1600}
height={945}
alt=""
decoding="async"
/>
<ChatEbene zustand={zustand} variante="desktop" />
</div>
</div>
);
export default ChatSeite;
+178
View File
@@ -0,0 +1,178 @@
import { useEffect, useMemo, useState } from "react";
import { useAblauf } from "../gemeinsam";
/**
* Ablauf der Chat-Demo (Station „Chat“, Wunsch des Inhabers 2026-10-04): Im MacBook ist der Chat des Kundenportals offen,
* die Kamera zoomt auf das Gespräch, damit man mitlesen kann. Marlene von der Bäckerei Sonnenschein hängt ein Foto an,
* tippt, schickt eine Sprachnachricht; Kenso vom WEBklar-Team antwortet. Am Ende zoomt die Kamera wieder heraus.
* Reine Logik, kein CSS: die Komponenten werden per React.lazy nachgeladen.
* Ohne Bewegung (prefers-reduced-motion) steht sofort der Endzustand da: alle Nachrichten, hineingezoomt.
*/
export type ChatNachricht = {
von: "ich" | "team";
text?: string;
/** Foto im Anhang (dasselbe Roggenbrot wie auf der Website der Station Live-Vorschau) */
foto?: boolean;
/** Sprachnachricht mit Dauer */
sprache?: string;
zeit: string;
};
/** Das Gespräch; die Uhrzeiten zeigen, dass Antworten ihre Zeit haben dürfen (Antwortzeit laut Website: spätestens am nächsten Werktag). */
export const NACHRICHTEN: readonly ChatNachricht[] = [
{ von: "ich", foto: true, text: "Guten Morgen! ☀️ Unser neues Roggenbrot ist gerade aus dem Ofen. Darf das auf die Startseite?", zeit: "07:52" },
{ von: "team", text: "Und wie das darf! 😍 Das Foto ist so knusprig, ich rieche es bis hierher.", zeit: "08:41" },
{ von: "ich", sprache: "0:12", zeit: "08:44" },
{
von: "team",
text: "Alles notiert: Brot ganz nach oben, Preis dazu, Öffnungszeiten etwas größer. Schauen Sie mal in die Vorschau! 🥖",
zeit: "10:20",
},
{ von: "ich", text: "Sieht super aus! Morgen gibt’s Croissants fürs ganze Team. 🥐", zeit: "10:26" },
{ von: "team", text: "Dann stellen wir schon mal den Kaffee an. ☕", zeit: "10:31" },
];
export const ABSENDER = { ich: "Du", team: "Kenso" } as const;
/** Wohin der Zeiger zeigt (Knöpfe des Eingabebereichs) */
export type ChatZiel = "ruhe" | "anhang" | "senden" | "mikro";
export type ChatZustand = {
/** Anzahl der sichtbaren Nachrichten (0 bis 6) */
sichtbar: number;
/** Text im Eingabefeld (wird Zeichen für Zeichen getippt) */
eingabe: string;
/** Foto liegt als Anhang über dem Eingabefeld */
anhang: boolean;
/** Sprachaufnahme läuft: Sekunden, sonst null */
aufnahme: number | null;
/** Kamera hineingezoomt */
zoom: boolean;
/** Ziel des Zeigers und Zähler der Klicks (jede neue Zahl = eine Klickwelle) */
zeiger: ChatZiel;
klick: number;
/** Nachrichten blenden aus (Übergang zur nächsten Runde) */
aus: boolean;
runde: number;
statisch: boolean;
};
type Schritt = {
dauer: number;
sichtbar: number;
zoom: boolean;
/** Index der Nachricht, deren Text in dieser Zeit getippt wird */
tippt?: number;
/** Index der Nachricht, deren Text fertig im Feld steht */
fertig?: number;
anhang?: boolean;
aufnahme?: boolean;
zeiger?: ChatZiel;
klick?: boolean;
aus?: boolean;
};
/** Millisekunden je getipptem Zeichen */
const TIPPEN_MS = 20;
const tippdauer = (i: number) => (NACHRICHTEN[i].text?.length ?? 0) * TIPPEN_MS + 250;
const SCHRITTE: readonly Schritt[] = [
{ dauer: 700, sichtbar: 0, zoom: false },
{ dauer: 1100, sichtbar: 0, zoom: true },
// Foto anhängen
{ dauer: 700, sichtbar: 0, zoom: true, zeiger: "anhang" },
{ dauer: 500, sichtbar: 0, zoom: true, zeiger: "anhang", klick: true, anhang: true },
{ dauer: tippdauer(0), sichtbar: 0, zoom: true, anhang: true, tippt: 0 },
{ dauer: 650, sichtbar: 0, zoom: true, anhang: true, fertig: 0, zeiger: "senden" },
{ dauer: 2400, sichtbar: 1, zoom: true, zeiger: "senden", klick: true },
// Kenso antwortet
{ dauer: 2800, sichtbar: 2, zoom: true },
// Sprachnachricht
{ dauer: 600, sichtbar: 2, zoom: true, zeiger: "mikro" },
{ dauer: 1700, sichtbar: 2, zoom: true, zeiger: "mikro", klick: true, aufnahme: true },
{ dauer: 1400, sichtbar: 3, zoom: true, zeiger: "mikro", klick: true },
{ dauer: 3400, sichtbar: 4, zoom: true },
// noch eine kurze Antwort von Marlene
{ dauer: tippdauer(4), sichtbar: 4, zoom: true, tippt: 4 },
{ dauer: 550, sichtbar: 4, zoom: true, fertig: 4, zeiger: "senden" },
{ dauer: 1800, sichtbar: 5, zoom: true, zeiger: "senden", klick: true },
{ dauer: 2600, sichtbar: 6, zoom: true },
// herauszoomen, kurz stehen lassen, ausblenden
{ dauer: 3000, sichtbar: 6, zoom: false },
{ dauer: 700, sichtbar: 6, zoom: false, aus: true },
];
const DAUERN = SCHRITTE.map((s) => s.dauer);
/** Gesamtdauer einer Runde in Millisekunden (für Tests und die Feinabstimmung der Bühne). */
export const CHAT_RUNDE_MS = DAUERN.reduce((a, b) => a + b, 0);
/** Anfangszustand: leerer Chat, nicht gezoomt (die Bühne zeigt das sofort, bevor der Taktgeber geladen ist). */
export const CHAT_START: ChatZustand = {
sichtbar: 0,
eingabe: "",
anhang: false,
aufnahme: null,
zoom: false,
zeiger: "ruhe",
klick: 0,
aus: false,
runde: 0,
statisch: false,
};
/** Endzustand ohne Bewegung: alles da, hineingezoomt, leeres Eingabefeld. */
const ENDE: Omit<ChatZustand, "runde"> = { ...CHAT_START, sichtbar: NACHRICHTEN.length, zoom: true, statisch: true };
/**
* Taktgeber der Demo. `laeuft` = Station aktiv, im Bild, Tab sichtbar, Bewegung erlaubt.
* Läuft sie nicht, steht die Demo im Anfangszustand (kein Timer); mit `statisch` im Endzustand.
*/
export const useChatAblauf = (laeuft: boolean, statisch: boolean): ChatZustand => {
const aktivLauf = laeuft && !statisch;
const { schritt, runde } = useAblauf(aktivLauf, DAUERN, true);
const s = SCHRITTE[Math.min(schritt, SCHRITTE.length - 1)];
// Tippen und Aufnahmezeit: innerhalb eines Schritts im Takt weiterzählen
const [takt, setTakt] = useState(0);
const zaehlt = aktivLauf && (s.tippt !== undefined || s.aufnahme === true);
useEffect(() => {
setTakt(0);
if (!zaehlt) return;
const start = Date.now();
const t = window.setInterval(() => setTakt(Date.now() - start), 40);
return () => window.clearInterval(t);
}, [zaehlt, schritt, runde]);
let eingabe = "";
if (s.tippt !== undefined) {
const text = NACHRICHTEN[s.tippt].text ?? "";
eingabe = Array.from(text).slice(0, Math.floor(takt / TIPPEN_MS)).join("");
} else if (s.fertig !== undefined) {
eingabe = NACHRICHTEN[s.fertig].text ?? "";
}
// Klickzähler: wächst mit jedem Schritt, der klickt (für die Klickwelle des Zeigers)
const klicks = SCHRITTE.slice(0, schritt + 1).filter((x) => x.klick).length;
const klick = s.klick ? runde * 10 + klicks : 0;
const aufnahme = s.aufnahme ? Math.min(12, 1 + Math.floor(takt / 140)) : null;
// Nur ein neues Objekt, wenn sich wirklich etwas ändert (der Takt läuft schneller als die Buchstaben)
return useMemo<ChatZustand>(
() =>
statisch
? { ...ENDE, runde }
: {
sichtbar: s.sichtbar,
eingabe,
anhang: s.anhang === true,
aufnahme,
zoom: s.zoom,
zeiger: s.zeiger ?? "ruhe",
klick,
aus: s.aus === true,
runde,
statisch: false,
},
[statisch, runde, s, eingabe, aufnahme, klick],
);
};
+383
View File
@@ -0,0 +1,383 @@
/*
* Chat-Demo (Station Chat): Live-Ebene über der Aufnahme des leeren Portal-Chats. Alle Klassen mit Präfix ch-.
* Desktop 1512 x 893 mit Kamera-Zoom, Handy 402 x 874. Farben und Formen wie im Portal (kundenbereich.css, chat-composer.css).
* Grundzustand statisch; Bewegung nur transform/opacity hinter prefers-reduced-motion: no-preference.
*/
.ch-seite {
position: absolute;
inset: 0;
overflow: hidden;
background: #0b0712;
}
/* Die Kamera: hineingezoomt füllt die Eingabezeile (822 px breit) das MacBook fast ganz, unten bleibt sie im Bild.
In der Station Chat gleitet das iPhone zur Seite (kundenportal.css), damit nichts das Gespräch verdeckt. */
.ch-kamera {
position: absolute;
left: 0;
top: 0;
width: 1512px;
height: 893px;
transform-origin: 0 0;
transform: scale(1) translate(0px, 0px);
}
.ch-kamera[data-zoom="ja"] {
transform: scale(1.8) translate(-472px, -246px);
}
.ch-kulisse {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
}
.ch-handy {
position: absolute;
inset: 0;
pointer-events: none;
}
.ch-ebene {
position: absolute;
inset: 0;
pointer-events: none;
color: #fff;
font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 16px;
line-height: 1.4;
text-align: left;
letter-spacing: normal;
-webkit-font-smoothing: antialiased;
user-select: none;
}
.ch-ebene *,
.ch-ebene *::before,
.ch-ebene *::after {
box-sizing: border-box;
margin: 0;
}
/* ── Nachrichten ─────────────────────────────────────────────────────── */
.ch-ausschnitt {
position: absolute;
overflow: hidden;
}
.ch-liste {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.ch-liste[data-aus="ja"] {
opacity: 0;
}
.ch-blase {
max-width: 76%;
padding: 11px 15px 10px;
border-radius: 16px;
font-size: 17px;
word-break: break-word;
}
[data-variante="handy"] .ch-blase {
max-width: 84%;
font-size: 15px;
}
.ch-blase--ich {
align-self: flex-end;
background: rgb(157 61 254 / 0.26);
border: 1px solid rgb(157 61 254 / 0.5);
border-bottom-right-radius: 4px;
transform-origin: 100% 100%;
}
.ch-blase--team {
align-self: flex-start;
background: rgb(255 255 255 / 0.06);
border: 1px solid rgb(255 255 255 / 0.16);
border-bottom-left-radius: 4px;
transform-origin: 0 100%;
}
.ch-text {
display: block;
}
.ch-meta {
display: block;
margin-top: 4px;
font-size: 12.5px;
opacity: 0.6;
}
.ch-blase--foto {
padding: 6px 6px 9px;
}
.ch-blase--foto .ch-text,
.ch-blase--foto .ch-meta {
padding: 0 9px;
}
.ch-blase--foto .ch-text {
margin-top: 8px;
}
.ch-foto {
display: block;
width: auto;
height: 236px;
border-radius: 11px;
}
[data-variante="handy"] .ch-foto {
width: 100%;
height: auto;
}
/* Sprachnachricht wie im Portal: Abspielknopf, Wort, Leiste, Dauer */
.ch-audio {
display: flex;
align-items: center;
gap: 10px;
width: 260px;
max-width: 100%;
padding: 2px 0;
}
.ch-audio__play {
flex: none;
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: #fff;
color: #17102a;
}
.ch-audio__play svg {
width: 16px;
height: 16px;
}
.ch-audio__mitte {
flex: 1;
display: grid;
gap: 7px;
}
.ch-audio__wort {
font-size: 12px;
font-weight: 600;
opacity: 0.8;
}
.ch-audio__leiste {
display: block;
height: 6px;
border-radius: 999px;
background: rgb(255 255 255 / 0.2);
}
.ch-audio__zeit {
flex: none;
font-size: 12px;
font-variant-numeric: tabular-nums;
opacity: 0.8;
}
/* ── Eingabebereich ──────────────────────────────────────────────────── */
/* Anhang über dem Eingabefeld (wie die Vorschau im Portal vor dem Senden) */
.ch-anhang {
position: absolute;
width: 64px;
height: 64px;
border-radius: 12px;
border: 1px solid rgb(255 255 255 / 0.16);
overflow: hidden;
transform-origin: 0 100%;
}
.ch-anhang img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Getippter Text: deckt das Platzhalter-Feld der Aufnahme ab; ist der Text länger, bleibt sein Ende sichtbar */
.ch-feld {
position: absolute;
display: flex;
align-items: center;
justify-content: flex-end;
overflow: hidden;
background: #171719;
border: 1px solid rgb(255 255 255 / 0.16);
font-size: 16px;
white-space: nowrap;
}
[data-variante="handy"] .ch-feld {
background: #1b1b1d;
}
.ch-feld__text {
flex: none;
min-width: 100%;
}
.ch-feld__strich {
display: inline-block;
width: 2px;
height: 1.15em;
margin-left: 1px;
vertical-align: -0.2em;
background: #c9a2ff;
}
.ch-senden {
position: absolute;
display: grid;
place-items: center;
border-radius: 999px;
background: #9d3dfe;
color: #fff;
font-size: 16px;
font-weight: 700;
}
.ch-senden svg {
width: 19px;
height: 19px;
}
/* Sprachaufnahme: rote Leiste statt Anhang, Textfeld und Mikrofon */
.ch-aufnahme {
position: absolute;
display: flex;
align-items: center;
gap: 10px;
padding: 0 6px 0 16px;
border-radius: 999px;
border: 1px solid rgb(248 113 113 / 0.45);
background: #1e1416;
font-size: 15px;
}
.ch-aufnahme__punkt {
flex: none;
width: 10px;
height: 10px;
border-radius: 50%;
background: #f87171;
}
.ch-aufnahme__wort {
flex: none;
font-size: 13px;
color: rgb(255 255 255 / 0.6);
}
.ch-aufnahme__zeit {
flex: none;
min-width: 36px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.ch-aufnahme__pegel {
flex: 1;
min-width: 0;
height: 24px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
overflow: hidden;
color: #fca5a5;
}
.ch-aufnahme__pegel i {
flex: none;
width: 3px;
height: 100%;
border-radius: 2px;
background: currentColor;
transform: scaleY(var(--ch-h, 0.5));
}
.ch-aufnahme__stopp {
flex: none;
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
}
.ch-aufnahme__stopp::before {
content: "";
width: 12px;
height: 12px;
border-radius: 3px;
background: #f87171;
}
/* ── Bewegung ────────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: no-preference) {
.ch-kamera {
transition: transform 1150ms cubic-bezier(0.65, 0, 0.35, 1);
}
/* neue Nachricht: ploppt aus ihrer Ecke auf, mit einem kleinen Nachfedern */
.ch-blase {
animation: ch-auf 560ms cubic-bezier(0.2, 0.9, 0.3, 1.25) both;
}
.ch-liste {
transition: opacity 600ms ease;
}
.ch-anhang {
animation: ch-auf 420ms cubic-bezier(0.2, 0.9, 0.3, 1.25) both;
}
.ch-feld__strich {
animation: ch-blinken 1s steps(1) infinite;
}
.ch-senden[data-druck="ja"] {
animation: ch-druck 320ms ease-out both;
}
.ch-aufnahme {
animation: ch-einblenden 220ms ease-out both;
}
.ch-aufnahme__punkt {
animation: ch-pulsieren 1.2s ease-in-out infinite;
}
.ch-aufnahme__pegel i {
animation: ch-pegel 520ms ease-in-out infinite alternate;
}
}
@keyframes ch-auf {
from {
opacity: 0;
transform: translateY(14px) scale(0.86);
}
45% {
opacity: 1;
}
to {
opacity: 1;
transform: none;
}
}
@keyframes ch-blinken {
50% {
opacity: 0;
}
}
@keyframes ch-druck {
40% {
transform: scale(0.9);
}
to {
transform: none;
}
}
@keyframes ch-einblenden {
from {
opacity: 0;
}
}
@keyframes ch-pulsieren {
50% {
opacity: 0.25;
}
}
@keyframes ch-pegel {
from {
transform: scaleY(0.15);
}
to {
transform: scaleY(var(--ch-h, 0.5));
}
}
+64
View File
@@ -0,0 +1,64 @@
/*
* Gemeinsame Teile der Funktions-Demos: Zeiger und Klickwelle.
* Grundzustand statisch; Gleiten und Welle nur ohne prefers-reduced-motion (nur transform/opacity).
*/
.dz-zeiger {
position: absolute;
left: 0;
top: 0;
z-index: 30;
pointer-events: none;
opacity: 1;
}
.dz-zeiger[data-sichtbar="nein"] {
opacity: 0;
}
.dz-pfeil {
display: block;
transform: scale(var(--dz-g, 1.6));
transform-origin: 0 0;
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 0.35));
}
.dz-finger {
position: absolute;
left: -14px;
top: -14px;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgb(255 255 255 / 0.55);
border: 2px solid rgb(0 0 0 / 0.25);
box-shadow: 0 2px 8px rgb(0 0 0 / 0.25);
}
.dz-welle {
position: absolute;
left: calc(-24px * var(--dz-g, 1.6));
top: calc(-24px * var(--dz-g, 1.6));
width: calc(48px * var(--dz-g, 1.6));
height: calc(48px * var(--dz-g, 1.6));
border-radius: 50%;
border: calc(3px * var(--dz-g, 1.6)) solid rgb(229 37 28 / 0.85);
opacity: 0;
}
@media (prefers-reduced-motion: no-preference) {
.dz-zeiger {
transition:
transform 750ms cubic-bezier(0.45, 0, 0.2, 1),
opacity 300ms ease;
}
.dz-welle {
animation: dz-welle 650ms ease-out both;
}
}
@keyframes dz-welle {
from {
opacity: 1;
transform: scale(0.3);
}
to {
opacity: 0;
transform: scale(1.4);
}
}
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useRef, useState, useSyncExternalStore, type RefObject } from "react";
import "./gemeinsam.css";
/**
* Gemeinsame Bausteine der Funktions-Demos im Abschnitt Kundenportal (Spezifikation 2026-10-04-funktionen-demos).
* Jede Demo zeigt eine erfundene Kunden-Website im MacBook (1512 x 893) oder im iPhone (402 x 874,
* Safe Area oben 59 / unten 34) und spielt einen kurzen Ablauf ab, solange ihr Schritt aktiv und sichtbar ist.
* Bewegung nur mit transform/opacity und nur ohne prefers-reduced-motion; sonst zeigt jede Demo ihren Endzustand.
*/
export type DemoVariante = "desktop" | "handy";
/** Designgrößen der Bildschirmflächen (wie DeviceFrames: MacBook-Safari-Fläche und iPhone-Inhalt). */
export const FLAECHE = {
desktop: { breite: 1512, hoehe: 893 },
handy: { breite: 402, hoehe: 874, oben: 59, unten: 34 },
} as const;
// ── Medienabfragen ─────────────────────────────────────────────────────────
const abonniere = (abfrage: string) => (melden: () => void) => {
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const mq = window.matchMedia(abfrage);
mq.addEventListener?.("change", melden);
return () => mq.removeEventListener?.("change", melden);
};
const lies = (abfrage: string, standard: boolean) => () =>
typeof window !== "undefined" && window.matchMedia ? window.matchMedia(abfrage).matches : standard;
/** true, wenn Bewegung erlaubt ist (prefers-reduced-motion: no-preference). Ohne matchMedia: keine Bewegung. */
export const useBewegungErlaubt = () => {
const abfrage = "(prefers-reduced-motion: no-preference)";
return useSyncExternalStore(abonniere(abfrage), lies(abfrage, false), () => false);
};
/** true bei einer Maus (Hover-Effekte nur dann, Spezifikation 8.4). */
export const useFeinerZeiger = () => {
const abfrage = "(hover: hover) and (pointer: fine)";
return useSyncExternalStore(abonniere(abfrage), lies(abfrage, false), () => false);
};
/** true, solange der Browser-Tab sichtbar ist (im Hintergrund läuft nichts). */
export const useTabSichtbar = () =>
useSyncExternalStore(
(melden) => {
if (typeof document === "undefined") return () => {};
document.addEventListener("visibilitychange", melden);
return () => document.removeEventListener("visibilitychange", melden);
},
() => (typeof document === "undefined" ? true : document.visibilityState !== "hidden"),
() => true,
);
/** true, solange das Element zu mindestens `anteil` im Bild ist. Ohne IntersectionObserver: false. */
export const useImBild = (ref: RefObject<Element | null>, anteil = 0.35) => {
const [imBild, setImBild] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const beobachter = new IntersectionObserver(([e]) => setImBild(e.isIntersecting), { threshold: anteil });
beobachter.observe(el);
return () => beobachter.disconnect();
}, [ref, anteil]);
return imBild;
};
// ── Ablauf ─────────────────────────────────────────────────────────────────
/**
* Taktgeber einer Demo: geht die Schritte mit ihren Dauern (ms) durch, solange `laeuft`.
* Hört `laeuft` auf, springt der Ablauf auf Schritt 0 zurück (beim nächsten Mal beginnt die Geschichte von vorn).
* Nach dem letzten Schritt: mit `schleife` wieder Schritt 0, sonst bleibt der letzte stehen.
* Rückgabe: Index des aktuellen Schritts und ein Zähler der Durchläufe (für Neuaufbau, z. B. Heatmap leeren).
*/
export const useAblauf = (laeuft: boolean, dauern: readonly number[], schleife = true) => {
const [stand, setStand] = useState({ schritt: 0, runde: 0 });
const dauernRef = useRef(dauern);
dauernRef.current = dauern;
useEffect(() => {
if (!laeuft) {
setStand((s) => (s.schritt === 0 ? s : { schritt: 0, runde: s.runde + 1 }));
return;
}
const liste = dauernRef.current;
const letzter = liste.length - 1;
if (stand.schritt >= letzter && !schleife) return;
const t = window.setTimeout(() => {
setStand((s) =>
s.schritt >= letzter ? { schritt: 0, runde: s.runde + 1 } : { schritt: s.schritt + 1, runde: s.runde },
);
}, liste[stand.schritt] ?? 1000);
return () => window.clearTimeout(t);
}, [laeuft, stand.schritt, schleife]);
return stand;
};
/**
* Ob eine Demo gerade abspielen darf: Schritt aktiv, im Bild, Tab sichtbar und Bewegung erlaubt.
* `statisch` = keine Bewegung erlaubt: dann zeigt die Demo ihren Endzustand.
*/
export const useDemoLauf = (aktiv: boolean, imBild = true) => {
const bewegung = useBewegungErlaubt();
const tab = useTabSichtbar();
return { laeuft: aktiv && imBild && tab && bewegung, statisch: !bewegung };
};
// ── Zeiger und Klickwelle ─────────────────────────────────────────────────
type ZeigerProps = {
/** Spitze des Zeigers in Designpunkten der Fläche */
x: number;
y: number;
/** maus = Pfeil (MacBook), finger = runder Tipp-Punkt (iPhone) */
art?: "maus" | "finger";
/** Jede Änderung dieser Zahl löst eine Klickwelle aus (0 = noch kein Klick) */
klick?: number;
sichtbar?: boolean;
/** Maßstab des Zeigers (1 = 30 Designpunkte hoch); im MacBook wirkt 1.6 natürlich, weil alles verkleinert ist */
groesse?: number;
};
/** Ein dekorativer Mauszeiger, der per transform zu (x, y) gleitet; Klicks zeigen eine kurze Welle. */
export const DemoZeiger = ({ x, y, art = "maus", klick = 0, sichtbar = true, groesse = 1.6 }: ZeigerProps) => (
<div
className="dz-zeiger"
data-art={art}
data-sichtbar={sichtbar ? "ja" : "nein"}
style={{ transform: `translate(${x}px, ${y}px)`, ["--dz-g" as string]: groesse }}
aria-hidden="true"
>
{klick > 0 ? <span key={klick} className="dz-welle" /> : null}
{art === "maus" ? (
<svg className="dz-pfeil" viewBox="0 0 20 30" width="20" height="30">
<path
d="M1.5 1.5v22.2l5.4-5.1 3.7 8.6 3.6-1.6-3.6-8.4h7.6z"
fill="#111"
stroke="#fff"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
) : (
<span className="dz-finger" />
)}
</div>
);
// ── Hilfen ─────────────────────────────────────────────────────────────────
/** Deterministischer Zufall (mulberry32): gleiche Saat, gleiche Folge – Demos sehen bei jedem Laden gleich aus. */
export const zufall = (saat: number) => {
let a = saat >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
};
/** Zahl im deutschen Format (1.284). */
export const zahl = (n: number) => new Intl.NumberFormat("de-DE").format(n);
+180
View File
@@ -0,0 +1,180 @@
/**
* Heatmap-Zeichnung wie im echten Overlay der WEBklar-Analyse (Kundenportal analytics/client/h.js):
* jeder Klick ist ein weicher Fleck (radialer Verlauf, Deckkraft 0,25), überlagerte Flecken werden über
* die Farbskala transparent -> blau -> grün -> gelb -> rot eingefärbt. So sieht die Demo aus wie das Produkt.
*/
/** Klickpunkt relativ zur Fläche (0 bis 1). */
export type HeatPunkt = { x: number; y: number };
let skala: Uint8ClampedArray | null = null;
const farbskala = () => {
if (skala) return skala;
const c = document.createElement("canvas");
c.width = 256;
c.height = 1;
const g = c.getContext("2d");
if (!g) return null;
const lg = g.createLinearGradient(0, 0, 256, 0);
lg.addColorStop(0.25, "rgb(0,0,255)");
lg.addColorStop(0.55, "rgb(0,255,0)");
lg.addColorStop(0.85, "rgb(255,255,0)");
lg.addColorStop(1, "rgb(255,0,0)");
g.fillStyle = lg;
g.fillRect(0, 0, 256, 1);
skala = g.getImageData(0, 0, 256, 1).data;
return skala;
};
/**
* Zeichnet alle Punkte neu auf die Leinwand (ihre width/height in Pixeln bestimmen die Auflösung).
* radius in Leinwand-Pixeln. Ohne 2D-Kontext (jsdom) passiert nichts.
*/
export const zeichneHeatmap = (leinwand: HTMLCanvasElement, punkte: readonly HeatPunkt[], radius: number) => {
const g = leinwand.getContext("2d", { willReadFrequently: true });
const farben = farbskala();
if (!g || !farben) return;
const b = leinwand.width;
const h = leinwand.height;
g.clearRect(0, 0, b, h);
for (const p of punkte) {
const x = p.x * b;
const y = p.y * h;
const rg = g.createRadialGradient(x, y, 0, x, y, radius);
rg.addColorStop(0, "rgba(0,0,0,0.25)");
rg.addColorStop(1, "rgba(0,0,0,0)");
g.fillStyle = rg;
g.fillRect(x - radius, y - radius, radius * 2, radius * 2);
}
const bild = g.getImageData(0, 0, b, h);
const d = bild.data;
for (let i = 3; i < d.length; i += 4) {
const a = d[i];
if (!a) continue;
const k = Math.min(255, a * 3) * 4;
d[i - 3] = farben[k];
d[i - 2] = farben[k + 1];
d[i - 1] = farben[k + 2];
d[i] = Math.min(200, 60 + a);
}
g.putImageData(bild, 0, 0);
};
/** Rechteck einer Klickzone relativ zur Fläche (0 bis 1). */
export type ZonenRechteck = { x: number; y: number; b: number; h: number };
/**
* Verteilt `anzahl` Punkte in einer Zone: dicht zur Mitte hin (Summe zweier Zufallszahlen), wie echte Klicks
* auf einen Knopf. `zufall` liefert Zahlen in [0, 1).
*/
export const punkteInZone = (zone: ZonenRechteck, anzahl: number, zufall: () => number): HeatPunkt[] => {
const punkte: HeatPunkt[] = [];
for (let i = 0; i < anzahl; i++) {
const rx = (zufall() + zufall()) / 2;
const ry = (zufall() + zufall()) / 2;
punkte.push({ x: zone.x + rx * zone.b, y: zone.y + ry * zone.h });
}
return punkte;
};
// ── Heatmap mit einzelnen, weich erscheinenden Klickpunkten ───────────────────────────
/** Ein Klick der Demo: Lage relativ zur Fläche (0 bis 1) und Stärke 0 (unsichtbar) bis 1 (voll). */
export type KlickPunkt = HeatPunkt & { staerke: number };
/** Deckkraft eines Flecks wie im echten Overlay (h.js). Ein frischer Demo-Klick zählt etwas stärker, damit man ihn sieht. */
const FLECK_ALPHA = 0.25;
const KLICK_GEWICHT = 1.6;
const fleck = (g: CanvasRenderingContext2D, x: number, y: number, radius: number, alpha: number) => {
const rg = g.createRadialGradient(x, y, 0, x, y, radius);
rg.addColorStop(0, `rgba(0,0,0,${alpha})`);
rg.addColorStop(1, "rgba(0,0,0,0)");
g.fillStyle = rg;
g.fillRect(x - radius, y - radius, radius * 2, radius * 2);
};
/** Färbt die Deckkraft-Stufen eines Bildausschnitts mit der Farbskala ein (wie h.js). */
const faerbe = (d: Uint8ClampedArray, farben: Uint8ClampedArray) => {
for (let i = 3; i < d.length; i += 4) {
const a = d[i];
if (!a) continue;
const k = Math.min(255, a * 3) * 4;
d[i - 3] = farben[k];
d[i - 2] = farben[k + 1];
d[i - 1] = farben[k + 2];
d[i] = Math.min(200, 60 + a);
}
};
const neueLeinwand = (b: number, h: number) => {
const c = document.createElement("canvas");
c.width = Math.max(1, b);
c.height = Math.max(1, h);
return c;
};
/**
* Heatmap-Ebene der Demo: der Grundbestand (Klicks der letzten 30 Tage) wird einmal als Deckkraft-Karte gezeichnet;
* einzelne Demo-Klicks lassen sich danach mit beliebiger Stärke dazulegen. Ändert sich nur ein Klick, wird nur sein
* kleiner Ausschnitt neu berechnet (ein paar Tausend Pixel) – so kann ein neuer Punkt über mehrere Bilder weich
* erscheinen, ohne die ganze Fläche je Bild neu zu zeichnen. Ohne 2D-Kontext (jsdom) passiert nichts.
*/
export const heatEbene = (sichtbar: HTMLCanvasElement, radius: number) => {
const b = sichtbar.width;
const h = sichtbar.height;
const basis = neueLeinwand(b, h);
const gBasis = basis.getContext("2d", { willReadFrequently: true });
const gSicht = sichtbar.getContext("2d", { willReadFrequently: true });
let klicks: readonly KlickPunkt[] = [];
/** Deckkraft-Karte eines Ausschnitts: Grundbestand plus alle Klicks, die hineinreichen. */
const karte = (x0: number, y0: number, w: number, hh: number) => {
const teil = neueLeinwand(w, hh);
const g = teil.getContext("2d", { willReadFrequently: true });
if (!g || !gBasis) return null;
g.putImageData(gBasis.getImageData(x0, y0, w, hh), 0, 0);
for (const k of klicks) {
if (k.staerke <= 0) continue;
const x = k.x * b - x0;
const y = k.y * h - y0;
if (x < -radius || y < -radius || x > w + radius || y > hh + radius) continue;
fleck(g, x, y, radius, Math.min(1, FLECK_ALPHA * KLICK_GEWICHT * k.staerke));
}
return g.getImageData(0, 0, w, hh);
};
const zeige = (x0: number, y0: number, w: number, hh: number) => {
const farben = farbskala();
if (!gSicht || !farben || w <= 0 || hh <= 0) return;
const bild = karte(x0, y0, w, hh);
if (!bild) return;
faerbe(bild.data, farben);
gSicht.putImageData(bild, x0, y0);
};
return {
/** Grundbestand neu zeichnen (z. B. nach neuer Messung der Zonen) und alles neu einfärben. */
basis(punkte: readonly HeatPunkt[]) {
if (!gBasis) return;
gBasis.clearRect(0, 0, b, h);
for (const p of punkte) fleck(gBasis, p.x * b, p.y * h, radius, FLECK_ALPHA);
zeige(0, 0, b, h);
},
/** Klicks setzen; neu eingefärbt werden nur die Ausschnitte der Klicks, deren Stärke sich geändert hat. */
klicks(neu: readonly KlickPunkt[]) {
const alt = klicks;
klicks = neu;
const laenge = Math.max(alt.length, neu.length);
for (let i = 0; i < laenge; i++) {
const k = neu[i] ?? alt[i];
if (alt[i] && neu[i] && alt[i].staerke === neu[i].staerke && alt[i].x === neu[i].x && alt[i].y === neu[i].y) continue;
const x0 = Math.max(0, Math.floor(k.x * b - radius - 1));
const y0 = Math.max(0, Math.floor(k.y * h - radius - 1));
const x1 = Math.min(b, Math.ceil(k.x * b + radius + 1));
const y1 = Math.min(h, Math.ceil(k.y * h + radius + 1));
zeige(x0, y0, x1 - x0, y1 - y0);
}
},
};
};
@@ -0,0 +1,91 @@
import type { FocusEvent } from "react";
import { useFeinerZeiger } from "../gemeinsam";
import { HEATMAP_START, type HeatmapZustand } from "./ablauf";
import { SUMME_KLICKS, TOP_ZONE, anteil, rangliste, zoneNach } from "./zonen";
import "./heatmap.css";
/**
* Die Diagnose unter dem MacBook (Demo Website-Analyse, Spezifikation 3.2): die vier meistgeklickten Zonen mit
* Balken und Prozent, darunter ein beobachtender Satz zur gewählten Zone (ohne Auswahl: zur meistgeklickten).
* Normaler Seitenmaßstab mit den Seitentoken, funktioniert hell und dunkel. Die Zeilen sind Knöpfe:
* Fokus, Hover oder Klick wählen die Zone, auf der Website erscheint dann der Rahmen.
* `kompakt` ist für die Handy-Karte: Satz oben, nur die drei meistgeklickten Zonen.
*/
type Props = { zustand?: HeatmapZustand; kompakt?: boolean };
const zahl = (n: number) => new Intl.NumberFormat("de-DE").format(n);
/** Tastaturfokus? (Mausklicks sollen nicht doppelt wählen.) Wo :focus-visible fehlt, zählt jeder Fokus. */
const tastaturFokus = (el: HTMLElement) => {
try {
return el.matches(":focus-visible");
} catch {
return true;
}
};
const HeatmapDiagnose = ({ zustand = HEATMAP_START, kompakt = false }: Props) => {
const fein = useFeinerZeiger();
const zeilen = rangliste(kompakt ? 3 : 4);
const gewaehlt = zoneNach(zustand.auswahl) ?? TOP_ZONE;
const spitze = zeilen[0].klicks;
return (
<section className={`hmd${kompakt ? " hmd--kompakt" : ""}`} aria-label="Diagnose" data-auswahl={gewaehlt.id}>
<div className="hmd__kopf">
<p className="hmd__titel">Diagnose</p>
<span className="hmd__quelle">{kompakt ? "Beispieldaten" : "letzte 30 Tage · Beispieldaten"}</span>
<span className="hmd__summe">{zahl(SUMME_KLICKS)} Klicks</span>
</div>
<div className="hmd__koerper">
<ul className="hmd__liste" aria-label="Meistgeklickte Stellen der Seite">
{zeilen.map((z) => {
const an = zustand.auswahl === z.id;
return (
<li key={z.id}>
<button
type="button"
className="hmd__zeile"
aria-pressed={an}
onClick={() => zustand.waehle(!fein && an ? null : z.id)}
onFocus={(e: FocusEvent<HTMLButtonElement>) => {
if (tastaturFokus(e.currentTarget)) zustand.waehle(z.id);
}}
onBlur={() => {
if (an) zustand.waehle(null);
}}
onMouseEnter={fein ? () => zustand.waehle(z.id) : undefined}
onMouseLeave={fein ? () => zustand.waehle(null) : undefined}
>
<span className="hmd__name">
<span>{z.name}</span>
{z.tot ? <span className="hmd__tot">toter Klick</span> : null}
</span>
<span className="hmd__spur" aria-hidden="true">
<span className="hmd__balken" style={{ transform: `scaleX(${(z.klicks / spitze).toFixed(3)})` }} />
</span>
<span className="hmd__prozent">{anteil(z)}{"\u00a0"}%</span>
</button>
</li>
);
})}
</ul>
<div className="hmd__rechts">
<p className="hmd__satz" aria-live="polite" aria-atomic="true" data-tot={gewaehlt.tot ? "ja" : "nein"}>
{gewaehlt.satz}
</p>
{/* Farbskala der Heatmap (wie in heat.ts: blau, grün, gelb, rot) */}
<div className="hmd__legende" aria-hidden="true">
<span>wenig</span>
<span className="hmd__skala" />
<span>viel</span>
</div>
</div>
</div>
</section>
);
};
export default HeatmapDiagnose;
export { HeatmapDiagnose };
@@ -0,0 +1,730 @@
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type MouseEvent, type RefObject } from "react";
import { DemoZeiger, useBewegungErlaubt, useFeinerZeiger, zufall, type DemoVariante } from "../gemeinsam";
import { heatEbene, punkteInZone, type HeatPunkt, type KlickPunkt } from "../heat";
import { HEATMAP_START, KLICK_ZIELE, type HeatmapZustand } from "./ablauf";
import { FLAECHE_GROESSE, TOP_ZONE, ZONEN, ZONEN_LAYOUT, schildText, zoneNach, type Zone, type PunktRechteck } from "./zonen";
import "./heatmap.css";
/**
* Die erfundene Beispiel-Website „Boulderhalle Überhang“ (Demo Website-Analyse, Spezifikation 3.2) mit der
* Heatmap darüber. 'desktop' wird in 1512 x 893 gesetzt (MacBook), 'handy' in 402 x 874 (iPhone, oben 59 und
* unten 34 frei). Die Seite ist reine Dekoration: aria-hidden, keine fokussierbaren Elemente.
* Alle Bilder sind selbst gezeichnete SVG/CSS-Grafiken, nichts wird nachgeladen.
*/
/** `zustand` fehlt, solange der Taktgeber noch lädt: dann steht die Website im Anfangszustand da. */
type Props = { zustand?: HeatmapZustand; variante: DemoVariante };
// ── Routenbild: selbst gezeichnete Kletterwand ────────────────────────────────
type Farbe = "lime" | "orange" | "pink" | "cyan";
const FARBEN: Record<Farbe, { hell: string; dunkel: string }> = {
lime: { hell: "#c6f432", dunkel: "#8aa81a" },
orange: { hell: "#ff7a1a", dunkel: "#b8500a" },
pink: { hell: "#ff4f9a", dunkel: "#b82a6b" },
cyan: { hell: "#2ad1ff", dunkel: "#0e86ad" },
};
/** Formen von Klettergriffen um den Nullpunkt (etwa 70 breit). */
const FORMEN: Record<string, string> = {
a: "M-30 8C-35-14-10-33 14-29C35-25 38-2 30 14C22 31-25 33-30 8Z",
b: "M-35 2C-31-25 31-27 35 1C37 22 21 30 0 30C-22 30-37 23-35 2Z",
c: "M-37-8Q0-24 37-8Q42 13 0 15Q-42 13-37-8Z",
d: "M0-27C16-27 28-15 28 0C28 15 16 27 0 27C-16 27-28 15-28 0C-28-15-16-27 0-27Z",
e: "M-25 20C-33-4-8-31 14-27C35-23 31 8 23 22C9 31-14 31-25 20Z",
};
/** [x, y, Form, Größe, Drehung]; x und y als Anteil der Wand (0 bis 1). */
type Griff = readonly [number, number, string, number, number];
const ROUTEN: { farbe: Farbe; grad: string; griffe: readonly Griff[] }[] = [
{
farbe: "lime",
grad: "4",
griffe: [
[0.14, 0.84, "b", 1.1, -8],
[0.22, 0.72, "a", 0.9, 20],
[0.13, 0.6, "c", 1, -15],
[0.24, 0.47, "e", 0.95, 35],
[0.15, 0.34, "a", 0.9, -30],
[0.26, 0.22, "d", 0.9, 0],
[0.18, 0.1, "b", 1.15, 10],
],
},
{
farbe: "orange",
grad: "5",
griffe: [
[0.36, 0.86, "c", 1, 10],
[0.44, 0.74, "a", 1, -20],
[0.34, 0.62, "b", 0.9, 15],
[0.47, 0.5, "e", 1, -40],
[0.38, 0.37, "d", 0.85, 0],
[0.5, 0.25, "a", 0.95, 25],
[0.42, 0.12, "b", 1.1, -5],
],
},
{
farbe: "pink",
grad: "6",
griffe: [
[0.6, 0.82, "a", 1, 5],
[0.68, 0.71, "e", 0.9, -25],
[0.57, 0.58, "c", 1.05, 20],
[0.7, 0.46, "b", 1, -10],
[0.61, 0.34, "a", 0.9, 40],
[0.73, 0.22, "d", 0.9, 0],
[0.64, 0.1, "c", 1.1, -12],
],
},
{
farbe: "cyan",
grad: "7",
griffe: [
[0.83, 0.85, "b", 1, -5],
[0.9, 0.72, "a", 0.9, 30],
[0.79, 0.6, "e", 1, -15],
[0.91, 0.48, "c", 0.95, 10],
[0.8, 0.36, "a", 1, -35],
[0.9, 0.24, "b", 0.9, 15],
[0.84, 0.11, "d", 1.1, 0],
],
},
];
/** Kleine graue Tritte zwischen den Routen: [x, y, Form, Größe, Drehung]. */
const TRITTE: readonly Griff[] = [
[0.06, 0.66, "d", 0.4, 0],
[0.31, 0.8, "c", 0.45, 20],
[0.52, 0.64, "d", 0.4, 0],
[0.47, 0.88, "d", 0.42, 0],
[0.77, 0.77, "c", 0.45, -15],
[0.96, 0.62, "d", 0.4, 0],
[0.31, 0.5, "d", 0.38, 0],
[0.55, 0.4, "c", 0.45, 30],
[0.74, 0.4, "d", 0.38, 0],
[0.07, 0.4, "c", 0.45, -20],
[0.34, 0.2, "d", 0.38, 0],
[0.56, 0.2, "d", 0.4, 0],
[0.96, 0.34, "c", 0.45, 25],
[0.06, 0.2, "d", 0.38, 0],
];
const Griffe = ({ griff, farbe, mass }: { griff: Griff; farbe: Farbe | "grau"; mass: number }) => {
const [x, y, form, groesse, drehung] = griff;
const f = farbe === "grau" ? { hell: "#b9b6ae", dunkel: "#7d7a73" } : FARBEN[farbe];
const pfad = FORMEN[form];
const g = groesse * mass;
return (
<g transform={`translate(${x} ${y})`}>
<path d={pfad} transform={`translate(${5 * g} ${8 * g}) rotate(${drehung}) scale(${g})`} fill="#000" opacity="0.38" />
<g transform={`rotate(${drehung}) scale(${g})`}>
<path d={pfad} fill={f.dunkel} transform="translate(0 4)" />
<path d={pfad} fill={f.hell} />
<ellipse cx="-9" cy="-10" rx="13" ry="6" fill="#fff" opacity="0.38" transform="rotate(-24)" />
</g>
</g>
);
};
/** Die Wand als SVG. b und h sind die Maße der Zeichnung, `klein` ist die Handy-Fassung (weniger, kleinere Griffe). */
const Routenwand = ({ b, h, klein }: { b: number; h: number; klein?: boolean }) => {
const id = useId().replace(/[^a-zA-Z0-9]/g, "");
// flache Wand (Desktop, 260 hoch): kleinere Griffe, sonst drängen sie sich
const mass = klein ? 0.5 : h < 300 ? 0.68 : 1;
const px = (x: number) => x * b;
const py = (y: number) => h * (0.17 + (y - 0.1) * 0.9);
const matte = h * 0.93;
return (
<svg
className="hm-wand__svg"
viewBox={`0 0 ${b} ${h}`}
preserveAspectRatio="xMidYMid slice"
focusable="false"
aria-hidden="true"
>
<defs>
<linearGradient id={`${id}w`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#3b3b41" />
<stop offset="1" stopColor="#222226" />
</linearGradient>
<pattern id={`${id}p`} width={klein ? 16 : 32} height={klein ? 16 : 32} patternUnits="userSpaceOnUse">
<circle cx={klein ? 8 : 16} cy={klein ? 8 : 16} r={klein ? 1.3 : 2.6} fill="#151517" />
</pattern>
</defs>
<rect width={b} height={h} fill={`url(#${id}w)`} />
<rect width={b} height={h} fill={`url(#${id}p)`} opacity="0.9" />
{/* Plattenfugen */}
<g stroke="#141416" strokeWidth={klein ? 1.5 : 3}>
<path d={`M${b * 0.25} 0V${matte}M${b * 0.5} 0V${matte}M${b * 0.75} 0V${matte}M0 ${h * 0.5}H${b}`} />
</g>
<g stroke="#4a4a52" strokeWidth={klein ? 0.8 : 1.5} opacity="0.6">
<path
d={`M${b * 0.25 + 3} 0V${matte}M${b * 0.5 + 3} 0V${matte}M${b * 0.75 + 3} 0V${matte}M0 ${h * 0.5 + 3}H${b}`}
/>
</g>
{/* Dach: die Wand hängt über */}
<path d={`M0 0H${b}V${h * 0.05}L0 ${h * 0.12}Z`} fill="#18181b" />
<path d={`M0 ${h * 0.12}L${b} ${h * 0.05}`} stroke="#4a4a52" strokeWidth={klein ? 1 : 2} />
{/* Matte */}
<rect y={matte} width={b} height={h - matte} fill="#1b2633" />
<path d={`M0 ${matte}H${b}`} stroke="#35506b" strokeWidth={klein ? 1.5 : 3} />
<path
d={`M${b * 0.2} ${matte}V${h}M${b * 0.4} ${matte}V${h}M${b * 0.6} ${matte}V${h}M${b * 0.8} ${matte}V${h}`}
stroke="#121a24"
strokeWidth={klein ? 1 : 2}
/>
{/* Tritte */}
{TRITTE.filter((_, i) => !klein || i % 2 === 0).map((t, i) => (
<Griffe key={`t${i}`} griff={[px(t[0]), py(t[1]), t[2], t[3], t[4]]} farbe="grau" mass={mass} />
))}
{ROUTEN.map((r) => {
const griffe = klein ? r.griffe.filter((_, i) => i % 2 === 0 || i === r.griffe.length - 1) : r.griffe;
const start = griffe[0];
const oben = griffe[griffe.length - 1];
return (
<g key={r.farbe}>
{griffe.map((g, i) => (
<g key={i}>
{/* Kreidespur */}
<ellipse
cx={px(g[0]) - 8 * mass}
cy={py(g[1]) - 14 * mass}
rx={30 * mass}
ry={15 * mass}
fill="#fff"
opacity="0.07"
/>
<Griffe griff={[px(g[0]), py(g[1]), g[2], g[3], g[4]]} farbe={r.farbe} mass={mass} />
</g>
))}
{/* Schwierigkeit am Startgriff */}
<g transform={`translate(${px(start[0]) - 54 * mass} ${py(start[1]) + 6 * mass})`}>
<circle r={19 * mass} fill="#f3efe6" />
<circle r={19 * mass} fill="none" stroke={FARBEN[r.farbe].hell} strokeWidth={5 * mass} />
<text
textAnchor="middle"
dominantBaseline="central"
fontSize={22 * mass}
fontWeight="700"
fill="#141414"
fontFamily="inherit"
>
{r.grad}
</text>
</g>
{/* Ring um den Zielgriff */}
<circle
cx={px(oben[0])}
cy={py(oben[1])}
r={46 * mass}
fill="none"
stroke={FARBEN[r.farbe].hell}
strokeWidth={3 * mass}
strokeDasharray={`${9 * mass} ${9 * mass}`}
opacity="0.8"
/>
</g>
);
})}
<rect width={b} height={h} fill="none" />
</svg>
);
};
// ── Kleine Zeichnungen ───────────────────────────────────────────────────────
const LogoZeichen = () => (
<svg className="hm-logo__zeichen" viewBox="0 0 56 56" aria-hidden="true" focusable="false">
<rect width="56" height="56" rx="16" fill="#c6f432" />
<path d="M13 44V22L33 11L44 19V44Z" fill="#141414" />
<path d="M13 22L33 11" stroke="#c6f432" strokeWidth="2" opacity="0.5" />
<circle cx="25" cy="33" r="4.5" fill="#ff7a1a" />
<circle cx="35" cy="24" r="3.6" fill="#ff4f9a" />
<circle cx="33" cy="38" r="3.4" fill="#2ad1ff" />
</svg>
);
const Pfeil = () => (
<svg className="hm-pfeil" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M5 12h14M13 6l6 6-6 6" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
const Handset = () => (
<svg className="hm-tel__symbol" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="6.5" y="2.5" width="11" height="19" rx="3" fill="none" stroke="currentColor" strokeWidth="2" />
<path d="M10.5 18.5h3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
// ── Messen und Klickpunkte ────────────────────────────────────────────────────
type Rechtecke = Record<string, PunktRechteck>;
const gleich = (a: Rechtecke, b: Rechtecke) =>
Object.keys(b).length === Object.keys(a).length &&
Object.entries(b).every(([id, r]) => {
const alt = a[id];
return (
alt && Math.abs(alt.x - r.x) < 0.5 && Math.abs(alt.y - r.y) < 0.5 && Math.abs(alt.b - r.b) < 0.5 && Math.abs(alt.h - r.h) < 0.5
);
});
/**
* Misst die Zonen-Rechtecke relativ zur Fläche in Designpunkten. getBoundingClientRect liefert Bildschirmpunkte;
* der Maßstab des Rahmens (transform: scale) ergibt sich aus Bildschirmbreite / Layoutbreite der Fläche.
* Ohne Messung (jsdom, ausgeblendet) gelten die festen Ersatz-Rechtecke.
*/
const useGemessen = (ref: RefObject<HTMLElement | null>, variante: DemoVariante) => {
const [rechtecke, setRechtecke] = useState<Rechtecke>(ZONEN_LAYOUT[variante]);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
let aktiv = true;
const messe = () => {
if (!aktiv) return;
const fl = el.getBoundingClientRect();
if (!fl.width || !el.offsetWidth) return;
const k = fl.width / el.offsetWidth;
const neu: Rechtecke = {};
el.querySelectorAll<HTMLElement>("[data-zone]").forEach((z) => {
const r = z.getBoundingClientRect();
neu[z.dataset.zone as string] = {
x: (r.left - fl.left) / k,
y: (r.top - fl.top) / k,
b: r.width / k,
h: r.height / k,
};
});
if (Object.keys(neu).length === 0) return;
setRechtecke((alt) => (gleich(alt, neu) ? alt : neu));
};
messe();
const beobachter = typeof ResizeObserver === "function" ? new ResizeObserver(messe) : null;
beobachter?.observe(el);
// Die Schrift lädt später und verschiebt Zeilenumbrüche: dann neu messen
document.fonts?.ready.then(messe).catch(() => {});
window.addEventListener("resize", messe);
return () => {
aktiv = false;
beobachter?.disconnect();
window.removeEventListener("resize", messe);
};
}, [ref, variante]);
return rechtecke;
};
/**
* Wie viele Punkte eine Zone bekommt. Die Skala der Heatmap sättigt schon bei zwei, drei überlagerten Flecken (rot);
* die Dichte steigt darum steil mit dem Klickanteil (Tageskarte = 1, nur sie wird durchgehend rot).
* Jede Zone bekommt aber mindestens so viele Punkte, dass ihre Klicks sichtbar sind (Wunsch des Inhabers 2026-10-04:
* nicht nur Punkte auf „Tageskarte kaufen“, auch z. B. die Telefonnummer). Große Zonen bekommen mehr Punkte.
*/
const punktAnzahl = (zone: Zone, r: PunktRechteck, variante: DemoVariante) => {
const anteil = zone.klicks / TOP_ZONE.klicks;
// Fläche eines Flecks in Designpunkten (Leinwand = halbe Fläche, der Radius gilt in Leinwandpunkten)
const fleck = Math.PI * (2 * LEINWAND[variante].radius) ** 2;
const flecke = Math.max(r.b * r.h, 2 * fleck) / (2 * fleck);
const obergrenze = Math.max(1, Math.round(zone.klicks / KLICKS_JE_PUNKT[variante]));
const minimum = Math.round(zone.klicks / MINDEST_KLICKS_JE_PUNKT[variante]);
return Math.min(obergrenze, Math.max(minimum, Math.round(anteil ** 1.8 * 8 * flecke)));
};
/** Alle Klickpunkte (Beispieldaten, feste Saat) je Zone, relativ zur Fläche. 85 % in der Zone, 15 % als Streuung am Rand. */
const erzeugePunkte = (rechtecke: Rechtecke, variante: DemoVariante): Record<string, HeatPunkt[]> => {
const { breite, hoehe } = FLAECHE_GROESSE[variante];
const aus: Record<string, HeatPunkt[]> = {};
ZONEN.forEach((zone, i) => {
const r = rechtecke[zone.id] ?? ZONEN_LAYOUT[variante][zone.id];
const rand = Math.min(r.b, r.h) * 0.3;
const zufallsfolge = zufall(1000 + i * 17 + (variante === "handy" ? 5 : 0));
const anzahl = punktAnzahl(zone, r, variante);
const kern = Math.round(anzahl * 0.85);
const innen = punkteInZone({ x: r.x / breite, y: r.y / hoehe, b: r.b / breite, h: r.h / hoehe }, kern, zufallsfolge);
const streu = punkteInZone(
{
x: (r.x - rand) / breite,
y: (r.y - rand) / hoehe,
b: (r.b + 2 * rand) / breite,
h: (r.h + 2 * rand) / hoehe,
},
anzahl - kern,
zufallsfolge,
);
// mischen, damit jede Teilmenge (die ersten n Punkte) gleichmäßig über die Zone verteilt ist
const alle = [...innen, ...streu].map((p) => ({
x: Math.min(1, Math.max(0, p.x)),
y: Math.min(1, Math.max(0, p.y)),
}));
for (let n = alle.length - 1; n > 0; n--) {
const m = Math.floor(zufallsfolge() * (n + 1));
[alle[n], alle[m]] = [alle[m], alle[n]];
}
aus[zone.id] = alle;
});
return aus;
};
const KLICKS_JE_PUNKT = { desktop: 4, handy: 7 } as const;
/** Untergrenze: höchstens so viele Klicks je gezeichnetem Punkt (Telefon 96 Klicks = 5 Punkte auf dem MacBook). */
const MINDEST_KLICKS_JE_PUNKT = { desktop: 18, handy: 30 } as const;
const LEINWAND = { desktop: { b: 756, h: 447, radius: 19 }, handy: { b: 201, h: 437, radius: 9 } } as const;
// ── Seite ───────────────────────────────────────────────────────────────────
const HeatmapSeite = ({ zustand = HEATMAP_START, variante }: Props) => {
const wurzel = useRef<HTMLDivElement>(null);
const leinwand = useRef<HTMLCanvasElement>(null);
const fein = useFeinerZeiger();
const rechtecke = useGemessen(wurzel, variante);
const punkte = useMemo(() => erzeugePunkte(rechtecke, variante), [rechtecke, variante]);
const { fuellung, statisch } = zustand;
const bewegung = useBewegungErlaubt();
const ebene = useRef<ReturnType<typeof heatEbene> | null>(null);
/** Stärke jedes Demo-Klicks, wie sie gerade gezeichnet ist (0 bis 1) */
const staerken = useRef<number[]>([]);
// Grundbestand (Klicks der letzten 30 Tage): neu zeichnen nur bei neuer Messung, nicht je Bild
useEffect(() => {
const c = leinwand.current;
if (!c) return;
ebene.current = heatEbene(c, LEINWAND[variante].radius);
const grund: HeatPunkt[] = [];
for (const z of ZONEN) {
const alle = punkte[z.id];
grund.push(...alle.slice(0, Math.round((fuellung[z.id] ?? 0) * alle.length)));
}
ebene.current.basis(grund);
staerken.current = [];
}, [punkte, fuellung, variante]);
// Demo-Klicks: jeder Klick ist genau EIN Punkt an der Stelle des Zeigers. Ein neuer Punkt wächst in gut 0,4 s weich
// herein, beim Neustart blenden alle Demo-Klicks ebenso weich aus. Neu eingefärbt wird je Bild nur der kleine
// Ausschnitt um die Punkte, die sich ändern; nach der Überblendung läuft nichts mehr.
const gesetzt = zustand.klickPunkte[variante].length;
useEffect(() => {
const e = ebene.current;
if (!e) return;
const { breite, hoehe } = FLAECHE_GROESSE[variante];
const lage = KLICK_ZIELE[variante].map((p) => ({ x: p.x / breite, y: p.y / hoehe }));
const von = lage.map((_, i) => staerken.current[i] ?? 0);
const nach = lage.map((_, i) => (i < gesetzt ? 1 : 0));
const setze = (t: number) => {
staerken.current = von.map((v, i) => v + (nach[i] - v) * t);
e.klicks(lage.map((p, i): KlickPunkt => ({ ...p, staerke: staerken.current[i] })));
};
if (!bewegung || von.every((v, i) => v === nach[i])) {
setze(1);
return;
}
const dauer = nach.some((n, i) => n < von[i]) ? 700 : 420;
let raf = 0;
const beginn = performance.now();
const bild = (jetzt: number) => {
const t = Math.min(1, (jetzt - beginn) / dauer);
setze(1 - (1 - t) ** 3); // sanft abbremsend
if (t < 1) raf = requestAnimationFrame(bild);
};
raf = requestAnimationFrame(bild);
return () => cancelAnimationFrame(raf);
}, [gesetzt, variante, bewegung, punkte, fuellung]);
const zoneUnter = (e: MouseEvent) => {
const z = (e.target as Element).closest?.("[data-zone]");
return z?.getAttribute("data-zone") ?? null;
};
const bewegt = (e: MouseEvent) => {
if (!zustand.maus) zustand.setzeMaus(true);
const id = zoneUnter(e);
if (id !== zustand.auswahl) zustand.waehle(id);
};
const verlassen = () => {
zustand.setzeMaus(false);
zustand.waehle(null);
};
const getippt = (e: MouseEvent) => {
const id = zoneUnter(e);
// Maus: wählen. Finger: wählen, nochmal tippen hebt die Auswahl auf.
zustand.waehle(!fein && id !== null && id === zustand.auswahl ? null : id);
};
const gewaehlt = zoneNach(zustand.auswahl);
const r = gewaehlt ? rechtecke[gewaehlt.id] : undefined;
const { breite } = FLAECHE_GROESSE[variante];
const zeiger = zustand.zeiger[variante];
// Schildchen über der Zone; im Kopf der Seite und auf dem Handy in der oberen Hälfte darunter (sonst verdeckt es die Überschrift)
// Große Zonen (das Routenbild) tragen das Schildchen im Inneren, sonst würde es Nachbarn verdecken.
const innen = r ? r.h >= (variante === "desktop" ? 250 : 140) : false;
const unten = !innen && r ? (variante === "desktop" ? r.y < 80 : r.y < 440) : false;
const lage = innen ? "innen" : unten ? "unter" : "ueber";
return (
<div
ref={wurzel}
className={`hm hm-seite hm-${variante}`}
data-variante={variante}
data-phase={zustand.phase}
data-laeuft={zustand.laeuft ? "ja" : "nein"}
aria-hidden="true"
onMouseMove={fein ? bewegt : undefined}
onMouseLeave={fein ? verlassen : undefined}
onClick={getippt}
>
{variante === "desktop" ? <Desktop /> : <Handy />}
<canvas
ref={leinwand}
className="hm-heat"
width={LEINWAND[variante].b}
height={LEINWAND[variante].h}
data-anteil={zustand.punkte}
/>
{gewaehlt && r ? (
<>
<div
key={`rahmen-${gewaehlt.id}`}
className="hm-rahmen"
style={{ left: r.x, top: r.y, width: r.b, height: r.h }}
/>
<div
key={`schild-${gewaehlt.id}`}
className="hm-schild"
data-lage={lage}
style={{
top: innen ? r.y + (variante === "desktop" ? 28 : 10) : unten ? r.y + r.h + 14 : r.y - 14,
...(r.x + r.b / 2 > breite * 0.55
? { right: breite - (r.x + r.b) + (innen ? (variante === "desktop" ? 28 : 10) : 0) }
: { left: r.x + (innen ? (variante === "desktop" ? 28 : 10) : 0) }),
}}
>
{schildText(gewaehlt)}
</div>
</>
) : null}
{statisch ? null : (
<DemoZeiger
x={zeiger.x}
y={zeiger.y}
klick={zeiger.klick}
sichtbar={zeiger.sichtbar}
art={variante === "handy" ? "finger" : "maus"}
groesse={variante === "handy" ? 1 : 1.9}
/>
)}
</div>
);
};
// ── Desktop-Fassung (1512 x 893) ─────────────────────────────────────────────
const Desktop = () => (
<>
<div className="hm-kopf">
<div className="hm-logo" data-zone="logo">
<LogoZeichen />
<span className="hm-logo__text">
<span className="hm-logo__klein">Boulderhalle</span>
<span className="hm-logo__gross">Überhang</span>
</span>
</div>
<div className="hm-nav">
<span className="hm-nav__punkt" data-zone="nav-routen">
Routen
</span>
<span className="hm-nav__punkt" data-zone="nav-kurse">
Kurse
</span>
<span className="hm-nav__punkt">Preise</span>
<span className="hm-nav__punkt">Öffnungszeiten</span>
</div>
<div className="hm-tel" data-zone="telefon">
<Handset />
<span>0221 4710 142</span>
</div>
</div>
<div className="hm-held">
<div className="hm-held__vor">
<span className="hm-held__punkt" />
Frisch geschraubt
</div>
<div className="hm-held__titel">
<span>
Heute <em>14</em>
</span>
<span>neue Boulder</span>
</div>
<div className="hm-knopf" data-zone="tageskarte">
<span>Tageskarte kaufen</span>
<Pfeil />
</div>
</div>
<div className="hm-wand" data-zone="routenbild">
<Routenwand b={640} h={260} />
</div>
{/* Öffnungszeiten liegen unter der Wand und links vom iPhone der Bühne (das deckt x > 1158 / y > 419 der Fläche) */}
<div className="hm-karte hm-oeffnung" data-zone="oeffnungszeiten">
<div className="hm-karte__titel">Öffnungszeiten</div>
<div className="hm-preise">
<div>
<span>Mo–Fr</span>
<b>10–23 Uhr</b>
</div>
<div>
<span>Sa, So</span>
<b>9–21 Uhr</b>
</div>
</div>
</div>
<div className="hm-karten">
<div className="hm-karte hm-karte--live" data-zone="auslastung">
<div className="hm-karte__kopf">
<span className="hm-live" />
Live-Auslastung
</div>
<div className="hm-zahl">
42<span>%</span>
</div>
<div className="hm-balken">
<span style={{ width: "42%" }} />
</div>
<div className="hm-karte__text">Gerade viel Platz an der Wand.</div>
</div>
<div className="hm-karte hm-karte--kurs" data-zone="schnupperkurs">
<div className="hm-marke hm-marke--cyan">Für Einsteiger</div>
<div className="hm-karte__titel">Schnupperkurs</div>
<div className="hm-karte__text">Samstag, 10 Uhr, 90 Minuten. Leihschuhe sind dabei.</div>
</div>
<div className="hm-karte" data-zone="preise">
<div className="hm-karte__titel">Preise</div>
<div className="hm-preise">
<div>
<span>Tageskarte</span>
<b>14 €</b>
</div>
<div>
<span>10er-Karte</span>
<b>120 €</b>
</div>
<div>
<span>Monatskarte</span>
<b>59 €</b>
</div>
</div>
</div>
</div>
</>
);
// ── Handy-Fassung (402 x 874, oben 59 und unten 34 frei) ──────────────────────
const Handy = () => (
<>
<div className="hm-h-kopf">
<div className="hm-logo" data-zone="logo">
<LogoZeichen />
<span className="hm-logo__text">
<span className="hm-logo__klein">Boulderhalle</span>
<span className="hm-logo__gross">Überhang</span>
</span>
</div>
<div className="hm-tel" data-zone="telefon">
<Handset />
<span>0221 4710 142</span>
</div>
</div>
<div className="hm-h-nav">
<span className="hm-nav__punkt" data-zone="nav-routen">
Routen
</span>
<span className="hm-nav__punkt" data-zone="nav-kurse">
Kurse
</span>
<span className="hm-nav__punkt">Preise</span>
<span className="hm-nav__punkt">Zeiten</span>
</div>
<div className="hm-h-held">
<div className="hm-held__vor">
<span className="hm-held__punkt" />
Frisch geschraubt
</div>
<div className="hm-held__titel">
<span>
Heute <em>14</em>
</span>
<span>neue Boulder</span>
</div>
<div className="hm-knopf" data-zone="tageskarte">
<span>Tageskarte kaufen</span>
<Pfeil />
</div>
</div>
<div className="hm-h-live hm-karte hm-karte--live" data-zone="auslastung">
<div className="hm-zahl">
42<span>%</span>
</div>
<div className="hm-h-live__rechts">
<div className="hm-karte__kopf">
<span className="hm-live" />
Live-Auslastung
</div>
<div className="hm-balken">
<span style={{ width: "42%" }} />
</div>
</div>
</div>
<div className="hm-wand" data-zone="routenbild">
<Routenwand b={362} h={150} klein />
</div>
<div className="hm-karte hm-karte--kurs hm-h-kurs" data-zone="schnupperkurs">
<div className="hm-karte__titel">Schnupperkurs</div>
<div className="hm-karte__text">Sa 10 Uhr, Leihschuhe dabei</div>
</div>
<div className="hm-h-reihe">
<div className="hm-karte" data-zone="preise">
<div className="hm-karte__titel">Preise</div>
<div className="hm-preise">
<div>
<span>Tageskarte</span>
<b>14 €</b>
</div>
<div>
<span>Monat</span>
<b>59 €</b>
</div>
</div>
</div>
<div className="hm-karte" data-zone="oeffnungszeiten">
<div className="hm-karte__titel">Geöffnet</div>
<div className="hm-preise">
<div>
<span>Mo–Fr</span>
<b>10–23</b>
</div>
<div>
<span>Sa, So</span>
<b>9–21</b>
</div>
</div>
</div>
</div>
</>
);
export default HeatmapSeite;
export { HeatmapSeite };
+243
View File
@@ -0,0 +1,243 @@
/**
* Ablauf der Demo „Website-Analyse“: Die Heatmap der letzten 30 Tage ist von Anfang an ganz zu sehen. Ein simulierter
* Zeiger klickt nacheinander Stellen der Beispiel-Website an; jeder Klick legt genau EINEN neuen Punkt an die Stelle des
* Zeigers (Wunsch des Inhabers 2026-10-04). Nach ca. 10 s hält der Ablauf ca. 6 s, die Demo-Klicks blenden aus, neu.
* Sobald die Maus über der Website ist (oder eine Zone gewählt wird), stoppt der Ablauf und die Heatmap ist voll.
* Diese Datei wird sofort geladen und darf kein CSS importieren. Es laufen nur setTimeout-Ketten, kein rAF,
* und nichts, solange `laeuft` false ist.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import type { DemoVariante } from "../gemeinsam";
import { SUMME_KLICKS, TOP_ZONE, ZONEN, zonenPunkt, zoneNach } from "./zonen";
export type HeatmapPhase = "aufbau" | "voll" | "ausblenden";
export type HeatmapZeiger = { x: number; y: number; klick: number; sichtbar: boolean };
export type HeatmapZustand = {
phase: HeatmapPhase;
/** Wie viele Klickpunkte je Zone sichtbar sind: 0 (keine) bis 1 (alle); der Grundbestand ist immer ganz zu sehen. */
fuellung: Readonly<Record<string, number>>;
/** Die Demo-Klicks bis jetzt, je Fassung in Designpunkten der Fläche (genau die Stellen, an denen der Zeiger klickte). */
klickPunkte: Record<DemoVariante, readonly { x: number; y: number }[]>;
/** Anzahl sichtbarer Klickpunkte insgesamt (von 1.284) und ihr Anteil 0 bis 1. */
punkte: number;
anteil: number;
/** Simulierter Zeiger je Fassung in Designpunkten der jeweiligen Fläche. */
zeiger: Record<DemoVariante, HeatmapZeiger>;
/** Gewählte Zone (Maus, Tippen, Rangliste) oder im statischen Zustand die meistgeklickte; sonst null. */
auswahl: string | null;
/** true, solange die Maus über der Website ist. */
maus: boolean;
/** Läuft der simulierte Ablauf gerade (wirklich, nicht pausiert)? */
laeuft: boolean;
statisch: boolean;
waehle: (id: string | null) => void;
setzeMaus: (an: boolean) => void;
};
// ── Drehbuch ────────────────────────────────────────────────────────────────
/** Reihenfolge der angeklickten Zonen: häufig geklickte öfter (Tageskarte 3 x, Live-Auslastung und Routenbild 2 x). */
export const KLICK_FOLGE: readonly string[] = [
"tageskarte",
"auslastung",
"routenbild",
"telefon",
"tageskarte",
"nav-kurse",
"preise",
"schnupperkurs",
"routenbild",
"oeffnungszeiten",
"auslastung",
"nav-routen",
"logo",
"tageskarte",
];
/** Wo im Rechteck der Zeiger landet (Anteil von Breite und Höhe), damit nicht jeder Klick genau die Mitte trifft. */
const ZIEL_VERSATZ: readonly [number, number][] = [
[0.5, 0.5],
[0.5, 0.35],
[0.4, 0.55],
[0.5, 0.5],
[0.62, 0.45],
[0.5, 0.5],
[0.45, 0.4],
[0.55, 0.5],
[0.62, 0.4],
[0.5, 0.45],
[0.55, 0.6],
[0.5, 0.5],
[0.5, 0.5],
[0.38, 0.55],
];
const DAUER = { start: 450, flug: 560, klick: 130, voll: 6000, aus: 1000 } as const;
type Schritt =
| { art: "start"; dauer: number }
| { art: "flug" | "klick"; ziel: number; dauer: number }
| { art: "voll" | "aus"; dauer: number };
const SCHRITTE: readonly Schritt[] = [
{ art: "start", dauer: DAUER.start },
...KLICK_FOLGE.flatMap((_, ziel): Schritt[] => [
{ art: "flug", ziel, dauer: DAUER.flug },
{ art: "klick", ziel, dauer: DAUER.klick },
]),
{ art: "voll", dauer: DAUER.voll },
{ art: "aus", dauer: DAUER.aus },
];
const VOLL_START = SCHRITTE.findIndex((s) => s.art === "voll");
/** Dauer des Aufbaus bis „voll“ in Millisekunden (für Tests und Doku). */
export const AUFBAU_DAUER = SCHRITTE.slice(0, VOLL_START).reduce((summe, s) => summe + s.dauer, 0);
// ── Füllung der Heatmap nach jedem Klick ───────────────────────────────────
/** Der Grundbestand: alle Punkte jeder Zone sind von Anfang an zu sehen. */
const ALLE: Readonly<Record<string, number>> = Object.freeze(Object.fromEntries(ZONEN.map((z) => [z.id, 1])));
/** Wie viele Demo-Klicks es insgesamt gibt; der Grundbestand sind die übrigen Klicks der Summe. */
const DEMO_KLICKS = KLICK_FOLGE.length;
/** Gezählte Klicks: Grundbestand plus die Demo-Klicks bis jetzt (am Ende genau die Summe 1.284). */
const zaehlePunkte = (klicks: number) => SUMME_KLICKS - DEMO_KLICKS + klicks;
// ── Zeigerziele ──────────────────────────────────────────────────────────────
const START_POSITION: Record<DemoVariante, { x: number; y: number }> = {
desktop: { x: 1180, y: 760 },
handy: { x: 300, y: 700 },
};
const ziele = (variante: DemoVariante) =>
KLICK_FOLGE.map((id, i) => zonenPunkt(variante, id, ZIEL_VERSATZ[i][0], ZIEL_VERSATZ[i][1]));
const ZIELE: Record<DemoVariante, { x: number; y: number }[]> = { desktop: ziele("desktop"), handy: ziele("handy") };
/** Die Stellen aller Demo-Klicks je Fassung (Designpunkte), in Klick-Reihenfolge – auch zum Ausblenden gebraucht. */
export const KLICK_ZIELE: Readonly<Record<DemoVariante, readonly { x: number; y: number }[]>> = ZIELE;
// ── Ableitung aus dem Schritt ──────────────────────────────────────────────────
const aus = (schritt: number) => SCHRITTE[Math.min(schritt, SCHRITTE.length - 1)];
const zeigerFuer = (variante: DemoVariante, schritt: number, aktiv: boolean): HeatmapZeiger => {
const s = aus(schritt);
if (s.art === "start") return { ...START_POSITION[variante], klick: 0, sichtbar: aktiv };
if (s.art === "flug") {
const p = ZIELE[variante][s.ziel];
return { ...p, klick: s.ziel, sichtbar: aktiv };
}
if (s.art === "klick") {
const p = ZIELE[variante][s.ziel];
return { ...p, klick: s.ziel + 1, sichtbar: aktiv };
}
// „voll“ und „ausblenden“: der Zeiger bleibt am letzten Ziel, wird aber unsichtbar
const letzte = ZIELE[variante][KLICK_FOLGE.length - 1];
return { ...letzte, klick: KLICK_FOLGE.length, sichtbar: false };
};
/** Wie viele Demo-Klicks zu diesem Schritt schon gesetzt sind (beim Ausblenden wieder keiner). */
const klicksFuer = (schritt: number) => {
const s = aus(schritt);
if (s.art === "flug") return s.ziel;
if (s.art === "klick") return s.ziel + 1;
if (s.art === "voll") return DEMO_KLICKS;
return 0;
};
const klickPunkteFuer = (klicks: number): HeatmapZustand["klickPunkte"] => ({
desktop: ZIELE.desktop.slice(0, klicks),
handy: ZIELE.handy.slice(0, klicks),
});
const phaseFuer = (schritt: number): HeatmapPhase => {
const art = aus(schritt).art;
return art === "voll" ? "voll" : art === "aus" ? "ausblenden" : "aufbau";
};
/** Anfangszustand (Grundbestand der Heatmap, nichts gewählt, Auswahl und Maus ohne Wirkung): damit zeigt die Bühne die
* Website samt Heatmap sofort, bevor der Taktgeber geladen ist. */
export const HEATMAP_START: HeatmapZustand = {
phase: "aufbau",
fuellung: ALLE,
klickPunkte: klickPunkteFuer(0),
punkte: zaehlePunkte(0),
anteil: zaehlePunkte(0) / SUMME_KLICKS,
zeiger: { desktop: zeigerFuer("desktop", 0, false), handy: zeigerFuer("handy", 0, false) },
auswahl: null,
maus: false,
laeuft: false,
statisch: false,
waehle: () => {},
setzeMaus: () => {},
};
// ── Hook ───────────────────────────────────────────────────────────────────────
/**
* Zustand der Heatmap-Demo.
* laeuft = der Ablauf darf abspielen (Station aktiv, im Bild, Tab sichtbar, Bewegung erlaubt).
* statisch = keine Bewegung erlaubt: volle Heatmap, kein Zeiger, Auswahl = meistgeklickte Zone.
* Hört `laeuft` auf, beginnt der Ablauf beim nächsten Mal von vorn (beim Grundbestand).
*/
export const useHeatmapAblauf = (laeuft: boolean, statisch: boolean): HeatmapZustand => {
const [schritt, setSchritt] = useState(0);
const [maus, setMaus] = useState(false);
const [gewaehlt, setGewaehlt] = useState<string | null>(null);
const interaktiv = maus || gewaehlt !== null;
const spielt = laeuft && !statisch && !interaktiv;
useEffect(() => {
if (!laeuft || statisch) {
setSchritt((s) => (s === 0 ? s : 0));
return;
}
if (interaktiv) return;
const t = window.setTimeout(
() => setSchritt((s) => (s >= SCHRITTE.length - 1 ? 0 : s + 1)),
aus(schritt).dauer,
);
return () => window.clearTimeout(t);
}, [laeuft, statisch, interaktiv, schritt]);
const waehle = useCallback((id: string | null) => {
const gueltig = id !== null && zoneNach(id) ? id : null;
setGewaehlt(gueltig);
// Wer etwas wählt, sieht die volle Heatmap; nach dem Loslassen geht es von dort aus weiter (nicht vom halben Aufbau)
if (gueltig !== null) setSchritt((s) => (phaseFuer(s) === "aufbau" ? VOLL_START : s));
}, []);
const setzeMaus = useCallback((an: boolean) => {
setMaus(an);
if (an) setSchritt((s) => (phaseFuer(s) === "aufbau" ? VOLL_START : s));
}, []);
return useMemo<HeatmapZustand>(() => {
const voll = statisch || interaktiv;
const klicks = voll ? DEMO_KLICKS : klicksFuer(schritt);
const punkte = zaehlePunkte(klicks);
const zeigerAktiv = spielt && phaseFuer(schritt) === "aufbau";
return {
phase: voll ? "voll" : phaseFuer(schritt),
fuellung: ALLE,
klickPunkte: klickPunkteFuer(klicks),
punkte,
anteil: punkte / SUMME_KLICKS,
zeiger: {
desktop: zeigerFuer("desktop", schritt, zeigerAktiv),
handy: zeigerFuer("handy", schritt, zeigerAktiv),
},
auswahl: gewaehlt ?? (statisch ? TOP_ZONE.id : null),
maus,
laeuft: spielt,
statisch,
waehle,
setzeMaus,
};
}, [schritt, statisch, interaktiv, spielt, gewaehlt, maus, waehle, setzeMaus]);
};
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more