Files
AI-Vortrag/PATTERNS.md
Erik Thiele 2633ca0e0d Add full deck, config tool, and updated design docs
Adds the complete "KI verstehen" presentation (36 slides), the
Präsentations-Konfigurator (config.html + slides-manifest.js) for
curating which slides to show, and updates DESIGN.md/PATTERNS.md/
deck-template.html to reflect the new house font (General Sans) and
newly established patterns (chapter dividers, term tooltips, the
mitmach game, ambient sound, and the slide configurator).
2026-07-25 16:29:28 +02:00

1011 lines
44 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Slide-Patterns — Copy-Paste-Bibliothek
Bausteine für `deck-template.html`. Regeln und Tokens stehen in `DESIGN.md`.
Jedes Pattern sagt an, ob das CSS schon im Template liegt oder mitkopiert werden muss.
„Mitkopieren" heißt: CSS in den Block `SLIDE-SPEZIFISCHES CSS` am Ende des `<style>`, JS in den Block `SLIDE-SPEZIFISCHES JS` am Ende des `<script>`.
**Inhalt**
1. [Versus-Slide](#1-versus-slide) — CSS im Template
2. [Code-Slide mit Live-Demo](#2-code-slide-mit-live-demo) — CSS im Template, JS mitkopieren
3. [SVG-Chart](#3-svg-chart) — CSS mitkopieren
4. [Feature-Grid mit CSS-Demos](#4-feature-grid-mit-css-demos) — Grid im Template, Demos mitkopieren
5. [Live-Daten via WebSocket](#5-live-daten-via-websocket) — CSS + JS mitkopieren
6. [Zwei-Spalten-Abwägung](#6-zwei-spalten-abwägung) — CSS im Template
7. [Kapitel-Trenner](#7-kapitel-trenner) — CSS mitkopieren
8. [Begriffs-Tooltip](#8-begriffs-tooltip) — CSS + JS mitkopieren
9. [Mitmach-Spiel](#9-mitmach-spiel) — CSS + JS mitkopieren
10. [Ambient-Sound-Button](#10-ambient-sound-button) — CSS + JS mitkopieren
11. [Präsentations-Konfigurator](#11-präsentations-konfigurator) — eigene Dateien (`config.html` + `slides-manifest.js`)
---
## 1. Versus-Slide
Direktvergleich, randlos, ohne `.slide-content`. Links das Alte (`×`), rechts das Neue (`→`). Beachte: links fährt horizontal ein (`reveal-slide-x`), rechts vertikal mit `d3` — das erzeugt die Bewegung zur Mitte hin.
```html
<section class="slide">
<div class="versus">
<div class="versus-side left">
<div class="reveal-slide-x">
<div class="versus-label">Bisher · [Label]</div>
<div class="versus-headline">Kurze<br>Zuspitzung.</div>
<ul class="versus-list">
<li>Nachteil eins</li>
<li>Nachteil zwei</li>
<li>Nachteil drei</li>
<li>Nachteil vier</li>
</ul>
</div>
</div>
<div class="versus-side right">
<div class="reveal d3">
<div class="versus-label">Jetzt · [Label]</div>
<div class="versus-headline">Kurze<br>Antwort.</div>
<ul class="versus-list">
<li>Vorteil eins</li>
<li>Vorteil zwei</li>
<li>Vorteil drei</li>
<li>Vorteil vier</li>
</ul>
</div>
</div>
<div class="versus-vs">VS</div>
</div>
</section>
```
Max. 45 Listenpunkte pro Seite, gleiche Anzahl links und rechts. Ungleiche Spalten sehen aus wie ein Fehler.
---
## 2. Code-Slide mit Live-Demo
Das stärkste Argument des Formats: links die Behauptung, rechts der Code, darunter das laufende Ergebnis. Der gezeigte Code sollte tatsächlich das tun, was die Preview darunter zeigt — sonst ist es ein Mockup, und dann kann man auch PPTX nehmen.
```html
<section class="slide">
<div class="code-slide">
<div class="code-left">
<span class="slide-eyebrow reveal d1">Live-Demo</span>
<h2 class="slide-title reveal d2" style="font-size: 92px;">Der Code<br>ist der <span class="accent">Slide.</span></h2>
<p class="slide-lead reveal d3" style="font-size: 24px; margin-bottom: 0;">Rechts steht echtes HTML. Klick auf den Button — der Zähler zählt hoch.</p>
</div>
<div class="code-right">
<div class="code-window reveal d4">
<div class="code-tabs">
<div class="dots"><span></span><span></span><span></span></div>
<span class="filename">slide-demo.html</span>
<span class="live">LIVE</span>
</div>
<div class="code-body">
<span class="com">// Ein Klick — echter State im Browser</span>
<span class="kw">const</span> <span class="fn">btn</span> = document.<span class="fn">querySelector</span>(<span class="str">'.btn'</span>);
<span class="kw">let</span> count = <span class="num">0</span>;
btn.<span class="fn">addEventListener</span>(<span class="str">'click'</span>, () <span class="kw">=&gt;</span> {
count<span class="kw">++</span>;
});
</div>
</div>
<div class="code-preview reveal d5">
<button class="demo-button" id="demoBtn">Klick mich →</button>
<div class="demo-output">Klicks: <span class="count" id="demoCount">0</span></div>
</div>
</div>
</div>
</section>
```
**CSS mitkopieren** (die Preview-Ausgabe):
```css
.demo-output { font-family: var(--font-mono); font-size: 24px; color: var(--paper); }
.demo-output .count {
color: var(--cancom-red);
font-family: var(--font-display); font-weight: 700;
font-size: 40px;
display: inline-block;
min-width: 60px; text-align: right;
transition: transform 0.3s;
}
.demo-output .count.bump { transform: scale(1.3); }
```
**JS mitkopieren.** Der Ripple rechnet die Mausposition durch den Scale-Faktor der Bühne zurück — ohne das landet er beim skalierten Deck an der falschen Stelle:
```js
(function() {
const btn = document.getElementById('demoBtn');
const out = document.getElementById('demoCount');
if (!btn || !out) return;
let n = 0;
btn.addEventListener('click', (e) => {
n++;
out.textContent = n;
out.classList.add('bump');
setTimeout(() => out.classList.remove('bump'), 300);
// clientX/Y sind Bildschirm-Pixel, der Button lebt in der skalierten Bühne
const rect = btn.getBoundingClientRect();
const scale = rect.width / btn.offsetWidth;
const ripple = document.createElement('span');
ripple.className = 'ripple';
ripple.style.setProperty('--rx', ((e.clientX - rect.left) / scale) + 'px');
ripple.style.setProperty('--ry', ((e.clientY - rect.top) / scale) + 'px');
btn.appendChild(ripple);
setTimeout(() => ripple.remove(), 700);
});
})();
```
Syntax-Klassen im `.code-body`: `.kw` Keyword · `.fn` Funktion · `.str` String · `.com` Kommentar · `.num` Zahl · `.prop` Property. Einrückung im HTML ist die Einrückung im Code — der Block ist kein `<pre>`, aber `.code-body` erbt Whitespace aus dem Markup, also sauber formatieren.
---
## 3. SVG-Chart
Balken sind `<rect>` mit `transform: scaleY(0)`, die bei `.slide.visible` auf 1 fahren. Die Staffelung kommt aus inline `transition-delay`. Für einen neuen Chart: `y` und `height` so setzen, dass `y + height = 420` (die Achse), sonst schwebt der Balken.
```html
<div class="chart-wrap">
<svg class="chart-svg reveal d3" viewBox="0 0 600 500" preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient id="gradGrey" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#5a5a68"/><stop offset="100%" stop-color="#2a2a34"/>
</linearGradient>
<linearGradient id="gradRed" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#ff5060"/><stop offset="100%" stop-color="#E4002B"/>
</linearGradient>
</defs>
<line class="chart-axis" x1="40" y1="420" x2="580" y2="420"/>
<rect class="chart-bar pptx" x="80" y="120" width="80" height="300" style="transition-delay: 0.4s;"/>
<rect class="chart-bar html" x="180" y="80" width="80" height="340" style="transition-delay: 0.5s;"/>
<text class="chart-label" x="150" y="460">Kategorie</text>
<text class="chart-value" x="120" y="105">1×</text>
<text class="chart-value red" x="220" y="65">3×</text>
</svg>
<div class="reveal d4">
<p style="font-size: 26px; line-height: 1.5; color: var(--paper-dim); margin-bottom: 8px;">
Erklärender Satz zum Chart.
</p>
<ul class="chart-legend">
<li><span class="swatch pptx"></span>Referenz · statisch</li>
<li><span class="swatch html"></span>Neu · datengebunden</li>
</ul>
</div>
</div>
```
**CSS mitkopieren:**
```css
.chart-wrap { flex: 1; display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: center; }
.chart-svg { width: 100%; height: 500px; }
.chart-bar {
fill: var(--ink-line);
transform-origin: bottom;
transform: scaleY(0);
transition: transform 1.2s var(--ease-out-expo);
}
.slide.visible .chart-bar { transform: scaleY(1); }
.chart-bar.pptx { fill: url(#gradGrey); }
.chart-bar.html { fill: url(#gradRed); filter: drop-shadow(0 0 20px var(--cancom-red-glow)); }
.chart-label {
font-family: var(--font-mono); font-size: 14px;
fill: var(--paper-mute); letter-spacing: 0.14em; text-transform: uppercase;
}
.chart-value { font-family: var(--font-display); font-weight: 700; fill: var(--paper); font-size: 28px; }
.chart-value.red { fill: var(--cancom-red); }
.chart-axis { stroke: var(--ink-line); stroke-width: 1; }
.chart-legend { list-style: none; margin-top: 40px; }
.chart-legend li {
display: flex; align-items: center; gap: 16px;
padding: 14px 0;
font-size: 22px; color: var(--paper-dim);
border-bottom: 1px solid var(--ink-line);
}
.chart-legend .swatch { width: 20px; height: 20px; border-radius: 4px; }
.chart-legend .swatch.pptx { background: #4b4b56; }
.chart-legend .swatch.html { background: var(--cancom-red); box-shadow: 0 0 12px var(--cancom-red-glow); }
```
`transform-origin: bottom` funktioniert bei SVG nur zuverlässig, wenn die `<rect>` nicht zusätzlich per `transform`-Attribut verschoben ist. Position immer über `x`/`y` setzen.
---
## 4. Feature-Grid mit CSS-Demos
4×2-Raster, `.wide` überspannt zwei Spalten. Jede Tile: Tag oben, Demo in der Mitte, Name + Beschreibung unten. Das Grid-CSS liegt im Template, die einzelnen Demos kopierst du nach Bedarf.
```html
<div class="feature-grid">
<div class="feature-tile reveal d3">
<span class="feature-tag">CSS · Blob</span>
<div class="demo-blob"></div>
<div>
<div class="feature-name">Organische<br>Formen</div>
<div class="feature-desc">border-radius animiert. Läuft ewig, kostet nichts.</div>
</div>
</div>
<div class="feature-tile wide reveal d4">
<span class="feature-tag">CSS · Ticker</span>
<div class="demo-scroll-text"><span>WORT · WORT · WORT · WORT · WORT · WORT · </span></div>
<div>
<div class="feature-name">Endlos-Ticker</div>
<div class="feature-desc">Marquee ohne &lt;marquee&gt;. Nahtlos, mit Fade-Maske.</div>
</div>
</div>
</div>
```
Bei 8 Spalten-Einheiten (4×2) muss die Summe aufgehen: 6 normale + 1 `.wide` = 8. Verrechnest du dich, bricht die zweite Zeile.
**Demo-Bausteine — nur mitkopieren, was du benutzt:**
```css
/* Blob — organische Form */
.demo-blob {
width: 80px; height: 80px;
background: var(--cancom-red);
border-radius: 40% 60% 55% 45% / 50% 45% 55% 50%;
animation: blob 4s ease-in-out infinite;
filter: drop-shadow(0 0 20px var(--cancom-red-glow));
margin: 0 auto;
}
@keyframes blob {
0%, 100% { border-radius: 40% 60% 55% 45% / 50% 45% 55% 50%; transform: rotate(0deg); }
33% { border-radius: 60% 40% 30% 70% / 60% 30% 70% 40%; transform: rotate(120deg); }
66% { border-radius: 30% 60% 70% 40% / 40% 70% 30% 60%; transform: rotate(240deg); }
}
/* 3D-Cube — HTML: <div class="demo-3d-cube"><div class="cube">6× <div class="face front|back|right|left|top|bottom"></div></div></div> */
.demo-3d-cube { width: 80px; height: 80px; margin: 0 auto; perspective: 400px; }
.demo-3d-cube .cube {
width: 100%; height: 100%;
position: relative;
transform-style: preserve-3d;
animation: cube-spin 8s linear infinite;
}
.demo-3d-cube .face {
position: absolute; width: 80px; height: 80px;
border: 2px solid var(--cancom-red);
background: rgba(228,0,43,0.15);
}
.demo-3d-cube .face.front { transform: translateZ(40px); }
.demo-3d-cube .face.back { transform: rotateY(180deg) translateZ(40px); }
.demo-3d-cube .face.right { transform: rotateY(90deg) translateZ(40px); }
.demo-3d-cube .face.left { transform: rotateY(-90deg) translateZ(40px); }
.demo-3d-cube .face.top { transform: rotateX(90deg) translateZ(40px); }
.demo-3d-cube .face.bottom { transform: rotateX(-90deg) translateZ(40px); }
@keyframes cube-spin { to { transform: rotateX(360deg) rotateY(360deg); } }
/* Ticker — Text im <span> muss doppelt drinstehen, sonst springt der Loop bei -50% */
.demo-scroll-text {
overflow: hidden; white-space: nowrap;
font-family: var(--font-display); font-weight: 700;
font-size: 30px; color: var(--cancom-red);
letter-spacing: -0.02em;
mask-image: linear-gradient(90deg, transparent, black 15%, black 85%, transparent);
}
.demo-scroll-text span { display: inline-block; animation: scroll-x 12s linear infinite; padding-right: 40px; }
@keyframes scroll-x { from { transform: translateX(0); } to { transform: translateX(-50%); } }
/* Audio-Bars — HTML: <div class="demo-charts-bars">6× <span></span></div> */
.demo-charts-bars { display: flex; gap: 6px; align-items: flex-end; height: 60px; }
.demo-charts-bars span {
flex: 1; background: var(--cancom-red);
border-radius: 2px 2px 0 0;
animation: bar-dance 1.6s ease-in-out infinite;
}
.demo-charts-bars span:nth-child(1) { animation-delay: 0s; }
.demo-charts-bars span:nth-child(2) { animation-delay: 0.2s; }
.demo-charts-bars span:nth-child(3) { animation-delay: 0.4s; }
.demo-charts-bars span:nth-child(4) { animation-delay: 0.6s; }
.demo-charts-bars span:nth-child(5) { animation-delay: 0.8s; }
.demo-charts-bars span:nth-child(6) { animation-delay: 1.0s; }
@keyframes bar-dance { 0%,100% { height: 20%; } 50% { height: 100%; } }
/* Living Gradient */
.demo-gradient {
height: 60px; border-radius: 8px;
background: linear-gradient(90deg, var(--cancom-red), var(--accent-warm), var(--accent-cool), var(--cancom-red));
background-size: 300% 100%;
animation: gradient-shift 6s linear infinite;
}
@keyframes gradient-shift { 0% { background-position: 0% 50%; } 100% { background-position: 300% 50%; } }
/* Typewriter — steps() muss zur Zeichenzahl des Textes passen */
.demo-typewriter {
font-family: var(--font-mono);
color: var(--paper);
font-size: 20px;
overflow: hidden; white-space: nowrap;
border-right: 3px solid var(--cancom-red);
width: 0;
animation: typing 4s steps(30, end) infinite,
caret-blink 0.7s step-end infinite;
}
@keyframes typing { 0% { width: 0; } 50% { width: 100%; } 100% { width: 100%; } }
@keyframes caret-blink { 50% { border-color: transparent; } }
/* Hover-Shift — Micro-Interaktion */
.demo-hover-shift {
font-family: var(--font-display); font-weight: 700;
font-size: 44px; color: var(--paper);
letter-spacing: -0.02em;
cursor: pointer;
transition: letter-spacing 0.5s var(--ease-out-expo), color 0.5s;
text-align: center;
}
.demo-hover-shift:hover { letter-spacing: 0.05em; color: var(--cancom-red); }
```
---
## 5. Live-Daten via WebSocket
Der Beweis-Slide schlechthin: ein offener Stream, mehrere Ticks pro Sekunde direkt in den DOM. Beispiel Binance BTC/EUR — öffentlich, kein Key, kein CORS-Problem.
**Der Fallback ist Pflicht.** Ohne Netz auf der Bühne zeigt die Slide sonst „—.——" — ein Eigentor gegen die eigene These. Der Handler unten simuliert Ticks, wenn die Verbindung scheitert.
```html
<div class="api-wrap">
<div class="api-panel rate reveal d4">
<div class="api-panel-head">
<span>BTC · EUR</span>
<span class="api-live-dot" id="wsStatus">Verbinde…</span>
</div>
<div class="api-rate-pair">1 BTC =</div>
<div class="api-rate-value">
<span id="btcRateNum">—.——</span><span class="currency">EUR</span>
</div>
<div class="api-rate-meta">
<span id="btcChange">24h Change: —</span>
<span class="updated" id="btcTicks">0 Ticks</span>
</div>
</div>
<div class="api-panel conv reveal d5">
<div class="api-panel-head"><span>Konverter</span><span>Live berechnet</span></div>
<div style="display: flex; flex-direction: column; gap: 14px;">
<label class="conv-row">
<span class="conv-flag btc"></span>
<span class="conv-code">BTC</span>
<input class="conv-input" id="convBTC" type="number" value="1" min="0" step="0.001" inputmode="decimal">
</label>
<div class="conv-swap"><span class="conv-swap-icon"></span></div>
<label class="conv-row">
<span class="conv-flag eur"></span>
<span class="conv-code">EUR</span>
<input class="conv-output" id="convEUR" type="text" readonly value="—">
</label>
</div>
<div class="conv-hint">Tippe eine Zahl · Powered by <code>new WebSocket()</code></div>
</div>
</div>
```
**CSS mitkopieren:**
```css
.api-wrap { flex: 1; display: grid; grid-template-columns: 1fr 1fr; gap: 60px; align-items: stretch; }
.api-panel {
background: linear-gradient(180deg, rgba(255,255,255,0.03), rgba(0,0,0,0.15));
border: 1px solid var(--ink-line);
border-radius: 16px;
padding: 48px;
display: flex; flex-direction: column;
position: relative; overflow: hidden;
}
.api-panel.rate::before {
content: ""; position: absolute;
top: -50%; right: -30%;
width: 500px; height: 500px;
background: radial-gradient(circle, var(--cancom-red-dim), transparent 60%);
filter: blur(20px);
pointer-events: none;
}
.api-panel-head {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 32px;
font-family: var(--font-mono); font-size: 16px;
color: var(--paper-mute);
letter-spacing: 0.2em; text-transform: uppercase;
position: relative; z-index: 2;
}
.api-live-dot { display: inline-flex; align-items: center; gap: 10px; color: var(--cancom-red); }
.api-live-dot::before {
content: ""; width: 10px; height: 10px;
border-radius: 50%;
background: var(--cancom-red);
box-shadow: 0 0 12px var(--cancom-red-glow);
animation: pulse 1.2s infinite;
}
.api-live-dot.disconnected { color: var(--paper-mute); }
.api-live-dot.disconnected::before { animation: none; background: var(--paper-mute); box-shadow: none; }
.api-rate-pair {
font-family: var(--font-mono); font-size: 22px;
color: var(--paper-dim); letter-spacing: 0.1em;
margin-bottom: 12px;
position: relative; z-index: 2;
}
.api-rate-value {
font-family: var(--font-display); font-weight: 700;
font-size: 112px; line-height: 0.95;
letter-spacing: -0.04em;
color: var(--paper);
position: relative; z-index: 2;
display: flex; align-items: baseline;
flex-wrap: wrap; gap: 14px;
font-variant-numeric: tabular-nums; /* sonst springt das Layout bei jedem Tick */
word-break: break-all;
}
.api-rate-value .currency {
color: var(--cancom-red);
font-size: 56px;
text-shadow: 0 0 40px var(--cancom-red-glow);
letter-spacing: 0;
}
.api-rate-meta {
margin-top: auto;
padding-top: 32px;
border-top: 1px solid var(--ink-line);
display: flex; justify-content: space-between;
font-family: var(--font-mono); font-size: 14px;
color: var(--paper-mute); letter-spacing: 0.14em;
text-transform: uppercase;
position: relative; z-index: 2;
}
.api-rate-meta .updated { color: var(--paper-dim); }
/* Richtungs-Flash. Grün/Rot sind semantisch, nicht Marke. */
.tick-up { color: #4ade80 !important; text-shadow: 0 0 40px rgba(74,222,128,0.6) !important; }
.tick-down { color: #ff5060 !important; text-shadow: 0 0 40px rgba(255,80,96,0.6) !important; }
#btcRateNum { transition: color 0.4s ease-out, text-shadow 0.4s ease-out; }
/* Converter */
.api-panel.conv { justify-content: space-between; }
.conv-row {
display: flex; align-items: center; gap: 20px;
padding: 20px 24px;
background: rgba(0,0,0,0.35);
border: 1px solid var(--ink-line);
border-radius: 12px;
transition: border-color 0.15s ease-out;
}
.conv-row:focus-within { border-color: var(--cancom-red); box-shadow: 0 0 0 3px var(--cancom-red-dim); }
.conv-flag {
width: 56px; height: 56px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-family: var(--font-display); font-weight: 700;
font-size: 22px;
flex-shrink: 0;
}
.conv-flag.eur { background: linear-gradient(135deg, #003399, #002266); color: #FFCC00; }
.conv-flag.usd { background: linear-gradient(135deg, #00693E, #004225); color: #F5F5F2; }
.conv-flag.btc { background: linear-gradient(135deg, #F7931A, #B9700F); color: #fff; box-shadow: 0 0 20px rgba(247,147,26,0.4); }
.conv-code {
font-family: var(--font-mono); font-size: 16px;
letter-spacing: 0.2em; color: var(--paper-mute);
text-transform: uppercase; width: 60px;
}
.conv-input, .conv-output {
flex: 1;
background: transparent; border: none; outline: none;
font-family: var(--font-display); font-weight: 600;
font-size: 56px; color: var(--paper);
text-align: right; letter-spacing: -0.02em;
width: 100%; min-width: 0;
-moz-appearance: textfield;
}
.conv-input::-webkit-outer-spin-button,
.conv-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.conv-output.updating { color: var(--cancom-red); }
.conv-swap { text-align: center; margin: -8px 0; position: relative; z-index: 2; }
.conv-swap-icon {
display: inline-block;
width: 48px; height: 48px;
border-radius: 50%;
background: var(--cancom-red);
color: #fff;
font-size: 22px; line-height: 48px;
box-shadow: 0 0 24px var(--cancom-red-glow);
}
.conv-hint {
margin-top: 20px;
font-family: var(--font-mono); font-size: 14px;
color: var(--paper-mute); letter-spacing: 0.1em;
text-align: center;
}
.conv-hint code { color: var(--cancom-red); background: rgba(228,0,43,0.1); padding: 2px 8px; border-radius: 4px; }
```
**JS mitkopieren:**
```js
(function initBtcStream() {
const rateEl = document.getElementById('btcRateNum');
const changeEl = document.getElementById('btcChange');
const ticksEl = document.getElementById('btcTicks');
const statusEl = document.getElementById('wsStatus');
const btcInput = document.getElementById('convBTC');
const eurOut = document.getElementById('convEUR');
if (!rateEl || !btcInput || !eurOut) return;
let currentRate = null;
let tickCount = 0;
let ws = null;
let reconnectTimer = null;
const fmt = (v, digits = 2) => new Intl.NumberFormat('de-DE', {
minimumFractionDigits: digits, maximumFractionDigits: digits
}).format(v);
function recalc() {
if (currentRate == null) return;
const btc = parseFloat(btcInput.value);
if (isNaN(btc)) { eurOut.value = '—'; return; }
eurOut.value = fmt(btc * currentRate) + ' €';
}
btcInput.addEventListener('input', recalc);
function applyTick(newRate, changePct) {
const prev = currentRate;
currentRate = newRate;
tickCount++;
rateEl.textContent = fmt(newRate);
ticksEl.textContent = tickCount.toLocaleString('de-DE') + ' Ticks';
if (changePct != null) {
const sign = changePct >= 0 ? '+' : '';
changeEl.textContent = '24h Change: ' + sign + changePct.toFixed(2) + ' %';
changeEl.style.color = changePct >= 0 ? '#4ade80' : '#ff5060';
}
if (prev != null && newRate !== prev) {
const cls = newRate > prev ? 'tick-up' : 'tick-down';
rateEl.classList.remove('tick-up', 'tick-down');
void rateEl.offsetWidth; // Reflow erzwingen, sonst startet die Anim nicht neu
rateEl.classList.add(cls);
setTimeout(() => rateEl.classList.remove(cls), 400);
}
recalc();
}
function connect() {
statusEl.textContent = 'Verbinde…';
statusEl.classList.add('disconnected');
try {
ws = new WebSocket('wss://stream.binance.com:9443/ws/btceur@ticker');
} catch (e) { handleFail(e); return; }
ws.addEventListener('open', () => {
statusEl.classList.remove('disconnected');
statusEl.textContent = 'Live · Binance';
});
ws.addEventListener('message', (ev) => {
try {
const d = JSON.parse(ev.data); // c = last price, P = 24h % change
applyTick(parseFloat(d.c), parseFloat(d.P));
} catch (e) { /* malformed frame ignorieren */ }
});
ws.addEventListener('close', () => {
statusEl.classList.add('disconnected');
statusEl.textContent = 'Getrennt · retry';
scheduleReconnect();
});
ws.addEventListener('error', handleFail);
}
function handleFail(err) {
console.warn('[ws] error', err);
statusEl.classList.add('disconnected');
statusEl.textContent = 'Offline · Fallback';
// Simulierte Ticks, damit die Demo auch ohne Netz läuft
if (currentRate == null) currentRate = 55000;
if (!window.__btcFallbackTimer) {
window.__btcFallbackTimer = setInterval(() => {
const wobble = (Math.random() - 0.5) * 40;
applyTick(currentRate + wobble, 0);
}, 400);
}
}
function scheduleReconnect() {
clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connect, 3000);
}
connect();
})();
```
**Für eine andere Datenquelle** bleibt das Gerüst gleich: `connect()` tauschen, im `message`-Handler auf `applyTick(wert, änderung)` mappen, `handleFail` mit plausiblen Dummy-Werten füttern. Bei REST statt WebSocket: `setInterval` + `fetch`, aber die Quelle muss CORS erlauben — sonst scheitert es nur auf der Bühne, nicht auf deinem Rechner.
---
## 6. Zwei-Spalten-Abwägung
Der ehrliche Deal: Wann das Neue, wann das Alte. Nutzt `.pain-grid` mit zwei Spalten, die linke Karte rot hinterlegt.
Diese Slide macht das Deck glaubwürdig. Ein Vortrag, der nur Vorteile kennt, ist eine Werbung — einer, der die Grenzen benennt, ist ein Argument.
```html
<div class="pain-grid" style="grid-template-columns: 1fr 1fr;">
<div class="pain-card reveal d3" style="background: linear-gradient(180deg, rgba(228,0,43,0.08), rgba(0,0,0,0.15)); border-color: rgba(228,0,43,0.4);">
<span class="pain-num" style="color: var(--cancom-red);">→ [Option A]</span>
<div>
<div class="pain-title">Wenn's <span style="color: var(--cancom-red);">wow</span> machen soll</div>
<div class="pain-body" style="font-size: 22px;">Konkrete Anwendungsfälle, keine Allgemeinplätze.</div>
</div>
</div>
<div class="pain-card reveal d4">
<span class="pain-num">→ [Option B]</span>
<div>
<div class="pain-title">Wenn <span style="color: var(--paper-mute);">X</span> zählt</div>
<div class="pain-body" style="font-size: 22px;">Wo die andere Option ehrlich die bessere ist.</div>
</div>
</div>
</div>
```
---
## 7. Kapitel-Trenner
Ruhiger Zwischen-Slide bei Themenwechsel: riesige, transparente „Geister-Zahl" im Hintergrund, roter Mono-Kicker, kurze Headline. Kein `.slide-content`, kein `data-num` — zählt bewusst nicht als Inhalts-Slide.
```html
<section class="slide">
<div class="section-slide">
<div class="section-ghost-num">03</div>
<div class="section-kicker reveal d1">Kapitel 03 / 07</div>
<h2 class="section-title reveal d2">Kurzer<br><span class="accent">Kapitel-Titel.</span></h2>
<p class="section-sub reveal d3">Ein Satz, der ankündigt, was jetzt kommt.</p>
</div>
</section>
```
**CSS mitkopieren:**
```css
.section-slide {
background: radial-gradient(ellipse at 75% 25%, #1a0409 0%, var(--ink) 60%), var(--ink);
padding: 120px; height: 100%;
display: flex; flex-direction: column; justify-content: center;
position: relative; overflow: hidden;
}
.section-ghost-num {
position: absolute; right: 20px; top: 50%; transform: translateY(-50%);
font-family: var(--font-display); font-weight: 700; font-size: 480px; line-height: 0.8;
color: transparent; -webkit-text-stroke: 1.5px var(--ink-line); z-index: 0; user-select: none;
}
.section-kicker {
position: relative; z-index: 2;
font-family: var(--font-mono); font-size: 18px; letter-spacing: 0.3em; text-transform: uppercase;
color: var(--cancom-red); display: flex; align-items: center; gap: 16px; margin-bottom: 40px;
}
.section-kicker::before { content: ""; width: 60px; height: 2px; background: var(--cancom-red); }
.section-title {
position: relative; z-index: 2;
font-family: var(--font-display); font-weight: 700; font-size: 150px; line-height: 0.94;
letter-spacing: -0.04em; color: var(--paper); margin-bottom: 32px; max-width: 1500px;
}
.section-title .accent { color: var(--cancom-red); }
.section-sub {
position: relative; z-index: 2;
font-size: 28px; line-height: 1.4; color: var(--paper-dim); max-width: 1100px;
}
```
Faustregel: ein Trenner pro Kapitel, nicht pro Slide. Die Geister-Zahl (`.section-ghost-num`) trägt die Kapitel-Nummer, nicht die Slide-Nummer.
---
## 8. Begriffs-Tooltip
Fachbegriffe für Laien-Publikum anklickbar erklären, ohne die Slide zu überladen. Ein Wort bekommt eine gepunktete Unterstreichung plus „?"-Badge; Klick öffnet eine kurze Definition.
```html
<span class="term" data-title="MCP" data-def="Model Context Protocol: ein offener Standard, über den eine KI einheitlich auf Werkzeuge und Daten zugreifen kann.">MCP</span>
```
**CSS mitkopieren:**
```css
.term { border-bottom: 1.5px dotted var(--cancom-red); cursor: help; transition: color 0.15s ease-out; }
.term:hover, .term.term-active { color: var(--cancom-red); }
.term::after {
content: "?"; display: inline-flex; align-items: center; justify-content: center;
width: 0.62em; height: 0.62em; margin-left: 0.2em; border-radius: 50%;
border: 1px solid currentColor; font-family: var(--font-mono); font-size: 0.5em; font-weight: 700;
vertical-align: super; opacity: 0.7;
}
#globalTooltip {
position: fixed; max-width: 380px;
background: #14141c; border: 1px solid var(--cancom-red); border-radius: 12px;
padding: 18px 22px; font-family: 'Satoshi', sans-serif; font-size: 15px; line-height: 1.48;
color: rgba(245,245,242,0.75); text-align: left;
box-shadow: 0 24px 70px rgba(0,0,0,0.65), 0 0 0 1px rgba(228,0,43,0.15);
opacity: 0; transform: translateY(6px) scale(0.98); pointer-events: none;
transition: opacity 0.16s ease-out, transform 0.16s ease-out; z-index: 20000;
}
#globalTooltip.visible { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
#globalTooltip .gt-title {
display: block; font-family: 'JetBrains Mono', monospace; font-weight: 700; font-size: 12px;
letter-spacing: 0.16em; text-transform: uppercase; color: var(--cancom-red); margin-bottom: 8px;
}
#globalTooltip::before {
content: ""; position: absolute; left: var(--gt-arrow-x, 24px); width: 14px; height: 14px;
background: #14141c; border: 1px solid var(--cancom-red); transform: translateX(-50%) rotate(45deg);
}
#globalTooltip.arrow-top::before { top: -8px; border-right: none; border-bottom: none; }
#globalTooltip.arrow-bottom::before { bottom: -8px; border-left: none; border-top: none; }
```
**HTML einmal im Body:**
```html
<div id="globalTooltip" role="tooltip"><span class="gt-title"></span><span class="gt-def"></span></div>
```
**JS mitkopieren.** Bewusst als eigenständiges, globales Element umgesetzt (nicht neben dem Begriff im DOM) — sonst schneiden `overflow:hidden`-Container wie `.pain-card` oder `.feature-tile` das Tooltip ab. Positionierung läuft in echten Viewport-Pixeln, klappt automatisch nach oben oder unten aus, je nachdem wo im Fenster noch Platz ist:
```js
(function initTermTooltips() {
const tip = document.getElementById('globalTooltip');
if (!tip) return;
const titleEl = tip.querySelector('.gt-title');
const defEl = tip.querySelector('.gt-def');
let activeTerm = null;
function closeTip() {
tip.classList.remove('visible');
if (activeTerm) activeTerm.classList.remove('term-active');
activeTerm = null;
}
function openTip(term) {
if (activeTerm === term) { closeTip(); return; }
if (activeTerm) activeTerm.classList.remove('term-active');
activeTerm = term;
term.classList.add('term-active');
titleEl.textContent = term.dataset.title || '';
defEl.textContent = term.dataset.def || '';
tip.classList.remove('arrow-top', 'arrow-bottom');
tip.style.visibility = 'hidden';
tip.classList.add('visible');
const tw = tip.offsetWidth, th = tip.offsetHeight;
const rect = term.getBoundingClientRect();
const margin = 16;
let left = rect.left + rect.width / 2 - tw / 2;
left = Math.max(margin, Math.min(left, window.innerWidth - tw - margin));
let top = rect.bottom + 14;
let arrowClass = 'arrow-top';
if (top + th > window.innerHeight - margin) {
top = rect.top - th - 14;
arrowClass = 'arrow-bottom';
}
tip.style.left = left + 'px';
tip.style.top = Math.max(margin, top) + 'px';
tip.style.setProperty('--gt-arrow-x', (rect.left + rect.width / 2 - left) + 'px');
tip.classList.add(arrowClass);
tip.style.visibility = '';
}
document.addEventListener('click', (e) => {
const term = e.target.closest ? e.target.closest('.term') : null;
if (term) { e.stopPropagation(); openTip(term); }
else if (!e.target.closest('#globalTooltip')) closeTip();
});
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeTip(); });
window.addEventListener('resize', closeTip);
})();
```
Bei Slide-Wechsel zusätzlich `closeTip()` aufrufen (z. B. `SlidePresentation.prototype.showSlide` umschließen), sonst bleibt ein Tooltip beim Weiterklicken sichtbar hängen.
---
## 9. Mitmach-Spiel
Publikum rät live mit statt nur zuzuschauen — stärker als jede Erklärung „von außen". Beispiel: Satz Wort für Wort per Multiple-Choice vervollständigen, mit Wahrscheinlichkeits-Badges (macht das Prinzip hinter LLM-Textvorhersage erlebbar).
```html
<div class="game-wrap reveal d4">
<div class="game-sentence" id="gameSentence">Alle meine <span class="game-blank" id="gameBlank">___</span></div>
<div class="game-options" id="gameOptions"></div>
<p class="game-feedback" id="gameFeedback">Klicken Sie auf ein Wort.</p>
<p class="game-progress" id="gameProgress"></p>
</div>
```
**CSS mitkopieren:**
```css
.game-wrap { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 44px; }
.game-sentence { font-family: var(--font-display); font-weight: 700; font-size: 62px; line-height: 1.3; color: var(--paper); letter-spacing: -0.02em; max-width: 1600px; }
.game-blank { display: inline-block; min-width: 200px; text-align: center; border-bottom: 4px dotted var(--cancom-red); color: var(--paper-mute); transition: transform 0.3s var(--ease-out-expo), color 0.2s ease-out; }
.game-blank.filled { color: var(--cancom-red); border-bottom-style: solid; }
.game-blank.bump { transform: scale(1.15); }
.game-options { display: flex; gap: 20px; flex-wrap: wrap; }
.game-option { padding: 22px 40px; border-radius: 10px; border: 1px solid var(--ink-line); background: #0f0f16; color: var(--paper); font-family: var(--font-display); font-weight: 600; font-size: 26px; cursor: pointer; position: relative; transition: transform 0.15s ease-out, border-color 0.12s ease-out, background 0.15s ease-out; }
.game-option:hover { transform: translateY(-4px); border-color: var(--cancom-red); }
.game-option .prob-badge { position: absolute; top: -16px; right: -12px; background: var(--cancom-red); color: #fff; font-family: var(--font-mono); font-size: 13px; font-weight: 700; padding: 4px 10px; border-radius: 20px; box-shadow: 0 0 12px var(--cancom-red-glow); opacity: 0; transform: scale(0.6); transition: opacity 0.2s ease-out, transform 0.2s ease-out; }
.game-option.revealed .prob-badge { opacity: 1; transform: scale(1); }
.game-option.low .prob-badge { background: #3a3a44; box-shadow: none; }
.game-feedback { font-family: var(--font-mono); font-size: 18px; color: var(--paper-mute); letter-spacing: 0.03em; min-height: 26px; }
.game-feedback.strong { color: var(--cancom-red); font-family: var(--font-display); font-weight: 600; font-size: 22px; }
.game-progress { font-family: var(--font-mono); font-size: 14px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--paper-mute); }
```
**JS mitkopieren.** Baut die Options-Buttons pro Schritt dynamisch aus einem `steps`-Array — nach jedem Klick wird das gewählte Wort fest ins Satzpräfix übernommen und der nächste Schritt (mit neuen Optionen) erzeugt:
```js
(function initAutocompleteGame() {
const sentence = document.getElementById('gameSentence');
let blank = document.getElementById('gameBlank');
const optionsWrap = document.getElementById('gameOptions');
const feedback = document.getElementById('gameFeedback');
const progress = document.getElementById('gameProgress');
if (!sentence || !blank || !optionsWrap || !feedback) return;
const prefix = 'Alle meine';
const steps = [
{ options: [{ word: 'Entchen', prob: 91 }, { word: 'Kinder', prob: 5 }, { word: 'Freunde', prob: 3 }, { word: 'Autos', prob: 1 }] },
// … weitere Schritte, je ein Array von { word, prob }
];
let confirmed = [];
let stepIndex = 0;
let locked = false;
function renderOptions() {
optionsWrap.innerHTML = '';
steps[stepIndex].options.forEach((opt) => {
const btn = document.createElement('button');
btn.className = 'game-option';
if (opt.prob < 50) btn.classList.add('low');
btn.innerHTML = opt.word + '<span class="prob-badge">' + opt.prob + ' %</span>';
btn.addEventListener('click', () => onPick(btn, opt));
optionsWrap.appendChild(btn);
});
}
function onPick(btn, opt) {
if (locked) return;
locked = true;
optionsWrap.querySelectorAll('.game-option').forEach((b) => b.classList.remove('revealed'));
btn.classList.add('revealed');
blank.textContent = opt.word;
blank.classList.add('filled', 'bump');
setTimeout(() => blank.classList.remove('bump'), 300);
const best = steps[stepIndex].options[0].word;
feedback.classList.toggle('strong', opt.word === best);
feedback.textContent = opt.word === best
? 'Genau das macht ein LLM: die wahrscheinlichste Fortsetzung wählen (' + opt.prob + ' %).'
: '„' + opt.word + '" wäre möglich, aber unwahrscheinlicher (' + opt.prob + ' %).';
setTimeout(() => {
confirmed.push(opt.word);
stepIndex++;
locked = false;
if (stepIndex < steps.length) {
sentence.innerHTML = prefix + ' ' + confirmed.join(' ') + ' <span class="game-blank filled" id="gameBlank">___</span>';
blank = document.getElementById('gameBlank'); // Referenz nach innerHTML-Ersatz neu holen!
renderOptions();
progress.textContent = 'Wort ' + (stepIndex + 1) + ' von ' + steps.length;
} else {
sentence.textContent = prefix + ' ' + confirmed.join(' ') + '.';
optionsWrap.innerHTML = '';
feedback.textContent = 'Fertig! Wort für Wort die wahrscheinlichste Fortsetzung — genau so entsteht jede LLM-Antwort.';
feedback.classList.add('strong');
progress.textContent = 'Satz vollständig';
}
}, 1100);
}
renderOptions();
})();
```
**Falle:** Nach jedem `sentence.innerHTML = …`-Ersatz existiert das alte `#gameBlank`-Element nicht mehr im DOM. Die JS-Referenz darauf **muss** neu geholt werden (`document.getElementById('gameBlank')`), sonst schreibt der nächste Schritt unsichtbar in einen losgelösten Knoten.
---
## 10. Ambient-Sound-Button
Optionaler, per Web Audio API erzeugter Klangteppich für die Titelfolie — kein externes Audio-File, läuft offline. Baut vor Vortragsbeginn Spannung auf, blendet beim Verlassen der Folie automatisch aus.
```html
<button class="ambient-toggle" id="ambientToggle" title="Sound (nur auf dieser Folie)">🔈</button>
```
**CSS mitkopieren:**
```css
.ambient-toggle {
width: 36px; height: 36px; border-radius: 50%; flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
background: rgba(20,20,28,0.55); border: 1px solid var(--ink-line);
cursor: pointer; font-size: 15px; line-height: 1; color: var(--paper-mute);
transition: border-color 0.15s ease-out, color 0.15s ease-out, box-shadow 0.2s ease-out;
}
.ambient-toggle:hover { border-color: var(--cancom-red); color: var(--paper); }
.ambient-toggle.playing {
color: var(--paper); border-color: rgba(228,0,43,0.5);
box-shadow: 0 0 14px var(--cancom-red-glow);
animation: pulse 1.6s infinite;
}
```
**JS-Prinzip** (voller Code in `ki-verstehen.html`, Abschnitt „AMBIENT-SOUND"): `AudioContext` erst beim ersten Klick anlegen (Autoplay-Policy verlangt eine Nutzer-Geste), Graph aus Sub-Drone-Oszillatoren + rhythmischem Puls + Arpeggio bauen, `masterGain` mit `setTargetAtTime` sanft ein-/ausblenden. Ein gepatchtes `SlidePresentation.prototype.showSlide` prüft bei jedem Slide-Wechsel, ob die Titelfolie noch aktiv ist, und regelt die Lautstärke entsprechend runter oder rauf:
```js
function updatePlayback() {
if (!ctx || !master) return;
const shouldPlay = soundOn && isTitleSlideActive(); // slide.dataset.slideId === '1'
const now = ctx.currentTime;
master.gain.cancelScheduledValues(now);
master.gain.setTargetAtTime(shouldPlay ? 0.45 : 0, now, shouldPlay ? 1.4 : 0.6);
}
```
**Regel:** Keine Geräusch-Layer, die dauerhaft wie Rauschen/Hintergrundrascheln wirken — das ermüdet. Rhythmische, sich entwickelnde Elemente (Puls, Arpeggio) wirken motivierend, ein Dauerdrone allein wirkt nur „atmosphärisch", nicht spannungsaufbauend.
---
## 11. Präsentations-Konfigurator
Bei Decks mit vielen Slides oder mehreren Vortrags-Längen: Slides kuratieren statt löschen. Zwei zusätzliche Dateien neben dem Deck:
**`slides-manifest.js`** — einzige Quelle der Wahrheit für Titel/Reihenfolge, von Deck **und** Konfigurator geladen:
```js
const SLIDE_MANIFEST = [
{ id: 1, title: "Titel", kapitel: "—", type: "content", locked: true },
{ id: 2, title: "Hook: …", kapitel: "—", type: "content" },
{ id: 3, title: "Kapitel 1 · …", kapitel: "…", type: "divider" },
// … ein Eintrag pro <section class="slide" data-slide-id="…">, gleiche Reihenfolge
];
const SLIDE_CONFIG_STORAGE_KEY = 'cancomDeckSlideConfig';
function loadSlideConfig() { /* liest Set aus localStorage, Fallback null */ }
function saveSlideConfig(enabledIdSet) { /* schreibt Set in localStorage */ }
```
**Im Deck:** jede `<section class="slide">` bekommt `data-slide-id="N"` in Dokumentreihenfolge (1-basiert). `SlidePresentation` liest die aktivierten IDs **zuerst aus dem URL-Parameter** `?slides=1,4,5,…`, erst danach aus `localStorage` als Fallback — `file://`-Storage wird zwischen einzelnen HTML-Dateien nicht überall geteilt (Safari trennt strikt pro Datei). Navigation (Pfeiltasten, Mausrad, Swipe) läuft über ein gefiltertes `visibleSlides`-Array statt über alle Slides:
```js
computeVisibleSlides() {
const all = Array.from(this.slides);
const enabled = this.resolveEnabledSlideIds();
if (!enabled) return all;
const filtered = all.filter((slide) => enabled.has(parseInt(slide.dataset.slideId, 10)));
return filtered.length ? filtered : all; // Sicherheitsnetz: nie leere Bühne
}
```
**`config.html`** — eigenständige Seite: Checkbox-Liste gruppiert nach Kapitel (`SLIDE_MANIFEST` nach `kapitel` gruppieren), Buttons „Alle an/aus", „Kapitel an/aus", live mitlaufender Zähler. Beim Wechsel zur Präsentation wird die Auswahl in die URL kodiert:
```js
function goToDeck() {
saveSlideConfig(enabled);
const ids = Array.from(enabled).sort((a, b) => a - b).join(',');
window.location.href = 'ki-verstehen.html?slides=' + encodeURIComponent(ids);
}
```
**Locked Slides:** Titel und Closing im Manifest mit `locked: true` markieren — Checkbox disabled, immer Teil der Auswahl. Ein Deck ohne Anfang oder Ende ist kein Deck mehr.