Zum Inhalt
← Seitenübergänge

Native Seitenübergänge zwischen echten Seiten (Cross-document View Transitions)

Echte Seitenaufrufe mit App-Gefühl: Der Kopf bleibt stehen, das Bild wächst aus der Karte zur Detailseite, und das Ganze steckt in einer einzigen CSS-Zeile.

Übersicht und Detailseiten mit gleichem Bild (Speisekarte, Projekte, Produkte): der Klassiker mit Wiedererkennung.

Beide Seiten (gleiche Origin) setzen in CSS @view-transition { navigation: auto; }. Beim Klick auf einen normalen Link macht der Browser einen Schnappschuss der alten Seite, lädt die neue ganz normal und blendet über. Standard ist ein Crossfade von ::view-transition-old(root) zu ::view-transition-new(root). Elemente mit demselben view-transition-name auf beiden Seiten lösen sich aus dem Crossfade und morphen in Position und Größe. Mit den Events pageswap (alte Seite, letzter Frame) und pagereveal (neue Seite, vor dem ersten Rendern) vergibt man Namen erst kurz vor dem Schnappschuss, liest navigation.activation (von welcher zu welcher URL) und setzt Typen wie viewTransition.types.add('forwards'). Mit html:active-view-transition-type(forwards) steuert man je Typ eigene Keyframes. Optional hält <link rel="expect" blocking="render" href="#hero"> das erste Rendern zurück, bis das Ziel-Element im DOM ist.

Die Seite fühlt sich wie eine App an, obwohl jede Navigation ein echter Seitenaufruf bleibt. Zurück-Button, Scroll-Wiederherstellung, Deep Links, Fokus, Ansage des Seitentitels und Analytics funktionieren ohne Nachbau. Es kostet kaum Gewicht und hat keine SPA-Nebenwirkungen wie doppelte Listener oder Embeds, die nicht neu starten.

Wann einsetzen

Bei Portfolio-, Studio-, Editorial- und Shop-Seiten mit klassischem Multi-Page-Aufbau, etwa Astro, Eleventy, WordPress, Webflow oder statisches HTML. Immer dann, wenn Crossfade, Wipe, Slide, Vorhang oder ein Shared-Element-Morph reichen.

Wann nicht

Wenn die Animation unterwegs reagieren soll, etwa gestaffelt per Split-Text oder physikbasiert: Schnappschüsse lassen sich nach dem Start nicht mehr umchoreografieren. Nicht, wenn WebGL über den Seitenwechsel weiterlaufen soll, und nicht für Navigationen zu einer anderen Origin. Firefox zeigt Stand Herbst 2026 noch einen normalen Seitenwechsel.

Varianten

  • Reiner Root-Crossfade: Standard, ohne eine Zeile JS
  • Root mit eigenem Keyframe: Wipe, Slide oder Scale-Fade
  • Mit benannten Elementen: Header bleibt stehen, Bild und Titel morphen
  • Typgesteuert: vorwärts und zurück, Übersicht zu Detail und Detail zu Übersicht
  • Selektiv aktiviert: Übergang nur für bestimmte Navigationen, Typ in pageswap gesetzt (Google AI Mode)
  • Mit Splash-Element: ein verstecktes Element mit eigenem view-transition-name, das nur während des Übergangs sichtbar ist (Usecue)
  • Quelle hervorheben: a:navigation-source bekommt einen eigenen view-transition-name, der angeklickte Link hebt sich im Übergang ab (Chrome 156, nur Chromium; Rückfall per Klick-Markierung)

Umsetzung

Global in CSS: @view-transition { navigation: auto; }. Namen über eine CSS-Variable vergeben oder im pageswap-Handler setzen und nach viewTransition.finished wieder entfernen, damit sie eindeutig bleiben. Den pagereveal-Listener als klassisches, parser-blockierendes Script im <head> registrieren. Typen übernimmt die neue Seite nicht von der alten, sie müssen dort erneut gesetzt werden. Dauer und Kurve setzt man auf ::view-transition-group(*), ::view-transition-old(*) und ::view-transition-new(*). Bei Bildern mit anderem Seitenverhältnis auf beiden Seiten ::view-transition-old/new(name) { height: 100%; object-fit: cover } setzen. Fallstricke: Navigationen über 4 s bricht Chrome mit TimeoutError ab. Bilder auf der Zielseite blockieren das Rendern, deshalb mit Speculation Rules vorrendern. view-transition-name erzeugt einen neuen Stacking Context. Width/Height-Keyframes laufen auf dem Main Thread, besser auf transform umrechnen. Keine Übergänge per Hover auslösen, weil die Pseudo-Ebene Hover blockiert. Den angeklickten Link trifft seit Chrome 156 (20.10.2026) der Selektor :navigation-source, ohne JavaScript: a:navigation-source { view-transition-name: quelle } auf der alten Seite. Firefox und Safari kennen ihn noch nicht (Stand 02.11.2026), deshalb in @supports selector(:navigation-source) und als eigene Regel; der Rückfall merkt sich den Link beim Klick oder liest NavigateEvent.sourceElement und setzt den Namen in pageswap.

Robust umsetzen

Telefon. Läuft auf iOS Safari ab 18.2 und auf Chrome für Android. Auf schwachen Geräten können automatisch erzeugte Größen-Animationen Frames verlieren (Google-Fallstudie), deshalb transform-Keyframes bevorzugen.

Ohne Bewegung. @media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } } ergibt einen normalen Seitenwechsel, ganz ohne JS. Alternativ Namen nur unter prefers-reduced-motion: no-preference vergeben, dann bleibt ein ruhiger Crossfade.

Barrierefrei. Fokus, Ansage des Seitentitels und Zurück-Navigation bleiben nativ, das ist der größte Vorteil. Während der Animation ist die Pseudo-Ebene nicht bedienbar, deshalb den Übergang kurz halten.

Code

<!DOCTYPE html>
<!--
  Native Seitenübergänge zwischen echten Seiten (Cross-document View Transitions)
  Fundus · Seitenübergänge · https://cecodesign.de/lab/fundus/seitenuebergaenge/vt-cross-document-mpa/
  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>Native Seitenübergänge zwischen echten Seiten · 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: 'Fraunces'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fraunces:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-style: normal; font-display: swap; }
@font-face { font-family: 'Fraunces'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/fraunces:vf@latest/latin-wght-italic.woff2') format('woff2-variations'); font-weight: 100 900; font-style: italic; font-display: swap; }
@font-face { font-family: 'Commissioner'; src: url('https://cdn.jsdelivr.net/fontsource/fonts/commissioner:vf@latest/latin-full-normal.woff2') format('woff2-variations'); font-weight: 100 900; font-display: swap; }

/* Im echten Einsatz (zwei HTML-Seiten, gleiche Origin) steht in beiden Seiten nur:
   @view-transition { navigation: auto; }
   Die Demo simuliert die beiden Seiten als Zustände und startet denselben Übergang
   mit document.startViewTransition(). Die ::view-transition-Regeln unten gelten 1:1. */

:root {
  /* Osteria Cardo: dunkles Tannengrün als Raum, Papier-Weiß als Schrift, Senf und Rosé als Hausfarben */
  --grund: #293e3a; --grund-tief: #1f302c; --papier: #f3eee2; --leise: rgba(243, 238, 226, .64); --linie: rgba(243, 238, 226, .16);
  --senf: #eac16e; --rose: #e1a894; --salbei: #7f937a; --tinte: #23302c;
  --rand: clamp(16px, 3.2vw, 44px);
  --kopf: 68px;
  /* Übergang: wird je Variante per data-variante gesetzt */
  --vt-dauer: 600ms;
  --vt-kurve: cubic-bezier(0.76, 0, 0.24, 1);
}
:root[data-variante='crossfade'] { --vt-dauer: 400ms; --vt-kurve: cubic-bezier(0.25, 0.1, 0.25, 1); }
:root.reduziert { --vt-dauer: 250ms; --vt-kurve: cubic-bezier(0.25, 0.1, 0.25, 1); }

* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--grund); color: var(--papier); font-family: 'Commissioner', system-ui, sans-serif; font-variation-settings: 'FLAR' 0; -webkit-font-smoothing: antialiased; }
body { min-height: 100svh; }
a { color: inherit; text-decoration: none; }
h1[tabindex]:focus { outline: none; }
svg { display: block; }
.serif { font-family: 'Fraunces', Georgia, serif; font-optical-sizing: auto; font-variation-settings: 'SOFT' 100, 'WONK' 0; }
em { font-family: 'Fraunces', Georgia, serif; font-style: italic; font-weight: 400; }
/* Versal-Labels: Commissioner mit ausgestellten Strichenden (FLAR) als Brücke zur Serif */
.label { font: 560 11.5px/1.45 'Commissioner', system-ui, sans-serif; font-variation-settings: 'FLAR' 90; letter-spacing: .15em; text-transform: uppercase; }
.leise { color: var(--leise); }
.preis { font-variant-numeric: tabular-nums lining-nums; }
.knopf { display: inline-flex; align-items: center; justify-content: center; gap: .6em; height: 40px; padding: 0 18px; border-radius: 999px; background: var(--senf); color: var(--tinte); transition: background-color .15s linear; white-space: nowrap; }
.knopf:hover { background: #f1cf87; }
.knopf--leer { background: none; color: var(--papier); box-shadow: inset 0 0 0 1px var(--linie); }
.knopf--leer:hover { background: rgba(243, 238, 226, .08); }
.knopf:focus-visible { outline: 2px solid var(--papier); outline-offset: 3px; }

/* ---------- Kopf (bleibt über den Seitenwechsel stehen) ---------- */
.kopf { position: sticky; top: 0; z-index: 10; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 16px;
  height: var(--kopf); padding: 0 var(--rand); background: var(--grund); border-bottom: 1px solid var(--linie); }
.marke { display: inline-flex; align-items: center; gap: .6em; white-space: nowrap; font-size: 22px; font-weight: 520; letter-spacing: -.01em; line-height: 1; }
.marke svg { width: 26px; height: 26px; color: var(--senf); }
.marke small { display: block; margin-top: 3px; font-size: 9.5px; color: var(--leise); }
.menue { display: flex; gap: clamp(16px, 2.4vw, 32px); }
.menue a { position: relative; color: var(--leise); transition: color .15s linear; }
.menue a:hover, .menue a[aria-current] { color: var(--papier); }
.menue a[aria-current]::after { content: ''; position: absolute; left: 0; right: 0; bottom: -7px; height: 1.5px; background: var(--senf); }
.kopf__rechts { justify-self: end; display: flex; align-items: center; gap: 18px; }
.kopf__rechts .knopf { height: 36px; }
.offen { display: inline-flex; align-items: center; gap: .6em; }
.offen i { width: 7px; height: 7px; border-radius: 50%; background: var(--salbei); box-shadow: 0 0 0 3px rgba(127, 147, 122, .25); }
.offen[data-auf] i { background: #9fc48b; box-shadow: 0 0 0 3px rgba(159, 196, 139, .25); }

/* ---------- Übersicht: die Karte ---------- */
.aufmacher { display: grid; grid-template-columns: minmax(0, 1fr) minmax(240px, 340px); align-items: end; gap: 20px 56px;
  padding: clamp(26px, 4.6vh, 56px) var(--rand) clamp(22px, 4vh, 40px); }
.titel-gross { font-size: clamp(42px, 5.8vw, 96px); font-weight: 430; line-height: .98; letter-spacing: -.02em; }
.titel-gross em { color: var(--senf); font-weight: 340; }
.intro { display: grid; gap: 18px; }
.intro p { font-size: clamp(15px, 1.1vw, 17px); line-height: 1.55; color: var(--leise); max-width: 36ch; }
.intro__zeile { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; }
.abschnitt { display: flex; justify-content: space-between; gap: 12px; padding: 0 var(--rand) 14px; color: var(--leise); }
.raster { list-style: none; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: clamp(14px, 1.8vw, 28px); padding: 0 var(--rand); }
.karte { display: grid; gap: 14px; outline: none; }
.karte__bild { display: block; aspect-ratio: 4 / 3; overflow: hidden; border-radius: 4px; }
.karte__bild svg, .held svg, .weiter__bild svg { width: 100%; height: 100%; }
.karte__bild svg { transition: scale 1s cubic-bezier(0.16, 1, 0.3, 1); }
.karte__zeile { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: baseline; gap: 6px 14px; }
.karte__gang { grid-column: 1 / -1; color: var(--senf); }
/* Kartentitel und h1: gleiche Schnittwerte, Laufweite und Zeilenhöhe, damit die Schnappschüsse deckungsgleich skalieren */
.karte__titel, .projekt__titel { font-family: 'Fraunces', Georgia, serif; font-weight: 460; font-variation-settings: 'opsz' 60, 'SOFT' 100, 'WONK' 0; letter-spacing: -.015em; line-height: 1; white-space: nowrap; }
.karte__titel { justify-self: start; font-size: clamp(24px, 2vw, 30px); }
.karte__preis { font-size: 17px; font-weight: 500; }
.karte__art { grid-column: 1 / -1; font-size: 14.5px; line-height: 1.45; color: var(--leise); max-width: 34ch; }
@media (hover: hover) {
  .karte:hover .karte__bild svg { scale: 1.035; }
}
.karte:focus-visible .karte__titel { text-decoration: underline; text-underline-offset: .18em; text-decoration-thickness: 1px; }
.fuss { display: grid; grid-template-columns: repeat(3, minmax(0, auto)); justify-content: space-between; gap: 12px 24px; margin: clamp(40px, 8vh, 96px) var(--rand) 0; padding: 18px 0 clamp(120px, 17vh, 160px); border-top: 1px solid var(--linie); color: var(--leise); }

/* ---------- Gerichtseite ---------- */
.projekt { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); align-items: start; gap: clamp(24px, 4vw, 64px); padding: clamp(18px, 3.4vh, 36px) var(--rand) 0; }
.projekt__text { display: flex; flex-direction: column; min-width: 0; }
.zurueck { display: inline-flex; align-items: center; gap: .7em; align-self: flex-start; color: var(--leise); transition: color .15s linear; }
.zurueck span { transition: translate .15s linear; }
.zurueck:hover { color: var(--papier); }
.zurueck:hover span { translate: -4px 0; }
.zurueck:focus-visible { outline: 1px solid currentColor; outline-offset: 6px; }
.projekt__label { margin-top: clamp(28px, 6.4vh, 80px); color: var(--senf); }
.projekt__titel { justify-self: start; align-self: flex-start; margin: 14px 0 14px; font-size: clamp(44px, 5.4vw, 96px); outline: none; }
.projekt__unter { font-size: clamp(21px, 2vw, 30px); line-height: 1.18; font-weight: 380; }
.projekt__unter em { color: var(--rose); }
.fakten { margin-top: clamp(22px, 4vh, 40px); max-width: 470px; font-size: 14.5px; }
.fakten div { display: grid; grid-template-columns: 8.2em 1fr; gap: 12px; padding: 10px 0; border-top: 1px solid var(--linie); }
.fakten dt { color: var(--leise); }
.fakten dt.label { font-size: 10.5px; padding-top: 2px; }
.projekt__lauf { margin-top: clamp(18px, 3vh, 30px); max-width: 44ch; font-size: clamp(15px, 1.1vw, 17px); line-height: 1.55; display: grid; gap: .8em; color: rgba(243, 238, 226, .86); }
.projekt__tun { display: flex; flex-wrap: wrap; gap: 10px; margin-top: clamp(20px, 3.4vh, 34px); }
.held { width: 100%; aspect-ratio: 4 / 3; overflow: hidden; border-radius: 4px; }
.weiter { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 20px; margin: clamp(48px, 9vh, 110px) var(--rand) 0; padding: 20px 0 clamp(120px, 17vh, 160px); border-top: 1px solid var(--linie); }
.weiter__titel { display: block; margin-top: 10px; font-size: clamp(34px, 4.6vw, 72px); font-weight: 420; letter-spacing: -.02em; line-height: 1; }
.weiter__titel span { color: var(--senf); }
.weiter__bild { width: clamp(120px, 13vw, 190px); aspect-ratio: 4 / 3; overflow: hidden; border-radius: 3px; }
.weiter:focus-visible .weiter__titel { text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: .1em; }

/* ---------- View Transitions (gelten genauso für @view-transition zwischen echten Seiten) ---------- */
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) { animation-duration: var(--vt-dauer); animation-timing-function: var(--vt-kurve); }

/* Bild und Titel morphen: Schnappschüsse füllen die Gruppe, statt verzerrt zu werden */
::view-transition-old(held), ::view-transition-new(held) { height: 100%; object-fit: cover; }
::view-transition-old(titel), ::view-transition-new(titel) { width: 100%; height: 100%; }
::view-transition-group(kopf) { z-index: 3; }

/* Benannt: der Rest der Seite überblendet nacheinander statt doppelt belichtet */
html[data-variante='benannt']:not(.reduziert) {
  &::view-transition-old(root) { animation: vt-aus 300ms var(--vt-kurve) both; }
  &::view-transition-new(root) { animation: vt-ein 400ms var(--vt-kurve) 200ms both; }
}
@keyframes vt-aus { to { opacity: 0; } }
@keyframes vt-ein { from { opacity: 0; } }

/* Typgesteuert: vorwärts schiebt die neue Seite von rechts herein, zurück umgekehrt */
html:active-view-transition-type(vorwaerts) {
  &::view-transition-old(root) { animation-name: vt-raus-links; }
  &::view-transition-new(root) { animation-name: vt-rein-rechts; }
}
html:active-view-transition-type(zurueck) {
  &::view-transition-old(root) { animation-name: vt-raus-rechts; z-index: 1; }
  &::view-transition-new(root) { animation-name: vt-rein-links; }
}
@keyframes vt-raus-links { to { transform: translateX(-30%); } }
@keyframes vt-rein-rechts { from { transform: translateX(100%); box-shadow: -40px 0 80px rgba(25, 24, 20, .16); } to { box-shadow: -40px 0 80px rgba(25, 24, 20, 0); } }
@keyframes vt-raus-rechts { from { box-shadow: -40px 0 80px rgba(25, 24, 20, .16); } to { transform: translateX(100%); box-shadow: -40px 0 80px rgba(25, 24, 20, 0); } }
@keyframes vt-rein-links { from { transform: translateX(-30%); } }

/* Quelle: der angeklickte Link bleibt über dem Seitenwechsel stehen, hebt sich an und blendet zuletzt aus.
   Im echten Einsatz (zwei Seiten) markiert ihn :navigation-source (Chrome 156) ohne JavaScript.
   Die Demo navigiert nicht wirklich, deshalb setzt sie beim Klick data-quelle; das ist zugleich der Rückfall
   für Browser ohne den Selektor (Klick merken oder navigation.addEventListener('navigate', e => e.sourceElement)).
   Beide Wege in getrennten Regeln, sonst verwirft ein Browser ohne :navigation-source die ganze Regel. */
html[data-variante='quelle'] [data-quelle] .karte__bild { outline: 2px solid var(--senf); outline-offset: 5px; }
html[data-variante='quelle'] [data-quelle] :is(.karte__titel, .weiter__titel), html[data-variante='quelle'] .zurueck[data-quelle] { color: var(--senf); }
html[data-variante='quelle']:not(.reduziert) [data-quelle] { view-transition-name: quelle; }
@supports selector(:navigation-source) {
  html[data-variante='quelle'] :navigation-source .karte__bild { outline: 2px solid var(--senf); outline-offset: 5px; }
  html[data-variante='quelle'] :navigation-source :is(.karte__titel, .weiter__titel), html[data-variante='quelle'] .zurueck:navigation-source { color: var(--senf); }
  html[data-variante='quelle']:not(.reduziert) a:navigation-source { view-transition-name: quelle; }
}
html[data-variante='quelle']:not(.reduziert) {
  &::view-transition-group(quelle) { z-index: 4; animation-duration: 0s; }
  &::view-transition-old(quelle) { animation: vt-quelle 900ms var(--vt-kurve) both; }
  &::view-transition-old(root) { animation: vt-dunkel 400ms var(--vt-kurve) both; }
  &::view-transition-new(root) { animation: vt-ein 450ms var(--vt-kurve) 300ms both; }
}
@keyframes vt-quelle { 0% { scale: 1; } 40% { scale: 1.04; opacity: 1; } 70% { scale: 1.04; opacity: 1; } 100% { scale: 1.04; opacity: 0; } }
@keyframes vt-dunkel { to { opacity: 0; filter: brightness(.6); } }

/* Reduzierte Bewegung: keine Namen (im JS), keine Typen, nur der ruhige Root-Crossfade */

/* ---------- Demo-Steuerung ---------- */
.dock { position: fixed; z-index: 40; left: 50%; bottom: 16px; translate: -50% 0; width: max-content; max-width: calc(100% - 24px);
  display: grid; gap: 8px; padding: 10px 12px; border-radius: 14px; color: var(--papier);
  background: rgba(20, 31, 28, .84); -webkit-backdrop-filter: blur(14px) saturate(1.2); backdrop-filter: blur(14px) saturate(1.2); box-shadow: 0 10px 40px rgba(0, 0, 0, .28), inset 0 0 0 1px rgba(243, 238, 226, .08);
  view-transition-name: dock; }
.dock__zeile { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.dock .label { font-size: 10.5px; color: rgba(243, 238, 226, .58); }
.modi { display: flex; gap: 4px; }
.modi button { font: inherit; letter-spacing: inherit; text-transform: inherit; font-variation-settings: inherit; color: rgba(243, 238, 226, .74); background: none; border: 1px solid rgba(243, 238, 226, .16); border-radius: 999px; height: 28px; padding: 0 12px; cursor: pointer; transition: background-color .15s linear, color .15s linear; }
.dock .modi button { color: rgba(243, 238, 226, .74); }
.modi button:hover { color: var(--papier); }
.dock .modi button[aria-pressed='true'] { background: var(--senf); color: var(--tinte); border-color: var(--senf); }
.reduziert .modi { opacity: .55; }
.modi button:focus-visible { outline: 2px solid var(--papier); outline-offset: 2px; }
.werte { font: 400 11px/1.6 'Commissioner', system-ui, sans-serif; font-variation-settings: 'FLAR' 0; letter-spacing: .01em; color: rgba(243, 238, 226, .62); font-variant-numeric: tabular-nums; }
.werte > span { display: block; white-space: nowrap; }
.werte b { color: var(--papier); font-weight: 560; }
::view-transition-group(dock) { animation: none; z-index: 5; }
::view-transition-old(dock) { display: none; }
::view-transition-new(dock) { animation: none; }

/* Gedachter Zeiger für die Vorführung */
.zeiger { position: fixed; z-index: 50; left: 0; top: 0; width: 26px; height: 26px; margin: -13px 0 0 -13px; border-radius: 50%; border: 1.5px solid #fff; background: rgba(255, 255, 255, .22); mix-blend-mode: difference; pointer-events: none; opacity: 0; transition: opacity .15s linear; view-transition-name: zeiger; }
.zeiger.an { opacity: 1; }
::view-transition-group(zeiger) { animation: none; z-index: 6; }
::view-transition-old(zeiger) { display: none; }
::view-transition-new(zeiger) { animation: none; }

:root.reduziert * { transition-duration: .01ms !important; }

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

@media (hover: none) {
  .modi button { height: 34px; }
}
@media (max-width: 1100px) {
  .offen { display: none; }
}
@media (max-width: 900px) {
  .aufmacher { grid-template-columns: minmax(0, 1fr); }
  .projekt { grid-template-columns: minmax(0, 1fr); }
  .projekt__text { order: 2; }
  .held { order: 1; }
  .projekt__label { margin-top: 8px; }
  .zurueck { margin-bottom: 4px; }
  .fuss { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 720px) {
  .menue { display: none; }
  .kopf { grid-template-columns: 1fr auto; }
  .raster { grid-template-columns: minmax(0, 1fr); row-gap: 32px; }
  .werte > span { white-space: normal; }
  .werte .lang { display: none; }
  .dock { width: calc(100% - 24px); padding: 8px 10px; bottom: 12px; background: rgba(20, 31, 28, .95); }
  .dock__zeile > span.label { display: none; }
  .projekt__titel { white-space: normal; }
  .weiter__bild { display: none; }
  .intro__zeile .knopf { flex: 1 1 auto; }
  .marke { font-size: 19px; }
  .kurz-weg { display: none; }
  .abschnitt span:last-child { display: none; }
}
/* 320 px: Kopf-Knopf etwas schmaler, damit nichts quer scrollt */
@media (max-width: 380px) { .kopf { gap: 10px; } .kopf__rechts .knopf { padding: 0 13px; } }
/* Kontrastmodus: Knöpfe mit Rahmen */
@media (forced-colors: active) { button, [role=button], [role=tab], [role=switch] { border: 1px solid ButtonText !important; } }
</style>
</head>
<body>
<header class="kopf" data-kopf>
  <a class="marke serif" href="#karte" data-ziel="karte" aria-label="Osteria Cardo, Startseite">
    <svg viewBox="0 0 26 26" aria-hidden="true"><path d="M13 24.5V12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M13 13.2c-3.4 0-5.6-2.6-5.6-5.6 1.7.2 2.9 1 3.6 2.1C11.2 6.6 12 4 13 1.5c1 2.5 1.8 5.1 2 8.2.7-1.1 1.9-1.9 3.6-2.1 0 3-2.2 5.6-5.6 5.6Z" fill="currentColor"/><path d="M13 19c-1.8-2.2-4.2-2.9-6.6-2.4M13 19c1.8-2.2 4.2-2.9 6.6-2.4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
    <span>Osteria Cardo<small class="label">Mainz</small></span>
  </a>
  <nav class="menue label" aria-label="Hauptmenü">
    <a href="#karte" data-ziel="karte" aria-current="page">Karte</a>
    <a href="#haus" data-tot>Haus</a>
    <a href="#wein" data-tot>Wein</a>
    <a href="#gruppen" data-tot>Gruppen</a>
  </nav>
  <div class="kopf__rechts">
    <span class="offen label leise" data-offen><i aria-hidden="true"></i><span></span></span>
    <a class="knopf label" href="#reservieren" data-tot><span><span class="kurz-weg">Tisch </span>reservieren</span></a>
  </div>
</header>
<main id="seite"></main>

<aside class="dock" aria-label="Demo-Steuerung">
  <div class="dock__zeile">
    <span class="label">Übergang</span>
    <div class="modi label" role="group" aria-label="Art des Seitenübergangs">
      <button type="button" data-variante="benannt" aria-pressed="true">Benannt</button>
      <button type="button" data-variante="typen" aria-pressed="false">Typen</button>
      <button type="button" data-variante="crossfade" aria-pressed="false">Crossfade</button>
      <button type="button" data-variante="quelle" aria-pressed="false">Quelle</button>
    </div>
  </div>
  <p class="werte" data-werte aria-live="polite"></p>
</aside>
<div class="zeiger" aria-hidden="true"></div>

<script>
(() => {
  const html = document.documentElement;
  const reduziert = () => html.classList.contains('reduziert');
  const seite = document.getElementById('seite');
  const kopf = document.querySelector('[data-kopf]');
  const zeiger = document.querySelector('.zeiger');
  const werteFeld = document.querySelector('[data-werte]');
  const kannVT = typeof document.startViewTransition === 'function';

  /* Werte: Root-Crossfade 400 ms (Policybazaar), Browser-Kurve ease;
     Seitenübergänge 600 ms mit cubic-bezier(0.76, 0, 0.24, 1) (typischer Seiten-Wipe) */
  const WERTE = {
    benannt:   { dauer: 600, kurve: 'cubic-bezier(0.76, 0, 0.24, 1)' },
    typen:     { dauer: 600, kurve: 'cubic-bezier(0.76, 0, 0.24, 1)' },
    crossfade: { dauer: 400, kurve: 'cubic-bezier(0.25, 0.1, 0.25, 1)' },
    quelle:    { dauer: 900, kurve: 'cubic-bezier(0.76, 0, 0.24, 1)' }
  };
  const kannQuelle = !!(window.CSS && CSS.supports && CSS.supports('selector(:navigation-source)'));
  const VARIANTEN = Object.keys(WERTE);
  const ausUrl = new URLSearchParams(location.search).get('v');
  const REDUZIERT = { dauer: 250, kurve: 'cubic-bezier(0.25, 0.1, 0.25, 1)' };
  let variante = 'benannt', letzteNavigation = null;

  /* ---------- Öffnungszeiten: Status aus echten Zeiten berechnet, nicht als Deko ---------- */
  // Mo Ruhetag · Di–Sa 17:30–23 Uhr · So 12–15 Uhr
  const ZEITEN = [[12 * 60, 15 * 60], null, [17.5 * 60, 23 * 60], [17.5 * 60, 23 * 60], [17.5 * 60, 23 * 60], [17.5 * 60, 23 * 60], [17.5 * 60, 23 * 60]];
  const TAGE = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
  const uhr = m => `${Math.floor(m / 60)}${m % 60 ? ':' + String(m % 60).padStart(2, '0') : ''}`;
  function oeffnung() {
    const d = new Date(), tag = d.getDay(), jetzt = d.getHours() * 60 + d.getMinutes(), z = ZEITEN[tag];
    if (z && jetzt >= z[0] && jetzt < z[1]) return { auf: true, text: `Jetzt geöffnet bis ${uhr(z[1])} Uhr` };
    if (z && jetzt < z[0]) return { auf: false, text: `Heute ab ${uhr(z[0])} Uhr` };
    for (let i = 1; i < 8; i++) { const t = (tag + i) % 7; if (ZEITEN[t]) return { auf: false, text: `${i === 1 ? 'Morgen' : TAGE[t]} ab ${uhr(ZEITEN[t][0])} Uhr` }; }
  }
  const status = oeffnung(), offenFeld = document.querySelector('[data-offen]');
  offenFeld.querySelector('span').textContent = status.text;
  if (status.auf) offenFeld.setAttribute('data-auf', '');

  /* ---------- Bildwelt: eigene SVG-Teller von oben, keine Fotos ---------- */
  let svgNr = 0;
  const blatt = (x, y, l, r, f, ader) => `<g transform="translate(${x} ${y}) rotate(${r})"><path d="M0 0C${l * .3} ${-l * .3} ${l * .72} ${-l * .3} ${l} 0C${l * .72} ${l * .3} ${l * .3} ${l * .3} 0 0Z" fill="${f}"/>${ader ? `<path d="M${l * .08} 0H${l * .86}" stroke="${ader}" stroke-width="1.2" stroke-linecap="round"/>` : ''}</g>`;
  const punkte = (liste, f, r) => liste.map(([x, y], i) => `<circle cx="${x}" cy="${y}" r="${Array.isArray(r) ? r[i % r.length] : r}" fill="${f}"/>`).join('');
  // Rand-Requisiten: Leinenserviette mit Streifen und Gabel als Silhouette
  const tisch = (n, grund, serviette, streifen) => `
    <rect width="400" height="300" fill="${grund}"/>
    <g transform="translate(-34 196) rotate(-14)"><rect width="128" height="150" rx="3" fill="${serviette}"/><path d="M0 22H128M0 30H128" stroke="${streifen}" stroke-width="3"/></g>
    <g transform="translate(352 40) rotate(8)" fill="rgba(20,28,25,.2)"><rect x="-3" y="62" width="6" height="170" rx="3"/><path d="M-14 0h4v40h3V0h4v40h3V0h4v40h3V0h4v44c0 12-8 20-14 22h-0c-6-2-15-10-15-22Z"/></g>
    <ellipse cx="208" cy="162" rx="128" ry="124" fill="url(#s${n})"/>
    <circle cx="200" cy="150" r="118" fill="#f8f4ea"/>
    <circle cx="200" cy="150" r="118" fill="none" stroke="rgba(35,48,44,.08)" stroke-width="1.5"/>
    <circle cx="200" cy="150" r="88" fill="#efe8da"/>
    <circle cx="200" cy="150" r="88" fill="none" stroke="rgba(35,48,44,.07)" stroke-width="1"/>
    <path d="M118 108a92 92 0 0 1 66-48" fill="none" stroke="#fff" stroke-width="4" stroke-linecap="round" opacity=".8"/>`;
  const schatten = n => `<defs><radialGradient id="s${n}"><stop offset=".78" stop-color="#101a17" stop-opacity=".32"/><stop offset="1" stop-color="#101a17" stop-opacity="0"/></radialGradient></defs>`;

  function teller(id, alt) {
    const n = ++svgNr;
    const kopfzeile = `<svg viewBox="0 0 400 300" preserveAspectRatio="xMidYMid slice" ${alt ? `role="img" aria-label="${alt}"` : 'aria-hidden="true"'}>${schatten(n)}`;
    if (id === 'rote-bete') {
      const viertel = r => `M0 0H${r}A${r} ${r} 0 0 1 0 ${r}Z`;
      const keil = (x, y, r, w, a, b) => `<g transform="translate(${x} ${y}) rotate(${w})"><path d="${viertel(r)}" fill="${a}"/><path d="${viertel(r * .78)}" fill="${b}"/><path d="${viertel(r * .5)}" fill="${a}"/></g>`;
      const scheibe = (x, y, r, a, b, c) => `<circle cx="${x}" cy="${y}" r="${r}" fill="${a}"/><circle cx="${x}" cy="${y}" r="${r * .8}" fill="${b}"/><circle cx="${x}" cy="${y}" r="${r * .58}" fill="${a}"/><circle cx="${x}" cy="${y}" r="${r * .34}" fill="${c}"/><circle cx="${x - r * .3}" cy="${y - r * .35}" r="${r * .16}" fill="#fff" opacity=".18"/>`;
      return kopfzeile + tisch(n, '#eac16e', '#f6efe0', '#c9573f') + `
        ${scheibe(168, 124, 34, '#6e1238', '#9a2556', '#b6406f')}
        ${scheibe(232, 132, 30, '#c97a1f', '#e7a73a', '#f4c55e')}
        ${scheibe(196, 186, 31, '#6e1238', '#9a2556', '#b6406f')}
        ${scheibe(244, 188, 22, '#a8325e', '#e6a2bd', '#a8325e')}
        ${keil(142, 182, 30, -100, '#6e1238', '#9a2556')}${keil(262, 104, 26, 120, '#c97a1f', '#e7a73a')}
        <circle cx="206" cy="146" r="15" fill="#e8dfcf"/><circle cx="204" cy="144" r="15" fill="#fcfaf4"/>
        <circle cx="154" cy="150" r="10" fill="#e8dfcf"/><circle cx="152" cy="148" r="10" fill="#fcfaf4"/>
        <circle cx="226" cy="214" r="11" fill="#e8dfcf"/><circle cx="224" cy="212" r="11" fill="#fcfaf4"/>
        <circle cx="270" cy="152" r="8" fill="#e8dfcf"/><circle cx="268" cy="150" r="8" fill="#fcfaf4"/>
        ${punkte([[186, 104], [214, 160], [176, 160], [252, 162], [160, 196], [230, 108], [210, 214], [268, 176], [142, 128]], '#9a6634', [3.4, 2.6, 3])}
        ${punkte([[188, 102], [215, 158], [253, 160], [229, 106]], '#c99559', 1.2)}
        ${blatt(198, 140, 16, -40, '#6f8f3c', '#a7c06a')}${blatt(214, 150, 13, 30, '#5f7f33', '')}${blatt(150, 146, 12, -110, '#6f8f3c', '')}${blatt(222, 210, 14, 200, '#6f8f3c', '#a7c06a')}${blatt(266, 148, 11, -70, '#5f7f33', '')}
        ${punkte([[180, 202], [246, 118], [146, 116], [262, 204]], 'rgba(183,170,72,.75)', [2.4, 1.8])}
      </svg>`;
    }
    if (id === 'cappellacci') {
      const hut = (x, y, r) => `<g transform="translate(${x} ${y}) rotate(${r})"><path d="M-28 4C-28-14-14-24 0-24S28-14 28 4C20 10 10 12 0 12S-20 10-28 4Z" fill="#e2b45a"/><path d="M-26 2C-25-12-13-21 0-21S25-12 26 2C18 7 9 9 0 9S-18 7-26 2Z" fill="#f2d48a"/><ellipse cx="0" cy="-6" rx="12" ry="10" fill="#f8e2a6"/><path d="M-12-4c4 5 20 5 24 0" fill="none" stroke="#d9a548" stroke-width="1.6" stroke-linecap="round"/><path d="M-20-10c3-6 9-9 14-10" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" opacity=".55"/></g>`;
      return kopfzeile + tisch(n, '#7f937a', '#e9e4d6', '#2f4a40') + `
        <path d="M148 120C168 92 238 88 258 120S240 206 196 204 130 170 148 120Z" fill="#e8892e"/>
        <path d="M158 122C176 102 230 100 246 124" fill="none" stroke="#f3a85c" stroke-width="7" stroke-linecap="round"/>
        ${punkte([[160, 182], [254, 170], [176, 106], [236, 196], [268, 132]], 'rgba(176,112,40,.55)', [7, 5, 6])}
        ${hut(176, 136, -18)}${hut(226, 128, 22)}${hut(206, 176, 4)}${hut(158, 178, -40)}${hut(250, 172, 38)}
        ${blatt(188, 108, 34, -28, '#3f5c35', '#6f8c55')}${blatt(232, 160, 30, 64, '#476638', '#77935b')}${blatt(150, 152, 28, 200, '#3f5c35', '#6f8c55')}${blatt(262, 112, 24, -120, '#476638', '')}
        <path d="M196 148l14-4 4 9-15 4Z" fill="#fbf3d9"/><path d="M230 104l12 2-2 8-12-3Z" fill="#fbf3d9"/><path d="M170 198l10-6 5 7-11 5Z" fill="#fbf3d9"/><path d="M264 150l9 4-4 7-8-5Z" fill="#fbf3d9"/>
        ${punkte([[200, 120], [218, 152], [182, 160], [240, 140], [192, 196], [168, 122], [254, 192]], '#2b231b', 1.3)}
      </svg>`;
    }
    // Zwetschge
    const haelfte = (x, y, r) => `<g transform="translate(${x} ${y}) rotate(${r})"><ellipse rx="31" ry="27" fill="#3b1640"/><ellipse rx="27" ry="23" fill="#c4582f"/><ellipse rx="23" ry="19" fill="#e3a03c"/><ellipse rx="15" ry="12" fill="#eeb655"/><ellipse cx="2" rx="8" ry="11" fill="#8a4a2a"/><ellipse cx="-1" cy="-3" rx="2.5" ry="4" fill="#b8744a"/></g>`;
    return kopfzeile + tisch(n, '#e1a894', '#f4ede0', '#7a2a44') + `
      <path d="M140 150C138 116 170 94 204 98S266 120 262 156 230 214 196 210 142 184 140 150Z" fill="#4a1838"/>
      <path d="M152 140C160 118 184 106 206 108" fill="none" stroke="#6f2e55" stroke-width="5" stroke-linecap="round"/>
      ${haelfte(180, 134, -20)}${haelfte(236, 150, 24)}${haelfte(196, 188, 70)}
      <g transform="translate(238 104) rotate(-22)"><ellipse rx="34" ry="16" fill="#e5dccb"/><ellipse cx="-2" cy="-2" rx="33" ry="15" fill="#fcf8ef"/><path d="M-22-6c12-6 30-6 42 0" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round"/><path d="M-26 3c14 5 34 5 48 0" fill="none" stroke="#ebe2d1" stroke-width="2" stroke-linecap="round"/></g>
      ${punkte([[160, 172], [164, 180], [154, 168], [226, 196], [232, 204], [270, 130], [262, 124], [214, 116], [148, 120]], '#c8955a', [3.6, 2.4, 3])}
      ${punkte([[166, 176], [228, 200], [266, 128], [150, 124]], '#e6c189', [1.8, 1.4])}
      ${blatt(258, 92, 14, -150, '#58783f', '#8daa62')}${blatt(262, 98, 11, -100, '#4c6c36', '')}${blatt(152, 196, 12, 150, '#58783f', '')}
    </svg>`;
  }

  /* ---------- Inhalte: Osteria Cardo, Herbstkarte ---------- */
  const PROJEKTE = [
    { id: 'rote-bete', name: 'Rote Bete', gang: 'Antipasto', preis: '13', art: 'Ringelbete, Gelbe Bete, Ziegenquark, Haselnuss',
      unter: 'Drei Sorten Bete, <em>ein Löffel Quark.</em>',
      fakten: [['Herkunft', 'Bete vom Wochenmarkt am Dom'], ['Dazu', 'Silvaner, Rheinhessen, trocken'], ['Allergene', 'G, H']],
      text: ['Rote, gelbe und Ringelbete garen zwei Stunden im Salzteig, bis sie fast süß sind. Wir schneiden sie dünn, legen sie lauwarm aus und setzen Ziegenquark aus der Pfalz dazu.',
             'Geröstete Haselnüsse, etwas Kresse und ein Tropfen Holunderessig. Mehr braucht dieser Teller nicht.'] },
    { id: 'cappellacci', name: 'Cappellacci', gang: 'Primo', preis: '19', art: 'Muskatkürbis, braune Butter, Salbei, Parmigiano',
      unter: 'Kürbis im Teig, <em>Salbei in der Butter.</em>',
      fakten: [['Teig', 'jeden Mittag von Hand gefaltet'], ['Dazu', 'Grauburgunder, Rheinhessen'], ['Allergene', 'A, C, G']],
      text: ['Cappellacci kommen aus Ferrara, wo man Kürbis mit Amaretti und Senffrüchten füllt. Wir bleiben nah am Original und nehmen Muskatkürbis aus Rheinhessen.',
             'Rund vierhundert Stück falten wir jeden Mittag. Am Tisch kommen braune Butter, frittierter Salbei und Parmigiano, 24 Monate gereift, dazu.'] },
    { id: 'zwetschge', name: 'Zwetschge', gang: 'Dolce', preis: '9', art: 'in Rotwein geschmort, Mascarpone, Amaretti',
      unter: 'Späte Zwetschgen, <em>kalte Mascarpone.</em>',
      fakten: [['Frucht', 'Hauszwetschgen, letzte Ernte'], ['Dazu', 'Vin Santo, 5 cl, 7 €'], ['Allergene', 'A, C, G, H']],
      text: ['Die letzten Zwetschgen des Jahres sind die besten: fest, dunkel, mit wenig Säure. Wir schmoren sie halbiert in Rotwein mit Zimt und Orangenschale.',
             'Dazu eine Nocke Mascarpone und zerbröselte Amaretti aus der eigenen Backstube. Nur solange die Zwetschgen reichen.'] }
  ];
  const pfad = ziel => ziel === 'karte' ? '/karte' : '/karte/' + ziel;

  function seiteKarte() {
    const karten = PROJEKTE.map(p => `
      <li><a class="karte" href="#${p.id}" data-ziel="${p.id}">
        <span class="karte__bild" data-bild="${p.id}">${teller(p.id)}</span>
        <span class="karte__zeile"><span class="label karte__gang">${p.gang}</span><b class="karte__titel" data-titel="${p.id}">${p.name}</b><span class="karte__preis preis">${p.preis} €</span><span class="karte__art">${p.art}</span></span>
      </a></li>`).join('');
    return `<div data-seite="karte">
      <section class="aufmacher">
        <h1 class="titel-gross serif" tabindex="-1">Was der Herbst <em>auf den Tisch</em> bringt.</h1>
        <div class="intro">
          <p>Saisonale Küche aus Rheinhessen mit italienischem Akzent. Die Karte wechselt, wenn der Markt wechselt, und jedes Gericht hat seine eigene Seite mit eigener Adresse.</p>
          <div class="intro__zeile"><a class="knopf label" href="#reservieren" data-tot>Tisch reservieren</a><a class="knopf knopf--leer label" href="#wein" data-tot>Weinkarte</a></div>
        </div>
      </section>
      <div class="abschnitt label"><span>Herbstkarte · seit 14. Oktober</span><span>3 von 12 Gerichten</span></div>
      <ul class="raster">${karten}</ul>
      <footer class="fuss label"><span>Osteria Cardo · Mainz, Altstadt</span><span>Di–Sa ab 17:30 · So 12–15 · Mo Ruhetag</span><span>A Gluten · C Ei · G Milch · H Nüsse</span></footer>
    </div>`;
  }
  function seiteProjekt(id) {
    const i = PROJEKTE.findIndex(p => p.id === id), p = PROJEKTE[i], n = PROJEKTE[(i + 1) % PROJEKTE.length];
    return `<div data-seite="${p.id}">
      <article class="projekt">
        <div class="projekt__text">
          <a class="zurueck label" href="#karte" data-ziel="karte"><span aria-hidden="true">←</span> Zur Herbstkarte</a>
          <p class="projekt__label label">${p.gang} · <span class="preis">${p.preis} €</span></p>
          <h1 class="projekt__titel" tabindex="-1" data-titel="${p.id}">${p.name}</h1>
          <p class="projekt__unter serif">${p.unter}</p>
          <dl class="fakten">${p.fakten.map(([a, b]) => `<div><dt class="label">${a}</dt><dd>${b}</dd></div>`).join('')}</dl>
          <div class="projekt__lauf">${p.text.map(t => `<p>${t}</p>`).join('')}</div>
          <div class="projekt__tun"><a class="knopf label" href="#reservieren" data-tot>Tisch reservieren</a><a class="knopf knopf--leer label" href="#anrufen" data-tot>Anrufen</a></div>
        </div>
        <figure class="held" data-bild="${p.id}">${teller(p.id, p.name + ': ' + p.art)}</figure>
      </article>
      <a class="weiter" href="#${n.id}" data-ziel="${n.id}">
        <span><span class="label leise">Nächster Gang · ${n.gang}</span><span class="weiter__titel serif">${n.name} <span>→</span></span></span>
        <span class="weiter__bild">${teller(n.id)}</span>
      </a>
    </div>`;
  }

  /* ---------- Werte-Anzeige ---------- */
  function zeigeWerte() {
    const nav = letzteNavigation ? `<span><b>Navigation</b> ${letzteNavigation.von} → ${letzteNavigation.nach}${letzteNavigation.typ ? ' · Typ ' + letzteNavigation.typ : ''}</span>` : `<span><b>Navigation</b> Gericht öffnen, dann zurück zur Karte</span>`;
    let text;
    if (reduziert()) text = `<span><b>reduziert</b> nur Root-Crossfade · ${REDUZIERT.dauer} ms · ${REDUZIERT.kurve} · keine Namen, keine Typen</span>`;
    else if (!kannVT) text = '<span><b>ohne View Transitions</b> normaler Seitenwechsel ohne Animation</span>';
    else {
      const w = WERTE[variante];
      const art = {
        benannt: 'kopf bleibt stehen · held und titel morphen · Rest Root-Crossfade',
        typen: 'kopf steht · vorwaerts: alt translateX(0 → −30 %), neu translateX(100 % → 0) · zurueck umgekehrt',
        crossfade: 'nur ::view-transition-old/new(root) · keine Namen, kein JS nötig',
        quelle: `angeklickter Link hebt sich (scale 1.04), blendet zuletzt aus · :navigation-source hier: ${kannQuelle ? 'ja' : 'nein'}, Demo: data-quelle`
      }[variante];
      text = `<span><b>${w.dauer} ms</b> · ${w.kurve}</span><span class="lang">${art}</span>`;
    }
    werteFeld.innerHTML = text + nav;
  }
  function setzeVariante(name, url) {
    variante = name;
    html.dataset.variante = name;
    document.querySelectorAll('.modi [data-variante]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.variante === name)));
    if (url) { const u = new URL(location.href); u.searchParams.set('v', name); history.replaceState(history.state, '', u); }
    zeigeWerte();
  }

  /* ---------- Navigation ---------- */
  let aktuell = 'karte', laeuft = false, merkY = 0;
  const benenne = (el, name) => { if (el) el.style.viewTransitionName = name; };
  const namenWeg = () => document.querySelectorAll('[style*="view-transition-name"]').forEach(el => el.style.viewTransitionName = '');

  function rendere(ziel, mitFokus) {
    // Entspricht dem Laden der neuen Seite: neuer Inhalt, neuer Titel, Scroll oben
    // (zurück zur Karte: Scroll-Position wiederherstellen, wie der Browser es bei MPA tut)
    const zurueckZurKarte = ziel === 'karte';
    svgNr = 0;
    seite.innerHTML = ziel === 'karte' ? seiteKarte() : seiteProjekt(ziel);
    document.title = (ziel === 'karte' ? 'Herbstkarte' : PROJEKTE.find(p => p.id === ziel).name) + ' · Osteria Cardo';
    scrollTo(0, zurueckZurKarte ? merkY : 0);
    aktuell = ziel;
    if (mitFokus) seite.querySelector('h1').focus({ preventScroll: true });
  }

  function navigiere(ziel, mitFokus, quelle) {
    if (laeuft || ziel === aktuell) return Promise.resolve();
    // Wie :navigation-source: der auslösende Link ist markiert, bis die neue Seite steht
    if (quelle) quelle.setAttribute('data-quelle', '');
    const von = aktuell;
    if (von === 'karte') merkY = scrollY;
    // Typ: Karte → Gericht und Gericht → nächster Gang sind vorwärts, zur Karte zurück
    const typ = ziel === 'karte' ? 'zurueck' : 'vorwaerts';
    letzteNavigation = { von: pfad(von), nach: pfad(ziel), typ: (!reduziert() && kannVT && variante === 'typen') ? typ : '' };
    zeigeWerte();

    if (!kannVT) { rendere(ziel, mitFokus); return Promise.resolve(); }   // data-quelle fällt mit der alten Seite weg

    const mitNamen = !reduziert() && variante !== 'crossfade';
    // Welches Bild und welcher Titel morphen: Karte ↔ Gerichtseite, zwischen zwei Gerichten nur das Bild
    const morph = (seiteId, gegenId) => seiteId === 'karte'
      ? [seite.querySelector(`[data-bild="${gegenId}"]`), seite.querySelector(`[data-titel="${gegenId}"]`)]
      : [seite.querySelector('.held'), (gegenId === 'karte') ? seite.querySelector('h1') : null];
    // „pageswap“: Namen auf der alten Seite erst kurz vor dem Schnappschuss setzen
    if (mitNamen) {
      benenne(kopf, 'kopf');
      if (variante === 'benannt') {
        const [bild, titel] = morph(von, ziel);
        benenne(bild, 'held'); benenne(titel, 'titel');
      }
    }
    const typen = (!reduziert() && variante === 'typen') ? [typ] : [];
    const update = () => {
      namenWeg();
      rendere(ziel, mitFokus);
      // „pagereveal“: dieselben Namen auf der neuen Seite vergeben
      if (mitNamen) {
        benenne(kopf, 'kopf');
        if (variante === 'benannt') {
          const [bild, titel] = morph(ziel, von);
          benenne(bild, 'held'); benenne(titel, 'titel');
        }
      }
    };
    laeuft = true;
    let vt;
    try { vt = document.startViewTransition({ update, types: typen }); }
    catch (e) { vt = document.startViewTransition(update); }
    vt.ready.catch(() => {});
    return vt.finished.catch(() => {}).then(() => { namenWeg(); laeuft = false; });
  }

  document.addEventListener('click', e => {
    const a = e.target.closest('a');
    if (!a || a.closest('.dock')) return;
    e.preventDefault();
    if (a.hasAttribute('data-tot')) return;
    if (!a.dataset.ziel) return;
    stoppeVorfuehrung();
    navigiere(a.dataset.ziel, true, a);
  });
  document.querySelectorAll('.modi [data-variante]').forEach(b => b.addEventListener('click', () => { stoppeVorfuehrung(); setzeVariante(b.dataset.variante, true); }));

  function start() {
    namenWeg(); laeuft = false; merkY = 0; letzteNavigation = null;
    rendere('karte', false);
    zeigeWerte();
  }

  /* ---------- Vorführung: gedachter Zeiger klickt sich durch ---------- */
  const REIHE = ['benannt', 'typen', 'crossfade'];
  let runde = 0, wecker = null, lief = false, schritt = 0, zx = 0, zy = 0;
  const warte = (ms, t) => new Promise(r => { wecker = setTimeout(() => r(t === runde), ms); });

  function zeigerZu(x, y, t) {
    const von = `translate(${zx}px, ${zy}px)`, nach = `translate(${x}px, ${y}px)`;
    zx = x; zy = y;
    zeiger.animate({ transform: [von, nach] }, { duration: reduziert() ? 1 : 750, easing: 'cubic-bezier(0.16, 1, 0.3, 1)', fill: 'forwards' });
    return warte(reduziert() ? 200 : 820, t);
  }
  const klickPuls = () => zeiger.animate({ scale: [1, 0.7, 1] }, { duration: 260, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' });

  async function spiel(t) {
    if (!(await warte(900, t))) return;
    while (t === runde) {
      const aufKarte = aktuell === 'karte';
      if (aufKarte) setzeVariante(new URLSearchParams(location.search).has('v') ? variante : REIHE[schritt % REIHE.length]);
      const ziel = aufKarte ? PROJEKTE[schritt % PROJEKTE.length].id : 'karte';
      const el = aufKarte ? seite.querySelector(`.karte[data-ziel="${ziel}"] .karte__bild`) : seite.querySelector('.zurueck');
      if (!el) return;
      const r = el.getBoundingClientRect();
      const x = Math.round(r.left + r.width * (aufKarte ? 0.55 : 0.4)), y = Math.round(r.top + r.height * 0.5);
      if (!(await zeigerZu(x, y, t))) return;
      klickPuls();
      if (!(await warte(140, t))) return;
      await navigiere(ziel, false, el.closest('a'));
      if (t !== runde) return;
      if (!aufKarte) schritt++;
      if (!(await warte(aufKarte ? 1500 : 900, t))) return;
    }
  }
  function stoppeVorfuehrung() { runde++; clearTimeout(wecker); lief = false; zeiger.classList.remove('an'); html.classList.remove('vorfuehrung'); }

  function neustart() {
    stoppeVorfuehrung();
    start();
    wecker = setTimeout(() => navigiere(PROJEKTE[0].id, false), 500);
  }
  function vorfuehren() {
    stoppeVorfuehrung();
    start();
    schritt = 0; lief = true;
    html.classList.add('vorfuehrung');
    zx = Math.round(innerWidth * 0.62); zy = Math.round(innerHeight * 0.3);
    zeiger.animate({ transform: [`translate(${zx}px, ${zy}px)`] }, { duration: 1, fill: 'forwards' });
    zeiger.classList.add('an');
    spiel(runde);
  }
  function pause() { const war = lief; stoppeVorfuehrung(); lief = war; }
  function weiter() { if (lief) { const t = ++runde; html.classList.add('vorfuehrung'); zeiger.classList.add('an'); spiel(t); } }

  setzeVariante(VARIANTEN.includes(ausUrl) ? ausUrl : 'benannt');
  start();
  addEventListener('load', zeigeWerte);

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