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).
44 KiB
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
- Versus-Slide — CSS im Template
- Code-Slide mit Live-Demo — CSS im Template, JS mitkopieren
- SVG-Chart — CSS mitkopieren
- Feature-Grid mit CSS-Demos — Grid im Template, Demos mitkopieren
- Live-Daten via WebSocket — CSS + JS mitkopieren
- Zwei-Spalten-Abwägung — CSS im Template
- Kapitel-Trenner — CSS mitkopieren
- Begriffs-Tooltip — CSS + JS mitkopieren
- Mitmach-Spiel — CSS + JS mitkopieren
- Ambient-Sound-Button — CSS + JS mitkopieren
- 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.
<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. 4–5 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.
<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">=></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):
.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:
(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.
<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:
.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.
<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 <marquee>. 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:
/* 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.
<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:
.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:
(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.
<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.
<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:
.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.
<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:
.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:
<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:
(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).
<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:
.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:
(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.
<button class="ambient-toggle" id="ambientToggle" title="Sound (nur auf dieser Folie)">🔈</button>
CSS mitkopieren:
.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:
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:
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:
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:
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.