Zum Inhalt
← Seitenübergänge

Paralleler Übergang mit Vorladen (beide Seiten gleichzeitig im DOM)

Raus-Animation und Laden der neuen Seite starten beim Klick gleichzeitig, beide Container liegen kurz übereinander im DOM: Die Ladezeit verschwindet hinter dem Übergang.

Beim Klick starten zwei Dinge gleichzeitig: die Raus-Animation der alten Seite und das Laden der neuen (HTML, JS-Chunk, Titelbild). Die neue Seite wird neben der alten in denselben Bereich eingehängt. Beide animieren überlappend: alt raus mit Fade und y-Versatz, neu rein. Erst wenn Animation und Laden fertig sind, wird der alte Container entfernt. So entsteht keine Totzeit zwischen Raus und Rein.

Der Übergang verdeckt die Ladezeit, statt sie zu verlängern. Die Seite wirkt dadurch schneller. Überlappung liest sich flüssiger als das klassische Raus, Warten, Rein.

Wann einsetzen

Bei JS-Routern (Barba mit sync: true, Taxi, eigener Router, React Router), wenn Seiten spürbar laden, zum Beispiel wegen großer Titelbilder oder Daten.

Wann nicht

Wenn eine klassische Website mit mehreren Seiten reicht: Dort blendet der Browser mit @view-transition { navigation: auto; } selbst über. Auch nicht, wenn die Seiten ohnehin sofort da sind.

Varianten

  • Eigener Vanilla-Router: Container klonen, neue Seite einsetzen, beide animieren (blenkcode, Codrops)
  • React Router mit startViewTransition und flushSync, am Ende ein Clip-Morph per View Transition (Thibault Guignand)
  • Barba mit sync: true
  • Taxi mit enableViewTransitions: Der DOM-Tausch läuft in document.startViewTransition

Umsetzung

Im Klick-Handler preventDefault, dann laden und Raus-Animation gleichzeitig starten: const seite = cache.get(url) ?? fetch(url).then(r => r.text()); const weg = raus(alt); Sobald die Seite da ist, den Container per DOMParser herauslösen, neben den alten einsetzen und rein(neu) starten. Mit await Promise.all([weg, reinFertig]) auf beide warten, dann den alten Container entfernen, history.pushState und document.title setzen. Vorladen per Hover, Fokus oder pointerdown in eine Map von Promises legen. React: navigate in flushSync innerhalb von startViewTransition aufrufen, sonst fotografiert der Browser zweimal die alte Seite. Safari: 50 ms Puffer und void el.offsetHeight vor dem Entfernen (Clip-Path-Cache). Fallstricke: Doppelklicks sperren, Scroll-Position und Fokus selbst setzen, Seitenaufruf für die Statistik manuell senden.

Robust umsetzen

Telefon. Übergang unverändert. Statt bei Hover lädt die Seite beim Aufsetzen des Fingers (touchstart oder pointerdown) oder sobald der Link sichtbar wird.

Ohne Bewegung. Sofortiger Tausch ohne Animation, sobald die Seite geladen ist. Taxi macht das bei reduzierter Bewegung automatisch.

Barrierefrei. Bei JS-Routern Pflicht: Fokus auf die neue Überschrift setzen, den Seitentitel per aria-live ansagen, Scroll-Position zurücksetzen und den Zurück-Button testen. Den alten Container während des Übergangs inert schalten.

Code

<!DOCTYPE html>
<!--
  Paralleler Übergang mit Vorladen (beide Seiten gleichzeitig im DOM)
  Fundus · Seitenübergänge · https://cecodesign.de/lab/fundus/seitenuebergaenge/parallel-crossfade-preload/
  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>Paralleler Übergang mit Vorladen · 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: 'Host Grotesk'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/host-grotesk:vf@latest/latin-wght-normal.woff2') format('woff2-variations'); font-weight: 300 800; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/newsreader:vf@latest/latin-opsz-italic.woff2') format('woff2-variations'); font-weight: 200 800; font-style: italic; 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; }

/* Merle Asmus, Interaction-Design in Erfurt. Rezept „Stille Visitenkarte“:
   Graustufen, eine Farbe für Fokus und Link-Zustände, Host Grotesk 400/500, Newsreader kursiv als Stimme. */
:root {
  color-scheme: light;
  --grund: #fcfcfc; --text: #0b0b09; --leise: #646464; --linie: #e8e8e8; --flaeche: #f0f0f0; --blau: #2f5bea;
  --spalte: 640px; --seite: 1072px; --rand: clamp(16px, 4vw, 48px);
  /* Übergang (gemessen bei Thibault Guignand): power2.inOut */
  --kurve: cubic-bezier(0.65, 0, 0.35, 1);
  --ui: cubic-bezier(0.165, 0.84, 0.44, 1);   /* Hover und Farbe, 120 ms (Rezept) */
}
@media (prefers-color-scheme: dark) {
  :root { color-scheme: dark; --grund: #111110; --text: #ededeb; --leise: #9a9a96; --linie: #262624; --flaeche: #1b1b1a; --blau: #7d9bff; }
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--text); font: 400 16px/1.6 'Host Grotesk', system-ui, sans-serif; -webkit-font-smoothing: antialiased; font-kerning: normal; }
body { min-height: 100svh; padding-bottom: 132px; }
a { color: inherit; text-decoration: none; }
em { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-weight: 400; font-size: 1.07em; font-optical-sizing: auto; letter-spacing: 0; line-height: 1; }
:focus-visible { outline: 2px solid var(--blau); outline-offset: 3px; border-radius: 3px; }
h1:focus { outline: none; }
.mono { font: 450 11px/1.4 'Geist Mono', ui-monospace, monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--leise); }
.ziffern { font-variant-numeric: tabular-nums; }
.leise { color: var(--leise); }

/* ---------- Kopf (bleibt stehen, gehört zu keiner Seite) ---------- */
.kopf { max-width: var(--seite); margin: 0 auto; padding: 22px var(--rand); display: flex; justify-content: space-between; align-items: baseline; gap: 16px; }
.marke { font-weight: 500; letter-spacing: -.005em; white-space: nowrap; }
.marke span { color: var(--leise); font-weight: 400; }
.menue { display: flex; gap: 22px; font-size: 15px; }
.menue a { color: var(--leise); transition: color .12s var(--ui); }
.menue a:hover, .menue a[aria-current] { color: var(--text); }

/* ---------- Bühne: beide Seiten im selben Rasterfeld ---------- */
.buehne { max-width: var(--seite); margin: 0 auto; padding: clamp(28px, 6vh, 64px) var(--rand) 0; display: grid; }
.seite { grid-area: 1 / 1; min-width: 0; }
.seite[inert] { pointer-events: none; }

/* Startseite: ein Satz statt Hero, darunter Listen */
.intro { max-width: var(--spalte); font-size: clamp(1.25rem, 2vw, 1.5rem); font-weight: 500; line-height: 1.4; letter-spacing: -.01em; text-wrap: pretty; }
.intro .leise { font-weight: 400; }
.block { max-width: var(--spalte); margin-top: clamp(40px, 7vh, 72px); }
.block h2 { font-size: 1rem; font-weight: 500; color: var(--leise); margin-bottom: 10px; }
.liste { list-style: none; }
.liste a { display: grid; grid-template-columns: 1fr auto auto auto; align-items: baseline; gap: 14px; padding: 9px 10px; margin: 0 -10px; border-radius: 8px; transition: background-color .12s var(--ui); }
.liste a:hover { background: var(--flaeche); }
.liste .art { color: var(--leise); }
.liste .jahr { color: var(--leise); text-align: right; min-width: 3ch; }
.liste .titel { font-weight: 500; }
/* Vorlade-Punkt: Ring = noch nicht geladen, voll = liegt im Speicher */
.punkt { width: 7px; height: 7px; border-radius: 50%; box-shadow: inset 0 0 0 1px #c9c9c6; align-self: center; transition: background-color .16s var(--ui), box-shadow .16s var(--ui); }
.punkt.laedt { box-shadow: inset 0 0 0 1px var(--blau); }
.punkt.da { background: var(--blau); box-shadow: inset 0 0 0 1px var(--blau); }
.notizen a { grid-template-columns: 1fr auto; }
.liste .titel { min-width: 0; }

/* Projektseite */
.leiste { display: flex; justify-content: space-between; gap: 16px; font-size: 15px; }
.leiste a { color: var(--leise); display: inline-flex; gap: 8px; align-items: center; transition: color .12s var(--ui); }
.leiste a:hover { color: var(--text); }
.leiste .punkt { margin-left: 2px; }
.projekt-kopf { max-width: var(--spalte); margin-top: clamp(24px, 4.5vh, 44px); }
.projekt-kopf h1 { font-size: clamp(1.25rem, 2vw, 1.5rem); font-weight: 500; line-height: 1.4; letter-spacing: -.01em; }
.projekt-kopf p { font-size: clamp(1.25rem, 2vw, 1.5rem); line-height: 1.4; letter-spacing: -.01em; color: var(--leise); text-wrap: pretty; }
.fakten { display: grid; grid-template-columns: repeat(4, auto); justify-content: start; gap: 4px 40px; margin-top: 22px; font-size: 14px; }
.fakten dt { color: var(--leise); }
.titelbild { margin-top: clamp(24px, 4.5vh, 40px); border-radius: 10px; overflow: hidden; background: var(--flaeche); }
.titelbild svg { display: block; width: 100%; height: auto; }
.text { max-width: var(--spalte); margin-top: 32px; text-wrap: pretty; }
.text + .text { margin-top: 14px; }

/* ---------- Werkbank: Varianten, Zeitleiste, Werte ---------- */
.werkbank { position: fixed; left: 50%; bottom: 14px; translate: -50% 0; z-index: 20; width: min(calc(100% - 28px), 1000px);
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr); gap: 10px 28px; align-items: center;
  padding: 14px 18px; border-radius: 12px; background: color-mix(in srgb, var(--grund) 88%, transparent);
  backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); box-shadow: 0 0 0 1px var(--linie), 0 8px 30px rgba(0, 0, 0, .06); }
.schalter { display: inline-flex; gap: 2px; padding: 2px; border-radius: 8px; background: var(--flaeche); }
.schalter button { font: inherit; font-size: 13px; color: var(--leise); background: none; border: 0; padding: 5px 10px; border-radius: 6px; cursor: pointer; transition: color .12s var(--ui), background-color .12s var(--ui); white-space: nowrap; }
.schalter button:hover { color: var(--text); }
.schalter button[aria-pressed='true'] { color: var(--text); background: var(--grund); box-shadow: 0 0 0 1px var(--linie); }
.werte { margin-top: 9px; font-size: 10px; letter-spacing: .1em; line-height: 1.55; }
.werte b { font-weight: 500; color: var(--text); }
.zeit { display: grid; grid-template-columns: 6.2em 1fr; white-space: nowrap; gap: 5px 10px; align-items: center; font-size: 10px; letter-spacing: .1em; }
.spur { position: relative; height: 8px; border-radius: 2px; background: var(--flaeche); }
.balken { position: absolute; top: 0; bottom: 0; border-radius: 2px; }
.balken.raus { background: #b4b4b0; }
.balken.raus.ui { background: #d6d6d2; }
.balken.laden { background: var(--blau); }
.balken.rein { background: var(--text); }
.balken.tot { background: repeating-linear-gradient(135deg, transparent 0 3px, rgba(200, 60, 40, .55) 3px 5px); }
.balken.dom { background: color-mix(in srgb, var(--blau) 22%, transparent); }
.kopfzeile { grid-column: 2; display: flex; justify-content: space-between; gap: 12px; position: relative; }
.kopfzeile .summe { color: var(--text); }
.kopfzeile .summe.leer { color: var(--leise); }
.zeiger-linie { position: absolute; top: -2px; bottom: -2px; width: 1px; background: var(--text); opacity: .7; }
.achse { grid-column: 2; display: flex; justify-content: space-between; color: var(--leise); opacity: .8; font-size: 9px; }

/* Vorführung: gedachter Zeiger */
.zeiger { position: fixed; left: 0; top: 0; z-index: 30; width: 20px; height: 20px; pointer-events: none; display: none; }
.vorfuehrung .zeiger { display: block; }
.zeiger.klick { animation: klick .32s var(--kurve); }
@keyframes klick { 50% { scale: .82; } }

/* View Transitions (Variante 3): nur der Seiten-Container wechselt, der Kopf bleibt */
::view-transition-old(root), ::view-transition-new(root) { animation: none; }
.vt-laeuft .seite { view-transition-name: seite; }
::view-transition-group(seite) { animation: none; }
::view-transition-old(seite) { animation: vt-raus 350ms var(--kurve) both; }
::view-transition-new(seite) { animation: vt-rein 350ms var(--kurve) 250ms both; }
@keyframes vt-raus { to { opacity: 0; transform: translateY(-16px); } }
@keyframes vt-rein { from { opacity: 0; transform: translateY(16px); } }

.vorschau body { overflow: hidden; padding-bottom: 0; }
.vorschau .werkbank .schalter, .vorschau .werte, .vorschau .notizen-block { display: none; }
.vorschau .werkbank { grid-template-columns: 1fr; width: 500px; left: auto; right: 20px; translate: none; bottom: 18px; padding: 12px 16px; }
.vorschau .vorschau-aus { display: none; }

.reduziert .zeiger.klick { animation: none; }
.reduziert .liste a, .reduziert .punkt { transition: none; }

@media (max-width: 760px) {
  body { padding-bottom: 176px; }
  .menue { gap: 14px; font-size: 14px; }
  .menue a:not([aria-current]):nth-child(2) { display: none; }
  .werkbank { grid-template-columns: 1fr; gap: 8px; padding: 12px 14px; bottom: 10px; width: calc(100% - 20px); }
  .werte { display: none; }
  .schalter { display: flex; }
  .schalter button { flex: 1; padding: 6px 4px; font-size: 12.5px; }
  .liste .art { display: none; }
  .liste a { grid-template-columns: 1fr auto auto; }
  .zeit { grid-template-columns: 5.6em 1fr; }
  .fakten { grid-template-columns: repeat(2, auto); gap: 4px 28px; }
  .leiste .naechste-name { display: none; }
}
@media (hover: none) {
  .liste a:hover { background: none; }
  .liste a:active { background: var(--flaeche); }
}
</style>
</head>
<body>
<header class="kopf">
  <a class="marke" href="#/" data-link>Merle Asmus <span>· Erfurt</span></a>
  <nav class="menue" aria-label="Hauptnavigation">
    <a href="#/" data-link aria-current="page">Arbeiten</a><a href="#/">Notizen</a><a href="#/">Kontakt</a>
  </nav>
</header>

<main class="buehne" data-buehne></main>
<p class="sr" aria-live="polite" data-ansage style="position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)"></p>

<aside class="werkbank" aria-label="Demo-Steuerung">
  <div class="vorschau-aus">
    <div class="schalter" role="group" aria-label="Variante">
      <button type="button" data-variante="parallel" aria-pressed="true">Parallel</button>
      <button type="button" data-variante="nacheinander" aria-pressed="false">Nacheinander</button>
      <button type="button" data-variante="vt" aria-pressed="false">View Transition</button>
    </div>
    <p class="mono werte" data-werte></p>
  </div>
  <div class="zeit mono" data-zeit>
    <span></span>
    <div class="kopfzeile"><span data-zustand>Klick auf eine Arbeit</span><span class="summe leer ziffern" data-summe>0 ms</span></div>
    <span>Alt raus</span><div class="spur" data-spur="raus"></div>
    <span>Laden</span><div class="spur" data-spur="laden"></div>
    <span>Neu rein</span><div class="spur" data-spur="rein"></div>
    <span>2 im DOM</span><div class="spur" data-spur="dom"></div>
    <span></span><div class="achse ziffern"><span>0</span><span>400</span><span>800</span><span>1200</span><span>1600 ms</span></div>
  </div>
</aside>
<svg class="zeiger" data-zeiger viewBox="0 0 20 20" aria-hidden="true"><path d="M2.5 1.8 17 10.2l-6.5 1.4-3.1 6.1Z" fill="#0b0b09" stroke="#fcfcfc" stroke-width="1.3" stroke-linejoin="round"/></svg>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const $ = s => document.querySelector(s);
  const buehne = $('[data-buehne]'), ansage = $('[data-ansage]'), zeiger = $('[data-zeiger]');

  /* ---------- Werte (exakt so im Bau-Block) ---------- */
  const W = {
    kurve: 'cubic-bezier(0.65, 0, 0.35, 1)',   // power2.inOut, gemessen
    uiRaus: 300,                                // UI-Ebenen raus, ab 0 ms
    inhaltRaus: 350, inhaltRausAb: 250,         // Inhalt raus, ab 250 ms
    rein: 350, reinFruehestens: 250,            // neue Seite rein, frühestens ab 250 ms
    versatz: 16,                                // y-Versatz in px (eigene Wahl)
    puffer: 50,                                 // Safari-Puffer vor dem Entfernen
    ladezeit: 520,                              // simulierte Ladezeit kalt (HTML + Titelbild)
    skala: 1600
  };

  /* ---------- Inhalte: Merle Asmus, Interaction-Design ---------- */
  const ARBEITEN = [
    { slug: 'wiesengrund', titel: 'Gärtnerei Wiesengrund', art: 'Bestell-App', jahr: 2026,
      satz: 'Jungpflanzen <em>vorbestellen</em>, am Samstag abholen. Eine App für die Stammkundschaft, die bisher am Telefon bestellt hat.',
      fakten: [['Kunde', 'Gärtnerei Wiesengrund, Erfurt'], ['Rolle', 'Konzept, Interface'], ['Jahr', '2026'], ['Dauer', '14 Wochen']],
      text: ['Jedes Frühjahr kamen rund 600 Bestellungen per Telefon und Zettel. Die App bildet genau diesen Ablauf ab: Sorte wählen, Menge eintragen, Abholfenster buchen. Kein Konto, keine Vorkasse.',
             'Das Team sieht morgens eine Packliste statt eines Stapels Notizen. Die Abholung am Tor dauert seitdem weniger als eine Minute.'] },
    { slug: 'rauschert', titel: 'Orgelbau Rauschert', art: 'Website und Archiv', jahr: 2025,
      satz: 'Vierzig Jahre Werkstattbuch, jetzt <em>durchsuchbar</em>: jede restaurierte Orgel mit Disposition, Baujahr und Fotos.',
      fakten: [['Kunde', 'Orgelbau Rauschert'], ['Rolle', 'Struktur, Gestaltung'], ['Jahr', '2025'], ['Dauer', '5 Monate']],
      text: ['Das Archiv lag in Ordnern und auf einer Festplatte. Wir haben 312 Instrumente erfasst und nach Ort, Epoche und Register filterbar gemacht.',
             'Gemeinden finden ihre Orgel jetzt selbst und schicken Anfragen mit allen Angaben. Die Werkstatt telefoniert weniger und baut mehr.'] },
    { slug: 'tafelglas', titel: 'Tafelglas Heldrungen', art: 'Konfigurator', jahr: 2024,
      satz: 'Maß eingeben, Glasart wählen, Preis <em>sofort</em> sehen. Der Konfigurator ersetzt ein Formular mit 23 Feldern.',
      fakten: [['Kunde', 'Tafelglas Heldrungen'], ['Rolle', 'Interface, Prototyp'], ['Jahr', '2024'], ['Dauer', '10 Wochen']],
      text: ['Handwerksbetriebe bestellen Zuschnitte oft vom Telefon aus, auf der Baustelle. Deshalb zuerst die Maße, dann alles andere, und jede Eingabe zeigt sofort den Preis.',
             'Fehlbestellungen wegen vertauschter Breite und Höhe sind fast verschwunden: Die Scheibe wird beim Tippen im Maßstab gezeichnet.'] },
    { slug: 'kammerton', titel: 'Musikschule Kammerton', art: 'Leitsystem', jahr: 2023,
      satz: 'Neunzehn Räume auf drei Etagen. Ein Raumplan, der am Telefon und an der Tür <em>gleich</em> aussieht.',
      fakten: [['Kunde', 'Musikschule Kammerton'], ['Rolle', 'Leitsystem, Web-App'], ['Jahr', '2023'], ['Dauer', '4 Monate']],
      text: ['Neue Schülerinnen suchten jeden September ihren Raum. Jetzt tragen Türschild und Stundenplan dieselbe Nummer in derselben Schrift.',
             'Die Web-App zeigt morgens, welche Räume frei sind. Lehrkräfte tauschen Stunden ohne Aushang im Treppenhaus.'] }
  ];
  const NOTIZEN = [['Formulare, die auf der Baustelle funktionieren', '12.10.2026'], ['Warum ich Abholfenster statt Uhrzeiten anbiete', '03.08.2026'], ['Ein Leitsystem ist auch eine Schriftfrage', '19.05.2026']];

  /* ---------- Titelbilder als eigene SVG ---------- */
  const T = (x, y, s, inhalt, f = 'currentColor', w = 400, extra = '') => `<text x="${x}" y="${y}" font-size="${s}" font-weight="${w}" fill="${f}" ${extra}>${inhalt}</text>`;
  const MONO = `font-family="Geist Mono, monospace" letter-spacing="1.6"`;
  const BILD = {
    wiesengrund() {
      const reihen = [['Tomate ‚Ochsenherz‘', '6 Stück', '#9fb59a'], ['Paprika ‚Pantos‘', '4 Stück', '#c9a46c'], ['Basilikum, großblättrig', '3 Töpfe', '#7d9a7a'], ['Gurke ‚Marketmore‘', '2 Stück', '#b7c7a3']];
      let r = '';
      reihen.forEach(([n, m, c], i) => {
        const y = 196 + i * 74;
        r += `<circle cx="508" cy="${y}" r="20" fill="${c}"/><path d="M500 ${y + 6}c4-14 14-18 18-16-2 8-8 16-18 16Z" fill="#fcfcfc" opacity=".7"/>`
          + T(540, y - 3, 17, n, '#0b0b09', 500) + T(540, y + 18, 14, m, '#646464') + `<line x1="488" x2="752" y1="${y + 37}" y2="${y + 37}" stroke="#ececea"/>`;
      });
      return `<svg viewBox="0 0 1200 520" role="img" aria-label="Bestell-App der Gärtnerei Wiesengrund auf einem Telefon">
        <rect width="1200" height="520" fill="#e6eae2"/><circle cx="610" cy="300" r="250" fill="#dbe2d5"/>
        <g opacity=".9"><path d="M170 520C196 392 262 320 352 288" fill="none" stroke="#9fb59a" stroke-width="3"/><path d="M262 360c-50-6-82-44-84-86 46 2 80 36 84 86Z" fill="#b7c7a3"/><path d="M300 318c18-46 62-66 102-56-12 44-56 66-102 56Z" fill="#9fb59a"/><path d="M226 430c-44 12-90-10-104-50 42-14 90 8 104 50Z" fill="#c4d1b6"/></g>
        <rect x="460" y="56" width="320" height="560" rx="44" fill="#fcfcfc" stroke="#cfd6c8" stroke-width="2"/>
        ${T(488, 112, 13, 'BESTELLUNG · SA 14. MÄRZ', '#646464', 450, MONO)}${T(488, 146, 26, 'Ihre Auswahl', '#0b0b09', 500)}${r}
        <rect x="800" y="186" width="250" height="132" rx="18" fill="#fcfcfc" stroke="#cfd6c8" stroke-width="2"/>
        ${T(826, 224, 12, 'ABHOLUNG', '#646464', 450, MONO)}${T(826, 262, 28, 'Sa 9–12 Uhr', '#0b0b09', 500)}${T(826, 292, 15, 'Tor 2, Gewächshaus B', '#646464')}
        <rect x="800" y="336" width="170" height="48" rx="24" fill="#2f4a33"/>${T(832, 366, 16, 'Vormerken', '#f2f4ef', 500)}
      </svg>`;
    },
    rauschert() {
      let p = '';
      const n = 25, mitte = 12;
      for (let i = 0; i < n; i++) {
        const d = Math.abs(i - mitte) / mitte, h = 110 + 250 * Math.pow(1 - d, 1.3) + (i % 2 ? 0 : 18) * (1 - d);
        const x = 168 + i * 35.5, w = i % 2 ? 22 : 26, fuss = 430, oben = fuss - 44 - h;
        p += `<rect x="${x}" y="${oben}" width="${w}" height="${h}" rx="1.5" fill="url(#zinn)"/><rect x="${x}" y="${oben}" width="${w}" height="3" fill="#ece4d0" opacity=".5"/>`
          + `<path d="M${x} ${fuss - 44}h${w}l-${w / 2 - 3} 44h-6Z" fill="url(#zinn)" opacity=".85"/>`
          + `<path d="M${x + 5} ${fuss - 70}h${w - 10}l-3 9h-${w - 16}Z" fill="#1d1c1a" opacity=".75"/>`;
      }
      return `<svg viewBox="0 0 1200 520" role="img" aria-label="Orgelprospekt aus Zinnpfeifen auf dunklem Grund">
        <defs><linearGradient id="zinn" x1="0" x2="1"><stop offset="0" stop-color="#6f6655"/><stop offset=".42" stop-color="#d9cfb8"/><stop offset=".6" stop-color="#b3a68a"/><stop offset="1" stop-color="#5c5446"/></linearGradient>
        <radialGradient id="schein" cx=".5" cy=".55" r=".55"><stop offset="0" stop-color="#3a352c"/><stop offset="1" stop-color="#1d1c1a"/></radialGradient></defs>
        <rect width="1200" height="520" fill="url(#schein)"/>${p}<line x1="150" x2="1050" y1="431" y2="431" stroke="#4a463e" stroke-width="2"/>
        ${T(150, 476, 12, 'PRINCIPAL 8′', '#9a9384', 450, MONO)}${T(420, 476, 12, 'OCTAVE 4′', '#9a9384', 450, MONO)}${T(660, 476, 12, 'GEDACKT 8′', '#9a9384', 450, MONO)}${T(900, 476, 12, 'MIXTUR 3F.', '#9a9384', 450, MONO)}
      </svg>`;
    },
    tafelglas() {
      return `<svg viewBox="0 0 1200 520" role="img" aria-label="Glaskonfigurator mit bemaßter Scheibe und Preis">
        <rect width="1200" height="520" fill="#e4eaec"/>
        <rect x="300" y="120" width="420" height="290" rx="3" fill="#cfdde1" opacity=".7" transform="rotate(-4 510 265)"/>
        <rect x="270" y="110" width="420" height="290" rx="3" fill="rgba(143,179,189,.38)" stroke="rgba(70,110,120,.55)" stroke-width="2"/>
        <path d="M300 380 450 130M380 390 520 160" stroke="#fcfcfc" stroke-width="6" opacity=".55" stroke-linecap="round"/>
        <g stroke="#55666b" stroke-width="1.5"><line x1="270" x2="690" y1="78" y2="78"/><line x1="270" x2="270" y1="70" y2="86"/><line x1="690" x2="690" y1="70" y2="86"/>
        <line x1="236" x2="236" y1="110" y2="400"/><line x1="228" x2="244" y1="110" y2="110"/><line x1="228" x2="244" y1="400" y2="400"/></g>
        ${T(480, 64, 13, '1240 MM', '#3f4d51', 450, MONO + ' text-anchor="middle"')}${T(222, 255, 13, '860 MM', '#3f4d51', 450, MONO + ' text-anchor="middle" transform="rotate(-90 222 255)"')}
        <rect x="770" y="110" width="300" height="290" rx="18" fill="#fcfcfc" stroke="#cdd7da" stroke-width="2"/>
        ${T(798, 148, 12, 'GLASART', '#646464', 450, MONO)}${T(798, 178, 19, 'Floatglas, 6 mm', '#0b0b09', 500)}
        <line x1="798" x2="1042" y1="214" y2="214" stroke="#e3e7e8" stroke-width="6" stroke-linecap="round"/><line x1="798" x2="930" y1="214" y2="214" stroke="#3d6470" stroke-width="6" stroke-linecap="round"/><circle cx="930" cy="214" r="11" fill="#fcfcfc" stroke="#3d6470" stroke-width="2.5"/>
        ${T(798, 262, 12, 'KANTEN', '#646464', 450, MONO)}${T(798, 290, 17, 'geschliffen, gesäumt', '#0b0b09')}
        ${T(798, 350, 12, 'PREIS INKL. MWST.', '#646464', 450, MONO)}${T(798, 384, 32, '184,60 €', '#0b0b09', 500, 'font-variant-numeric="tabular-nums"')}
      </svg>`;
    },
    kammerton() {
      const tage = ['MO', 'DI', 'MI', 'DO', 'FR'];
      let g = '';
      tage.forEach((t, i) => {
        g += T(712 + i * 70, 140, 12, t, '#646464', 450, MONO);
        for (let j = 0; j < 5; j++) {
          const an = (i * 7 + j * 3) % 5 === 0, voll = i === 2 && j === 2;
          g += `<rect x="704" y="${158 + j * 52}" width="60" height="44" rx="6" transform="translate(${i * 70} 0)" fill="${voll ? '#2f5bea' : an ? '#d7dffb' : '#e9e8e3'}"/>`;
        }
      });
      return `<svg viewBox="0 0 1200 520" role="img" aria-label="Türschild Raum 2.14 und Wochenplan der Musikschule">
        <rect width="1200" height="520" fill="#efeee9"/>
        <rect x="170" y="74" width="380" height="380" rx="8" fill="#fcfcfc" stroke="#dcdbd5" stroke-width="2"/>
        ${T(206, 128, 13, '2. OBERGESCHOSS', '#646464', 450, MONO)}${T(198, 300, 150, '2.14', '#0b0b09', 500, 'letter-spacing="-6"')}
        <line x1="206" x2="514" y1="344" y2="344" stroke="#e3e2dc" stroke-width="2"/>${T(206, 386, 22, 'Klavier', '#0b0b09', 500)}${T(206, 416, 17, 'Flügel, Steinway B', '#646464')}
        <path d="M488 392h22m-9-9 9 9-9 9" fill="none" stroke="#2f5bea" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
        ${T(704, 104, 13, 'RAUM 2.14 · KW 38', '#646464', 450, MONO)}${g}
      </svg>`;
    }
  };

  /* ---------- „Server“: liefert HTML-Text, wie fetch(url).then(r => r.text()) ---------- */
  function seitenHTML(pfad) {
    if (pfad === '/') {
      return `<!doctype html><title>Merle Asmus · Interaction-Design, Erfurt</title><main><article class="seite" data-container data-pfad="/">
        <h1 class="intro" tabindex="-1" data-inhalt>Merle Asmus gestaltet Apps und Websites für Handwerk, Kultur und kleine Betriebe in Thüringen, <em>von der ersten Skizze</em> bis zum fertigen Code. <span class="leise">Seit 2019 selbstständig in Erfurt. Ab Januar wieder frei.</span></h1>
        <section class="block" data-inhalt><h2 data-ui>Ausgewählte Arbeiten</h2><ul class="liste">
          ${ARBEITEN.map(a => `<li><a href="#/arbeiten/${a.slug}" data-link><span class="titel">${a.titel}</span><span class="art">${a.art}</span><span class="jahr ziffern">${a.jahr}</span></a></li>`).join('')}
        </ul></section>
        <section class="block notizen-block" data-inhalt><h2 data-ui>Notizen</h2><ul class="liste notizen">
          ${NOTIZEN.map(([t, d]) => `<li><a href="#/"><span>${t}</span><span class="jahr ziffern">${d}</span></a></li>`).join('')}
        </ul></section>
      </article></main>`;
    }
    const i = ARBEITEN.findIndex(a => '/arbeiten/' + a.slug === pfad), a = ARBEITEN[i], n = ARBEITEN[(i + 1) % ARBEITEN.length];
    return `<!doctype html><title>${a.titel} · Merle Asmus</title><main><article class="seite" data-container data-pfad="${pfad}">
      <nav class="leiste" data-ui aria-label="Projektnavigation"><a href="#/" data-link data-zurueck>← Alle Arbeiten</a><a href="#/arbeiten/${n.slug}" data-link data-naechste><span>Nächste<span class="naechste-name">: ${n.titel}</span> →</span></a></nav>
      <header class="projekt-kopf" data-inhalt><h1 tabindex="-1">${a.titel}</h1><p>${a.satz}</p>
        <dl class="fakten">${a.fakten.map(([k, v]) => `<div><dt>${k}</dt><dd${k === 'Jahr' ? ' class="ziffern"' : ''}>${v}</dd></div>`).join('')}</dl></header>
      <figure class="titelbild" data-inhalt>${BILD[a.slug]()}</figure>
      ${a.text.map(t => `<p class="text" data-inhalt>${t}</p>`).join('')}
    </article></main>`;
  }

  /* ---------- Vorladen: Cache aus Promises, startet bei Hover, Fokus oder Fingerdruck ---------- */
  const speicher = new Map();   // pfad -> { versprechen, fertig }
  const pfadVon = a => (a.getAttribute('href') || '#/').slice(1) || '/';
  function laden(pfad) {
    if (speicher.has(pfad)) return speicher.get(pfad);
    const eintrag = { fertig: false };
    eintrag.versprechen = new Promise(r => setTimeout(r, W.ladezeit))
      .then(() => new DOMParser().parseFromString(seitenHTML(pfad), 'text/html'))
      .then(doc => { eintrag.fertig = true; punkteMalen(); return doc; });
    speicher.set(pfad, eintrag);
    punkteMalen();
    return eintrag;
  }
  function punkteMalen() {
    document.querySelectorAll('.buehne [data-link]').forEach(a => {
      let p = a.querySelector('.punkt');
      if (!p) {
        p = document.createElement('span'); p.className = 'punkt'; p.setAttribute('aria-hidden', 'true');
        const jahr = a.querySelector('.jahr');
        jahr ? a.insertBefore(p, jahr) : a.appendChild(p);
      }
      const e = speicher.get(pfadVon(a));
      p.classList.toggle('laedt', !!e && !e.fertig);
      p.classList.toggle('da', !!e && e.fertig);
    });
  }
  const vorladen = e => { const a = e.target.closest && e.target.closest('.buehne [data-link]'); if (a && variante !== 'nacheinander') laden(pfadVon(a)); };
  buehne.addEventListener('pointerover', e => { if (e.pointerType === 'mouse') vorladen(e); });
  buehne.addEventListener('pointerdown', vorladen);   // Touch: beim Aufsetzen des Fingers
  buehne.addEventListener('focusin', vorladen);

  /* ---------- Zeitleiste ---------- */
  const spur = n => $(`[data-spur="${n}"]`);
  const zustand = $('[data-zustand]'), summe = $('[data-summe]');
  let t0 = 0, balken = [], uhrTL = null, ende = null;
  function tlStart(text) {
    document.querySelectorAll('.spur').forEach(s => s.innerHTML = '');
    balken = []; ende = null; t0 = performance.now();
    zustand.textContent = text; summe.classList.add('leer');
    cancelAnimationFrame(uhrTL); uhrTL = requestAnimationFrame(tlMalen);
  }
  function tlBalken(n, a, b, klasse = '') {   // a/b in ms ab Klick; b === null wächst bis tlEnde
    const el = document.createElement('i'); el.className = `balken ${n} ${klasse}`;
    spur(n === 'tot' ? 'laden' : n).appendChild(el);
    const o = { el, a, b }; balken.push(o); return o;
  }
  function tlMalen() {
    const jetzt = performance.now() - t0, bis = ende ?? jetzt;
    balken.forEach(o => {
      const b = Math.min(o.b ?? bis, bis), a = Math.min(o.a, bis);
      o.el.style.left = (a / W.skala * 100) + '%';
      o.el.style.width = Math.max(0, (b - a) / W.skala * 100) + '%';
    });
    summe.textContent = Math.round(bis) + ' ms';
    if (ende === null) uhrTL = requestAnimationFrame(tlMalen);
  }
  function tlEnde(text) { ende = performance.now() - t0; tlMalen(); zustand.textContent = text; summe.classList.remove('leer'); }
  const jetzt = () => performance.now() - t0;
  const warte = ms => new Promise(r => setTimeout(r, ms));

  /* ---------- Animationen (Web Animations API, Promise über .finished) ---------- */
  const anim = (els, keyframes, dauer, ab = 0) => Promise.all([...els].map(el =>
    el.animate(keyframes, { duration: dauer, delay: ab, easing: W.kurve, fill: 'both' }).finished));
  function raus(alt) {
    const ui = alt.querySelectorAll('[data-ui]'), inhalt = alt.querySelectorAll('[data-inhalt]');
    return Promise.all([
      anim(ui, [{ opacity: 1 }, { opacity: 0 }], W.uiRaus, 0),
      anim(inhalt, [{ opacity: 1, transform: 'none' }, { opacity: 0, transform: `translateY(${-W.versatz}px)` }], W.inhaltRaus, W.inhaltRausAb)
    ]);
  }
  function rein(neu) {
    return Promise.all([
      anim(neu.querySelectorAll('[data-ui]'), [{ opacity: 0 }, { opacity: 1 }], W.rein),
      anim(neu.querySelectorAll('[data-inhalt]'), [{ opacity: 0, transform: `translateY(${W.versatz}px)` }, { opacity: 1, transform: 'none' }], W.rein)
    ]);
  }

  /* ---------- Router ---------- */
  let variante = 'parallel', laeuft = false, aktuell = '/';
  function einsetzen(doc) {
    const neu = document.importNode(doc.querySelector('[data-container]'), true);
    buehne.appendChild(neu);
    return neu;
  }
  function fixieren(alt) {   // alte Seite bleibt optisch stehen, während wir nach oben springen
    const y = scrollY;
    if (y > 0) { alt.style.position = 'relative'; alt.style.top = -y + 'px'; scrollTo(0, 0); }
  }
  function abschluss(neu, doc, nutzer) {
    document.title = doc.title;
    ansage.textContent = 'Seite geladen: ' + doc.title;
    document.querySelectorAll('.menue a')[0].toggleAttribute('aria-current', true);
    if (nutzer) neu.querySelector('h1').focus({ preventScroll: true });
    punkteMalen();
  }

  async function gehe(pfad, nutzer = true) {
    if (laeuft || pfad === aktuell) return;
    laeuft = true; aktuell = pfad;
    const alt = buehne.querySelector('[data-container]');
    alt.setAttribute('inert', ''); alt.removeAttribute('data-container');

    if (reduziert()) {   // sofortiger Swap, sobald die Seite da ist
      tlStart('Reduziert: Swap ohne Animation');
      const e = laden(pfad), kalt = !e.fertig, l = tlBalken('laden', 0, kalt ? null : 0);
      const doc = await e.versprechen; l.b = jetzt();
      fixieren(alt); alt.remove(); const neu = einsetzen(doc);
      tlEnde(kalt ? 'Reduziert: Swap nach dem Laden' : 'Reduziert: sofort aus dem Speicher');
      abschluss(neu, doc, nutzer); laeuft = false; return;
    }

    if (variante === 'parallel') {
      tlStart('Raus und Laden starten gleichzeitig');
      const e = laden(pfad), kalt = !e.fertig;
      const l = tlBalken('laden', 0, kalt ? null : 0);
      if (!kalt) zustand.textContent = 'Seite lag schon im Speicher';
      tlBalken('raus', 0, W.uiRaus, 'ui'); tlBalken('raus', W.inhaltRausAb, W.inhaltRausAb + W.inhaltRaus);
      fixieren(alt);
      const weg = raus(alt);
      const doc = await e.versprechen; l.b = jetzt();
      if (jetzt() < W.reinFruehestens) await warte(W.reinFruehestens - jetzt());
      const neu = einsetzen(doc), s = jetzt();
      const dom = tlBalken('dom', s, null);
      tlBalken('rein', s, s + W.rein);
      zustand.textContent = 'Beide Seiten im DOM';
      await Promise.all([weg, rein(neu)]);
      await warte(W.puffer); void neu.offsetHeight;   // Safari: Puffer, dann Layout erzwingen
      alt.remove(); dom.b = jetzt();
      tlEnde(kalt ? 'Laden lief hinter dem Übergang' : 'Vorgeladen: echter Crossfade');
      abschluss(neu, doc, nutzer);
    } else if (variante === 'nacheinander') {
      tlStart('Erst raus, dann laden, dann rein');
      speicher.delete(pfad);   // klassisch: kein Vorladen
      tlBalken('raus', 0, W.uiRaus, 'ui'); tlBalken('raus', W.inhaltRausAb, W.inhaltRausAb + W.inhaltRaus);
      fixieren(alt);
      await raus(alt); alt.remove();
      const la = jetzt(), l = tlBalken('laden', la, null), tot = tlBalken('tot', la, null);
      zustand.textContent = 'Totzeit: leere Seite';
      const doc = await laden(pfad).versprechen; l.b = tot.b = jetzt();
      const neu = einsetzen(doc), s = jetzt();
      tlBalken('rein', s, s + W.rein);
      await rein(neu);
      tlEnde('Totzeit zwischen Raus und Rein');
      abschluss(neu, doc, nutzer);
    } else {
      tlStart('Laden, dann Swap in der View Transition');
      const e = laden(pfad), kalt = !e.fertig, l = tlBalken('laden', 0, kalt ? null : 0);
      const doc = await e.versprechen; l.b = jetzt();
      const s = jetzt();
      tlBalken('raus', s, s + 350); tlBalken('rein', s + 250, s + 600); tlBalken('dom', s, s + 600);
      let neu;
      const tausch = () => { fixieren(alt); alt.remove(); neu = einsetzen(doc); };
      if (document.startViewTransition) {
        html.classList.add('vt-laeuft');
        try { await document.startViewTransition(tausch).finished; } catch (_) { if (!neu) tausch(); }
        html.classList.remove('vt-laeuft');
      } else {   // ohne View Transitions: dieselben Werte per WAAPI
        const weg = anim([alt], [{ opacity: 1, transform: 'none' }, { opacity: 0, transform: `translateY(${-W.versatz}px)` }], 350);
        neu = einsetzen(doc);
        await Promise.all([weg, anim([neu], [{ opacity: 0, transform: `translateY(${W.versatz}px)` }, { opacity: 1, transform: 'none' }], 350, 250)]);
        alt.remove();
      }
      tlEnde(kalt ? 'Alte Seite stand während des Ladens' : 'Vorgeladen: Swap sofort');
      abschluss(neu, doc, nutzer);
    }
    laeuft = false;
  }

  document.addEventListener('click', e => {
    const a = e.target.closest('[data-link]');
    if (!a) return;
    e.preventDefault();
    if (e.isTrusted) stoppVorfuehrung();
    gehe(pfadVon(a), e.isTrusted);
  });

  /* ---------- Varianten ---------- */
  const knoepfe = document.querySelectorAll('[data-variante]'), werteEl = $('[data-werte]');
  const WERTE = {
    parallel: '<b>power2.inOut</b> · cubic-bezier(0.65, 0, 0.35, 1)<br>UI raus 300 ms · Inhalt raus 350 ms ab 250 ms<br>rein 350 ms ab max(250, geladen) · y 16 px · Puffer 50',
    nacheinander: '<b>power2.inOut</b> · cubic-bezier(0.65, 0, 0.35, 1)<br>raus 600 ms → laden 520 ms → rein 350 ms<br>kein Vorladen, Gesamt etwa 1470 ms',
    vt: '<b>startViewTransition</b> · power2.inOut<br>old 350 ms · new 350 ms ab 250 ms · y 16 px<br>Swap erst nach dem Laden (wie Taxi)'
  };
  function setzeVariante(v) {
    variante = v;
    knoepfe.forEach(k => k.setAttribute('aria-pressed', String(k.dataset.variante === v)));
    werteEl.innerHTML = WERTE[v];
  }
  knoepfe.forEach(k => k.addEventListener('click', () => { setzeVariante(k.dataset.variante); speicher.clear(); punkteMalen(); }));

  /* ---------- Start ---------- */
  function startseite() {
    buehne.innerHTML = '';
    const doc = new DOMParser().parseFromString(seitenHTML('/'), 'text/html');
    einsetzen(doc); aktuell = '/'; document.title = doc.title;
    speicher.clear(); speicher.set('/', { fertig: true, versprechen: Promise.resolve(doc) });
    punkteMalen();
  }

  /* ---------- Vorführung: gedachter Zeiger, eine kalte und eine vorgeladene Navigation ---------- */
  let lauf = 0, angehalten = false, zx = 0, zy = 0;
  const zeigerZu = (x, y, dauer = 700) => new Promise(r => {
    const vx = zx, vy = zy, s = performance.now();
    const k = t => 1 - Math.pow(1 - t, 3);
    const f = t => { const p = Math.min(1, (t - s) / dauer); zx = vx + (x - vx) * k(p); zy = vy + (y - vy) * k(p); zeiger.style.transform = `translate(${zx - 3}px, ${zy - 2}px)`; p < 1 ? requestAnimationFrame(f) : r(); };
    requestAnimationFrame(f);
  });
  const mitte = el => { const r = el.getBoundingClientRect(); return [r.left + Math.min(r.width * .35, 140), r.top + r.height / 2]; };
  async function schlafen(ms, id) { const s = performance.now(); while (performance.now() - s < ms) { if (id !== lauf) throw 0; await warte(50); while (angehalten) { if (id !== lauf) throw 0; await warte(100); } } }
  function klick(a) { zeiger.classList.remove('klick'); void zeiger.offsetWidth; zeiger.classList.add('klick'); a.click(); }
  async function warteFertig(id) { while (laeuft) await schlafen(60, id); }

  async function runde(id) {
    const q = s => buehne.querySelector('[data-container] ' + s);
    // 1) kalt: direkt klicken, Laden läuft hinter dem Raus
    const ziel1 = q('.liste li:nth-child(2) a');
    await zeigerZu(...mitte(ziel1), 800); await schlafen(120, id);
    klick(ziel1); await schlafen(400, id); await warteFertig(id); await schlafen(1900, id);
    // 2) vorgeladen: auf „Nächste“ zeigen, kurz warten, dann klicken
    const ziel2 = q('[data-naechste]');
    await zeigerZu(...mitte(ziel2), 800);
    ziel2.dispatchEvent(new PointerEvent('pointerover', { bubbles: true, pointerType: 'mouse' }));
    await schlafen(900, id);
    klick(ziel2); await schlafen(400, id); await warteFertig(id); await schlafen(1900, id);
    // 3) zurück zur Übersicht, ebenfalls vorgeladen
    const ziel3 = q('[data-zurueck]');
    await zeigerZu(...mitte(ziel3), 700);
    ziel3.dispatchEvent(new PointerEvent('pointerover', { bubbles: true, pointerType: 'mouse' }));
    await schlafen(800, id);
    klick(ziel3); await schlafen(400, id); await warteFertig(id); await schlafen(1300, id);
  }
  async function vorfuehren() {
    const id = ++lauf; angehalten = false;
    html.classList.add('vorfuehrung');
    if (html.classList.contains('vorschau')) setzeVariante('parallel');
    zx = innerWidth * .78; zy = innerHeight * .55; zeiger.style.transform = `translate(${zx}px, ${zy}px)`;
    try {
      await warteFertig(id);
      for (;;) { if (aktuell !== '/') startseite(); else { speicher.clear(); speicher.set('/', { fertig: true, versprechen: Promise.resolve(new DOMParser().parseFromString(seitenHTML('/'), 'text/html')) }); punkteMalen(); } await runde(id); }
    } catch (_) { /* abgebrochen */ }
  }
  function stoppVorfuehrung() { lauf++; angehalten = false; html.classList.remove('vorfuehrung'); }
  function neustart() { stoppVorfuehrung(); laeuft = false; startseite(); tlStart('Klick auf eine Arbeit'); tlEnde('Klick auf eine Arbeit'); summe.classList.add('leer'); summe.textContent = '0 ms'; }
  function pause() { angehalten = true; }
  function weiter() { if (html.classList.contains('vorfuehrung')) angehalten = false; }

  setzeVariante('parallel');
  startseite();

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