
5 Website-Prompts zum Nachbauen
Die Prompts zu den fünf interaktiven Websites aus meinem Video: vom scroll-gesteuerten 3D-Film über eine isometrische Software-Fabrik bis zum Skizzenbuch, in das man selbst zeichnen kann. Kopieren, in Claude Code einfügen, nachbauen.
Wenn du aus meinem Video kommst: Hier sind die Prompts zu allen fünf Websites, die ich dort zeige. Jeder Prompt beschreibt seine Seite so genau, dass ein Coding-Modell sie Wert für Wert nachbauen kann: Farben, Schriften, Texte, 3D-Koordinaten und Animationszeiten.
Deshalb sind die Prompts auch sehr lang. Sie sind unten eingeklappt. Mit Prompt kopieren holst du dir den kompletten Text, ohne ihn aufklappen zu müssen.
So nutzt du die Prompts
- Leg einen leeren Ordner an und starte darin Claude Code.
- Kopier den Prompt und füg ihn komplett ein. Kürze nichts, die Details sind der Punkt.
- Claude schreibt die Seite als eine einzige Datei, meistens
index.html. Bei so langen Prompts passiert das oft in mehreren Schritten. Lass es durchlaufen. - Öffne die Seite über einen lokalen Server statt per Doppelklick. Manche Seiten laden sonst keine Bilder oder bleiben leer:
python3 -m http.server 8000
Danach rufst du http://localhost:8000 im Browser auf.
Am Ende jedes Prompts steht eine Abnahme-Checkliste. Wenn etwas anders aussieht als erwartet, bitte Claude, die Seite gegen diese Liste zu prüfen. Das bringt meist mehr als eine allgemeine Fehlerbeschreibung.
1. Tableau Abendfilm
Eine Landingpage für eine fiktive Reservierungs-Software für Cafés und Bistros. Beim Scrollen läuft ein kleiner 3D-Film durch einen Freitagabend in einem Pariser Café: Die Sonne sinkt, die Lichter gehen an, eine Uhr läuft mit, und jedes Kapitel zeigt eine Funktion der Software. Der Look ist zweifarbig in Crème und Bordeaux, mit körniger Papierstruktur.
Technik: Three.js mit eigenen Shadern, eine einzige HTML-Datei.
Prompt anzeigenPrompt ausblenden1.472 Zeilen
# Prompt: „Tableau Abendfilm“ – scroll-gesteuerte 3D-Landingpage mit Three.js
Du bist ein erfahrener Creative Developer (Three.js, WebGL-Shader, CSS). Baue exakt die unten spezifizierte Landingpage nach. Ziel ist ein **1:1-Nachbau**: gleiche Szene, gleiche Kamerafahrt, gleiche Farben, gleiche Texte, gleiches Verhalten.
## Arbeitsweise (bitte strikt befolgen)
1. Erzeuge **eine einzige Datei** `tableau-abendfilm.html`. Kein Build-Step, kein Framework, keine weiteren Dateien.
2. Die Datei besteht aus genau diesen Teilen in dieser Reihenfolge: **(A) Kopf** → **(B) CSS** → **(C) HTML-Markup** → **(D) Importmap** → **(E) JavaScript-Modul** (zusammengesetzt aus den Abschnitten E1 bis E25 in genau dieser Reihenfolge).
3. **Alle Codeblöcke in diesem Prompt sind die Referenz-Implementierung. Übernimm sie Zeichen für Zeichen.** Kürze nichts, „verbessere“ keine Zahlen, ersetze keine Werte durch Platzhalter und fasse keine Abschnitte zusammen. Jede Koordinate, jede Farbe und jeder Keyframe ist bewusst gewählt (z. B. damit die Kamera nie durch Wände fährt).
4. Die Texte zwischen den Codeblöcken erklären, was der jeweilige Teil tut, damit du ihn verstehst. Sie sind keine Aufforderung, etwas anders zu bauen.
5. Wenn du die Datei aus Platzgründen in mehreren Schritten schreibst, schreibe sie trotzdem vollständig und prüfe am Ende, dass alle Abschnitte E1–E25 enthalten sind.
6. Zum Testen die Datei über einen lokalen Server öffnen (ES-Module + Importmap), z. B. `python3 -m http.server 8000` im Ordner der Datei und dann `http://localhost:8000/tableau-abendfilm.html`.
## Worum es geht
**Produkt:** Tableau – Reservierungs- und Gästemanagement-Software für unabhängige Cafés, Bistros und Weinbars. Markenversprechen: „Volle Tische. Zeit für deine Gäste.“
**Sprache & Ton:** Deutsch, immer „du“, ruhig, direkt, konkret. Keine Ausrufezeichen, keine Emojis, kein Hype.
**Idee der Seite:** Beim Scrollen läuft ein kleiner 3D-Film durch einen Freitagabend (Fr, 25. Sept.) im fiktiven „Café du Coin“ in Paris. Die Sonne sinkt, die Lichter gehen an, eine Uhr läuft mit. Jedes Kapitel zeigt eine Funktion von Tableau.
**Stil:** Illustrativer Zwei-Ton-Look. Jede Fläche ist entweder im Licht (Crème `#E9DFCD`) oder im Schatten (Bordeaux `#8A2219`), dazu ein Tiefschatten (`#5C1C17`). Keine Verläufe auf Objekten, keine Texturen außer Schrift. An Schattenkanten eine körnige, gestippelte Übergangszone (Pixel-Hash im Shader) plus eine Papierkörnung über dem ganzen Bild. Wenige Akzentfarben: Himmel/Glas `#CBD9DA`, Bäume/Pflanzen Salbeigrün `#A3B58E`, Markise und Buttons Markisenrot `#A52B20`. Die Seite drumherum sieht aus wie ein Reservierungsbuch: Crème-Papier `#F3EBDD` mit feinen, rötlichen Linien alle 28 px, dünne dunkle Rahmen (1 px, `rgba(36,39,34,.72)`), eckige Boxen ohne Rundungen und ohne Schatten.
**Schriften (Google Fonts):** DM Serif Display (Überschriften, auch kursiv für Akzente), DM Sans (Fließtext, Buttons), DM Mono (Uhrzeit, Labels, Zeitleiste, Navigation).
## Der Film – Kapitel und Scroll-Fortschritt
Der Film-Abschnitt ist 900vh hoch, die Bühne darin `position: sticky` (100svh). Der Scroll-Fortschritt `p` läuft von 0 bis 1 über 800vh. Die Textpaneele liegen absolut im Film-Abschnitt bei `top: calc(var(--at) * 800vh + 50vh)` und scrollen natürlich über die Szene.
| p | Uhrzeit | Kapitel | Kamera / Szene |
|---|---|---|---|
| 0 | 18:25 | Hero | Totale von der Straße auf das Café. Headline oben auf dem Papier, der Bildrahmen startet unter dem Hero-Text und zieht sich bis p=0,045 auf volle Höhe auf. |
| 0,18 | 18:40 | Terrasse | Fahrt zwischen den Platanen auf die volle Terrasse. |
| 0,43 | 19:10 | Empfang | Mittig durch die offene Tür in den Raum, dann Schwenk auf das Tablet am Empfangspult. |
| 0,625 | 19:40 | Tischplan | Kamera steigt durch die (ausgeblendete) Decke und blickt senkrecht auf den Gastraum; HTML-Labels an jedem Tisch, Toast „Tisch 06 zugewiesen“. |
| 0,805 | 20:45 | Tisch 04 | Nahaufnahme Familie Laurent mit Geburtstagstorte; Gäste stoßen an; Gästewunsch-Chips. |
| 0,965 | 22:30 | Feierabend | Rückwärts durchs Fenster hinaus in die Nacht: Lichterkette, leuchtende Fenster, Sterne. |
Darunter folgen „Für wen“, ein roter CTA-Block und der Footer.
## Abnahmekriterien
- Die Szene sieht in jedem Kapitel wie oben beschrieben aus; die Kamera fährt nirgends durch Wände, Türflügel oder das Empfangspult.
- Die Draufsicht steht gerade (Eingang unten, Rückwand oben), Tisch-Labels sitzen auf den Tischen.
- Das Tablet liegt sichtbar **auf** dem Pult und ist lesbar.
- Gäste, Frisuren und Farben sind bei jedem Laden identisch (Seed 11).
- Die Uhr in der Zeitleiste läuft 18:25 → 22:40, die Kapitel-Marker springen per Klick.
- Bei `prefers-reduced-motion` keine Trägheit, keine Dauer-Animationen, kein Puls.
- Auf dem Handy (375 px) stapelt sich der Hero, die Paneele liegen in der unteren Bildhälfte, keine horizontale Scrollbar.
---
## (A) Kopf der Datei
Zeichensatz, Titel, Meta-Beschreibung und die Google-Fonts-Einbindung.
```html
<meta charset="utf-8">
<title>Tableau Abendfilm</title>
<meta name="description" content="Tableau – Reservierungen und Gästemanagement für unabhängige Cafés, Bistros und Weinbars.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:ital,opsz,wght@0,9..40,400..700;1,9..40,400..700&family=DM+Serif+Display:ital@0;1&display=swap">
```
## (B) CSS
Design-Tokens als CSS-Variablen, Papier-Hintergrund, Navigation, Hero, Bildrahmen, Tisch-Labels, Toast, Chips, Zeitleiste, Kapitel-Paneele, Abschnitte nach dem Film und die Breakpoints 980 px / 620 px.
```html
<style>
:root{
color-scheme: light;
--paper:#F3EBDD;
--paper-line:rgba(120,37,30,.075);
--ink:#242722;
--ink-2:#5E5A52;
--edge:rgba(36,39,34,.72);
--porz:#FFFCF7;
--kalk:#E4D8C5;
--rot:#A52B20;
--rot-hover:#78251E;
--rot-50:#F8E9E4;
--himmel:#D6E2E3;
--himmel-7:#3F5F63;
--ocker-5:#F6EACF;
--ocker-7:#7C5412;
--salbei-5:#E6EDE1;
--salbei-7:#3C6547;
--serif:'DM Serif Display', Georgia, 'Times New Roman', serif;
--sans:'DM Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
--mono:'DM Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace;
--nav-h:72px;
}
*{box-sizing:border-box}
html{scroll-behavior:auto}
body{
margin:0;
background-color:var(--paper);
background-image:linear-gradient(var(--paper-line) 1px, transparent 1px);
background-size:100% 28px;
color:var(--ink);
font:400 17px/1.55 var(--sans);
-webkit-font-smoothing:antialiased;
}
a{color:inherit}
button{font:inherit}
:focus-visible{outline:2px solid var(--ink);outline-offset:2px}
/* ---------- Nav ---------- */
.nav{
position:fixed; z-index:50; top:0; left:0; right:0;
padding:calc(12px + env(safe-area-inset-top, 0px)) 16px 0;
pointer-events:none;
}
.nav-in{
pointer-events:auto;
height:48px; display:flex; align-items:center; gap:28px;
padding:0 6px 0 14px;
background:var(--porz); border:1px solid var(--edge);
}
.brand{display:flex; align-items:center; gap:9px; text-decoration:none}
.brand svg{width:30px; height:auto; display:block; fill:var(--ink)}
.brand span{font:400 25px/1 var(--serif); letter-spacing:-.01em}
.nav-links{display:flex; gap:22px; font:500 12.5px/1 var(--mono); text-transform:uppercase; letter-spacing:.04em}
.nav-links a{text-decoration:none; color:var(--ink-2)}
.nav-links a:hover{color:var(--ink)}
.nav-cta{margin-left:auto; display:flex; gap:6px}
.btn{
display:inline-flex; align-items:center; gap:10px; height:36px; padding:0 16px;
border:1px solid transparent; cursor:pointer; text-decoration:none;
font:600 14px/1 var(--sans); letter-spacing:.005em; white-space:nowrap;
transition:background-color .14s, color .14s, border-color .14s, transform .08s;
}
.btn:active{transform:translateY(1px)}
.btn-primary{background:var(--rot); color:var(--porz)}
.btn-primary:hover{background:var(--rot-hover)}
.btn-ghost{background:transparent; color:var(--ink); border-color:transparent}
.btn-ghost:hover{background:rgba(36,39,34,.06)}
.btn-outline{background:var(--porz); color:var(--ink); border-color:var(--edge)}
.btn-outline:hover{background:var(--paper)}
.btn-lg{height:48px; padding:0 22px; font-size:15.5px}
.btn .arr{display:inline-block; width:14px; height:14px}
/* ---------- Film ---------- */
.film{position:relative; height:900vh}
.stage{position:sticky; top:0; height:100vh; height:100svh; overflow:hidden; z-index:1}
.hero{
position:absolute; left:16px; right:16px; top:calc(var(--nav-h) + env(safe-area-inset-top, 0px));
display:grid; grid-template-columns:minmax(0,1fr) minmax(250px,360px);
gap:12px 48px; align-items:end;
padding:clamp(12px,3vh,36px) 8px 0;
will-change:transform, opacity;
}
.hero h1{
margin:0; font:400 clamp(46px,7.6vw,128px)/.9 var(--serif); letter-spacing:-.025em;
text-wrap:balance;
}
.hero h1 em{font-style:italic; color:var(--rot)}
.hero-side{display:flex; flex-direction:column; gap:18px; padding-bottom:10px}
.hero-side p{margin:0; font-size:17px; line-height:1.5; color:var(--ink-2); max-width:36ch; text-wrap:pretty}
.hero-side .row{display:flex; gap:8px; flex-wrap:wrap}
.eyebrow{font:500 12px/1.3 var(--mono); letter-spacing:.06em; text-transform:uppercase; color:var(--rot)}
.hint{font:400 12px/1 var(--mono); color:var(--ink-2); letter-spacing:.03em}
.frame{
position:absolute; left:16px; right:16px; bottom:16px;
top:var(--frame-top, 50%);
overflow:hidden; border:1px solid var(--edge);
background:#8A2219;
}
.frame canvas, .frame .layer{position:absolute; left:0; bottom:0; width:100%; height:var(--canvas-h,100%); display:block}
.grain{position:absolute; inset:0; pointer-events:none; mix-blend-mode:multiply; opacity:.5; background-size:180px 180px}
.layer{pointer-events:none}
/* table labels (floor plan chapter) */
.tl{
position:absolute; left:0; top:0; display:flex; align-items:center; gap:7px;
padding:4px 8px 4px 5px; white-space:nowrap;
background:var(--porz); color:var(--ink); border:1px solid var(--edge);
font:500 11px/1 var(--mono); letter-spacing:.02em;
will-change:transform;
}
.tl b{display:inline-grid; place-items:center; min-width:24px; height:20px; padding:0 3px; font:400 15px/1 var(--serif); letter-spacing:0; background:var(--paper); border:1px solid rgba(36,39,34,.25)}
.tl.seated{background:var(--ink); color:var(--porz); border-color:var(--ink)}
.tl.seated b{background:transparent; border-color:rgba(255,252,247,.35); color:var(--porz)}
.tl.reserved{background:var(--himmel); border-color:var(--himmel-7); color:#23393c}
.tl.soon{background:var(--ocker-5); border-color:var(--ocker-7); color:var(--ocker-7)}
.tl.free{border-style:dashed; color:var(--ink-2)}
.tl.assigned{background:var(--rot-50); border-color:var(--rot); color:var(--rot-hover); box-shadow:0 0 0 3px rgba(165,43,32,.25)}
.tl.assigned b{border-color:var(--rot); color:var(--rot)}
.tl.pulse::after{content:""; position:absolute; inset:-3px; border:2px solid var(--rot); animation:ring .9s cubic-bezier(.16,1,.3,1) 2}
@keyframes ring{from{opacity:1; transform:scale(1)} to{opacity:0; transform:scale(1.35)}}
.toast{
position:absolute; left:50%; bottom:76px; transform:translateX(-50%);
display:flex; align-items:center; gap:16px; padding:12px 16px;
background:var(--ink); color:var(--porz); font:500 14px/1.3 var(--sans);
white-space:nowrap; pointer-events:none; opacity:0;
font-variant-numeric:tabular-nums;
}
.toast u{text-underline-offset:3px; color:#F0D3CB}
.chips{position:absolute; left:0; top:0; display:flex; flex-direction:column; gap:6px; will-change:transform; opacity:0}
.chip{
display:flex; align-items:center; gap:8px; padding:7px 11px 7px 9px;
background:var(--porz); border:1px solid var(--edge); white-space:nowrap;
font:500 13px/1.2 var(--sans);
}
.chip i{font-style:normal; font:500 10.5px/1 var(--mono); letter-spacing:.04em; text-transform:uppercase; padding:4px 6px; border:1px solid currentColor}
.chip .c-rot{color:var(--rot)}
.chip .c-ocker{color:var(--ocker-7); background:var(--ocker-5)}
.chip .c-salbei{color:var(--salbei-7); background:var(--salbei-5)}
.chip span{color:var(--ink-2); font-weight:400}
/* bottom bar: clock + timeline */
.bar{
position:absolute; left:12px; right:12px; bottom:12px; height:46px;
display:flex; align-items:stretch; background:var(--porz); border:1px solid var(--edge);
font:500 12px/1 var(--mono); letter-spacing:.03em; font-variant-numeric:tabular-nums;
}
.clock{display:flex; align-items:center; gap:14px; padding:0 16px; border-right:1px solid var(--edge); white-space:nowrap}
.clock .t{font:400 22px/1 var(--serif); letter-spacing:0}
.clock .seats{color:var(--ink-2)}
.clock .seats b{color:var(--ink); font-weight:500}
.track{position:relative; flex:1; margin:0 18px}
.track::before{content:""; position:absolute; left:0; right:0; top:50%; border-top:1px dashed rgba(36,39,34,.35)}
.marker{position:absolute; top:8px; bottom:8px; width:2px; background:var(--rot); left:0; transform:translateX(-1px)}
.tick{
position:absolute; top:50%; transform:translate(-4px,-50%);
display:flex; align-items:center; gap:7px; padding:4px 6px 4px 0; border:0; background:transparent; cursor:pointer;
font:500 11.5px/1 var(--mono); letter-spacing:.03em; color:var(--ink-2); white-space:nowrap;
}
.tick::before{content:""; width:8px; height:8px; border:1px solid var(--ink); background:var(--porz); flex:none}
.tick.on{color:var(--ink)}
.tick.on::before{background:var(--rot); border-color:var(--rot)}
.tick span{background:var(--porz); padding:2px 3px}
/* chapter panels */
.panel{
position:absolute; z-index:3; left:40px;
top:calc(var(--at) * 800vh + 50vh); transform:translateY(-50%);
width:min(430px, calc(100% - 32px));
background:var(--porz); border:1px solid var(--edge);
padding:26px 28px 28px;
display:flex; flex-direction:column; gap:14px;
}
.panel.right{left:auto; right:40px}
.panel h2{margin:0; font:400 clamp(30px,2.9vw,40px)/1.04 var(--serif); letter-spacing:-.012em; text-wrap:balance}
.panel p{margin:0; color:var(--ink-2); font-size:16.5px; line-height:1.55; text-wrap:pretty}
.panel .eyebrow{display:flex; gap:10px; align-items:center}
.panel .eyebrow::after{content:""; flex:1; border-top:1px solid rgba(165,43,32,.35)}
.stats{display:flex; gap:6px; flex-wrap:wrap; font:500 12px/1 var(--mono); font-variant-numeric:tabular-nums}
.stats span{padding:7px 9px; border:1px solid rgba(36,39,34,.28); background:var(--paper)}
.stats b{font-weight:500; color:var(--rot)}
.rows{display:flex; flex-direction:column; border:1px solid rgba(36,39,34,.25); background:var(--porz)}
.rrow{display:grid; grid-template-columns:48px minmax(0,1fr) auto; gap:10px; align-items:center; padding:10px 12px; font-size:14px; line-height:1.3; font-variant-numeric:tabular-nums}
.rrow + .rrow{border-top:1px solid rgba(36,39,34,.14)}
.rrow .tm{font:500 13px/1 var(--mono)}
.rrow .nm{font-weight:600}
.rrow .nm span{display:block; font-weight:400; font-size:12.5px; color:var(--ink-2); white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
.pill{font:500 11px/1 var(--mono); padding:5px 7px; letter-spacing:.02em; white-space:nowrap}
.pill.ok{background:var(--salbei-5); color:var(--salbei-7)}
.pill.warn{background:var(--ocker-5); color:var(--ocker-7)}
.pill.in{background:var(--ink); color:var(--porz)}
.legend{display:flex; flex-wrap:wrap; gap:6px}
.legend .tl{position:static; transform:none}
/* ---------- After the film ---------- */
.after{position:relative; z-index:2; background:var(--paper); background-image:inherit; border-top:1px solid var(--edge)}
.wrap{max-width:1280px; margin:0 auto; padding-inline:16px}
.who{display:grid; grid-template-columns:minmax(0,5fr) minmax(0,7fr); gap:64px; padding-block:120px 110px}
.who h2{margin:14px 0 0; font:400 clamp(38px,4.6vw,62px)/1 var(--serif); letter-spacing:-.018em; text-wrap:balance}
.who ul{list-style:none; margin:0; padding:0; border-top:1px solid var(--edge)}
.who li{display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1.35fr); gap:28px; padding:26px 0; border-bottom:1px solid var(--edge)}
.who h3{margin:0; font:400 25px/1.15 var(--serif)}
.who li p{margin:0; color:var(--ink-2); text-wrap:pretty}
.cta{background:var(--rot); color:var(--porz)}
.cta .wrap{display:flex; align-items:flex-end; justify-content:space-between; gap:32px; flex-wrap:wrap; padding-block:96px}
.cta h2{margin:0; max-width:14ch; font:400 clamp(40px,5.6vw,78px)/.98 var(--serif); letter-spacing:-.02em}
.cta h2 em{font-style:italic}
.cta .side{display:flex; flex-direction:column; gap:18px; max-width:380px}
.cta p{margin:0; color:#F6DDD6}
.btn-inverse{background:var(--porz); color:var(--rot-hover)}
.btn-inverse:hover{background:var(--paper)}
footer .wrap{display:flex; gap:48px; flex-wrap:wrap; align-items:flex-start; padding-block:56px 48px; font-size:14px; color:var(--ink-2)}
footer .brand span{color:var(--ink)}
footer nav{display:flex; gap:22px; flex-wrap:wrap; margin-left:auto}
footer nav a{text-decoration:none}
footer small{flex-basis:100%; border-top:1px solid rgba(36,39,34,.25); padding-top:18px; font:400 12px/1 var(--mono)}
.nogl-note{display:none}
.no-gl .nogl-note{display:grid; place-items:center; position:absolute; inset:0; color:var(--porz); font:400 22px/1.3 var(--serif); padding:24px; text-align:center}
/* ---------- Responsive ---------- */
@media (max-width: 980px){
.nav-links{display:none}
.hero{grid-template-columns:1fr; gap:14px}
.hero-side{padding-bottom:4px}
.hero-side p{font-size:15.5px}
.panel, .panel.right{left:16px; right:auto; top:calc(var(--at) * 800vh + 58vh); transform:none; padding:20px 20px 22px}
.tick span{display:none}
.tick.on span{display:inline}
.who{grid-template-columns:1fr; gap:36px; padding-block:88px}
.who li{grid-template-columns:1fr; gap:8px}
}
@media (max-width: 620px){
.nav-cta .btn-ghost{display:none}
.hero-side .row .btn-outline{display:none}
.clock .seats{display:none}
.clock{padding:0 12px}
.toast{font-size:12.5px; gap:10px; bottom:70px; max-width:calc(100% - 24px); white-space:normal}
.panel h2{font-size:28px}
.panel p{font-size:15px}
.chip{font-size:12px}
}
@media (prefers-reduced-motion: reduce){
.tl.pulse::after{animation:none}
.btn{transition:none}
}
</style>
```
## (C) HTML-Markup
Navigation mit Logo (Inline-SVG eines Bistrotischs auf Säulenfuß), Film-Abschnitt mit Hero, Bildrahmen (Canvas, Körnung, Label-Ebene mit Gästewunsch-Chips, Toast, Zeitleiste) und den fünf Kapitel-Paneelen, danach „Für wen“, CTA und Footer. Alle Texte exakt so übernehmen.
```html
<header class="nav">
<div class="nav-in">
<a class="brand" href="#top" aria-label="Tableau Startseite"><svg class="mark" viewBox="0 0 88 70" aria-hidden="true"><ellipse cx="44" cy="8" rx="43" ry="7.5"/><path d="M34 17H54C49 21 47 25 47 32V50C47 52 50 53 50 56C50 59 47 60 47 62C49 65 58 66 64 68H24C30 66 39 65 41 62C41 60 38 59 38 56C38 53 41 52 41 50V32C41 25 39 21 34 17Z"/></svg><span>Tableau</span></a>
<nav class="nav-links" aria-label="Hauptnavigation">
<a href="#film">Ein Abend</a>
<a href="#fuer-wen">Für wen</a>
<a href="#demo">Preise</a>
</nav>
<div class="nav-cta">
<a class="btn btn-ghost" href="#demo">Anmelden</a>
<a class="btn btn-primary" href="#demo">Demo vereinbaren</a>
</div>
</div>
</header>
<main id="top">
<section class="film" id="film" aria-label="Ein Freitagabend mit Tableau">
<div class="stage" id="stage">
<div class="hero" id="hero">
<h1>Volle Tische.<br>Zeit für <em>deine Gäste.</em></h1>
<div class="hero-side">
<span class="eyebrow">Für Cafés, Bistros & Weinbars</span>
<p>Tableau organisiert Reservierungen, Tischplan, Warteliste und Gästewünsche. Scroll durch einen Freitagabend im Café du Coin.</p>
<div class="row">
<a class="btn btn-primary btn-lg" href="#demo">Demo vereinbaren</a>
<a class="btn btn-outline btn-lg" href="#demo">14 Tage testen</a>
</div>
</div>
</div>
<div class="frame" id="frame">
<canvas id="gl" aria-label="Animierte 3D-Szene: ein volles Bistro an einem Freitagabend"></canvas>
<div class="grain" id="grain"></div>
<div class="layer" id="labels" aria-hidden="true">
<div class="chips" id="chips">
<div class="chip"><i class="c-rot">Anlass</i>Geburtstag <span>· 40 Jahre</span></div>
<div class="chip"><i class="c-ocker">Allergie</i>Nüsse <span>· Küche informiert</span></div>
<div class="chip"><i class="c-salbei">Stammgast</i>12. Besuch <span>· Tisch 04</span></div>
</div>
</div>
<div class="toast" id="toast" role="status">Tisch 06 zugewiesen · Jansen · 2 P. · 20:15 Uhr <u>Rückgängig</u></div>
<div class="bar">
<div class="clock"><span class="t" id="clock">18:25</span><span>Fr, 25. Sept.</span><span class="seats"><b id="seats">32</b>/54 Plätze</span></div>
<div class="track" id="track"><div class="marker" id="marker"></div></div>
</div>
<div class="nogl-note">Die 3D-Szene braucht WebGL. Der Rest der Seite funktioniert ohne.</div>
</div>
</div>
<article class="panel" style="--at:.18">
<span class="eyebrow">18:40 · Terrasse</span>
<h2>Die Sonne steht tief. Draußen ist jeder Tisch belegt.</h2>
<p>Tableau führt Innenraum und Terrasse getrennt. Du siehst, wer draußen sitzen möchte, wer als Nächstes kommt und welcher Tisch gleich frei wird.</p>
<div class="stats"><span>Terrasse <b>14/16</b></span><span>Innen <b>18/38</b></span><span>Warteliste <b>2</b></span></div>
</article>
<article class="panel right" style="--at:.43">
<span class="eyebrow">19:10 · Empfang</span>
<h2>Ein Blick aufs Tablet, dann gehst du mit den Gästen zum Tisch.</h2>
<p>Reservierungen stehen nach Uhrzeit sortiert, mit Personenzahl, Tisch und Wünschen. Walk-ins trägst du mit zwei Tipps ein.</p>
<div class="rows">
<div class="rrow"><span class="tm">19:30</span><span class="nm">Familie Laurent<span>4 P. · Tisch 04 · Geburtstag</span></span><span class="pill ok">Bestätigt</span></div>
<div class="rrow"><span class="tm">19:45</span><span class="nm">Okafor<span>3 P. · Tisch 05 · Hochstuhl</span></span><span class="pill ok">Bestätigt</span></div>
<div class="rrow"><span class="tm">20:15</span><span class="nm">Jansen<span>2 P. · Warteliste</span></span><span class="pill warn">wartet 15 Min.</span></div>
</div>
</article>
<article class="panel" style="--at:.625">
<span class="eyebrow">19:40 · Tischplan</span>
<h2>Der Raum, so wie er gerade ist.</h2>
<p>Jeder Tisch zeigt, ob er belegt, reserviert oder gleich frei ist, mit Personenzahl und nächster Reservierung. Zum Vergeben tippst du auf den Tisch.</p>
<div class="legend">
<span class="tl seated"><b>08</b>Am Tisch</span>
<span class="tl reserved"><b>11</b>Reserviert</span>
<span class="tl soon"><b>09</b>Gleich frei</span>
<span class="tl free"><b>12</b>Frei</span>
</div>
</article>
<article class="panel" style="--at:.805">
<span class="eyebrow">20:45 · Tisch 04</span>
<h2>Familie Laurent feiert. Die Küche wusste von der Nussallergie.</h2>
<p>Anlässe, Allergien und Hochstühle hängen an der Reservierung. Service und Küche sehen sie, bevor die Gäste da sind.</p>
</article>
<article class="panel right" style="--at:.965">
<span class="eyebrow">22:30 · Feierabend</span>
<h2>54 Plätze, zwei Sitzungen, kein Zettel am Tresen.</h2>
<p>So läuft ein voller Abend mit Tableau. In 20 Minuten zeigen wir dir das mit deinem eigenen Tischplan.</p>
<div><a class="btn btn-primary btn-lg" href="#demo">Demo vereinbaren</a></div>
</article>
</section>
<div class="after">
<section class="wrap who" id="fuer-wen">
<div>
<span class="eyebrow">Für wen</span>
<h2>Gemacht für inhabergeführte Gastronomie.</h2>
</div>
<ul>
<li><h3>Das Nachbarschaftsbistro</h3><p>Stammgäste, Walk-ins und zwei Sitzungen am Abend. Tableau merkt sich Lieblingstische und Allergien.</p></li>
<li><h3>Die kleine Weinbar</h3><p>Wenige Plätze, lange Abende. Die Warteliste zeigt, wer wie lange wartet und wo bald etwas frei wird.</p></li>
<li><h3>Das Café mit Terrasse</h3><p>Sonne heißt Andrang. Innenraum und Terrasse getrennt im Blick, auch auf dem Tablet am Tresen.</p></li>
</ul>
</section>
<section class="cta" id="demo">
<div class="wrap">
<h2>Bereit für den nächsten <em>vollen Abend?</em></h2>
<div class="side">
<p>Wir richten Tableau mit deinem Tischplan ein und zeigen dir in 20 Minuten, wie es im Service aussieht.</p>
<div><a class="btn btn-inverse btn-lg" href="#demo">Demo vereinbaren</a></div>
</div>
</div>
</section>
<footer>
<div class="wrap">
<a class="brand" href="#top"><svg class="mark" viewBox="0 0 88 70" aria-hidden="true"><ellipse cx="44" cy="8" rx="43" ry="7.5"/><path d="M34 17H54C49 21 47 25 47 32V50C47 52 50 53 50 56C50 59 47 60 47 62C49 65 58 66 64 68H24C30 66 39 65 41 62C41 60 38 59 38 56C38 53 41 52 41 50V32C41 25 39 21 34 17Z"/></svg><span>Tableau</span></a>
<span>Reservierungen und Gästemanagement für unabhängige Cafés, Bistros und Weinbars.</span>
<nav aria-label="Fußzeile"><a href="#film">Ein Abend</a><a href="#fuer-wen">Für wen</a><a href="#top">Impressum</a><a href="#top">Datenschutz</a></nav>
<small>© 2026 Tableau · Prototyp</small>
</div>
</footer>
</div>
</main>
```
## (D) Importmap
Three.js **r170** vom jsDelivr-CDN. Genau diese Version verwenden.
```html
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
} }
</script>
```
## (E) JavaScript-Modul
Die folgenden Abschnitte ergeben zusammen den Inhalt des `<script type="module">`. Füge sie **in genau dieser Reihenfolge direkt hintereinander** ein. E1 beginnt mit dem öffnenden `<script type="module">`-Tag, der letzte Abschnitt endet mit `</script>`.
### E1 – Modul-Start und Imports
Öffnet das ES-Modul und importiert Three.js und `mergeGeometries` über die Importmap.
```js
<script type="module">
import * as THREE from 'three';
import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js';
```
### E2 – helpers
Kleine Helfer: clamp/lerp/smoothstep, ein **deterministischer Zufallsgenerator mit Seed 11** (wichtig, damit Gäste, Frisuren, Farben und Fenster bei jedem Laden identisch sind), Matrix-Builder `M()`, `bake()` (Geometrie mit Matrix backen, nur position/normal/uv behalten) und `limb()`, die aus zwei Punkten eine Kapsel zwischen ihnen erzeugt – daraus werden alle Arme und Beine gebaut.
```js
/* ================= helpers ================= */
const $ = (s) => document.querySelector(s);
const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
const lerp = (a, b, t) => a + (b - a) * t;
const smooth = (a, b, v) => { const t = clamp((v - a) / (b - a)); return t * t * (3 - 2 * t); };
const window4 = (p, a, b, c, d) => smooth(a, b, p) * (1 - smooth(c, d, p));
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
function rng(seed) { return () => { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
const R = rng(11);
const pick = (arr) => arr[Math.floor(R() * arr.length)];
const V = (x, y, z) => new THREE.Vector3(x, y, z);
const M = (p = [0, 0, 0], r = [0, 0, 0], s = [1, 1, 1]) =>
new THREE.Matrix4().compose(V(...p), new THREE.Quaternion().setFromEuler(new THREE.Euler(...r)), V(...s));
function bake(geo, m) {
const g = geo.index ? geo.toNonIndexed() : geo.clone();
for (const k of Object.keys(g.attributes)) if (!['position', 'normal', 'uv'].includes(k)) g.deleteAttribute(k);
if (m) g.applyMatrix4(m);
return g;
}
const merged = (list) => mergeGeometries(list, false);
function limb(a, b, r, seg = 6, cap = 2) {
const A = V(...a), B = V(...b), d = B.clone().sub(A), len = Math.max(0.01, d.length());
const q = new THREE.Quaternion().setFromUnitVectors(V(0, 1, 0), d.normalize());
return bake(new THREE.CapsuleGeometry(r, len, cap, seg), new THREE.Matrix4().compose(A.clone().add(B).multiplyScalar(.5), q, V(1, 1, 1)));
}
```
### E3 – renderer
WebGL-Renderer mit weichen Schatten (PCFSoft), Pixel-Ratio gedeckelt auf 1,75 (Handy 1,5), Kamera mit 40° FOV. Ohne WebGL bekommt `body` die Klasse `no-gl` und im Rahmen erscheint ein Hinweis.
```js
/* ================= renderer ================= */
const canvas = $('#gl');
let renderer;
try {
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
} catch (e) {
document.body.classList.add('no-gl');
throw e;
}
const small = Math.min(innerWidth, innerHeight) < 700;
renderer.setPixelRatio(Math.min(devicePixelRatio, small ? 1.5 : 1.75));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(40, 1, 0.05, 900);
```
### E4 – two-tone material
Das Herz des Looks. Jedes Material ist ein `MeshLambertMaterial`, dessen Fragment-Shader per `onBeforeCompile` umgebaut wird: Die berechnete Helligkeit wird **nicht** ausgegeben, sondern in drei feste Töne übersetzt (Licht / Schatten / Tiefschatten). An der Schwelle sorgt ein Pixel-Hash für die körnige, gestippelte Kante. Jede Palette ist `[Licht, Schatten, Tiefschatten]`. Die `ghost`-Materialien gehören zu allem oberhalb des Gastraums: In der Draufsicht werden sie unsichtbar (colorWrite/depthWrite aus), werfen aber weiter Schatten.
```js
/* ================= two-tone material ================= */
const G = { uGrain: { value: 0.12 }, uT1: { value: 0.42 }, uT2: { value: 0.19 } };
const PAL = {
base: ['#E9DFCD', '#8A2219', '#5C1C17'],
stone: ['#EEE5D5', '#8E2419', '#611D17'],
linen: ['#D8C8B0', '#7E1F17', '#551611'],
rouge: ['#C24A36', '#6E1A13', '#4A110D'],
awning: ['#B3311F', '#6A1811', '#4A110D'],
dark: ['#6E3226', '#43160F', '#30100B'],
hair: ['#7A392B', '#4A1812', '#35100C'],
glass: ['#CBD9DA', '#7FA0A4', '#577A7F'],
leaf: ['#A3B58E', '#3F6A4A', '#2B4A34'],
tile: ['#DCCDB6', '#7C1E16', '#561511'],
};
function toon(p, opts = {}) {
const m = new THREE.MeshLambertMaterial({ color: 0xffffff, flatShading: opts.flat ?? true, side: opts.side ?? THREE.FrontSide });
const u = { uLit: { value: new THREE.Color(p[0]) }, uShadow: { value: new THREE.Color(p[1]) }, uDeep: { value: new THREE.Color(p[2]) } };
m.onBeforeCompile = (sh) => {
Object.assign(sh.uniforms, u, G);
sh.fragmentShader = sh.fragmentShader
.replace('#include <common>', `#include <common>
uniform vec3 uLit; uniform vec3 uShadow; uniform vec3 uDeep; uniform float uGrain; uniform float uT1; uniform float uT2;
float tbHash(vec2 p){ p = fract(p * vec2(443.897, 441.423)); p += dot(p, p.yx + 19.19); return fract((p.x + p.y) * p.x); }`)
.replace('#include <opaque_fragment>', `
float tbL = dot(outgoingLight, vec3(0.2126, 0.7152, 0.0722));
float tbN = (tbHash(floor(gl_FragCoord.xy)) - 0.5) * uGrain;
float tbA = step(uT1 + tbN, tbL);
float tbB = step(uT2 + tbN * 0.6, tbL);
gl_FragColor = vec4(mix(mix(uDeep, uShadow, tbB), uLit, tbA), diffuseColor.a);`);
};
return m;
}
const mats = {};
for (const k in PAL) mats[k] = toon(PAL[k]);
mats.shade = toon(PAL.rouge, { side: THREE.DoubleSide });
// materials of everything above the dining room: hidden from the camera in the top-down shot but still casting shadows
const ghost = { stone: toon(PAL.stone), glass: toon(PAL.glass), dark: toon(PAL.dark), glow: new THREE.MeshBasicMaterial({ color: '#7FA0A4' }) };
const ghostList = Object.values(ghost);
const glowWin = new THREE.MeshBasicMaterial({ color: '#7FA0A4' });
const bulbMat = new THREE.MeshBasicMaterial({ color: '#E9DFCD' });
const flameMat = new THREE.MeshBasicMaterial({ color: '#FFD48A' });
function mesh(geo, mat, parent, cast = true) {
const o = new THREE.Mesh(geo, mat); o.castShadow = cast; o.receiveShadow = true;
if (parent) parent.add(o);
return o;
}
function boxMM(x0, y0, z0, x1, y1, z1, mat, parent, cast = true) {
const o = mesh(new THREE.BoxGeometry(x1 - x0, y1 - y0, z1 - z0), mat, parent, cast);
o.position.set((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2);
return o;
}
function instanced(geo, mat, list, parent, cast = true) {
const im = new THREE.InstancedMesh(geo, mat, list.length);
const m = new THREE.Matrix4(), q = new THREE.Quaternion(), e = new THREE.Euler(), s = new THREE.Vector3();
list.forEach((o, i) => {
e.set(o.rx || 0, o.ry || 0, o.rz || 0); q.setFromEuler(e); s.setScalar(o.s || 1);
m.compose(V(o.x, o.y || 0, o.z), q, s); im.setMatrixAt(i, m);
});
im.castShadow = cast; im.receiveShadow = true; im.frustumCulled = false;
im.instanceMatrix.needsUpdate = true;
parent.add(im);
return im;
}
```
### E5 – lights
Eine Sonne (DirectionalLight, Intensität π·1,7, Schattenkarte 2048², Handy 1536²), ein Hemisphärenlicht (π·0,24) und ein Helfer `lamp()` für Punktlichter, die abends angehen. Die Faktoren π gleichen Three.js' physikalische Lichteinheiten aus, damit die Schwellen 0,42 / 0,19 im Shader stimmen.
```js
/* ================= lights ================= */
const sun = new THREE.DirectionalLight(0xffffff, Math.PI * 1.7);
sun.castShadow = true;
sun.shadow.mapSize.set(small ? 1536 : 2048, small ? 1536 : 2048);
Object.assign(sun.shadow.camera, { left: -24, right: 24, top: 24, bottom: -24, near: 1, far: 160 });
sun.shadow.bias = -0.0005; sun.shadow.normalBias = 0.025;
sun.target.position.set(0, 0, -2);
scene.add(sun, sun.target);
const hemi = new THREE.HemisphereLight(0xffffff, 0x777777, Math.PI * 0.24);
scene.add(hemi);
const lampLights = [];
function lamp(x, y, z, group) { const l = new THREE.PointLight(0xffffff, 0, 6.5, 2); l.position.set(x, y, z); scene.add(l); lampLights.push({ l, group }); }
```
### E6 – world
Boden, Gehweg, Straße mit Mittelstreifen, Innenboden und das Schachbrett-Fliesenmuster (instanziert).
```js
/* ================= world ================= */
const world = new THREE.Group(); scene.add(world);
const upper = new THREE.Group(); world.add(upper);
// ground, sidewalk, street
boxMM(-90, -0.3, 0.05, 90, 0, 140, mats.linen, world, false);
boxMM(-90, 0, 0.05, 90, 0.12, 6.2, mats.stone, world, false);
boxMM(-90, 0, 15.6, 90, 0.12, 40, mats.stone, world, false);
for (let x = -60; x < 60; x += 4.5) boxMM(x, 0.002, 10.8, x + 2.2, 0.006, 11.0, mats.base, world, false);
// interior floor + checker tiles
boxMM(-8, -0.2, -10, 8, 0, -0.3, mats.base, world, false);
{
const tiles = [];
for (let i = 0; i < 32; i++) for (let j = 0; j < 19; j++) if ((i + j) % 2 === 0) tiles.push({ x: -8 + 0.25 + i * 0.5, y: 0.003, z: -0.55 - j * 0.5 });
const tg = new THREE.PlaneGeometry(0.5, 0.5); tg.rotateX(-Math.PI / 2);
instanced(tg, mats.tile, tiles, world, false);
}
```
### E7 – building: ground floor
Erdgeschoss-Fassade mit fünf Öffnungen (4 Fenster, 1 Tür), Pilastern, Fensterbänken, Kämpfern, Bistro-Gardinen mit Stange, zwei nach innen geöffneten Türflügeln, Seiten- und Rückwand.
```js
// ---- building: ground floor ----
const openings = [[-7.3, -4.5, .62, 3.3], [-3.7, -1.3, .62, 3.3], [-0.9, 0.9, 0, 3.1], [1.3, 3.7, .62, 3.3], [4.5, 7.3, .62, 3.3]];
{
let xp = -8.3;
for (const [a, b, y0, y1] of openings) {
boxMM(xp, 0, -0.3, a, 4.2, 0.05, mats.stone, world);
boxMM(xp + 0.05, 0.3, 0.05, a - 0.05, 3.9, 0.16, mats.stone, world); // pilaster
if (y0 > 0) {
boxMM(a, 0, -0.3, b, y0, 0.05, mats.stone, world);
boxMM(a - 0.06, y0 - 0.06, -0.3, b + 0.06, y0, 0.2, mats.stone, world); // sill
boxMM(a, 2.62, -0.2, b, 2.7, -0.12, mats.dark, world); // transom
boxMM(a + 0.02, y0, -0.24, b - 0.02, y0 + 0.9, -0.22, mats.linen, world); // café curtain
boxMM(a + 0.02, y0 + 0.86, -0.25, b - 0.02, y0 + 0.9, -0.2, mats.dark, world); // curtain rod
}
boxMM(a, y1, -0.3, b, 4.2, 0.05, mats.stone, world);
xp = b;
}
boxMM(xp, 0, -0.3, 8.3, 4.2, 0.05, mats.stone, world);
// door leaves, open inwards
for (const s of [-1, 1]) {
const hinge = new THREE.Group(); hinge.position.set(0.9 * s, 0, -0.22); hinge.rotation.y = 1.38 * -s; world.add(hinge);
mesh(new THREE.BoxGeometry(.87, 3.08, .06), mats.dark, hinge).position.set(-0.44 * s, 1.54, 0);
mesh(new THREE.BoxGeometry(.6, 1.7, .02), mats.glass, hinge).position.set(-0.45 * s, 2.0, .035);
}
boxMM(-8.3, 4.1, -0.3, 8.3, 4.2, 0.4, mats.stone, world); // fascia below the cornice
// side + back walls
boxMM(-8.3, 0, -10.3, -8.0, 4.2, -0.3, mats.stone, world);
boxMM(8.0, 0, -10.3, 8.3, 4.2, -0.3, mats.stone, world);
boxMM(-8.3, 0, -10.3, 8.3, 4.2, -10.0, mats.stone, world);
}
```
### E8 – building: upper floors, cornice, mansard (ghosted in the top-down shot)
Obergeschosse mit Fenstern, Gesimsen, Balkonen und Geländern, Mansarddach mit Gauben und Kaminen. Alles hier nutzt die `ghost`-Materialien. Dazu zwei Nachbarhäuser (nicht ghost). Die Geländerstäbe werden am Ende gesammelt instanziert.
```js
// ---- building: upper floors, cornice, mansard (ghosted in the top-down shot) ----
const bars = { main: [], other: [] };
function facadeWindows(parent, xs, floors, m, glowRate, zF, key) {
for (const [y0, h] of floors) for (const x of xs) {
const w = 1.1, y1 = y0 + h, lit = R() < glowRate;
boxMM(x - w / 2, y0, zF - 0.02, x + w / 2, y1, zF + 0.03, lit ? m.glow : m.glass, parent);
boxMM(x - 0.03, y0, zF, x + 0.03, y1, zF + 0.07, m.stone, parent);
boxMM(x - w / 2 - 0.1, y0 - 0.02, zF, x - w / 2, y1 + 0.02, zF + 0.12, m.stone, parent);
boxMM(x + w / 2, y0 - 0.02, zF, x + w / 2 + 0.1, y1 + 0.02, zF + 0.12, m.stone, parent);
boxMM(x - w / 2 - 0.22, y1, zF, x + w / 2 + 0.22, y1 + 0.22, zF + 0.22, m.stone, parent);
boxMM(x - 0.78, y0 - 0.12, zF, x + 0.78, y0, zF + 0.46, m.stone, parent);
boxMM(x - 0.76, y0 + 0.86, zF + 0.4, x + 0.76, y0 + 0.9, zF + 0.46, m.dark, parent);
for (let bx = x - 0.7; bx <= x + 0.71; bx += 0.1) bars[key].push({ x: bx, y: y0 + 0.43, z: zF + 0.43 });
}
}
function mansard(parent, x0, x1, y0, zB, zF, h, mat) {
const s = new THREE.Shape();
s.moveTo(zF, 0); s.lineTo(zF - 1.3, h); s.lineTo(zB + 1.3, h); s.lineTo(zB, 0); s.lineTo(zF, 0);
const g = new THREE.ExtrudeGeometry(s, { depth: x1 - x0, bevelEnabled: false });
g.rotateY(-Math.PI / 2); g.translate(x1, y0, 0);
return mesh(g, mat, parent);
}
{
boxMM(-8.3, 4.2, -10.3, 8.3, 10.8, 0, ghost.stone, upper);
boxMM(-8.5, 4.2, -0.1, 8.5, 4.5, 0.42, ghost.stone, upper);
boxMM(-8.5, 10.8, -0.1, 8.5, 11.1, 0.45, ghost.stone, upper);
facadeWindows(upper, [-5.9, -2.5, 0, 2.5, 5.9], [[5.0, 2.2], [8.15, 2.1]], ghost, 0.45, 0, 'main');
mansard(upper, -8.3, 8.3, 11.1, -10.3, 0.25, 2.6, ghost.dark);
for (const x of [-5.9, -2.5, 2.5, 5.9]) {
boxMM(x - 0.5, 11.3, -0.9, x + 0.5, 12.7, 0.0, ghost.stone, upper);
boxMM(x - 0.36, 11.45, -0.02, x + 0.36, 12.5, 0.03, ghost.glow, upper);
boxMM(x - 0.6, 12.7, -0.95, x + 0.6, 12.85, 0.08, ghost.dark, upper);
}
for (const x of [-6.5, 1.5, 6.8]) boxMM(x - 0.4, 12.5, -6, x + 0.4, 14.6, -4.8, ghost.stone, upper);
}
// neighbours
{
const nb = new THREE.Group(); world.add(nb);
const m = { stone: mats.stone, glass: mats.glass, glow: glowWin, dark: mats.dark };
boxMM(-28, 0, -10.3, -8.3, 12.6, 0.2, mats.base, nb);
boxMM(-28, 12.6, -10.3, -8.3, 12.9, 0.5, mats.stone, nb);
facadeWindows(nb, [-25.5, -22.5, -19.5, -16.5, -13.5, -10.5], [[4.6, 2.1], [7.6, 2.1], [10.3, 1.8]], m, 0.35, 0.2, 'other');
for (const x of [-25.5, -21, -16.5, -12, -10.4]) boxMM(x - 1, 0.1, 0.2, x + 0.4, 3.2, 0.3, mats.dark, nb);
boxMM(8.3, 0, -10.3, 26, 10.2, 0.1, mats.base, nb);
boxMM(8.3, 10.2, -10.3, 26, 10.5, 0.4, mats.stone, nb);
mansard(nb, 8.3, 26, 10.5, -10.3, 0.1, 2.4, mats.dark);
facadeWindows(nb, [10.4, 13.4, 16.4, 19.4, 22.4], [[4.4, 2.0], [7.3, 2.0]], m, 0.3, 0.1, 'other');
for (const x of [11, 15.5, 20]) boxMM(x - 1.1, 0.1, 0.1, x + 1.1, 3.3, 0.22, mats.dark, nb);
const barGeo = new THREE.BoxGeometry(0.018, 0.84, 0.018);
instanced(barGeo, ghost.dark, bars.main, upper);
instanced(barGeo, mats.dark, bars.other, nb);
}
```
### E9 – awning + string lights
Die Markise aus 16 Streifen (rot/crème im Wechsel), Volant mit Schriftzug „CAFÉ DU COIN · BISTRO · VINS“ (Canvas-Textur), Stützstangen, 25 Glühbirnen als Lichterkette und vier Punktlichter über der Terrasse.
```js
// ---- awning + string lights ----
const signCanvas = document.createElement('canvas'); signCanvas.width = 2048; signCanvas.height = 48;
const signTex = new THREE.CanvasTexture(signCanvas); signTex.colorSpace = THREE.SRGBColorSpace; signTex.anisotropy = 4;
const signMat = new THREE.MeshBasicMaterial({ map: signTex, transparent: true });
{
const aw = new THREE.Group(); world.add(aw);
const slope = Math.atan2(0.7, 3.0), len = Math.hypot(3.0, 0.7);
for (let i = 0; i < 16; i++) {
const m = mesh(new THREE.BoxGeometry(0.95, 0.03, len), i % 2 ? mats.base : mats.awning, aw);
m.position.set(-7.6 + (i + 0.5) * 0.95, 3.1, 1.5); m.rotation.x = slope;
}
boxMM(-7.6, 2.42, 2.99, 7.6, 2.78, 3.03, mats.awning, aw);
const sign = new THREE.Mesh(new THREE.PlaneGeometry(15.2, 0.356), signMat); sign.position.set(0, 2.6, 3.035); aw.add(sign);
for (const x of [-7.5, 7.5]) mesh(limbGeo([x, 3.4, 0.05], [x, 2.78, 2.98], 0.02), mats.dark, aw);
const bulbs = [];
for (let i = 0; i < 25; i++) { const x = -7.4 + i * (14.8 / 24); const f = (i % 6) / 6; bulbs.push({ x, y: 2.36 - Math.sin(f * Math.PI) * 0.1, z: 3.08 }); }
instanced(new THREE.SphereGeometry(0.045, 8, 6), bulbMat, bulbs, aw, false);
for (const x of [-5.1, -2.6, 2.6, 5.1]) lamp(x, 2.45, 2.2, 'terrace');
}
function limbGeo(a, b, r) { return limb(a, b, r, 5); }
```
### E10 – furniture geometries
Zusammengeführte Geometrien: Bistrostuhl, Barhocker, der Tischfuß als Lathe-Profil (dieselbe Silhouette wie das Logo), runde, quadratische und lange Tischplatten, Teller, Weinglas, Flasche.
```js
// ---- furniture geometries ----
const chairGeo = merged([
bake(new THREE.CylinderGeometry(.2, .2, .035, 14), M([0, .46, 0])),
...[[-1, -1], [1, -1], [-1, 1], [1, 1]].map(([sx, sz]) => bake(new THREE.CylinderGeometry(.013, .016, .46, 5), M([sx * .14, .23, sz * .14], [sz * .07, 0, -sx * .07]))),
...[-1, 1].map((sx) => bake(new THREE.CylinderGeometry(.013, .013, .42, 5), M([sx * .155, .66, -.07]))),
bake(new THREE.TorusGeometry(.16, .018, 5, 14, Math.PI), M([0, .86, -.03], [-Math.PI / 2, 0, 0])),
bake(new THREE.TorusGeometry(.16, .013, 5, 14, Math.PI), M([0, .7, -.03], [-Math.PI / 2, 0, 0])),
]);
const stoolGeo = merged([
bake(new THREE.CylinderGeometry(.17, .17, .05, 14), M([0, .76, 0])),
...[0, 1, 2].map((i) => { const a = i * Math.PI * 2 / 3; return bake(new THREE.CylinderGeometry(.015, .02, .78, 5), M([Math.sin(a) * .12, .38, Math.cos(a) * .12], [Math.cos(a) * .1, 0, -Math.sin(a) * .1])); }),
bake(new THREE.TorusGeometry(.13, .012, 4, 12), M([0, .3, 0], [Math.PI / 2, 0, 0])),
]);
const pedestal = new THREE.LatheGeometry([[0, 0], [.2, 0], [.19, .02], [.1, .04], [.05, .09], [.04, .16], [.058, .2], [.04, .25], [.034, .55], [.045, .66], [.09, .715], [0, .72]].map(([r, y]) => new THREE.Vector2(r, y)), 10);
const tableGeo = {
round: merged([bake(pedestal), bake(new THREE.CylinderGeometry(.38, .38, .035, 20), M([0, .74, 0]))]),
square: merged([bake(pedestal), bake(new THREE.BoxGeometry(.8, .035, .8), M([0, .74, 0]))]),
long: merged([bake(pedestal, M([-.55, 0, 0])), bake(pedestal, M([.55, 0, 0])), bake(new THREE.BoxGeometry(1.7, .035, .8), M([0, .74, 0]))]),
};
const plateGeo = new THREE.CylinderGeometry(.11, .09, .015, 14);
const wineGeo = new THREE.LatheGeometry([[0, 0], [.035, 0], [.035, .006], [.006, .012], [.006, .09], [.03, .11], [.038, .15], [.034, .19], [0, .19]].map(([r, y]) => new THREE.Vector2(r, y)), 8);
const bottleGeo = merged([
bake(new THREE.CylinderGeometry(.036, .036, .2, 8), M([0, .1, 0])),
bake(new THREE.CylinderGeometry(.013, .034, .06, 8), M([0, .23, 0])),
bake(new THREE.CylinderGeometry(.013, .013, .07, 6), M([0, .29, 0])),
]);
```
### E11 – people
Die stilisierten Figuren. Personen bekommen **glatte** Schattierung (flat:false), damit die Licht-Schatten-Grenze der runden Form folgt. Köpfe mit Hals, Nase, Ohren, optional Brille; fünf Frisuren als runde Schalen (Dutt, kurz mit Pony, lang, Pferdeschwanz, Baskenmütze); grobe Hände; bei ~35 % hochgekrempelte Ärmel. `seated()` = sitzend (Blick +z, Tisch vorne), `standing()` = stehend, optional mit Schürze, Tablett oder Tablet. Der rechte Arm hängt in einer eigenen Gruppe am Schultergelenk, damit er zum Anstoßen/Trinken rotieren kann.
```js
/* ================= people ================= */
// People use smooth shading: the light/shadow edge then follows the curved form instead of facets
const pm = {};
for (const k of ['base', 'linen', 'rouge', 'dark', 'hair']) pm[k] = toon(PAL[k], { flat: false });
const smoothOf = new Map(Object.keys(pm).map((k) => [mats[k], pm[k]]));
const S = (m) => smoothOf.get(m) || m;
const skin = pm.base;
const limbP = (a, b, r) => limb(a, b, r, 10, 4);
const ball = (r, p, s = [1, 1, 1], rot = [0, 0, 0]) => bake(new THREE.SphereGeometry(r, 16, 12), M(p, rot, s));
function hairGeo(style) {
// a rounded shell sitting slightly behind and above the head: forehead and face stay free, no cut edges
const shell = ball(.109, [0, .205, -.004], [1.04, 1, 1.02]);
if (style === 0) return merged([shell, ball(.058, [0, .3, -.085])]); // bun
if (style === 1) return merged([ball(.104, [0, .21, -.01], [1.02, .97, 1]), ball(.07, [.025, .27, .06], [1.25, .48, .85], [0, 0, .35])]); // short with fringe
if (style === 2) return merged([shell, ball(.11, [0, .075, -.055], [1.08, 1.55, .62])]); // long
if (style === 3) return merged([shell, limbP([0, .25, -.1], [0, .03, -.15], .034)]); // ponytail
return merged([ball(.1, [0, .205, -.006], [1.02, .96, 1]), ball(.125, [.018, .29, .005], [1, .3, 1], [0, 0, .24])]); // beret
}
function headGroup(style, hairMat, specs) {
const head = new THREE.Group();
mesh(merged([
bake(new THREE.CylinderGeometry(.043, .05, .12, 12), M([0, .03, 0])),
ball(.1, [0, .18, .02], [1, 1.13, 1.05]),
ball(.017, [0, .165, .128], [1, 1.3, 1.25]),
ball(.022, [-.1, .17, .012], [.55, 1, .9]), ball(.022, [.1, .17, .012], [.55, 1, .9]),
]), skin, head);
mesh(hairGeo(style), hairMat, head);
if (specs) mesh(merged([
bake(new THREE.TorusGeometry(.027, .0045, 5, 14), M([-.04, .185, .127])),
bake(new THREE.TorusGeometry(.027, .0045, 5, 14), M([.04, .185, .127])),
limbP([-.013, .188, .13], [.013, .188, .13], .004),
limbP([-.066, .186, .122], [-.1, .19, .04], .004), limbP([.066, .186, .122], [.1, .19, .04], .004),
]), pm.dark, head, false);
return head;
}
const hand = (w, t) => limbP(w, t, .029);
const people = [];
function seated(opts = {}) {
const g = new THREE.Group();
const top = S(opts.top || pick([mats.base, mats.linen, mats.rouge, mats.base, mats.dark]));
const sleeve = R() < .35 ? skin : top;
const legsM = pick([pm.linen, pm.dark, pm.base]);
const hairM = pick([pm.hair, pm.hair, pm.base, pm.linen, pm.dark]);
mesh(merged([
limbP([-.1, .53, -.02], [-.1, .53, .3], .072), limbP([.1, .53, -.02], [.1, .53, .3], .072),
limbP([-.1, .5, .32], [-.11, .1, .36], .056), limbP([.1, .5, .32], [.11, .1, .36], .056),
]), legsM, g);
mesh(merged([limbP([-.11, .045, .32], [-.115, .04, .45], .042), limbP([.11, .045, .32], [.115, .04, .45], .042)]), pm.dark, g);
mesh(merged([
bake(new THREE.CapsuleGeometry(.15, .3, 4, 14), M([0, .84, -.03], [.1, 0, 0], [1.18, 1, .78])),
limbP([-.2, 1.0, -.03], [-.23, .83, .1], .05),
]), top, g);
mesh(limbP([-.23, .83, .1], [-.155, .8, .33], .043), sleeve, g);
mesh(hand([-.15, .797, .345], [-.125, .785, .425]), skin, g);
const head = headGroup(opts.hair ?? Math.floor(R() * 5), hairM, R() < .22); head.position.set(0, 1.08, 0); g.add(head);
const arm = new THREE.Group(); arm.position.set(.2, 1.0, -.03); g.add(arm);
mesh(limbP([0, 0, 0], [.03, -.17, .13], .05), top, arm);
mesh(limbP([.03, -.17, .13], [-.045, -.2, .36], .043), sleeve, arm);
mesh(hand([-.048, -.2, .375], [-.06, -.19, .45]), skin, arm);
if (opts.glass) { const gl = mesh(wineGeo, mats.glass, arm); gl.position.set(-.06, -.255, .43); }
g.scale.setScalar(opts.scale || lerp(.95, 1.06, R()));
g.userData = { head, arm, ph: R() * 6.28, cheer: !!opts.cheer, sip: R() < .5 };
people.push(g);
return g;
}
function standing(opts = {}) {
const g = new THREE.Group();
const top = S(opts.top || pick([mats.base, mats.linen, mats.rouge]));
const legsM = S(opts.legs || mats.dark);
const legs = [];
for (const s of [-1, 1]) {
const hip = new THREE.Group(); hip.position.set(.1 * s, .9, 0); g.add(hip);
mesh(merged([limbP([0, 0, 0], [0, -.8, 0], .066), limbP([0, -.855, -.02], [0, -.86, .12], .044)]), legsM, hip);
legs.push(hip);
}
mesh(merged([
bake(new THREE.CapsuleGeometry(.15, .38, 4, 14), M([0, 1.26, 0], [0, 0, 0], [1.15, 1, .75])),
limbP([-.21, 1.44, 0], [-.25, 1.13, .02], .05), limbP([-.25, 1.13, .02], [-.24, .89, .08], .044),
]), top, g);
mesh(hand([-.24, .88, .085], [-.235, .79, .1]), skin, g);
if (opts.apron) mesh(new THREE.CylinderGeometry(.19, .25, .62, 20), pm.base, g).position.set(0, .72, .03);
const head = headGroup(opts.hair ?? Math.floor(R() * 5), S(opts.hairMat || pick([mats.hair, mats.dark, mats.base])), opts.specs);
head.position.set(0, 1.5, 0); g.add(head);
const arm = new THREE.Group(); arm.position.set(.21, 1.44, 0); g.add(arm);
if (opts.tray) {
mesh(merged([limbP([0, 0, 0], [.06, -.22, .1], .05), limbP([.06, -.22, .1], [.04, .03, .29], .044)]), top, arm);
mesh(hand([.04, .035, .3], [.04, .05, .37]), skin, arm);
mesh(new THREE.CylinderGeometry(.2, .2, .015, 20), mats.dark, arm).position.set(.04, .085, .3);
for (const [dx, dz] of [[-.07, .25], [.08, .33], [.0, .4]]) mesh(wineGeo, mats.glass, arm).position.set(.04 + dx, .093, dz);
} else if (opts.tablet) {
mesh(merged([limbP([0, 0, 0], [.02, -.26, .08], .05), limbP([.02, -.26, .08], [-.07, -.33, .29], .044)]), top, arm);
mesh(hand([-.075, -.335, .3], [-.1, -.345, .38]), skin, arm);
} else {
mesh(merged([limbP([0, 0, 0], [.04, -.31, .02], .05), limbP([.04, -.31, .02], [.03, -.55, .08], .044)]), top, arm);
mesh(hand([.03, -.56, .085], [.025, -.65, .1]), skin, arm);
}
g.userData = { head, arm, legs, ph: R() * 6.28 };
people.push(g);
return g;
}
```
### E12 – tables & guests
Alle Tische mit ID, Typ, Position, Anzahl Gäste, Status und Label-Text für den Tischplan (01–12 innen, 21–28 Terrasse, Terrasse 0,12 m höher). `seatsFor()` verteilt Stühle; auf besetzte Plätze kommen Gäste, Teller, Gläser, Flaschen. Stühle, Tische und Geschirr sind instanziert.
```js
/* ================= tables & guests ================= */
const TABLES = [
{ id: '01', type: 'round', x: -4.3, z: -3.0, g: 2, st: 'seated', txt: 'Am Tisch · 2 P.' },
{ id: '02', type: 'square', x: -4.3, z: -5.6, g: 4, st: 'seated', txt: 'Am Tisch · 4 P.' },
{ id: '03', type: 'round', x: -4.3, z: -8.2, g: 0, st: 'reserved', txt: '20:30 · 2 P.' },
{ id: '04', type: 'square', x: -1.2, z: -3.6, g: 4, st: 'seated', txt: 'Laurent · 4 P.', cake: true },
{ id: '05', type: 'square', x: -1.2, z: -6.3, g: 3, st: 'seated', txt: 'Am Tisch · 3 P.' },
{ id: '06', type: 'round', x: -1.2, z: -8.7, g: 0, st: 'free', txt: 'Frei · 2 P.' },
{ id: '07', type: 'round', x: 1.8, z: -4.0, g: 2, st: 'seated', txt: 'Am Tisch · 2 P.' },
{ id: '08', type: 'long', x: 1.9, z: -6.9, g: 6, st: 'seated', txt: 'Am Tisch · 6 P.' },
{ id: '09', type: 'round', x: 4.8, z: -3.1, g: 2, st: 'soon', txt: 'bis 20:15' },
{ id: '10', type: 'square', x: 4.8, z: -5.6, g: 4, st: 'seated', txt: 'Am Tisch · 4 P.' },
{ id: '11', type: 'square', x: 4.8, z: -8.2, g: 0, st: 'reserved', txt: '20:45 · 4 P.' },
{ id: '12', type: 'round', x: 7.0, z: -7.0, g: 0, st: 'free', txt: 'Frei · 2 P.', axis: 'z' },
{ id: '21', type: 'round', x: -6.2, z: 1.4, g: 2, y: .12 }, { id: '22', type: 'round', x: -4.0, z: 1.4, g: 2, y: .12 },
{ id: '23', type: 'round', x: 3.6, z: 1.4, g: 2, y: .12 }, { id: '24', type: 'round', x: 6.0, z: 1.4, g: 2, y: .12 },
{ id: '25', type: 'round', x: -6.2, z: 3.9, g: 2, y: .12 }, { id: '26', type: 'round', x: -4.0, z: 3.9, g: 1, y: .12 },
{ id: '27', type: 'round', x: 3.6, z: 3.9, g: 2, y: .12 }, { id: '28', type: 'round', x: 6.0, z: 3.9, g: 0, y: .12 },
];
function seatsFor(t) {
const out = [], add = (dx, dz) => out.push({ x: t.x + dx, z: t.z + dz, ry: Math.atan2(-dx, -dz), dx, dz });
if (t.type === 'round') { const d = .72; if (t.axis === 'z') { add(0, -d); add(0, d); } else { add(-d, 0); add(d, 0); } }
else if (t.type === 'square') { const d = .78; add(-d, 0); add(d, 0); add(0, -d); add(0, d); }
else { add(-.45, -.72); add(.45, -.72); add(-.45, .72); add(.45, .72); add(-1.12, 0); add(1.12, 0); }
return out;
}
{
const tl = { round: [], square: [], long: [] }, chairs = [], plates = [], glasses = [], bottles = [];
for (const t of TABLES) {
const y = t.y || 0;
tl[t.type].push({ x: t.x, y, z: t.z });
seatsFor(t).forEach((s, i) => {
chairs.push({ x: s.x, y, z: s.z, ry: s.ry });
if (i < t.g) {
const p = seated({ glass: R() < .45 && !t.cake, cheer: t.cake });
p.position.set(s.x, y, s.z); p.rotation.y = s.ry; world.add(p);
const k = .48 / Math.hypot(s.dx, s.dz);
plates.push({ x: s.x - s.dx * k, y: y + .765, z: s.z - s.dz * k });
const px = -s.dz / Math.hypot(s.dx, s.dz), pz = s.dx / Math.hypot(s.dx, s.dz);
if (!t.cake || i < 2) glasses.push({ x: s.x - s.dx * (k + .12) + px * .13, y: y + .757, z: s.z - s.dz * (k + .12) + pz * .13 });
}
});
if (t.g >= 3 || (t.g === 2 && R() < .5)) bottles.push({ x: t.x + .12, y: y + .757, z: t.z - .08 });
}
for (const k in tl) if (tl[k].length) instanced(tableGeo[k], mats.base, tl[k], world);
instanced(chairGeo, mats.dark, chairs, world);
instanced(plateGeo, mats.base, plates, world);
instanced(wineGeo, mats.glass, glasses, world);
instanced(bottleGeo, mats.dark, bottles, world);
}
```
### E13 – cake at Tisch 04
Zweistöckige Torte mit vier Kerzen und flackernden Flammen auf Tisch 04 (Familie Laurent).
```js
// ---- cake at Tisch 04 ----
const cakePos = V(-1.2, 1.05, -3.6);
const flames = [];
{
const c = new THREE.Group(); c.position.set(-1.2, .757, -3.6); world.add(c);
mesh(new THREE.CylinderGeometry(.16, .16, .015, 16), mats.base, c).position.y = .008;
mesh(new THREE.CylinderGeometry(.13, .13, .08, 16), mats.base, c).position.y = .055;
mesh(new THREE.CylinderGeometry(.09, .09, .06, 16), mats.linen, c).position.y = .125;
for (let i = 0; i < 4; i++) {
const a = i * Math.PI / 2 + .4, x = Math.sin(a) * .05, z = Math.cos(a) * .05;
mesh(new THREE.CylinderGeometry(.006, .006, .07, 5), mats.rouge, c, false).position.set(x, .19, z);
const f = new THREE.Mesh(new THREE.ConeGeometry(.009, .03, 6), flameMat); f.position.set(x, .24, z); c.add(f); flames.push(f);
}
}
```
### E14 – bar
Theke links, Regale mit Flaschen, Spiegel, fünf Barhocker, zwei Gäste an der Bar, Barkeeper mit Schürze.
```js
// ---- bar ----
{
boxMM(-7.25, 0, -8.8, -6.75, 1.05, -3.6, mats.dark, world);
boxMM(-7.3, 1.05, -8.85, -6.62, 1.1, -3.55, mats.base, world);
boxMM(-6.78, 0.1, -8.8, -6.74, 0.14, -3.6, mats.base, world);
for (const y of [1.45, 1.95, 2.45]) boxMM(-8.0, y - 0.03, -8.6, -7.72, y, -3.8, mats.base, world);
boxMM(-7.99, 1.3, -7.6, -7.97, 2.9, -4.8, mats.glass, world);
const bl = [];
for (const y of [1.45, 1.95, 2.45]) for (let z = -8.5; z < -3.9; z += 0.16) if (R() < .82) bl.push({ x: -7.86, y, z, s: lerp(.9, 1.15, R()) });
instanced(bottleGeo, mats.dark, bl.filter((_, i) => i % 3), world);
instanced(bottleGeo, mats.glass, bl.filter((_, i) => !(i % 3)), world);
const st = [-4.3, -5.2, -6.1, -7.0, -7.9].map((z) => ({ x: -6.35, z }));
instanced(stoolGeo, mats.dark, st, world);
for (const z of [-5.2, -7.0]) { const p = seated({ glass: true }); p.position.set(-6.35, .3, z); p.rotation.y = -Math.PI / 2; world.add(p); }
const bt = standing({ top: mats.base, apron: true, hairMat: mats.dark }); bt.position.set(-7.55, 0, -6.2); bt.rotation.y = Math.PI / 2; world.add(bt);
}
```
### E15 – host stand + tablet
Empfangspult mit **flacher** Platte; das Tablet liegt auf einem kleinen Ständer darüber (um 0,38 rad gekippt, zum Eingang gedreht), damit es nicht in der Platte versinkt. Der Bildschirm ist eine Canvas-Textur mit der Tableau-Tagesansicht. Dahinter der Gastgeber mit gesenktem Kopf, hinten ein Kellner mit Tablett.
```js
// ---- host stand + tablet ----
const tabCanvas = document.createElement('canvas'); tabCanvas.width = 768; tabCanvas.height = 544;
const tabTex = new THREE.CanvasTexture(tabCanvas); tabTex.colorSpace = THREE.SRGBColorSpace; tabTex.anisotropy = 8;
{
boxMM(2.2, 0, -1.52, 2.8, 1.0, -1.08, mats.dark, world);
boxMM(2.12, 1.0, -1.54, 2.88, 1.05, -1.06, mats.base, world);
// tablet leans on a small stand above the flat top, turned towards the entrance
const tab = new THREE.Group(); tab.position.set(2.5, 1.05, -1.28); tab.rotation.set(0, -.95, 0); world.add(tab);
mesh(new THREE.BoxGeometry(.2, .05, .05), mats.dark, tab).position.set(0, .03, -.07);
const tilt = new THREE.Group(); tilt.position.set(0, .068, 0); tilt.rotation.x = .38; tab.add(tilt);
mesh(new THREE.BoxGeometry(.38, .014, .27), mats.dark, tilt);
const screen = new THREE.Mesh(new THREE.PlaneGeometry(.35, .248), new THREE.MeshBasicMaterial({ map: tabTex }));
screen.rotation.x = -Math.PI / 2; screen.position.y = .0085; tilt.add(screen);
const host = standing({ top: mats.dark, tablet: true, hair: 0 }); host.position.set(2.62, 0, -1.9); host.rotation.y = -.25; host.userData.head.rotation.x = .38; host.userData.fixedHead = true; world.add(host);
const w1 = standing({ top: mats.base, tray: true, apron: true, hair: 1 }); w1.position.set(0.3, 0, -7.9); w1.rotation.y = .6; world.add(w1);
}
```
### E16 – walking waiter on the terrace
Ein Kellner mit Tablett, der auf der linken Terrassenhälfte hin- und herläuft.
```js
// ---- walking waiter on the terrace ----
const walker = standing({ top: mats.base, tray: true, apron: true, hair: 1, hairMat: mats.dark });
walker.position.set(-4, .12, 2.65); world.add(walker);
```
### E17 – pendant lamps inside
Zehn Pendelleuchten mit roten Schirmen; vier davon haben ein Punktlicht, das ab ca. 19:30 angeht. In der Draufsicht werden die Leuchten ausgeblendet.
```js
// ---- pendant lamps inside ----
const pendants = new THREE.Group(); world.add(pendants);
{
const shade = new THREE.ConeGeometry(.24, .2, 14, 1, true);
for (const [x, z, on] of [[-4.3, -5.6, 1], [-1.2, -3.6, 1], [-1.2, -6.3, 0], [1.9, -6.9, 1], [4.8, -5.6, 1], [1.8, -4.0, 0], [-4.3, -3.0, 0], [4.8, -3.1, 0], [-4.3, -8.2, 0], [4.8, -8.2, 0]]) {
mesh(new THREE.CylinderGeometry(.006, .006, 1.7, 4), mats.dark, pendants, false).position.set(x, 3.35, z);
const s = mesh(shade, mats.shade, pendants); s.position.set(x, 2.45, z);
const b = new THREE.Mesh(new THREE.SphereGeometry(.06, 8, 6), bulbMat); b.position.set(x, 2.36, z); pendants.add(b);
if (on) lamp(x, 2.2, z, 'inside');
}
}
```
### E18 – interior walls: mirror, frames, clock, plants, kitchen door
Spiegel an der Rückwand, Küchentür, Wanduhr, drei Bilderrahmen rechts, drei Topfpflanzen.
```js
// ---- interior walls: mirror, frames, clock, plants, kitchen door ----
{
boxMM(-3.5, 1.4, -9.99, 1.5, 2.7, -9.93, mats.glass, world);
boxMM(-3.6, 1.32, -9.99, 1.6, 1.4, -9.9, mats.dark, world);
boxMM(-3.6, 2.7, -9.99, 1.6, 2.78, -9.9, mats.dark, world);
boxMM(3.2, 0, -9.99, 4.6, 2.6, -9.92, mats.dark, world);
const clock = mesh(new THREE.CylinderGeometry(.32, .32, .06, 20), mats.base, world); clock.rotation.x = Math.PI / 2; clock.position.set(6.2, 2.9, -9.95);
mesh(new THREE.BoxGeometry(.02, .22, .02), mats.dark, world).position.set(6.2, 2.97, -9.9);
for (const [z, w, h, y] of [[-2.2, .8, 1.0, 2.1], [-4.1, 1.1, .8, 2.3], [-8.9, .8, 1.1, 2.0]]) {
boxMM(7.94, y - h / 2, z - w / 2, 7.99, y + h / 2, z + w / 2, mats.dark, world);
boxMM(7.9, y - h / 2 + .07, z - w / 2 + .07, 7.95, y + h / 2 - .07, z + w / 2 - .07, pick([mats.linen, mats.rouge, mats.glass]), world);
}
const leaf = (x, z, sc = 1) => {
const p = new THREE.Group(); p.position.set(x, 0, z); p.scale.setScalar(sc); world.add(p);
mesh(new THREE.CylinderGeometry(.24, .18, .46, 10), mats.rouge, p).position.y = .23;
const lg = [];
for (let i = 0; i < 9; i++) { const a = i * 2.4, r = .1 + R() * .18; lg.push(bake(new THREE.IcosahedronGeometry(.22 + R() * .12, 0), M([Math.sin(a) * r, .7 + R() * .9, Math.cos(a) * r], [R(), R(), R()], [1, .45, .8]))); }
mesh(merged(lg), mats.leaf, p);
};
leaf(7.35, -9.3, 1.3); leaf(-7.4, -9.35, 1.1); leaf(7.4, -0.9, 1.0);
}
```
### E19 – street: trees, lamps, bicycle, chalkboard
Vier Platanen mit facettierten Kronen (hoch angesetzt, damit sie die Fassade nicht verdecken), zwei Straßenlaternen, ein Fahrrad am Baum und ein Kreideaufsteller („Ce soir“) vor der Tür.
```js
// ---- street: trees, lamps, bicycle, chalkboard ----
{
for (const x of [-9.8, -4.9, 4.6, 10.2]) {
const t = new THREE.Group(); t.position.set(x, .12, 5.45); world.add(t);
mesh(new THREE.CylinderGeometry(.11, .18, 5.2, 7), mats.linen, t).position.y = 2.6;
mesh(limb([0, 4.0, 0], [.8, 5.2, .3], .06), mats.linen, t);
mesh(limb([0, 4.3, 0], [-.7, 5.4, -.2], .06), mats.linen, t);
const fg = [];
for (let i = 0; i < 9; i++) { const a = i * 2.1 + x; fg.push(bake(new THREE.IcosahedronGeometry(.6 + R() * .5, 0), M([Math.sin(a) * (.4 + R() * .8), 5.5 + R() * 1.5, Math.cos(a) * (.3 + R() * .5)], [R(), R(), R()], [1.15, .8, 1]))); }
mesh(merged(fg), mats.leaf, t);
boxMM(-.6, .001, -.6, .6, .02, .6, mats.dark, t, false);
}
for (const x of [-7.3, 7.6]) {
const l = new THREE.Group(); l.position.set(x, .12, 5.95); world.add(l);
mesh(new THREE.CylinderGeometry(.05, .09, 4.1, 7), mats.dark, l).position.y = 2.05;
mesh(new THREE.BoxGeometry(.3, .42, .3), mats.dark, l).position.y = 4.25;
const b = new THREE.Mesh(new THREE.BoxGeometry(.22, .28, .22), bulbMat); b.position.y = 4.22; l.add(b);
}
const bike = new THREE.Group(); bike.position.set(5.5, .12, 5.95); bike.rotation.set(0, .1, .1); world.add(bike);
for (const x of [-.52, .52]) mesh(new THREE.TorusGeometry(.33, .022, 4, 18), mats.dark, bike).position.set(x, .35, 0);
mesh(merged([limb([-.52, .35, 0], [-.05, .38, 0], .018), limb([-.05, .38, 0], [-.15, .78, 0], .018), limb([-.15, .78, 0], [.38, .78, 0], .018), limb([.38, .78, 0], [.52, .35, 0], .018), limb([-.05, .38, 0], [.38, .78, 0], .018), limb([.38, .78, 0], [.4, .95, 0], .016), limb([.3, .96, -.22], [.5, .96, .22], .014), bake(new THREE.BoxGeometry(.22, .04, .09), M([-.17, .83, 0]))]), mats.dark, bike);
}
const boardCanvas = document.createElement('canvas'); boardCanvas.width = 256; boardCanvas.height = 360;
const boardTex = new THREE.CanvasTexture(boardCanvas); boardTex.colorSpace = THREE.SRGBColorSpace;
const boardMat = new THREE.MeshBasicMaterial({ map: boardTex });
{
const b = new THREE.Group(); b.position.set(1.55, .12, .75); b.rotation.y = -.35; world.add(b);
const f = mesh(new THREE.BoxGeometry(.62, .9, .04), mats.dark, b); f.position.set(0, .45, .14); f.rotation.x = -.22;
const bk = mesh(new THREE.BoxGeometry(.62, .9, .04), mats.dark, b); bk.position.set(0, .45, -.14); bk.rotation.x = .22;
const face = new THREE.Mesh(new THREE.PlaneGeometry(.52, .74), boardMat); face.position.set(0, .47, .165); face.rotation.x = -.22; b.add(face);
}
```
### E20 – sky, clouds, stars
Himmelskugel mit Verlaufs-Shader (Farben ändern sich mit der Uhrzeit), fünf flache Wolken, 260 Sterne, die nachts einblenden.
```js
// ---- sky, clouds, stars ----
const skyU = { top: { value: new THREE.Color() }, hor: { value: new THREE.Color() } };
const sky = new THREE.Mesh(new THREE.SphereGeometry(500, 24, 12), new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false, uniforms: skyU,
vertexShader: 'varying vec3 vP; void main(){ vP = normalize(position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }',
fragmentShader: `uniform vec3 top; uniform vec3 hor; varying vec3 vP;
void main(){ float h = clamp(vP.y * 2.2, 0.0, 1.0); gl_FragColor = vec4(mix(hor, top, pow(h, 0.75)), 1.0);
#include <colorspace_fragment>
}`,
}));
sky.renderOrder = -1; scene.add(sky);
const cloudMat = new THREE.MeshBasicMaterial({ color: '#F7F3EA' });
for (const [x, y, z, s] of [[-60, 42, -160, 1.2], [30, 55, -190, 1.6], [90, 38, -170, 1], [-120, 60, -200, 1.4], [150, 50, -120, 1.2]]) {
const parts = [];
for (let i = 0; i < 6; i++) parts.push(bake(new THREE.SphereGeometry(6 + R() * 5, 8, 5), M([i * 7 - 18 + R() * 4, R() * 3, R() * 4], [0, 0, 0], [1.4, .45, .8])));
const c = new THREE.Mesh(merged(parts), cloudMat); c.position.set(x, y, z); c.scale.setScalar(s); scene.add(c);
}
const starGeo = new THREE.BufferGeometry();
{
const pts = [];
for (let i = 0; i < 260; i++) { const a = R() * Math.PI * 2, e = .12 + R() * 1.3; pts.push(Math.cos(a) * Math.cos(e) * 450, Math.sin(e) * 450, Math.sin(a) * Math.cos(e) * 450); }
starGeo.setAttribute('position', new THREE.Float32BufferAttribute(pts, 3));
}
const starMat = new THREE.PointsMaterial({ color: '#F3D9A4', size: 1.8, sizeAttenuation: false, transparent: true, opacity: 0, depthWrite: false });
scene.add(new THREE.Points(starGeo, starMat));
```
### E21 – canvas textures
Zeichnet die drei Texturen: Markisen-Schriftzug, Kreidetafel und die Tableau-App auf dem Tablet (Datum, vier Reservierungen mit Status-Pills). Wird nach dem Laden der Schriften neu gezeichnet.
```js
/* ================= canvas textures ================= */
function drawTextures() {
// awning valance
let x = signCanvas.getContext('2d');
x.clearRect(0, 0, 2048, 48);
x.fillStyle = '#F3E9D6'; x.textAlign = 'center'; x.textBaseline = 'middle';
x.font = '400 34px "DM Serif Display", Georgia, serif';
if ('letterSpacing' in x) x.letterSpacing = '10px';
x.fillText('CAFÉ DU COIN · BISTRO · VINS', 1024, 26);
signTex.needsUpdate = true;
// chalkboard
x = boardCanvas.getContext('2d');
x.fillStyle = '#2B2C28'; x.fillRect(0, 0, 256, 360);
x.fillStyle = '#EDE6DA'; x.textAlign = 'center'; if ('letterSpacing' in x) x.letterSpacing = '0px';
x.font = 'italic 400 44px "DM Serif Display", Georgia, serif'; x.fillText('Ce soir', 128, 70);
x.font = '500 22px "DM Sans", sans-serif';
['Coq au vin', 'Moules frites', 'Tarte Tatin'].forEach((t, i) => x.fillText(t, 128, 140 + i * 50));
x.font = '400 18px "DM Mono", monospace'; x.fillStyle = '#E3A08F'; x.fillText('Terrasse bis 23 Uhr', 128, 316);
boardTex.needsUpdate = true;
// host tablet: Tableau day view
x = tabCanvas.getContext('2d');
const W = 768, H = 544;
x.fillStyle = '#F6F0E5'; x.fillRect(0, 0, W, H);
x.fillStyle = '#242722'; x.fillRect(0, 0, 70, H);
x.fillStyle = '#A52B20'; x.fillRect(18, 22, 34, 34);
x.fillStyle = '#5E5A52'; for (let i = 0; i < 4; i++) x.fillRect(24, 110 + i * 56, 22, 22);
x.textAlign = 'left'; x.textBaseline = 'alphabetic';
x.fillStyle = '#242722'; x.font = '400 40px "DM Serif Display", Georgia, serif'; x.fillText('Freitag, 25. September', 100, 64);
x.fillStyle = '#68645B'; x.font = '500 20px "DM Sans", sans-serif'; x.fillText('Abend · 38 Reservierungen · Warteliste 2', 100, 98);
const rows = [
['19:30', 'Familie Laurent', '4 P. · Tisch 04 · Geburtstag', 'Am Tisch', '#242722', '#FFFCF7', true],
['19:45', 'Okafor', '3 P. · Tisch 05 · Hochstuhl', 'Bestätigt', '#E6EDE1', '#3C6547'],
['20:15', 'Jansen', '2 P. · Warteliste', '15 Min.', '#F6EACF', '#7C5412'],
['20:30', 'Weber', '2 P. · Tisch 03', 'Bestätigt', '#E6EDE1', '#3C6547'],
];
rows.forEach(([tm, nm, sub, st, bg, fg, sel], i) => {
const y = 128 + i * 100;
x.fillStyle = sel ? '#F8E9E4' : '#FFFCF7'; x.fillRect(96, y, W - 116, 88);
x.strokeStyle = sel ? '#A52B20' : '#E4D8C5'; x.lineWidth = sel ? 3 : 2; x.strokeRect(96, y, W - 116, 88);
x.fillStyle = '#242722'; x.font = '600 28px "DM Sans", sans-serif'; x.fillText(tm, 116, y + 54);
x.fillText(nm, 222, y + 42);
x.fillStyle = '#68645B'; x.font = '400 20px "DM Sans", sans-serif'; x.fillText(sub, 222, y + 70);
x.font = '600 18px "DM Sans", sans-serif';
const tw = x.measureText(st).width + 28;
x.fillStyle = bg; x.fillRect(W - 40 - tw, y + 28, tw, 34);
x.fillStyle = fg; x.fillText(st, W - 40 - tw + 14, y + 51);
});
tabTex.needsUpdate = true;
}
drawTextures();
document.fonts.ready.then(() => { drawTextures(); layout(); });
```
### E22 – camera path
Die Kamerafahrt: Keyframes `[Scroll-Fortschritt, Position, Blickziel, FOV]`, verbunden über zentripetale Catmull-Rom-Kurven. Innerhalb eines Segments wird linear interpoliert. Dazu leichte Maus-Parallaxe und Handkamera-Wackeln (nicht in der Draufsicht). Beim senkrechten Blick nach unten wird der Up-Vektor auf −z gedreht, damit der Raum gerade im Bild steht. Befindet sich die Kamera innerhalb des Gebäudes oberhalb von 3,6 m, werden die ghost-Materialien und Pendelleuchten ausgeblendet. **Die Keyframes sind so gewählt, dass die Kamera nie durch Wände fährt** (u. a. erst mittig durch die Tür, dann zum Tablet; am Ende rückwärts durchs Fenster unterhalb des Kämpfers).
```js
/* ================= camera path ================= */
// [progress, position, look-at target, fov]
const KF = [
[0.00, [0, 3.0, 25], [0, 4.8, 0], 40],
[0.06, [-1.6, 2.8, 21], [0, 4.1, 0], 40],
[0.14, [-6.6, 2.3, 14.5], [-1, 2.4, 1], 42],
[0.23, [-4.7, 1.8, 9.6], [0.4, 1.4, 1.4], 44],
[0.30, [-0.9, 1.7, 4.4], [0, 1.6, -1], 48],
[0.345, [0, 1.66, 0.9], [0.1, 1.5, -3], 50],
[0.38, [0.2, 1.62, -1.55], [2.3, 1.15, -1.5], 50],
[0.41, [2.02, 1.47, -0.84], [2.52, 1.09, -1.3], 42],
[0.47, [1.94, 1.5, -0.78], [2.5, 1.09, -1.31], 40],
[0.53, [0.2, 5.6, -3.3], [-0.6, 0, -5.2], 50],
[0.585, [-1.9, 15.8, -5.0], [-1.9, 0, -5.6], 42],
[0.665, [-1.9, 16.2, -5.0], [-1.9, 0, -5.6], 42],
[0.72, [-2.3, 4.6, -1.8], [-1.2, .8, -3.6], 46],
[0.77, [-2.72, 1.72, -2.12], [-1.2, .86, -3.62], 42],
[0.83, [-2.6, 1.78, -1.84], [-1.2, .9, -3.6], 40],
[0.875, [-2.5, 2.1, 0.3], [-1.4, 1.3, -3.2], 46],
[0.935, [-1.6, 2.8, 9.2], [-0.4, 2.9, 0], 44],
[1.00, [0, 3.2, 25], [0, 4.9, 0], 40],
];
const posCurve = new THREE.CatmullRomCurve3(KF.map((k) => V(...k[1])), false, 'centripetal');
const tgtCurve = new THREE.CatmullRomCurve3(KF.map((k) => V(...k[2])), false, 'centripetal');
const tgt = new THREE.Vector3(), dirV = new THREE.Vector3();
const pointer = { x: 0, y: 0, sx: 0, sy: 0 };
addEventListener('pointermove', (e) => { if (e.pointerType === 'mouse') { pointer.x = e.clientX / innerWidth - .5; pointer.y = e.clientY / innerHeight - .5; } });
function placeCamera(p, t) {
let i = 0;
while (i < KF.length - 2 && p > KF[i + 1][0]) i++;
const u = clamp((p - KF[i][0]) / (KF[i + 1][0] - KF[i][0]));
const s = (i + u) / (KF.length - 1);
posCurve.getPoint(s, camera.position);
tgtCurve.getPoint(s, tgt);
if (!reduce) {
pointer.sx += (pointer.x - pointer.sx) * .05; pointer.sy += (pointer.y - pointer.sy) * .05;
const top = window4(p, .55, .585, .665, .7);
tgt.x += (pointer.sx * .5 + Math.sin(t * .31) * .04) * (1 - top);
tgt.y -= (pointer.sy * .3 + Math.sin(t * .23) * .03) * (1 - top);
}
// looking straight down: steer the roll with a -z up vector so the room stays square to the screen
dirV.copy(tgt).sub(camera.position).normalize();
const vert = smooth(.8, .97, -dirV.y);
camera.up.set(0, 1 - vert, -vert).normalize();
camera.lookAt(tgt);
const aspect = camera.aspect;
camera.fov = lerp(KF[i][3], KF[i + 1][3], u) * (aspect < 1 ? 1.32 : aspect < 1.3 ? 1.12 : 1);
camera.updateProjectionMatrix();
const inside = camera.position.x > -8.4 && camera.position.x < 8.4 && camera.position.z > -10.4 && camera.position.z < 0.3 && camera.position.y > 3.6;
for (const m of ghostList) { m.colorWrite = !inside; m.depthWrite = !inside; }
pendants.visible = !inside;
}
```
### E23 – time of day
Uhrzeit (18:25 → 22:40) und belegte Plätze als Funktion des Scroll-Fortschritts. Die Sonne sinkt von 21° auf −6° und dreht von −36° auf −58° Azimut; danach Nacht: Himmel wird dunkel-bordeaux, Sterne erscheinen, Fenster leuchten warm, Innenlampen ab p≈0,5, Terrassenlichter ab p≈0,72.
```js
/* ================= time of day ================= */
const clockKeys = [[0, 18 * 60 + 25], [.18, 18 * 60 + 40], [.43, 19 * 60 + 10], [.625, 19 * 60 + 40], [.805, 20 * 60 + 45], [.965, 22 * 60 + 30], [1, 22 * 60 + 40]];
const seatKeys = [[0, 32], [.18, 38], [.43, 44], [.625, 47], [.805, 54], [.965, 39], [1, 36]];
const interp = (keys, p) => { let i = 0; while (i < keys.length - 2 && p > keys[i + 1][0]) i++; return lerp(keys[i][1], keys[i + 1][1], clamp((p - keys[i][0]) / (keys[i + 1][0] - keys[i][0]))); };
const C = (h) => new THREE.Color(h);
const SKY = { dayT: C('#A8C1C5'), dayH: C('#E7ECE4'), duskT: C('#8FA4A6'), duskH: C('#EFC6A8'), nightT: C('#2A120F'), nightH: C('#5E221A') };
const CLOUD = { day: C('#F7F3EA'), dusk: C('#F1D0BB'), night: C('#48201A') };
const GLOW = { off: C('#7FA0A4'), on: C('#F4DBA6') };
const BULB = { off: C('#D8C8B0'), on: C('#FFE2A8') };
const tmpC = new THREE.Color();
function setTime(p) {
const el = THREE.MathUtils.degToRad(p < .6 ? lerp(21, 9, p / .6) : p < .8 ? lerp(9, 2, (p - .6) / .2) : lerp(2, -6, (p - .8) / .1));
const az = THREE.MathUtils.degToRad(lerp(-36, -58, p));
const d = V(Math.sin(az) * Math.cos(el), Math.sin(Math.max(el, .02)), Math.cos(az) * Math.cos(el));
sun.position.copy(sun.target.position).addScaledVector(d, 70);
sun.intensity = Math.PI * 1.7 * smooth(-0.02, 0.05, el);
const night = smooth(.76, .93, p), dusk = smooth(.5, .8, p);
hemi.intensity = Math.PI * lerp(.24, .205, night);
skyU.top.value.copy(SKY.dayT).lerp(SKY.duskT, dusk).lerp(SKY.nightT, night);
skyU.hor.value.copy(SKY.dayH).lerp(SKY.duskH, dusk).lerp(SKY.nightH, night);
cloudMat.color.copy(CLOUD.day).lerp(CLOUD.dusk, dusk).lerp(CLOUD.night, night);
starMat.opacity = night * .9;
const inside = smooth(.5, .6, p), terrace = smooth(.72, .86, p);
for (const { l, group } of lampLights) l.intensity = (group === 'inside' ? inside * 3.1 : terrace * 3.4);
tmpC.copy(GLOW.off).lerp(GLOW.on, night); glowWin.color.copy(tmpC); ghost.glow.color.copy(tmpC);
bulbMat.color.copy(BULB.off).lerp(BULB.on, Math.max(terrace, inside * .8));
const dim = lerp(1, .55, night);
signMat.color.setScalar(dim); boardMat.color.setScalar(dim);
}
```
### E24 – DOM overlays
Alles HTML über der Szene: Zeitleiste mit Kapitel-Sprungmarken, Tisch-Labels im Tischplan (per 3D→2D-Projektion positioniert), Tisch 06 wechselt bei p>0,618 von „Frei“ auf „Jansen · 20:15“ mit Puls-Ring, Toast „Tisch 06 zugewiesen“, Gästewunsch-Chips an der Torte, das Aufziehen des Bildrahmens im Hero, Uhr und Sitzplatzzähler, und die Papierkörnung als erzeugte Noise-Textur.
```js
/* ================= DOM overlays ================= */
const stage = $('#stage'), frame = $('#frame'), hero = $('#hero'), film = $('#film');
const labelLayer = $('#labels'), chips = $('#chips'), toast = $('#toast');
const clockEl = $('#clock'), seatsEl = $('#seats'), marker = $('#marker'), track = $('#track');
const CHAPTERS = [[.18, '18:40', 'Terrasse'], [.43, '19:10', 'Empfang'], [.625, '19:40', 'Tischplan'], [.805, '20:45', 'Tisch 04'], [.965, '22:30', 'Feierabend']];
const ticks = CHAPTERS.map(([at, tm, name]) => {
const b = document.createElement('button');
b.className = 'tick'; b.type = 'button'; b.style.left = (at * 100) + '%';
b.innerHTML = `<span>${tm} ${name}</span>`;
b.setAttribute('aria-label', `Zu ${tm} Uhr springen: ${name}`);
b.addEventListener('click', () => scrollTo({ top: film.offsetTop + at * (film.offsetHeight - stage.clientHeight), behavior: reduce ? 'auto' : 'smooth' }));
track.appendChild(b);
return { b, at };
});
const tLabels = TABLES.filter((t) => t.st).map((t) => {
const el = document.createElement('div');
el.className = 'tl ' + t.st; el.innerHTML = `<b>${t.id}</b><span>${t.txt}</span>`;
labelLayer.appendChild(el);
return { t, el, v: V(t.x, .95, t.z) };
});
const t06 = tLabels.find((l) => l.t.id === '06');
let assigned = false;
const proj = new THREE.Vector3();
function toScreen(v) { proj.copy(v).project(camera); return [(proj.x * .5 + .5) * W, (-proj.y * .5 + .5) * H]; }
let W = 1, H = 1, heroTop = 300, finalTop = 72;
function layout() {
const sh = stage.clientHeight, sw = stage.clientWidth;
finalTop = 72;
W = Math.max(1, sw - 32); H = Math.max(1, sh - finalTop - 16);
frame.style.setProperty('--canvas-h', H + 'px');
renderer.setSize(W, H, false);
canvas.style.width = W + 'px'; canvas.style.height = H + 'px';
camera.aspect = W / H;
heroTop = Math.min(hero.offsetTop + hero.offsetHeight + 14, sh * .62);
}
addEventListener('resize', layout);
layout();
// paper grain
{
const c = document.createElement('canvas'); c.width = c.height = 180;
const x = c.getContext('2d'), img = x.createImageData(180, 180);
for (let i = 0; i < img.data.length; i += 4) { const v = 205 + Math.random() * 50; img.data[i] = v; img.data[i + 1] = v * .98; img.data[i + 2] = v * .95; img.data[i + 3] = 255; }
x.putImageData(img, 0, 0);
$('#grain').style.backgroundImage = `url(${c.toDataURL()})`;
}
let lastClock = '', lastSeats = -1;
function updateDOM(p) {
const k = smooth(0, .045, p);
frame.style.top = lerp(heroTop, finalTop, k) + 'px';
hero.style.opacity = String(clamp(1 - k * 1.35));
hero.style.transform = `translateY(${-k * 48}px)`;
hero.style.visibility = k > .98 ? 'hidden' : 'visible';
const mins = Math.round(interp(clockKeys, p));
const cs = String(Math.floor(mins / 60)).padStart(2, '0') + ':' + String(mins % 60).padStart(2, '0');
if (cs !== lastClock) { clockEl.textContent = cs; lastClock = cs; }
const seats = Math.round(interp(seatKeys, p));
if (seats !== lastSeats) { seatsEl.textContent = seats; lastSeats = seats; }
marker.style.left = (p * 100) + '%';
let on = 0; ticks.forEach((t, i) => { if (p >= t.at - .07) on = i; });
ticks.forEach((t, i) => t.b.classList.toggle('on', i === on));
// floor-plan labels
const lo = window4(p, .57, .6, .665, .69);
if (lo > 0.001) {
for (const l of tLabels) {
const [x, y] = toScreen(l.v);
l.el.style.transform = `translate(${x}px, ${y}px) translate(-50%, -50%)`;
l.el.style.opacity = lo;
l.el.style.display = '';
}
const isA = p > .618;
if (isA !== assigned) {
assigned = isA;
t06.el.className = 'tl ' + (isA ? 'assigned' : 'free') + (isA && !reduce ? ' pulse' : '');
t06.el.querySelector('span').textContent = isA ? 'Jansen · 20:15' : 'Frei · 2 P.';
}
} else for (const l of tLabels) l.el.style.display = 'none';
toast.style.opacity = window4(p, .615, .63, .665, .685);
toast.style.transform = `translateX(-50%) translateY(${(1 - smooth(.615, .63, p)) * 10}px)`;
// guest wishes at Tisch 04
const co = window4(p, .765, .785, .845, .865);
chips.style.opacity = co;
if (co > 0.001) {
const [x, y] = toScreen(cakePos);
const nx = Math.min(x + 40, W - 300);
chips.style.transform = `translate(${Math.max(12, nx)}px, ${Math.max(12, y - 150)}px)`;
}
}
```
### E25 – animation
Lebendigkeit: Köpfe drehen sich, Familie Laurent stößt im Torten-Kapitel an, andere Gäste nippen gelegentlich, der Kellner läuft mit Beinschwung, Kerzen flackern. Die Hauptschleife glättet den Scroll-Fortschritt (Trägheit), rendert nur, solange der Film sichtbar ist, und respektiert `prefers-reduced-motion`.
```js
/* ================= animation ================= */
function animate(t, p) {
for (const g of people) {
const u = g.userData;
if (reduce) break;
if (!u.fixedHead) u.head.rotation.y = Math.sin(t * .45 + u.ph) * .32;
if (u.cheer) u.arm.rotation.x = -1.05 * window4(p, .77, .79, .85, .87) * (0.85 + Math.sin(t * 2 + u.ph) * .15);
else if (u.sip && !u.legs) u.arm.rotation.x = -.95 * Math.pow(Math.max(0, Math.sin(t * .33 + u.ph)), 10);
}
// walking waiter: back and forth on the left half of the terrace
if (!reduce) {
const span = 5.2, sp = .75, cyc = (t * sp) % (span * 2), fwd = cyc < span;
const x = -6.9 + (fwd ? cyc : span * 2 - cyc);
walker.position.x = x; walker.rotation.y = fwd ? Math.PI / 2 : -Math.PI / 2;
const sw = Math.sin(t * sp * 5.6) * .42;
walker.userData.legs[0].rotation.x = sw; walker.userData.legs[1].rotation.x = -sw;
walker.position.y = .12 + Math.abs(Math.cos(t * sp * 5.6)) * .025;
flames.forEach((f, i) => { f.scale.y = 1 + Math.sin(t * 17 + i * 2) * .18; });
}
}
let pS = null, last = performance.now();
function readP() {
const r = film.getBoundingClientRect();
return clamp(-r.top / Math.max(1, film.offsetHeight - stage.clientHeight));
}
function frameLoop(now) {
requestAnimationFrame(frameLoop);
const dt = Math.min(.05, (now - last) / 1000); last = now;
const pt = readP();
if (pS === null || reduce) pS = pt; else pS += (pt - pS) * (1 - Math.exp(-dt * 5.5));
if (film.getBoundingClientRect().bottom < 0) return;
const t = now / 1000;
setTime(pS);
placeCamera(pS, t);
animate(t, pS);
sky.position.copy(camera.position);
renderer.render(scene, camera);
updateDOM(pS);
}
requestAnimationFrame(frameLoop);
</script>
```
---
## Abschlussprüfung (vor der Ausgabe selbst durchgehen)
1. Ist die Datei vollständig (A, B, C, D und E1–E25, in dieser Reihenfolge)?
2. Sind alle Zahlenwerte, Farben, Texte und Keyframes unverändert übernommen?
3. Wird Three.js r170 per Importmap geladen und `mergeGeometries` aus `three/addons/utils/BufferGeometryUtils.js` importiert?
4. Öffne die Seite über einen lokalen Server und scrolle einmal komplett durch: Hero → Terrasse → Empfang/Tablet → Tischplan → Torte → Nacht → „Für wen“ → CTA → Footer. Die Browser-Konsole muss fehlerfrei sein.2. Software Factory
Eine Website für eine Claude-Code-Agentur, deren Hero eine isometrische 3D-Fabrik ist. Beim Scrollen fährt die Kamera an einem Förderband entlang durch sechs Stationen, von der Spezifikation bis zum Deployment. Kleine orange Bots arbeiten an den Stationen, und ein Werkstück verwandelt sich von der Idee bis zur verschickten Kiste. Danach schiebt sich die normale Website über die Szene.
Technik: Three.js r128 mit weichem Clay-Look, eine einzige HTML-Datei.
Prompt anzeigenPrompt ausblenden839 Zeilen
# Prompt: „Software Factory“ – interaktive 3D-Website für eine Claude-Code-Agentur
> Kopiere alles ab der nächsten Linie in Claude (oder ein anderes Coding-Modell) und schicke es ab.
---
Du bist ein Senior Creative Developer mit Erfahrung in Three.js, WebGL und scroll-getriebenen Landingpages. Baue mir exakt die unten spezifizierte Website nach. Halte dich bei **allen** Zahlen, Farben, Texten, Koordinaten und Code-Bausteinen genau an diese Spezifikation. Wo Code vorgegeben ist, übernimm ihn wörtlich. Wo nur beschrieben ist, setze es genau so um. Erfinde keine zusätzlichen Features, lass nichts weg und ändere keine Texte.
## 0. Rahmenbedingungen
- **Eine einzige Datei `index.html`**, kein Build-Schritt, kein Framework. Alles CSS und JS inline.
- **Three.js r128 als UMD-Build**, genau diese Zeile vor dem eigenen Script:
`<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>`
(r128, weil der Code `renderer.outputEncoding`, `THREE.sRGBEncoding` und `texture.encoding` nutzt.)
- **Google Fonts**, genau dieser Link:
`https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Bricolage+Grotesque:opsz,wght@12..96,500..800&display=swap`
- Sprache der Seite: Deutsch. `<title>Die Claude-Code-Werft</title>`, `<meta charset="utf-8">`, Viewport-Meta mit `viewport-fit=cover`.
- Das gesamte Script steckt in einer IIFE mit `'use strict'`.
- Es gibt **keine** Konturlinien (Outlines) an 3D-Objekten. Der Look ist weiches „Clay“-Rendering: abgerundete Formen, weiche Schatten, kräftige, warme Farben auf Creme.
## 1. Konzept in einem Satz
Eine Landingpage, deren Hero eine isometrische 3D-Fabrik ist: Beim Scrollen fährt die Kamera an einem Förderband entlang durch sechs Stationen (Spezifikation → Architektur → Planung → Implementierung → Qualitätssicherung → Deployment). Kleine orange Claude-Bots arbeiten an den Stationen, und ein Werkstück verwandelt sich von der Idee bis zur verschickten Kiste. Nach Station 6 schiebt sich die normale Website (Leistungen, Arbeitsweise, Zusammenarbeit, Kontakt, Footer) über die Szene.
## 2. Design-Tokens (CSS)
```css
:root{
--cream:#F6F0E6; --paper:#FFFBF5; --sand:#EAD9C0; --line:#E7DBC8;
--orange:#EE6A3C; --orange-h:#F3845C; --orange-deep:#C9491D;
--ink:#1E1D1B; --ink-2:#44403A; --muted:#766F64; --sage:#7DAA5A; --butter:#F6BE45; --night:#1E1D1B;
--display:'Bricolage Grotesque', 'Arial Rounded MT Bold', ui-sans-serif, system-ui, sans-serif;
--sans:'Instrument Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
--mono:'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
color-scheme: light;
}
*{box-sizing:border-box}
body{margin:0; background:var(--cream); color:var(--ink); font:16px/1.55 var(--sans); -webkit-font-smoothing:antialiased; overflow-x:hidden}
a{color:inherit}
:focus-visible{outline:2px solid var(--ink); outline-offset:2px}
.sr{position:absolute!important; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap}
```
Die Seite ist bewusst **nur hell** (kein Dark Mode).
**Farben der 3D-Szene** (sRGB-Hex, werden im Code nach linear konvertiert):
```js
const C={cream:0xF6F0E6, paper:0xFFF8EE, white:0xFFFFFF, floor:0xEFE9DF, pad:0xE8DDCB, sand:0xE2CFB2, orange:0xEE6A3C, orangeDeep:0xD4532A, ink:0x1E1D1B, slate:0x4A4540, dark:0x2C2A27,
butter:0xF6BE45, sky:0x5A9BE3, sage:0x7DAA5A, pink:0xF39AB6, lilac:0xB9A8E8, mint:0x9FD8C4, kraft:0xDDA56A, tape:0xEFC38E, red:0xE85D6F, ray:0xFFF3E4};
```
## 3. Ebenen und DOM-Struktur
Reihenfolge im `<body>` und z-index:
1. Unsichtbares SVG mit `<symbol id="bot">` (das Maskottchen, siehe unten).
2. `<canvas id="scene">`: `position:fixed; inset:0; width:100%; height:100%; display:block; z-index:0`.
3. `<div id="labels">`: `position:fixed; inset:0; z-index:1; pointer-events:none; overflow:hidden; transition:opacity .3s`. Enthält die Sprechblase `<div class="bubble" id="bubble"><span class="b-name"></span><span class="b-text"></span></div>` und alle 3D-Beschriftungen (werden per JS eingefügt).
4. `<div class="hud">`: `position:fixed; inset:0; z-index:4; pointer-events:none`. Klickbare Kinder haben die Klasse `ui` mit `pointer-events:auto`. Enthält Topbar, Hero, Phasenleiste (Rail), Phasenkarte (Card) und Tempo-Steuerung.
5. `<main>` mit:
- `<div id="track">`: `position:relative; z-index:1; height:700vh; pointer-events:none`. Darin **sieben** `<section>` à `height:100vh`, jeweils nur mit einer `.sr`-Überschrift für Screenreader: „Dein nächstes Softwareprojekt auf Autopilot“ (diese Section hat `id="top"`), „Phase 1: Spezifikation“, „Phase 2: Architektur“, „Phase 3: Planung“, „Phase 4: Implementierung“, „Phase 5: Qualitätssicherung“, „Phase 6: Deployment“.
- `<div class="content">`: `position:relative; z-index:3; background:var(--cream)` mit einem Verlauf nach oben: `.content::before{content:""; position:absolute; left:0; right:0; top:-140px; height:140px; background:linear-gradient(rgba(246,240,230,0), var(--cream)); pointer-events:none}`. Darin die normalen Website-Sektionen und der Footer.
### 3.1 Maskottchen-SVG (exakt so)
```html
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<symbol id="bot" viewBox="0 0 40 46">
<g fill="none" stroke="#EE6A3C" stroke-width="3" stroke-linecap="round">
<path d="M15 29 C15 35 14.5 38 13.5 41"/><path d="M25 29 C25 35 26 38 27 41"/>
<path d="M6.5 18 C3 21 2 25 2.8 28.5"/><path d="M33.5 15 C37 12.5 38 9 37.4 5.5"/>
</g>
<ellipse cx="11.6" cy="42.4" rx="3.6" ry="2.1" fill="#EE6A3C"/><ellipse cx="28.9" cy="42.4" rx="3.6" ry="2.1" fill="#EE6A3C"/>
<circle cx="2.9" cy="29.5" r="2.2" fill="#EE6A3C"/><circle cx="37.3" cy="4.4" r="2.2" fill="#EE6A3C"/>
<rect x="6" y="2" width="28" height="28" rx="8.5" fill="#EE6A3C"/>
<g stroke="#FFF3E4" stroke-width="1.9" stroke-linecap="round">
<path d="M22 16H29"/><path d="M21.7 17L26.5 19.75"/><path d="M21 17.7L24.5 23.8"/><path d="M20 18V23.5"/><path d="M19 17.7L15.5 23.8"/><path d="M18.3 17L13.5 19.75"/>
<path d="M18 16H11"/><path d="M18.3 15L13.5 12.25"/><path d="M19 14.3L15.5 8.2"/><path d="M20 14V8.5"/><path d="M21 14.3L24.5 8.2"/><path d="M21.7 15L26.5 12.25"/>
</g>
</symbol>
</svg>
```
Ein abgerundeter oranger Würfel mit cremefarbenem Strahlen-Emblem, gebogenen Nudel-Armen (rechter Arm winkt) und Beinen mit ovalen Füßen. Verwendung immer über `<svg viewBox="0 0 40 46"><use href="#bot"/></svg>`.
## 4. HUD im Detail
### 4.1 Topbar (oben rechts, **kein Logo**)
```html
<header class="topbar">
<nav class="top-actions ui" aria-label="Hauptnavigation">
<a class="pill pill-ghost" href="#leistungen">Leistungen</a>
<a class="pill pill-orange" href="#kontakt">Projekt anfragen</a>
</nav>
</header>
```
```css
.topbar{position:absolute; top:0; left:0; right:0; display:flex; justify-content:flex-end; align-items:center; gap:12px; padding:calc(16px + env(safe-area-inset-top,0px)) 20px 0}
.top-actions{display:flex; gap:8px}
.pill{display:inline-flex; align-items:center; justify-content:center; gap:8px; height:42px; padding:0 20px; border-radius:999px; font:600 15px/1 var(--sans); text-decoration:none; border:0; cursor:pointer; transition:background .15s, transform .15s}
.pill:active{transform:translateY(1px)}
.pill-orange{background:var(--orange); color:var(--ink)}
.pill-orange:hover{background:var(--orange-h)}
.pill-ghost{background:var(--paper); color:var(--ink); box-shadow:0 8px 20px -14px rgba(30,20,10,.5)}
.pill-ghost:hover{background:#fff}
```
### 4.2 Hero (unten links, direkt auf dem Boden der 3D-Szene, **ohne** Kästen oder Pillen)
```html
<div class="hero" id="hero">
<p class="hero-eyebrow"><svg viewBox="0 0 40 46" aria-hidden="true"><use href="#bot"/></svg>Claude-Code-Agentur</p>
<h1>Dein nächstes Software­projekt auf <em>Autopilot.</em></h1>
<p class="hero-sub">In unserer Software Factory bauen Claude-Code-Agenten deine Web-App, Mobile-App oder dein internes Tool in sechs Phasen. Menschen prüfen jede Änderung, bevor sie live geht.</p>
<div class="hero-meta"><span class="go">Scroll dich durch die Factory <span class="arrow">↓</span></span><span>Beispielprojekt: Kundenportal</span></div>
</div>
```
```css
.hero{position:absolute; left:clamp(20px,3vw,48px); bottom:calc(clamp(24px,5vh,56px) + env(safe-area-inset-bottom,0px)); width:min(42vw, 640px); transition:transform .2s}
.hero-eyebrow{display:flex; align-items:center; gap:10px; margin:0 0 14px; font:500 12px/1 var(--mono); letter-spacing:.08em; text-transform:uppercase; color:var(--ink-2)}
.hero-eyebrow svg{width:26px; height:30px; animation:bob 1.6s ease-in-out infinite}
@keyframes bob{50%{transform:translateY(-3px) rotate(-3deg)}}
.hero h1{font-family:var(--display); font-weight:800; font-size:clamp(38px,4.3vw,76px); line-height:.94; letter-spacing:-.045em; margin:0; text-wrap:balance; font-variation-settings:"opsz" 96}
.hero h1 em{font-style:normal; color:var(--orange)}
.hero-sub{margin:20px 0 0; max-width:40ch; font-size:17px; line-height:1.5; color:var(--ink-2)}
.hero-meta{display:flex; flex-wrap:wrap; align-items:center; gap:10px 18px; margin-top:22px; font:500 13px/1 var(--mono); color:var(--muted)}
.hero-meta .go{color:var(--ink); display:inline-flex; gap:8px; align-items:center}
.hero-meta .arrow{display:inline-block; animation:nudge 1.4s ease-in-out infinite; color:var(--orange); font-size:16px}
@keyframes nudge{50%{transform:translateY(3px)}}
```
### 4.3 Phasenleiste links (Rail)
`<nav class="rail ui" id="rail" aria-label="Phasen" style="opacity:0"></nav>`. JS erzeugt sechs Buttons: `<button type="button"><span class="r-no">01</span><span class="r-name">Spezifikation</span></button>` usw. Ein Klick springt zur Phase.
```css
.rail{position:absolute; left:20px; top:50%; transform:translateY(-50%); display:flex; flex-direction:column; gap:2px; transition:opacity .3s}
.rail button{appearance:none; border:0; background:transparent; display:flex; align-items:center; gap:10px; padding:5px 14px 5px 5px; border-radius:999px; font:500 13px/1 var(--sans); color:var(--muted); cursor:pointer; text-align:left}
.rail .r-no{font:600 10px/1 var(--mono); width:26px; height:26px; border-radius:50%; display:grid; place-items:center; background:var(--paper); color:var(--ink-2)}
.rail button:hover{color:var(--ink)}
.rail button.on{background:var(--paper); color:var(--ink); font-weight:600; box-shadow:0 8px 20px -14px rgba(30,20,10,.5)}
.rail button.on .r-no{background:var(--orange); color:var(--ink)}
.rail button.done .r-no{background:#F9D7C7}
```
Der aktive Button bekommt zusätzlich `aria-current="step"`.
### 4.4 Phasenkarte unten rechts
```html
<aside class="card ui" id="card" aria-live="polite" style="opacity:0">
<div class="card-top"><span id="cardNo">PHASE 01 / 06</span><span class="card-dur" id="cardDur"></span></div>
<div class="card-body" id="cardBody">
<h2 class="card-title" id="cardTitle"></h2>
<p class="card-text" id="cardText"></p>
<ul class="arts" id="cardArts"></ul>
</div>
<div class="term" aria-hidden="true">
<div class="term-line"><span class="term-prompt">❯</span><span id="termCmd"></span><span class="caret"></span></div>
<div class="term-out" id="termOut"></div>
</div>
<a class="pill pill-orange card-cta" id="cardCta" href="#kontakt" hidden>Projekt anfragen</a>
<div class="segs" id="segs"></div>
</aside>
```
```css
.card{position:absolute; right:24px; bottom:calc(24px + env(safe-area-inset-bottom,0px)); width:min(440px, calc(100% - 32px)); background:var(--paper); border-radius:26px; padding:22px 24px 18px; box-shadow:0 30px 60px -34px rgba(70,40,15,.45); transition:opacity .3s}
.card-top{display:flex; justify-content:space-between; align-items:center; font:600 11px/1 var(--mono); letter-spacing:.06em; color:var(--orange-deep)}
.card-top .card-dur{font:500 11.5px/1 var(--mono); letter-spacing:0; color:var(--ink-2); background:var(--cream); border-radius:999px; padding:6px 10px}
.card-body{transition:opacity .22s, transform .22s}
.card-body.swap{opacity:0; transform:translateY(6px)}
.card-title{font:800 34px/1.02 var(--display); letter-spacing:-.03em; margin:10px 0 8px}
.card-text{margin:0; font-size:15px; line-height:1.5; color:var(--ink-2); max-width:44ch}
.arts{list-style:none; padding:0; margin:12px 0 0; display:flex; flex-wrap:wrap; gap:6px}
.arts li{font:500 12px/1 var(--mono); background:var(--cream); border-radius:999px; padding:6px 10px}
.term{margin-top:14px; background:var(--night); color:#EFE8DC; border-radius:14px; font:400 12.5px/1.5 var(--mono); padding:10px 13px; min-height:62px}
.term-line{white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
.term-prompt{color:var(--orange); margin-right:8px}
.caret{display:inline-block; width:7px; height:14px; background:var(--orange); vertical-align:-2px; margin-left:2px; border-radius:2px; animation:blink 1s steps(1) infinite}
@keyframes blink{50%{opacity:0}}
.term-out{color:#B6D58F; opacity:0; transition:opacity .3s; white-space:nowrap; overflow:hidden; text-overflow:ellipsis}
.term-out.show{opacity:1}
.card-cta{margin-top:14px}
.card-cta[hidden]{display:none}
.segs{display:flex; align-items:center; gap:6px; margin-top:16px}
.seg{appearance:none; border:0; padding:0; width:12px; height:12px; border-radius:999px; background:var(--sand); cursor:pointer; position:relative; overflow:hidden; transition:width .35s, background .2s}
.seg.done{background:var(--orange)}
.seg.on{width:68px}
.seg.on i{position:absolute; inset:0 auto 0 0; width:calc(var(--f,0) * 100%); background:var(--orange); border-radius:999px; min-width:12px}
.seg:hover{background:#DFC9A8}
.seg.done:hover{background:var(--orange-deep)}
```
`#segs` enthält sechs Buttons `<button type="button" class="seg" aria-label="Zu Phase 1: Spezifikation"><i></i></button>`. Die bereits erledigten Phasen sind orange Punkte, die aktuelle ist ein 68 px breiter Balken, der sich über `--f` (0 bis 1) füllt, die kommenden sind sandfarbene Punkte. Ein Klick springt zur Phase.
**Phasenwechsel:** `cardBody` bekommt die Klasse `swap`, nach 180 ms werden die Inhalte getauscht und `swap` entfernt. Gleichzeitig tippt das Mini-Terminal den Befehl Zeichen für Zeichen (alle 24 ms ein Zeichen). 320 ms nach dem letzten Zeichen erscheint die Ausgabezeile (Klasse `show`). Jeder neue Phasenwechsel bricht einen laufenden Tippvorgang per Token ab.
### 4.5 Tempo-Steuerung (oben rechts unter der Topbar)
```html
<div class="controls ui" id="controls" role="group" aria-label="Tempo der Werft">
<span class="ctl-label">TEMPO</span>
<button type="button" data-speed="0" aria-label="Pause">❚❚</button>
<button type="button" data-speed="1" class="on">1×</button>
<button type="button" data-speed="2">2×</button>
<button type="button" data-speed="4">4×</button>
<span class="ctl-hint">Klick einen Bot an</span>
</div>
```
```css
.controls{position:absolute; right:20px; top:calc(72px + env(safe-area-inset-top,0px)); display:flex; align-items:center; gap:2px; background:var(--paper); border-radius:999px; padding:4px; transition:opacity .3s; box-shadow:0 8px 20px -14px rgba(30,20,10,.5)}
.controls .ctl-label{font:600 10px/1 var(--mono); color:var(--muted); padding:0 8px 0 10px; letter-spacing:.06em}
.controls button{appearance:none; border:0; background:transparent; height:30px; min-width:34px; padding:0 9px; border-radius:999px; font:500 12.5px/1 var(--mono); color:var(--ink-2); cursor:pointer}
.controls button:hover{background:var(--cream)}
.controls button.on{background:var(--night); color:var(--paper)}
.controls .ctl-hint{font:500 12px/1 var(--sans); color:var(--muted); padding:0 12px 0 8px; border-left:1px solid var(--line); margin-left:6px}
```
Der Wert `data-speed` wird zu `timeScale` (0 = Pause). Er skaliert alle Animationen der Welt, aber nicht die Kamera.
### 4.6 3D-Beschriftungen und Sprechblase
```css
.wl{position:absolute; left:0; top:0; white-space:nowrap; will-change:transform; transition:opacity .35s}
.wl.station{display:flex; align-items:center; gap:8px; background:var(--night); color:var(--paper); font:600 13px/1 var(--sans); padding:5px 14px 5px 5px; border-radius:999px; box-shadow:0 8px 18px -10px rgba(30,20,10,.5)}
.wl.station b{font:600 11px/1 var(--mono); background:var(--orange); color:var(--ink); width:26px; height:26px; border-radius:50%; display:grid; place-items:center}
.wl.tag{font:500 11px/1 var(--mono); background:var(--paper); color:var(--ink); border-radius:999px; padding:6px 10px; box-shadow:0 6px 14px -8px rgba(30,20,10,.45)}
.wl.tag.warm{background:var(--butter)}
#labels.far .wl.tag{opacity:0}
.bubble{position:absolute; left:0; top:0; max-width:250px; background:var(--paper); border-radius:18px; padding:10px 14px 11px; font:500 13.5px/1.4 var(--sans); color:var(--ink); opacity:0; transition:opacity .2s; white-space:normal; width:max-content; box-shadow:0 14px 30px -14px rgba(30,20,10,.45)}
.bubble::after{content:""; position:absolute; left:50%; bottom:-6px; width:14px; height:14px; background:var(--paper); border-radius:3px; transform:translateX(-50%) rotate(45deg)}
.bubble.show{opacity:1}
.bubble .b-name{display:block; font:600 10px/1 var(--mono); color:var(--orange-deep); letter-spacing:.06em; margin-bottom:5px}
body.past .card, body.past .rail, body.past .controls, body.past .hero{opacity:0!important; visibility:hidden; pointer-events:none}
body.past #labels{opacity:0}
```
Jede Beschriftung wird pro Frame aus ihrer 3D-Weltposition projiziert: `translate3d(x,y,0) translate(-50%,-100%)`. Liegt sie mehr als 300 px links oder rechts bzw. 100 px oben oder unten außerhalb des Viewports, bekommt sie `visibility:hidden`. Ist die sichtbare Höhe der Kamera größer als 30 Welteinheiten, bekommt `#labels` die Klasse `far`, und die kleinen Tags blenden aus.
## 5. Inhaltssektionen unter der Szene (Texte wörtlich)
```css
.wrap{max-width:1120px; margin-inline:auto; padding-inline:24px}
.sec{padding-block:clamp(72px,10vw,120px)}
.sec + .sec{padding-top:0}
.eyebrow{font:600 11px/1 var(--mono); letter-spacing:.08em; color:var(--orange-deep); margin:0 0 18px}
.sec-title{font:800 clamp(34px,4.6vw,58px)/1 var(--display); letter-spacing:-.035em; margin:0 0 16px; max-width:20ch; text-wrap:balance}
.lede{font-size:18px; color:var(--ink-2); max-width:58ch; margin:0}
.products{display:grid; grid-template-columns:repeat(3,1fr); gap:16px; margin-top:44px}
.product{background:var(--paper); border-radius:28px; padding:28px 28px 26px}
.product svg{width:52px; height:52px; display:block}
.product h3{font:800 26px/1.1 var(--display); letter-spacing:-.025em; margin:18px 0 8px}
.product p{margin:0 0 18px; color:var(--ink-2)}
.meta{margin:0; display:grid; gap:8px; font:400 12.5px/1.4 var(--mono)}
.meta div{display:grid; grid-template-columns:80px 1fr; gap:8px}
.meta dt{color:var(--muted)}
.meta dd{margin:0; color:var(--ink)}
.principles{display:grid; grid-template-columns:repeat(2,1fr); gap:36px 56px; margin-top:44px}
.principles h3{font:600 18px/1.3 var(--sans); margin:0 0 6px; display:flex; align-items:center; gap:10px}
.principles h3::before{content:""; width:12px; height:12px; border-radius:50%; background:var(--orange); flex:none}
.principles p{margin:0; color:var(--ink-2); max-width:48ch}
.formats{list-style:none; padding:0; margin:44px 0 0; border-bottom:1px solid var(--line)}
.formats li{display:grid; grid-template-columns:48px 230px 150px 1fr; gap:20px; align-items:baseline; padding:24px 0; border-top:1px solid var(--line)}
.formats .f-no{font:600 12px/1 var(--mono); color:var(--ink); background:var(--orange); width:32px; height:32px; border-radius:50%; display:grid; place-items:center; align-self:center}
.formats .f-name{font:800 24px/1.1 var(--display); letter-spacing:-.025em}
.formats .f-dur{font:500 12.5px/1.4 var(--mono); color:var(--ink-2)}
.formats .f-dur span{display:block; color:var(--muted)}
.formats p{margin:0; color:var(--ink-2); max-width:56ch}
.contact{background:var(--orange); color:var(--ink); border-radius:36px; padding:clamp(32px,6vw,72px); position:relative; overflow:hidden}
.contact .eyebrow{color:var(--ink)}
.contact p{color:#3A2418; max-width:52ch; margin:0}
.mail-row{display:flex; flex-wrap:wrap; align-items:center; gap:12px; margin-top:28px}
.mail{font:500 clamp(17px,2.2vw,22px)/1.2 var(--mono); color:var(--ink); background:var(--paper); border-radius:999px; padding:12px 20px; user-select:all}
.contact .pill{background:var(--ink); color:var(--paper)}
.contact .pill:hover{background:#3A3833}
.copy-note{font:600 13px/1 var(--sans); color:var(--ink); min-width:8ch}
.parade{display:flex; gap:18px; margin-top:44px}
.parade svg{width:46px; height:52px; animation:bob 1.2s ease-in-out infinite}
.parade svg:nth-child(2){animation-delay:.15s}.parade svg:nth-child(3){animation-delay:.3s}.parade svg:nth-child(4){animation-delay:.45s}.parade svg:nth-child(5){animation-delay:.6s}
footer{padding-block:28px 40px; font-size:13px; color:var(--muted)}
footer .wrap{display:flex; flex-wrap:wrap; justify-content:space-between; gap:12px}
```
**Sektion 1** (`id="leistungen"`): Eyebrow „LEISTUNGEN“, H2 „Was bei uns vom Band läuft“. Lede: „Wir bauen Produkte, die in Betrieb gehen, keine Demos. Jedes Projekt durchläuft dieselben sechs Phasen, egal ob Web, Mobile oder internes Werkzeug.“ Darunter drei Karten (`article.product`) mit Icon, H3, Text und `dl.meta` (Stack, Dauer):
1. Icon Browser:
`<svg viewBox="0 0 52 52"><rect x="3" y="7" width="46" height="38" rx="9" fill="#1E1D1B"/><rect x="7" y="16" width="38" height="25" rx="5" fill="#FFFBF5"/><circle cx="10" cy="11.5" r="1.8" fill="#EE6A3C"/><circle cx="15" cy="11.5" r="1.8" fill="#F6BE45"/><rect x="11" y="20" width="9" height="17" rx="3" fill="#EAD9C0"/><rect x="23" y="20" width="18" height="6" rx="3" fill="#EE6A3C"/><rect x="23" y="29" width="14" height="3" rx="1.5" fill="#EAD9C0"/></svg>`
„Web-Apps“ / „Kundenportale, SaaS-Produkte, Dashboards und Buchungssysteme, die sich schnell anfühlen und sauber gebaut sind.“ / Stack „Next.js · TypeScript · Supabase“ / Dauer „3–8 Wochen“
2. Icon Handy:
`<svg viewBox="0 0 52 52"><rect x="13" y="3" width="26" height="46" rx="8" fill="#1E1D1B"/><rect x="16" y="9" width="20" height="33" rx="4" fill="#FFFBF5"/><rect x="19" y="13" width="14" height="7" rx="3" fill="#EE6A3C"/><rect x="19" y="23" width="14" height="3" rx="1.5" fill="#EAD9C0"/><rect x="19" y="29" width="10" height="3" rx="1.5" fill="#EAD9C0"/><rect x="22" y="44" width="8" height="2" rx="1" fill="#F6BE45"/></svg>`
„Mobile-Apps“ / „iOS- und Android-Apps aus einer Codebasis, inklusive Push, Offline-Modus und Veröffentlichung in beiden Stores.“ / „React Native · Expo · Swift“ / „4–10 Wochen“
3. Icon Terminal:
`<svg viewBox="0 0 52 52"><rect x="3" y="7" width="46" height="38" rx="9" fill="#1E1D1B"/><path d="M11 17l6 5-6 5" fill="none" stroke="#EE6A3C" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><rect x="21" y="25" width="12" height="3" rx="1.5" fill="#FFFBF5"/><rect x="11" y="33" width="24" height="3" rx="1.5" fill="#7DAA5A"/></svg>`
„Interne Tools“ / „Admin-Oberflächen, Datenpipelines und Agenten-Workflows, die deinem Team jede Woche Stunden sparen.“ / „Python · Claude API · MCP“ / „1–4 Wochen“
**Sektion 2:** Eyebrow „ARBEITSWEISE“, H2 „Agenten am Band, Menschen am Steuer“. Vier Prinzipien im 2×2-Raster (`.principles`, jeweils H3 + p):
- „Spezifikation vor Code“ – „Agenten arbeiten so gut wie ihr Auftrag. Deshalb steht am Anfang immer ein schriftliches, von dir abgenommenes Dokument.“
- „Jede Änderung wird geprüft“ – „Claude Code schreibt, ein Mensch reviewed. Kein Merge ohne Review und ohne grüne Tests.“
- „Du besitzt alles“ – „Repository, Infrastruktur und Zugänge liegen von Tag eins bei dir. Keine Blackbox, kein Lock-in.“
- „Demo statt Statusbericht“ – „Jeden Freitag siehst du lauffähige Software auf einer Preview-URL und gibst direkt Feedback.“
**Sektion 3:** Eyebrow „ZUSAMMENARBEIT“, H2 „So starten wir“. `<ol class="formats">` mit drei `li`, jeweils `span.f-no`, `span.f-name`, `span.f-dur` (mit innerem `span`) und `p`:
- „01“ „Discovery-Sprint“ „1 Woche“ / „Phasen 01–03“ – „Spezifikation, Architektur und Ticket-Plan. Am Ende hast du ein Dokument, mit dem jedes Team bauen könnte, und einen Festpreis für den Build.“
- „02“ „Build-Sprint“ „2–8 Wochen“ / „Phasen 04–06“ – „Unsere Agenten-Crews implementieren, testen und veröffentlichen. Du siehst jede Woche den Stand auf einer Preview-URL.“
- „03“ „Betrieb“ „monatlich“ / „laufend“ – „Monitoring, Updates, Sicherheits-Patches und neue Features. Oder wir übergeben alles sauber an dein eigenes Team.“
**Sektion 4** (`id="kontakt"`): ein oranger Block `.contact`. Eyebrow „KONTAKT“, H2 „Was sollen wir als Nächstes shippen?“, p „Schreib uns zwei, drei Sätze zu deinem Vorhaben. Wir melden uns mit einem Termin für ein Erstgespräch von 30 Minuten.“ Darunter die `.mail-row` mit `<span class="mail" id="mail">hallo@werft.studio</span>`, einem Button `<button class="pill" id="copyMail">Adresse kopieren</button>` und `<span class="copy-note" id="copyNote" aria-live="polite"></span>`. Der Klick ruft `navigator.clipboard.writeText` auf und zeigt „Kopiert“. Scheitert das, wird der Text der Adresse markiert und es erscheint „Markiert, jetzt kopieren“. Darunter `.parade` mit fünf Maskottchen-SVGs, die versetzt wippen.
**Footer:** links „© 2026 Werft · Claude-Code-Agentur“, rechts „Werft ist eine unabhängige Agentur und nicht mit Anthropic verbunden. Claude und Claude Code sind Marken von Anthropic.“
## 6. Phasen-Daten (wörtlich)
```js
const PHASES = [
{name:'Spezifikation', dur:'2–4 Tage', text:'Aus deiner Idee wird ein Dokument, auf das sich alle verlassen können: Ziele, User Stories und Akzeptanzkriterien. Was unklar ist, fragen wir nach, statt es zu raten.', arts:['PRD.md','User Stories','Akzeptanzkriterien'], cmd:'/spec "Kundenportal mit Login und Rechnungen"', out:'✓ PRD.md · 14 User Stories · 41 Kriterien'},
{name:'Architektur', dur:'1–3 Tage', text:'Datenmodell, Schnittstellen und Stack werden festgelegt. Jede Entscheidung landet als ADR im Repository, damit sie auch in einem Jahr noch nachvollziehbar ist.', arts:['ARCHITECTURE.md','ADRs','Datenmodell'], cmd:'/architect --stack nextjs,supabase', out:'✓ 6 ADRs · 9 Tabellen · API-Vertrag v1'},
{name:'Planung', dur:'1–2 Tage', text:'Wir zerlegen das Vorhaben in kleine, testbare Tickets mit klaren Grenzen. So arbeiten mehrere Agenten parallel, ohne sich in die Quere zu kommen.', arts:['Backlog','Tickets','Meilensteine'], cmd:'/plan --parallel 3', out:'✓ 38 Tickets · 4 Meilensteine · 3 Streams'},
{name:'Implementierung', dur:'2–6 Wochen', text:'Claude-Code-Agenten arbeiten parallel, jeder in einem eigenen Git-Worktree. Tests entstehen zusammen mit dem Code, und jede Änderung wird von einem Menschen reviewed.', arts:['Pull Requests','Tests','Commits'], cmd:'claude --worktree feat/rechnungen', out:'✓ PR #87 offen · 23 neue Tests'},
{name:'Qualitätssicherung', dur:'laufend', text:'Unit-, Integrations- und End-to-End-Tests, Security-Review und Code-Review. Was durchfällt, geht mit einer klaren Begründung zurück an die Werkbank.', arts:['Testbericht','Security-Review','Lighthouse 98'], cmd:'/code-review && npm run test:e2e', out:'✓ 412/412 Tests grün · 0 kritische Findings'},
{name:'Deployment', dur:'1–3 Tage', text:'Release auf Vercel, im App Store und bei Google Play, mit Monitoring und Rollback-Plan. Danach übergeben wir Code, Doku und Zugänge an dein Team.', arts:['Web-App','iOS-App','Android-App'], cmd:'git push origin main', out:'✓ Web live · iOS in Review · Android veröffentlicht'}
];
```
Kartenkopf: `PHASE 01 / 06` (zweistellig mit führender Null).
## 7. Three.js-Setup (exakt)
```js
const renderer=new THREE.WebGLRenderer({canvas, antialias:true}); // in try/catch, bei Fehler: html.no-webgl und abbrechen
renderer.setPixelRatio(Math.min(window.devicePixelRatio||1,2));
renderer.setClearColor(0xF6F0E6,1);
renderer.outputEncoding=THREE.sRGBEncoding;
renderer.shadowMap.enabled=true; renderer.shadowMap.type=THREE.PCFSoftShadowMap;
const scene=new THREE.Scene();
const cam=new THREE.OrthographicCamera(-10,10,10,-10,20,320); // near 20 / far 320 verhindert Z-Fighting
const DIR=new THREE.Vector3(-1,1.08,0.82).normalize(); // Blickrichtung: Kamera steht bei -x, oben, +z
const RIGHT=new THREE.Vector3().crossVectors(new THREE.Vector3(0,1,0),DIR).normalize();
const UP=new THREE.Vector3().crossVectors(DIR,RIGHT).normalize();
const lin = c => new THREE.Color(c).convertSRGBToLinear();
scene.add(new THREE.HemisphereLight(lin(0xFFFFFF),lin(0xEEE4D6),0.62));
const sun=new THREE.DirectionalLight(lin(0xFFF8F0),0.62);
const SUN_OFF=new THREE.Vector3(-2,10,4.5).normalize().multiplyScalar(60);
sun.castShadow=true; sun.shadow.mapSize.set(2048,2048); sun.shadow.bias=-0.0006; sun.shadow.normalBias=0.03;
sun.shadow.camera.near=1; sun.shadow.camera.far=160;
scene.add(sun); scene.add(sun.target);
```
Wegen dieser Blickrichtung sieht man die Flächen **-x, +y und +z**. Die Welt-x-Achse läuft auf dem Bildschirm nach **oben rechts**, +z läuft nach **unten rechts**.
Materialien sind `MeshStandardMaterial` (metalness 0, roughness Standard 0.62), gecacht nach Farbe und Rauheit. Bildschirme in der Szene nutzen `MeshBasicMaterial` mit Canvas-Textur. Boards und Blaupausen nutzen `MeshStandardMaterial({map, roughness:0.8})`. Jede CanvasTexture bekommt `encoding=THREE.sRGBEncoding` und maximale Anisotropie. Wenn `document.fonts.ready` erfüllt ist, werden alle Canvas-Texturen neu gezeichnet.
## 8. Geometrie-Helfer (wörtlich übernehmen)
Alle Boxen und Zylinder stehen mit dem **Boden** auf ihrer y-Position (Geometrie um h/2 nach oben verschoben).
```js
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
const lerp=(a,b,t)=>a+(b-a)*t;
const smooth=t=>t*t*(3-2*t);
const pick=a=>a[Math.random()*a.length|0];
function angLerp(a,b,t){ let d=((b-a+Math.PI)%(Math.PI*2)+Math.PI*2)%(Math.PI*2)-Math.PI; return a+d*t; }
const matCache=new Map();
function mat(c,rough){ const k=c+'|'+(rough||0); if(!matCache.has(k)) matCache.set(k,new THREE.MeshStandardMaterial({color:lin(c),roughness:rough||0.62,metalness:0})); return matCache.get(k); }
const geoCache=new Map();
function cached(key,make){ if(!geoCache.has(key)) geoCache.set(key,make()); return geoCache.get(key); }
/* abgerundete Box: unterteilte Box, deren Außenbänder auf Viertelkreise gebogen werden (inkl. korrekter Normalen und UVs) */
function mapAxis(c,half,r,seg){ const n=2*seg+1, k=c*n, a=Math.abs(k); if(a<=0.5) return k*2*(half-r); return Math.sign(k)*((half-r)+(a-0.5)/seg*r); }
function rgeo(w,h,d,r,seg){
seg=seg||2; r=Math.max(0.002,Math.min(r,w/2-1e-4,h/2-1e-4,d/2-1e-4));
return cached(`r${w}|${h}|${d}|${r.toFixed(4)}|${seg}`,()=>{
const n=2*seg+1, g=new THREE.BoxGeometry(1,1,1,n,n,n);
const P=g.attributes.position, N=g.attributes.normal, U=g.attributes.uv, hs=[w/2,h/2,d/2];
const p=[0,0,0], inn=[0,0,0], off=[0,0,0];
for(let i=0;i<P.count;i++){
const o=[P.getX(i),P.getY(i),P.getZ(i)];
for(let a=0;a<3;a++) p[a]=mapAxis(o[a],hs[a],r,seg);
const nx=Math.abs(N.getX(i)), ny=Math.abs(N.getY(i));
const ua=nx>0.5?2:0, va=ny>0.5?2:1;
U.setXY(i, mapAxis(U.getX(i)-0.5,hs[ua],r,seg)/(2*hs[ua])+0.5, mapAxis(U.getY(i)-0.5,hs[va],r,seg)/(2*hs[va])+0.5);
let len=0; for(let a=0;a<3;a++){ inn[a]=clamp(p[a],-(hs[a]-r),hs[a]-r); off[a]=p[a]-inn[a]; len+=off[a]*off[a]; }
len=Math.sqrt(len);
if(len>1e-6){ for(let a=0;a<3;a++) p[a]=inn[a]+off[a]/len*r; N.setXYZ(i,off[0]/len,off[1]/len,off[2]/len); }
P.setXYZ(i,p[0],p[1],p[2]);
}
g.translate(0,h/2,0); return g;
});
}
function pgeo(w,h,d){ return cached(`p${w}|${h}|${d}`,()=>{ const g=new THREE.BoxGeometry(w,h,d); g.translate(0,h/2,0); return g; }); }
/* abgerundeter Zylinder (Lathe mit Radius f an beiden Kanten) */
function rcgeo(r,h,f,s){ f=Math.min(f,r*0.5,h*0.5); return cached(`c${r}|${h}|${f}|${s}`,()=>{
const V=THREE.Vector2, pts=[new V(0,0),new V(r-f,0)];
for(let i=1;i<=6;i++){ const a=-Math.PI/2+i/6*Math.PI/2; pts.push(new V(r-f+Math.cos(a)*f,f+Math.sin(a)*f)); }
for(let i=0;i<=6;i++){ const a=i/6*Math.PI/2; pts.push(new V(r-f+Math.cos(a)*f,h-f+Math.sin(a)*f)); }
pts.push(new V(0,h)); return new THREE.LatheGeometry(pts,s||36); }); }
/* Kapsel, die an ihrem oberen Gelenk (y=0) hängt und bis y=-len reicht */
function capgeo(r,len){ return cached(`k${r}|${len}`,()=>{ const V=THREE.Vector2, pts=[];
for(let i=0;i<=8;i++){ const a=-Math.PI/2+i/8*Math.PI/2; pts.push(new V(Math.max(0,Math.cos(a)*r),-len+Math.sin(a)*r)); }
for(let i=0;i<=8;i++){ const a=i/8*Math.PI/2; pts.push(new V(Math.max(0,Math.cos(a)*r),Math.sin(a)*r)); }
return new THREE.LatheGeometry(pts,14); }); }
function sgeo(r){ return cached(`s${r}`,()=>new THREE.SphereGeometry(r,24,16)); }
const M=(c)=>typeof c==='number'?mat(c):c;
let CUR=scene; // Standard-Elternobjekt: die Welt oder ein Stations-Frame, während diese Station gebaut wird
function mk(g,m,x,y,z,parent){ const o=new THREE.Mesh(g,m); o.position.set(x||0,y||0,z||0); (parent||CUR).add(o); return o; }
function box(w,h,d,c,x,y,z,parent,r){ return mk(rgeo(w,h,d,r===undefined?Math.min(0.2,Math.min(w,h,d)*0.3):r),M(c),x,y,z,parent); }
function pbox(w,h,d,c,x,y,z,parent){ return mk(pgeo(w,h,d),M(c),x,y,z,parent); }
function cyl(r,h,c,x,y,z,parent,f){ return mk(rcgeo(r,h,f===undefined?Math.min(0.08,r*0.3,h*0.3):f),M(c),x,y,z,parent); }
function sph(r,c,x,y,z,parent){ return mk(sgeo(r),M(c),x,y,z,parent); }
function grp(x,y,z,parent){ const g=new THREE.Group(); g.position.set(x||0,y||0,z||0); (parent||CUR).add(g); return g; }
/* Box, bei der genau eine Seite (face: 0=+x 1=-x 2=+y 3=-y 4=+z 5=-z) eine Canvas-Textur trägt */
function tbox(w,h,d,c,face,tex,x,y,z,parent,lit){ const b=mat(c); const ms=[b,b,b,b,b,b]; ms[face]=lit?new THREE.MeshStandardMaterial({map:tex,roughness:0.8}):new THREE.MeshBasicMaterial({map:tex}); return mk(rgeo(w,h,d,Math.min(0.06,Math.min(w,h,d)*0.3)),ms,x,y,z,parent); }
const texDrawers=[];
function canvasTex(w,h,draw){ const cv=document.createElement('canvas'); cv.width=w; cv.height=h; const ctx=cv.getContext('2d'); const tex=new THREE.CanvasTexture(cv); tex.encoding=THREE.sRGBEncoding; tex.anisotropy=renderer.capabilities.getMaxAnisotropy(); const api={ctx,tex,w,h,draw(){ draw(ctx,w,h); tex.needsUpdate=true; }}; api.draw(); texDrawers.push(api); return api; }
const F={sans:'"Instrument Sans", system-ui, sans-serif', mono:'"JetBrains Mono", ui-monospace, monospace', serif:'"Bricolage Grotesque", system-ui, sans-serif'};
function rr(x,X,Y,w,h,r){ x.beginPath(); x.moveTo(X+r,Y); x.arcTo(X+w,Y,X+w,Y+h,r); x.arcTo(X+w,Y+h,X,Y+h,r); x.arcTo(X,Y+h,X,Y,r); x.arcTo(X,Y,X+w,Y,r); x.closePath(); }
```
**Wichtig gegen Flackern:** Keine zwei Flächen dürfen exakt auf derselben Höhe liegen. Aufgesetzte Platten und Ränder ragen immer etwas über den Körper darunter hinaus (siehe die Podeste in Station 6).
## 9. Die Strecke: gerader Lauf, Rechtskurve, gerader Lauf
Das Förderband ist ein Pfad mit der Bogenlänge `s`:
- **Abschnitt A:** von Welt-x = -9 entlang +x, Länge 55. Hier liegen die Stationen 1 bis 3.
- **Viertelkreis nach rechts** (Richtung +z), Radius 7.
- **Abschnitt B:** entlang +z, 59 Einheiten lang. Hier liegen die Stationen 4 bis 6.
Jede Station wird in einem **lokalen Frame** gebaut, in dem das Band entlang lokal +x läuft und die „Rückseite“ der Station bei lokal -z liegt. Frame A ist die Welt selbst. Frame B ist eine um -90° gedrehte Gruppe.
```js
const SX=[0,18,36,54,72,90]; // x-Position jeder Station in ihrem eigenen Frame
const X0=-9, LA=55, RC=7, LARC=Math.PI/2*RC, TX=X0+LA, SB=LA+LARC;
const BELT_LEN=SB+59, BELT_Y=0.69;
function pathAt(s,o){ o=o||{}; if(s<=LA){ o.x=X0+s; o.z=0; o.a=0; } else if(s<=SB){ const t=(s-LA)/RC; o.x=TX+Math.sin(t)*RC; o.z=RC-Math.cos(t)*RC; o.a=-t; } else { o.x=TX+RC; o.z=RC+(s-SB); o.a=-Math.PI/2; } return o; }
const FB=new THREE.Group(); FB.rotation.y=-Math.PI/2; FB.position.set(TX+RC,0,RC+(81-SB)-54); scene.add(FB); FB.updateMatrixWorld(true);
const FR=[scene,scene,scene,FB,FB,FB], SOFF=[9,9,9,27,27,27];
const TH=SX.map(x=>x+1.2); // Verwandlungspunkt im Stations-Frame
const THS=SX.map((x,i)=>x+1.2+SOFF[i]); // derselbe Punkt als Pfadlänge s
function toWorld(frame,x,y,z){ const v=new THREE.Vector3(x,y,z); return frame===scene?v:frame.localToWorld(v); }
function inFrame(i,fn){ const prev=CUR; CUR=FR[i]; fn(); CUR=prev; }
function atPath(s){ const p=pathAt(s); const g=grp(p.x,0,p.z,scene); g.rotation.y=p.a; return g; }
```
`o.a` ist die Drehung um y, mit der ein Objekt in Laufrichtung zeigt.
### 9.1 Boden
```js
const floor=new THREE.Mesh(new THREE.PlaneGeometry(700,700),mat(C.floor,0.9)); floor.rotation.x=-Math.PI/2; floor.receiveShadow=true; scene.add(floor);
const grid=new THREE.GridHelper(700,350,lin(0xE6DCCD),lin(0xE6DCCD)); grid.position.y=0.006; scene.add(grid);
SX.forEach((x,i)=>inFrame(i,()=>box(15.4,0.05,13.4,C.pad,x+0.5,0,-2,null,0.02))); // Stationsflächen
for(let x=-14;x<44;x+=2.4){ box(1.1,0.03,0.16,C.sand,x,0.05,5.1,null,0.012); box(1.1,0.03,0.16,C.sand,x,0.05,2.35,null,0.012); } // Gehweg-Markierung A
inFrame(3,()=>{ for(let x=45;x<106;x+=2.4){ box(1.1,0.03,0.16,C.sand,x,0.05,5.1,null,0.012); box(1.1,0.03,0.16,C.sand,x,0.05,2.35,null,0.012); } }); // Gehweg-Markierung B
```
### 9.2 Förderband
```js
const beltSamples=[]; for(let q=0;q<=BELT_LEN+1e-6;q+=0.5) beltSamples.push(pathAt(q,{}));
const side=(p,o)=>[p.x+Math.sin(p.a)*o, p.z+Math.cos(p.a)*o];
(()=>{ // Bandkörper: Grundriss des Pfads, extrudiert mit weicher Fase
const pts=beltSamples.map(p=>side(p,1.18)).concat(beltSamples.map(p=>side(p,-1.18)).reverse());
const shp=new THREE.Shape(); shp.moveTo(pts[0][0],-pts[0][1]); for(let i=1;i<pts.length;i++) shp.lineTo(pts[i][0],-pts[i][1]);
const g=new THREE.ExtrudeGeometry(shp,{depth:0.38,bevelEnabled:true,bevelThickness:0.12,bevelSize:0.12,bevelSegments:5,steps:1});
g.rotateX(-Math.PI/2); g.translate(0,0.18,0); mk(g,mat(C.paper),0,0,0,scene);
})();
for(let q=2;q<BELT_LEN;q+=8) box(0.5,0.08,2.3,C.slate,0,0,0,atPath(q)); // Füße
const beltTex=canvasTex(256,64,(x,w,h)=>{ x.fillStyle='#D8CDBE'; x.fillRect(0,0,w,h); x.fillStyle='#CBBFAE'; x.fillRect(0,0,14,h); x.fillRect(128,0,14,h); });
beltTex.tex.wrapS=THREE.RepeatWrapping;
const belt=(()=>{ const pos=[],uv=[],nor=[],idx=[]; // Laufband als Streifen, der dem Pfad folgt
beltSamples.forEach((p,i)=>{ const l=side(p,-1.1), r=side(p,1.1); pos.push(l[0],0.684,l[1], r[0],0.684,r[1]); nor.push(0,1,0,0,1,0); const u=i*0.25; uv.push(u,0,u,1); if(i>0){ const a=(i-1)*2; idx.push(a,a+1,a+2, a+1,a+3,a+2); } });
const g=new THREE.BufferGeometry(); g.setAttribute('position',new THREE.Float32BufferAttribute(pos,3)); g.setAttribute('normal',new THREE.Float32BufferAttribute(nor,3)); g.setAttribute('uv',new THREE.Float32BufferAttribute(uv,2)); g.setIndex(idx);
return mk(g,new THREE.MeshStandardMaterial({map:beltTex.tex,roughness:0.9}),0,0,0,scene); })();
[-1.22,1.22].forEach(o=>{ const pts=beltSamples.filter((_,i)=>i%2===0).map(p=>{ const q=side(p,o); return new THREE.Vector3(q[0],0.66,q[1]); }); mk(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(pts),700,0.09,10,false),mat(C.paper),0,0,0,scene); }); // runde Schienen
[18,36,90,108].forEach(q=>{ const g=atPath(q); const d=cyl(0.34,0.1,C.orange,0,0.37,1.28,g); d.rotation.x=Math.PI/2; const d2=cyl(0.13,0.12,C.paper,0,0.37,1.28,g); d2.rotation.x=Math.PI/2; }); // orange Zierscheiben an der Vorderseite
// Einlauf "Deine Idee"
box(3.2,2.3,3.2,C.paper,-11,0,0,null,0.4); box(3.3,0.34,3.3,C.orange,-11,2.2,0,null,0.15); sph(0.75,C.butter,-11,2.9,0);
// Baum in der Innenseite der Kurve
(()=>{ const cx=TX-0.6, cz=RC+0.6; cyl(1.1,0.6,C.paper,cx,0,cz,null,0.2); cyl(1.14,0.14,C.orange,cx,0.5,cz,null,0.06); cyl(0.14,1.3,C.kraft,cx,0.6,cz);
sph(0.95,C.sage,cx,2.3,cz); sph(0.7,C.sage,cx+0.6,1.9,cz+0.3); sph(0.6,C.sage,cx-0.55,2.0,cz-0.2); sph(0.55,0x96C06F,cx+0.1,2.95,cz+0.1); })();
```
Im Loop bewegt sich die Textur: `beltTex.tex.offset.x -= dt*ITEM_SPEED/2`.
### 9.3 Werkstücke, die sich verwandeln
Jedes Werkstück ist eine Gruppe mit acht Varianten, von denen immer genau eine sichtbar ist. Die Variante ergibt sich daraus, wie viele Verwandlungspunkte `THS` das Werkstück schon passiert hat.
```js
const ITEM_SPEED=1.5, ITEM_GAP=4.8, ITEM_S0=-3;
const N_ITEMS=Math.ceil((BELT_LEN+1.2-ITEM_S0)/ITEM_GAP), LOOP=N_ITEMS*ITEM_GAP;
const ideaMat=new THREE.MeshStandardMaterial({color:lin(C.butter),emissive:lin(0xF2A51E),emissiveIntensity:0.35,roughness:0.4});
function variant(k,parent){
const g=grp(0,0,0,parent);
switch(k){
case 0: sph(0.34,ideaMat,0,0.36,0,g); break; // Idee: leuchtende gelbe Kugel
case 1: box(1.0,0.06,1.3,C.white,0,0,0,g); [-0.4,-0.2,0].forEach(z=>pbox(0.64,0.012,0.07,0xC9BFAE,0,0.06,z,g)); pbox(0.32,0.012,0.07,C.orange,-0.16,0.06,0.28,g); break; // Blatt Papier
case 2: box(1.25,0.07,1.25,C.sky,0,0,0,g); pbox(0.44,0.012,0.3,C.white,-0.2,0.07,-0.25,g); pbox(0.44,0.012,0.3,C.white,-0.2,0.07,0.25,g); pbox(0.3,0.012,0.8,0xDCEBFB,0.28,0.07,0,g); break; // Blaupause
case 3: [C.butter,C.mint,C.lilac].forEach((c,i)=>{ const b=box(0.95,0.08,0.62,c,0,i*0.085,0,g); b.rotation.y=[0,0.14,-0.1][i]; }); break; // Ticket-Stapel
case 4: box(1.05,0.5,1.05,C.dark,0,0,0,g); [[C.orange,-0.1,-0.3,0.5],[C.sky,0.05,-0.12,0.6],[C.sage,0.12,0.06,0.4],[C.butter,-0.05,0.24,0.55]].forEach(s=>box(s[3],0.02,0.08,s[0],s[1],0.49,s[2],g,0.01)); break; // Code-Block
case 5: { box(1.05,0.5,1.05,C.paper,0,0,0,g); const a=box(0.14,0.04,0.36,C.sage,-0.16,0.49,0.08,g,0.02); a.rotation.y=0.8; const b=box(0.14,0.04,0.68,C.sage,0.12,0.49,-0.04,g,0.02); b.rotation.y=-0.65; break; } // getestet (grüner Haken)
case 6: box(1.15,0.95,1.15,C.kraft,0,0,0,g); box(0.26,0.97,1.17,C.tape,0,0,0,g,0.04); break; // Versandkiste
case 7: { box(1.05,0.5,1.05,C.red,0,0,0,g); const a=box(0.13,0.04,0.7,C.white,0,0.49,0,g,0.02); a.rotation.y=0.785; const b=box(0.13,0.04,0.7,C.white,0,0.49,0,g,0.02); b.rotation.y=-0.785; break; } // durchgefallen (rotes X)
}
g.visible=false; return g;
}
const items=[];
for(let i=0;i<N_ITEMS;i++){
const g=grp(0,BELT_Y,0); const vs=[]; for(let k=0;k<8;k++) vs.push(variant(k,g));
items.push({g,vs,s:ITEM_S0+i*ITEM_GAP,v:-1,cur:-1,fail:Math.random()<0.2,rej:null,gone:false});
}
function variantAt(q){ let v=0; for(let i=0;i<THS.length;i++) if(q>THS[i]) v=i+1; return v; }
```
**Pro Frame für jedes Werkstück:**
```js
it.s+=dt*ITEM_SPEED;
if(it.s>BELT_LEN+1.2){ it.s-=LOOP; it.fail=Math.random()<0.2; it.rej=null; it.gone=false; it.g.visible=true; it.cur=-1; it.v=-1; }
const v=variantAt(it.s); const P=pathAt(it.s,PT);
if(v!==it.v){
if(it.v===4&&v===5){ // passiert gerade den QA-Scanner
if(it.fail){ it.rej={t:0,x:it.g.position.x,z:it.g.position.z}; qaFail++; laserRed=0.9; qaTex.draw(); say(qa2,'Das hier geht zurück. Edge Case gefunden.'); }
else { qaPass++; qaTex.draw(); }
}
it.v=v;
}
let show=v, px=P.x, py=BELT_Y, pz=P.z, s=1;
if(it.rej){ it.rej.t+=dt/1.1; const q=it.rej.t; if(q>=1){ it.gone=true; } else { show=7; px=lerp(it.rej.x,BINW.x,q); pz=lerp(it.rej.z,BINW.z,q); py=lerp(BELT_Y,BINW.y,q)+Math.sin(Math.PI*q)*2.2; it.g.rotation.z=q*3; } }
else it.g.rotation.z=0;
if(it.gone){ it.g.visible=false; /* weiter zum nächsten Werkstück */ }
if(v>0&&!it.rej){ const d=it.s-THS[v-1]; if(d<0.7) s=1+0.35*Math.sin(Math.PI*d/0.7); } // kurzer "Pop" nach der Verwandlung
if(show!==it.cur){ it.vs.forEach((g,i)=>g.visible=i===show); it.cur=show; }
if(show===0) it.vs[0].position.y=Math.sin(T*3+it.s)*0.06; // Idee schwebt leicht
it.g.position.set(px,py,pz); if(!it.rej) it.g.rotation.y=P.a; it.g.scale.setScalar(s);
```
Etwa 20 % der Werkstücke fallen in der QA durch und fliegen in einem Bogen in die Kiste „Zurück an die Werkbank“.
### 9.4 Pressen über dem Band (alle Stationen außer QA)
```js
const presses=[];
SX.forEach((sx,i)=>{ if(i===4) return; inFrame(i,()=>{
const x=TH[i];
box(0.3,2.7,0.3,C.paper,x,0,1.62); box(0.3,2.7,0.3,C.paper,x,0,-1.62); box(0.56,0.4,3.56,C.paper,x,2.65,0);
const lampMat=new THREE.MeshStandardMaterial({color:lin(C.sage),emissive:lin(C.sage),emissiveIntensity:0.4});
sph(0.15,lampMat,x,3.12,0);
const head=grp(x,2.05,0); cyl(0.07,1.2,C.slate,0,0.3,0,head); box(1.3,0.32,1.3,C.orange,0,0,0,head,0.14);
presses.push({s:THS[i],head,lampMat});
}); });
```
Pro Frame: `near` ist der kleinste Abstand `|it.s - p.s|` aller nicht aussortierten Werkstücke, `pr = clamp(1-near/0.6,0,1)`, `head.position.y = 2.05 - 0.45*smooth(pr)`. Die Lampe (Farbe **und** Emissive) ist bei `pr>0.2` butter, sonst sage.
## 10. Die Bots (3D-Maskottchen)
Aufbau: runder oranger Würfel mit cremefarbenem Strahlen-Emblem auf der Vorderseite (+z), Nudel-Arme und -Beine aus zwei Kapselsegmenten mit Gelenk, runde Hände, ovale Füße. Keine Augen.
```js
const bots=[], hitMeshes=[];
const shadowGeo=new THREE.CircleGeometry(0.6,28);
const shadowMat=new THREE.MeshBasicMaterial({color:lin(0x6B4A2E),transparent:true,opacity:0.12,depthWrite:false});
const hitMat=new THREE.MeshBasicMaterial({visible:false});
const botMat=new THREE.MeshStandardMaterial({color:lin(C.orange),roughness:0.48});
const rayMat=new THREE.MeshStandardMaterial({color:lin(C.ray),roughness:0.55});
const RAYS=[0.24,0.19,0.24,0.19,0.24,0.19,0.24,0.19,0.24,0.19,0.24,0.19];
const CAMF=Math.atan2(DIR.x,DIR.z); // Drehung, mit der ein Bot in die Kamera schaut
function limb(parent,x,y,z,r,l1,l2){
const a=grp(x,y,z,parent); mk(capgeo(r,l1),botMat,0,0,0,a);
const b=grp(0,-l1,0,a); mk(capgeo(r,l2),botMat,0,0,0,b);
return {a,b};
}
function makeBot(o){
const S=o.scale||0.92;
const root=grp(o.x,0,o.z);
const sh=new THREE.Mesh(shadowGeo,shadowMat); sh.rotation.x=-Math.PI/2; sh.position.y=0.02; root.add(sh);
const hop=grp(0,0,0,root), body=grp(0,0,0,hop);
mk(rgeo(1.05,1.05,0.6,0.24,3),botMat,0,0.55,0,body);
const face=grp(0,1.075,0.302,body);
RAYS.forEach((len,i)=>{ const p=grp(0,0,0,face); p.rotation.z=i*Math.PI/6; const m=mk(capgeo(0.03,len),rayMat,0,-0.045,0,p); m.scale.z=0.35; });
const armL=limb(body,-0.52,1.0,0,0.055,0.26,0.24), armR=limb(body,0.52,1.0,0,0.055,0.26,0.24);
sph(0.085,botMat,0,-0.27,0,armL.b).scale.set(1,1.1,0.8); sph(0.085,botMat,0,-0.27,0,armR.b).scale.set(1,1.1,0.8);
const legs=[-0.2,0.2].map(lx=>{ const L=limb(hop,lx,0.62,0,0.065,0.25,0.25); const f=sph(0.12,botMat,0,-0.28,0.05,L.b); f.scale.set(0.95,0.55,1.4); return L; });
const hit=new THREE.Mesh(pgeo(1.5,1.9,1.1),hitMat); root.add(hit);
root.scale.setScalar(S); root.rotation.y=o.face===undefined?CAMF-CUR.rotation.y:o.face;
const b={root,hop,body,armL,armR,legs,S,mode:o.mode||'idle',lines:o.lines,name:o.name,station:o.station,phase:Math.random()*10,hopT:-1,path:o.path||null,pi:0,at:0,wait:0,speed:o.speed||1.4,cheer:0,waitFace:o.waitFace,carry:o.carry!==undefined};
if(b.carry) box(0.7,0.07,0.5,o.carry,0,1.62,0,body,0.03); // trägt ein Blatt/Ticket über dem Kopf
if(o.tool==='lens'){ const ring=new THREE.Mesh(new THREE.TorusGeometry(0.17,0.04,10,28),mat(C.ink)); ring.position.set(0,-0.5,0.08); armR.b.add(ring); const glass=new THREE.Mesh(new THREE.CircleGeometry(0.15,24),new THREE.MeshStandardMaterial({color:lin(0xDCEBFB),transparent:true,opacity:0.75,roughness:0.1})); glass.position.copy(ring.position); armR.b.add(glass); }
hit.userData.bot=b; hitMeshes.push(hit); bots.push(b); return b;
}
const faceTo=(dx,dz)=>Math.atan2(dx,dz);
function setArm(A,x,z,ex,ez,k){ A.a.rotation.x=lerp(A.a.rotation.x,x,k); A.a.rotation.z=lerp(A.a.rotation.z,z,k); A.b.rotation.x=lerp(A.b.rotation.x,ex,k); A.b.rotation.z=lerp(A.b.rotation.z,ez||0,k); }
function updateBot(b,dt,rdt,T){
const t=T+b.phase; let moving=false;
if(b.path){
if(b.wait>0){ b.wait-=dt; if(b.waitFace!==undefined) b.root.rotation.y=angLerp(b.root.rotation.y,b.waitFace[b.at],Math.min(1,dt*5)); }
else{
const tg=b.path[b.pi], dx=tg[0]-b.root.position.x, dz=tg[1]-b.root.position.z, dist=Math.hypot(dx,dz);
if(dist<0.04){ b.wait=1.6; b.at=b.pi; b.pi=(b.pi+1)%b.path.length; }
else{ const st=Math.min(dist,b.speed*dt); b.root.position.x+=dx/dist*st; b.root.position.z+=dz/dist*st; b.root.rotation.y=angLerp(b.root.rotation.y,faceTo(dx,dz),Math.min(1,dt*8)); moving=dt>0; }
}
}
const k=Math.min(1,rdt*10+0.05);
const p=t*9;
b.legs.forEach((L,i)=>{ const ph=p+i*Math.PI; const hip=moving?-Math.sin(ph)*0.55:0; const knee=moving?0.1+Math.max(0,Math.sin(ph+1.3))*0.95:0.05;
L.a.rotation.x=lerp(L.a.rotation.x,hip,0.35); L.b.rotation.x=lerp(L.b.rotation.x,knee,0.35); });
let bob=moving?Math.abs(Math.sin(p))*0.07:(Math.sin(t*2)*0.018+0.018);
b.body.rotation.z=lerp(b.body.rotation.z,moving?Math.sin(p)*0.06:Math.sin(t*1.3)*0.02,0.2);
b.body.rotation.x=lerp(b.body.rotation.x,moving?0.08:0,0.1);
const sw=moving?Math.sin(p)*0.5:Math.sin(t*1.6)*0.05;
let L=[sw,-0.2,-0.3,0], R=[-sw,0.2,-0.3,0]; // [Schulter x, Schulter z, Ellbogen x, Ellbogen z]
switch(b.mode){
case 'type': L=[-1.15+Math.sin(t*15)*0.12,-0.28,-0.75,0]; R=[-1.15+Math.sin(t*15+Math.PI)*0.12,0.28,-0.75,0]; bob+=Math.abs(Math.sin(t*8))*0.015; break;
case 'point': R=[-1.45,0.35+Math.sin(t*2)*0.12,-0.1,0]; break;
case 'write': R=[-1.9+Math.sin(t*6)*0.2,0.45+Math.sin(t*3)*0.3,-0.5,0]; break;
case 'inspect': R=[-1.25,0.25,-0.9,0]; b.body.rotation.y=Math.sin(t*0.9)*0.45; break;
case 'wave': if(Math.sin(t*0.7)>0.3) R=[0,2.55,0,0.55*Math.sin(t*12)]; break;
}
if(b.carry){ L=[0,-2.7,0,-0.9]; R=[0,2.7,0,0.9]; }
if(b.cheer>0){ b.cheer-=dt; L=[0,-2.6,0,0.4*Math.sin(t*14)]; R=[0,2.6,0,-0.4*Math.sin(t*14)]; bob+=Math.abs(Math.sin(t*9))*0.22; }
setArm(b.armL,L[0],L[1],L[2],L[3],k); setArm(b.armR,R[0],R[1],R[2],R[3],k);
b.body.position.y=bob;
if(b.hopT>=0){ b.hopT+=rdt; const q=b.hopT/0.55; if(q>=1){ b.hopT=-1; b.hop.position.y=0; b.hop.rotation.y=0; } else { b.hop.position.y=Math.sin(Math.PI*q)*0.75; b.hop.rotation.y=q*Math.PI*2; } } // Sprung mit Pirouette
}
```
## 11. Die sechs Stationen (Koordinaten im jeweiligen Stations-Frame)
Stationen 1 bis 3 werden direkt in der Welt gebaut. **Vor Station 4 setzt du `CUR=FB;`** und **nach Station 6 wieder `CUR=scene;`**. Alle Positionen unten sind lokal. Die Bot-Zitate erscheinen beim Anklicken, zufällig ausgewählt.
### Station 1 – Spezifikation (x ≈ 0)
- Canvas-Textur `prdTex` (512×320): weißer Grund. „PRD.md“ in `600 30px` Mono bei (26,50), darunter ein oranger abgerundeter Balken (26,62,96×7, Radius 3.5). Text „Ziel: Kunden sehen ihre Rechnungen online“ (`500 19px` Sans, #44403A, bei 26,104). Vier Checkbox-Zeilen ab y=146 im Abstand von 40: „Login per Magic Link“ ✓, „Rechnungen als PDF“ ✓, „Zahlungsstatus live“, „Deutsch und Englisch“. Die Checkbox ist ein 22×22-Rechteck mit Radius 6, erledigt #7DAA5A mit weißem Haken (lineWidth 3.5), offen #EDE3D3. Rechts eine gelbe Haftnotiz (#F6BE45, 370,150, 114×114, Radius 14) mit „?“ in `800 66px` Bricolage bei (410,230).
- Whiteboard: `cyl(0.07,0.85,C.slate,-4.1,0,-6); cyl(0.07,0.85,C.slate,0.1,0,-6); box(0.9,0.08,0.9,C.slate,-4.1,0,-6); box(0.9,0.08,0.9,C.slate,0.1,0,-6); tbox(4.6,2.8,0.16,C.orange,4,prdTex.tex,-2,0.8,-6,null,true);`
- Schreibtisch mit Papierstapeln und Tasse: `box(3.0,0.14,1.5,C.paper,3.4,0.76,-4.9);` vier Beine `cyl(0.06,0.78,C.slate,…)` bei (2.1,-5.5),(4.7,-5.5),(2.1,-4.3),(4.7,-4.3); `box(0.8,0.26,1.0,C.white,2.5,0.9,-4.9); box(0.8,0.42,1.0,C.white,3.5,0.9,-5.0)` (rotation.y 0.12); `cyl(0.15,0.28,C.orange,4.4,0.9,-4.6)`.
- Bot **SPEC-01** bei (-1.2,-4.2), face `Math.PI*1.1`, mode `write`, Zitate: „Bevor ich Code schreibe, frage ich lieber dreimal nach.“, „Akzeptanzkriterium 7: Login klappt auch mit Umlauten.“, „Ich formuliere gerade die User Stories.“, „Was genau heißt „schnell“? Ich schreibe es als Zahl auf.“
- Bot **SPEC-02** bei (3.4,-3.3), path `[[3.4,-3.3],[0.9,-2.05]]`, waitFace `[Math.PI,CAMF]`, carry `C.white`, Zitate: „Frisches PRD, noch warm vom Drucker.“, „Diese Seite geht aufs Band.“, „Ich bringe Anforderungen, keine Vermutungen.“
- Drei aufsteigende Fragezeichen: Sprite mit 64×64-Canvas („?“ orange, `800 54px` Bricolage, zentriert bei 32,50), `scale 0.8`. Pro Frame: `f=((T*0.45+off)%1)`, Position `(spec1.x-0.2+sin(f*6+off*9)*0.3, 1.9+f*1.8, spec1.z+0.3)`, `opacity=sin(PI*f)`, off = 0, 1/3, 2/3.
### Station 2 – Architektur (x ≈ 18)
- Canvas-Textur `bpTex` (512×340): Blau #4F8FDB mit weißem Raster (alle 26 px, rgba(255,255,255,.16)). Weiße abgerundete Boxen (150×60, Radius 12, lineWidth 3, Text `500 20px` Mono bei X+16/Y+37): „UI“ (40,28), „API“ (40,140), „Postgres“ (40,252), „Auth“ (310,140), „PDF-Dienst“ (310,252). Verbindungslinien: (115,88→115,140), (115,200→115,252), (190,170→310,170), (190,190→310,272).
- Zeichentisch: vier Beine `cyl(0.06,1.0,C.slate,…)` bei (14.3,-5.3),(16.9,-5.3),(14.3,-3.9),(16.9,-3.9); Platte `tbox(3.0,0.1,1.9,C.paper,2,bpTex.tex,15.6,1.0,-4.6,null,true)` mit `rotation.x=0.3`.
- Hologramm-Sockel: `cyl(1.3,0.38,C.paper,21,0,-5.4,null,0.12); cyl(1.36,0.12,C.orange,21,0.14,-5.4,null,0.05);`
- Hologramm-Gruppe bei (21,0.38,-5.4), dreht sich mit `dt*0.5`. Material `MeshBasicMaterial({color:lin(C.sky),transparent:true,opacity:0.22,depthWrite:false})` plus Kanten `LineBasicMaterial({color:lin(0x2F74C8)})` über `EdgesGeometry(g,28)`. Das ist die **einzige** Stelle mit Linien. Teile: Zylinder `rcgeo(0.55,0.7,0.001,24)` bei y 0.3, Box `pgeo(1.5,0.5,1.0)` bei y 1.5, Panel `pgeo(1.9,0.95,0.08)` bei y 2.45, Verbinder `pgeo(0.05,0.5,0.05)` bei y 1.0 und `pgeo(0.05,0.45,0.05)` bei y 2.0, Nebenmodul `pgeo(0.5,0.35,0.5)` bei (1.25,1.55,0.2) und Steg `pgeo(0.5,0.05,0.05)` bei (0.75,1.72,0.2). Die drei Hauptteile wippen: `y = basis + sin(T*1.6+i)*0.06`.
- Bot **ARCH-01** bei (19.2,-3.5), face `faceTo(1.2,-1.2)`, mode `point`: „Postgres oder SQLite? Ich halte es als ADR fest.“, „Diese Schnittstelle bekommt einen klaren Vertrag.“, „Weniger Services, mehr Klarheit.“, „Das Datenmodell zuerst, dann der Rest.“
- Bot **ARCH-02** bei (15.6,-2.9), face `Math.PI*0.9`, mode `write`: „Ich zeichne gerade den Datenfluss.“, „Auth läuft über einen eigenen Dienst. Steht so im ADR-003.“
### Station 3 – Planung (x ≈ 36)
- Canvas-Textur `kbTex` (740×340): weiß, zwei senkrechte Trenner #EDE3D3 bei w/3 und 2w/3 (von y=70 bis h-20). Kopfzeile mit farbigem Punkt (r=8) und Titel (`600 24px` Sans): „Backlog“ (#C9BFAE), „In Arbeit“ (#EE6A3C), „Fertig“ (#7DAA5A).
- Board: `cyl(0.08,0.75,C.slate,32.6,0,-6.25); cyl(0.08,0.75,C.slate,39.4,0,-6.25); tbox(7.4,3.4,0.18,C.butter,4,kbTex.tex,36,0.7,-6.3,null,true);`
- Karten: `box(1.9,0.6,0.07,Farbe,…,0.03)` bei z -6.15, Spalten-x `36+[-2.47,0,2.47]`, Zeilen-y `2.8-r*0.8`. Jede Karte hat eine dunkle Linie `pbox(1.1,0.012,0.08,0x6B655C,-0.25,0.35,0.036,karte)` mit `rotation.x=PI/2`. Start: 3 / 2 / 2 Karten. Farben zufällig aus `[C.butter,C.mint,C.lilac,C.pink,C.white,C.sky]`.
- Alle 2.4 s (skalierte Zeit) läuft `kStep`: Hat „Fertig“ drei Karten, wird die erste entnommen. Dann wandert die erste Karte aus „In Arbeit“ nach „Fertig“ und die erste aus „Backlog“ nach „In Arbeit“. Die entnommene Karte kommt mit neuer Zufallsfarbe und Scale 0 ans Ende von „Backlog“. Positionen gleiten mit `dt*5` ins Ziel, Scale wächst mit `dt*6` auf 1.
- Bot **PLAN-01** bei (33.2,-4.3), face `faceTo(-0.3,-1)+0.9`, mode `point`: „Ticket 142 ist klein genug. Weiter.“, „Das hier teile ich in drei Tickets.“, „Parallelisierbar? Ja. Ab an die Werkbank.“, „Jedes Ticket hat eine Definition of Done.“
- Bot **PLAN-02** bei (38.6,-4.4), path `[[38.6,-4.4],[37.4,-2.05]]`, waitFace `[Math.PI,CAMF]`, carry `C.butter`: „Ich bringe das nächste Ticket zur Werkbank.“, „Abhängigkeiten geprüft, das hier kann los.“
### Station 4 – Implementierung (x ≈ 54, Frame B)
- Drei animierte Code-Monitore: Canvas 256×160, Grund #1E1D1B, Zeilennummern-Spalte #2A2825 (24 px) mit Markern #55504A. Elf Zeilen im Abstand von 13 px, jede aus 1 bis 4 abgerundeten Token-Balken (Höhe 6, Breite 12 bis 60, Farben `['#EE6A3C','#5A9BE3','#7DAA5A','#F6BE45','#8A837A','#B9A8E8']`, Einrückung 0 bis 3 Stufen à 14 px). In der letzten Zeile erscheinen die Tokens nacheinander, dahinter blinkt ein oranger Cursor. Alle 0.13 s (skaliert) kommt ein Token hinzu. Ist die Zeile fertig, scrollt der Inhalt eine Zeile hoch.
- Tische bei x `[50.5,54,57.5]`: `box(2.6,0.12,1.3,C.paper,dx,0.76,-5)`, Beine `cyl(0.06,0.76,…)` bei ±1.15 / ±0.5, Monitorfuß `cyl(0.07,0.45,C.slate,dx,0.88,-5.3)` + `box(0.6,0.05,0.4,C.slate,dx,0.88,-5.3)`, Monitor `tbox(1.75,1.08,0.1,C.dark,4,tex,dx,1.2,-5.35)`, Tastatur `box(1.0,0.05,0.35,C.sand,dx,0.88,-4.72,null,0.02)`.
- Bots **DEV-01/02/03** bei (dx,-3.8), face `Math.PI`, mode `type`, jeweils mit eigenen Zitaten:
- DEV-01: „Test zuerst, dann Code.“, „Mein Worktree, meine Baustelle.“, „PR ist offen, jetzt schaut ein Mensch drüber.“
- DEV-02: „Refactor erledigt, alle Tests grün.“, „Ich halte mich an CLAUDE.md. Versprochen.“, „Commit 3 von 7 für dieses Ticket.“
- DEV-03: „Kleiner Diff, schnelles Review.“, „Der PDF-Export hatte einen Bug. Jetzt nicht mehr.“, „Ich lese erst den Code, dann schreibe ich neuen.“
- Roboterarm bei (52.3,0,-2.35): Basis `cyl(0.58,0.38,C.paper,…,0.12)`, Turm-Gruppe bei y 0.38 mit `cyl(0.42,0.3,C.orange,…,0.1)`, Schulter-Gruppe bei y 0.45 mit Kugel r 0.26 orange und `cyl(0.16,1.5,C.paper,…,0.08)`, Ellbogen-Gruppe bei y 1.5 mit Kugel r 0.22 orange und `cyl(0.13,1.25,C.paper,…,0.07)`, Handgelenk-Gruppe bei y 1.25 mit Kugel r 0.16 orange, `box(0.5,0.16,0.3,C.slate,0,0.05,0)` und zwei Greiferfingern `box(0.1,0.3,0.14,C.slate,±0.18,-0.25,0)`. Animation mit `ph=T*1.1`: `turret.rotation.y=-0.2+sin(ph)*0.7; shoulder.rotation.x=0.55+sin(ph*2)*0.18; elbow.rotation.x=1.05+sin(ph*2+1)*0.25; wrist.rotation.y=T*2`.
### Station 5 – Qualitätssicherung (x ≈ 72, Frame B)
- Scanner-Tor bei `QX=TH[4]`: `box(1.4,3.6,0.7,C.paper,QX,0,-1.95,null,0.3); box(1.4,3.6,0.7,C.paper,QX,0,1.95,null,0.3); box(1.4,0.9,4.6,C.paper,QX,3.4,0,null,0.4);` Oben drauf eine gelbe Kapsel `mk(capgeo(0.2,1.6),mat(C.butter,0.45),QX,4.42,0.8)` mit `rotation.x=PI/2`.
- QA-Screen-Textur (256×200, Grund #1E1D1B): „TESTS“ (#8A837A, `500 17px` Mono, 18,32), große Zahl `qaPass` (Start 411, #B6D58F, `600 48px` Mono, 18,92), Fortschrittsbalken (18,112, 220×10, Radius 5, Hintergrund #48443E, Füllung #7DAA5A mit Anteil pass/(pass+fail)), „{qaFail} zurück“ (#F49AA8, `500 16px`, 18,156), „e2e · unit · security“ (#8A837A, 18,182). Montage: `tbox(1.05,0.82,0.06,C.dark,4,qaTex.tex,QX,1.9,2.3)`, roter Knopf `cyl(0.14,0.08,C.red,QX+0.25,1.3,2.3)` mit `rotation.x=PI/2`.
- Laser: `PlaneGeometry(3.2,2.7)`, `MeshBasicMaterial({color:lin(C.sage),transparent:true,opacity:0.18,side:THREE.DoubleSide,depthWrite:false})`, `rotation.y=PI/2`, Position (QX,2.05,0), wird mit `CUR.add` eingefügt. Scan-Balken `box(0.12,0.06,3.2,scanMat,QX,1,0,null,0.03)` mit `MeshBasicMaterial` sage, `y=0.75+(sin(T*2.4)*0.5+0.5)*2.5`. Normalzustand: `opacity=0.14+sin(T*4)*0.05`. Bei einem Ausschuss wird `laserRed=0.9`, dann sind Laser und Balken rot (#E85D6F) mit Opacity 0.32, bis `laserRed` wieder 0 erreicht.
- Ausschuss-Kiste: `BIN={x:75.2,y:0.9,z:-4.6}`, `BINW=toWorld(FB,…)`; `box(1.9,0.9,1.7,C.kraft,BIN.x,0,BIN.z,null,0.2); box(1.6,0.03,1.4,0x9C6B3F,BIN.x,0.89,BIN.z,null,0.01);`
- Bot **QA-01** bei (70.3,-2.7), face `faceTo(0.6,0.5)`, mode `inspect`, tool `lens`: „412 von 412 Tests grün.“, „Security-Review: keine Secrets im Code.“, „Ich schaue mir jede Kante zweimal an.“, „Lighthouse sagt 98. Ich will 100.“
- Bot **QA-02** (`qa2`) bei (76.8,-3.1), Standardblick zur Kamera: „Das hier geht zurück. Edge Case gefunden.“, „Fehler sind hier billiger als in Produktion.“, „Zurück an die Werkbank, mit Begründung im Ticket.“
### Station 6 – Deployment (x ≈ 90, Frame B)
- Web-App-Textur (400×260): Browserfenster in Creme (#FFF8EE), Titelleiste #EDE3D3 (34 px) mit drei Punkten (#EE6A3C, #F6BE45, #7DAA5A), URL-Pille mit „kundenportal.app“, linke Sidebar #F6F0E6 (86 px) mit orangem und drei grauen Balken, Überschrift „Deine Rechnungen“ (`800 21px` Bricolage bei 104,72) und vier weißen Karten (132×64, Radius 12) mit farbigem Status-Balken (grün, grün, orange, grün).
- Handy-Texturen (180×360) per `phoneTex(accent,title)`: Notch oben, farbiger Header (y 40, Höhe 70) mit weißem Titel, fünf weiße Listenzeilen, Button unten. iOS: `#EE6A3C`, „Rechnungen“. Android: `#7DAA5A`, „Zahlungen“.
- Drei Podeste bei x `[85, 88.3, 91.3]`, z -5.7: `box(2.3,0.62,1.6,C.paper,px,0,-5.7,null,0.25); box(2.4,0.16,1.7,C.orange,px,0.6,-5.7,null,0.07);` Die orange Platte ist größer und liegt höher als das Podest, sonst flackert es. Geräte-Gruppe bei (px,0.76,-5.7):
- Web: `box(0.8,0.12,0.5,C.slate)`, `cyl(0.06,0.3,C.slate,0,0.1,0)`, `tbox(2.1,1.35,0.1,C.dark,4,webTex,0,0.36,0)`
- Handys: `box(0.5,0.1,0.4,C.slate)`, `tbox(0.76,1.5,0.12,C.ink,4,tex,0,0.1,0)`
- Sanftes Pendeln: `g.rotation.y=-0.3+sin(T*0.7+i*1.3)*0.28`
- Raketenrampe: `cyl(1.5,0.32,C.paper,96.2,0,-6,null,0.12); cyl(1.56,0.12,C.orange,96.2,0.12,-6,null,0.05);` Raketen-Gruppe bei (96.2,0.32,-6):
- Körper als Lathe (32 Segmente, `mat(C.paper,0.45)`): Punkte `[0,0],[0.3,0],[0.42,0.12],[0.5,0.45],[0.53,1.0],[0.51,1.5],[0.46,1.9],[0,1.9]`
- Nase als Lathe (`mat(C.orange,0.45)`): `[0,1.9],[0.46,1.9],[0.4,2.2],[0.3,2.5],[0.16,2.72],[0,2.8]`
- Fenster `cyl(0.2,0.06,C.sky,0,1.35,0.49,…,0.02)` mit `rotation.x=PI/2`
- drei orange Flossen `box(0.1,0.7,0.46,C.orange,0,0.02,0.66,f,0.05)` in Gruppen mit `rotation.y = a+0.5` für a in `[0, 2.094, 4.189]`
- Düse `cyl(0.26,0.18,C.slate,0,-0.12,0,…,0.05)`
- unsichtbares Klick-Volumen `pgeo(1.8,3.6,1.8)` mit `userData.rocket=true`
- Raketen-Zustandsautomat (skalierte Zeit):
- `idle`: Countdown `next` (Start 9 s), danach `launch()`
- `count` (0.8 s): Rakete zittert (`x=96.2+sin(t*70)*0.05`), 40 % Chance pro Frame auf eine Rauchwolke am Boden (Streuung 1.2)
- `fly`: `v+=dt*10; y+=v*dt`, alle 0.05 s eine Rauchwolke unter der Rakete (Streuung 0.6). Ab y>70 folgt `gone`
- `gone` (2.4 s): Rakete ist unsichtbar (Scale 0.001)
- `ret` (0.7 s): Rakete ploppt mit Überschwinger `(1+0.25*sin(PI*q))*q` wieder auf, danach `idle` mit `next=14`
- `launch()` lässt SHIP-01 und SHIP-02 drei Sekunden lang jubeln (`cheer=3`)
- Rauch: 18 Kugeln r 0.42 (weiß, transparent, roughness 1) als Pool in Frame B. Eine Wolke lebt 1.5 s, wächst von 0.6 auf 2.8, verblasst von 0.9 auf 0 und driftet leicht.
- Bot **SHIP-01** (`dep1`) bei (94.2,-4.0), face `faceTo(1,-1)-0.6`: „Preview-Deployment steht.“, „Countdown läuft. Klick auf die Rakete, wenn du nicht warten willst.“, „Rollback-Plan liegt bereit. Hoffentlich brauchen wir ihn nie.“
- Bot **SHIP-02** (`dep2`) bei (88.3,-3.5), mode `wave`: „Web-App ist live!“, „Die iOS-App ist im App Store Review.“, „Android ist schon veröffentlicht.“, „Übergabe-Doku liegt im Repo.“
- Auslauf „Release“: `box(3.4,2.6,3.4,C.paper,98.8,0,0,null,0.4); box(3.5,0.34,3.5,C.orange,98.8,2.5,0,null,0.15);` Kistenstapel: Kisten `box(1.15,0.95,1.15,C.kraft)` + Klebeband `box(0.26,0.97,1.17,C.tape,…,0.04)` bei (101.8,0,-3.2), (103.1,0,-3.2), (102.4,1.0,-3.2), (101.8,0,-1.9).
### Bots außerhalb der Stationen (Welt-Koordinaten, `CUR=scene`)
- **KONTEXT-01**: path `[[3,3.7],[44,3.7]]`, waitFace `[CAMF,CAMF]`, speed 2.1, carry `C.white`: „Ich bringe Kontext von Phase zu Phase.“, „Übergabe-Notizen stehen im CLAUDE.md.“, „Kein Wissen geht zwischen den Phasen verloren.“ Gehört zu keiner Station (station 0).
- **KONTEXT-02**: Start (TX+RC-3.7, 58), path `[[TX+RC-3.7,58],[TX+RC-3.7,15]]`, waitFace `[CAMF,CAMF]`, speed 2.1, carry `C.butter`: „Feedback aus der QA geht direkt zurück ins Ticket.“, „Unterwegs zur nächsten Station.“
- **EMPFANG** bei (-8.2,3.2), mode `wave`: „Hallo! Hier kommt deine Idee aufs Band.“, „Scroll runter, ich zeige dir die Werft.“, „Jede Idee startet hier als kleine gelbe Kugel.“
### Schatten
Nach dem Aufbau: `scene.traverse`. Jedes Mesh außer dem Boden bekommt `castShadow` und `receiveShadow`, außer es hat `userData.noShadow`, das unsichtbare Hit-Material, das Kontaktschatten-Material oder ein transparentes Material. Hologramm, Laser, Scan-Balken und Rauch sind mit `noShadow` markiert.
## 12. Beschriftungen in der Welt
```js
function addLabel(html,x,y,z,cls,frame){ /* .wl-Element anlegen, Weltposition = toWorld(frame||scene,x,y,z) */ }
PHASES.forEach((p,i)=>addLabel(`<b>${pad2(i+1)}</b>${p.name}`,SX[i]+6,0.1,-8.5,'station',FR[i]));
addLabel('Deine Idee',-11,4.1,0,'tag warm'); addLabel('Release',98.8,3.2,0,'tag',FB);
['feat/rechnungen','feat/login','fix/pdf-export'].forEach((t,i)=>addLabel(t,deskX[i],2.55,-5.35,'tag',FB));
addLabel('Zurück an die Werkbank',75.2,1.3,-4.6,'tag',FB);
addLabel('Web-App',85,2.85,-5.7,'tag',FB); addLabel('iOS',88.3,2.65,-5.7,'tag',FB); addLabel('Android',91.3,2.65,-5.7,'tag',FB);
addLabel('ADR-003',21,3.9,-5.4,'tag');
```
## 13. Kamera und Scroll
- Der Scroll-Fortschritt `p = clamp(scrollY/(trackHeight-innerHeight),0,1)`, daraus `uT = p*6`. Es gibt sieben Keyframes: Index 0 ist die Hero-Ansicht, Index 1 bis 6 sind die Stationen. **Nach Station 6 gibt es kein Rauszoomen.** Die Kamera bleibt stehen, und die Website schiebt sich darüber.
- Die Kamera folgt weich: `uS += (uT-uS)*min(1, rdt*5)` (bei reduced motion `*12`).
```js
function buildKeys(){
const a=W/H, mob=W<760;
const stH=mob?clamp(24/a*0.62,20,34):15.5;
K=[mob?{x:-6,z:0,h:clamp(26/a*0.62,22,40),ox:0.1,oy:-0.22}:{x:-11,z:0,h:21,ox:-0.07,oy:0.22}];
SX.forEach((x,i)=>{ const w=toWorld(FR[i],x+1.2,0,-3.4); K.push({x:w.x,z:w.z,h:stH,ox:mob?0.02:0.07,oy:mob?-0.2:-0.02}); });
}
function dwell(f){ const d=0.2; if(f<=d) return 0; if(f>=1-d) return 1; return smooth((f-d)/(1-2*d)); } // Verweilen an jeder Station
function camAt(u){ const i=Math.min(5,Math.floor(u)), f=u-i, e=dwell(f), A=K[i], B=K[i+1];
let h=lerp(A.h,B.h,e); if(i>=1&&i<=4) h+=Math.sin(Math.PI*e)*5; // beim Wechsel zwischen Stationen kurz rauszoomen
return {x:lerp(A.x,B.x,e),z:lerp(A.z,B.z,e),h,ox:lerp(A.ox,B.ox,e),oy:lerp(A.oy,B.oy,e)}; }
function applyCam(u){
const c=camAt(u), vw=c.h*W/H; curH=c.h;
tgt.set(c.x,0,c.z).addScaledVector(RIGHT,c.ox*vw).addScaledVector(UP,c.oy*c.h).addScaledVector(RIGHT,par.x*0.5).addScaledVector(UP,par.y*0.35);
cam.left=-vw/2; cam.right=vw/2; cam.top=c.h/2; cam.bottom=-c.h/2;
cam.position.copy(tgt).addScaledVector(DIR,150); cam.lookAt(tgt); cam.updateProjectionMatrix();
const fx=tgt.x-tgt.y*DIR.x/DIR.y, fz=tgt.z-tgt.y*DIR.z/DIR.y; // Sonne folgt dem Bildmittelpunkt auf dem Boden
sun.target.position.set(fx,0,fz); sun.position.set(fx,0,fz).add(SUN_OFF);
const s=Math.ceil(c.h*Math.max(1,W/H)*0.6/2)*2;
if(s!==shadowSize){ shadowSize=s; const sc=sun.shadow.camera; sc.left=-s; sc.right=s; sc.top=s; sc.bottom=-s; sc.updateProjectionMatrix(); }
}
```
`ox`/`oy` verschieben den Blickpunkt in Bildschirmanteilen. Im Hero steht dadurch der Einlauf „Deine Idee“ rechts neben der Headline, und das Band läuft nach oben rechts ins Bild. An den Stationen steht die Station links von der Phasenkarte.
- **Maus-Parallaxe** (nicht bei reduced motion): `par` folgt der Mausposition in NDC mit `rdt*3`.
- **Übergang in die Website:** `exitA = 1-clamp((scrollY-max)/(innerHeight*0.3),0,1)`. Karte und Rail haben Opacity `cardA*exitA`, Labels und Tempo-Steuerung haben `exitA`. `body.past` wird gesetzt, wenn `exitA<=0`. Ist `scrollY > max+innerHeight` (Szene ganz verdeckt) und die Kamera eingeschwungen, wird nicht mehr gerendert.
- **HUD pro Frame:** `heroA = 1-clamp((u-0.1)/0.35,0,1)` steuert Opacity, Visibility und `translateY((1-heroA)*16px)` des Hero. `cardA = clamp((u-0.45)/0.3,0,1)*exitA` steuert Karte und Rail (pointer-events erst ab 0.5). `body.in-phase` wird bei `cardA>0.5` gesetzt. Aktive Phase `k = clamp(round(u),1,6)`, Füllstand des aktiven Segments `--f = clamp(u-(k-0.5),0,1)`.
- **Sprung zu Phase k** (Rail oder Segment): `scrollTo({top: k/6*max, behavior: reduce?'auto':'smooth'})`.
## 14. Interaktion
- **Hover** (jeder zweite Frame, nicht über `.ui, .content, .hero, a, button`): Raycast auf `hitMeshes` (nur die unsichtbaren Hit-Boxen, `recursive=false`). Über einem Bot: Cursor `pointer`, und der Bot springt einmal (`hopT=0`), sobald man ihn neu betritt. Über der Rakete: Cursor `pointer`.
- **Klick** auf einen Bot: Sprung plus Sprechblase mit einem zufälligen Zitat und seinem Namen (z. B. „SPEC-01“). **Klick** auf die Rakete: `launch()`, und SHIP-01 sagt „Und ab dafür!“.
- **Sprechblase:** bleibt 3.8 s sichtbar und folgt dem Bot. Weltposition über `root.getWorldPosition()`, y = `(1.75+hop.y)*scale`, auf dem Bildschirm 12 px darüber, zentriert, mit dem Fuß nach unten.
- **Automatisches Plaudern:** Ist die Kamera nahe einer Station (`|uS-round(uS)|<0.2`, Station 1 bis 6), sind seit dem letzten Satz mehr als 7.5 s vergangen, ist keine Blase offen, läuft die Zeit (`timeScale>0`) und ist reduced motion aus, dann sagt ein zufälliger Bot dieser Station etwas.
## 15. Render-Loop (Reihenfolge)
1. `rdt = min(0.05, Frame-Zeit)`, `dt = rdt*timeScale`, `T += dt`
2. Scroll-Glättung, Parallaxe, `applyCam`
3. Bandtextur verschieben, Werkstücke, Pressen
4. `updateBot` für alle Bots
5. Fragezeichen, Hologramm, Kanban, Monitore, Roboterarm, QA-Laser, Geräte-Pendeln, Rakete, Rauch
6. Hover-Raycast, Auto-Plaudern
7. `renderer.render(scene,cam)`
8. Labels und Sprechblase projizieren
9. HUD aktualisieren
Bei `resize`: `renderer.setSize(W,H,false)` und `buildKeys()`.
## 16. Responsive, Barrierefreiheit, Robustheit
```css
@media (max-width:1080px){ .rail{display:none} }
@media (max-width:760px){
.top-actions .pill-ghost, .controls .ctl-hint{display:none}
.pill{height:38px; padding:0 15px; font-size:14px}
.hero{left:16px; right:16px; width:auto; bottom:calc(20px + env(safe-area-inset-bottom,0px))}
.hero h1{font-size:clamp(32px,9.6vw,44px)}
.hero-sub{font-size:15px; margin-top:12px}
.hero-eyebrow{margin-bottom:10px}
.card{left:16px; right:16px; width:auto; bottom:calc(16px + env(safe-area-inset-bottom,0px)); padding:16px 18px 14px; border-radius:22px}
.card-title{font-size:27px; margin:8px 0 6px}
.card-text{font-size:14px}
.arts{display:none}
.term{margin-top:10px; min-height:56px; font-size:11.5px}
.controls{top:calc(66px + env(safe-area-inset-top,0px)); right:16px}
body.in-phase .controls{display:none}
.products, .principles{grid-template-columns:1fr}
.formats li{grid-template-columns:1fr; gap:8px}
}
@media (max-height:640px) and (min-width:761px){ .arts{display:none} .card-text{font-size:14px} .hero-sub{display:none} }
@media (prefers-reduced-motion:reduce){ .hero-eyebrow svg, .parade svg, .hero-meta .arrow, .caret{animation:none} }
.no-webgl #scene{display:none}
.no-webgl .hero-sub::after{content:" (Die 3D-Werft braucht WebGL, das in diesem Browser nicht verfügbar ist.)"}
```
- Bei `prefers-reduced-motion`: `timeScale` startet bei 0.5, keine Parallaxe, kein Auto-Plaudern, das Terminal schreibt sofort statt zu tippen.
- Fehlt `THREE` oder scheitert der WebGL-Renderer, bekommt `<html>` die Klasse `no-webgl`, und die Seite funktioniert ohne Szene weiter.
- Canvas, Labels und alle rein dekorativen SVGs haben `aria-hidden="true"`. Die Phasenkarte ist `aria-live="polite"`.
## 17. Abnahme-Checkliste
Prüfe am Ende selbst, ob alles davon zutrifft:
- [ ] Beim Laden: Headline unten links auf dem Boden, rechts daneben der Einlauf „Deine Idee“ mit gelber Kugel und winkendem Bot, das Band läuft nach oben rechts.
- [ ] Beim Scrollen fährt die Kamera zu sechs Stationen, verweilt an jeder und zoomt dazwischen kurz raus. Nach Station 3 folgt die Kamera dem Band durch die Rechtskurve am Baum.
- [ ] Die Werkstücke verwandeln sich an jeder Presse: Kugel → Papier → Blaupause → Tickets → Code-Block → getesteter Block → Kiste. Etwa jedes fünfte fliegt in der QA rot in die Ausschuss-Kiste.
- [ ] Die Bots sind runde orange Würfel mit Strahlen-Emblem, gehen mit Kniebeuge, tippen, zeigen, schreiben, winken und tragen Dinge über dem Kopf. Beim Klick springen sie mit Pirouette und sprechen.
- [ ] Die Rakete startet alle 14 s oder auf Klick, und die Bots jubeln.
- [ ] Die Phasenkarte zeigt Phase, Dauer, Text, Artefakte, ein tippendes Terminal und klickbare Fortschrittssegmente.
- [ ] Nach Station 6 schiebt sich die Website ohne Rauszoomen über die Szene, und das HUD blendet aus.
- [ ] Kein Flackern (Z-Fighting), keine Konsolenfehler, keine Konturlinien außer am Hologramm.
- [ ] Mobile (375 px): Headline unten, Szene darüber, die Karte liegt unten über die volle Breite.
Liefere die komplette `index.html` in einem einzigen Codeblock.3. Palmera
Die Marketing- und Shop-Website für eine fiktive Marke für Dosen-Cocktails. Das Highlight ist der Hero: Ein Foto bekommt über eine Tiefenkarte echte Räumlichkeit, das Glas im Vordergrund bewegt sich mit der Maus. Dazu gibt es ein Mini-Spiel zum Einschenken, verschiebbare Polaroids, einen Shop mit Warenkorb und eine Händler-Suche.
Technik: HTML, CSS und Vanilla-JavaScript mit WebGL, keine externen Bibliotheken.
Wichtig: Diese Seite braucht eigene Bilder. Welche genau, steht in Abschnitt 2 des
Prompts. Leg sie vorher in einen Ordner assets/. Ohne Bilder baut Claude die Seite mit
Platzhaltern, aber der Tiefen-Effekt im Hero funktioniert dann nicht.
Prompt anzeigenPrompt ausblenden577 Zeilen
# Prompt: Palmera – interaktive Cocktail-Website mit Depth-Map-Hero
> **So nutzt du diesen Prompt:** Kopiere alles ab der Linie unten in deinen KI-Coding-Assistenten (z. B. Claude Code). Leg vorher die Bilder aus Abschnitt 2 in einen Ordner `assets/` neben die spätere `index.html`. Ohne eigene Bilder baut die KI die Seite mit Platzhaltern, der Depth-Effekt braucht aber die drei Hero-Dateien.
---
Du bist ein erfahrener Creative Developer. Baue mir die komplette Marketing- und Shop-Website für **Palmera**, eine fiktive Marke für fertig gemixte Cocktails aus der Dose. Halte dich **exakt** an diese Spezifikation: Farben, Maße, Texte, Timings und Reihenfolge sind verbindlich. Wo Code vorgegeben ist, übernimm ihn wörtlich. Erfinde keine zusätzlichen Sektionen, Texte oder Features.
## 1. Technik und Rahmen
- **Eine Datei:** `index.html` mit Inline-`<style>` und Inline-`<script>`. Reines HTML, CSS und Vanilla-JavaScript, **kein** Framework, **kein** Build-Tool, **keine** externen JS-Bibliotheken.
- **Einzige externe Ressource:** Google Fonts: `Anton` (Display) und `Instrument Sans` (400, 500, 600, italic 400).
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Anton&family=Instrument+Sans:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
```
- **Sprache:** `<html lang="de">`, alle Texte auf Deutsch, Du-Ansprache, locker, kurz, ohne Emojis.
- `<title>Palmera – Prost auf die Sonne</title>`, Meta-Description: „Palmera: fertig gemixte Cocktails aus der Dose. Sunset Spritz, Coco Lime und Passion Fizz. Du brauchst nur noch Eis.“
- **WebGL-Hinweis:** Beim Öffnen per `file://` blockiert der Browser WebGL-Texturen aus lokalen Dateien. Liefere deshalb zusätzlich ein kleines Python-Skript `build.py`, das alle `src="assets/…"` in der `index.html` durch Base64-Data-URIs ersetzt und das Ergebnis als `palmera.html` speichert (eigenständige Datei, läuft per Doppelklick). Alternativ läuft die Seite über `python3 -m http.server`.
- Bilder, die mehrfach vorkommen, nur einmal einbinden: Zweitverwendungen bekommen `data-copy="<id>"` und ein Script am Anfang setzt `img.src = document.getElementById(id).src`.
## 2. Assets (Ordner `assets/`)
| Datei | Inhalt | Format |
|---|---|---|
| `hero-photo.jpg` | Hochformat-Foto 1058 × 1402 px: Person mit weißer Cap und grünem T-Shirt vor knallblauer Putzwand (#18AFE5) mit Palmenschatten oben rechts, hält einen orangen Cocktail (Glas mit Orangen- und Limettenscheibe, roter Strohhalm) Richtung Kamera. Glas, Hand und Arm ragen nach links unten. | JPG |
| `hero-photoBack.jpg` | Dasselbe Foto, aber Glas, Hand und Unterarm per KI-Inpainting entfernt (dort ist nur Wand, Shirt und Person zu sehen). Wird für die freigelegten Kanten beim Parallax gebraucht. | JPG, gleiche Größe |
| `hero-data.png` | Tiefendaten, gleiche Größe. **R** = Tiefe des Hintergrunds (Wand + Person ohne Glas), **G** = Tiefe des Vordergrunds (Glas/Hand/Arm), **B** = Maske des Vordergrunds (255 = Glas/Hand/Arm, 0 = Rest). Hell = nah, dunkel = fern. Die Maske muss links ungefähr bei 36 % der Bildbreite anfangen, dort schneidet der Bilderrahmen. | PNG, keine Farbkorrektur |
| `cans-trio.webp` | Lifestyle-Foto 960 × 1200: drei Dosen (Sunset Spritz orange links, Coco Lime blau Mitte, Passion Fizz creme rechts) auf heller Steinmauer vor blauer Wand mit Palmenschatten, Orangen, Limette, Eiswürfel. | WebP |
| `can-sunset.webp` | Freigestellter Packshot, orange Dose „PALMERA – SUNSET SPRITZ“, transparenter Hintergrund, eng zugeschnitten, ca. 368 × 860. | WebP mit Alpha |
| `can-passion.webp` | Freigestellter Packshot, cremefarbene Dose „PALMERA – PASSION FIZZ“, transparent, ca. 377 × 860. | WebP mit Alpha |
| `can-coco.webp` | Coco-Lime-Dose (blau), eng zugeschnitten. Wenn es keinen freigestellten Packshot gibt, ein Ausschnitt aus `cans-trio` mit blauer Wand als Hintergrund (ca. 236 × 700). | WebP |
| `glass.webp` | Freigestelltes Longdrinkglas mit goldenem Palmenblatt-Logo und „PALMERA“, türkis verlaufender Boden, transparent, ca. 442 × 860. | WebP mit Alpha |
| `trio.webp` | Drei Cocktails in Gläsern auf Sand vor blauer Wand (960 × 1200). | WebP |
| `drinker.webp` | Junger Mann mit weißer Cap trinkt einen gelben Drink mit Papierschirmchen vor blauer Wand (1444 × 1798). | WebP |
| `cap-palm.webp` | Weiße Cap hängt in einer Palme über orangefarbener Bougainvillea, blauer Himmel (960 × 1200). | WebP |
**Falls `hero-data.png` noch nicht existiert**, erzeuge sie mit einem Python-Skript (Pillow + numpy + `transformers`):
1. Tiefenkarte aus `hero-photo.jpg` mit `pipeline("depth-estimation", model="depth-anything/Depth-Anything-V2-Small-hf")`, auf 0–255 normalisieren, nah = hell.
2. Maske für Glas + Hand + Arm (Segmentierung oder manuell), leicht weichzeichnen (1–2 px) → Kanal **B**.
3. **G** = Tiefenkarte (im Vordergrund relevant).
4. **R** = Tiefenkarte, in der die Maskenfläche mit der umgebenden Hintergrund-Tiefe aufgefüllt wird (Maske dilatieren, Umgebung per Blur hineinziehen).
5. Als RGB-PNG speichern.
## 3. Design-System
### Farben (CSS-Variablen auf `:root`)
```css
--wall: #18AFE5; /* Wandblau aus dem Foto, Seitenhintergrund */
--navy: #06233A; /* Text, Schatten, Konturen */
--cream: #FFF4DE; /* Headlines, Rahmen, helle Flächen */
--orange: #FF5A1F; /* CTA, Sunset Spritz */
--green: #0E5A45;
--sun: #FFC93C; /* Gelb für Sticker und Highlights */
--sand: #EFE3CC; /* Shop-Hintergrund, Info-Boxen */
--img-ratio: 1058 / 1402;
--pad: clamp(20px, 4vw, 64px);
--display: "Anton", Impact, "Arial Narrow", sans-serif;
--ease: cubic-bezier(.2,.7,.2,1);
```
Sortenfarben: Sunset `#FF5A1F`, Coco `#1C9BD0` (Sektion-Hintergrund `#0B6E99`), Passion `#F3E7CF` (Akzent Rostrot `#C4532A`).
### Typografie
- **Body:** Instrument Sans, `-webkit-font-smoothing: antialiased`, Navy auf Wandblau.
- **Display-Headlines (`.display`):** Anton 400, `line-height: .95`, `letter-spacing: .005em`, `font-size: clamp(52px, 8.4vw, 132px)`, Farbe Creme mit **hartem Poster-Schatten** `text-shadow: 0.035em 0.035em 0 var(--navy)`. Variante `.display.ink`: Navy-Text mit Orange-Schatten.
- **Eyebrow:** 13px, 600, `letter-spacing: .14em`, Großbuchstaben, davor ein 26 × 2 px Strich in `currentColor`, Abstand 10px.
- **Lede:** `clamp(17px, 1.35vw, 20px)`, `line-height: 1.55`, `max-width: 46ch`.
### Wiederkehrende Stilmittel
- **Harte Offset-Schatten** statt weicher Schatten bei interaktiven Elementen: z. B. `box-shadow: 0 7px 0 var(--navy)`, beim Drücken `translateY` + kleinerer Schatten.
- Buttons und Pills sind komplett rund (`border-radius: 999px`), Bildrahmen 12px cremefarbene Border mit 28px Radius.
- **Filmkorn über der ganzen Seite:** `body::after`, `position: fixed; inset: -50%; z-index: 100; pointer-events: none; opacity: .09; mix-blend-mode: multiply;` mit diesem Hintergrund:
```
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .55 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")
```
- **Container:** `.wrap { width: min(1320px, 100%); margin: 0 auto; padding: 0 var(--pad); }`
- **Kein horizontales Scrollen:** `body { overflow-x: hidden }` und zusätzlich `@supports (overflow: clip) { html, body { overflow-x: clip; } }`.
- **Scroll-Reveal:** `.reveal { opacity: 0; translate: 0 28px; transition: opacity 800ms var(--ease), translate 800ms var(--ease), transform 400ms var(--ease); }`, `.reveal.in { opacity: 1; translate: 0 0; }`. Staffelung über `data-d="1|2|3"` → `transition-delay` 90/180/270 ms. IntersectionObserver mit `threshold: 0.15`, `rootMargin: '0px 0px -40px 0px'`, einmalig. **Wichtig:** `translate` statt `transform` verwenden, damit gedrehte Elemente ihre Rotation behalten.
- `@keyframes spin { to { transform: rotate(360deg); } }`, `@keyframes rise { from { opacity: 0; transform: translateY(0.4rem); } to { opacity: 1; transform: none; } }`, `@keyframes slidein { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: none; } }`.
- **Fokus:** `a, button, input:focus-visible { outline: 3px solid var(--navy); outline-offset: 3px; }`; runde Buttons behalten runde Outline.
- Visuell versteckte Labels über `.sr` (1 × 1 px, clip).
## 4. Seitenaufbau (genau diese Reihenfolge)
1. Sticky Topbar (fixed)
2. Hero mit Depth-Effekt
3. Schräges Laufband
4. Sorten
5. Shop (mit Gratis-Glas-Aktion)
6. Rezepte
7. Einschenken (Mini-Spiel)
8. Sonnenwand (Polaroids)
9. Händler-Suche
10. Club + Footer
11. Warenkorb-Schublade + Toast (fixed)
---
## 5. Sticky Topbar
- Fixed, `top: 14px`, horizontal zentriert, Pille mit `background: rgba(255,244,222,.92)`, `backdrop-filter: blur(12px)`, `padding: 8px 8px 8px 24px`, `gap: 36px`, Schatten `0 18px 40px -18px rgba(6,35,58,.5)`.
- Inhalt: Logo „Palmera“ (Anton 24px, Navy) + Links **Sorten · Shop · Rezepte · Händler** (500, 16px, Hover unterstrichen) + Button **„Warenkorb“** (Klasse `.shop`: Navy-Pille, Creme-Text, `padding: 10px 18px`, `white-space: nowrap`, `display: inline-block`).
- Versteckt (`transform: translate(-50%, -140%)`), fährt ein (`translate(-50%, 0)`, 500ms), sobald der Hero weniger als 8 % sichtbar ist (IntersectionObserver auf den Hero, `threshold: 0.08`).
- **Warenkorb-Zähler** `.cart-count`: kleiner oranger Kreis oben rechts am Button (22px, Creme-Border 2px, 12px/600), `scale(0)` bis der Korb etwas enthält, beim Hinzufügen `bump`-Animation (bei 40 % `scale(1.45)`, Overshoot-Easing `cubic-bezier(.3,1.6,.5,1)`).
- Unter 900px nur Logo + Warenkorb-Button zeigen.
## 6. Hero
### Layout
- `.hero`: `height: 100svh; min-height: 600px; overflow: hidden; isolation: isolate;` Hintergrund Wandblau. Definiere auf `.hero` die Headline-Größe `--h: min(22vh, calc((100vw - 52.8vh - var(--pad)) / 5.8));`
- **Navigation** (absolut oben, `padding: calc(var(--pad) * .6 + safe-area-top) var(--pad) 0`): Logo „Palmera“ (Anton 30px) links; rechts Sorten · Shop · Rezepte · Händler und Navy-Pille **„Jetzt bestellen“** (Klasse `.shop`, mit Warenkorb-Zähler, `href="#shop"`, Attribut `data-cart`).
- **Bühne `.stage`:** absolut rechts unten, `height: 100%`, `aspect-ratio: var(--img-ratio)`. Variablen: `--ft: 13%; --fr: 6%; --fl: 36%; --radius: 28px; --radius-bottom: 0px; --frame-bottom: -60px; --border: 12px;`. Inhalt (alle `position: absolute; inset: 0; width/height 100%`):
1. `<img class="fallback" id="photo" src="assets/hero-photo.jpg">` (object-fit cover, z 1)
2. `<canvas class="layer-back" id="back">` (z 1)
3. `<div class="frame">` Bilderrahmen: `top: var(--ft); right: var(--fr); left: var(--fl); bottom: var(--frame-bottom)`, 12px Creme-Border, Radius 28px, Schatten `0 40px 70px -30px rgba(6,35,58,.55)`, z 2
4. `<canvas class="layer-front" id="front">` (z 3, `filter: drop-shadow(calc(var(--sx,0)*1px) calc(22px + var(--sy,0)*1px) 22px rgba(6,35,58,.38))`)
5. `<img class="src" id="photoBack">` und `<img class="src" id="data">` (versteckt)
- `.fallback` und `.layer-back` bekommen `clip-path: inset(var(--ft) var(--fr) 0 var(--fl) round var(--radius) var(--radius) var(--radius-bottom) var(--radius-bottom));`. Der Vordergrund (Glas, Hand, Arm) wird **nicht** geclippt und ragt über den Rahmen nach links heraus, das ist der Kern-Effekt.
- **Headline** `<h1 class="headline"><span>Prost</span><span>auf die Sonne.</span></h1>` (Spans als Block): absolut, `left: var(--pad)`, `top: calc(57% - var(--h) * 1.5)`, Anton, `font-size: var(--h)`, `line-height: 1`, Creme, `text-shadow: .035em .035em 0 var(--navy)`, `white-space: nowrap`, z 2 (also **unter** dem Glas: das Glas überdeckt das Ende von „Sonne.“). Einblenden mit `rise` 900ms, Delay 150ms.
- **Intro** (absolut, `left: var(--pad)`, `bottom: calc(var(--pad) + safe-area-bottom)`, z 4, `rise` mit 350ms Delay):
- Text (18px, 1.5, max 36ch): „Echte Cocktails, fertig gemixt in der Dose. Aufmachen, auf Eis gießen, Prost. Mehr Arbeit ist es nicht.“
- Aktionen (flex, `gap: 14px 16px`, `margin-top: 26px`): Primär-CTA + Ghost-Button (siehe unten)
- Faktenzeile (`margin-top: 22px`, 14px/500), Punkte als 4px-Trenner: **3 Sorten** · 330 ml Dose · 4er-Pack ab 12,90 € · Gratis-Glas ab 2 Packs (unter 520px fällt der letzte Punkt weg)
### Primär-CTA „Jetzt probieren“ (`href="#shop"`), läuft beim Hover voll wie ein Glas
```css
.cta { position: relative; isolation: isolate; overflow: hidden; display: inline-flex; align-items: center; gap: 16px;
padding: 18px 22px 18px 30px; border-radius: 999px; background: var(--orange); color: var(--navy);
border: 3px solid var(--navy); box-shadow: 0 7px 0 var(--navy); font-weight: 600; font-size: 19px; white-space: nowrap;
transition: transform 160ms var(--ease), box-shadow 160ms var(--ease), color 250ms 120ms, translate 300ms var(--ease); }
.cta-fill { position: absolute; z-index: -1; left: -50%; top: calc(100% + 110px); width: 200%; aspect-ratio: 1;
border-radius: 42%; background: var(--navy); animation: spin 5s linear infinite; transition: top 650ms cubic-bezier(.3,.1,.2,1); }
.cta:hover, .cta:focus-visible { color: var(--cream); transform: translateY(-2px); box-shadow: 0 9px 0 var(--navy); }
.cta:hover .cta-fill { top: -65%; }
.cta:active { transform: translateY(5px); box-shadow: 0 2px 0 var(--navy); }
```
- Rechts im Button `.cta-dots` (feste Breite 74px, Höhe 22px): drei Kreise (22px, 2.5px Navy-Border) in `#FF7A45`, `#1C9BD0`, `#F3E7CF`, absolut bei `left: 26px`, überlappend durch `translateX(-13px / 0 / 13px)`. Beim Hover fächern sie auf `translate(-26px,-4px) / (0,-4px) / (26px,-4px)` mit 0/60/120ms Versatz, Border wird Creme. Die Breite darf sich nicht ändern, damit der Nachbar-Button nicht springt.
- **Magnet-Effekt** (nur bei `pointer: fine`, nicht bei reduced motion): Liegt der Cursor näher als `max(Buttonbreite, 140px)` an der Button-Mitte, setze `cta.style.translate = dx*0.18 px, dy*0.18*1.4 px`, sonst 0.
### Ghost-Button „Händler finden“ (`href="#haendler"`)
3px Navy-Border, transparent, `padding: 16px 22px`, 600/17px, links ein Standort-Pin-Icon (20px, Stroke). Hover: Creme-Hintergrund, Pin hüpft 4px hoch (Overshoot).
### Wand-Atmosphäre (macht die leere blaue Fläche lebendig)
- `.hero::before`: Putzstruktur, `mix-blend-mode: multiply; opacity: .16;` mit
```
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='260' height='260'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.28' numOctaves='4' seed='7'/%3E%3CfeDiffuseLighting lighting-color='%23fff' surfaceScale='2.6'%3E%3CfeDistantLight azimuth='225' elevation='52'/%3E%3C/feDiffuseLighting%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23f)'/%3E%3C/svg%3E")
```
- `.hero::after`: Lichteinfall, `mix-blend-mode: soft-light`, Hintergrund `radial-gradient(55% 60% at 8% 0%, rgba(255,214,120,.55), transparent 70%), radial-gradient(40% 50% at 100% 100%, rgba(6,35,58,.25), transparent 70%)`. Beide `z-index: 0`.
- **Palmenschatten** `.palm` (nach der Headline im DOM, z 2, liegt also **über** der Headline wie ein echter Schatten): `left: -7vw; top: -12vh; width: min(78vw, 1250px); aspect-ratio: 1000/820; opacity: .2; mix-blend-mode: multiply; transform-origin: 0 0; animation: sway 9s ease-in-out infinite alternate;` mit `@keyframes sway { from { transform: rotate(-1.6deg) scale(1); } to { transform: rotate(1.4deg) scale(1.015); } }`. Enthält `<svg viewBox="0 0 1000 820" preserveAspectRatio="xMinYMin meet"><g id="palmG" fill="#06233A" stroke="#06233A"></g></svg>`, das SVG hat `filter: blur(2.5px)`. Die Wedel werden prozedural erzeugt, **übernimm diesen Code wörtlich**:
```js
(function palmShadow() {
const g = $('#palmG'); let seed = 11;
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
const f = n => n.toFixed(1);
let html = '';
// [Startpunkt x, y, Winkel, Länge, Durchhang]
[[-60, -70, 0.30, 900, 150], [-80, -40, 0.74, 820, 110], [-40, -90, 1.18, 640, -70], [-90, 10, 0.02, 560, 120]].forEach(([x0, y0, ang, len, droop]) => {
const dx = Math.cos(ang), dy = Math.sin(ang), nx = -dy, ny = dx;
const P0 = [x0, y0], P1 = [x0 + dx * len * .5, y0 + dy * len * .5];
const P2 = [x0 + dx * len + nx * droop, y0 + dy * len + ny * droop];
const B = t => [0, 1].map(k => (1 - t) ** 2 * P0[k] + 2 * (1 - t) * t * P1[k] + t * t * P2[k]);
const T = t => { const v = [0, 1].map(k => 2 * (1 - t) * (P1[k] - P0[k]) + 2 * t * (P2[k] - P1[k])); const l = Math.hypot(...v); return [v[0] / l, v[1] / l]; };
html += `<path d="M${f(P0[0])} ${f(P0[1])} Q${f(P1[0])} ${f(P1[1])} ${f(P2[0])} ${f(P2[1])}" fill="none" stroke-width="9" stroke-linecap="round"/>`;
let d = '';
const N = 52;
for (let i = 0; i < N; i++) {
const t = 0.07 + 0.92 * i / N, p = B(t), tg = T(t);
const L = len * 0.3 * Math.pow(Math.sin(Math.PI * Math.min(1, t * 1.08)), 0.55) * (1 - 0.3 * t) * (0.85 + 0.3 * rnd());
for (const side of [-1, 1]) {
if (rnd() < 0.07) continue;
const a = side * (0.78 - 0.3 * t + (rnd() - .5) * 0.16) + 0.12;
const c = Math.cos(a), sn = Math.sin(a);
const u = [tg[0] * c - tg[1] * sn, tg[0] * sn + tg[1] * c], v = [-u[1], u[0]];
const bend = side * 0.18;
const ub = [u[0] * Math.cos(bend) - u[1] * Math.sin(bend), u[0] * Math.sin(bend) + u[1] * Math.cos(bend)];
const tip = [p[0] + ub[0] * L, p[1] + ub[1] * L + L * 0.12];
const ctl = [p[0] + u[0] * L * .55, p[1] + u[1] * L * .55];
const w = 7.5 * (1 - 0.45 * t);
d += `M${f(p[0] + v[0] * w)} ${f(p[1] + v[1] * w)}Q${f(ctl[0] + v[0] * w * .8)} ${f(ctl[1] + v[1] * w * .8)} ${f(tip[0])} ${f(tip[1])}Q${f(ctl[0] - v[0] * w * .8)} ${f(ctl[1] - v[1] * w * .8)} ${f(p[0] - v[0] * w)} ${f(p[1] - v[1] * w)}Z`;
}
}
html += `<path d="${d}" stroke="none"/>`;
});
g.innerHTML = html;
})();
```
Parallax (nur `pointer: fine`, nur solange `scrollY < innerHeight`): `x = clientX/innerWidth - .5`, `y = clientY/innerHeight - .5` → Palme `translate: x*28px y*18px`, Sticker `translate: x*-16px y*-12px`, beide mit `transition: translate 1.2s var(--ease)`.
### Sticker „Neu: Passion Fizz“
- `<a class="badge" href="#sorten" data-pick="passion">`, absolut, z 4, `width: calc(var(--h) * .82)`, rund, Hintergrund `--sun`, 3px Navy-Border, `box-shadow: 5px 6px 0 var(--navy)`, `rotate: -12deg`, Position `left: calc(var(--pad) + var(--h) * 2.6); top: calc(57% - var(--h) * 1.62);` (rechts neben „Prost“).
- Einflug-Animation `stick` 800ms, Delay 700ms, Overshoot: von `opacity 0, scale 2, rotate 20deg` zu `opacity 1, scale 1, rotate -12deg`.
- Innen: rotierender Kreistext (SVG, `inset: 6%`, `animation: spin 16s linear infinite`, beim Hover 4s) mit `<textPath>` auf einem Kreis r=39: „NEU · PASSION FIZZ · NEU · PASSION FIZZ · “ in Anton 12.5, letter-spacing 1.6, Navy. In der Mitte (38 % Größe) eine halbe Maracuja als SVG: Kreis r17 `#6B2A55` mit Navy-Kontur 2.5, innen Kreis r12.5 `#F6C445`, sechs Navy-Kerne r1.7. Hover: `scale: 1.08`, Frucht dreht 18° und wächst leicht.
- Klick scrollt zu den Sorten **und** wählt Passion Fizz aus.
### Depth-Effekt (WebGL): übernimm diesen Code wörtlich
```js
(() => {
const STRENGTH = 0.032; // Stärke der Verschiebung (UV-Einheiten)
const FOCUS = 0.299; // Tiefe, die stillsteht: Gesicht (0 = Wand, 1 = Glas)
const ZOOM = 1.05; // leicht reinzoomen, damit keine Kanten sichtbar werden
const WALL = [24/255, 175/255, 229/255];
const stage = document.getElementById('stage');
const backCanvas = document.getElementById('back');
const frontCanvas = document.getElementById('front');
const [photo, photoBack, dataImg] = ['photo','photoBack','data'].map(id => document.getElementById(id));
const imgs = [photo, photoBack, dataImg];
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
const portrait = matchMedia('(max-aspect-ratio: 5/4)');
const VS = `attribute vec2 p; varying vec2 vUv;
void main(){ vUv = p * 0.5 + 0.5; gl_Position = vec4(p, 0.0, 1.0); }`;
const FS = `precision mediump float;
varying vec2 vUv;
uniform sampler2D uPhoto, uData;
uniform vec2 uOff;
uniform float uFocus, uFeather, uFront, uZoom, uAspect;
uniform vec3 uWall;
void main(){
vec2 uv = (vUv - 0.5) / uZoom + 0.5;
vec4 data = texture2D(uData, uv);
float d = uFront > 0.5 ? data.g : data.r;
vec2 suv = clamp(uv + uOff * (d - uFocus) * vec2(1.0, uAspect), 0.001, 0.999);
vec3 c = texture2D(uPhoto, suv).rgb;
if (uFront > 0.5) {
float a = texture2D(uData, suv).b;
gl_FragColor = vec4(c * a, a);
} else {
float f = uFeather > 0.0 ? smoothstep(0.0, uFeather, vUv.x) : 1.0;
gl_FragColor = vec4(mix(uWall, c, f), 1.0);
}
}`;
function makeLayer(canvas, front) {
const gl = canvas.getContext('webgl', { alpha: front, premultipliedAlpha: true, antialias: false });
if (!gl) return null;
const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)); return s; };
const prog = gl.createProgram();
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VS));
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FS));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
gl.useProgram(prog);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, 'p');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE); // Tiefendaten nicht farbkorrigieren
[['uPhoto', front ? photo : photoBack], ['uData', dataImg]].forEach(([name, img], i) => {
const t = gl.createTexture();
gl.activeTexture(gl.TEXTURE0 + i);
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.uniform1i(gl.getUniformLocation(prog, name), i);
});
const u = n => gl.getUniformLocation(prog, n);
const U = { off: u('uOff'), focus: u('uFocus'), feather: u('uFeather'), front: u('uFront'),
zoom: u('uZoom'), aspect: u('uAspect'), wall: u('uWall') };
gl.uniform1f(U.front, front ? 1 : 0);
gl.uniform1f(U.focus, FOCUS);
gl.uniform1f(U.zoom, ZOOM);
gl.uniform1f(U.aspect, photo.naturalWidth / photo.naturalHeight);
gl.uniform3fv(U.wall, WALL);
return {
resize(w, h) { canvas.width = w; canvas.height = h; gl.viewport(0, 0, w, h); },
draw(ox, oy, feather) { gl.uniform2f(U.off, ox, oy); gl.uniform1f(U.feather, feather); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); }
};
}
function start() {
let layers;
try {
layers = [makeLayer(backCanvas, false), makeLayer(frontCanvas, true)];
if (layers.includes(null)) throw new Error('WebGL nicht verfügbar');
} catch (e) {
console.warn('Depth-Effekt deaktiviert:', e.message);
backCanvas.remove(); frontCanvas.remove();
return; // statisches Foto bleibt sichtbar
}
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.round(stage.clientWidth * dpr), h = Math.round(stage.clientHeight * dpr);
layers.forEach(l => l.resize(w, h));
render();
};
const target = { x: 0, y: 0 }, cur = { x: 0.9, y: -0.2 }; // Startwert -> sanfter Schwung beim Laden
let lastPointer = -Infinity, running = false, visible = true, raf = 0;
function render() {
const ox = -cur.x * STRENGTH, oy = cur.y * STRENGTH;
layers.forEach(l => l.draw(ox, oy, 0));
frontCanvas.style.setProperty('--sx', (cur.x * -10).toFixed(2)); // Schatten wandert gegenläufig
frontCanvas.style.setProperty('--sy', (cur.y * -6).toFixed(2));
}
function tick(t) {
if (t - lastPointer > 2500) { // kein Cursor -> langsames Schweben
target.x = Math.sin(t * 0.00042) * 0.6;
target.y = Math.sin(t * 0.00029 + 1.3) * 0.3;
}
cur.x += (target.x - cur.x) * 0.05;
cur.y += (target.y - cur.y) * 0.05;
render();
raf = requestAnimationFrame(tick);
}
const setRunning = () => {
const should = visible && !document.hidden && !reduceMotion.matches;
if (should && !running) { running = true; raf = requestAnimationFrame(tick); }
if (!should && running) { running = false; cancelAnimationFrame(raf); cur.x = cur.y = 0; render(); }
};
window.addEventListener('pointermove', e => {
if (e.pointerType === 'touch') return;
target.x = (e.clientX / innerWidth) * 2 - 1;
target.y = (e.clientY / innerHeight) * 2 - 1;
lastPointer = performance.now();
}, { passive: true });
new ResizeObserver(resize).observe(stage);
new IntersectionObserver(([en]) => { visible = en.isIntersecting; setRunning(); }).observe(stage);
document.addEventListener('visibilitychange', setRunning);
reduceMotion.addEventListener('change', setRunning);
portrait.addEventListener('change', render);
if (reduceMotion.matches) { cur.x = cur.y = 0; }
resize();
setRunning();
}
Promise.all(imgs.map(i => i.decode ? i.decode() : Promise.resolve())).then(start, () => {});
})();
```
### Hero im Hochformat (`@media (max-aspect-ratio: 5/4)`)
- `.hero { height: auto; min-height: 0; display: flex; flex-direction: column; --h: 15vw; }`
- Headline wird relativ, `order: -1`, `padding: calc(84px + safe-area-top) var(--pad) 0`.
- `.stage { position: relative; height: auto; width: 120vw; margin-left: -19vw; --ft: 6%; --fr: 7%; --radius: 20px; --radius-bottom: 20px; --frame-bottom: 0px; --border: 8px; }` (die leere Wand liegt links außerhalb, Hand und Limette ragen bis zum Rand).
- Intro relativ, `padding: 28px var(--pad) calc(40px + safe-area-bottom)`. CTA 17px/`15px 18px 15px 24px`, Ghost 16px/`13px 18px`.
- Sticker: `left: auto; right: calc(var(--pad) * .6); top: calc(84px + safe-area-top + 27vw); width: 25vw; max-width: 120px;` → sitzt auf der oberen rechten Ecke des Fotorahmens.
- Palme: `width: 130vw; top: -4vh; left: -30vw`. In der Nav nur „Jetzt bestellen“.
## 7. Schräges Laufband
- Direkt unter dem Hero: Navy-Band, Creme-Text, `transform: rotate(-2deg)`, `margin: -18px -4vw 0` (im Hochformat `margin-top: 0`), `padding: 18px 0`, z 5, Schatten `0 20px 40px -20px rgba(6,35,58,.6)`.
- Text Anton `clamp(34px, 5vw, 72px)`, Wörter jeweils gefolgt von einer kleinen orangen Sonne (SVG, `.7em`, Kreis r8 + 8 Strahlen): **Sunset Spritz · Coco Lime · Passion Fizz · Nur noch Eis · Fertig aus der Dose · Prost auf die Sonne**. Inhalt 4× wiederholen.
- JS-Loop: Grundgeschwindigkeit 0.6 px/Frame nach links, plus Scrollgeschwindigkeit (`vel += (dy - vel) * 0.1`, Boost `min(|vel| * 0.35, 14)`), Endlos-Wrap bei der halben Breite, dazu `skewX(clamp(-vel*0.4, -12°, 12°))`. Bei reduced motion stillstehend.
## 8. Sorten (`#sorten`)
- Sektion mit sortenabhängigen Farben über `data-flavor`, weiche Übergänge (700ms):
- `sunset`: bg `#FF5A1F`, fg Navy, Akzent Creme, Chip `rgba(6,35,58,.1)`
- `coco`: bg `#0B6E99`, fg Creme, Akzent `#C9E27A`, Chip `rgba(255,244,222,.14)`, Button Creme mit Text `#0B6E99`
- `passion`: bg `#F3E7CF`, fg Navy, Akzent `#C4532A`, Chip `rgba(6,35,58,.08)`, Headline hier Navy mit Orange-Schatten (sonst unlesbar)
- `padding: clamp(120px, 14vw, 200px) 0 clamp(80px, 10vw, 140px); margin-top: -40px;`. Grid `1fr 1.05fr`, Gap `clamp(32px, 6vw, 96px)`.
- **Links:** Foto `cans-trio.webp` in 4:5-Rahmen (12px Creme-Border, Radius 28, `rotate(-2.5deg)`, Schatten `0 50px 80px -40px rgba(6,35,58,.7)`) mit drei **Hotspots** direkt auf den Dosen: Sunset `--x: 37.5%; --y: 62%`, Coco `60% / 57%`, Passion `82.5% / 63%`. Hotspot: 54px Kreis, `rgba(255,244,222,.25)` + Blur 4px, innen Creme-Punkt mit Navy-Ring, pulsierender Ring (`ping` 2.2s); aktiv: Innenpunkt Orange und größer. Hover: `scale(1.12)` + Tooltip (Navy-Pille mit Sortenname).
- **Rechts:** Eyebrow „Drei Dosen, null Aufwand“, H2 (`.display`, `font-size: clamp(44px,5.4vw,84px)`) „Tipp auf die Dose.“, Tabs (Pillen mit 2px Border, aktiv gefüllt): Sunset Spritz · Coco Lime · Passion Fizz (Pfeiltasten wechseln). Darunter ein Panel, das beim Wechsel 240ms ausblendet und neu erscheint:
- Sortenname (Anton `clamp(56px, 7vw, 108px)`, Akzentfarbe, Navy-Schatten), Tagline (600, `clamp(20px,1.8vw,26px)`), Beschreibung (17px/1.6, max 46ch)
- 4 Geschmacksbalken im 2er-Grid (Süße, Säure, Bitter, Frische; 10px hoch, animieren 900ms von 0 auf den Wert)
- Zutaten als Chips
- Kaufzeile: Preis „12,90 €“ (Anton 40px) mit Unterzeile „4er-Pack · 4 × 330 ml · X % vol“, Mengenwahl (– 1 +, 1–12), Button „In den Warenkorb“ (fügt einen 4er-Pack der aktiven Sorte hinzu, Flug-Animation startet am Hotspot).
- Die Sortenwahl ist **global**: Tabs, Hotspots, Hero-Sticker und die Farbpunkte im Einschenk-Spiel setzen alle dieselbe aktive Sorte.
### Daten (wörtlich übernehmen)
```js
const FLAVORS = {
sunset: {
name: 'Sunset Spritz', tag: 'Der Sonnenuntergang in der Dose.', short: 'Blutorange & Bitterorange',
desc: 'Blutorange, Bitterorange und ein trockener Weißwein, aufgefüllt mit feiner Soda. Bittersüß, spritzig und genau richtig für die Stunde vor dem Abendessen.',
notes: { 'Süße': 50, 'Säure': 58, 'Bitter': 52, 'Frische': 70 },
ing: ['Blutorange', 'Bitterorange', 'Weißwein', 'Soda'],
abv: '6,5', liquid: '#FF5A1F', garnish: '#FF8A3D', swatch: '#FF5A1F',
img: 'canSunset', tint: '#FFB894', word: 'SUNSET', flag: 'Klassiker'
},
coco: {
name: 'Coco Lime', tag: 'Kokos, Limette, Kopf aus.', short: 'Kokos & Limette',
desc: 'Kokoswasser, frisch gepresste Limette und weißer Rum. Leicht, cremig im Abgang und so erfrischend, dass die Dose schneller leer ist, als dir lieb ist.',
notes: { 'Süße': 38, 'Säure': 72, 'Bitter': 12, 'Frische': 90 },
ing: ['Kokoswasser', 'Limette', 'Weißer Rum', 'Rohrzucker', 'Soda'],
abv: '5,5', liquid: '#E3EFD4', garnish: '#9CC43C', swatch: '#1C9BD0',
img: 'canCoco', tint: '#0F9ED9', word: 'COCO', flag: 'Sommer-Liebling'
},
passion: {
name: 'Passion Fizz', tag: 'Sauer macht lustig.', short: 'Maracuja & Vanille',
desc: 'Maracuja, ein Hauch Vanille und Zitrone auf Wodka-Basis. Fruchtig-sauer, feinperlig und mit einem Duft, der nach Urlaub riecht.',
notes: { 'Süße': 62, 'Säure': 66, 'Bitter': 10, 'Frische': 74 },
ing: ['Maracuja', 'Vanille', 'Zitrone', 'Wodka', 'Soda'],
abv: '6,0', liquid: '#F6C445', garnish: '#F2B21B', swatch: '#F3E7CF',
img: 'canPassion', tint: '#F6DDB4', word: 'PASSION', flag: 'Neu'
}
};
const SIZES = { 4: { label: '4er-Pack', ml: '4 × 330 ml', price: 12.90 }, 12: { label: '12er-Karton', ml: '12 × 330 ml', price: 34.90 } };
const GIFT_AT = 2, GIFT_VALUE = 9.90;
const euro = n => n.toFixed(2).replace('.', ',') + ' €';
```
`img` ist die ID des jeweiligen Produktbilds im Shop, dessen `src` für Warenkorb und Flug-Animation wiederverwendet wird.
## 9. Shop (`#shop`, Klasse `.store`, **nicht** `.shop`, die ist für den Warenkorb-Button vergeben)
- Hintergrund `--sand`, `padding: clamp(90px, 11vw, 150px) 0`.
- **Kopf** (flex, space-between, unten ausgerichtet): links Eyebrow „Shop“ + H2 `.display.ink` (`clamp(48px, 7vw, 112px)`) „Ab in den Kühlschrank.“; rechts Lede (max 40ch) „Gekühlt geliefert in zwei bis drei Werktagen. Und wer zwei Packs bestellt, bekommt unser Palmera-Glas geschenkt.“ und darunter die **Gratis-Glas-Pille**: Creme, 2px Navy-Border, `box-shadow: 0 4px 0 var(--navy)`, links runder 40px-Kreis in Wandblau mit Glas-Bild, Statustext, rechts 90 × 8 px Fortschrittsbalken (orange Füllung). Bei erreichter Aktion wird die Pille Sonnengelb.
- **Raster** `.products`, Reihenfolge: **Sunset Spritz, Coco Lime, Palmera-Glas (Aktion), Passion Fizz**. Das Glas steht also zwischen den Produkten.
- **≥ 1000px:** 2 × 2-Raster, **liegende Karten** (Bild links 48 %, Infos rechts), `min-height: clamp(400px, 34vw, 480px)`, Gap `clamp(16px, 2vw, 28px)`.
- **641–999px:** eine Spalte, ebenfalls liegend.
- **≤ 640px:** horizontales Wisch-Karussell mit hochformatigen Karten: `grid-auto-flow: column; grid-auto-columns: 80%; overflow-x: auto; scroll-snap-type: x mandatory;` negativer Seitenrand, damit die Karten bis zum Bildschirmrand laufen, Scrollbar ausblenden. Hochformat-Bildfläche `aspect-ratio: 1/1.02`.
### Produktkarte (`<article class="product" data-id="sunset|coco|passion">`)
- Weiß, Radius 24, `overflow: hidden`. Hover: `translateY(-6px)`, Schatten `0 34px 50px -32px rgba(6,35,58,.5)`.
- **Bildfläche** `.p-visual`: Hintergrund `--tint` der Sorte.
- Flag-Pille oben links (Creme, 2px Navy-Border, 12px/600): Klassiker / Sommer-Liebling / Neu.
- Riesiger Sortenname dahinter (`SUNSET`, `COCO`, `PASSION`), Anton, `rgba(255,255,255,.3)`, mittig; bei liegenden Karten **senkrecht** (`writing-mode: vertical-rl; rotate: 180deg; font-size: clamp(110px, 11vw, 190px)`), Hover verschiebt ihn leicht (`translate: -50% -46%`).
- Dose absolut zentriert (`left: 50%; translate: -50% 0`), liegend `top: 13%; height: 74%`, hochkant `top: 8%; height: 84%`, `rotate(-5deg)`, `drop-shadow(0 26px 22px rgba(6,35,58,.35))`. Hover: `rotate(4deg) translateY(-12px) scale(1.04)` mit Overshoot-Easing 700ms.
- Coco Lime (Foto-Ausschnitt statt Freisteller): Klasse `is-photo`, kein Drop-Shadow, Ränder per `mask-image` weich in die Kartenfarbe auslaufen lassen (links/rechts 12 %, oben 8 %, unten 10 %, beide Verläufe mit `mask-composite: intersect`).
- **Infos** `.p-body` (Padding `clamp(24px, 2.6vw, 36px)`): Kategorie „Cocktail · 6,5 % vol“ (13px/600, 65 % Deckkraft), Name (Anton `clamp(34px, 3vw, 50px)`), Chips „Glas gratis ab 2“ (grün: bg `#DDF2D6`, Text `#1F6B2E`) und „Vegan“ (bg `#F2EDE4`), Kurzbeschreibung zweizeilig („Blutorange & Bitterorange / Weißwein, Soda“, „Kokos & Limette / Weißer Rum, Soda“, „Maracuja & Vanille / Wodka, Soda“).
- **Größenwahl** (Segmented Control, `role="radiogroup"`): „4er-Pack“ | „12er-Karton“, aktiv Navy. Wechselt Preis (12,90 € / 34,90 €) und „x,xx € pro Dose“.
- Fußzeile: Preis (Anton 38px) + oranger runder **„+“-Button** (60px, 2px Navy-Border, `box-shadow: 0 4px 0 var(--navy)`). Nach dem Klick wird er 1,4 s lang grün (`#3BAA5C`) und zeigt ein Häkchen (Plus dreht weg, Häkchen ploppt rein).
### Aktions-Kachel „Palmera-Glas“ (`<article class="reward" id="reward">`)
- Navy-Karte mit Creme-Text. Bildfläche Wandblau mit **sich drehendem Strahlenkranz** (`::before`, `inset: -45%`, `repeating-conic-gradient(from 0deg, rgba(255,244,222,.22) 0 9deg, transparent 9deg 18deg)`, 60 s pro Umdrehung), darauf das Glas (`top: 15%; height: 70%`). Hover: Glas kippt -6°.
- Runder Stempel oben rechts (96px, Sonnengelb, 3px Navy, `box-shadow: 3px 4px 0 var(--navy)`, `rotate: 12deg`, Anton 21px): „Gratis / ab 2“.
- Text: Kategorie „Aktion · Solange der Vorrat reicht“, Name „Palmera-Glas“, Beschreibung „Bestell zwei Packs, egal welche Sorte, und das Glas liegt deiner Lieferung gratis bei.“, zwei Fortschritts-Slots (10px, füllen sich gelb pro Pack), Statuszeile, Preis „~~9,90 €~~ **0,00 €**“ (0,00 € in Sonnengelb).
- **Freigeschaltet** (≥ 2 Packs, ein 12er-Karton zählt als ein Pack): Klasse `won`, Stempel wird Orange mit Text „Dein / Glas!“ und macht eine Stempel-Animation (`scale 2.2 → 1`), das Glas hüpft einmal (`cheer`), Toast „Gratis-Glas freigeschaltet. Cheers!“ (nur beim Übergang).
- Statustexte (in Pille, Kachel und Warenkorb gleich): „Noch 2 Packs bis zum Gratis-Glas“ → „Nur noch 1 Pack bis zum Gratis-Glas“ → „Gratis-Glas freigeschaltet. Liegt deiner Lieferung bei.“
- Unter dem Raster: „Alle Preise inkl. MwSt., zzgl. Versand. Verkauf nur an Personen ab 18 Jahren.“ (13px, 65 %).
### Warenkorb-Logik
- State `cart = [{ id, size, qty }]`, `packs = Summe qty`. Gleiche Sorte + Größe wird zusammengefasst.
- **Flug-Animation** beim Hinzufügen: Klon des Produktbilds (`position: fixed`, 150px hoch, Drop-Shadow) fliegt per Web Animations API in einem Bogen zum sichtbaren Warenkorb-Button (Topbar, wenn eingeblendet, sonst Hero-Nav): Keyframes Einblenden → Bogen 140px nach oben bei 55 % mit `scale(.7) rotate(-24deg)` → Ziel mit `scale(.12) rotate(-50deg)`, 900ms, `cubic-bezier(.45,0,.25,1)`. Danach Zähler aktualisieren + `bump`. Toast „1 × Sunset Spritz (4er-Pack) liegt im Korb“ mit Farbpunkt der Sorte.
- Klick auf einen `[data-cart]`-Link: Ist der Korb leer, normal zum Shop scrollen, sonst die Schublade öffnen.
### Warenkorb-Schublade
- `<aside class="drawer">` fixed rechts, `width: min(440px, 100%)`, Creme, fährt per `translateX(105%) → 0` (500ms) herein; dahinter Scrim `rgba(6,35,58,.45)`. Geschlossen: `inert` + `aria-hidden="true"`. Öffnen fokussiert den Schließen-Button, Schließen gibt den Fokus zurück; Escape und Klick auf den Scrim schließen; `html { overflow: hidden }` solange offen.
- Kopf „Dein Korb“ (Anton 36px) + runder ×-Button (44px, 2px Navy-Border).
- Navy-Box mit Glas-Icon, Statustext und Fortschrittsbalken (gelb).
- Positionen: Thumbnail (64 × 76, Sorten-Tint, Radius 14), Name, „4er-Pack · 4 × 330 ml“, Mengen-Stepper (– n +, bei 0 entfernen), Preis rechts. Bei freigeschalteter Aktion zusätzlich die Position „Palmera-Glas / Geschenk ab 2 Packs“ mit ~~9,90 €~~ „Gratis“ in Grün `#1F7A3A`.
- Leerer Zustand: „Noch nichts drin. Die Dosen warten schon im Kühlschrank.“ + oranger Button „Zum Shop“.
- Fuß: „Zwischensumme“ + Betrag (Anton 30px), Hinweis „inkl. MwSt., zzgl. Versand · nur ab 18“, voller Navy-Button „Zur Kasse“ → Toast „Demo: Die Kasse ist noch nicht angebunden.“
## 10. Rezepte (`#rezepte`)
- Hintergrund Wandblau. Kopf: Eyebrow „Wenn du doch mixen willst“, H2 „Drei Twists.“; rechts Lede „Palmera schmeckt pur am besten. Aber für die lange Nacht gibt es drei Varianten, die wir selbst gern trinken. Karte antippen zum Umdrehen.“
- Drei **3D-Flip-Karten** (3 Spalten, `perspective: 1600px`, Seitenverhältnis 3:4, leicht gedreht −3° / 2° / −1.5°; Hover: gerade + 6px hoch). Klick dreht die Karte um die Y-Achse (800ms, `cubic-bezier(.5,.1,.2,1)`). Vorder- und Rückseite: 10px Creme-Border, Radius 26.
- **Vorderseite:** Nummer + Sorte (Anton 20px), großes Frucht-SVG oben rechts angeschnitten (`right: -12%; width: 78%`, Hover dreht −12°), Titel unten (Anton `clamp(38px, 3.8vw, 60px)`), Meta-Zeile + Button „Umdrehen ↻“.
1. „01 · Sunset Spritz“, bg Orange, Orangenscheibe (Creme-Rand, `#E8431A`, Creme-Segmentlinien), **Palmera Royale**, „2 Min · 2 Zutaten“
2. „02 · Coco Lime“, bg `#1C9BD0`, Creme-Text, Limettenscheibe (`#B7D65A`), **Coco Crush**, „3 Min · 3 Zutaten“
3. „03 · Passion Fizz“, bg Sonnengelb, halbe Maracuja (`#6B2A55` Rand, `#F6C445` innen, Navy-Kerne), **Sonnen-Slush**, „5 Min · Mixer“
- **Rückseite** (Creme): Titel (Anton 30px), nummerierte Schritte (Marker orange), Tipp-Box (`--sand`, Radius 14), Button „← Zurück“.
- Palmera Royale: „Weinglas zu zwei Dritteln mit Eis füllen.“ / „Eine halbe Dose Sunset Spritz eingießen.“ / „Mit 5 cl trockenem Prosecco auffüllen.“ / „Einmal vorsichtig umrühren, Orangenzeste drüber.“ Tipp: „Die Zeste über dem Glas knicken, damit die Öle drauf landen.“
- Coco Crush: „Glas randvoll mit Crushed Ice füllen.“ / „Eine Dose Coco Lime langsam darübergießen.“ / „Zwei Limettenspalten ausdrücken und mit ins Glas geben.“ / „Mit Minze und ein paar Kokoschips toppen.“ Tipp: „Die Dose vorher zehn Minuten ins Eisfach. Und dann nicht vergessen.“
- Sonnen-Slush: „Eine Dose Passion Fizz über Nacht in der Eiswürfelform einfrieren.“ / „Würfel mit einem Schuss frischer Passion Fizz und etwas Limette in den Mixer.“ / „Kurz pulsen, bis es sämig ist.“ / „Mit Strohhalm und Schirmchen servieren. Schirmchen ist Pflicht.“ Tipp: „Nicht zu lange mixen, sonst wird's Saft statt Slush.“
- Unter 900px: eine Spalte, max. 420px breit.
## 11. Einschenken: Mini-Spiel (`#einschenken`)
- Hintergrund Creme, 2 Spalten. Links: Eyebrow „Das einzige Rezept, das du brauchst“, H2 `.display.ink` „Eis rein.<br>Palmera drauf.“, Lede „Klingt einfach? Dann zeig mal. Halt den Knopf gedrückt und lass los, wenn der Drink genau am Eichstrich steht.“, nummerierte Schritte (große orange Anton-Ziffern): „Glas bis oben mit Eis füllen. Mehr Eis heißt weniger Verwässern.“ / „Dose Palmera gut gekühlt aufmachen und bis zum Strich einschenken.“ / „Mit einer Scheibe Frucht garnieren. Fertig.“ Darunter eine Sand-Box mit „Letzter Versuch“, „Dein Rekord“ (Anton 34px) und „Sorte“ mit drei Farbpunkten (34px, aktiv größer mit orangem Ring).
- **Rechts die Spielfläche** `.game`: Wandblau, 12px weißer Rahmen, Radius 32, `aspect-ratio: 1/1.05` (mobil 1/1.2), dezenter Palmenschatten oben rechts per Gradient. Oben mittig eine Navy-Status-Pille, unten die Buttons **„Gedrückt halten“** (orange, `box-shadow: 0 6px 0 navy`, gedrückt eingedrückt) und **„Neues Glas“** (Creme). Das SVG liegt zwischen Pille und Buttons (`top: 52px; height: calc(100% - 138px)`).
- **SVG (wörtlich übernehmen):**
```html
<svg viewBox="0 0 400 420" aria-hidden="true">
<defs>
<clipPath id="inside"><path d="M118 132 L282 132 L270 352 Q268 360 260 360 L140 360 Q132 360 130 352 Z"/></clipPath>
<linearGradient id="glassShine" x1="0" x2="1">
<stop offset="0" stop-color="#fff" stop-opacity=".55"/><stop offset=".18" stop-color="#fff" stop-opacity=".05"/>
<stop offset=".8" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity=".35"/>
</linearGradient>
</defs>
<ellipse cx="200" cy="392" rx="110" ry="12" fill="#06233A" opacity=".25"/>
<g id="bottle" style="transform-origin: 318px 160px; transition: transform 350ms cubic-bezier(.2,.7,.2,1)">
<rect x="296" y="50" width="44" height="106" rx="7" id="bottleLabel" fill="#FF5A1F" stroke="#06233A" stroke-width="2.5"/>
<rect x="302" y="56" width="7" height="92" rx="3.5" fill="#fff" opacity=".28"/>
<rect x="298" y="42" width="40" height="12" rx="5" fill="#D5DBE0" stroke="#06233A" stroke-width="2.5"/>
<rect x="298" y="152" width="40" height="10" rx="5" fill="#D5DBE0" stroke="#06233A" stroke-width="2.5"/>
<text id="canText" x="318" y="103" text-anchor="middle" font-family="Anton, Impact, sans-serif" font-size="9.5" letter-spacing=".5" fill="#FFF4DE" transform="rotate(-90 318 103)">PALMERA</text>
</g>
<rect id="stream" x="242" y="0" width="7" height="0" rx="3.5" fill="#FF5A1F" opacity="0"/>
<g clip-path="url(#inside)">
<rect x="100" y="120" width="200" height="260" fill="#fff" opacity=".18"/>
<path id="liquid" fill="#FF5A1F" d=""/>
<g id="bubbles"></g>
<g id="ice"></g>
</g>
<g id="spill" opacity="0">
<path d="M116 132 q-6 40 -2 80 q2 10 -4 10 q-6 0 -4 -10 q4 -40 4 -80z" id="spillL" fill="#FF5A1F"/>
<path d="M284 132 q6 50 2 110 q-2 10 4 10 q6 0 4 -10 q-4 -60 -4 -110z" id="spillR" fill="#FF5A1F"/>
</g>
<path d="M112 124 L288 124 L275 356 Q273 372 257 372 L143 372 Q127 372 125 356 Z" fill="none" stroke="#FFF4DE" stroke-width="7" stroke-linejoin="round"/>
<path d="M118 132 L282 132 L270 352 Q268 360 260 360 L140 360 Q132 360 130 352 Z" fill="url(#glassShine)"/>
<rect x="132" y="358" width="136" height="12" rx="4" fill="#FFF4DE" opacity=".45"/>
<g id="mark">
<line x1="104" x2="296" stroke="#06233A" stroke-width="3" stroke-dasharray="8 7"/>
<rect x="18" width="80" height="26" rx="13" fill="#06233A"/>
<text x="58" text-anchor="middle" font-family="Instrument Sans, sans-serif" font-weight="600" font-size="13" fill="#FFF4DE">Eichstrich</text>
</g>
<g id="garnish" transform="translate(122 118) rotate(-18)">
<circle r="30" fill="#FFF4DE"/><circle r="26" id="garnishFill" fill="#FF8A3D"/>
<g stroke="#FFF4DE" stroke-width="2.2" opacity=".85">
<line x1="-24" y1="0" x2="24" y2="0"/><line x1="0" y1="-24" x2="0" y2="24"/>
<line x1="-17" y1="-17" x2="17" y2="17"/><line x1="-17" y1="17" x2="17" y2="-17"/>
</g>
<circle r="4" fill="#FFF4DE"/>
</g>
</svg>
```
- **Spiellogik (exakte Werte):** `TOP = 132, BOTTOM = 360, H = 228, TARGET = 0.78, GLASS_ML = 300`. Eichstrich-Linie bei `y = BOTTOM - TARGET * H`, Label-Rect bei `y - 13`, Text bei `y + 4.5`.
- Flüssigkeit: Pfad von x = 100 bis 300 in 10er-Schritten, Oberfläche `surf = BOTTOM - min(fill,1) * H`, Welle `sin(x*0.06 + t*0.006) * (2.5 + wobble*6)`.
- Vier Eiswürfel (halbtransparent weiß, rx 8, mit Glanzstreifen): `{x:150, rest:318, r:-14, s:44}, {x:206, rest:322, r:10, s:46}, {x:176, rest:278, r:24, s:40}, {x:226, rest:280, r:-6, s:38}`. Sie liegen am Boden und schwimmen auf, sobald die Oberfläche über ihnen ist (`min(rest, surf - 4 + sin(t*0.003+i)*2 + i*6)`, Lerp 0.2, leichtes Schaukeln).
- 14 Bläschen (r 1.5–4, weiß .6) steigen mit 18–48 px/s und sind nur unter der Oberfläche sichtbar.
- Halten (Pointer mit `setPointerCapture`, oder Leertaste/Enter): Dose kippt per `transform: translate(42px, -88px) rotate(-100deg)`, Strahl vom y = 96 bis zur Oberfläche sichtbar, `fill += dt * (0.2 + min(pourTime, 2.2) * 0.14)` (der Strahl wird kräftiger), `wobble` steigt. Ab `fill >= 1.08` automatisch Ende. Überlauf-Tropfen werden ab `fill > 1` eingeblendet.
- Loslassen → Auswertung: `ml = round(|fill - TARGET| * 300)`. Texte: Überlauf „Übergelaufen! Tisch abwischen.“; ≤ 4 ml „Perfekt! Barkeeper-Level.“; ≤ 15 ml „Knapp drüber: X ml zu viel.“ / „Fast: X ml fehlen noch.“; sonst „Großzügig. X ml über dem Strich.“ / „Da passt noch was rein: X ml.“ Die Pille ploppt dabei auf. „Letzter Versuch“ = „X ml daneben“ bzw. „Überlauf“. Rekord in `localStorage` (`palmera-best`, alle Zugriffe in try/catch); bei einem neuen Rekord ≤ 4 ml Toast „Neuer Rekord. Du darfst an die Bar.“ Button heißt danach „Nochmal?“, erneutes Drücken setzt zurück.
- Farben folgen der aktiven Sorte: Flüssigkeit, Strahl und Tropfen = `liquid`, Dosenkörper = `swatch`, Garnitur = `garnish`, Dosenschrift Creme (bei Passion `#C4532A`).
- Kontextmenü auf dem Button unterdrücken, `touch-action: none`.
## 12. Sonnenwand (verschiebbare Polaroids)
- Hintergrund Navy, Creme-Text. Kopf: Eyebrow „#Palmeramoment“, H2 „Die Sonnenwand.“ in **Wandblau** mit schwarzem Schatten; rechts Lede „Momente aus unserem Sommer. Schieb die Bilder rum, so wie du sie haben willst.“ und Outline-Button „Neu mischen“ (Shuffle-Icon).
- Board (`position: relative; height: clamp(540px, 48vw, 700px)`, mobil 720px) mit absolut platzierten Elementen (`left: var(--x); top: var(--y)`, Transform `translate(var(--dx), var(--dy)) rotate(var(--r)) scale(var(--s))`):
- Polaroid `drinker.webp` „Mittagspause, Kreuzberg“ / „Juli“ (x 2 %, y 6 %, −6°, 300px)
- Polaroid `cap-palm.webp` „Irgendwer vermisst seine Cap“ / „Aug“ (26 %, 22 %, 4°, 260px)
- Polaroid Hero-Foto „Prost auf die Sonne“ / „Juni“ (50 %, 3 %, −3°, 290px)
- Polaroid `trio.webp` „Alle drei, bitte“ / „Sep“ (73 %, 26 %, 7°, 250px)
- Runder oranger Sticker (150px) mit rotierendem Kreistext „EISKALT SERVIEREN · EISKALT SERVIEREN · “ und „Nur / Eis“ in der Mitte (40 %, 66 %, −10°)
- Gelber Sticker „Made for Sunny Days“ (Anton 30px, 12 %, 70 %, 6°)
- Polaroid: Creme, `padding: 12px 12px 0`, Bild 4:5, Bildunterschrift (14px/600, rechts das Monatskürzel mit 55 % Deckkraft), Schatten `0 30px 50px -25px rgba(0,0,0,.8)`.
- **Drag & Drop** mit Pointer Events (`setPointerCapture`): verschiebt `--dx/--dy`, das gezogene Element kommt nach vorn (steigender z-index), wird etwas größer (`--s: 1.05`) und bekommt einen stärkeren Schatten. Elemente dürfen höchstens 40 % ihrer Breite seitlich und 20px oben/unten aus dem Board ragen.
- „Neu mischen“ verteilt alle Elemente zufällig im Board (Rotation −9° bis 9°).
- **Mobil ≤ 900px:** Startpositionen `[0,0,−6°] [50,3,5°] [3,33,4°] [48,37,−5°] [64,70,−10°] [0,87,5°]` (x %, y %, Rotation), Polaroids 190px (≤ 520px: 150px, ohne Monatskürzel), runder Sticker 112px.
## 13. Händler-Suche (`#haendler`)
- Hintergrund Orange, 2 Spalten. Links: Eyebrow „Kühlschrank um die Ecke“, H2 „Wo gibt's Palmera?“, Lede „Gib deine Postleitzahl ein und wir zeigen dir, wer in deiner Nähe gekühlte Dosen hat. Die Punkte verraten, welche Sorten gerade da sind.“, Formular (rundes Creme-Input mit 3px Navy-Border, Placeholder „Postleitzahl, z. B. 10997“, nur Ziffern, max. 5; Navy-Button „Suchen“), Hinweis „Demo: Die Ergebnisse sind Beispieldaten.“
- Rechts zuerst ein Leer-Zustand (gestrichelter Rahmen, Pin-Icon, „Noch keine Suche. Die nächste Dose wartet schon.“), nach der Suche vier Ergebnis-Karten (Creme, Radius 20, `box-shadow: 0 6px 0 navy`, gestaffelt mit `slidein`, je 70ms Versatz): Nummer im Navy-Kreis, Name, „Typ · Straße Nr, PLZ“, drei Sortenpunkte (gefüllt = vorrätig, sonst transparent), rechts „X,X km“ (Anton 24px) + „N Min. zu Fuß“ (km × 12).
- Deterministische Demo-Daten aus der PLZ als Seed:
```js
const SHOPS = [['Späti Sonnendeck','Kiosk'],['Getränke Brandt','Getränkemarkt'],['Bar Azul','Bar'],['Feinkost Meyer','Feinkost'],['Kiosk am Kanal','Kiosk'],['Strandbar Welle','Beachbar'],['Markthalle Nord','Markt'],['Lido Café','Café'],['Eckladen Yıldız','Kiosk'],['Weinbar Lotte','Bar']];
const STREETS = ['Hafenstraße','Sonnenallee','Am Strandbad','Lindenweg','Palmenplatz','Kanalufer','Marktstraße','Gartenstraße'];
function rng(seed) { return () => { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; }; }
// r = rng(+plz); 4 zufällige Läden; Straße + Hausnummer 1–90; km = 0.2 + r()*4.8 (1 Nachkommastelle); Sorte vorrätig wenn r() > .3; nach km sortieren
```
## 14. Club + Footer
- Hintergrund Navy. **Club-Karte:** Wandblau, Radius 36, Padding `clamp(32px, 5vw, 72px)`, Grid `1.2fr 1fr`. Eyebrow „Sommer-Club“, H2 (`clamp(46px, 6vw, 96px)`) „Neue Sorte zuerst.“, Lede „Einmal im Monat: neue Rezepte, Drops und die Info, wo wir mit dem Palmera-Bus Station machen.“, Formular (E-Mail-Input + oranger Button „Eintragen“; nach Absenden ersetzt durch „Du bist drin. Check dein Postfach.“), Kleingedrucktes „Kein Spam, jederzeit abmeldbar. Nur für Menschen ab 18.“ Rechts unten ragt das Cap-in-Palme-Foto angeschnitten aus der Karte (`width: 38%; right: -4%; bottom: -12%; rotate(8deg)`, 10px Creme-Border, Radius 22).
- **Riesiger Schriftzug „PALMERA“** über die volle Breite (Anton, Wandblau, `font-size: clamp(90px, 21.5vw, 360px)`, `line-height: .8`, Buchstaben mit `justify-content: space-between` verteilt). Jeder Buchstabe ist ein eigenes Span: Hover hebt ihn an und dreht −4° (Overshoot), Farbe Orange. Beim ersten Sichtbarwerden (40 %) eine Welle: jeder Buchstabe steigt mit 60ms Versatz auf (`animation-fill-mode: backwards`, sonst blockiert die Animation den Hover).
- **Fußzeile** (1px Linie `rgba(255,244,222,.2)` darüber, 14px): links „18+“ im Kreis + „Palmera enthält Alkohol. Genieß verantwortungsvoll.“, Mitte Links Sorten · Rezepte · Händler · Impressum · Datenschutz, rechts „© 2026 Palmera“.
## 15. Toast
Fixed unten mittig, Navy-Pille mit Farbpunkt links (26px, Farbe per `--c`), fährt mit Overshoot von unten herein (`translate(-50%, 160%)` → `translate(-50%, 0)`), verschwindet nach 2,8 s.
## 16. Responsive-Zusammenfassung
- `max-aspect-ratio: 5/4`: Hero-Hochformat (siehe 6), Laufband ohne negativen Top-Margin.
- `max-width: 900px`: Sorten, Einschenken, Händler, Club einspaltig; Fotorahmen der Sorten max. 520px zentriert; Rezeptkarten einspaltig; Club-Bild relativ unter dem Text (60 % Breite, rechtsbündig, ragt unten raus); Topbar nur Logo + Warenkorb; Spiel-Buttons 15px, `nowrap`.
- `max-width: 640px`: Shop-Karussell.
- `max-width: 520px`: Geschmacksbalken einspaltig, Händler-Karten zweispaltig (km unter den Text), Hotspots 44px.
- Auf keiner Breite darf die Seite seitlich scrollen (das schräge Laufband wird abgeschnitten).
## 17. Barrierefreiheit und Bewegung
- `prefers-reduced-motion: reduce`: kein Smooth-Scroll, keine Einblend-, Dreh-, Schwing- oder Pulsanimationen (Hero-Headline, CTA-Füllung, Palme, Sticker, Strahlenkranz, Hotspot-Ping, Kreistexte, PALMERA-Welle, Kartenflip, Schublade), Reveals sofort sichtbar, Depth-Effekt steht still in der Mitte, keine Flug-Animation, Laufband steht.
- Sinnvolle Alt-Texte für alle Fotos, dekorative Elemente `aria-hidden="true"`. Tabs mit `role="tablist"/"tab"`, `aria-selected`; Hotspots und Farbpunkte mit `aria-pressed`; Größenwahl als `radiogroup`; Toast und Statuszeilen mit `role="status"`/`aria-live="polite"`.
- Ohne WebGL bleibt das statische Hero-Foto sichtbar.
## 18. Abnahme-Checkliste (prüfe alles im Browser, bevor du fertig meldest)
1. Hero: Glas, Hand und Arm ragen über den cremefarbenen Rahmen und überdecken das Ende von „Sonne.“; bei Mausbewegung verschieben sich Vorder- und Hintergrund gegeneinander, ohne Cursor schwebt das Bild langsam.
2. Palmenschatten schwingt oben links über Wand und Headline, Putzstruktur ist sichtbar, Sticker fliegt ein und dreht sich.
3. CTA läuft beim Hover navyblau voll, die drei Punkte fächern auf, der Nachbar-Button bewegt sich nicht.
4. Topbar erscheint erst nach dem Hero.
5. Sorten: Hotspot-Klick wechselt Hintergrund, Texte, Balken, Tabs und die Spielfarbe; Passion-Headline ist lesbar.
6. Shop: 2 × 2 liegende Karten ab 1000px, Karussell auf dem Handy; „+“ lässt die Dose in den Warenkorb fliegen; ab 2 Packs Stempel „Dein Glas!“ und Toast; Schublade zeigt Positionen, Gratis-Glas und Summe (z. B. 12,90 + 34,90 = 47,80 €).
7. Spiel: Halten füllt das Glas, Loslassen bewertet in ml, Rekord bleibt nach Reload erhalten.
8. Polaroids lassen sich ziehen, „Neu mischen“ verteilt sie.
9. PLZ-Suche liefert für dieselbe PLZ immer dieselben vier Ergebnisse.
10. Keine Konsolenfehler, kein horizontales Scrollen bei 375px, 768px und 1440px Breite.4. Kladde No. 5
Eine Landingpage für ein fiktives Skizzenbuch. Die ganze Seite sieht aus wie ein Blatt im Skizzenbuch, davor schwebt ein 3D-Notizbuch. Beim Scrollen legt es sich hin, das Gummiband rutscht ab, der Einband klappt auf, und ein 3D-Fineliner zeichnet Strich für Strich Skizzen auf die Seite. Besucher können mit der Maus selbst auf die Seite zeichnen.
Technik: Three.js r128 plus 2D-Canvas für die Seiten, eine einzige HTML-Datei.
Prompt anzeigenPrompt ausblenden635 Zeilen
# Prompt: Landingpage „Kladde No. 5“ – interaktives 3D-Skizzenbuch
> Kopiere alles ab der nächsten Linie in Claude (oder ein anderes KI-Coding-Tool). Der Prompt beschreibt die Seite so genau, dass sie Wert für Wert nachgebaut werden kann: Farben, Schriften, Texte, 3D-Maße, Animationszeiten und die Koordinaten der Skizzen.
---
Du bist ein erfahrener Creative Developer. Baue mir eine Landingpage für ein fiktives Skizzenbuch namens **„Kladde No. 5“**. Halte dich exakt an die folgende Spezifikation. Erfinde nichts dazu, lass nichts weg und ändere keine Werte. Wo Code-Blöcke stehen, übernimm die Werte, Pfade und Koordinaten wörtlich.
## 0. Technischer Rahmen
- **Eine einzige Datei** `index.html` mit inline `<style>` und inline `<script>`. Kein Build-Schritt, kein Framework, kein Tailwind.
- **Einzige externe Bibliothek:** Three.js **r128** als UMD-Build, geladen direkt vor dem eigenen Script:
```html
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
```
Nutze die r128-API (`THREE.sRGBEncoding`, `renderer.outputEncoding`, `texture.encoding`, `Color.convertSRGBToLinear()`). Keine ES-Module, keine Addons.
- **Schriften** über Google Fonts:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Instrument+Sans:wght@400;500;600&family=Reenie+Beanie&display=swap">
```
- Sprache der Seite: Deutsch (`<html lang="de">`), `<meta charset="utf-8">`, Viewport-Meta, `<title>Kladde No. 5</title>`, Meta-Description: „Kladde No. 5 – ein A5-Skizzenbuch mit 150 g/m² Papier, 5 mm Raster und Hardcover, das flach aufliegt.“
- Nur ein helles Theme (bewusst, kein Dark Mode). Die Seite muss auf 390 px Breite funktionieren und darf nie horizontal scrollen.
## 1. Konzept in einem Satz
Die ganze Website sieht aus wie eine Seite in einem Skizzenbuch: hellgrauer Papiergrund mit 5-mm-Punktraster, eine riesige Serifen-Headline „KREATIVER BEGLEITER“, davor schwebt ein kleines 3D-Notizbuch. Beim Scrollen legt es sich hin, wird größer, das Gummiband rutscht ab, der Einband klappt auf, und ein 3D-Fineliner zeichnet Strich für Strich Skizzen auf die rechte Seite. Die Besucherin kann selbst mit der Maus auf die Seite zeichnen. Überall stehen kleine handschriftliche Randnotizen in Bleistift-Grau.
## 2. Design-Tokens
```css
:root {
color-scheme: light;
--paper: #ECEBE6; /* Seitenhintergrund */
--sheet: #F6F5F0; /* helle Papierflächen, Karten */
--raster: #C3C1B9; /* Punkte des Rasters */
--rule: #D5D3CB; /* Linien, Karo, Trenner */
--ink: #141413; /* Text */
--ink-2: #3D3C38; /* Fließtext sekundär */
--graphite: #7F7D77; /* Labels, Notizen */
--faint: #A9A7A0; /* sehr leise Notizen */
--accent: #2E4B43; /* = aktuelle Einbandfarbe, wird per JS getauscht */
--on-accent: #F6F5F0;
--serif: "Instrument Serif", "Iowan Old Style", "Times New Roman", serif;
--sans: "Instrument Sans", "Helvetica Neue", Arial, sans-serif;
--hand: "Reenie Beanie", "Bradley Hand", "Segoe Print", cursive;
--cell: 22px;
--gutter: clamp(16px, 4vw, 48px);
--raster-img: radial-gradient(circle at 1.5px 1.5px, var(--raster) 1.15px, transparent 1.6px);
}
html[data-raster="grid"] { --raster-img: linear-gradient(to bottom, var(--rule) 1px, transparent 1px), linear-gradient(to right, var(--rule) 1px, transparent 1px); }
html[data-raster="blank"] { --raster-img: none; }
html { background: var(--paper); scroll-behavior: smooth; }
body {
margin: 0; background-color: var(--paper); background-image: var(--raster-img); background-size: var(--cell) var(--cell);
color: var(--ink); font-family: var(--sans); font-size: 16px; line-height: 1.55; overflow-x: hidden; -webkit-font-smoothing: antialiased;
}
* { box-sizing: border-box; }
a { color: inherit; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.wrap { max-width: 1320px; margin-inline: auto; padding-inline: var(--gutter); }
.eyebrow { font: 600 .74rem/1 var(--sans); letter-spacing: .16em; text-transform: uppercase; color: var(--graphite); margin: 0; }
.note { position: absolute; font-family: var(--hand); color: var(--graphite); font-size: 1.75rem; line-height: 1;
transform: rotate(var(--r, -4deg)); pointer-events: none; white-space: nowrap; z-index: 1; }
```
**Einbandfarben** (steuern `--accent`, das 3D-Cover und den Ring am Stift):
| id | Name | Hex |
|---|---|---|
| `tanne` (Standard) | Tannengrün | `#2E4B43` |
| `tinte` | Tintenblau | `#26334D` |
| `rost` | Rostrot | `#8C4434` |
| `graphit` | Graphit | `#3A3A3C` |
**Raster** (Namen für Datenblatt und Bestellübersicht): `dots` → „Punktraster 5 mm“, `grid` → „Karo 5 mm“, `blank` → „Blanko“. Standard ist `dots`, gesetzt als `document.documentElement.dataset.raster`.
**Buttons:**
```css
.btn { font: 600 .95rem/1 var(--sans); padding: .95rem 1.35rem; border-radius: 999px; border: 1px solid var(--ink);
background: transparent; color: var(--ink); cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: .55rem;
transition: background .2s, color .2s; }
.btn:hover { background: var(--ink); color: var(--sheet); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.btn.primary:hover { background: var(--ink); border-color: var(--ink); }
.btn svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
```
## 3. Header (sticky)
- `<header class="top">`, `position: sticky; top: env(safe-area-inset-top, 0px); z-index: 30;` Hintergrund `color-mix(in srgb, var(--paper) 86%, transparent)`, `backdrop-filter: blur(8px)`, `border-bottom: 1px solid var(--rule)`.
- Innen `.wrap` als Grid `1fr auto 1fr`, `gap: 1rem`, `padding-block: .7rem`.
- **Links – Logo:** Link auf `#start`, `aria-label="Kladde No. 5, zum Anfang"`. „Kladde“ in `400 1.9rem/1 var(--serif)`, `letter-spacing: -.01em`. Direkt dahinter „No. 5“ in der Handschrift (`400 1.35rem/1 var(--hand)`), Farbe `--accent`, `transform: rotate(-6deg) translateY(-2px)`. Beide `align-items: baseline`, `gap: .35rem`.
- **Mitte – Navigation:** „Papier“ (`#papier`), „Aufbau“ (`#aufbau`), „Bestellen“ (`#bestellen`). Schrift `400 1.2rem/1 var(--serif)`, `gap: 2.4rem`, Farbe `--ink-2`, beim Hover `--ink` und unterstrichen (`text-underline-offset: 4px`).
- **Rechts:** Flex mit `gap: 1.2rem`:
1. **Raster-Schalter** als Segmented Control (`role="group" aria-label="Seitenraster"`): Pille mit `1px solid var(--ink)`, `border-radius: 999px`, `padding: 3px`, Hintergrund `--sheet`. Drei Buttons „Punkte“ / „Karo“ / „Blanko“ (`data-raster="dots|grid|blank"`, `aria-pressed`). Button: `500 .8rem/1 var(--sans)`, `padding: .45rem .75rem`, rund. Aktiv: Hintergrund `--ink`, Text `--sheet`. Vor jedem Label ein 12×12-px-Icon mit `1px solid currentColor`, `border-radius: 2px`, `opacity: .85`: Punkte = `radial-gradient(circle at 2px 2px, currentColor .8px, transparent 1.1px) 0 0 / 4px 4px`, Karo = zwei 0,6-px-Linien-Gradients im 4-px-Raster, Blanko = leer.
2. **„Vorbestellen“** als Link auf `#bestellen` in `400 1.3rem/1 var(--serif)`, unterstrichen (`text-underline-offset: 5px`, `text-decoration-thickness: 1.5px`), dahinter hochgestellt `<sup>24,90 €</sup>` in `500 .7rem/1 var(--sans)`, Farbe `--graphite`, `margin-left: .25rem`.
- **Responsive:** unter 860 px Navigation ausblenden und Grid auf `1fr auto`. Unter 560 px „Vorbestellen“ ausblenden. Unter 440 px die Raster-Labels ausblenden (nur Icons, Padding `.45rem .6rem`).
## 4. Hero – festgepinnte Bühne
### 4.1 Struktur
```html
<main>
<section class="hero" id="start" aria-label="Kladde No. 5">
<div class="pin" id="stage">
<p class="kicker">Für alles, was noch keine Form hat.</p>
<h1 class="mega"><span>Kreativer</span><span>Begleiter</span></h1>
<p class="byline"><em>Kladde No. 5</em> · Skizzenbuch A5 · 150 g/m² · 112 Seiten</p>
<canvas id="book" role="img" aria-label="Dreidimensionales Skizzenbuch vor der Überschrift „Kreativer Begleiter“. Beim Scrollen legt es sich hin und klappt auf, dann zeichnet ein Fineliner einen Leuchtturm, eine Kaffeetasse und eine Berglandschaft."></canvas>
<div class="fallback">Hier läge das Skizzenbuch.<br>Dein Browser zeigt leider kein WebGL.</div>
<span class="note n1">fig. 1</span>
<span class="note n2">5 mm</span>
<span class="note n3">Ø 0,3</span>
<span class="note n4">150 g/m²</span>
<span class="note n5">HB</span>
<span class="note n6">S. 001</span>
<span class="note n7">2B</span>
<span class="note n8">A5</span>
<div class="scrollhint" aria-hidden="true">
<svg viewBox="0 0 96 96">
<defs><path id="ring" d="M48 48m-36 0a36 36 0 1 1 72 0"/></defs>
<text><textPath href="#ring" startOffset="4">scrollen</textPath></text>
<rect class="m" x="40" y="44" width="16" height="26" rx="8"/>
<path class="m wheel" d="M48 50v5"/>
<path class="m" d="M45 40l3-3 3 3"/>
</svg>
</div>
<p class="try" id="tryText">zeichne selbst auf die rechte Seite</p>
<div class="dock" id="dock">
<span class="lab">Einband: <strong id="coverName">Tannengrün</strong></span>
<div class="sws" role="group" aria-label="Einbandfarbe">
<!-- 4 runde Swatches, je: <button type="button" class="sw" id="c-tanne" data-cover="tanne" style="--c:#2E4B43" aria-label="Tannengrün" aria-pressed="true"></button> -->
</div>
<button type="button" class="btn" id="flipBtn">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M14 4.5c-2-2-5.5-2.2-8-.8M2 11.5c2 2 5.5 2.2 8 .8M5.5 1.8 6 3.7 4.2 4.4M10.5 14.2 10 12.3l1.8-.7"/></svg>
Umblättern
</button>
</div>
</div>
</section>
```
### 4.2 CSS der Bühne
Das JS setzt auf `.hero` zwei Custom Properties: `--p` (Scroll-Fortschritt 0–1) und `--o` (Öffnungsgrad des Buchs 0–1), plus die Klasse `.is-open`, sobald `--o > 0.95`.
```css
.hero { position: relative; height: 290vh; --p: 0; --o: 0; }
.pin { position: sticky; top: 0; height: 100vh; height: 100svh; overflow: hidden; }
.mega { position: absolute; inset: 0; margin: 0; z-index: 0; pointer-events: none;
display: flex; flex-direction: column; align-items: center; justify-content: center;
font: 400 clamp(3.4rem, min(22.5vw, 40vh), 30rem)/.82 var(--serif); letter-spacing: -.025em; text-transform: uppercase;
color: var(--accent); transition: color .5s;
transform: translateY(calc(var(--o) * -2vh)) scale(calc(1 - var(--o) * .05)); }
.mega span { display: block; }
.kicker, .byline { position: absolute; left: 0; right: 0; z-index: 2; margin: 0; text-align: center; padding-inline: var(--gutter); pointer-events: none; }
.kicker { top: calc(4vh + 60px); font: 400 clamp(1.35rem, 2.5vw, 2.3rem)/1.1 var(--serif); opacity: calc(1 - var(--o) * 1.6); }
.byline { bottom: 7vh; font: 400 clamp(1.1rem, 1.8vw, 1.6rem)/1.2 var(--serif); color: var(--ink-2); opacity: calc(1 - var(--o) * 1.6); }
.byline em { font-style: italic; color: var(--ink); }
.pin canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; z-index: 1; touch-action: pan-y; }
.pin .fallback { display: none; position: absolute; inset: 30% 30%; z-index: 1; place-items: center; text-align: center; background: var(--sheet); border: 1px dashed var(--faint); font: 400 1.7rem/1.2 var(--hand); color: var(--graphite); }
.pin.nogl .fallback { display: grid; }
.pin .note { z-index: 0; color: var(--faint); font-size: 1.9rem; } /* Notizen liegen HINTER dem Canvas */
.n1 { top: 20%; left: 11%; } .n2 { top: 27%; left: 23%; --r: 4deg; } .n3 { top: 33%; right: 34%; --r: 3deg; }
.n4 { top: 47%; right: 12%; --r: 6deg; } .n5 { top: 55%; left: 30%; --r: -3deg; } .n6 { top: 72%; right: 30%; }
.n7 { bottom: 9%; left: 26%; --r: 5deg; } .n8 { bottom: 7%; right: 24%; --r: -6deg; }
.scrollhint { position: absolute; right: var(--gutter); bottom: 2vh; width: 88px; height: 88px; z-index: 2; opacity: calc(1 - var(--p) * 9); pointer-events: none; }
.scrollhint svg { width: 100%; height: 100%; overflow: visible; }
.scrollhint text { font-family: var(--hand); font-size: 17px; fill: var(--ink-2); letter-spacing: 4px; }
.scrollhint .m { fill: none; stroke: var(--ink-2); stroke-width: 1.3; }
.scrollhint .wheel { animation: wheel 1.6s ease-in-out infinite; }
@keyframes wheel { 0%, 100% { transform: translateY(0); opacity: 1; } 60% { transform: translateY(5px); opacity: .3; } }
.dock { position: absolute; left: 50%; bottom: max(16px, 3vh); z-index: 3; transform: translate(-50%, calc((1 - var(--o)) * 20px));
display: flex; align-items: center; gap: .9rem; flex-wrap: wrap; justify-content: center; max-width: calc(100% - 32px); width: max-content;
background: color-mix(in srgb, var(--sheet) 92%, transparent); border: 1px solid var(--rule); border-radius: 999px;
padding: .45rem .45rem .45rem 1.1rem; box-shadow: 0 18px 40px -28px rgba(20,20,19,.5);
opacity: clamp(0, calc((var(--o) - .75) * 4), 1); pointer-events: none; }
.hero.is-open .dock { pointer-events: auto; }
.dock .lab { font-size: .85rem; color: var(--graphite); }
.dock .lab strong { color: var(--ink); font-weight: 600; }
.dock .btn { padding: .7rem 1.1rem; }
.sws { display: flex; gap: .45rem; }
.sw { width: 28px; height: 28px; border-radius: 50%; border: 3px solid var(--sheet); background: var(--c); box-shadow: 0 0 0 1px var(--faint); cursor: pointer; padding: 0; transition: transform .2s; }
.sw:hover { transform: scale(1.08); }
.sw[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--ink); }
.try { position: absolute; left: 50%; bottom: calc(max(16px, 3vh) + 4.2rem); z-index: 3; transform: translateX(-50%) rotate(-2deg);
font: 400 1.7rem/1 var(--hand); color: var(--accent); pointer-events: none; white-space: nowrap; opacity: clamp(0, calc((var(--o) - .75) * 4), 1); }
@media (max-width: 880px) { .n2, .n3, .n6 { display: none; } .kicker { top: calc(6vh + 56px); } .byline { bottom: 18vh; } .dock { border-radius: 22px; } }
```
Bei Touch-Geräten (`matchMedia('(pointer: coarse)')`) lautet der Hinweis `#tryText` stattdessen „tippe und zieh auf der rechten Seite“.
## 5. Die 3D-Szene (Three.js r128)
### 5.1 Renderer, Kamera, Licht
- `WebGLRenderer({ canvas, antialias: true, alpha: true })`, `setPixelRatio(min(devicePixelRatio, 2))`, `outputEncoding = sRGBEncoding`, Schatten an (`PCFSoftShadowMap`), `setClearColor(0x000000, 0)`. Wenn `THREE` fehlt oder der Renderer wirft: Klasse `nogl` auf `#stage` setzen und abbrechen.
- Hilfsfunktion für alle Hex-Farben von Materialien: `col = h => new THREE.Color(h).convertSRGBToLinear()`. Alle CanvasTextures bekommen `encoding = sRGBEncoding` und maximale Anisotropie.
- `PerspectiveCamera(30, aspect, 0.1, 60)`. Ziel `camTarget = (0.05, 0, 0.12)`, Blickrichtung `camDir = normalize(0.12, 0.93, 0.62)`.
- **fit()** (per `ResizeObserver` auf `#stage`): Renderer auf Bühnengröße, `aspect = w/h`, `t = tan(fov/2)`, Abstand `d = max(3.2 / (2t), 3.7 / (2t · aspect))`, `camera.position = camDir · d + camTarget`, `lookAt(camTarget)`.
- Licht: `HemisphereLight(0xffffff, 0xcfcbc2, 0.78)` und `DirectionalLight(0xffffff, 0.62)` bei `(-2.6, 7, 3.4)`, wirft Schatten: `mapSize 2048`, Schattenkamera `±3.4`, `near 1`, `far 20`, `bias -0.0006`, `normalBias 0.015`, `radius 4`.
- Boden: `PlaneGeometry(24, 24)` flach gelegt mit `ShadowMaterial({ opacity: 0.17 })`, empfängt Schatten. So sieht man nur den Schatten, nicht die Fläche.
### 5.2 Maße (Welt-Einheiten ≈ Dezimeter)
```js
const W = 1.48, H = 2.10, CT = 0.02, BT = 0.09, PW = W - 0.02, CWID = W + 0.02, CD = H + 0.05;
const HB = BT / 2; // jede Seitenhälfte ist halb so dick wie der Block
const CW = 1000, CH = 1419; // Pixelgröße jeder Seiten-Textur (A5-Verhältnis)
const INK = '#1b1c20'; // Fineliner-Tinte
```
Hierarchie: `scene → holder (Group) → book (Group)`. `holder` wird vom Scrollen gedreht und skaliert, `book` bekommt die Mausneigung. Das Buch liegt in der XZ-Ebene, Y zeigt nach oben, der Rücken liegt bei x = 0, das Buch erstreckt sich nach +x. Alle Buchteile werfen und empfangen Schatten.
### 5.3 Materialien und Texturen
- **Seitenschnitt:** Canvas 4×64 px, jede dritte Zeile `#d6d2c7`, sonst `#efece4`. `RepeatWrapping`, `repeat (1, 2)`, `MeshLambertMaterial`.
- **Cover:** Canvas 512×736 px. `paintCover(hex)` malt:
1. Vollfläche in `hex`.
2. Linearen Verlauf von (0,0) nach (512,736): `rgba(255,255,255,0.06)` → `rgba(0,0,0,0.06)`.
3. 9000 zufällige Punkte der Größe 1,2 px, je zur Hälfte `rgba(255,255,255,0.028)` und `rgba(0,0,0,0.035)`. Das ist eine feine, matte Körnung. **Wichtig: kein Linien- oder Karomuster.**
4. Einen eingeprägten Schriftzug „Kladde“ in `400 46px "Instrument Serif"`, zentriert bei x = 256, in drei Lagen: `rgba(0,0,0,0.28)` bei y = 668, `rgba(255,255,255,0.10)` bei y = 670.5, `rgba(0,0,0,0.10)` bei y = 669.
Material der Oberseite: `MeshStandardMaterial({ map, roughness: 0.62 })`. Kanten: `MeshStandardMaterial({ color: col(hex), roughness: 0.8 })`. Innenseite (Vorsatz): `MeshLambertMaterial({ color: col('#EAE6DC') })`.
### 5.4 Bauteile
Die Materialreihenfolge bei `BoxGeometry` ist `[+x, −x, +y, −y, +z, −z]`.
1. **Hinterer Deckel:** `Box(CWID, CT, CD)` bei `(CWID/2, CT/2, 0)`. Materialien: Kante, Kante, Vorsatz (oben), Kante, Kante, Kante.
2. **Rechte Seitenhälfte:** `Box(PW, HB, H)`, Geometrie um `+HB/2` in y verschoben, Position `(0.01 + PW/2, CT, 0)`. Die Oberseite (+y) trägt die **rechte Seitentextur** (Canvas 1000×1419, `MeshLambertMaterial`), alle anderen Flächen den Seitenschnitt. Die Oberkante liegt dauerhaft bei `pageTop = CT + HB`.
3. **Cover-Drehpunkt** `coverPivot` (Group) bei x = 0. Darin:
- **Vorderer Deckel:** `Box(CWID, CT, CD)` bei `(CWID/2, CT/2, 0)`. Materialien: Kante, Kante, Cover-Textur (oben), Vorsatz (unten), Kante, Kante.
- **Linke Seitenhälfte, die am Deckel hängt:** `Box(PW, HB, H)`, Geometrie um `−HB/2` in y verschoben, Position `(0.01 + PW/2, 0, 0)` **innerhalb von coverPivot**. Die Unterseite (−y, Index 3) trägt die **linke Seitentextur**, alle anderen Flächen den Seitenschnitt. Die linke Textur bekommt `center = (0.5, 0.5)` und `rotation = Math.PI`, weil diese Fläche nach dem Aufklappen um 180° gedreht oben liegt. Dadurch klappen die linken Seiten immer mit dem Einband zusammen auf und zu. Nichts erscheint vorher schon links auf dem Tisch.
4. **Buchrücken:** `Box(0.03, CT*2 + BT, CD)` bei `(-0.012, (CT*2+BT)/2, 0)` in Kantenfarbe. Nur sichtbar, solange `open < 0.04`.
5. **Gummiband als Schlaufe** (Material `MeshStandardMaterial({ color: col('#1c1c1c'), roughness: 0.6 })`), Group `band`:
- `bandTop = Box(0.034, 0.008, CD + 0.016)`, das Stück über dem Cover.
- Zwei Enden `Box(0.034, 1, 0.008)` für `side = −1` und `+1`. Sie laufen an der vorderen und hinteren Buchkante nach unten und werden in y auf die aktuelle Höhe skaliert.
6. **Zwei Lesebändchen:** `PlaneGeometry(0.034, 0.42, 1, 30)`, flach gelegt, um `H/2 + 0.12` in z verschoben. Für jeden Vertex: wenn `z < CD/2`, dann `y = CT + 0.0015`, sonst `y = max(0.0015, CT + 0.0015 − (z − CD/2) · 0.55)`. So hängen sie über die untere Kante auf den Tisch. `MeshLambertMaterial`, `DoubleSide`. Bändchen 1: x = 0.36, Farbe `#B88A3E`, `rotation.y = 0.05`. Bändchen 2: x = 0.43, Farbe `#232323`, `rotation.y = −0.07`.
7. **Blatt zum Umblättern:** `flipPivot` (Group, anfangs unsichtbar) mit `PlaneGeometry(PW, H, 28, 1)`, flach gelegt und um `0.01 + PW/2` in x verschoben. Die Ausgangspositionen werden gespeichert. Zwei Meshes teilen sich dieselbe Geometrie: Vorderseite (`FrontSide`, Textur = Kopie der rechten Seite) und Rückseite (`BackSide`, Textur = horizontal gespiegelte Rückseite).
8. **Fineliner:** `pen` (Group, der Ursprung ist die Stiftspitze) → `penAxis` (Group) → Zylinder entlang +y, alle mit 28 Segmenten und Schatten:
| r oben | r unten | Höhe | y-Mitte | Material |
|---|---|---|---|---|
| 0.0035 | 0.0035 | 0.034 | 0.017 | Metall |
| 0.011 | 0.024 | 0.07 | 0.069 | Schwarz |
| 0.026 | 0.029 | 0.06 | 0.134 | Schwarz |
| 0.0295 | 0.0295 | 0.024 | 0.176 | Ring (Einbandfarbe) |
| 0.029 | 0.029 | 0.8 | 0.588 | Schwarz |
| 0.0305 | 0.0305 | 0.24 | 1.0 | Schwarz |
| 0.024 | 0.0305 | 0.03 | 1.135 | Schwarz |
Dazu ein Clip `Box(0.012, 0.2, 0.008)` in Metall bei `(0, 1.02, 0.036)`. Schwarz = `MeshStandardMaterial({ color: col('#1a1a1a'), roughness: 0.38, metalness: 0.1 })`, Metall = `({ color: col('#a8a8a4'), roughness: 0.28, metalness: 0.85 })`, Ring = `({ color: col('#2E4B43'), roughness: 0.5 })`, bei Farbwechsel neu eingefärbt.
## 6. Die Seiten (2D-Canvas)
### 6.1 Leere Seite `paperBase(ctx, side, no)`
- Fläche `#F5F3ED`.
- Raster: `step = 1000/148·5` (= 5 mm), `ox = (CW − floor(CW/step)·step)/2`, erste Zeile bei `oy = 176`, bis `CH − 40`.
- `dots`: Kreise mit Radius 2,1 px in `#bdbab1`.
- `grid`: Linien in `#d4d1c8`, 1,4 px breit.
- `blank`: nichts.
- Kopfzeile (nur wenn `no` gesetzt ist): Seitenzahl dreistellig (`001`) und das Wort „Datum“ in `500 24px "Instrument Sans"`, Farbe `#9c9a93`, Linie in `#b9b6ad`, 1,5 px.
- Rechte Seite (`side === 'r'`): Zahl rechtsbündig bei (CW−110, 120), „Datum“ bei (110, 120), Linie von (192, 124) bis (430, 124).
- Linke Seite: Zahl bei (110, 120), „Datum“ rechtsbündig bei (CW−360, 120), Linie von (CW−350, 124) bis (CW−110, 124).
### 6.2 Vorsatzblatt (linke Seite beim ersten Aufklappen)
Fläche `#EFEBE1`. Labels in `500 25px "Instrument Sans"`, Farbe `#9c9a93`. Linien in `#bab6ab`, 1,5 px, von x = 120 bis CW−120. Handschrift in `"Reenie Beanie"`, Farbe INK:
| Label (x = 120) | y Label | Handschrift (x = 140, y = y+84) | Größe |
|---|---|---|---|
| Dieses Buch gehört | 360 | Mara Kessler | 80px |
| Falls gefunden, bitte zurück an | 560 | Atelier 4, Hafenstraße 12, Kiel | 58px |
| Finderlohn | 760 | ein Flat White und mein Dank | 58px |
Die Linie liegt jeweils bei y + 96. Darunter „Begonnen“ bei (120, 960) und „Beendet“ bei (560, 960). Linien von 120 bis 440 und von 560 bis CW−120, beide bei y = 1056. Handschriftlich „25.09.26“ bei (140, 1044) in 62px. Unten zentriert bei y = 1300 in `400 30px "Instrument Serif"`, Farbe `#aaa79f`: „Kladde No. 5 · 150 g/m² · 112 Seiten“.
## 7. Die Skizzen (Strich für Strich)
### 7.1 Hilfsfunktionen (exakt so implementieren)
```js
function rnd(seed) { let s = seed % 2147483647; return () => (s = s * 16807 % 2147483647) / 2147483647; }
function resample(P, step = 7) { /* Polylinie in Punkte mit ~7 px Abstand zerlegen */
const out = [P[0]];
for (let i = 1; i < P.length; i++) {
const a = P[i-1], b = P[i], n = Math.max(1, Math.ceil(Math.hypot(b[0]-a[0], b[1]-a[1]) / step));
for (let k = 1; k <= n; k++) out.push([a[0] + (b[0]-a[0])*k/n, a[1] + (b[1]-a[1])*k/n]);
}
return out;
}
function catmull(P, step = 7) { /* Catmull-Rom-Spline durch P, ~7 px Abtastung */
const out = [];
for (let i = 0; i < P.length - 1; i++) {
const p0 = P[i-1] || P[i], p1 = P[i], p2 = P[i+1], p3 = P[i+2] || P[i+1];
const n = Math.max(2, Math.ceil(Math.hypot(p2[0]-p1[0], p2[1]-p1[1]) / step));
for (let s = 0; s < n; s++) {
const t = s/n, t2 = t*t, t3 = t2*t;
const f = j => 0.5 * (2*p1[j] + (-p0[j]+p2[j])*t + (2*p0[j]-5*p1[j]+4*p2[j]-p3[j])*t2 + (-p0[j]+3*p1[j]-3*p2[j]+p3[j])*t3);
out.push([f(0), f(1)]);
}
}
out.push(P[P.length-1]);
return out;
}
function Sk(seed) {
const r = rnd(seed), S = [];
const wob = (pts, amp) => { /* leichtes Handzittern entlang der Strichlänge */
const a = r()*6.3, b = r()*6.3, f1 = 0.011 + r()*0.012, f2 = 0.013 + r()*0.012; let len = 0;
return pts.map((p, i) => { if (i) len += Math.hypot(p[0]-pts[i-1][0], p[1]-pts[i-1][1]);
return [p[0] + Math.sin(len*f1 + a)*amp, p[1] + Math.sin(len*f2 + b)*amp]; });
};
return {
S,
line(x1, y1, x2, y2, amp = 1.3) { /* gerade Linie mit kleinem Überschwinger an beiden Enden */
const dx = x2-x1, dy = y2-y1, d = Math.hypot(dx, dy) || 1, e1 = r()*5, e2 = r()*6;
S.push({ pts: wob(resample([[x1 - dx/d*e1, y1 - dy/d*e1], [x2 + dx/d*e2, y2 + dy/d*e2]]), amp) });
},
poly(P, amp = 1.2) { S.push({ pts: wob(resample(P), amp) }); },
curve(P, amp = 1) { S.push({ pts: wob(catmull(P), amp) }); },
ell(cx, cy, rx, ry, a0 = 0, a1 = Math.PI * 2.08, amp = 1.2) {
const n = Math.ceil(Math.abs(a1-a0) * Math.max(rx, ry) / 7), P = [];
for (let i = 0; i <= n; i++) { const a = a0 + (a1-a0)*i/n; P.push([cx + Math.cos(a)*rx, cy + Math.sin(a)*ry]); }
S.push({ pts: wob(P, amp) });
},
text(text, x, y, size) { S.push({ text, x, y, size }); } /* Handschrift, wird von links nach rechts „geschrieben“ */
};
}
```
Alle Koordinaten unten sind Pixel auf der 1000×1419-Seite.
### 7.2 Skizze 1 – Leuchtturm (Seed 17)
```js
const k = Sk(17);
const L = y => 500 - (100 - (1000 - y) / 440 * 38), R = y => 1000 - L(y);
k.text('25.09.26', 206, 116, 56);
k.line(L(1000), 1000, L(560), 560); k.line(R(1000), 1000, R(560), 560);
[900, 830, 740, 670].forEach(y => k.line(L(y), y, R(y), y, 0.7));
[[900, 830], [740, 670]].forEach(([a, b]) => { for (let x = L(a) + 7; x + 17 < R(b) - 2; x += 13) k.line(x, a - 4, x + 15, b + 5, 0.4); });
k.curve([[480,1000],[479,963],[490,947],[510,947],[521,963],[520,1000]], 0.5);
k.poly([[492,772],[508,772],[508,797],[492,797],[492,772]], 0.4);
k.poly([[406,561],[594,561],[594,546],[406,546],[406,562]], 0.6);
k.line(412, 514, 588, 514, 0.6);
[418, 450, 482, 518, 550, 582].forEach(x => k.line(x, 546, x, 516, 0.3));
k.poly([[456,514],[456,463],[544,463],[544,514]], 0.5);
k.line(486, 464, 486, 513, 0.3); k.line(514, 464, 514, 513, 0.3);
k.curve([[445,463],[462,431],[500,413],[538,431],[555,463]], 0.5); k.line(443, 463, 557, 463, 0.5);
k.line(500, 413, 500, 388, 0.3); k.ell(500, 381, 6, 6, 0, 6.7, 0.3);
k.line(446, 478, 176, 418, 0.8); k.line(446, 500, 170, 562, 0.8);
k.line(554, 478, 824, 418, 0.8); k.line(554, 500, 830, 562, 0.8);
k.curve([[262,1034],[292,1004],[330,988],[372,996],[408,1000]], 1);
k.curve([[592,1000],[640,984],[676,990],[712,1006],[742,1034]], 1);
for (let i = 0; i < 5; i++) { k.line(296 + i*18, 1030, 308 + i*18, 1012, 0.3); k.line(624 + i*18, 1030, 636 + i*18, 1010, 0.3); }
[[1054,170,830,9],[1090,214,786,7],[1122,272,728,5]].forEach(([y, x0, x1, a]) => {
const P = []; let i = 0; for (let x = x0; x <= x1; x += 34, i++) P.push([x, y + (i % 2 ? -a : a)]); k.curve(P, 0.5);
});
[[262,300,1],[326,262,0.8],[706,322,0.9]].forEach(([x, y, s]) =>
k.curve([[x-24*s, y+6*s],[x-11*s, y-5*s],[x, y+4*s],[x+11*s, y-6*s],[x+26*s, y+5*s]], 0.3));
k.text('Leuchtfeuer Westerhever, Ebbe.', 150, 1236, 66);
```
### 7.3 Skizze 2 – Flat White (Seed 29)
```js
const k = Sk(29);
k.text('26.09.26', 206, 116, 56);
k.ell(500, 936, 274, 66, 0.1, Math.PI * 2.12, 1.2);
k.ell(500, 928, 152, 30, 0.05, Math.PI - 0.05, 0.7);
k.ell(500, 642, 186, 46, Math.PI, Math.PI * 3.08, 1);
k.ell(500, 650, 158, 32, Math.PI, Math.PI * 3.05, 0.7);
k.curve([[500,676],[468,652],[474,630],[500,641],[526,630],[532,652],[500,676]], 0.4);
k.curve([[314,644],[322,730],[350,820],[400,893],[500,916],[600,893],[650,820],[678,730],[686,644]], 1);
k.curve([[676,690],[742,678],[780,716],[766,792],[716,820],[648,816]], 0.8);
k.curve([[672,724],[722,718],[738,756],[714,786],[656,790]], 0.6);
const bodyR = y => 686 - Math.pow((y - 644) / 272, 1.8) * 186;
for (let y = 700; y <= 872; y += 15) k.line(bodyR(y) - 46, y + 10, bodyR(y) - 12, y - 6, 0.3);
for (let x = 604; x <= 720; x += 14) k.line(x, 968, x + 16, 950, 0.3);
[[440,588],[500,578],[560,590]].forEach(([x, y]) => k.curve([[x,y],[x-14,y-40],[x+10,y-80],[x-8,y-122],[x+8,y-160]], 0.5));
k.text('Flat White, Rösterei am Hafen, 7:42.', 150, 1236, 62);
```
### 7.4 Skizze 3 – Hochgrat (Seed 41)
```js
const k = Sk(41);
k.text('27.09.26', 206, 116, 56);
k.line(150,420,850,420,0.6); k.line(850,420,850,1010,0.6); k.line(850,1010,150,1010,0.6); k.line(150,1010,150,420,0.6);
k.ell(690, 520, 36, 36, 0, 6.7, 0.6);
k.curve([[150,770],[220,712],[285,728],[360,600],[415,650],[470,556],[540,640],[610,612],[680,690],[760,660],[850,720]], 1);
k.poly([[332,650],[346,638],[358,656],[372,634],[392,630]], 0.4);
k.poly([[442,606],[458,594],[470,614],[486,590],[506,600]], 0.4);
[[470,556,540,640],[360,600,415,650],[610,612,680,690]].forEach(([x1,y1,x2,y2]) => {
for (let i = 1; i < 6; i++) { const t = i/6, px = x1 + (x2-x1)*t, py = y1 + (y2-y1)*t; k.line(px-4, py+8, px-18, py+34, 0.3); }
});
const front = [[150,870],[250,808],[330,836],[430,762],[520,826],[600,796],[700,852],[850,812]];
k.curve(front, 1);
const fp = catmull(front);
const fy = x => fp.reduce((b, p) => Math.abs(p[0]-x) < Math.abs(b[0]-x) ? p : b)[1];
for (let x = 172; x <= 828; x += 13) { const y = fy(x); k.line(x, y + 10, x - 6, y + 32 + (x * 7) % 23, 0.3); }
k.line(150, 906, 850, 906, 0.5);
for (let i = 0; i < 7; i++) { const x = 214 + i*88 + (i%2)*20, y = 928 + (i%3)*16; k.line(x, y, x + 44, y, 0.3); }
const pine = (x, y, s) => { k.poly([[x,y-72*s],[x-15*s,y-42*s],[x-6*s,y-44*s],[x-22*s,y-10*s],[x+22*s,y-10*s],[x+6*s,y-44*s],[x+15*s,y-42*s],[x,y-72*s]], 0.4); k.line(x, y-10*s, x, y, 0.2); };
pine(214,906,1); pine(252,906,0.8); pine(774,906,0.9); pine(810,906,1.1);
[[300,500,0.8],[344,476,0.6]].forEach(([x, y, s]) => k.curve([[x-24*s,y+6*s],[x-11*s,y-5*s],[x,y+4*s],[x+11*s,y-6*s],[x+26*s,y+5*s]], 0.3));
k.text('Hochgrat, 1834 m. Wind aus West.', 150, 1236, 62);
```
Die Skizzen erst bauen, **nachdem die Schriften geladen sind**: `Promise.race` aus `document.fonts.load` für `64px "Reenie Beanie"`, `500 24px "Instrument Sans"` und `46px "Instrument Serif"` sowie einem Timeout von 2,5 s. Danach bei jedem Text-Strich die Breite `s.w` mit `measureText` in `${size}px "Reenie Beanie"` messen. Danach das Cover neu malen und beide Seiten rendern.
## 8. Zeichen-Engine
### 8.1 Zustand
```js
const P = { no: 1, sk: 0, si: 0, pi: 0, acc: 0, rev: 0, user: [] }; // rechte Seite: Seitenzahl, Skizze, Strich, Punkt, Restweg, Text-Fortschritt, eigene Striche
let leftState = { type: 'end' }; // 'end' = Vorsatzblatt, sonst { type: 'verso', no }
const S = { phase: 'scroll', p: 0, open: 0, band: 0, pose: 0, travel: null, doneT: 0, flipT: 0, resume: false, lift: 0.22 };
let penC = [860, 330]; // Stiftspitze in Seitenpixeln
const HOVER = [870, 300], SIDE = [1190, 860], SIDE_LIFT = 0.42;
const restPos = new THREE.Vector3(0.52, CT*2 + BT + 0.03, 0.56); // Stift liegt quer auf dem geschlossenen Cover
const ease = t => t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2;
const toLocal = (c, lift) => new THREE.Vector3(0.01 + c[0]/CW*PW, CT + HB + lift, -H/2 + c[1]/CH*H);
```
- **Tintenstrich:** `strokeStyle = INK`, `lineWidth 3.6` (eigene Striche der Nutzerin: 3.4), `lineCap/lineJoin = round`.
- **Handschrift-Text wird freigelegt:** mit einem Clip-Rechteck von `x + a` bis `x + b`, Höhe von `y − size` bis `y + 0.7·size`. So wird nie doppelt übermalt. Das erste Stück beginnt bei `a = −14`, das letzte endet bei `s.w + 14`.
- **renderRight()** zeichnet die rechte Seite komplett neu: leere Seite, alle fertigen Striche, den aktuellen Strich bis `pi` bzw. `rev`, dann alle Nutzer-Striche. Das wird beim Rasterwechsel gebraucht.
### 8.2 Ablauf beim Zeichnen
- **Geschwindigkeiten:** Linien `SPEED = 1750` px/s, Text `TEXT_SPEED = 470` px/s.
- **inkStep(dt):** Bei Linien wird `acc` um `SPEED·dt` erhöht, dann werden so viele Segmente gezeichnet, wie der Weg hergibt. Die Stiftspitze sitzt interpoliert zwischen den Punkten. Bei Text wächst `rev`, die Spitze liegt bei `[x + rev, y − 0.24·size + sin(rev·0.09)·0.16·size]` (kleines Auf und Ab beim Schreiben). Der Stift liegt dabei auf der Seite (`lift = 0`). Am Strich-Ende kommt `beginTravel()`.
- **beginTravel():** Stift hebt ab und fliegt zum Startpunkt des nächsten Strichs. Dauer `0.1 + Distanz/3600` s, Bogenhöhe `min(0.07, 0.015 + Distanz/5000)`, Verlauf mit `ease`, Lift wird zu 0. Gibt es keinen Strich mehr, wechselt die Phase zu `done` und der Stift fliegt in 0,6 s zu `HOVER` mit Lift 0.22.
- **done:** Der Stift schwebt über der Seite (`lift = 0.22 + sin(t·1.6)·0.015`). Nach **5,5 s** ohne Nutzereingabe wird automatisch umgeblättert.
### 8.3 Umblättern
- **Button „Umblättern“** oder Automatik, nur in Phase `draw` oder `done`:
1. Die Vorderseite des Blatts bekommt eine Kopie der rechten Seite.
2. Die Rückseite zeigt die leere linke Seite `no + 1`, horizontal gespiegelt.
3. Die rechte Seite springt auf `no + 2`, die nächste Skizze (`(sk + 1) % 3`), keine Nutzer-Striche, und wird neu gerendert.
4. Das Blatt dreht in **1,15 s** mit `ease` um die z-Achse von 0 bis π, bei `y = pageTop + 0.003`.
5. Dabei wölbt es sich: für jeden Vertex mit Ausgangs-x `x` und `f = x/W` gilt `x' = x·(1 − 0.05·sin(πt)·f)` und `y' = −sin(πt)·0.3·f²`. Danach Normalen neu berechnen.
6. Am Ende das Blatt ausblenden, die linke Seite wird zur leeren Seite `no + 1`, dann weiterzeichnen.
- Der Stift schwebt während des Umblätterns nach `HOVER`.
### 8.4 Selbst zeichnen (Pointer auf dem Canvas)
- Per Raycaster nur gegen `rightBlock` testen, nur Treffer mit `face.normal.y > 0.5`. Seitenpixel = `[uv.x · CW, (1 − uv.y) · CH]`.
- **Maus über der Seite:** Cursor ausblenden (`cursor: none`). Der 3D-Stift folgt der Maus mit Glättung `1 − exp(−dt·22)` und schwebt 0.05 über dem Papier. Der Autopilot pausiert, solange die letzte Bewegung weniger als 1,1 s her ist, und fliegt danach per `beginTravel()` zurück zu seinem Strich.
- **Gedrückte Maus:** Striche in INK mit 3,4 px direkt auf die rechte Textur zeichnen und in `P.user` speichern. Der Stift sitzt exakt auf dem Punkt (`lift 0`). `setPointerCapture` nutzen.
- Zusätzlich neigt die Mausposition relativ zur Canvas-Mitte das Buch: `book.rotation.x → mouse.y · 0.05`, `book.rotation.y → mouse.x · 0.08`, jeweils mit Faktor 0.05 pro Frame angenähert. Bei `pointerleave` zurück auf 0.
## 9. Scroll-Choreografie (das Herzstück)
Scroll-Fortschritt: `p = clamp01(−heroRect.top / (heroRect.height − innerHeight))`. Pro Frame geglättet: `S.p = lerp(S.p, p, 1 − exp(−dt·9))`. Alle Werte hängen an `sp = S.p`:
```js
const flat = ease(clamp01((sp - 0.03) / 0.42)); // Buch legt sich hin
const pick = clamp01((sp - 0.08) / 0.16); // Stift hebt vom Cover ab
S.band = clamp01((sp - 0.18) / 0.10); // Gummiband geht ab
S.open = clamp01((sp - 0.24) / 0.26); // Einband klappt auf
S.pose = pick;
holder.rotation.set(lerp(0.66, 0.16, flat), lerp(-0.36, 0, flat), lerp(-0.2, 0, flat));
holder.scale.setScalar(lerp(0.34, 1, ease(clamp01((sp - 0.02) / 0.48)))); // KLEIN starten, beim Öffnen wachsen
holder.position.y = lerp(0.06, 0, flat) + (1 - flat) * Math.sin(now * 1.3) * 0.035; // leichtes Schweben, solange es steht
ground.material.opacity = 0.17 * flat; // Schatten erst, wenn es liegt
hero.style.setProperty('--p', sp); hero.style.setProperty('--o', S.open); hero.classList.toggle('is-open', S.open > 0.95);
```
**Was das Auge sehen muss:**
1. **Beim Laden:** Die Headline „KREATIVER / BEGLEITER“ füllt die Breite in der Einbandfarbe. Davor schwebt mittig ein **kleines**, geschlossenes, schräg zur Kamera gekipptes Notizbuch (etwa ein Drittel seiner Endgröße). Der Fineliner liegt diagonal auf dem Cover, das Gummiband ist zu. Die Headline bleibt gut lesbar.
2. **Scrollen:** Das Buch kippt in die Waagerechte und wächst. Der Stift hebt in einem Bogen ab und parkt rechts neben dem Buch bei `SIDE` (Glättung `1 − exp(−dt·12)`).
3. **Gummiband:** `slide = ease(clamp01(band/0.6))`, `drop = ease(clamp01((band − 0.6)/0.4))`. Die Schlaufe rutscht über das Cover von `x = W − 0.14` nach `x = CWID + 0.03` über die Außenkante. Danach sinkt die Höhe von `hTop = CT·2 + BT + 0.004` auf `0.004`, und das Band liegt flach neben dem hinteren Deckel. `bandTop.y = hTop`. Die Enden bekommen `scale.y = max(0.001, hTop)` bei `y = hTop/2` und `z = side·(CD/2 + 0.006)`. **Das Band darf nie durch das Buch fahren.**
4. **Aufklappen** (`o = ease(open)`):
- `coverPivot.rotation.z = o·π`
- `coverPivot.position.y = lerp(CT + BT, CT, o)`
- `book.position.x = −CWID/2 · (1 − o)`, damit das geschlossene Buch zentriert ist und das offene um den Rücken
- Die linke Seitenhälfte schwingt mit dem Deckel mit.
5. **Voll offen** (`open === 1`): Phase wechselt zu `draw`, und der Stift fliegt zum ersten Strich (das Datum auf der Datumszeile). Das Dock mit Farbwahl und „Umblättern“ plus der Hinweis „zeichne selbst auf die rechte Seite“ blenden ein. Kicker und Byline sind ausgeblendet.
6. **Zurückscrollen:** Alles läuft rückwärts. Ein laufendes Umblättern wird sofort abgeschlossen. Die angefangene Zeichnung bleibt erhalten und wird beim erneuten Öffnen weitergezeichnet.
Stift-Pose: `penAxis.rotation = (lerp(−π/2, 0.3, e), 0, lerp(0, −0.6, e))`, `pen.rotation.y = lerp(0.62, 0, e)` mit `e = ease(pose)`. Liegend wird er so zu schräg haltend nach rechts. Außerhalb der Scroll-Phase: `pen.position` nähert sich `toLocal(penC, lift)` an, beim Zeichnen ohne Verzögerung, sonst mit `1 − exp(−dt·30)`.
Performance: Nur rendern, solange die Hero-Section sichtbar ist (`IntersectionObserver`). Pro Frame `dt` auf maximal 0,05 s begrenzen.
## 10. Die Abschnitte unter dem Hero
### 10.1 Manifest
`<section class="manifest wrap" aria-label="Warum Kladde">` mit `padding-block: clamp(5rem, 13vw, 11rem)`. Randnotizen: „S. 004“ (`top 12%; left 8%`), „Gleis 7, 8:14“ (`top 22%; right 10%; --r: 6deg`), „HB“ (`bottom 14%; left 16%; --r: 3deg`), „2B, weich“ (`bottom 9%; right 20%; --r: -7deg`).
Absatz `.reveal`: `400 clamp(2rem, 4.7vw, 4.5rem)/1.07 var(--serif)`, `letter-spacing: -.012em`, zentriert, `max-width: 21em`, `text-wrap: balance`. Text mit drei eingebetteten Doodles:
> Die meisten Ideen verschwinden irgendwo zwischen Bahnsteig **[Leuchtturm-Doodle]** und Bürotür. Sie brauchen kein neues Tool. Sie brauchen fünf Millimeter Raster, ein Papier, das Tusche aushält, **[Stift-Doodle]** und ein Buch, das in der Jackentasche wartet, bis du wieder etwas siehst, das du festhalten willst. **[Tassen-Doodle]**
Doodles als `<span class="dd" aria-hidden="true"><svg viewBox="0 0 60 40"><path d="…"/></svg></span>`:
- Leuchtturm: `M24 36 27 12h6l3 24ZM25.5 26h9M26.3 19h7.4M26 12V7h8v5M28 7q2-4 4 0M35 9l15-4M35 10.5l15 4.5M25 9 10 5M25 10.5 10 15M6 37q12-3 22 0t26 0`
- Stift: `M8 33 44 8l7 4-36 25ZM44 8l4-3 7 4-4 3M8 33l-3 5 6-2`
- Tasse: `M14 16h28l-3 16q-11 5-22 0ZM42 19q8 0 7 6-1 5-9 5M8 36q22 5 44 0M23 11q-3-4 0-7M31 11q-3-4 0-7`
```css
.dd { display: inline-flex; align-items: center; justify-content: center; vertical-align: -.1em; height: .9em; padding-inline: .14em;
margin-inline: .06em; background: var(--sheet); border-radius: 3px; box-shadow: 0 1px 0 var(--rule); }
.dd svg { height: .72em; width: auto; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.w { color: #8E8C85; transition: color .45s ease; } .w.on { color: var(--ink); }
```
**Lese-Effekt:** Per JS jedes Wort in `<span class="w">` wickeln, die Doodle-Spans bekommen ebenfalls `.w`. Beim Scrollen: `p = clamp01((vh·0.85 − rect.top) / (rect.height + vh·0.35))`, die ersten `round(p · anzahl · 1.08)` Elemente bekommen `.on`. Die Wörter färben sich also beim Lesen von Grau zu Schwarz.
### 10.2 Papier (`id="papier"`)
- Eyebrow zentriert: „Papier“. Notizen „fig. 2“ (`top 16%; left 5%`) und „säurefrei, elfenbein“ (`top 36%; right 4%; --r: 5deg`).
- Große Typo `h2.bigtype`: `400 clamp(3.2rem, 10.5vw, 10rem)/.95 var(--serif)`, `letter-spacing: -.022em`, zentriert, vier Zeilen als Block-Spans:
1. „Punkte, Karo“
2. „oder *nichts.*“ – „nichts.“ kursiv in `--accent`
3. „Jede Seite“ – **Outline** (`color: transparent; -webkit-text-stroke: 1.1px var(--ink)`)
4. „fängt leer an.“ – Outline
- Intro (`max-width: 46ch`, zentriert, `--ink-2`): „Wähle dein Raster. Die Seite im Buch oben und der Hintergrund dieser Seite wechseln mit. Alle drei Varianten nutzen dasselbe 150-g-Papier, das Fineliner, Marker und leichte Lavuren trägt, ohne durchzuschlagen.“
- **Drei Papiermuster als Buttons** (`data-raster`, `aria-pressed`) im 3-Spalten-Grid (`max-width 980px`, `gap clamp(1rem, 4vw, 3.5rem)`, auch mobil 3 Spalten). Jeder Button enthält ein Blatt `.sheet`:
- `aspect-ratio: 148/210`, `--sheet`, `border-radius: 2px 7px 7px 2px`
- Schatten `0 1px 0 var(--rule), 0 22px 34px -22px rgba(20,20,19,.45)`, Raster `14px`
- Hover: `translateY(-6px) rotate(-1deg)`; aktiv: Outline 2 px in `--accent` mit Offset 6 px
Darunter Titel in Serif 1.5rem plus Unterzeile in `.85rem --graphite`:
- „Punktraster“ / „5 mm, dezent grau“
- „Karo“ / „5 mm, feine Linie“
- „Blanko“ / „nur Papier“
### 10.3 Aufbau (`id="aufbau"`)
Grid `7fr / 5fr`, darunter 880 px einspaltig. `border-top: 1px solid var(--rule)`.
**Links:**
- Eyebrow „Aufbau“
- `h2` „Gebaut, um flach aufzuliegen.“ in `400 clamp(2.6rem, 5.4vw, 5rem)/.98 var(--serif)`
- Text: „Die Lagen sind mit Faden geheftet statt geklebt. Deshalb öffnet sich das Buch ganz, und eine Zeichnung darf über die Mitte laufen.“
- Darunter eine **technische Zeichnung** als Inline-SVG (`viewBox="0 0 560 600"`, `width: 100%`, `margin-top: 2.5rem`), exakt:
```html
<path class="ext" d="M90 66V38M386 66V38M86 70H48M86 490H48M440 66V38M474 66V38"/>
<path class="dim" d="M90 44h296M90 38v12M386 38v12M56 70v420M50 70h12M50 490h12M440 44h34M440 38v12M474 38v12"/>
<text x="238" y="34" text-anchor="middle">148 mm</text>
<text x="40" y="280" text-anchor="middle" transform="rotate(-90 40 280)">210 mm</text>
<text x="457" y="34" text-anchor="middle">17</text>
<rect class="s cover" x="90" y="70" width="296" height="420" rx="12"/>
<rect class="band" x="334" y="62" width="11" height="436" rx="2"/>
<text class="logo" x="238" y="462" text-anchor="middle">Kladde</text>
<path class="rib1" d="M168 490h13v78l-6.5-7-6.5 7Z"/>
<path class="s" d="M188 490h13v66l-6.5-7-6.5 7Z"/>
<rect class="s cover" x="440" y="70" width="34" height="420" rx="6"/>
<path class="pg" d="M446 78v404M451 78v404M456 78v404M461 78v404M466 78v404"/>
<path class="s" d="M457 110v10M457 170v10M457 230v10M457 290v10M457 350v10M457 410v10" stroke-width="2"/>
<path class="lead" d="M339 210 298 176M175 540 236 560M98 80l26 30M462 300h42"/>
<g class="co"><circle cx="288" cy="168" r="11"/><text x="288" y="168">1</text></g>
<g class="co"><circle cx="248" cy="562" r="11"/><text x="248" y="562">2</text></g>
<g class="co"><circle cx="132" cy="120" r="11"/><text x="132" y="120">3</text></g>
<g class="co"><circle cx="516" cy="300" r="11"/><text x="516" y="300">4</text></g>
<text class="hand" x="360" y="585">fig. 3, M 1:1,4</text>
```
CSS dazu: `.s` Strich `--ink` 1.4, `.cover` Füllung `--sheet`, `.pg` `--faint` 0.8, `.dim` `--graphite` 1, `.ext` `--faint` gestrichelt `3 3`, `.band` Füllung `--ink`, `.rib1` Füllung `--accent`, `.lead` `--ink` 0.9. Texte 13px Sans in `--ink-2` mit `tabular-nums`, `.logo` Serif 20px in `--faint`, `.hand` Handschrift 26px in `--graphite`. Die Kreise `.co` haben Füllung `--paper` und Strich `--ink` 1.2, die Ziffern 12px/600 zentriert.
- **Legende** (2 Spalten, unter 480 px eine), jeweils mit runder Nummer (1.45rem Kreis, 1.2px Rand):
1. „Gummiband, hält das Buch in der Tasche zu“
2. „Zwei Lesebändchen, für Anfang und aktuelle Seite“
3. „Hardcover mit runden Ecken, Leinenstruktur“
4. „Fadenheftung in 7 Lagen, öffnet 180°“
**Rechts** (sticky bei `top: 6rem`, mobil statisch): Handschrift-Überschrift „Datenblatt“ (1.8rem, `rotate(-2deg)`), dann ein `dl` mit Zeilen im Grid `7.5rem / 1fr`, Linien oben und unten in `--rule`. `dt` in Versalien (`600 .72rem`, `letter-spacing .14em`, `--graphite`):
| dt | dd |
|---|---|
| Format | A5, 148 × 210 mm, 17 mm stark |
| Papier | 150 g/m², säurefrei, elfenbein |
| Raster | *(live: aktuelles Raster, id `specRaster`)* |
| Umfang | 112 nummerierte Seiten, Inhaltsverzeichnis |
| Einband | *(live: „Hardcover, “ + Farbname, id `specCover`)* |
| Extras | Falttasche hinten, 8 Archiv-Etiketten |
| Gewicht | 310 g |
### 10.4 Bestellen (`id="bestellen"`)
Grid `1.1fr / .9fr`, `align-items: end`, Linie oben. Notiz „Lieferung in 2–3 Tagen“ (`top 3.5rem; right 4%; --r: 5deg`).
- **Links:** Eyebrow „Bestellen“, `h2` „Die erste Seite wartet schon.“, Text: „Deine Auswahl von oben ist übernommen. Einband und Raster kannst du hier noch nicht ändern, dafür oben auf der Seite.“
- **Rechts eine Karte** `.buy`:
- Aufbau: `--sheet`, Rand `--rule`, `border-radius: 3px 8px 8px 3px`, Schatten `0 30px 50px -34px rgba(20,20,19,.4)`
- Kopf: „Kladde No. 5“ (Serif 1.9rem) und „24,90 €“ (Serif 2.3rem)
- Liste mit gestrichelten Trennlinien: „Einband“ → farbiger Punkt (12 px, `--accent`) + Farbname (id `sumCover`), „Raster“ → Rastername (id `sumRaster`), „Format“ → „A5, 112 Seiten“
- Button `.btn.primary` in voller Breite: „Vorbestellen“
- Darunter ein `aria-live`-Absatz in Handschrift 1.6rem, `--accent`. Klick schreibt: „Vorgemerkt: {Farbname}, {Rastername}.“ Es gibt keinen echten Shop.
### 10.5 Footer
Linie oben, Flex mit Abstand. Links in Handschrift 1.7rem „bis zur nächsten Seite.“, rechts klein in `--graphite`: „Kladde No. 5 · Skizzenbuch A5 · Beispielseite, kein echter Shop“.
## 11. Zustandslogik der Bedienelemente
- **setRaster(r):** setzt `html[data-raster]` (tauscht das CSS-Raster der ganzen Seite) und `aria-pressed` auf **allen** `[data-raster]`-Buttons (Header und Papiermuster). Aktualisiert `specRaster` und `sumRaster`, setzt die Raster-Variable für die 3D-Seiten und rendert rechte und linke Seite neu. Die bisherige Zeichnung bleibt dabei erhalten.
- **setCover(id):** setzt `--accent` auf den Hex-Wert. Dadurch wechseln Headline, Buttons, Bestellpunkt und Akzente mit. Aktualisiert `aria-pressed` der Swatches, `coverName`, `specCover` und `sumCover`, malt die Cover-Textur neu und färbt Kanten und Stiftring um.
## 12. Barrierefreiheit und Randfälle
- `prefers-reduced-motion: reduce`:
- Manifest-Wörter sofort schwarz
- Keine CSS-Transitions
- Beim Öffnen steht die Skizze sofort fertig da
- Umblättern ohne Animation
- Kein automatisches Umblättern
- Ohne WebGL erscheint das Fallback-Feld mit dem Satz aus dem HTML.
- Alle Buttons haben `type="button"`, sichtbaren Fokus und `aria-pressed`.
- Canvas: `touch-action: pan-y`, damit man auf dem Handy über dem Buch weiterscrollen kann.
## 13. Abnahme-Checkliste
Prüfe am Ende jeden Punkt und korrigiere, bis alle stimmen:
1. Beim Laden ist „KREATIVER / BEGLEITER“ vollständig lesbar, das Buch davor ist klein und schwebt leicht.
2. Das Cover hat **kein** Karo- oder Linienmuster, nur feine Körnung und das geprägte „Kladde“.
3. Beim Scrollen wird das Buch größer, legt sich hin, das Gummiband rutscht über die Kante und fällt neben das Buch, dann klappt der Einband auf.
4. Die linken Seiten klappen **mit** dem Einband mit. Links liegt nie schon etwas, bevor der Deckel unten ist.
5. Erst wenn das Buch ganz offen ist, schreibt der Stift das Datum und zeichnet den Leuchtturm. Danach folgen Kaffeetasse und Berge, jeweils mit Umblättern.
6. Mit der Maus über der rechten Seite folgt der 3D-Stift dem Cursor, mit gedrückter Maus zeichnet man selbst.
7. Raster- und Farbwechsel greifen gleichzeitig auf Seite, 3D-Buch, Datenblatt und Bestellkarte.
8. Zurückscrollen schließt das Buch sauber, erneutes Öffnen setzt die Zeichnung fort.
9. Bei 390 px Breite gibt es keinen horizontalen Scroll, alles ist bedienbar.
Liefere mir am Ende die komplette `index.html` in einem Stück.5. Halbton Discs
Fernsehserien als 3D-CDs, die auf einer geschwungenen Bahn liegen und wie eine Slideshow durchgeblättert werden. Beim Hovern zeichnet sich ein handgemalter Kreis um die CD, CDs lassen sich greifen und werfen. Ein Klick auf die mittlere CD führt mit einer Animation zu einer Detailseite im Magazin-Stil.
Technik: Three.js r128, eine einzige HTML-Datei.
Wichtig: Leg vorher einen Ordner covers/ mit eigenen Serienbildern an, die
Dateinamen stehen in Abschnitt 2 des Prompts. Verwende nur Bilder, an denen du die Rechte
hast.
Die Idee stammt vom Projekt A24 von Ravi Klaassens. Schau dir das Original an, bevor du loslegst.
Prompt anzeigenPrompt ausblenden296 Zeilen
# Prompt: 3D-Serien-Discs mit Three.js
> Kopiere alles unterhalb der Linie in Claude (oder ein anderes Coding-Modell).
> Lege vorher einen Ordner `covers/` mit deinen eigenen Serienbildern an (Dateinamen siehe Abschnitt 2).
> Verwende nur Bilder, an denen du die Rechte hast.
---
Baue mir eine interaktive Website als **eine einzige Datei `index.html`** (HTML + CSS + JavaScript inline), ohne Build-Tool, ohne Framework und ohne GSAP. Die einzige externe Bibliothek ist **Three.js r128** von `https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js` (UMD-Build, globales `THREE`). Schriften kommen von Google Fonts. Bilder liegen relativ in `covers/`.
Die Seite zeigt Fernsehserien als **3D-CDs**, die auf einer geschwungenen Bahn liegen und wie eine Slideshow durchgeblättert werden. Beim Hovern zeichnet sich ein handgemalter Kritzel-Kreis um die CD, CDs lassen sich greifen und werfen. Ein Klick auf die mittlere CD startet eine Übergangsanimation zu einer editorialen Detailseite. Scrollt man auf der Detailseite nach unten, kommt man über eine sich drehende CD zur nächsten Serie.
Halte dich exakt an die folgenden Werte. Wo Zahlen stehen, übernimm sie genau so.
---
## 1. Gestaltung, Schriften, Grundlayout
**Markenname:** `halbton` (eigenes, fiktives Label – keine fremden Logos verwenden).
**Sprache der Oberfläche:** Deutsch. `<title>Halbton Discs</title>`.
**Farben (CSS-Variablen auf `:root`):**
- `--ground: #e9e9e6` (Seitenhintergrund, helles warmes Grau)
- `--ink: #131313` (Text)
- `--muted: #8a8b88` (sekundärer Text)
- `--rule: rgba(19,19,19,.28)` (Haarlinien)
- `color-scheme: light`. Es gibt bewusst nur ein helles Theme.
**Schriften (Google Fonts, ein `<link>`):**
`Anton`, `Bodoni Moda` (ital, opsz 6..96, wght 400 und 700, jeweils normal und kursiv), `DM Mono` (400, 500), `Figtree` (400, 500, 600, 800).
- `--display: "Bodoni Moda", "Didot", "Bodoni 72", Georgia, serif` – Titel, Zahlen, Zitate
- `--sans: "Figtree", "Helvetica Neue", Arial, sans-serif` – Fließtext, Navigation
- `--mono: "DM Mono", ui-monospace, "SF Mono", Menlo, monospace` – kleine Labels in Versalien mit Sperrung
**Body:** `height:100%`, `overflow:hidden`, Hintergrund `var(--ground)`, `-webkit-font-smoothing: antialiased`.
**Papier-Grain über der ganzen Seite:** `body::before`, `position:fixed; inset:0; pointer-events:none; opacity:.5`, Hintergrund ist ein SVG-Data-URI 220×220 mit
`<filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .09 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/>`.
**Ebenen:** `#stage` (fixed, inset 0, z-index 1) enthält das WebGL-Canvas (`touch-action:none`). Alle UI-Elemente liegen darüber (z-index 4–5), die Detailseite bei z-index 20.
**Navigation** (fixed, oben mittig, `top: calc(env(safe-area-inset-top) + 18px)`, `gap:34px`, 17px, Gewicht 500):
- Logo `halbton` in Bodoni Moda, 700, kursiv, 20px, `letter-spacing:-.02em`
- Link „Serien“ (aktiv: 4px schwarzer Punkt 10px unter dem Wort, mittig)
- Link „Filme“ in `--muted`
- Button „Index“ mit kleinem Chevron (8×8px, Rahmen rechts/unten 1.5px, 45° gedreht; bei offen −135°). Öffnet ein Dropdown (`position:absolute; top:34px; right:-12px; min-width:260px`, Hintergrund `rgba(245,245,242,.92)` mit `backdrop-filter: blur(12px)`, 1px Rahmen `--rule`). Jede Zeile: Serientitel links, Katalognummer rechts in Mono 11px. Klick springt zur Serie (kürzester Weg im Kreis). Escape schließt.
**Info-Panel oben links** (fixed, `left:16px`, `top: calc(env(safe-area-inset-top)+14px)`, Breite `min(430px, 100% - 32px)`, `pointer-events:none`, `aria-live="polite"`):
- `h1` Serientitel: Bodoni Moda 400, `clamp(30px, 3.6vw, 52px)`, `line-height:.98`, `letter-spacing:-.035em`, Versalien, `text-wrap: balance`, 14px Abstand nach unten.
- Danach eine `dl` mit Zeilen (`grid-template-columns: 120px 1fr`, oben 1px Linie `--rule`, Padding 9px 0 8px). `dt` in Mono 10.5px, Versalien, `letter-spacing:.16em`. `dd` rechtsbündig, 17px, `line-height:1.55`.
- Zeilen: **Idee von**, **Laufzeit**, **Sender**, **Besetzung** (5 Namen untereinander).
- Beim Wechsel der aktiven Serie spielen Panel und Kennzahlen eine Einblend-Animation: 0.55s, `cubic-bezier(.2,.7,.2,1)`, von `opacity:0; translateY(8px); blur(3px)` zu normal.
**Kennzahlen unten mittig** (fixed, `bottom: calc(env(safe-area-inset-bottom) + 44px)`, Breite `min(820px, 100% - 32px)`, zwei Spalten, `gap:40px`, zentriert):
- Je Spalte ein Label in Mono 10.5px, Versalien, `letter-spacing:.2em` („Staffeln“, „Episoden“) und darunter die Zahl in Bodoni Moda, `clamp(44px, 5vw, 76px)`, `letter-spacing:-.04em`, `line-height:.9`.
**Steuerung unten rechts** (fixed, 16px vom Rand): Button „←“, Zähler `01 / 08` (Mono 12px, tabular-nums), Button „→“. Buttons 34×34px rund, 1px Rahmen `--rule`, beim Hover schwarz gefüllt mit hellem Pfeil.
**Hinweis unten links** (ab 1100px Breite sichtbar): `Scrollen · Ziehen · CD greifen & werfen`, Mono 10.5px, Versalien, `--muted`.
**Griff unten mittig:** 44×4px, Radius 4px, `rgba(0,0,0,.35)`, 8px über dem unteren Rand.
**Mobil (≤720px):** Navigation rechtsbündig (right 16px, gap 18px, 14px), „Filme“ ausblenden; Info-Panel beginnt 52px unter dem oberen Rand, Zeilen `90px 1fr`, `dd` 14px, Besetzungs-Zeile ausblenden; Kennzahlen einspaltig, nur die erste Kennzahl zeigen, 64px vom unteren Rand; Steuerung horizontal zentriert.
---
## 2. Daten: 8 Serien
Lege ein Array `ALBUMS` mit genau diesen 8 Einträgen in dieser Reihenfolge an. Felder:
`title, artist (= Idee von), year (= Laufzeit), genre (= Sender), cat, tracks (= Besetzung, 5 Namen), seasons, episodes, synopsis, img (optional), focus [x, y] (Pixel im Originalbild, Mittelpunkt des quadratischen Ausschnitts), style (prozedurales Ersatz-Cover), pal (Farbpalette), rim (Farbe der Randschrift)`.
1. **Breaking Bad** – Vince Gilligan – 2008–2013 – AMC – HT-S01 – Bryan Cranston, Aaron Paul, Anna Gunn, Dean Norris, Bob Odenkirk – 5 Staffeln, 62 Episoden – `img: covers/breaking-bad.webp`, `focus: [700, 360]` (für ein 1280×720-Bild), `style: desert`, `pal: ['#c9c46a','#e7b54a','#5b4a2a','#7fd6ff']`, `rim: rgba(255,255,255,.75)`
2. **Better Call Saul** – Vince Gilligan & Peter Gould – 2015–2022 – AMC – HT-S02 – Bob Odenkirk, Rhea Seehorn, Jonathan Banks, Giancarlo Esposito, Michael Mando – 6 / 63 – `img: covers/better-call-saul.webp`, `focus: [1000, 540]` (2000×1125), `style: type`, `pal: ['#f2c14e','#1b1b1b','#d9482f']`, `words: ['OBJECTION ','SUSTAINED ']`, `badge: '§'`, `rim: rgba(255,255,255,.75)`
3. **The Walking Dead** – Frank Darabont – 2010–2022 – AMC – HT-S03 – Andrew Lincoln, Norman Reedus, Melissa McBride, Lauren Cohan, Danai Gurira – 11 / 177 – `img: covers/the-walking-dead.png`, `focus: [440, 205]` (728×410), `style: trees`, `pal: ['#8d9a86','#2a3129','#141814','#b8412c']`, `rim: rgba(238,234,224,.7)`
4. **Game of Thrones** – David Benioff & D. B. Weiss – 2011–2019 – HBO – HT-S04 – Emilia Clarke, Kit Harington, Peter Dinklage, Lena Headey, Sophie Turner – 8 / 73 – `img: covers/game-of-thrones.webp`, `focus: [640, 404]` (1440×809), `style: split`, `pal: ['#0e1116','#9cc4dc','#e2672a','#f3d9a4']`, `rim: rgba(241,237,228,.7)`
5. **Dark** – Baran bo Odar & Jantje Friese – 2017–2020 – Netflix – HT-S05 – Louis Hofmann, Lisa Vicari, Oliver Masucci, Jördis Triebel, Maja Schöne – 3 / 26 – kein Bild, `style: rings`, `pal: ['#0b0b0c','#c9b27c','#3b3a36']`, `rim: rgba(201,178,124,.8)`
6. **Stranger Things** – The Duffer Brothers – 2016–2025 – Netflix – HT-S06 – Winona Ryder, David Harbour, Millie Bobby Brown, Finn Wolfhard, Gaten Matarazzo – 5 / 42 – `img: covers/stranger-things.webp`, `focus: [540, 780]` (1080×1920, hochkant), `style: grid`, `pal: ['#07060d','#c2222f','#f0c9a0','#3fb8c9']`, `rim: rgba(255,255,255,.7)`
7. **The Sopranos** – David Chase – 1999–2007 – HBO – HT-S07 – James Gandolfini, Edie Falco, Lorraine Bracco, Michael Imperioli, Steven Van Zandt – 6 / 86 – kein Bild, `style: halftone`, `pal: ['#121212','#d8d2c4','#b3201e']`, `rim: rgba(241,235,221,.7)`
8. **Mad Men** – Matthew Weiner – 2007–2015 – AMC – HT-S08 – Jon Hamm, Elisabeth Moss, January Jones, Christina Hendricks, John Slattery – 7 / 92 – kein Bild, `style: stripes`, `pal: ['#14120f','#3d352b','#c9a86a','#efe6d4']`, `rim: rgba(239,230,212,.7)`
**Inhaltsangaben (`synopsis`)**, genau so übernehmen:
1. Ein Chemielehrer in Albuquerque erfährt, dass er Krebs hat – und beginnt, gemeinsam mit einem ehemaligen Schüler Methamphetamin herzustellen, um seine Familie abzusichern. Aus dem biederen Walter White wird Schritt für Schritt jemand, den er selbst nicht wiedererkennt.
2. Die Vorgeschichte von Saul Goodman: Wie der kleine Pflichtverteidiger Jimmy McGill, stets im Schatten seines Bruders, zum Anwalt der Unterwelt von Albuquerque wird. Langsam erzählt, präzise gebaut und mit einer der besten Nebenfiguren des Fernsehens.
3. Sheriff Rick Grimes erwacht aus dem Koma in einer Welt, die von Untoten überrannt wurde. Er sucht seine Familie und führt eine Gruppe Überlebender durch den Zusammenbruch jeder Ordnung – wobei die Lebenden oft gefährlicher sind als die Toten.
4. Adelshäuser ringen um den Eisernen Thron von Westeros, während jenseits der Mauer im Norden eine uralte Bedrohung erwacht. Nach den Romanen von George R. R. Martin – Intrigen, Kriege und Drachen im großen Maßstab.
5. In der Kleinstadt Winden verschwinden Kinder. Die Suche legt ein Geflecht aus Zeitreisen frei, das vier Familien über mehrere Generationen miteinander verbindet. Die erste deutsche Netflix-Serie.
6. In Hawkins, Indiana, verschwindet ein Junge spurlos. Seine Freunde, seine Mutter, der Polizeichef und ein Mädchen mit übernatürlichen Kräften stoßen auf geheime Experimente und eine düstere Parallelwelt.
7. Der Mafiaboss Tony Soprano leidet unter Panikattacken und landet bei einer Psychiaterin. Zwischen Familie, Geschäft und Therapie entsteht das Porträt eines Mannes, der beides zugleich sein will: Vater und Gangster.
8. New York in den 1960er-Jahren: Der Werber Don Draper ist der Star seiner Agentur an der Madison Avenue – und ein Mann mit falscher Identität. Eine Chronik von Karriere, Rollenbildern und gesellschaftlichem Umbruch.
Wenn ein Bild fehlt oder nicht lädt, wird automatisch das prozedurale Cover (`style`) verwendet. Wenn deine Bilder andere Maße haben, passe `focus` so an, dass das Mittelloch der CD nicht mitten durch ein Gesicht geht.
---
## 3. Cover-Texturen (Canvas 1024×1024)
Für jede Serie wird ein Canvas `S = 1024` (Mitte `C = 512`) gezeichnet und als `THREE.CanvasTexture` verwendet (`encoding = sRGBEncoding`, maximale Anisotropie).
Reihenfolge in `paintCover(canvas, serie, index, bild|null)`:
1. `clearRect`, dann auf einen Kreis mit Radius 512 clippen.
2. **Mit Foto:** quadratischer Ausschnitt mit Seitenlänge = kürzere Bildseite, zentriert auf `focus`, an die Bildränder geklemmt, auf 1024×1024 gezeichnet mit `ctx.filter = 'saturate(1.2) contrast(1.05)'` (danach wieder `'none'`). Dann ein linearer Verlauf von y=520 (transparent) bis y=1024 (`rgba(0,0,0,.45)`) und eine radiale Vignette von r=300 (transparent) bis r=512 (`rgba(0,0,0,.16)`).
**Ohne Foto:** das prozedurale Motiv (siehe unten) mit einem Seed-Zufallsgenerator (mulberry32, Seed `index * 977 + 13`).
3. **Randschrift im Kreis:** Text `CREATED BY {artist} · {year} · {genre} · {seasons} STAFFELN` (Versalien), Schrift `500 19px "DM Mono"`, Farbe `rim`, Radius 474px, Buchstaben einzeln entlang des Kreises gesetzt (Winkelschritt = (Zeichenbreite + 5px) / 474), Start bei −1.02·π, im Uhrzeigersinn. Die Schrift läuft etwa über ein Drittel des Umfangs oben links.
4. **Grain:** Ein 256×256-Rausch-Canvas (jedes Pixel zufällig schwarz oder weiß, Alpha 8–24 von 255) als wiederholtes Muster über die ganze Fläche. Dann dasselbe Muster noch einmal mit `globalCompositeOperation = 'overlay'` und `globalAlpha = .15`.
5. **Klarer Außenrand:** Kreis mit Radius 507, Linienbreite 10, Farbe `rgba(210,214,220,.9)`.
**Wichtig: Auf den CDs stehen keine Serientitel und kein Label-Schriftzug.** Nur Bild, Randschrift, Grain und Außenrand.
Bilder werden asynchron geladen: zuerst das prozedurale Cover zeichnen, nach `img.onload` neu zeichnen und `texture.needsUpdate = true`. Merke dir pro Serie `hero` (Bildpfad oder `canvas.toDataURL('image/jpeg', .85)` als Fallback) und `discImg` (immer das fertige runde Cover als JPEG-DataURL, nach dem Foto-Laden aktualisiert).
**Prozedurale Motive (Ersatz-Cover, alle auf 1024×1024):**
- `desert`: Himmel-Verlauf pal[0]→pal[1] bis y=620; weiche helle Sonne (radial, Mitte 700/300, r=260); Tafelberge als Polygon in `#8a6a3c` mit Horizont bei y=600; Boden-Verlauf `#b88c4c`→pal[2]; perspektivische Straße `#3c3326` mit hellen Mittelstreifen `#e8d9a0`; 26 kleine hellblaue Kristallsplitter (pal[3], Alpha .45–.95) im Himmel.
- `type`: Hintergrund pal[0]; um −0.21 rad gedreht Zeilen aus den Wörtern `words` (abwechselnd), `800 128px Figtree`, Zeilenabstand 118px, jede 3. Zeile in pal[2], ungerade Zeilen gefüllt pal[1], sonst nur Kontur pal[1] (3px); dazu ein Kreis pal[2] bei (760, 300), r=92, darin `badge` in 80px Figtree 800.
- `trees`: vertikaler Verlauf pal[0]→pal[1] (60%)→pal[2]; drei Ebenen dunkler Baumstämme (Alpha .35/.63/.91) mit schrägen Ästen und dazwischen horizontale Nebelbänder (`rgba(200,210,195,.28)`); eine dünne Linie in pal[3] bei y=610.
- `split`: diagonal geteilt: oben links Eis (pal[1]), unten rechts Glut (pal[2]); 260 Partikel (Schnee oben links, orange Funken unten rechts); diagonale Linie in pal[3] mit Alpha .6.
- `rings`: Hintergrund pal[0]; 44 wellige, konzentrische Ringe um einen Punkt links oberhalb der Mitte, jeder 5. Ring dicker in pal[1], sonst pal[2].
- `grid`: 80er-Retro: dunkler Himmel mit Sternen, halbe Sonne mit Streifen, Glühen am Horizont (y=560), darunter ein perspektivisches Neon-Gitter in pal[3] mit Glow.
- `halftone`: Hintergrund pal[0]; Rasterpunkte (Abstand 20px) in pal[1], deren Größe spiralförmig um (360, 380) moduliert ist; roter Balken pal[2] bei y=700.
- `stripes`: Hintergrund pal[0]; um −π/5 gedrehte, halbtransparente Streifen in pal[1]/pal[2]; darüber 6 ineinander verschachtelte Rechteck-Konturen in pal[3] (Alpha .5) wie Glasfassaden.
---
## 4. Three.js-Szene
- `WebGLRenderer({ antialias: true, alpha: true })`, `pixelRatio = min(devicePixelRatio, 2)`, `outputEncoding = sRGBEncoding`, **`toneMapping = NoToneMapping`** (sonst werden die Farben ausgewaschen), `autoClear = false`.
- `PerspectiveCamera(34, aspect, 0.1, 200)`.
- **Umgebung für Spiegelungen:** eine kleine Hilfsszene mit einer innen gerenderten Box 30×30×30 (`#6b6d70`, BackSide) und vier leuchtenden Flächen (MeshBasicMaterial, Farbwerte > 1): 12×3 bei (0, 9, 8) Stärke 4; 3×10 bei (−10, 1, 5) Stärke 2.5; 6×6 bei (9, −3, 9) Stärke 1.6; 10×2 bei (0, −9, 4) Stärke 1.2 – alle auf den Ursprung ausgerichtet. Mit `PMREMGenerator.fromScene(envScene, 0.035)` zu `scene.environment`.
- Licht: `AmbientLight(0xffffff, .72)`, `DirectionalLight(0xffffff, .5)` bei (−4, 6, 10).
**Aufbau einer CD** (Radius `R = 2`, Loch `HOLE = 0.3`, Dicke `T = 0.05`). Hierarchie: `pivot` (Position/Neigung) → `spinner` (dreht um z) → Meshes.
- Oberseite: `RingGeometry(HOLE, R, 180, 1)` bei z = T/2 mit `MeshStandardMaterial({ map: coverTexture, roughness: .82, metalness: 0, envMapIntensity: .22, roughnessMap: noiseTex, bumpMap: noiseTex, bumpScale: .0015 })`. `noiseTex`: 512×512-Canvas mit Grauwerten 170–220 pro Pixel, `RepeatWrapping`, `repeat (3, 3)`. → matte, bedruckte Oberfläche, kaum Glanz.
- Unterseite: dieselbe Ring-Geometrie, um y 180° gedreht, bei z = −T/2, Silber: `MeshStandardMaterial({ color: 0xcdd1d6, metalness: 1, roughness: .22 })`.
- Außenkante: `CylinderGeometry(R, R, T, 180, 1, true)` um x 90° gedreht, Silber.
- Lochkante: `CylinderGeometry(HOLE, HOLE, T + .02, 64, 1, true)`, gedreht, Silber.
- Transparente Nabe: `RingGeometry(HOLE, .74, 120)` bei z = T/2 + .004, `color 0xe9ecef, metalness .2, roughness .12, transparent, opacity .55, depthWrite false`.
- Innere Lippe: `RingGeometry(HOLE, HOLE + .09, 96)` bei z = T/2 + .006, `color 0xf2f4f6, metalness .3, roughness .2, opacity .85`.
- Stapelring: `TorusGeometry(.6, .026, 12, 120)` bei z = T/2 + .008, `color 0xdfe3e7, metalness .5, roughness .15, opacity .8`.
- Schatten: eigene Ebene 6.4×6.4 in der Szene (nicht im Pivot) mit radialem Verlauf (Mitte `rgba(0,0,0,.5)`, 55% `rgba(0,0,0,.18)`, außen transparent), `opacity .35`, `depthWrite false`. Position pro Frame: Pivot + (0.35, −0.55, −1.1).
Oberseite, Unterseite und Außenkante sind Treffer-Meshes für den Raycaster (`userData.d = disc`).
Schriften müssen geladen sein, bevor die Texturen gezeichnet werden: `document.fonts.load(...)` für alle verwendeten Schnitte, mit 2.5s Timeout, dann Discs bauen, `resize()`, Loop starten.
---
## 5. Bahn und Größenverlauf (Karussell)
Zustand: `current` (Float) läuft weich zu `target` (Integer beim Einrasten): `current = damp(current, target, 6.5, dt)` mit `damp(a, b, λ, dt) = a + (b − a) · (1 − e^(−λ·dt))`. Die Liste ist **endlos**: Offset jeder CD `o = wrap(i − current)` mit `wrap(o) = ((o % N) + N·1.5) % N − N/2`. Aktive Serie = `round(current)` modulo N.
**Kamera in `resize()`:** `portrait = aspect < 0.85`. Kamera bei (0, 0, max(15, needW / (2·tan(fov/2)·aspect))) mit `needW = portrait ? 5.4 : 11`, Blick auf (0, portrait ? −0.2 : −0.35, 0). `layout.spx = portrait ? 2.2 : 4.6`, `layout.spy = portrait ? 3.4 : 1.0`, `layout.curve = .42`.
**Querformat (Desktop)** mit `op = max(0, o)`, `on = min(0, o)`:
- `x = spx · (op + op²·0.12 + on·0.62)` → links eng und überlappend, rechts weiter auseinander
- `y = op·spy + op²·curve + on·0.55` → links flach abfallend, rechts immer steiler nach oben (Kurve)
- `z = o·1.4 − max(0, −o − 1)·0.6` → rechte CDs näher an der Kamera, linke weiter weg
- `scale = 1.25 · 1.12^clamp(o, −3, 3)` → rechts groß, links klein
- `slope = atan(o > 0 ? spy + 2·o·curve : .55) · .18` (leichte Drehung entlang der Bahn)
**Hochformat:** `x = o·spx + o²·.28`, `y = o·spy`, `z = −|o|·1.3`, `scale = 1.05 · 1.12^clamp(o,−3,3)`, `slope = 0`.
Sichtbar nur, wenn `|o| < 3.4`.
**Grundneigung:** `rotation = ( −0.58 + near·0.2 + tilt.x, −0.22 + o·0.05 + tilt.y, 0.03·o + slope )` mit `near = max(0, 1 − |o|)`.
**Eigendrehung:** `angle += (0.06 + spinVel) · dt`, `spinVel *= e^(−1.4·dt)`, `spinner.rotation.z = angle`. Startwinkel pro CD: `(i·1.3) mod 2π − 0.6`.
---
## 6. Interaktion
**Hover** (Raycast auf die Treffer-Meshes; bei mehreren Treffern gewinnt die CD, die in der Zeichenreihenfolge oben liegt, siehe Abschnitt 8):
- Cursor `grab`.
- Normierte Zeigerposition relativ zur CD-Mitte auf dem Bildschirm: `nx, ny ∈ [−1, 1]` (geteilt durch den Bildschirmradius der CD, mindestens 40px).
- Neigung zum Zeiger: `tilt.x → −ny·0.38`, `tilt.y → nx·0.42` (gedämpft mit λ=7).
- Magnet-Versatz: `offTarget = (nx·0.22, ny·0.22, 0.45)` – die CD kommt nach vorn und leicht zum Zeiger.
- Skalierung zusätzlich `× (1 + hover·0.035)`, `hover` gedämpft mit λ=8.
- Schattenopazität `(.3 − hover·.08)`.
- Versatz folgt einer Feder: `vel += (offTarget − off)·120·dt; vel *= e^(−14·dt); off += vel·dt`.
**Greifen & Werfen:** `pointerdown` auf einer CD → Drag. Der Zeiger wird auf eine Ebene (Normale z) durch die CD projiziert, `offTarget = Treffer − Greifpunkt`, `offTarget.z = .8`, Länge maximal 4.2. Horizontale Mausbewegung dreht die CD: `spinVel = clamp(spinVel − dx·0.04, −14, 14)`. Beim Loslassen springt `offTarget` auf 0 → federt zurück. Die gezogene CD liegt immer ganz oben. Cursor `grabbing`.
**Klick** (Bewegung < 6px): auf eine seitliche CD → `target += round(o)` (sie kommt in die Mitte). Auf die **mittlere** CD → **Detailseite öffnen** (Abschnitt 9).
**Wischen auf freier Fläche:** `target = startTarget − dx / (W · 0.32)` (Hochformat: `W · 0.55` plus vertikaler Anteil `dy / (H · 0.4)`), beim Loslassen runden.
**Mausrad/Trackpad:** `preventDefault`, dominante Achse, `target += clamp(delta, −60, 60) · 0.0045`, 160ms nach dem letzten Ereignis auf ganze Zahl runden.
**Tastatur:** ←/↑ vorherige, →/↓ nächste Serie, Escape schließt Dropdown bzw. Detailseite.
Während die Detailseite offen ist, reagiert das Karussell auf nichts.
---
## 7. Kritzel-Kreis (Hand-drawn Scribble)
Wenn eine CD gehovert oder gezogen wird, zeichnet sich ein handgemalter Kreis um sie.
- Fortschritt `p`: beim Hover `+1.35·dt` bis 1, beim Verlassen `−2.6·dt` bis 0 (löscht sich rückwärts). Neue CD → neuer Zufalls-Seed `sd ∈ [0,10)` und Startwinkel `a0 = −0.5 + rand·0.6`.
- 2.15 Umläufe, 260 Stützpunkte, gezeichnet bis `floor(260 · easeInOut(p))` mit `easeInOut(t) = t < .5 ? 2t² : 1 − (−2t + 2)²/2`.
- Für Punkt j: `s = j/260 · 2π·2.15`, Winkel `an = a0 − s`,
`k = 1.07 + .03·sin(1.6s + sd) + .016·sin(4.2s + 2.3sd) + (s/total)·.045 + .004·sin(1.9j + boil·12.99)` mit `boil = floor(time·8)` (die Linie „zittert“ 8× pro Sekunde wie handgezeichnet).
Lokaler Punkt: `(cos(an)·R·k·(1 + .018·sin(.7s + sd)), sin(an)·R·k, T)`, mit der Weltmatrix des **Pivots** (nicht des Spinners – der Kreis dreht sich nicht mit) transformieren und auf den Bildschirm projizieren. So folgt der Kreis jeder Neigung und Bewegung der CD.
- Der Kreis ist **kein 2D-Overlay**, sondern ein Band-Mesh (Triangle-Strip aus zwei Vertices pro Punkt, Breite 2.3px, an beiden Enden über 6 Punkte auf 50% verjüngt) in einer eigenen Szene mit `OrthographicCamera(0, W, 0, −H, −1, 1)` (y negativ), `MeshBasicMaterial({ color: 0x111111, side: DoubleSide, depthTest: false })`, `frustumCulled = false`, Positionen per `DynamicDrawUsage` jeden Frame aktualisiert.
---
## 8. Geschichtetes Rendern (keine Überschneidungen)
Gekippte CDs würden sich im 3D-Raum durchdringen. Deshalb jeden Frame so rendern:
1. `renderer.clear()`. Alle Pivots unsichtbar, nur sichtbare Schatten → einmal rendern.
2. Sichtbare CDs nach `pivot.position.z` aufsteigend sortieren (die gezogene CD bekommt +100 und kommt zuletzt). Rang merken (`d.rank`).
3. Für jede CD in dieser Reihenfolge: nur diesen Pivot sichtbar machen, `renderer.clearDepth()`, rendern. **Direkt danach**, falls diese CD den Kritzel-Kreis trägt, die Kritzel-Szene rendern.
4. Danach alle wieder sichtbar schalten (für den Raycaster).
Ergebnis: Vordere CDs decken hintere komplett ab wie Papierscheiben, und der Kritzel-Kreis verschwindet hinter CDs, die weiter vorn liegen.
---
## 9. Übergang zur Detailseite
Klick auf die mittlere CD startet eine Sequenz mit Fortschritt `seq.p` (0 → 1 in 1.05s; zurück genauso) und `easeIO(t) = t < .5 ? 4t³ : 1 − (−2t + 2)³/2`:
- **Seitliche CDs** gleiten nach außen weg: `side = easeIO(clamp(p/.7))`, Richtung `dirX = o ≥ 0 ? +1 : −1`, x-Versatz `side · (15 + |o|·3) · dirX`, y-Versatz `side · dirX · 1.2`, zusätzliche y-Drehung `−side · dirX · .5`, Schatten blendet mit `(1 − side)` aus.
- **Die angeklickte CD** dreht sich klein bis zum Verschwinden: `shrink = easeIO(clamp((p − .22)/.78))`, Skalierung `× (1 − shrink)`, z `+ shrink·1.2`, Neigung x per `lerp` zu −0.15 und y zu 0 (sie dreht sich zur Kamera), zusätzliche Rotation `spinner.rotation.z = angle − shrink²·14` (immer schneller werdender Spin). Schatten schrumpft mit.
- Nach **1000ms** erscheint die Detailseite (`opacity 0 → 1` in .45s), Fokus auf den Schließen-Button.
- **Schließen** (Button oder Escape): Detailseite blendet aus, nach 420ms läuft `seq.p` zurück auf 0 → die CD dreht sich aus dem Nichts groß, die seitlichen CDs gleiten zurück. Erst bei `p = 0` ist das Karussell wieder bedienbar. Die Übersicht steht dann auf der zuletzt angesehenen Serie.
---
## 10. Detailseite (editorialer Aufbau)
`section.sheet` mit `role="dialog"`, `aria-modal`, im geschlossenen Zustand `inert` und `aria-hidden`. `position:fixed; inset:0; overflow-y:auto; overflow-x:hidden; overscroll-behavior:contain`, Hintergrund `--ground` plus derselbe Papier-Grain, seitliches Padding 16px.
Von oben nach unten:
- **Schließen-Button** fixed oben rechts (16px): „← Übersicht“, Figtree 17px/500, ohne Rahmen, Hover unterstrichen.
- Zwei dekorative „+“ am linken Rand (30px, bei 54vh und 98vh, mobil ausgeblendet).
- **Kopf:** 90px Abstand oben, Titel zentriert in Bodoni Moda 400, `clamp(56px, 10.5vw, 190px)`, `line-height:.84`, `letter-spacing:-.045em`, Versalien, `max-width: 11ch`, `text-wrap: balance`. Darunter eine 1px schwarze Linie über die **volle Breite**.
- **Mitwirkenden-Zeile** zentriert, 18px: `IDEE VON` {artist} `SENDER` {genre}, Zeilenumbruch, `BESETZUNG` {erste 4 Namen, kommagetrennt}. Die Labels in Mono 10px, Versalien, `letter-spacing:.2em`.
- **Faktentabelle** über die volle Breite (1px schwarze Linie oben, jede Zeile unten 1px `--rule`, Padding 8px 16px 7px): Label links (Mono 10.5px, Versalien), Wert rechts (17px, tabular-nums), nichts abschneiden. Zeilen: Laufzeit, Sender, Staffeln, Episoden.
- **Inhaltsangabe** zentriert, `max-width: 44ch`, 110px Abstand oben, Bodoni Moda `clamp(21px, 2.1vw, 30px)`, `line-height:1.28`.
- **Bild mit flüssiger Kante** (Abschnitt 11): volle Breite, 110px Abstand oben, Höhe `min(92vh, 900px)` (mobil 70vh), Hintergrund = `hero`, `background-size: cover`, `background-position: center 30%`.
- **Übergang zur nächsten Serie** (Abschnitt 12).
**Einblendung:** Titel, Mitwirkende, Tabelle und Inhaltsangabe haben die Klasse `.rise` mit Index `--i` 0–3: `opacity 0; translateY(34px)` → normal, 0.9s `cubic-bezier(.16,.84,.24,1)`, Verzögerung `--i · 90ms`. Das Bild kommt mit `translateY(120px)` → 0 in 1.2s und Opacity in 0.8s, beide 0.35s verzögert. Die Animation wird bei jedem Füllen der Seite neu gestartet (Klasse entfernen, Reflow erzwingen, Klasse setzen).
---
## 11. Flüssige, kleinteilige Bildkante
Die Oberkante des Bildes ist ein `clip-path: polygon(...)` mit 181 Punkten, **jeden Frame neu berechnet**:
```
g(t, c, w) = exp(−((t − c)/w)²)
c1 = .52 + .06·sin(.21·time + seed)
c2 = .26 + .05·sin(.17·time + 2·seed)
c3 = .78 + .05·sin(.19·time + 3·seed)
h = 18·g(t,c1,.09) + 12·g(t,c1−.06,.16) + 9·g(t,c2,.1) + 8·g(t,c3,.09) + 14·g(t,.5,.45)
h += round(2·sin(7t + seed)) · .9 // weiche Stufen / Plateaus
h += 1.6·sin(17t + .9time + seed) + .8·sin(31t − 1.3time + 2seed)
+ .45·sin(53t + 1.7time + 3seed) + .22·sin(89t − 2.1time) // feines Fließen
y = 58 − h + (|t − .5|·2)⁴·8 + (1 − reveal)·40 // in %, geklemmt auf 1…100
```
Punkte `(t·100% , y%)`, danach `100% 100%, 0% 100%`. `seed = Index · 1.7 + .4`.
`reveal`: wie weit das Bild ins Blickfeld gescrollt ist: `clamp((vh − bildOberkante) / (vh · .85), 0, 1)`, dann `1 − (1 − reveal)³`. Die Kante liegt beim Hereinscrollen tief und steigt beim Weiterscrollen hoch. Ergebnis: ein Gebirgsgrat mit Hauptgipfel, Schultern und Plateaus, der sich ständig langsam wie eine zähe Flüssigkeit bewegt.
---
## 12. Scroll-Übergang zur nächsten Serie
Unter dem Bild ein Abschnitt `.d-next` mit Höhe **230vh**, darin ein `position: sticky; top: 0; height: 100dvh; overflow: hidden`-Container:
- Vier 1px-Linien über die volle Breite (`rgba(19,19,19,.55)`) bei 2%, 29.5%, 57% und 84.5% der Höhe.
- Klein bei 8%: „Nächste Serie“ (Mono 10.5px, Versalien, `--muted`).
- Groß bei 15.75% (vertikal zentriert zwischen Linie 1 und 2): Titel der nächsten Serie, Bodoni Moda `clamp(44px, 6.4vw, 110px)`, Versalien.
- **Aufrechte CD**: `div` mit `width: min(82vw, 1000px)` (mobil 110vw), quadratisch, rund, Hintergrund = `discImg` der nächsten Serie, Mittelloch per `mask: radial-gradient(circle, transparent 14.5%, #000 15%)`, Silberrand `box-shadow: inset 0 0 0 6px rgba(215,219,224,.95), inset 0 0 0 9px rgba(160,165,172,.5)`, Nabe als `::after` (inset 31%, radialer Verlauf aus halbtransparentem Hellgrau). Wrapper mit `perspective: 1400px` und `filter: drop-shadow(0 30px 40px rgba(0,0,0,.2))`, horizontal zentriert.
- Scroll-Fortschritt `prog = clamp((scrollTop − abschnittOben) / (abschnittHöhe − vh))`, geglättet `ep = prog²(3 − 2prog)`.
- Position: `translate(−50%, vh − discHöhe · .36 · (.15 + .85·ep) px)` → die CD steigt von unten, bis **nur ihr oberes Drittel** sichtbar ist.
- Neigung fast senkrecht: `rotateX(14 − ep·6 deg)`, Drehung `rotateZ(angle)` mit `angle += dt · (60 + prog²·2400)` Grad pro Sekunde → sie dreht sich immer schneller.
- Titel: `translateY((1 − ep)·12px)`.
- **Kritzel-Kreis um die drehende CD:** eigenes Canvas über dem Container. Ab `prog > .62` zeichnet er sich mit `p = clamp((prog − .62)/.3)`, gleicher Algorithmus wie Abschnitt 7 (2.15 Umläufe, 300 Punkte, Zittern, `k = 1.06 + .025·sin(1.6s+sd) + .014·sin(4.2s+2.3sd) + (s/total)·.04 + .003·sin(1.9j + boil·12.99)`), Ellipse um die CD-Mitte mit `rx = discHöhe/2`, `ry = discHöhe/2 · cos(Neigung)`, Linienbreite 2.4px, `#111`, runde Enden, Punkte per `quadraticCurveTo` geglättet. Beim Zurückscrollen löscht er sich; bei `p = 0` neuer Seed.
- Bei `prog > .995`: Seite blendet in 0.3s aus (alle Kinder außer dem Schließen-Button `opacity: 0 !important`), nach 380ms wechselt der Inhalt zur nächsten Serie (Karussell-`target + 1`, dieselbe CD bleibt versteckt), `scrollTop = 0`, Einblendung startet neu. Nach der letzten Serie kommt wieder die erste.
---
## 13. Barrierefreiheit, Performance, Details
- `prefers-reduced-motion`: keine Eigendrehung, Übergänge praktisch sofort, keine Kantenanimation, keine Einblend-Animationen.
- Alle Buttons mit sichtbarem Fokus (2px Outline in `--ink`, Offset 4px).
- `dt` pro Frame auf max. 1/30s begrenzen.
- Kein `alert/confirm`, keine externen Hosts außer cdnjs und Google Fonts.
- Keine horizontale Scrollbar auf dem Handy.
---
## 14. Abnahme-Checkliste
Prüfe am Ende selbst:
1. 8 CDs liegen auf einer Kurve: links klein und überlappend, rechts groß und nach oben aus dem Bild laufend.
2. Keine CD schneidet eine andere; vordere liegen sauber über hinteren.
3. Hover: CD neigt sich zum Zeiger, kommt nach vorn, Kritzel-Kreis zeichnet sich, wird von vorderen CDs verdeckt, löscht sich beim Verlassen.
4. Ziehen: CD folgt, dreht sich, federt zurück.
5. Scrollen, Wischen, Pfeiltasten, Index-Dropdown und ←/→ blättern endlos.
6. CDs sind matt mit kräftigen Farben, ohne Titel und Label auf der Oberfläche, mit leichtem Grain und Randschrift.
7. Klick auf die Mitte: seitliche CDs gleiten nach außen, die mittlere dreht sich klein bis zum Verschwinden, dann erscheint die Detailseite.
8. Detailseite: großer zentrierter Titel, volle Linien, Fakten, Inhaltsangabe, Bild mit fließender Gebirgskante, die beim Scrollen hochsteigt.
9. Weiterscrollen: aufrechte CD der nächsten Serie steigt bis zu einem Drittel ins Bild, dreht immer schneller, Kritzel-Kreis erscheint, dann Wechsel zur nächsten Serie.
10. Schließen: alles läuft rückwärts, die CD dreht sich zurück an ihren Platz.
Liefere die komplette `index.html` in einem Stück.Kurz zusammengefasst
Fünf Prompts, fünf Websites, jede als einzelne HTML-Datei. Kopieren, in einen leeren Ordner mit Claude Code einfügen, über einen lokalen Server öffnen. Wenn das Ergebnis abweicht, lass Claude die Abnahme-Checkliste am Ende des Prompts abarbeiten.