Zum Inhalt
← Preloader & Intro

Boot-Sequenz / System-Start-Intro (Retro-Interface)

Die Seite startet wie ein Rechner: Röhre an, Startprotokoll läuft durch, dann schaltet sich die echte Oberfläche ein.

Die Seite startet wie ein Computer, eine Konsole oder ein altes Spiel. Die Röhre schaltet sich ein (Linie öffnet sich zum Bild), Startzeilen laufen durch oder eine Disc dreht sich. Am Ende schaltet sich die eigentliche Oberfläche ein. Oft gibt es Ton, aber nur auf Wunsch.

Nostalgie an die späten 90er und frühen 2000er plus Erzählung: Die Ladezeit wird zur Geschichte. Passt zum Retro-Tech-Strang 2026 (Röhren, Pixel, Y2K). Bei technischen Produkten kann das Startprotokoll echte Informationen tragen.

Wann einsetzen

Bei Portfolios mit Spiel- oder Retro-Konzept, Gaming- und Musikprojekten und bei technischen Werkzeugen, deren Startprotokoll selbst etwas über das Produkt sagt.

Wann nicht

Bei gewöhnlichen Business-Seiten und überall, wo Besucher schnell an Inhalte wollen. Nur, wenn das Konzept es trägt.

Varianten

  • Terminal-Zeilen mit Zeitstempel und Blockcursor
  • BIOS-Look mit Speichertest und Fortschrittsbalken aus Blockzeichen
  • Drehende Disc oder Antenne mit Ecklabels
  • Röhren-Einschaltlinie als Rahmen für alle drei

Umsetzung

Terminal: Zeilen-Array, per Intervall (30–80 ms) als Zeile anhängen, Monospace, Cursor-Blinken per CSS steps(). Röhre einschalten: Container zuerst scaleY(0.005) scaleX(0), dann scaleX(1), dann scaleY(1), dazu ein kurzer Helligkeitsblitz. Weil ein dunkles Bild als Linie fast schwarz ist, braucht es einen eigenen leuchtenden Strahl. Ausschalten umgekehrt mit Nachleuchten. Ton nur nach Nutzerklick (Autoplay-Regeln) und mit sichtbarem Schalter, per Web Audio erzeugt. Länge strikt begrenzen, Überspringen per Knopf und Esc anbieten. Im Einsatz nur beim ersten Besuch pro Sitzung zeigen (sessionStorage); die Demo spielt es bei jedem Laden.

Robust umsetzen

Telefon. Gekürzte Sequenz.

Ohne Bewegung. Kein Flackern, kein Blitz. Direkt die Oberfläche.

Barrierefrei. Boot-Text aria-hidden, das Laden per aria-live ansagen. Blinken unter 3 Hz. Ton nie automatisch. Überspringen ist per Tastatur erreichbar und setzt den Fokus auf die Hauptüberschrift.

Code

<!DOCTYPE html>
<!--
  Boot-Sequenz / System-Start-Intro (Retro-Interface)
  Fundus · Preloader & Intro · https://cecodesign.de/lab/fundus/preloader-intro/boot-sequenz-intro/
  Eigenständige Datei. Bilder liegen hier unter bilder/ – eigene einsetzen.
-->
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Überflug · Boot-Sequenz-Intro · Fundus-Demo</title>
<script>document.documentElement.classList.add('js');if(matchMedia('(prefers-reduced-motion: reduce)').matches)document.documentElement.classList.add('reduziert')</script>
<style>
@font-face { font-family: 'Geist'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/geist-mono:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }

:root {
  --grund: #08090a; --panel: #161718; --panel-2: #1c1d1f; --text: #f7f8f8; --text-2: #d0d6e0; --meta: #8a8f98; --linie: #242728;
  --marke: #ffb224;                       /* einziger Markenton: Bernstein wie Phosphor alter Monitore */
  --ok: #59d499; --info: #57c1ff; --warn: #ff6161;
  --kurve: cubic-bezier(0.16, 1, 0.3, 1); /* expo.out: Röhre und Einblenden */
  --ui: cubic-bezier(0.25, 0.46, 0.45, 0.94); /* easeOutQuad, 150 ms: Hover */
  --rand: clamp(16px, 3vw, 40px);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
html { background: #000; color: var(--text); font-family: 'Geist', system-ui, sans-serif; font-size: 16px; -webkit-font-smoothing: antialiased; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; }
svg { display: block; }
.mono { font-family: 'Geist Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; }
.label { font: 500 11px/1.3 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--meta); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

/* ---------- Oberfläche: die eigentliche Seite ---------- */
.oberflaeche { position: fixed; inset: 0; background: var(--grund); overflow: hidden; transform-origin: 50% 50%; }
.oberflaeche[hidden] { display: block; visibility: hidden; }
.wrap { width: min(1200px, 100% - 2 * var(--rand)); margin-inline: auto; }
.kopf { height: 64px; border-bottom: 1px solid var(--linie); }
.kopf .wrap { height: 100%; display: flex; align-items: center; gap: 28px; }
.marke { display: inline-flex; align-items: center; gap: 10px; font-weight: 600; font-size: 17px; letter-spacing: -0.01em; }
.marke svg { width: 22px; height: 22px; }
.nav { display: flex; gap: 22px; font-size: 14px; color: var(--meta); }
.nav a { transition: color .15s var(--ui); }
.nav a:hover { color: var(--text); }
.kopf__rechts { margin-left: auto; display: flex; align-items: center; gap: 16px; font-size: 14px; }
.kopf__rechts > a:first-child { color: var(--meta); transition: color .15s var(--ui); }
.kopf__rechts > a:first-child:hover { color: var(--text); }
.knopf { display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 14px; border-radius: 8px; font-size: 14px; font-weight: 500; white-space: nowrap; transition: background-color .15s var(--ui), border-color .15s var(--ui), color .15s var(--ui); }
.knopf--marke { background: var(--marke); color: #120c00; }
.knopf--marke:hover { background: #ffc457; }
.knopf--rand { border: 1px solid var(--linie); color: var(--text-2); }
.knopf--rand:hover { border-color: #3a3e42; color: var(--text); }
.knopf:focus-visible, .nav a:focus-visible { outline: 2px solid var(--marke); outline-offset: 2px; }

.held { padding-top: clamp(28px, 7vh, 72px); }
.neu { display: inline-flex; align-items: center; gap: 8px; height: 28px; padding: 0 12px; border: 1px solid var(--linie); border-radius: 999px; font-size: 13px; color: var(--text-2); }
.neu i { width: 6px; height: 6px; border-radius: 50%; background: var(--marke); }
.h1 { margin-top: 20px; font-size: clamp(2.5rem, 1.6rem + 3.6vw, 4.5rem); font-weight: 600; letter-spacing: -0.022em; line-height: 1; max-width: 13ch; text-wrap: balance; }
.unter { margin-top: 18px; max-width: 54ch; font-size: 1.0625rem; line-height: 1.6; letter-spacing: -0.006em; color: var(--meta); }
.unter b { color: var(--text-2); font-weight: 500; }
.ctas { margin-top: 26px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px 12px; }
.ctas .knopf { height: 40px; padding: 0 18px; }
.ctas .label { margin-left: 8px; letter-spacing: .08em; }

/* Produkt-UI als echtes HTML */
.app { margin-top: clamp(28px, 6vh, 56px); border: 1px solid var(--linie); border-radius: 12px; background: var(--panel); overflow: hidden; box-shadow: 0 30px 80px rgba(0, 0, 0, .45); }
.app__leiste { display: flex; align-items: center; gap: 14px; height: 44px; padding: 0 14px; border-bottom: 1px solid var(--linie); font-size: 13px; color: var(--meta); }
.app__leiste b { color: var(--text); font-weight: 500; }
.tabs { display: flex; gap: 2px; padding: 3px; border-radius: 8px; background: var(--grund); }
.tabs span { padding: 3px 10px; border-radius: 6px; }
.tabs span:first-child { background: var(--panel-2); color: var(--text); }
.suche { margin-left: auto; display: flex; align-items: center; gap: 8px; height: 28px; padding: 0 10px; border: 1px solid var(--linie); border-radius: 7px; }
.suche kbd { font: 500 11px/1 'Geist Mono', monospace; padding: 2px 5px; border: 1px solid var(--linie); border-radius: 4px; }
.app__koerper { display: grid; grid-template-columns: 150px minmax(0, 1fr) 280px; }
.stationen { border-right: 1px solid var(--linie); font-size: 13px; }
.stationen div, .zeit__reihe { height: 46px; display: flex; align-items: center; border-bottom: 1px solid var(--linie); }
.stationen div { padding: 0 14px; gap: 8px; color: var(--text-2); }
.stationen div small { color: var(--meta); font: 500 10px/1 'Geist Mono', monospace; }
.stationen .kopfzeile, .zeit__kopf { height: 30px; color: var(--meta); font: 500 10px/1 'Geist Mono', monospace; letter-spacing: .08em; text-transform: uppercase; }
.zeit { position: relative; overflow: hidden; }   /* Pässe nach 18:00 laufen aus dem Bild, statt in die Nachbarspalte */
.zeit__kopf { display: grid; grid-template-columns: repeat(6, 1fr); align-items: center; border-bottom: 1px solid var(--linie); }
.zeit__kopf span { padding-left: 8px; border-left: 1px solid var(--linie); height: 100%; display: flex; align-items: center; }
.zeit__reihe { position: relative; background: repeating-linear-gradient(90deg, transparent 0 calc(100% / 6 - 1px), rgba(255, 255, 255, .035) calc(100% / 6 - 1px) calc(100% / 6)); }
.pass { position: absolute; top: 9px; height: 27px; display: flex; align-items: center; gap: 6px; padding: 0 8px; border-radius: 6px; font: 500 11px/1 'Geist Mono', monospace; white-space: nowrap; overflow: hidden; background: rgba(87, 193, 255, .15); color: #9fdcff; border: 1px solid rgba(87, 193, 255, .25); }
.pass.ok { background: rgba(89, 212, 153, .14); color: #9be8c2; border-color: rgba(89, 212, 153, .25); }
.pass.naechster { background: var(--marke); color: #120c00; border-color: var(--marke); }
.pass.konflikt { --min: 158px; background: rgba(255, 97, 97, .14); color: #ffaaaa; border-color: rgba(255, 97, 97, .3); }
.jetzt { position: absolute; top: 0; bottom: 0; width: 1px; background: var(--marke); }
.jetzt::before { content: 'jetzt'; position: absolute; top: 10px; right: 6px; font: 500 10px/1 'Geist Mono', monospace; color: var(--marke); letter-spacing: .06em; text-transform: uppercase; }
.naechster-kontakt { border-left: 1px solid var(--linie); padding: 14px 16px; display: grid; align-content: start; gap: 10px; font-size: 13px; }
.countdown { font: 500 34px/1 'Geist Mono', monospace; letter-spacing: -0.02em; color: var(--text); }
.daten { display: grid; grid-template-columns: auto 1fr; gap: 7px 12px; }
.daten dt { color: var(--meta); white-space: nowrap; }
.daten dd { white-space: nowrap; text-align: right; color: var(--text-2); font-family: 'Geist Mono', monospace; font-size: 12px; }
.naechster-kontakt .knopf { justify-self: start; height: 30px; font-size: 13px; }

/* ---------- Röhre: Boot-Bildschirm ---------- */
.roehre { position: fixed; inset: 0; z-index: 20; background: #050606; color: #c9cdd2; overflow: hidden; transform-origin: 50% 50%; will-change: transform, filter; }
.roehre[hidden] { display: none; }
.roehre::after { content: ''; position: absolute; inset: 0; pointer-events: none;
  background: repeating-linear-gradient(180deg, rgba(255, 255, 255, .028) 0 1px, transparent 1px 3px), radial-gradient(120% 90% at 50% 50%, transparent 55%, rgba(0, 0, 0, .55) 100%); }
.schirm { position: absolute; inset: 0; padding: clamp(18px, 4vh, 44px) var(--rand); font: 400 clamp(11px, 1.45vw, 19px)/1.6 'Geist Mono', ui-monospace, monospace; }
.schirm .z { white-space: pre; overflow: hidden; text-overflow: clip; }
.schirm .t { color: #5d636b; }
.schirm .a { color: var(--marke); text-shadow: 0 0 10px rgba(255, 178, 36, .35); }
.schirm .g { color: var(--ok); }
.schirm .w { color: #f7f8f8; }
.schirm .m { color: var(--meta); }
.cursor { display: inline-block; width: .6em; height: 1.1em; vertical-align: -.18em; background: var(--marke); animation: blinken 1s steps(1) infinite; }
@keyframes blinken { 50% { opacity: 0; } }
.strahl { position: fixed; left: 0; right: 0; top: 50%; z-index: 22; height: 2px; margin-top: -1px; background: #fff; box-shadow: 0 0 10px 2px rgba(255, 255, 255, .75), 0 0 46px 8px rgba(255, 178, 36, .28); opacity: 0; transform: scaleX(0); pointer-events: none; }
.nachleuchten { position: fixed; left: 50%; top: 50%; z-index: 21; width: 6px; height: 6px; margin: -3px 0 0 -3px; border-radius: 50%; background: #fff; box-shadow: 0 0 18px 6px rgba(255, 255, 255, .55); opacity: 0; pointer-events: none; }

/* BIOS */
.bios { display: grid; grid-template-rows: auto 1fr auto; height: 100%; gap: 18px; }
.bios__kopf { display: flex; justify-content: space-between; gap: 24px; }
.bios__logo { font: 400 clamp(8px, .8vw, 11px)/1 'Geist Mono', monospace; color: var(--marke); white-space: pre; text-shadow: 0 0 10px rgba(255, 178, 36, .35); }
.bios__fuss { display: flex; gap: 40px; padding: 10px 170px 0 0; border-top: 1px solid #1f2224; color: var(--meta); }
.balken { color: var(--marke); }

/* Antenne (die „Disc“-Variante) */
.antenne { height: 100%; display: grid; place-items: center; }
.antenne__mitte { display: grid; justify-items: center; gap: 22px; }
.schuessel { width: clamp(130px, 26vmin, 240px); aspect-ratio: 1; perspective: 600px; }
.schuessel__dreh { width: 100%; height: 100%; transform-style: preserve-3d; animation: drehen 5s linear infinite; }
@keyframes drehen { to { transform: rotateY(360deg); } }
.schuessel svg { width: 100%; height: 100%; overflow: visible; }
.antenne__status { min-height: 1.6em; color: #f7f8f8; text-align: center; }
.ecke { position: absolute; color: var(--meta); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; line-height: 1.5; }
.ecke b { color: var(--marke); font-weight: 400; }
.ecke--ol { left: var(--rand); top: clamp(18px, 4vh, 44px); }
.ecke--or { right: var(--rand); top: clamp(18px, 4vh, 44px); text-align: right; }
.ecke--ul { left: var(--rand); bottom: clamp(18px, 4vh, 44px); }
.ecke--ur { right: var(--rand); bottom: clamp(18px, 4vh, 44px); text-align: right; }
.linie-fort { width: clamp(160px, 24vw, 300px); height: 2px; background: #1f2224; position: relative; overflow: hidden; }
.linie-fort i { position: absolute; inset: 0; background: var(--marke); transform-origin: 0 50%; transform: scaleX(0); }

/* Überspringen: immer sichtbar während des Intros */
.skip { position: fixed; z-index: 30; right: var(--rand); bottom: clamp(16px, 3vh, 28px); display: inline-flex; align-items: center; gap: 10px; height: 32px; padding: 0 12px; border: 1px solid #2c3033; border-radius: 8px; background: rgba(5, 6, 6, .8); color: var(--text-2); font: 500 12px/1 'Geist Mono', monospace; cursor: pointer; transition: border-color .15s var(--ui), color .15s var(--ui); }
.skip kbd { font: inherit; font-size: 10px; padding: 2px 5px; border: 1px solid #2c3033; border-radius: 4px; color: var(--meta); }
.skip:hover { color: #fff; border-color: #4a4f54; }
.skip:focus-visible { outline: 2px solid var(--marke); outline-offset: 2px; }
.skip[hidden] { display: none; }

/* ---------- Demo-Steuerung ---------- */
.dock { position: fixed; z-index: 40; left: 50%; bottom: 14px; translate: -50% 0; width: max-content; max-width: calc(100% - 24px); display: grid; gap: 8px; padding: 10px 12px; border-radius: 12px; background: rgba(22, 23, 24, .92); border: 1px solid var(--linie); -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px); }
.dock__zeile { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.modi { display: flex; gap: 2px; padding: 3px; border-radius: 8px; background: var(--grund); }
.modi button, .dock__knopf { height: 28px; padding: 0 10px; border: 0; border-radius: 6px; background: none; color: var(--meta); font: 500 11px/1 'Geist Mono', monospace; letter-spacing: .08em; text-transform: uppercase; cursor: pointer; transition: background-color .15s var(--ui), color .15s var(--ui); }
.modi button:hover, .dock__knopf:hover { color: var(--text); }
.modi button[aria-pressed='true'] { background: var(--panel-2); color: var(--text); }
.dock__knopf { border: 1px solid var(--linie); }
.dock__knopf[aria-pressed='true'] { color: var(--marke); border-color: rgba(255, 178, 36, .4); }
.modi button:focus-visible, .dock__knopf:focus-visible { outline: 2px solid var(--marke); outline-offset: 2px; }
.werte { font: 400 10.5px/1.6 'Geist Mono', monospace; color: var(--meta); }
.werte span { display: block; white-space: nowrap; }
.werte b { color: var(--text-2); font-weight: 500; }

.angehalten .cursor, .angehalten .schuessel__dreh { animation-play-state: paused; }

/* Reduzierte Bewegung: kein Flackern, kein Aufblitzen, direkt die Oberfläche */
:root.reduziert * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
:root.reduziert .cursor { animation: none; }

/* Galerie-Vorschau: nur die Bühne */
.vorschau body { overflow: hidden; }

@media (hover: none) {
  .modi button, .dock__knopf { height: 34px; }
  .skip { height: 40px; }
}
@media (max-width: 1100px) {
  .nav a:nth-child(n+4) { display: none; }
  .app__koerper { grid-template-columns: 120px minmax(0, 1fr) 264px; }
  .pass.konflikt { --min: 104px; }
  .pass .zusatz { display: none; }
}
@media (max-width: 760px) {
  .nav, .kopf__rechts > a:first-child { display: none; }
  .app__koerper { grid-template-columns: minmax(0, 1fr); }
  .stationen, .zeit { display: none; }
  .naechster-kontakt { border-left: 0; }
  .app__leiste .tabs, .app__leiste .suche { display: none; }
  .ctas .label { display: none; }
  .schirm .lang { display: none; }
  .schirm .z { white-space: pre-wrap; }
  .bios__logo { display: none; }
  .ecke--or, .ecke--ur { display: none; }
  .werte .lang { display: none; }
  .werte span { white-space: normal; }
}
@media (max-width: 640px) {
  .h1 { font-size: 2.5rem; }
  .unter { font-size: 1rem; }
  .kopf__rechts .knopf { height: 32px; padding: 0 12px; }
}
</style>
</head>
<body>
<!-- Die eigentliche Seite: SaaS für Bodenstationen, erfunden -->
<div class="oberflaeche" data-oberflaeche>
  <header class="kopf"><div class="wrap">
    <a class="marke" href="#" aria-label="Überflug, Startseite"><svg viewBox="0 0 22 22" aria-hidden="true"><circle cx="11" cy="11" r="3.2" fill="#ffb224"/><ellipse cx="11" cy="11" rx="9.5" ry="4.2" fill="none" stroke="#f7f8f8" stroke-width="1.4" transform="rotate(-28 11 11)"/><circle cx="19" cy="6.6" r="1.6" fill="#f7f8f8"/></svg>Überflug</a>
    <nav class="nav" aria-label="Hauptnavigation"><a href="#">Produkt</a><a href="#">Bodenstationen</a><a href="#">Preise</a><a href="#">Doku</a><a href="#">Changelog</a></nav>
    <div class="kopf__rechts"><a href="#">Anmelden</a><a class="knopf knopf--marke" href="#">Kostenlos testen</a></div>
  </div></header>
  <main class="wrap held">
    <a class="neu" href="#" data-ein><i></i>Neu: Konfliktprüfung über mehrere Missionen →</a>
    <h1 class="h1" data-ein tabindex="-1">Jeder Überflug, sauber eingeplant.</h1>
    <p class="unter" data-ein>Überflug plant die Kontakte Ihrer Satelliten mit <b>38 Bodenstationen</b>, löst Konflikte automatisch und schickt signierte Kommandos rechtzeitig raus.</p>
    <div class="ctas" data-ein><a class="knopf knopf--marke" href="#">Kostenlos testen</a><a class="knopf knopf--rand" href="#">Demo buchen</a><span class="label">1 bis 200 Satelliten · Rechenzentrum Frankfurt</span></div>
    <section class="app" data-ein aria-label="Ausschnitt aus der Anwendung: Kontaktplan">
      <div class="app__leiste"><b>Kontaktplan</b><span class="mono">Sa 01.11.2026 · UTC</span><div class="tabs"><span>Heute</span><span>Woche</span></div><div class="suche"><span>Satellit, Station …</span><kbd>⌘K</kbd></div></div>
      <div class="app__koerper">
        <div class="stationen">
          <div class="kopfzeile">Station</div>
          <div>Griesheim <small>DE</small></div><div>Weilheim <small>DE</small></div><div>Kiruna <small>SE</small></div><div>Svalbard <small>NO</small></div><div>Hartebeesthoek <small>ZA</small></div>
        </div>
        <div class="zeit" data-zeit>
          <div class="zeit__kopf"><span>12:00</span><span>13:00</span><span>14:00</span><span>15:00</span><span>16:00</span><span>17:00</span></div>
        </div>
        <aside class="naechster-kontakt" aria-label="Nächster Kontakt">
          <span class="label">Nächster Kontakt</span>
          <span class="countdown" data-countdown aria-live="off">00:04:12</span>
          <dl class="daten">
            <dt>Satellit</dt><dd>HEINER-3</dd>
            <dt>Station</dt><dd>Griesheim · 13 m</dd>
            <dt>AOS / LOS</dt><dd>14:07:22–14:18:40</dd>
            <dt>Max. Elevation</dt><dd>61,4°</dd>
            <dt>Band</dt><dd>S-Band · 2.245 MHz</dd>
            <dt>Kommandos</dt><dd>12 signiert</dd>
          </dl>
          <a class="knopf knopf--rand" href="#">Kommandos prüfen</a>
        </aside>
      </div>
    </section>
  </main>
</div>

<!-- Röhre: Boot-Bildschirm über der Seite. Für Screenreader ohne Inhalt (aria-hidden), Ansage separat. -->
<div class="roehre" data-roehre aria-hidden="true" hidden><div class="schirm" data-schirm></div></div>
<div class="strahl" data-strahl aria-hidden="true"></div>
<div class="nachleuchten" data-nachleuchten aria-hidden="true"></div>
<button class="skip" type="button" data-skip hidden>Überspringen <kbd>Esc</kbd></button>
<p class="sr" aria-live="polite" data-ansage></p>

<aside class="dock" aria-label="Demo-Steuerung">
  <div class="dock__zeile">
    <div class="modi" role="group" aria-label="Variante der Boot-Sequenz">
      <button type="button" data-variante="terminal" aria-pressed="true">Terminal</button>
      <button type="button" data-variante="bios" aria-pressed="false">BIOS</button>
      <button type="button" data-variante="antenne" aria-pressed="false">Antenne</button>
    </div>
    <button type="button" class="dock__knopf" data-neu>Neu starten</button>
    <button type="button" class="dock__knopf" data-ton aria-pressed="false">Ton aus</button>
  </div>
  <p class="werte" data-werte></p>
</aside>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = s => document.querySelector(s);
  const ober = $('[data-oberflaeche]'), roehre = $('[data-roehre]'), schirm = $('[data-schirm]');
  const strahl = $('[data-strahl]');
  const nach = $('[data-nachleuchten]'), skip = $('[data-skip]'), ansage = $('[data-ansage]'), werteFeld = $('[data-werte]');
  const kurz = () => matchMedia('(max-width: 760px)').matches;   // Telefon: gekürzte Sequenz

  /* ---------- Werte ----------
     Steckbrief: Zeilen 30–80 ms, gesamt 1500–3000 ms, steps()/linear für das Terminal,
     expo.out für die Röhre (scaleX 0 → 1, dann scaleY 0,005 → 1 mit Helligkeits-Blitz). */
  const ZEILE = 45;          // ms je Zeile
  const AN = { x: 200, y: 360, hell: 2.4 };   // Röhre einschalten
  const AUS = { y: 180, x: 120 };             // Röhre ausschalten
  const EIN = { dauer: 500, y: 6, stagger: 40 }; // Oberfläche setzt sich
  const HALTEN = 200;
  const LINIE = 0.005;       // scaleY der Einschaltlinie
  let variante = 'terminal';

  function bezier(x1, y1, x2, y2) {
    const k = (a1, a2) => [1 - 3 * a2 + 3 * a1, 3 * a2 - 6 * a1, 3 * a1];
    const [ax, bx, cx] = k(x1, x2), [ay, by, cy] = k(y1, y2);
    const X = t => ((ax * t + bx) * t + cx) * t, Y = t => ((ay * t + by) * t + cy) * t, dX = t => (3 * ax * t + 2 * bx) * t + cx;
    return x => {
      if (x <= 0) return 0; if (x >= 1) return 1;
      let t = x;
      for (let i = 0; i < 8; i++) { const d = dX(t); if (Math.abs(d) < 1e-6) break; const n = t - (X(t) - x) / d; if (n < 0 || n > 1) break; t = n; }
      if (Math.abs(X(t) - x) > 1e-4) { let lo = 0, hi = 1; for (let i = 0; i < 30; i++) { t = (lo + hi) / 2; if (X(t) < x) lo = t; else hi = t; } }
      return Y(t);
    };
  }
  const EXPO = bezier(0.16, 1, 0.3, 1);

  /* ---------- Uhr: pausierbar ---------- */
  let pausiert = false, lauf = 0;
  // t: Lauf-Nummer (bricht beim nächsten Lauf ab) oder true (läuft immer zu Ende)
  function tween(ms, fn, t) {
    return new Promise(res => {
      let z = 0, letzte = performance.now();
      const s = now => {
        if (t !== true && t !== lauf) return res(false);
        const dt = Math.min(64, now - letzte); letzte = now;
        if (!pausiert) z += dt;
        const p = Math.min(1, z / ms);
        fn(p);
        if (p < 1) requestAnimationFrame(s); else res(true);
      };
      fn(0); requestAnimationFrame(s);
    });
  }
  const warte = (ms, t) => tween(ms, () => {}, t);

  /* ---------- Ton: Web Audio, erst nach Klick, standardmäßig aus ---------- */
  let ton = false, ac = null;
  function piep(f = 1800, d = .022, g = .02, typ = 'square') {
    if (!ton || !ac || pausiert) return;
    const o = ac.createOscillator(), v = ac.createGain(), t0 = ac.currentTime;
    o.type = typ; o.frequency.setValueAtTime(f, t0);
    v.gain.setValueAtTime(g, t0); v.gain.exponentialRampToValueAtTime(.0001, t0 + d);
    o.connect(v).connect(ac.destination); o.start(t0); o.stop(t0 + d + .02);
  }
  function wumm() {   // Einschalten: tiefer Abfall wie bei einer Röhre
    if (!ton || !ac) return;
    const o = ac.createOscillator(), v = ac.createGain(), t0 = ac.currentTime;
    o.type = 'sine'; o.frequency.setValueAtTime(120, t0); o.frequency.exponentialRampToValueAtTime(38, t0 + .28);
    v.gain.setValueAtTime(.16, t0); v.gain.exponentialRampToValueAtTime(.0001, t0 + .32);
    o.connect(v).connect(ac.destination); o.start(t0); o.stop(t0 + .34);
  }

  /* ---------- Röhre ---------- */
  function setzeRoehre(el, sx, sy, hell) {
    el.style.transform = `scale(${sx.toFixed(4)}, ${sy.toFixed(4)})`;
    el.style.filter = hell > 1.001 ? `brightness(${hell.toFixed(3)})` : '';
  }
  // Der leuchtende Strahl: weiße Linie mit Glühen, solange das Bild auf eine Zeile zusammengezogen ist
  function setzeStrahl(sx, op) { strahl.style.transform = `scaleX(${sx.toFixed(4)})`; strahl.style.opacity = Math.max(0, Math.min(1, op)).toFixed(3); }
  async function einschalten(el, t) {
    wumm();
    setzeRoehre(el, 0, LINIE, AN.hell);
    if (!(await tween(AN.x, p => { const e = EXPO(p); setzeRoehre(el, e, LINIE, AN.hell); setzeStrahl(e, 1); }, t))) return false;
    const ok = await tween(AN.y, p => { const e = EXPO(p); setzeRoehre(el, 1, LINIE + (1 - LINIE) * e, AN.hell - (AN.hell - 1) * e); setzeStrahl(1, 1 - e * 2.5); }, t);
    if (ok) { el.style.transform = ''; el.style.filter = ''; }
    setzeStrahl(0, 0);
    return ok;
  }
  async function ausschalten(el, t) {
    if (!(await tween(AUS.y, p => { const e = EXPO(p); setzeRoehre(el, 1, 1 - (1 - LINIE) * e, 1 + (AN.hell - 1) * e); setzeStrahl(1, e * 1.4 - .4); }, t))) return false;
    if (!(await tween(AUS.x, p => { const e = EXPO(p); setzeRoehre(el, 1 - e, LINIE, AN.hell); setzeStrahl(1 - e, 1); }, t))) return false;
    setzeStrahl(0, 0);
    tween(360, p => { nach.style.opacity = (1 - EXPO(p)).toFixed(3); }, true);   // Nachleuchten des Punkts
    return true;
  }

  /* ---------- Inhalte der drei Varianten ---------- */
  const T = (s, k) => `<span class="t">[${s}]</span> ${k}`;
  const TERMINAL = [
    ['<span class="a">ÜBERFLUG</span> <span class="w">Bodensegment-Kern 4.2.1</span> <span class="m">(build 2611-a3f9, linux-x86_64)</span>', 1],
    ['<span class="m">(c) 2026 Überflug GmbH, Darmstadt</span>', 1],
    ['', 0],
    [T('  0.0412', 'Speichertest ................ <span class="g">32768 MB OK</span>'), 1, 120],
    [T('  0.1187', 'Uhr synchronisiert über GNSS, Abweichung <span class="w">0,4 µs</span>'), 1],
    [T('  0.2050', 'Bahndaten geladen: <span class="w">1.284 Objekte</span>, Stand 01.11. 06:00 UTC'), 1, 80],
    [T('  0.3311', 'Station Griesheim ........... <span class="g">verbunden</span>  23 ms'), 1],
    [T('  0.3402', 'Station Weilheim ............ <span class="g">verbunden</span>  31 ms'), 0],
    [T('  0.3518', 'Station Kiruna .............. <span class="g">verbunden</span>  58 ms'), 0],
    [T('  0.3604', 'Station Svalbard ............ <span class="g">verbunden</span>  64 ms'), 0],
    [T('  0.3711', 'Station Hartebeesthoek ...... <span class="m">wartet auf Sichtfenster</span>'), 0],
    [T('  0.5126', 'Konfliktprüfung: 46 Kontakte heute, <span class="g">0 Überschneidungen</span>'), 1, 80],
    [T('  0.5940', 'Kommandos signiert: <span class="w">12 bereit</span> für HEINER-3'), 1],
    [T('  0.6305', 'Nächster Kontakt: <span class="a">HEINER-3 über Griesheim in 00:04:12</span>'), 1],
    ['', 0],
    ['<span class="w">Oberfläche wird geladen</span> <span class="cursor"></span>', 1]
  ];
  const zeile = h => { const d = document.createElement('div'); d.className = 'z'; d.innerHTML = h || ' '; schirm.appendChild(d); return d; };

  async function terminal(t) {
    schirm.innerHTML = '';
    const z = kurz() ? TERMINAL.filter(x => x[1]) : TERMINAL;
    for (const [h, , extra] of z) {
      zeile(h);
      if (h) piep(1700 + Math.random() * 300);
      if (!(await warte(ZEILE + (extra || 0), t))) return false;
    }
    return true;
  }

  const LOGO = [
    '  ▄▄████▄▄  ',
    ' ██▀ ▄▄ ▀██ ▄█',
    ' ██  ██  ██▀ ',
    ' ▀██▄▄▄▄██▀  ',
    '   ▀▀▀▀▀▀    '
  ].join('\n');
  const MODULE = ['kern', 'uhr-gnss', 'bahndaten', 'stationen', 'antennen', 'konflikte', 'kommandos', 'signatur', 'telemetrie', 'archiv', 'api', 'oberflaeche'];
  async function bios(t) {
    schirm.innerHTML = `<div class="bios">
      <div>
        <div class="bios__kopf"><div>
          <div class="z"><span class="a">ÜBERFLUG BIOS 4.2</span>  <span class="m">Bodenrechner BR-07</span></div>
          <div class="z m">Copyright (c) 2026 Überflug GmbH, Darmstadt</div>
        </div><div class="bios__logo">${LOGO}</div></div>
        <div class="z"> </div>
        <div class="z">Prozessor   : <span class="w">2 × 32 Kerne, 3,1 GHz</span></div>
        <div class="z">Speicher    : <span class="w" data-mb>0</span> MB <span data-mbok></span></div>
        <div class="z lang">Uhrquelle   : <span class="w">GNSS, PPS an Port 2</span></div>
        <div class="z">Antennen    : <span class="w">5 erkannt</span>, 1 wartet</div>
        <div class="z"> </div>
        <div class="z">Module      : <span class="balken" data-balken></span> <span class="w" data-n></span></div>
        <div class="z m" data-mod> </div>
      </div>
      <div></div>
      <div class="bios__fuss z"><span>ESC = Überspringen</span><span class="lang">ENTF = Einstellungen</span><span class="lang">F12 = Startprotokoll</span></div>
    </div>`;
    const mb = schirm.querySelector('[data-mb]'), mbok = schirm.querySelector('[data-mbok]');
    const balken = schirm.querySelector('[data-balken]'), n = schirm.querySelector('[data-n]'), mod = schirm.querySelector('[data-mod]');
    // Speicher zählt linear hoch (steps), danach Module als Blockzeichen-Balken
    if (!(await tween(kurz() ? 300 : 480, p => { mb.textContent = String(Math.round(32768 * p)).padStart(5, ' '); }, t))) return false;
    mbok.innerHTML = '<span class="g">OK</span>'; piep(2200, .04, .025);
    const B = 18, N = kurz() ? 6 : MODULE.length, liste = kurz() ? MODULE.filter((_, i) => i % 2) : MODULE;
    for (let i = 1; i <= N; i++) {
      const voll = Math.round(B * i / N);
      balken.textContent = '█'.repeat(voll) + '░'.repeat(B - voll);
      n.textContent = `${String(i).padStart(2, '0')}/${String(N).padStart(2, '0')}`;
      mod.textContent = `  lade ${liste[i - 1]}.mod`;
      piep(1500 + i * 40);
      if (!(await warte(ZEILE + 20, t))) return false;
    }
    mod.innerHTML = '  <span class="a">alle Module geladen, starte Oberfläche</span> <span class="cursor"></span>';
    return warte(160, t);
  }

  const SCHUESSEL = `<svg viewBox="-50 -50 100 100" aria-hidden="true">
      <ellipse cx="0" cy="-6" rx="40" ry="40" fill="#141618" stroke="#ffb224" stroke-width="1.4"/>
      <ellipse cx="0" cy="-6" rx="28" ry="28" fill="none" stroke="#ffb224" stroke-opacity=".35" stroke-width="1"/>
      <ellipse cx="0" cy="-6" rx="15" ry="15" fill="none" stroke="#ffb224" stroke-opacity=".25" stroke-width="1"/>
      <path d="M-26 -32 L0 -6 L26 -32 M0 -6 L0 -46" stroke="#c9cdd2" stroke-width="1.2" fill="none"/>
      <circle cx="0" cy="-6" r="3.4" fill="#ffb224"/>
      <path d="M-8 34 L0 30 L8 34 L6 46 L-6 46 Z" fill="#2a2d30"/>
    </svg>`;
  const STATUS = ['Antenne richtet sich aus', 'Bahndaten für HEINER-3 geladen', 'Signal gesucht: S-Band 2.245 MHz', 'Träger gefunden, Uhr synchron', 'Verbindung steht'];
  async function antenne(t) {
    schirm.innerHTML = `<div class="antenne">
      <div class="ecke ecke--ol">Überflug · Bodensegment 4.2<br>Ziel <b>HEINER-3</b></div>
      <div class="ecke ecke--or">Az <b data-az>212,4°</b><br>El <b data-el>18,9°</b></div>
      <div class="ecke ecke--ul">Station Griesheim<br>49,87° N · 8,58° O</div>
      <div class="antenne__mitte">
        <div class="schuessel"><div class="schuessel__dreh">${SCHUESSEL}</div></div>
        <div class="antenne__status" data-status></div>
        <div class="linie-fort"><i data-fort></i></div>
      </div>
    </div>`;
    const st = schirm.querySelector('[data-status]'), fort = schirm.querySelector('[data-fort]'), az = schirm.querySelector('[data-az]'), el = schirm.querySelector('[data-el]');
    const liste = kurz() ? STATUS.filter((_, i) => i !== 1 && i !== 3) : STATUS, schritt = kurz() ? 220 : 210;
    const gesamt = liste.length * schritt;
    tween(gesamt, p => {
      fort.style.transform = `scaleX(${p.toFixed(3)})`;
      az.textContent = (212.4 + 31.7 * p).toFixed(1).replace('.', ',') + '°';
      el.textContent = (18.9 + 42.5 * p).toFixed(1).replace('.', ',') + '°';
    }, t);
    for (const s of liste) {
      st.textContent = s; piep(1300, .03, .018, 'sine');
      if (!(await warte(schritt, t))) return false;
    }
    st.innerHTML = '<span class="a">Verbindung steht</span>';
    return warte(120, t);
  }
  const VARIANTEN = { terminal, bios, antenne };

  /* ---------- Oberfläche: Zeitachse und Countdown ---------- */
  const PAESSE = [   // Station, Start (Stunden ab 12:00), Dauer, Satellit, Art
    [0, 0.35, 0.22, 'HEINER-1', 'ok'], [0, 2.12, 0.19, 'HEINER-3', 'naechster'], [0, 4.05, 0.2, 'MATHILDE-1', ''],
    [1, 0.9, 0.25, 'LICHTWIESE-2', 'ok'], [1, 2.6, 0.18, 'HEINER-4', ''], [1, 4.8, 0.22, 'HEINER-1', ''],
    [2, 0.15, 0.3, 'MATHILDE-1', 'ok'], [2, 1.7, 0.28, 'HEINER-4', 'ok'], [2, 3.3, 0.26, 'LICHTWIESE-2', ''], [2, 5.1, 0.24, 'HEINER-3', ''],
    [3, 1.05, 0.32, 'HEINER-3', 'ok'], [3, 2.75, 0.3, 'MATHILDE-1', 'konflikt'], [3, 4.4, 0.28, 'HEINER-1', ''],
    [4, 3.0, 0.24, 'LICHTWIESE-2', ''], [4, 5.2, 0.26, 'HEINER-4', '']
  ];
  function baueZeit() {
    const z = $('[data-zeit]');
    z.querySelectorAll('.zeit__reihe, .jetzt').forEach(e => e.remove());
    for (let s = 0; s < 5; s++) {
      const r = document.createElement('div'); r.className = 'zeit__reihe';
      PAESSE.filter(p => p[0] === s).forEach(([, a, d, n, art]) => {
        const b = document.createElement('span');
        b.className = 'pass ' + art;
        b.style.left = (a / 6 * 100).toFixed(2) + '%'; b.style.width = `max(${(d / 6 * 100).toFixed(2)}%, var(--min, 104px))`;
        b.innerHTML = art === 'konflikt' ? n + '<span class="zusatz">· verschoben</span>' : n;
        r.appendChild(b);
      });
      z.appendChild(r);
    }
    const j = document.createElement('div'); j.className = 'jetzt'; j.style.left = ((2 + 3 / 60 + 10 / 3600) / 6 * 100).toFixed(2) + '%';
    z.appendChild(j);
  }
  let rest = 252, uhrCd = null;
  function countdown() {
    clearInterval(uhrCd); rest = 252;
    const cd = $('[data-countdown]');
    const zeig = () => { const m = Math.floor(rest / 60), s = rest % 60; cd.textContent = `00:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; };
    zeig();
    uhrCd = setInterval(() => { if (!pausiert && rest > 0) { rest--; zeig(); } }, 1000);
  }

  /* ---------- Ablauf ---------- */
  let gemessen = null;
  function zeigeWerte() {
    const v = { terminal: 'Terminal', bios: 'BIOS', antenne: 'Antenne' }[variante];
    let t;
    if (reduziert()) t = '<span><b>reduziert</b> kein Intro, kein Flackern, kein Blitz · Oberfläche sofort</span>';
    else t = `<span><b>${v}</b> Zeilen ${ZEILE} ms (linear) · Cursor steps(1) 1 s · Röhre expo.out cubic-bezier(0.16, 1, 0.3, 1)</span>`
      + `<span class="lang"><b>an</b> scaleX 0 → 1 ${AN.x} ms, scaleY ${LINIE} → 1 ${AN.y} ms, brightness ${AN.hell} → 1 · <b>aus</b> scaleY ${AUS.y} ms, scaleX ${AUS.x} ms</span>`
      + `<span><b>Oberfläche</b> y ${EIN.y} px → 0, ${EIN.dauer} ms, Versatz ${EIN.stagger} ms · <b>gesamt</b> ${gemessen ? (gemessen / 1000).toFixed(2).replace('.', ',') + ' s gemessen' : 'läuft …'}${kurz() ? ' (Telefon: gekürzt)' : ''}</span>`;
    werteFeld.innerHTML = t;
  }
  function zeigeOberflaeche(sofort, t) {
    roehre.hidden = true; skip.hidden = true; setzeStrahl(0, 0);
    ober.style.visibility = ''; ober.style.transform = ''; ober.style.filter = '';
    const teile = [...ober.querySelectorAll('[data-ein]')];
    teile.forEach(e => { e.style.opacity = ''; e.style.transform = ''; });
    countdown();
    if (sofort) return;
    teile.forEach((e, i) => {
      e.style.opacity = 0;
      warte(i * EIN.stagger, t).then(ok => ok && tween(EIN.dauer, p => { const k = EXPO(p); e.style.opacity = k.toFixed(3); e.style.transform = `translateY(${((1 - k) * EIN.y).toFixed(2)}px)`; }, t));
    });
  }
  let laeuft = false;
  async function intro(opts = {}) {
    const t = ++lauf;
    if (reduziert()) { zeigeOberflaeche(true, t); ansage.textContent = 'Überflug geladen.'; return true; }
    laeuft = true;
    const start = performance.now();
    zeigeWerte();
    ober.style.visibility = 'hidden';
    schirm.innerHTML = '';
    roehre.hidden = false; skip.hidden = false;
    setzeRoehre(roehre, 0, LINIE, AN.hell);
    if (!(await warte(100, t))) return false;
    if (!(await einschalten(roehre, t))) return false;
    if (!(await VARIANTEN[variante](t))) return false;
    if (!(await warte(HALTEN, t))) return false;
    if (!(await ausschalten(roehre, t))) return false;
    roehre.hidden = true;
    ober.style.visibility = '';
    setzeRoehre(ober, 0, LINIE, AN.hell);
    const ein = einschalten(ober, t);
    zeigeOberflaeche(false, t);
    setzeRoehre(ober, 0, LINIE, AN.hell);
    if (!(await ein)) return false;
    skip.hidden = true;
    gemessen = performance.now() - start;
    laeuft = false;
    zeigeWerte();
    ansage.textContent = 'Überflug geladen.';
    if (opts.fokus) $('.h1').focus({ preventScroll: true });
    return true;
  }
  function ueberspringen() {
    if (!laeuft) return;
    stoppe(); lauf++; laeuft = false;
    zeigeOberflaeche(true, lauf);
    ansage.textContent = 'Intro übersprungen. Überflug geladen.';
    $('.h1').focus({ preventScroll: true });
  }
  skip.addEventListener('click', ueberspringen);
  addEventListener('keydown', e => { if (e.key === 'Escape') ueberspringen(); });

  function setzeVariante(v) {
    variante = v;
    document.querySelectorAll('[data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === v)));
    gemessen = null; zeigeWerte();
  }
  document.querySelectorAll('[data-variante]').forEach(b => b.addEventListener('click', () => { stoppe(); setzeVariante(b.dataset.variante); intro(); }));
  $('[data-neu]').addEventListener('click', () => { stoppe(); intro(); });
  $('[data-ton]').addEventListener('click', e => {
    ton = !ton;
    if (ton && !ac) { const A = window.AudioContext || window.webkitAudioContext; if (A) ac = new A(); }
    if (ac && ac.state === 'suspended') ac.resume();
    e.currentTarget.setAttribute('aria-pressed', String(ton));
    e.currentTarget.textContent = ton ? 'Ton an' : 'Ton aus';
  });
  ober.addEventListener('click', e => { if (e.target.closest('a')) e.preventDefault(); });

  /* ---------- Vorführung: Oberfläche, dann neu booten, Variante wechselt ---------- */
  let runde = 0;
  const FOLGE = ['terminal', 'bios', 'antenne'];
  function stoppe() { runde++; }
  async function spiel(r) {
    let i = 0;
    const gilt = () => r === runde;
    const halt = ms => new Promise(res => { let z = 0, l = performance.now(); const s = n => { if (!gilt()) return res(false); z += pausiert ? 0 : Math.min(64, n - l); l = n; if (z >= ms) res(true); else requestAnimationFrame(s); }; requestAnimationFrame(s); });
    while (gilt()) {
      if (!(await halt(reduziert() ? 3000 : 450))) return;
      if (reduziert()) continue;
      // „Neu laden“: die Seite schaltet ab wie eine Röhre, dann bootet sie
      const t = ++lauf;
      if (!(await ausschalten(ober, t))) return;
      setzeVariante(FOLGE[i++ % FOLGE.length]);
      if (!(await intro())) return;
      if (!(await halt(1500))) return;
    }
  }
  function neustart() { stoppe(); pausiert = false; document.body.classList.remove('angehalten'); intro(); }
  function vorfuehren() {
    stoppe(); lauf++; pausiert = false; laeuft = false; document.body.classList.remove('angehalten');
    zeigeOberflaeche(true, lauf);
    spiel(runde);
  }
  function pause() { pausiert = true; document.body.classList.add('angehalten'); }
  function weiter() { pausiert = false; document.body.classList.remove('angehalten'); }

  baueZeit();
  setzeVariante('terminal');
  // Auf der echten Seite: Intro beim Laden (im Einsatz nur einmal pro Sitzung, siehe einmal-intro-session)
  if (!new URLSearchParams(location.search).has('vorschau')) intro({ fokus: false });
  addEventListener('resize', zeigeWerte);

})();
</script>
</body>
</html>