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>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-04 18:16:15 +00:00
1 parent 58ac7745e6
commit c1d775e090
47 files changed
+2537 -87

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/
+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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

@@ -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.
+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.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 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.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 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())
+5 -4
View File
@@ -43,15 +43,16 @@ LANG_HOEHE = 5131
# Dateiname (ohne .webp) -> (Art, Zielbreiten)
BILDER = {
# Desktop-Fenster (Roh 3024 x 1786 = MacBook-Safari-Fläche 1512 x 893 @2x)
"desktop-1-projekte": ("desktop", (800, 1200, 1600)),
# 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)),
"desktop-3b-chat-foto": ("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-4b-chat-foto": ("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)),
+182 -41
View File
@@ -1,10 +1,23 @@
import { lazy, Suspense, useEffect, useRef, useState, useSyncExternalStore, version, type ComponentType, type ReactNode, type RefObject } from "react";
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 { useDemoLauf, useFeinerZeiger, useImBild } from "./demos/gemeinsam";
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";
/**
@@ -15,6 +28,10 @@ import "./kundenportal.css";
* 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 =
@@ -51,6 +68,9 @@ const handyBild = (name: string, alt: string, sizes: string, hoehe = 1696): Rahm
});
type DemoArt = "termin" | "heatmap" | "pinnwand";
/** Live-Stationen außerhalb der Gruppe „Funktionen“ */
type LiveArt = "vorschau" | "chat";
type LadeArt = DemoArt | LiveArt;
type Funktion = {
id: string;
@@ -64,13 +84,17 @@ type Funktion = {
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. */
/** 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 (Terminplaner und Pinnwand; bei der Website-Analyse steht dort die Diagnose). */
/** Hinweis unter dem MacBook (Live-Vorschau, Chat, Terminplaner, Pinnwand; bei der Website-Analyse steht dort die Diagnose). */
hinweis?: string;
};
@@ -84,7 +108,12 @@ const FUNKTIONEN: Funktion[] = [
id: "live-vorschau",
titel: "Live-Vorschau",
unterschrift: "Ihre Website, noch während wir daran bauen.",
desktop: { name: "desktop-1-projekte", alt: "Kundenportal: Projektübersicht mit Live-Vorschau der Website" },
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" },
},
{
@@ -104,8 +133,12 @@ const FUNKTIONEN: Funktion[] = [
id: "chat",
titel: "Chat",
unterschrift: "Frage, Foto oder Sprachnachricht direkt ans Team.",
desktop: { name: "desktop-3b-chat-foto", alt: "Kundenportal: Chat mit dem Team, im Verlauf ein Foto" },
handy: { name: "phone-4b-chat-foto", alt: "Kundenportal auf dem Handy: Chat mit Foto" },
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",
@@ -166,9 +199,16 @@ const FUNKTIONEN: Funktion[] = [
];
const LETZTER = FUNKTIONEN.length - 1;
/** Stationen vor "Als App": normale Schritte und längere Demo-Schritte (bestimmen die Scrollstrecke der Hülle). */
const ANZAHL_DEMO = FUNKTIONEN.filter((f) => f.demo).length;
const ANZAHL_NORMAL = LETZTER - ANZAHL_DEMO;
/** 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." };
@@ -187,6 +227,10 @@ const lazyMitVorladen = <P,>(laden: () => Promise<{ default: ComponentType<P> }>
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 })));
@@ -198,22 +242,31 @@ const PinnwandSeite = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandSei
const PinnwandHandy = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandHandy"));
/** Chunks je Demo; die Website-Chunks (Seite, Diagnose) und der Taktgeber laden gleichzeitig. */
const VORLADEN: Record<DemoArt, (() => void)[]> = {
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<DemoArt, string> = { termin: "#f6f1ea", heatmap: "#141414", pinnwand: "#f7f2e6" };
const Platzhalter = ({ art }: { art: DemoArt }) => (
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.adresse}`;
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 }) =>
@@ -266,6 +319,10 @@ const Buehne = () => {
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);
@@ -301,6 +358,7 @@ const Buehne = () => {
raf = 0;
if (!ruhig.matches) {
el.style.setProperty("--kp-q", "0");
el.style.setProperty("--kp-v", "0");
return;
}
const vh = window.innerHeight;
@@ -315,6 +373,15 @@ const Buehne = () => {
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);
@@ -348,23 +415,60 @@ const Buehne = () => {
const app = aktiv === LETZTER;
const eingehaengt = (i: number) => i <= hoechster + 1;
const demoEingehaengt = (art: DemoArt) => FUNKTIONEN.some((f, i) => f.demo === art && eingehaengt(i));
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 DemoArt[]) {
if (FUNKTIONEN.some((f, i) => f.demo === art && i <= hoechster + 1)) VORLADEN[art].forEach((vorladen) => vorladen());
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={{ ["--kp-nn" as string]: ANZAHL_NORMAL, ["--kp-nd" as string]: ANZAHL_DEMO }}
>
<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}
@@ -385,16 +489,17 @@ const Buehne = () => {
{FUNKTIONEN.map((f, i) => (
<div
key={f.id}
className={`kp-marker${f.demo ? " kp-marker--demo" : ""}`}
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"}>
<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">
<ol className="kp-liste" aria-label="Funktionen des Kundenportals" ref={liste}>
{FUNKTIONEN.map((f, i) => {
if (!f.demo) {
return (
@@ -414,9 +519,9 @@ const Buehne = () => {
}
// die drei Funktionen: ein Überpunkt, der aufklappt, sobald eine von ihnen erreicht ist
if (i !== DEMO_INDIZES[0]) return null;
const offen = aktivDemo !== undefined;
const offen = gruppe === "offen";
return (
<li key="funktionen" className="kp-gruppe" data-offen={offen ? "ja" : "nein"}>
<li key="funktionen" className="kp-gruppe" data-offen={offen ? "ja" : "nein"} data-zustand={gruppe}>
<button
type="button"
className="kp-eintrag kp-eintrag--gruppe"
@@ -424,10 +529,21 @@ const Buehne = () => {
aria-controls="kp-unterliste"
onClick={() => springe(DEMO_INDIZES[0])}
>
<span className="kp-eintrag__titel">{GRUPPE.titel}</span>
<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={!offen}>
<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
@@ -451,7 +567,8 @@ const Buehne = () => {
<DesktopFenster className="kp-fenster" adresse={FUNKTIONEN[aktiv].adresse}>
<div className="kp-ebenen" aria-hidden={app || undefined}>
{FUNKTIONEN.map((f, i) => {
if (!eingehaengt(i) || !(f.desktop || f.demo)) return null;
const art = ladeArt(f);
if (!eingehaengt(i) || !(f.desktop || art)) return null;
return (
<div
key={f.id}
@@ -463,9 +580,11 @@ const Buehne = () => {
>
{f.desktop ? (
<RahmenBild bild={desktopBild(f.desktop.name, f.desktop.alt)} />
) : f.demo ? (
) : art ? (
// ohne gemeldeten Zustand steht die Website im Anfangszustand da (Chunk lädt parallel zum Taktgeber)
<Suspense fallback={<Platzhalter art={f.demo} />}>
<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}
@@ -494,6 +613,7 @@ const Buehne = () => {
)}
{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}
@@ -504,16 +624,16 @@ const Buehne = () => {
)}
</HandyRahmen>
{/* unter dem MacBook, links neben dem iPhone: Hinweis auf das erfundene Beispiel und je Demo eine kleine Karte
(Website-Analyse: die Diagnose, Terminplaner und Pinnwand: ein Hinweis zur Funktion) */}
{/* 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) => f.demo).map((f) => (
{FUNKTIONEN.filter((f) => ladeArt(f)).map((f) => (
<p
key={f.id}
className="kp-beispiel"
data-an={f.demo === aktivDemo ? "ja" : "nein"}
aria-hidden={f.demo !== aktivDemo || undefined}
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“ */}
@@ -536,12 +656,12 @@ const Buehne = () => {
</Suspense>
) : null}
</div>
{FUNKTIONEN.filter((f) => f.demo && f.hinweis).map((f) => (
{FUNKTIONEN.filter((f) => ladeArt(f) && f.hinweis).map((f) => (
<p
key={f.id}
className="kp-hinweis"
data-an={f.demo === aktivDemo ? "ja" : "nein"}
aria-hidden={f.demo !== aktivDemo || undefined}
data-an={ladeArt(f) === aktivArt ? "ja" : "nein"}
aria-hidden={ladeArt(f) !== aktivArt || undefined}
>
{f.hinweis}
</p>
@@ -588,9 +708,12 @@ 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) */
@@ -600,6 +723,7 @@ const DemoKarteRahmen = ({
<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>
@@ -616,7 +740,7 @@ const DemoKarteRahmen = ({
* 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: DemoArt) => {
const useKarte = (art: LadeArt) => {
const ref = useRef<HTMLDivElement>(null);
const nah = useImBild(ref, 0);
const imBild = useImBild(ref, 0.6);
@@ -643,6 +767,22 @@ const TerminKarte = ({ f }: { f: Funktion }) => {
);
};
/** 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);
@@ -679,6 +819,7 @@ const HandyReihe = () => (
<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} />;
@@ -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));
}
}
+8 -1
View File
@@ -2,9 +2,10 @@ import { memo, useEffect } from "react";
import { useTerminAblauf, type TerminZustand } from "./termin/ablauf";
import { useHeatmapAblauf, type HeatmapZustand } from "./heatmap/ablauf";
import { usePinnwandAblauf, type PinnwandZustand } from "./pinnwand/ablauf";
import { useChatAblauf, type ChatZustand } from "./chat/ablauf";
/**
* Taktgeber der drei Funktions-Demos als unsichtbare Bauteile (rendern nichts, melden nur ihren Zustand).
* Taktgeber der Demos (drei Funktionen und der Chat) als unsichtbare Bauteile (rendern nichts, melden nur ihren Zustand).
* Sie liegen in einem eigenen Chunk, damit die Startseite die Ablauf-Logik erst lädt, wenn die Kundenportal-Bühne
* eine Demo-Station fast erreicht (Spezifikation 4). Die Bühne hält den gemeldeten Zustand und gibt denselben
* Zustand an Website, Handy-Ebene und Diagnose.
@@ -26,6 +27,12 @@ export const PinnwandSteuerung = memo(function PinnwandSteuerung({ laeuft, stati
return null;
});
export const ChatSteuerung = memo(function ChatSteuerung({ laeuft, statisch, onZustand }: Props<ChatZustand>) {
const zustand = useChatAblauf(laeuft, statisch);
useEffect(() => onZustand(zustand), [zustand, onZustand]);
return null;
});
/** `aktiv`: Station ist die aktive; sonst bleiben weder Maus-Zustand noch Auswahl der Heatmap hängen. */
export const HeatmapSteuerung = memo(function HeatmapSteuerung({
laeuft,
+91 -22
View File
@@ -6,10 +6,13 @@
*/
.kp {
/* Länge eines Funktionsschritts auf der Bühne; die normalen und die längeren Demo-Schritte (der Ablauf soll zu sehen
sein) plus der letzte Schritt ergeben die Scrollstrecke */
--kp-s: 45svh;
--kp-d: 70svh;
/* Länge eines Schritts auf der Bühne: normale Stationen, die Demos (der Ablauf soll zu sehen sein), die Live-Vorschau
(die Website läuft beim Scrollen durch) und der Chat. Mit dem letzten Schritt ergeben sie die Scrollstrecke der Hülle
(Summe setzt die Bühne inline, siehe HUELLE_HOEHE). */
--kp-l-normal: 45svh;
--kp-l-demo: 70svh;
--kp-l-vorschau: 110svh;
--kp-l-chat: 90svh;
--kp-ende: 120svh;
--kp-oben: calc(var(--header-h) + 24px);
--kp-buehne-h: calc(100svh - var(--header-h) - 48px);
@@ -18,8 +21,10 @@
.kp {
/* nichts bewegt sich im letzten Schritt, also braucht er keine lange Strecke */
--kp-ende: 50svh;
/* die Demos zeigen ihren Endzustand und brauchen keine lange Strecke */
--kp-d: 45svh;
/* die Demos zeigen ihren Endzustand, die Website steht still: keine lange Strecke nötig */
--kp-l-demo: 45svh;
--kp-l-vorschau: 45svh;
--kp-l-chat: 45svh;
}
}
@@ -37,10 +42,7 @@
.kp-huelle {
position: relative;
/* --kp-nn und --kp-nd (Zahl der normalen und der Demo-Schritte vor "Als App") setzt die Bühne inline */
height: calc(
var(--kp-buehne-h) + var(--kp-nn, 4) * var(--kp-s) + var(--kp-nd, 3) * var(--kp-d) + var(--kp-ende)
);
/* Höhe (Bühne + alle Schritte + letzter Schritt) setzt die Bühne inline aus den Schrittlängen */
}
/* unsichtbare Spur aus Markern: der IntersectionObserver beobachtet sie, die Bühne klebt darüber */
@@ -51,15 +53,13 @@
flex-direction: column;
pointer-events: none;
}
/* --kp-l (Länge des Schritts) setzt die Bühne je Marke inline */
.kp-marker {
flex: 0 0 var(--kp-s);
flex: 0 0 var(--kp-l, var(--kp-l-normal));
}
/* Die erste Marke reicht bis zur Mitte des Bildschirms, wenn die Bühne anklebt: Schritt 1 ist beim Ankommen aktiv */
.kp-marker--demo {
flex-basis: var(--kp-d);
}
.kp-marker:first-child {
flex-basis: calc(50svh - var(--kp-oben) + var(--kp-s));
flex-basis: calc(50svh - var(--kp-oben) + var(--kp-l, var(--kp-l-normal)));
}
.kp-marker:last-child {
flex: 1 1 auto;
@@ -184,12 +184,36 @@
.kp-gruppe[data-offen="ja"] .kp-eintrag--gruppe .kp-eintrag__titel {
color: var(--fg);
}
/* kleiner Pfeil hinter „Funktionen“: zeigt nach rechts, solange zu, nach unten, wenn offen */
.kp-eintrag__pfeil {
display: inline-block;
width: 0.7em;
height: 0.7em;
margin-left: 0.3em;
vertical-align: 0.05em;
color: var(--fg-muted);
transform: rotate(-90deg);
}
.kp-gruppe[data-offen="ja"] .kp-eintrag__pfeil {
transform: none;
}
.kp-unterliste {
position: relative;
display: flex;
flex-direction: column;
margin: 2px 0 4px;
padding-left: 14px;
border-left: 1px solid var(--line-strong);
padding-left: 15px;
}
/* die senkrechte Linie links der Unterpunkte: wächst beim Aufklappen von oben, schrumpft beim Zuklappen nach oben */
.kp-unterliste::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 1px;
background: var(--line-strong);
transform-origin: 50% 0;
}
.kp-unterliste[hidden] {
display: none;
@@ -229,26 +253,63 @@
}
}
@media (prefers-reduced-motion: no-preference) {
.kp-eintrag__pfeil {
transition: transform var(--dur-enter) var(--ease);
}
/* Aufklappen: Linie wächst, die Unterpunkte gleiten nacheinander herein ("Als App" gleitet per FLIP nach unten) */
.kp-unterliste:not([hidden])::before {
animation: kp-linie-auf 380ms var(--ease) both;
}
.kp-unterliste:not([hidden]) > li {
animation: kp-aufklappen var(--dur-enter) var(--ease) both;
animation: kp-aufklappen 380ms var(--ease) both;
}
.kp-unterliste:not([hidden]) > li:nth-child(2) {
animation-delay: 60ms;
animation-delay: 50ms;
}
.kp-unterliste:not([hidden]) > li:nth-child(3) {
animation-delay: 120ms;
animation-delay: 100ms;
}
/* Zuklappen: die Unterpunkte falten sich von unten nach oben ein, die Linie zieht sich zurück; danach wird die
Unterliste versteckt und "Als App" gleitet per FLIP nach oben (die Bühne wartet 260 ms) */
.kp-gruppe[data-zustand="schliesst"] .kp-unterliste::before {
animation: kp-linie-zu 240ms ease-in both;
}
.kp-gruppe[data-zustand="schliesst"] .kp-unterliste > li:nth-child(n) {
animation: kp-zuklappen 190ms ease-in both;
}
.kp-gruppe[data-zustand="schliesst"] .kp-unterliste > li:nth-child(2) {
animation-delay: 30ms;
}
.kp-gruppe[data-zustand="schliesst"] .kp-unterliste > li:nth-child(1) {
animation-delay: 60ms;
}
}
@keyframes kp-aufklappen {
from {
opacity: 0;
transform: translateY(-6px);
transform: translateY(-10px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes kp-zuklappen {
to {
opacity: 0;
transform: translateY(-10px) scale(0.97);
}
}
@keyframes kp-linie-auf {
from {
transform: scaleY(0);
}
}
@keyframes kp-linie-zu {
to {
transform: scaleY(0);
}
}
/* Geräte: Desktop-Fenster mit überlappendem Handy-Rahmen unten rechts */
.kp-geraete {
@@ -378,6 +439,12 @@
.kp-buehne[data-app="ja"] .kp-handy {
transform: translate(-35.9cqw, -14.2cqw) scale(1.5);
}
/* Station Chat: das iPhone gleitet nach rechts unten weg, damit es das hineingezoomte Gespräch im MacBook nicht verdeckt */
.kp-buehne[data-station="chat"] .kp-handy {
opacity: 0;
transform: translate(10cqw, 6cqw) scale(0.94);
pointer-events: none;
}
/* Der lange Screenshot: gleiche Breite wie der Schirm, 65,93 % seiner Höhe wandern aus dem Bild
(sichtbar sind 874 von 2565,5 px im iPhone-Inhalt, 1 - 1748 / 5131). Die Verschiebung hängt am Scrollfortschritt (--kp-q), kein eigener Scrollbereich. */
@@ -400,7 +467,9 @@
transition: opacity var(--dur-enter) var(--ease);
}
.kp-handy {
transition: transform var(--dur-enter) var(--ease);
transition:
transform var(--dur-enter) var(--ease),
opacity var(--dur-enter) var(--ease);
}
.kp-buehne .kp-lang {
transform: translateY(calc(var(--kp-q, 0) * -65.93%));
+202
View File
@@ -0,0 +1,202 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, renderHook, act, cleanup } from "@testing-library/react";
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import ChatSeite from "@/components/home/demos/chat/ChatSeite";
import ChatHandy from "@/components/home/demos/chat/ChatHandy";
import BaeckereiSeite from "@/components/home/demos/baeckerei/BaeckereiSeite";
import { useChatAblauf, CHAT_RUNDE_MS, CHAT_START, NACHRICHTEN, type ChatZustand } from "@/components/home/demos/chat/ablauf";
// Spezifikation 2026-10-04-vorschau-chat: Live-Vorschau (Bäckerei Sonnenschein) und Chat-Demo der Kundenportal-Bühne
const WURZEL = process.cwd();
const ENDE: ChatZustand = { ...CHAT_START, sichtbar: NACHRICHTEN.length, zoom: true, statisch: true };
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe("Chat: Gespräch", () => {
it("sechs Nachrichten im Wechsel, mit Foto und Sprachnachricht; das Team antwortet als Kenso", () => {
expect(NACHRICHTEN).toHaveLength(6);
expect(NACHRICHTEN.map((n) => n.von)).toEqual(["ich", "team", "ich", "team", "ich", "team"]);
expect(NACHRICHTEN[0].foto).toBe(true);
expect(NACHRICHTEN[2].sprache).toMatch(/^0:\d\d$/);
// die Uhrzeiten laufen vorwärts
const minuten = NACHRICHTEN.map((n) => Number(n.zeit.slice(0, 2)) * 60 + Number(n.zeit.slice(3)));
expect([...minuten].sort((a, b) => a - b)).toEqual(minuten);
});
it("verspricht nichts, was die Website nicht halten darf (Antwortzeit, Erfolg)", () => {
const text = NACHRICHTEN.map((n) => n.text ?? "").join(" ");
expect(text).not.toMatch(/sofort|in Minuten|24 Stunden|garantiert|mehr Kunden|mehr Umsatz/i);
// das Team spricht die Kundin mit „Sie“ an
expect(text).toContain("Schauen Sie");
});
});
describe("Chat: Ablauf", () => {
it("ohne Bewegung: sofort der Endzustand (alle Nachrichten, hineingezoomt), kein Timer", () => {
vi.useFakeTimers();
const { result } = renderHook(() => useChatAblauf(true, true));
expect(result.current.sichtbar).toBe(6);
expect(result.current.zoom).toBe(true);
expect(result.current.statisch).toBe(true);
expect(result.current.eingabe).toBe("");
expect(vi.getTimerCount()).toBe(0);
});
it("steht still, solange die Station nicht läuft (leerer Chat, nicht gezoomt)", () => {
vi.useFakeTimers();
const { result } = renderHook(() => useChatAblauf(false, false));
act(() => void vi.advanceTimersByTime(10_000));
expect(result.current.sichtbar).toBe(0);
expect(result.current.zoom).toBe(false);
});
it("zoomt hinein, hängt das Foto an, tippt Zeichen für Zeichen, schickt ab; am Ende zoomt er heraus und beginnt von vorn", () => {
vi.useFakeTimers();
const { result } = renderHook(() => useChatAblauf(true, false));
// in kleinen Schritten: jeder Schritt des Ablaufs plant seinen Timer erst nach dem Rendern
const vor = (ms: number) => {
for (let t = 0; t < ms; t += 50) act(() => void vi.advanceTimersByTime(Math.min(50, ms - t)));
};
expect(result.current.zoom).toBe(false);
vor(800);
expect(result.current.zoom).toBe(true);
vor(1800);
expect(result.current.anhang).toBe(true);
// während des Tippens wächst der Text im Feld
vor(600);
const teil = result.current.eingabe;
expect(teil.length).toBeGreaterThan(0);
expect(NACHRICHTEN[0].text?.startsWith(teil)).toBe(true);
vor(600);
expect(result.current.eingabe.length).toBeGreaterThan(teil.length);
// bis zur letzten Nachricht wird jede sichtbar, nie springt die Zahl zurück
let vorher = 0;
let gesehenAufnahme = false;
for (let t = 0; t < CHAT_RUNDE_MS - 5000; t += 100) {
vor(100);
if (result.current.aufnahme !== null) gesehenAufnahme = true;
if (result.current.sichtbar < vorher) break;
vorher = result.current.sichtbar;
}
expect(gesehenAufnahme).toBe(true);
expect(vorher).toBe(6);
// eine Runde später: wieder leer und nicht gezoomt
vor(6000);
expect(result.current.runde).toBeGreaterThan(0);
expect(result.current.sichtbar).toBeLessThan(6);
});
it("eine Runde dauert 20 bis 32 Sekunden (lang genug zum Lesen, kurz genug zum Zusehen)", () => {
expect(CHAT_RUNDE_MS).toBeGreaterThan(20_000);
expect(CHAT_RUNDE_MS).toBeLessThan(32_000);
});
});
describe("Chat: Darstellung", () => {
it("MacBook: Aufnahme des leeren Portal-Chats, darüber die sichtbaren Nachrichten; Kamera folgt dem Zustand", () => {
const { container, rerender } = render(<ChatSeite zustand={ENDE} />);
const wurzel = container.firstElementChild as HTMLElement;
expect(wurzel).toHaveAttribute("aria-hidden", "true");
expect(container.querySelector(".ch-kamera")).toHaveAttribute("data-zoom", "ja");
expect(container.querySelector(".ch-kulisse")?.getAttribute("srcset")).toContain("desktop-3c-chat-leer-2400.webp 2400w");
expect(container.querySelectorAll(".ch-blase")).toHaveLength(6);
expect(container.querySelectorAll(".ch-blase--ich")).toHaveLength(3);
expect(container.querySelector(".ch-audio")).toHaveTextContent("Sprachnachricht");
expect(container.querySelectorAll("a, button, input, select, textarea, [tabindex]")).toHaveLength(0);
rerender(<ChatSeite zustand={{ ...CHAT_START, zoom: true, anhang: true, eingabe: "Guten Morgen" }} />);
expect(container.querySelectorAll(".ch-blase")).toHaveLength(0);
expect(container.querySelector(".ch-feld")).toHaveTextContent("Guten Morgen");
expect(container.querySelector(".ch-anhang img")).not.toBeNull();
// Senden ist aktiv, sobald Text im Feld steht
expect(container.querySelector(".ch-senden")).toHaveTextContent("Senden");
rerender(<ChatSeite zustand={{ ...CHAT_START, zoom: true, sichtbar: 2, aufnahme: 7 }} />);
expect(container.querySelector(".ch-aufnahme")).toHaveTextContent("Aufnahme0:07");
expect(container.querySelector(".ch-feld")).toBeNull();
});
it("iPhone: nur die Ebene (transparent), Senden als runder Knopf ohne Wort", () => {
const { container } = render(<ChatHandy zustand={{ ...CHAT_START, eingabe: "Hallo", sichtbar: 1 }} />);
expect(container.querySelector(".ch-handy")).toHaveAttribute("aria-hidden", "true");
expect(container.querySelector("img.ch-kulisse")).toBeNull();
expect(container.querySelector(".ch-ebene")).toHaveAttribute("data-variante", "handy");
expect(container.querySelector(".ch-senden svg")).not.toBeNull();
expect(container.querySelector(".ch-senden")).not.toHaveTextContent("Senden");
});
});
describe("Bäckerei Sonnenschein (Live-Vorschau)", () => {
it("ist eine ganze Website: Kopf, Start, Sortiment, Backstube, Torten, Öffnungszeiten, Fuß", () => {
const { container } = render(<BaeckereiSeite />);
const text = container.textContent ?? "";
for (const teil of ["Sonnenschein", "Frisch gebacken.", "Jeden Morgen.", "Unser Sortiment", "Roggenbrot", "Unsere Backstube", "Kuchen & Torten", "Öffnungszeiten", "Impressum"]) {
expect(text).toContain(teil);
}
const wurzel = container.firstElementChild as HTMLElement;
expect(wurzel).toHaveAttribute("aria-hidden", "true");
expect(container.querySelectorAll("a, button, input, select, textarea, iframe, [tabindex]")).toHaveLength(0);
// gleiche Telefonnummer und gleiche Überschrift wie in den Portal-Aufnahmen (Fortschritt: „Frisch gebacken. Jeden Morgen.“)
expect(text).toContain("01234 / 56 78 90");
});
it("alle Fotos liegen selbst gehostet in public/ und stehen mit CC0-Quelle in QUELLEN.md", () => {
const { container } = render(<BaeckereiSeite />);
const quellen = readFileSync(`${WURZEL}/docs/relaunch/assets/baeckerei/QUELLEN.md`, "utf8");
const bilder = Array.from(container.querySelectorAll("img")).map((b) => b.getAttribute("src")!);
expect(bilder.length).toBeGreaterThanOrEqual(6);
for (const src of bilder) {
expect(src).toMatch(/^\/relaunch\/demos\/baeckerei\/[a-z-]+-\d+\.webp$/);
const datei = `${WURZEL}/public${src}`;
expect(existsSync(datei), src).toBe(true);
expect(statSync(datei).size / 1024, src).toBeLessThanOrEqual(60);
expect(quellen, src).toContain(src.split("/").pop()!);
}
// jede Datei im Ordner ist in QUELLEN.md belegt
for (const datei of readdirSync(`${WURZEL}/public/relaunch/demos/baeckerei`)) expect(quellen, datei).toContain(datei);
expect(quellen).toContain("CC0");
});
it("die Schrift Instrument Serif ist selbst gehostet und mit Lizenz abgelegt", () => {
const css = readFileSync(`${WURZEL}/src/components/home/demos/baeckerei/baeckerei.css`, "utf8");
for (const m of css.matchAll(/url\((\/fonts\/[^)]+)\)/g)) expect(existsSync(`${WURZEL}/public${m[1]}`), m[1]).toBe(true);
expect(readFileSync(`${WURZEL}/public/fonts/instrument-serif/LICENSE`, "utf8")).toContain("SIL Open Font License");
});
});
describe("Quelltext", () => {
const quelle = (pfad: string) => readFileSync(`${WURZEL}/src/components/home/demos/${pfad}`, "utf8");
it("ablauf.ts importiert kein CSS (die Komponenten werden per React.lazy geladen)", () => {
expect(quelle("chat/ablauf.ts")).not.toMatch(/import\s+["'][^"']+\.css["']|from\s+["'][^"']+\.css["']/);
});
it("lädt nichts von fremden Servern", () => {
for (const datei of ["chat/ChatEbene.tsx", "chat/ChatSeite.tsx", "chat/ChatHandy.tsx", "chat/chat.css", "baeckerei/BaeckereiSeite.tsx", "baeckerei/baeckerei.css"]) {
expect(quelle(datei), datei).not.toMatch(/https?:\/\//);
expect(quelle(datei), datei).not.toMatch(/googleapis|gstatic|unsplash/i);
}
});
it("bewegt nur transform und opacity und nur ohne reduzierte Bewegung", () => {
for (const datei of ["chat/chat.css", "baeckerei/baeckerei.css"]) {
const css = quelle(datei).replace(/\/\*[\s\S]*?\*\//g, "");
for (const treffer of css.matchAll(/@keyframes[^{]+\{([\s\S]*?)\n\}/g)) {
const eigenschaften = [...treffer[1].matchAll(/([a-z-]+)\s*:/g)].map((m) => m[1]);
for (const e of eigenschaften) expect(["transform", "opacity"], datei).toContain(e);
}
for (const m of css.matchAll(/transition:\s*([^;]+);/g)) {
// Kommas in cubic-bezier(...) trennen keine Übergänge
for (const teil of m[1].split(/,(?![^(]*\))/)) expect(["transform", "opacity"], `${datei}: ${m[0]}`).toContain(teil.trim().split(/\s+/)[0]);
}
const vorBewegung = css.split("@media (prefers-reduced-motion: no-preference)")[0];
expect(vorBewegung, datei).not.toMatch(/animation\s*:|transition\s*:/);
// die Website läuft nur mit Bewegung mit (transform mit --kp-v steht in der Medienabfrage)
if (datei.startsWith("baeckerei")) expect(vorBewegung).not.toContain("--kp-v");
}
});
});
+110 -19
View File
@@ -5,7 +5,8 @@ import { join } from "node:path";
import Kundenportal from "@/components/home/Kundenportal";
import GeraeteRahmen, { DesktopFenster, HandyRahmen } from "@/components/GeraeteRahmen";
// Spezifikation 7.2 (Kundenportal) und 9.5 (Bilder); Funktions-Demos: 2026-10-04-funktionen-demos (Stationen 5 bis 7)
// Spezifikation 7.2 (Kundenportal) und 9.5 (Bilder); Funktions-Demos: 2026-10-04-funktionen-demos (Stationen 5 bis 7);
// Live-Vorschau, Chat-Demo und weiches Auf-/Zuklappen: 2026-10-04-vorschau-chat
const WURZEL = `${process.cwd()}/`;
const BILDORDNER = join(WURZEL, "public/relaunch/portal");
@@ -29,11 +30,15 @@ const SCHRITTE = [
"Online – Hosting und Pflege auf Wunsch im Abo",
];
/** matchMedia: die Abfrage "ab 900 px" ist wahr, wenn desktop = true; "hover: hover" (feiner Zeiger, Maus), wenn fein = true. */
const mitBreite = (desktop: boolean, fein = desktop) => {
/** matchMedia: die Abfrage "ab 900 px" ist wahr, wenn desktop = true; "hover: hover" (feiner Zeiger, Maus), wenn fein = true;
* "prefers-reduced-motion: no-preference" (Bewegung erlaubt) nur, wenn bewegung = true (sonst zeigen die Demos ihren Endzustand). */
const mitBreite = (desktop: boolean, fein = desktop, bewegung = false) => {
const vorher = window.matchMedia;
window.matchMedia = ((query: string) => ({
matches: (desktop && query.includes("min-width: 900px")) || (fein && query.includes("hover: hover")),
matches:
(desktop && query.includes("min-width: 900px")) ||
(fein && query.includes("hover: hover")) ||
(bewegung && query.includes("prefers-reduced-motion: no-preference")),
media: query,
onchange: null,
addListener: () => {},
@@ -56,6 +61,11 @@ const zeigeDesktop = () => {
zuruecksetzen = mitBreite(true);
return render(<Kundenportal />);
};
/** Bühne ab 900 px mit erlaubter Bewegung (Demos laufen, die Gruppe klappt weich zu). */
const zeigeDesktopBewegt = () => {
zuruecksetzen = mitBreite(true, true, true);
return render(<Kundenportal />);
};
/** Bühne ab 900 px, aber ohne Maus (Tablet quer). */
const zeigeTablet = () => {
zuruecksetzen = mitBreite(true, false);
@@ -125,7 +135,7 @@ describe("Kundenportal: Text und Reihenfolge", () => {
it("Tablet quer (Bühne ohne Maus): der Bedienhinweis der Website-Analyse sagt „Tippen Sie“ statt „Zeigen Sie mit der Maus“", () => {
const { container } = zeigeTablet();
const hinweis = container.querySelectorAll(".kp-beispiel")[1];
const hinweis = Array.from(container.querySelectorAll(".kp-beispiel")).find((h) => h.textContent?.includes("Tippen"));
expect(hinweis).toHaveTextContent("Tippen Sie auf eine Stelle der Seite.");
expect(hinweis).not.toHaveTextContent("Maus");
});
@@ -177,9 +187,8 @@ describe("Kundenportal: Bilder (Spezifikation 9.5)", () => {
it("Desktop: zu Beginn hängen nur die ersten beiden Schritte ihre Bilder ein (spart Daten)", () => {
const { container } = zeigeDesktop();
const bilder = Array.from(container.querySelectorAll("img"));
// je zwei Desktop- und zwei Handy-Bilder
// die Live-Vorschau zeigt im MacBook die Beispiel-Website (wird nachgeladen), dazu Fortschritt und zwei Handy-Bilder
expect(bilder.map((b) => b.getAttribute("src"))).toEqual([
"/relaunch/portal/desktop-1-projekte-1200.webp",
"/relaunch/portal/desktop-2-fortschritt-1200.webp",
"/relaunch/portal/phone-1-projekte-780.webp",
"/relaunch/portal/phone-3-projektdetails-780.webp",
@@ -206,8 +215,12 @@ describe("Kundenportal: Bilder (Spezifikation 9.5)", () => {
it("in public/ liegen nur die Bilder, die der Abschnitt zeigt (keine Rohexporte, keine unbeworbenen Ansichten)", () => {
const skript = readFileSync(join(WURZEL, "scripts/relaunch-bilder.py"), "utf8");
// die alten Ansichten der News-Pinnwand ersetzt die Live-Demo (Station 7): ihre Bilder werden nicht mehr ausgeliefert
// die alten Ansichten der News-Pinnwand ersetzt die Live-Demo (Station 7), Live-Vorschau und Chat sind ebenfalls live:
// ihre alten Bilder werden nicht mehr ausgeliefert
for (const nicht of [
"desktop-1-projekte",
"desktop-3b-chat-foto",
"phone-4b-chat-foto",
"desktop-5-stundenkonto",
"desktop-6-funktionen",
"phone-6-vertraege",
@@ -218,9 +231,10 @@ describe("Kundenportal: Bilder (Spezifikation 9.5)", () => {
expect(skript, nicht).not.toContain(nicht);
expect(readdirSync(BILDORDNER).filter((d) => d.startsWith(nicht)), nicht).toEqual([]);
}
// nur Breiten aus der Spezifikation (Desktop 800/1200/1600, Handy 390/780)
// nur Breiten aus der Spezifikation (Desktop 800/1200/1600, Handy 390/780); der leere Chat zusätzlich in 2400 px,
// weil die Kamera der Chat-Demo hineinzoomt
for (const datei of readdirSync(BILDORDNER)) {
expect(datei).toMatch(/^(desktop-.+-(800|1200|1600)|phone-.+-(390|780))\.webp$/);
expect(datei).toMatch(/^(desktop-.+-(800|1200|1600)|desktop-3c-chat-leer-2400|phone-.+-(390|780))\.webp$/);
}
});
});
@@ -352,8 +366,9 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
// der lange Screenshot ist jetzt eingehängt und aktiv
const lang = container.querySelector(".kp-handy .kp-lage.is-aktiv .kp-lang img");
expect(lang?.getAttribute("src")).toContain("phone-2-projekte-lang");
// alle früheren Bilder sind inzwischen eingehängt
expect(container.querySelectorAll("img").length).toBe(4 + 8);
// alle früheren Bilder sind inzwischen eingehängt: zwei Desktop-Aufnahmen (Fortschritt, Rechnungen) und acht Handy-Aufnahmen
// (die Live-Stationen zeigen im MacBook Website bzw. Chat, deren Bilder liegen tiefer in der Ebene)
expect(container.querySelectorAll(".kp-lage > img, .kp-lage > .kp-lang > img").length).toBe(2 + 8);
});
it("Demo-Stationen: die drei Websites werden nachgeladen, sobald die Bühne sie fast erreicht, und aktiv ist nur eine", async () => {
@@ -391,6 +406,8 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
beobachterAttrappe();
const { container } = zeigeDesktop();
const adresse = () => container.querySelector(".dm-macbook__url")?.textContent;
expect(adresse()).toBe("baeckerei-sonnenschein.project.webklar.com");
meldeMarke(1);
expect(adresse()).toBe("project.webklar.com");
meldeMarke(4);
expect(adresse()).toBe("salon-schnittstelle.example");
@@ -421,33 +438,100 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
// der Hinweis aufs erfundene Beispiel steht daneben; nur der der aktiven Station ist für Screenreader da
const hinweise = Array.from(container.querySelectorAll(".kp-beispiel"));
expect(hinweise.map((h) => h.textContent)).toEqual([
"Erfundenes Beispiel · Bäckerei Sonnenschein",
"Erfundenes Beispiel · Bäckerei Sonnenschein im Kundenportal",
"Erfundenes Beispiel · salon-schnittstelle.example",
// mit Maus (diese Bühne meldet eine feine Zeigereingabe): Hinweis zum Zeigen
"Erfundenes Beispiel · Zeigen Sie mit der Maus auf die Seite.",
"Erfundenes Beispiel · musterbach.example",
]);
expect(hinweise.map((h) => h.getAttribute("data-an"))).toEqual(["nein", "ja", "nein"]);
expect(hinweise.map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "ja", "nein"]);
meldeMarke(6);
expect(diagnose()).toHaveAttribute("data-an", "nein");
expect(diagnose()).toHaveAttribute("inert");
});
it("Terminplaner und Pinnwand tragen unter dem MacBook eine Hinweiskarte (Kalender-Abo mit Verzögerung, neuer Zettel); nur die der aktiven Station ist sichtbar", () => {
it("Live-Vorschau, Chat, Terminplaner und Pinnwand tragen unter dem MacBook eine Hinweiskarte; nur die der aktiven Station ist sichtbar", () => {
beobachterAttrappe();
const { container } = zeigeDesktop();
const hinweise = () => Array.from(container.querySelectorAll(".kp-hinweis")) as HTMLElement[];
expect(hinweise().map((h) => h.textContent)).toEqual([
// stimmt: Vorschauen öffnen sich nur mit Portal-Sitzung (api/auth/preview-access), das Team bekommt Zugriff übers Ticketsystem
"Geschützt: Ihre Vorschau öffnet sich nur nach der Anmeldung im Kundenportal – Fremde sehen nichts.",
// stimmt: Schreibt das Team im Chat, geht an den Kunden die Mail K17 (gedrosselt)
"Antwortet das Team, kommt zusätzlich eine E-Mail – so verpassen Sie keine Nachricht.",
"Kalender-Abo: Den Link einmal in Google Kalender, Apple Kalender oder Outlook einfügen. Neue Termine erscheinen dort mit etwas Verzögerung.",
"Neuer Zettel: Er erscheint nach dem Aufhängen auf der Website.",
]);
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["ja", "nein", "nein", "nein"]);
meldeMarke(1);
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "nein"]);
meldeMarke(2);
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "ja", "nein", "nein"]);
meldeMarke(4);
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["ja", "nein"]);
expect(hinweise().map((h) => h.hasAttribute("aria-hidden"))).toEqual([false, true]);
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "ja", "nein"]);
expect(hinweise().map((h) => h.hasAttribute("aria-hidden"))).toEqual([true, true, false, true]);
meldeMarke(5);
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein"]);
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "nein"]);
meldeMarke(6);
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "ja"]);
expect(hinweise().map((h) => h.getAttribute("data-an"))).toEqual(["nein", "nein", "nein", "ja"]);
});
it("Live-Vorschau: im MacBook läuft die Beispiel-Website der Bäckerei (nachgeladen, aria-hidden, nichts Fokussierbares)", async () => {
beobachterAttrappe();
const { container } = zeigeDesktop();
await waitFor(() => expect(container.querySelector(".kp-fenster .kp-lage.is-aktiv .bk-seite")).not.toBeNull());
const ebene = container.querySelector(".kp-fenster .kp-lage.is-aktiv") as HTMLElement;
expect(ebene).toHaveAttribute("role", "img");
expect(ebene.getAttribute("aria-label")).toMatch(/Bäckerei/);
const seite = ebene.querySelector(".bk-seite") as HTMLElement;
expect(seite).toHaveAttribute("aria-hidden", "true");
expect(seite.querySelectorAll("a, button, input, select, textarea, [tabindex]")).toHaveLength(0);
expect(seite).toHaveTextContent("Frisch gebacken.");
// ohne Bewegung steht die Website oben (der Scrollfortschritt der Station bleibt 0)
expect((container.querySelector(".kp-buehne") as HTMLElement).style.getPropertyValue("--kp-v")).toBe("0");
});
it("Chat: im MacBook der Portal-Chat mit dem ganzen Gespräch (ohne Bewegung: Endzustand, hineingezoomt), das iPhone gleitet weg", async () => {
beobachterAttrappe();
const { container } = zeigeDesktop();
meldeMarke(2);
expect(container.querySelector(".kp-buehne")).toHaveAttribute("data-station", "chat");
await waitFor(() => expect(container.querySelectorAll(".kp-fenster .kp-lage.is-aktiv .ch-blase")).toHaveLength(6));
const ebene = container.querySelector(".kp-fenster .kp-lage.is-aktiv") as HTMLElement;
expect(ebene.getAttribute("aria-label")).toMatch(/Chat im Kundenportal/);
expect(ebene.querySelector(".ch-kamera")).toHaveAttribute("data-zoom", "ja");
expect(ebene.querySelector(".ch-seite")).toHaveAttribute("aria-hidden", "true");
expect(ebene.querySelector(".ch-blase--ich .ch-foto")).not.toBeNull();
expect(ebene).toHaveTextContent("Kenso · 08:41");
// auf der Bühne gibt es keine Chat-Ebene im iPhone (es gleitet in dieser Station weg)
expect(container.querySelector(".kp-handy .ch-ebene")).toBeNull();
});
it("der Überpunkt „Funktionen“ faltet sich mit Bewegung erst ein und klappt dann zu", async () => {
beobachterAttrappe();
zeigeDesktopBewegt();
const gruppe = screen.getByRole("button", { name: /^Funktionen/ });
const liste = screen.getByRole("list", { name: "Funktionen des Kundenportals" });
const unterliste = liste.querySelector<HTMLElement>("#kp-unterliste")!;
meldeMarke(6);
expect(gruppe).toHaveAttribute("aria-expanded", "true");
meldeMarke(7);
// sofort zu für Screenreader, sichtbar faltet sich die Unterliste noch ein
expect(gruppe).toHaveAttribute("aria-expanded", "false");
expect(liste.querySelector(".kp-gruppe")).toHaveAttribute("data-zustand", "schliesst");
expect(unterliste).not.toHaveAttribute("hidden");
expect(unterliste).toHaveAttribute("aria-hidden", "true");
await waitFor(() => expect(unterliste).toHaveAttribute("hidden"));
expect(liste.querySelector(".kp-gruppe")).toHaveAttribute("data-zustand", "zu");
// geht sie während des Einfaltens wieder auf, bleibt sie offen
meldeMarke(5);
meldeMarke(7);
meldeMarke(5);
expect(liste.querySelector(".kp-gruppe")).toHaveAttribute("data-zustand", "offen");
await new Promise((r) => setTimeout(r, 320));
expect(unterliste).not.toHaveAttribute("hidden");
});
it("die aktive Demo-Ebene trägt eine kurze Beschreibung für Screenreader, die Website darin bleibt aria-hidden", async () => {
@@ -479,7 +563,9 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
it("Handy-Karten: die drei Websites laden erst, wenn die Karte in Sicht kommt; Tippen auf die Heatmap wählt eine Zone", async () => {
beobachterAttrappe();
const { container } = zeigeHandy();
const karten = Array.from(container.querySelectorAll(".kp-karte--demo"));
// drei Funktions-Karten (Websites) und die Chat-Karte
expect(container.querySelectorAll(".kp-karte--demo")).toHaveLength(4);
const karten = Array.from(container.querySelectorAll(".kp-karte--demo[data-demo]"));
expect(karten).toHaveLength(3);
// noch nichts gesehen: Rahmen leer, nichts geladen
expect(container.querySelectorAll(".kp-karte--demo :is(.ts, .hm-seite, .pw-seite)")).toHaveLength(0);
@@ -505,6 +591,11 @@ describe("Kundenportal: Bühne wechselt mit dem IntersectionObserver", () => {
expect(heat.querySelector(".hmd")).toHaveAttribute("data-auswahl", "routenbild");
// Hinweis aufs erfundene Beispiel an jeder Demo-Karte
for (const karte of karten) expect(karte.querySelector(".kp-karte__beispiel")?.textContent).toMatch(/^Erfundenes Beispiel · .+\.example$/);
// Chat-Karte: die Portal-Aufnahme steht sofort da, darüber das Gespräch (ohne Bewegung: alle sechs Nachrichten)
const chat = container.querySelector(".kp-karte--demo:not([data-demo])") as HTMLElement;
expect(chat.querySelector("img")?.getAttribute("src")).toContain("phone-4c-chat-leer");
await waitFor(() => expect(chat.querySelectorAll(".ch-blase")).toHaveLength(6));
expect(chat.querySelector(".kp-karte__beispiel")?.textContent).toBe("Erfundenes Beispiel · Bäckerei Sonnenschein im Kundenportal");
});
});