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

44 KiB
Raw Permalink Blame History

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 — CSS im Template
  2. Code-Slide mit Live-Demo — CSS im Template, JS mitkopieren
  3. SVG-Chart — CSS mitkopieren
  4. Feature-Grid mit CSS-Demos — Grid im Template, Demos mitkopieren
  5. Live-Daten via WebSocket — CSS + JS mitkopieren
  6. Zwei-Spalten-Abwägung — CSS im Template
  7. Kapitel-Trenner — CSS mitkopieren
  8. Begriffs-Tooltip — CSS + JS mitkopieren
  9. Mitmach-Spiel — CSS + JS mitkopieren
  10. Ambient-Sound-Button — CSS + JS mitkopieren
  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.

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

<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):

.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 &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:

/* 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.